UI: alle 8 Dialoge auf barrierearme Modal-Basis umgestellt

Bisher waren alle Overlay-Dialoge plain divs ohne role/Fokus-Trap/Escape.
Jetzt setzen sie auf <Modal> auf (role=dialog, aria-modal, Fokus-Trap +
-Rueckgabe, Escape, Body-Scroll-Lock, konsistenter X-Button, Scroll bei langem
Inhalt).

- Modal: X-Schliessen-Button + max-h-[90vh]/overflow-y-auto ergaenzt
- BlockerDialog, ManualDialog (Timeline)
- leads: CSV-Import + Neuer Lead
- bereich: Neues Projekt
- projekt: Gate-Review + Projekt abschliessen
- lead: Zu Deal machen

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Raffael SheikhandClaude Opus 4.8 committed 2026-06-07 11:49:05 +02:00
1 parent 30006e3b32
commit f600c9f187
7 files changed
+489 -566

No files matched your search

@@ -3,6 +3,7 @@ import { toast } from 'sonner';
import { trpc } from '../../lib/trpc';
import { voice } from '../../lib/voice';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
export function BlockerDialog({ onClose }: { onClose: () => void }) {
const [title, setTitle] = useState('');
@@ -23,49 +24,40 @@ export function BlockerDialog({ onClose }: { onClose: () => void }) {
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Neuer Blocker</h2>
<form onSubmit={submit} className="mt-4 space-y-3">
<label className="block">
<span className="text-sm font-medium">Was klemmt?</span>
<input
required
ref={(el) => {
if (el && !title) el.focus();
}}
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="text-sm font-medium">Mehr Kontext (optional)</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
maxLength={1000}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
Erstellen
</Button>
</div>
</form>
</div>
</div>
<Modal open onClose={onClose} title="Neuer Blocker">
<form onSubmit={submit} className="mt-4 space-y-3">
<label className="block">
<span className="text-sm font-medium">Was klemmt?</span>
<input
required
ref={(el) => {
if (el && !title) el.focus();
}}
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</label>
<label className="block">
<span className="text-sm font-medium">Mehr Kontext (optional)</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
maxLength={1000}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
Erstellen
</Button>
</div>
</form>
</Modal>
);
}
@@ -1,10 +1,11 @@
import { useState } from 'react';
import { Pencil, Trash2, Plus, X } from 'lucide-react';
import { Pencil, Trash2, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { clsx } from 'clsx';
import { trpc } from '../../lib/trpc';
import { Card } from '../ui/Card';
import { Button } from '../ui/Button';
import { Modal } from '../ui/Modal';
import { formatDuration, formatDurationCompact } from '../../lib/format-duration';
import { TIME_CATEGORIES, type TimeCategory } from '../../lib/time-categories';
@@ -333,97 +334,78 @@ export function ManualDialog({
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Block nachtragen</h2>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="rounded-[var(--radius-sm)] p-1 text-[var(--color-ink-muted)] hover:bg-black/5"
>
<X size={16} />
</button>
</div>
<form onSubmit={submit} className="mt-4 space-y-3">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Start</span>
<input
type="datetime-local"
required
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Ende</span>
<input
type="datetime-local"
required
value={endedAt}
onChange={(e) => setEndedAt(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
</label>
</div>
<Modal open onClose={onClose} title="Block nachtragen">
<form onSubmit={submit} className="mt-4 space-y-3">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Bezug auf ToDo (optional)</span>
<select
value={todoId}
onChange={(e) => setTodoId(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
>
<option value="">— kein ToDo —</option>
{todos.map((t) => (
<option key={t.id} value={t.id}>
{t.title}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Kategorie</span>
<select
value={category}
onChange={(e) => setCategory(e.target.value as TimeCategory)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
>
{TIME_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Beschreibung (optional)</span>
<span className="text-xs text-[var(--color-ink-muted)]">Start</span>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={200}
type="datetime-local"
required
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
Speichern
</Button>
</div>
</form>
</div>
</div>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Ende</span>
<input
type="datetime-local"
required
value={endedAt}
onChange={(e) => setEndedAt(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
</label>
</div>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Bezug auf ToDo (optional)</span>
<select
value={todoId}
onChange={(e) => setTodoId(e.target.value)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
>
<option value="">— kein ToDo —</option>
{todos.map((t) => (
<option key={t.id} value={t.id}>
{t.title}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Kategorie</span>
<select
value={category}
onChange={(e) => setCategory(e.target.value as TimeCategory)}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
>
{TIME_CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-xs text-[var(--color-ink-muted)]">Beschreibung (optional)</span>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={200}
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
Speichern
</Button>
</div>
</form>
</Modal>
);
}
+11 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
type Size = 'sm' | 'md' | 'lg';
@@ -94,10 +95,18 @@ export function Modal({
aria-modal="true"
aria-labelledby={title != null ? titleId : undefined}
tabIndex={-1}
className={`w-full ${maxWidth[size]} rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)] focus:outline-none`}
className={`relative max-h-[90vh] w-full ${maxWidth[size]} overflow-y-auto rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)] focus:outline-none`}
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-4 top-4 rounded-[var(--radius-sm)] p-1 text-[var(--color-ink-muted)] hover:bg-black/5 hover:text-[var(--color-ink)]"
>
<X size={18} />
</button>
{title != null && (
<h2 id={titleId} className="text-xl font-semibold">
<h2 id={titleId} className="pr-8 text-xl font-semibold">
{title}
</h2>
)}
+80 -88
View File
@@ -4,6 +4,7 @@ import { trpc } from '../lib/trpc';
import { voice } from '../lib/voice';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Modal } from '../components/ui/Modal';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { toast } from 'sonner';
import { clsx } from 'clsx';
@@ -220,95 +221,86 @@ function ProjectCreateDialog({
});
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Neues Projekt</h2>
<form onSubmit={submit} className="mt-4 space-y-3">
<Field label="Name">
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<Field label="Beschreibung (optional)">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={2000}
rows={3}
placeholder="Worum geht's? Kurz beschreiben."
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<Field label="Logo (optional)">
<LogoUpload value={logoUrl} name={name} onChange={setLogoUrl} />
</Field>
<Field label="Bereich">
<select
required
value={areaId}
onChange={(e) => setAreaId(e.target.value)}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
>
<option value="" disabled>
{areas.length ? '— Bereich wählen —' : 'Bereiche werden geladen…'}
<Modal open onClose={onClose} title="Neues Projekt">
<form onSubmit={submit} className="mt-4 space-y-3">
<Field label="Name">
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<Field label="Beschreibung (optional)">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={2000}
rows={3}
placeholder="Worum geht's? Kurz beschreiben."
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<Field label="Logo (optional)">
<LogoUpload value={logoUrl} name={name} onChange={setLogoUrl} />
</Field>
<Field label="Bereich">
<select
required
value={areaId}
onChange={(e) => setAreaId(e.target.value)}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
>
<option value="" disabled>
{areas.length ? '— Bereich wählen —' : 'Bereiche werden geladen…'}
</option>
{areas.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
{areas.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
</Field>
<Field label="Projektart">
<select
value={kind}
onChange={(e) => setKind(e.target.value as 'kundenprojekt' | 'internes_projekt')}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
>
<option value="kundenprojekt">Kundenprojekt</option>
<option value="internes_projekt">Internes Projekt</option>
</select>
</Field>
<Field label="Outline-Steckbrief-URL">
<input
required
type="url"
value={steckbrief}
onChange={(e) => setSteckbrief(e.target.value)}
placeholder="https://outline.tool-ctrl.de/..."
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<Field label="Plane-Board-URL">
<input
required
type="url"
value={plane}
onChange={(e) => setPlane(e.target.value)}
placeholder="https://plane.tool-ctrl.de/..."
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={create.isPending}>
Anlegen
</Button>
</div>
</form>
</div>
</div>
))}
</select>
</Field>
<Field label="Projektart">
<select
value={kind}
onChange={(e) => setKind(e.target.value as 'kundenprojekt' | 'internes_projekt')}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
>
<option value="kundenprojekt">Kundenprojekt</option>
<option value="internes_projekt">Internes Projekt</option>
</select>
</Field>
<Field label="Outline-Steckbrief-URL">
<input
required
type="url"
value={steckbrief}
onChange={(e) => setSteckbrief(e.target.value)}
placeholder="https://outline.tool-ctrl.de/..."
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<Field label="Plane-Board-URL">
<input
required
type="url"
value={plane}
onChange={(e) => setPlane(e.target.value)}
placeholder="https://plane.tool-ctrl.de/..."
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={create.isPending}>
Anlegen
</Button>
</div>
</form>
</Modal>
);
}
+42 -50
View File
@@ -11,6 +11,7 @@ import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { ErrorState } from '../components/ui/ErrorState';
import { Modal } from '../components/ui/Modal';
import { useConfirm } from '../components/ui/ConfirmProvider';
import {
LEAD_STATUS_META,
@@ -357,57 +358,48 @@ function PromoteDialog({ leadId, onClose }: { leadId: string; onClose: () => voi
},
});
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="w-full max-w-sm rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
<Modal open onClose={onClose} title="Zu Deal machen" size="sm">
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Legt einen Pipeline-Deal in Stufe „Lead“ an und verknüpft ihn.
</p>
<form
className="mt-4 space-y-3"
onSubmit={(e) => {
e.preventDefault();
promote.mutate({ leadId, valueEur: Math.round(euro * 100), probability });
}}
>
<h2 className="text-xl font-semibold">Zu Deal machen</h2>
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Legt einen Pipeline-Deal in Stufe „Lead“ an und verknüpft ihn.
</p>
<form
className="mt-4 space-y-3"
onSubmit={(e) => {
e.preventDefault();
promote.mutate({ leadId, valueEur: Math.round(euro * 100), probability });
}}
>
<label className="block">
<span className="text-sm font-medium">Wert (EUR)</span>
<input
type="number"
min={0}
value={euro}
onChange={(e) => setEuro(Number(e.target.value))}
className={clsx(inputCls, 'mt-1')}
/>
</label>
<label className="block">
<span className="text-sm font-medium">Wahrscheinlichkeit: {probability}%</span>
<input
type="range"
min={0}
max={100}
value={probability}
onChange={(e) => setProbability(Number(e.target.value))}
className="mt-1 w-full"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={promote.isPending}>
Anlegen
</Button>
</div>
</form>
</div>
</div>
<label className="block">
<span className="text-sm font-medium">Wert (EUR)</span>
<input
type="number"
min={0}
value={euro}
onChange={(e) => setEuro(Number(e.target.value))}
className={clsx(inputCls, 'mt-1')}
/>
</label>
<label className="block">
<span className="text-sm font-medium">Wahrscheinlichkeit: {probability}%</span>
<input
type="range"
min={0}
max={100}
value={probability}
onChange={(e) => setProbability(Number(e.target.value))}
className="mt-1 w-full"
/>
</label>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={promote.isPending}>
Anlegen
</Button>
</div>
</form>
</Modal>
);
}
+155 -178
View File
@@ -6,6 +6,7 @@ import { trpc } from '../lib/trpc';
import { voice } from '../lib/voice';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Modal } from '../components/ui/Modal';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { LEAD_STATUS_META, LEAD_STATUS_ORDER, type LeadStatus } from '../lib/lead-meta';
@@ -107,90 +108,79 @@ function LeadImportDialog({ onClose }: { onClose: () => void }) {
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Leads aus CSV importieren</h2>
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Spalten: Quelle, Vorname, Nachname, Position, Firma, Straße, PLZ, Ort, Telefon, Mobil,
Fax, E-Mail, Website, Branche, Notizen. Je Zeile entsteht ein Lead mit primärem Kontakt;
Notizen werden als erster Austausch-Log-Eintrag gespeichert. Duplikate (gleicher
Firmenname) werden übersprungen.
</p>
<Modal open onClose={onClose} title="Leads aus CSV importieren">
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Spalten: Quelle, Vorname, Nachname, Position, Firma, Straße, PLZ, Ort, Telefon, Mobil, Fax,
E-Mail, Website, Branche, Notizen. Je Zeile entsteht ein Lead mit primärem Kontakt; Notizen
werden als erster Austausch-Log-Eintrag gespeichert. Duplikate (gleicher Firmenname) werden
übersprungen.
</p>
<div className="mt-4 space-y-3">
<label className="block">
<span className="text-sm font-medium">Projekt zuordnen (optional)</span>
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
disabled={!!result}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
>
<option value="">— kein Projekt —</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-sm font-medium">CSV-Datei</span>
<input
type="file"
accept=".csv,text/csv"
onChange={onFile}
className="mt-1 block w-full text-sm file:mr-3 file:rounded-[var(--radius-md)] file:border file:border-[var(--color-border)] file:bg-white file:px-3 file:py-1.5 file:text-sm"
/>
</label>
{fileName && !result && (
<p className="text-xs text-[var(--color-ink-muted)]">
Bereit: {fileName} ({Math.max(0, csv.split('\n').filter((l) => l.trim()).length - 1)}{' '}
Datenzeilen)
<div className="mt-4 space-y-3">
<label className="block">
<span className="text-sm font-medium">Projekt zuordnen (optional)</span>
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
disabled={!!result}
className="mt-1 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
>
<option value="">— kein Projekt —</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="block">
<span className="text-sm font-medium">CSV-Datei</span>
<input
type="file"
accept=".csv,text/csv"
onChange={onFile}
className="mt-1 block w-full text-sm file:mr-3 file:rounded-[var(--radius-md)] file:border file:border-[var(--color-border)] file:bg-white file:px-3 file:py-1.5 file:text-sm"
/>
</label>
{fileName && !result && (
<p className="text-xs text-[var(--color-ink-muted)]">
Bereit: {fileName} ({Math.max(0, csv.split('\n').filter((l) => l.trim()).length - 1)}{' '}
Datenzeilen)
</p>
)}
{result && (
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-3 text-sm">
<p className="font-medium text-[var(--color-ampel-green-fg)]">
{result.created} Leads importiert
</p>
)}
{result && (
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-3 text-sm">
<p className="font-medium text-[var(--color-ampel-green-fg)]">
{result.created} Leads importiert
</p>
<ul className="mt-1 space-y-0.5 text-xs text-[var(--color-ink-muted)]">
<li>{result.contacts} Kontakte angelegt</li>
<li>{result.touchpoints} Notizen als Log-Eintrag</li>
{result.skippedDuplicate > 0 && (
<li>{result.skippedDuplicate} Duplikate übersprungen</li>
)}
{result.skippedEmpty > 0 && (
<li>{result.skippedEmpty} leere Zeilen übersprungen</li>
)}
</ul>
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
{result ? 'Schließen' : 'Abbrechen'}
</Button>
{!result && (
<Button
type="button"
onClick={() => importMut.mutate({ csv, projectId: projectId || undefined })}
disabled={!csv || importMut.isPending}
>
{importMut.isPending ? 'Importiere…' : 'Importieren'}
</Button>
)}
<ul className="mt-1 space-y-0.5 text-xs text-[var(--color-ink-muted)]">
<li>{result.contacts} Kontakte angelegt</li>
<li>{result.touchpoints} Notizen als Log-Eintrag</li>
{result.skippedDuplicate > 0 && (
<li>{result.skippedDuplicate} Duplikate übersprungen</li>
)}
{result.skippedEmpty > 0 && <li>{result.skippedEmpty} leere Zeilen übersprungen</li>}
</ul>
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
{result ? 'Schließen' : 'Abbrechen'}
</Button>
{!result && (
<Button
type="button"
onClick={() => importMut.mutate({ csv, projectId: projectId || undefined })}
disabled={!csv || importMut.isPending}
>
{importMut.isPending ? 'Importiere…' : 'Importieren'}
</Button>
)}
</div>
</div>
</div>
</Modal>
);
}
@@ -282,121 +272,108 @@ function LeadCreateDialog({ onClose }: { onClose: () => void }) {
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Neuer Lead</h2>
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Nur die Firma ist Pflicht — alles Weitere füllst du im Dossier.
</p>
<form onSubmit={submit} className="mt-4 space-y-3">
<Field label="Firma">
<Modal open onClose={onClose} title="Neuer Lead">
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Nur die Firma ist Pflicht — alles Weitere füllst du im Dossier.
</p>
<form onSubmit={submit} className="mt-4 space-y-3">
<Field label="Firma">
<input
required
value={firma}
onChange={(e) => setFirma(e.target.value)}
className={inputCls}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Branche">
<input
required
value={firma}
onChange={(e) => setFirma(e.target.value)}
value={branche}
onChange={(e) => setBranche(e.target.value)}
className={inputCls}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Branche">
<input
value={branche}
onChange={(e) => setBranche(e.target.value)}
className={inputCls}
/>
</Field>
<Field label="Nische / Sub-Segment">
<input
value={subSegment}
onChange={(e) => setSubSegment(e.target.value)}
className={inputCls}
/>
</Field>
</div>
<Field label="Website">
<Field label="Nische / Sub-Segment">
<input
value={website}
onChange={(e) => setWebsite(e.target.value)}
value={subSegment}
onChange={(e) => setSubSegment(e.target.value)}
className={inputCls}
/>
</Field>
<Field label="Quelle (wie gefunden)">
<input
value={quelle}
onChange={(e) => setQuelle(e.target.value)}
className={inputCls}
/>
</Field>
<Field label="Projekt (optional)">
</div>
<Field label="Website">
<input
value={website}
onChange={(e) => setWebsite(e.target.value)}
className={inputCls}
/>
</Field>
<Field label="Quelle (wie gefunden)">
<input value={quelle} onChange={(e) => setQuelle(e.target.value)} className={inputCls} />
</Field>
<Field label="Projekt (optional)">
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
className={inputCls}
>
<option value="">— kein Projekt —</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Owner">
<select
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
value={ownerUserId}
onChange={(e) => setOwnerUserId(e.target.value)}
className={inputCls}
>
<option value="">— kein Projekt —</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
<option value="">— niemand —</option>
{(users.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Owner">
<select
value={ownerUserId}
onChange={(e) => setOwnerUserId(e.target.value)}
className={inputCls}
>
<option value="">— niemand —</option>
{(users.data ?? []).map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
<Field label="Status">
<select
value={status}
onChange={(e) => setStatus(e.target.value as LeadStatus)}
className={inputCls}
>
{LEAD_STATUS_ORDER.map((s) => (
<option key={s} value={s}>
{LEAD_STATUS_META[s].label}
</option>
))}
</select>
</Field>
</div>
<Field label={`Score: ${score}/10`}>
<input
type="range"
min={0}
max={10}
value={score}
onChange={(e) => setScore(Number(e.target.value))}
className="w-full"
/>
<Field label="Status">
<select
value={status}
onChange={(e) => setStatus(e.target.value as LeadStatus)}
className={inputCls}
>
{LEAD_STATUS_ORDER.map((s) => (
<option key={s} value={s}>
{LEAD_STATUS_META[s].label}
</option>
))}
</select>
</Field>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={create.isPending}>
Anlegen
</Button>
</div>
</form>
</div>
</div>
</div>
<Field label={`Score: ${score}/10`}>
<input
type="range"
min={0}
max={10}
value={score}
onChange={(e) => setScore(Number(e.target.value))}
className="w-full"
/>
</Field>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={create.isPending}>
Anlegen
</Button>
</div>
</form>
</Modal>
);
}
+95 -116
View File
@@ -8,6 +8,7 @@ import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { ErrorState } from '../components/ui/ErrorState';
import { Modal } from '../components/ui/Modal';
import { useConfirm } from '../components/ui/ConfirmProvider';
import { useCurrentUser } from '../hooks/useCurrentUser';
import { voice } from '../lib/voice';
@@ -704,65 +705,52 @@ function GateDialog({
},
});
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
<Modal open onClose={onClose} title={`Gate-Review: ${PHASE_META[phase].label}`}>
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Ziel: {PHASE_META[phase].gateZiel}
</p>
<form
className="mt-4 space-y-3"
onSubmit={(e) => {
e.preventDefault();
add.mutate({
projectId,
phase,
ergebnisErreicht: erreicht,
notiz: notiz.trim() || undefined,
advance,
});
}}
>
<h2 className="text-xl font-semibold">Gate-Review: {PHASE_META[phase].label}</h2>
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Ziel: {PHASE_META[phase].gateZiel}
</p>
<form
className="mt-4 space-y-3"
onSubmit={(e) => {
e.preventDefault();
add.mutate({
projectId,
phase,
ergebnisErreicht: erreicht,
notiz: notiz.trim() || undefined,
advance,
});
}}
>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={erreicht}
onChange={(e) => setErreicht(e.target.checked)}
/>
Ergebnis erreicht
</label>
<textarea
value={notiz}
onChange={(e) => setNotiz(e.target.value)}
rows={3}
placeholder="Rückblick / Maßnahmen (bei Gelb/Rot)"
className={inputCls}
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={erreicht}
onChange={(e) => setErreicht(e.target.checked)}
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={advance}
onChange={(e) => setAdvance(e.target.checked)}
/>
Folgephase jetzt freigeben
</label>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={add.isPending}>
Speichern
</Button>
</div>
</form>
</div>
</div>
Ergebnis erreicht
</label>
<textarea
value={notiz}
onChange={(e) => setNotiz(e.target.value)}
rows={3}
placeholder="Rückblick / Maßnahmen (bei Gelb/Rot)"
className={inputCls}
/>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={advance} onChange={(e) => setAdvance(e.target.checked)} />
Folgephase jetzt freigeben
</label>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={add.isPending}>
Speichern
</Button>
</div>
</form>
</Modal>
);
}
@@ -2320,65 +2308,56 @@ function AbschlussDialog({
{ key: 'retro', label: 'Retrospektive / Lessons Learned durchgeführt' },
];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
onClick={onClose}
>
<div
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-xl font-semibold">Projekt abschließen</h2>
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Ein Projekt endet bewusst (Leitfaden Phase 4).
</p>
<form onSubmit={submit} className="mt-4 space-y-3">
<div className="space-y-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] p-3">
{checkItems.map((it) => (
<label key={it.key} className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={checks[it.key]}
onChange={(e) => setChecks((c) => ({ ...c, [it.key]: e.target.checked }))}
className="mt-0.5"
/>
{it.label}
</label>
))}
</div>
<label className="block">
<span className="text-sm font-medium">Abschlussvermerk</span>
<textarea
value={vermerk}
onChange={(e) => setVermerk(e.target.value)}
rows={3}
maxLength={2000}
placeholder="Ziel erreicht? Aufwand gegen Plan? Wichtigste Erkenntnisse — eine halbe Seite, kein Roman."
className={clsx(inputCls, 'mt-1')}
/>
</label>
<label className="block">
<span className="text-sm font-medium">Lessons-Learned-URL (Outline)</span>
<input
required
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://outline.tool-ctrl.de/lessons-learned/..."
className={clsx(inputCls, 'mt-1')}
/>
</label>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
Abschließen
</Button>
</div>
</form>
</div>
</div>
<Modal open onClose={onClose} title="Projekt abschließen">
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Ein Projekt endet bewusst (Leitfaden Phase 4).
</p>
<form onSubmit={submit} className="mt-4 space-y-3">
<div className="space-y-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] p-3">
{checkItems.map((it) => (
<label key={it.key} className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={checks[it.key]}
onChange={(e) => setChecks((c) => ({ ...c, [it.key]: e.target.checked }))}
className="mt-0.5"
/>
{it.label}
</label>
))}
</div>
<label className="block">
<span className="text-sm font-medium">Abschlussvermerk</span>
<textarea
value={vermerk}
onChange={(e) => setVermerk(e.target.value)}
rows={3}
maxLength={2000}
placeholder="Ziel erreicht? Aufwand gegen Plan? Wichtigste Erkenntnisse — eine halbe Seite, kein Roman."
className={clsx(inputCls, 'mt-1')}
/>
</label>
<label className="block">
<span className="text-sm font-medium">Lessons-Learned-URL (Outline)</span>
<input
required
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://outline.tool-ctrl.de/lessons-learned/..."
className={clsx(inputCls, 'mt-1')}
/>
</label>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Abbrechen
</Button>
<Button type="submit" disabled={mutation.isPending}>
Abschließen
</Button>
</div>
</form>
</Modal>
);
}