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