chore(format): Prettier auf heute/projekt/lead.tsx anwenden
Diese drei Dateien waren nicht prettier-konform (nie formatiert). Isolierter Format-Lauf, damit der folgende Logik-Commit nur die echten Aenderungen zeigt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
00b611fa3b
commit
33a740ca7e
3 files changed
+580
-129
No files matched your search
@@ -219,9 +219,7 @@ function TodoRow({
|
||||
<span className={dotClass} />
|
||||
<span
|
||||
className={
|
||||
todo.status === 'dropped'
|
||||
? 'flex-1 line-through text-[var(--color-ink-muted)]'
|
||||
: 'flex-1'
|
||||
todo.status === 'dropped' ? 'flex-1 line-through text-[var(--color-ink-muted)]' : 'flex-1'
|
||||
}
|
||||
>
|
||||
{todo.title}
|
||||
@@ -358,7 +356,10 @@ function CheckInOutBar({
|
||||
const duration =
|
||||
checkedInAt && checkedOutAt
|
||||
? formatDurationCompact(
|
||||
Math.max(0, Math.round((new Date(checkedOutAt).getTime() - new Date(checkedInAt).getTime()) / 1000)),
|
||||
Math.max(
|
||||
0,
|
||||
Math.round((new Date(checkedOutAt).getTime() - new Date(checkedInAt).getTime()) / 1000),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -542,7 +543,12 @@ function EinstempeltView({
|
||||
todos,
|
||||
entry,
|
||||
}: {
|
||||
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped'; unplanned?: boolean }>;
|
||||
todos: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'open' | 'done' | 'dropped';
|
||||
unplanned?: boolean;
|
||||
}>;
|
||||
entry: {
|
||||
status: 'begun' | 'full' | 'absent';
|
||||
checkedInAt: string | Date | null;
|
||||
|
||||
@@ -297,9 +297,7 @@ function Header({ lead, onBack }: { lead: LeadDossier; onBack: () => void }) {
|
||||
<span className="text-[var(--color-ink-muted)]">Owner</span>
|
||||
<select
|
||||
value={lead.ownerUserId ?? ''}
|
||||
onChange={(e) =>
|
||||
update.mutate({ id: lead.id, ownerUserId: e.target.value || null })
|
||||
}
|
||||
onChange={(e) => update.mutate({ id: lead.id, ownerUserId: e.target.value || null })}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="">— niemand —</option>
|
||||
@@ -354,7 +352,10 @@ 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="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()}
|
||||
@@ -522,13 +523,7 @@ function EditableSection({
|
||||
);
|
||||
}
|
||||
|
||||
function VertrauenslevelControl({
|
||||
leadId,
|
||||
value,
|
||||
}: {
|
||||
leadId: string;
|
||||
value: number | null;
|
||||
}) {
|
||||
function VertrauenslevelControl({ leadId, value }: { leadId: string; value: number | null }) {
|
||||
const utils = trpc.useUtils();
|
||||
const update = trpc.lead.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
@@ -541,7 +536,10 @@ function VertrauenslevelControl({
|
||||
<select
|
||||
value={value ?? ''}
|
||||
onChange={(e) =>
|
||||
update.mutate({ id: leadId, vertrauenslevel: e.target.value ? Number(e.target.value) : null })
|
||||
update.mutate({
|
||||
id: leadId,
|
||||
vertrauenslevel: e.target.value ? Number(e.target.value) : null,
|
||||
})
|
||||
}
|
||||
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
|
||||
>
|
||||
@@ -687,8 +685,18 @@ function AddContactForm({ leadId, onDone }: { leadId: string; onDone: () => void
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input placeholder="Name *" value={name} onChange={(e) => setName(e.target.value)} className={inputCls} />
|
||||
<input placeholder="Rolle / Titel" value={rolle} onChange={(e) => setRolle(e.target.value)} className={inputCls} />
|
||||
<input
|
||||
placeholder="Name *"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
placeholder="Rolle / Titel"
|
||||
value={rolle}
|
||||
onChange={(e) => setRolle(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<select value={funktion} onChange={(e) => setFunktion(e.target.value)} className={inputCls}>
|
||||
<option value="">Funktion im Kaufprozess</option>
|
||||
{Object.entries(CONTACT_FUNKTION_LABEL).map(([v, l]) => (
|
||||
@@ -697,14 +705,43 @@ function AddContactForm({ leadId, onDone }: { leadId: string; onDone: () => void
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input placeholder="E-Mail" value={email} onChange={(e) => setEmail(e.target.value)} className={inputCls} />
|
||||
<input placeholder="Telefon" value={telefon} onChange={(e) => setTelefon(e.target.value)} className={inputCls} />
|
||||
<input placeholder="LinkedIn-URL" value={linkedin} onChange={(e) => setLinkedin(e.target.value)} className={inputCls} />
|
||||
<input placeholder="Sprache / Anrede" value={sprache} onChange={(e) => setSprache(e.target.value)} className={inputCls} />
|
||||
<input placeholder="Im Buying-Center seit" value={seit} onChange={(e) => setSeit(e.target.value)} className={inputCls} />
|
||||
<input
|
||||
placeholder="E-Mail"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
placeholder="Telefon"
|
||||
value={telefon}
|
||||
onChange={(e) => setTelefon(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
placeholder="LinkedIn-URL"
|
||||
value={linkedin}
|
||||
onChange={(e) => setLinkedin(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
placeholder="Sprache / Anrede"
|
||||
value={sprache}
|
||||
onChange={(e) => setSprache(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
placeholder="Im Buying-Center seit"
|
||||
value={seit}
|
||||
onChange={(e) => setSeit(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isPrimary} onChange={(e) => setIsPrimary(e.target.checked)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPrimary}
|
||||
onChange={(e) => setIsPrimary(e.target.checked)}
|
||||
/>
|
||||
Hauptkontakt
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -832,7 +869,12 @@ function AddTouchpointForm({ leadId, onDone }: { leadId: string; onDone: () => v
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input type="date" value={datum} onChange={(e) => setDatum(e.target.value)} className={inputCls} />
|
||||
<input
|
||||
type="date"
|
||||
value={datum}
|
||||
onChange={(e) => setDatum(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<select value={kanal} onChange={(e) => setKanal(e.target.value)} className={inputCls}>
|
||||
{Object.entries(TOUCHPOINT_KANAL_LABEL).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
@@ -849,10 +891,20 @@ function AddTouchpointForm({ leadId, onDone }: { leadId: string; onDone: () => v
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input placeholder="Outcome" value={outcome} onChange={(e) => setOutcome(e.target.value)} className={inputCls} />
|
||||
<input
|
||||
placeholder="Outcome"
|
||||
value={outcome}
|
||||
onChange={(e) => setOutcome(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<label className="flex flex-col text-xs text-[var(--color-ink-muted)]">
|
||||
Follow-up bis
|
||||
<input type="date" value={followUp} onChange={(e) => setFollowUp(e.target.value)} className={inputCls} />
|
||||
<input
|
||||
type="date"
|
||||
value={followUp}
|
||||
onChange={(e) => setFollowUp(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -904,7 +956,9 @@ function OffeneFragenSection({
|
||||
persist(fragen.map((x, j) => (j === i ? { ...x, done: !x.done } : x)))
|
||||
}
|
||||
/>
|
||||
<span className={clsx('flex-1', f.done && 'text-[var(--color-ink-muted)] line-through')}>
|
||||
<span
|
||||
className={clsx('flex-1', f.done && 'text-[var(--color-ink-muted)] line-through')}
|
||||
>
|
||||
{f.text}
|
||||
</span>
|
||||
<button
|
||||
@@ -996,8 +1050,18 @@ function AnhaengeSection({
|
||||
setUrl('');
|
||||
}}
|
||||
>
|
||||
<input placeholder="Bezeichnung" value={label} onChange={(e) => setLabel(e.target.value)} className={inputCls} />
|
||||
<input placeholder="URL" value={url} onChange={(e) => setUrl(e.target.value)} className={inputCls} />
|
||||
<input
|
||||
placeholder="Bezeichnung"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
placeholder="URL"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="secondary">
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
@@ -1010,7 +1074,12 @@ function AnhaengeSection({
|
||||
function ExternalLink({ url, label }: { url: string; label?: string }) {
|
||||
const href = url.startsWith('http') ? url : `https://${url}`;
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noreferrer" className="truncate text-[var(--color-ink)] hover:underline">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="truncate text-[var(--color-ink)] hover:underline"
|
||||
>
|
||||
{label ?? url}
|
||||
</a>
|
||||
);
|
||||
|
||||
+473
-97
@@ -367,7 +367,9 @@ function ZielSection({ projectId, project: p }: { projectId: string; project: Pr
|
||||
const [desc, setDesc] = useState(p.description ?? '');
|
||||
const [nichtZiele, setNichtZiele] = useState(p.nichtZiele ?? '');
|
||||
const [liefer, setLiefer] = useState(p.liefergegenstaende ?? '');
|
||||
const [flex, setFlex] = useState<ZieldreieckFlex | ''>((p.zieldreieckFlex as ZieldreieckFlex) ?? '');
|
||||
const [flex, setFlex] = useState<ZieldreieckFlex | ''>(
|
||||
(p.zieldreieckFlex as ZieldreieckFlex) ?? '',
|
||||
);
|
||||
const update = trpc.project.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast(voice.toast.saved);
|
||||
@@ -424,11 +426,24 @@ function ZielSection({ projectId, project: p }: { projectId: string; project: Pr
|
||||
<div className="space-y-3">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Maxime (roter Faden, ein Satz)</span>
|
||||
<input value={maxime} onChange={(e) => setMaxime(e.target.value)} maxLength={280} placeholder="z. B. „So schnell wie möglich, mit bestehenden Ressourcen.“" className={clsx(inputCls, 'mt-1')} />
|
||||
<input
|
||||
value={maxime}
|
||||
onChange={(e) => setMaxime(e.target.value)}
|
||||
maxLength={280}
|
||||
placeholder="z. B. „So schnell wie möglich, mit bestehenden Ressourcen.“"
|
||||
className={clsx(inputCls, 'mt-1')}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Ziel (SMART)</span>
|
||||
<textarea value={ziel} onChange={(e) => setZiel(e.target.value)} rows={2} maxLength={1000} placeholder="z. B. „Ladezeit der Startseite bis 31.07. unter 1 Sekunde, abgenommen vom Kunden.“" className={clsx(inputCls, 'mt-1')} />
|
||||
<textarea
|
||||
value={ziel}
|
||||
onChange={(e) => setZiel(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
placeholder="z. B. „Ladezeit der Startseite bis 31.07. unter 1 Sekunde, abgenommen vom Kunden.“"
|
||||
className={clsx(inputCls, 'mt-1')}
|
||||
/>
|
||||
</label>
|
||||
{zielHint && (
|
||||
<p className="flex items-start gap-1.5 text-xs text-[var(--color-ampel-amber-fg)]">
|
||||
@@ -438,19 +453,45 @@ function ZielSection({ projectId, project: p }: { projectId: string; project: Pr
|
||||
)}
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Nicht-Ziele (Scope-Schutz)</span>
|
||||
<textarea value={nichtZiele} onChange={(e) => setNichtZiele(e.target.value)} rows={2} maxLength={2000} placeholder="Was gehört ausdrücklich NICHT dazu?" className={clsx(inputCls, 'mt-1')} />
|
||||
<textarea
|
||||
value={nichtZiele}
|
||||
onChange={(e) => setNichtZiele(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
placeholder="Was gehört ausdrücklich NICHT dazu?"
|
||||
className={clsx(inputCls, 'mt-1')}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Liefergegenstände</span>
|
||||
<textarea value={liefer} onChange={(e) => setLiefer(e.target.value)} rows={2} maxLength={2000} placeholder="Was liegt am Ende konkret vor?" className={clsx(inputCls, 'mt-1')} />
|
||||
<textarea
|
||||
value={liefer}
|
||||
onChange={(e) => setLiefer(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
placeholder="Was liegt am Ende konkret vor?"
|
||||
className={clsx(inputCls, 'mt-1')}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Hintergrund / Beschreibung</span>
|
||||
<textarea value={desc} onChange={(e) => setDesc(e.target.value)} rows={3} maxLength={2000} className={clsx(inputCls, 'mt-1')} />
|
||||
<textarea
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
className={clsx(inputCls, 'mt-1')}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium">Zieldreieck: Wenn es eng wird, gibt zuerst nach…</span>
|
||||
<select value={flex} onChange={(e) => setFlex(e.target.value as ZieldreieckFlex | '')} className={clsx(inputCls, 'mt-1')}>
|
||||
<span className="text-sm font-medium">
|
||||
Zieldreieck: Wenn es eng wird, gibt zuerst nach…
|
||||
</span>
|
||||
<select
|
||||
value={flex}
|
||||
onChange={(e) => setFlex(e.target.value as ZieldreieckFlex | '')}
|
||||
className={clsx(inputCls, 'mt-1')}
|
||||
>
|
||||
<option value="">— nicht festgelegt —</option>
|
||||
{ZIELDREIECK_FLEX.map((z) => (
|
||||
<option key={z} value={z}>
|
||||
@@ -483,7 +524,15 @@ function ZielSection({ projectId, project: p }: { projectId: string; project: Pr
|
||||
);
|
||||
}
|
||||
|
||||
function ZielRow({ label, value, italic }: { label: string; value: string | null; italic?: boolean }) {
|
||||
function ZielRow({
|
||||
label,
|
||||
value,
|
||||
italic,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
italic?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-[var(--color-ink-muted)]">{label}</p>
|
||||
@@ -521,7 +570,9 @@ function Zieldreieck({ flex }: { flex: ZieldreieckFlex | null }) {
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{!flex && <span className="text-xs text-[var(--color-ink-faint)]">Priorität nicht festgelegt</span>}
|
||||
{!flex && (
|
||||
<span className="text-xs text-[var(--color-ink-faint)]">Priorität nicht festgelegt</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -582,7 +633,9 @@ function PhaseSection({ projectId, phase }: { projectId: string; phase: ProjectP
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">Gate-Ziel: {PHASE_META[phase].gateZiel}</p>
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||||
Gate-Ziel: {PHASE_META[phase].gateZiel}
|
||||
</p>
|
||||
{gates.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]">
|
||||
@@ -594,7 +647,9 @@ function PhaseSection({ projectId, phase }: { projectId: string; phase: ProjectP
|
||||
<span
|
||||
className={clsx(
|
||||
'mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[10px] text-white',
|
||||
g.ergebnisErreicht ? 'bg-[var(--color-ampel-green-fg)]' : 'bg-[var(--color-ampel-red-fg)]',
|
||||
g.ergebnisErreicht
|
||||
? 'bg-[var(--color-ampel-green-fg)]'
|
||||
: 'bg-[var(--color-ampel-red-fg)]',
|
||||
)}
|
||||
>
|
||||
{g.ergebnisErreicht ? '✓' : '✗'}
|
||||
@@ -604,7 +659,9 @@ function PhaseSection({ projectId, phase }: { projectId: string; phase: ProjectP
|
||||
<span className="text-xs text-[var(--color-ink-faint)]">
|
||||
{formatDay(g.reviewedAt)}
|
||||
</span>
|
||||
{g.notiz && <span className="block text-xs text-[var(--color-ink-muted)]">{g.notiz}</span>}
|
||||
{g.notiz && (
|
||||
<span className="block text-xs text-[var(--color-ink-muted)]">{g.notiz}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
@@ -612,7 +669,9 @@ function PhaseSection({ projectId, phase }: { projectId: string; phase: ProjectP
|
||||
</details>
|
||||
)}
|
||||
</Card.Body>
|
||||
{gateOpen && <GateDialog projectId={projectId} phase={phase} onClose={() => setGateOpen(false)} />}
|
||||
{gateOpen && (
|
||||
<GateDialog projectId={projectId} phase={phase} onClose={() => setGateOpen(false)} />
|
||||
)}
|
||||
</Card.Root>
|
||||
);
|
||||
}
|
||||
@@ -640,24 +699,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()}>
|
||||
<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">Gate-Review: {PHASE_META[phase].label}</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">Ziel: {PHASE_META[phase].gateZiel}</p>
|
||||
<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 });
|
||||
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)} />
|
||||
<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} />
|
||||
<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)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={advance}
|
||||
onChange={(e) => setAdvance(e.target.checked)}
|
||||
/>
|
||||
Folgephase jetzt freigeben
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -742,7 +829,11 @@ function StatusSection({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="text-xs text-[var(--color-ink-muted)]">
|
||||
Ampel
|
||||
<select value={neuAmpel} onChange={(e) => setNeuAmpel(e.target.value as 'green' | 'amber' | 'red')} className={inputCls}>
|
||||
<select
|
||||
value={neuAmpel}
|
||||
onChange={(e) => setNeuAmpel(e.target.value as 'green' | 'amber' | 'red')}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="green">Im Plan</option>
|
||||
<option value="amber">Gefährdet</option>
|
||||
<option value="red">Blockiert</option>
|
||||
@@ -750,7 +841,13 @@ function StatusSection({
|
||||
</label>
|
||||
<label className="text-xs text-[var(--color-ink-muted)]">
|
||||
Trend
|
||||
<select value={neuTrend} onChange={(e) => setNeuTrend(e.target.value as 'besser' | 'gleich' | 'schlechter' | '')} className={inputCls}>
|
||||
<select
|
||||
value={neuTrend}
|
||||
onChange={(e) =>
|
||||
setNeuTrend(e.target.value as 'besser' | 'gleich' | 'schlechter' | '')
|
||||
}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="besser">Besser</option>
|
||||
<option value="gleich">Gleich</option>
|
||||
@@ -758,9 +855,20 @@ function StatusSection({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={2} maxLength={1000} placeholder="2-3 Sätze Erläuterung" className={inputCls} />
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={1000}
|
||||
placeholder="2-3 Sätze Erläuterung"
|
||||
className={inputCls}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={extern} onChange={(e) => setExtern(e.target.checked)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={extern}
|
||||
onChange={(e) => setExtern(e.target.checked)}
|
||||
/>
|
||||
Nach außen kommunizierbar (Kundenprojekt)
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -777,12 +885,21 @@ function StatusSection({
|
||||
{latest ? (
|
||||
<div className="text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={clsx('rounded-full px-2 py-0.5 text-xs font-medium', ampelCls[latest.ampel])}>
|
||||
<span
|
||||
className={clsx(
|
||||
'rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
ampelCls[latest.ampel],
|
||||
)}
|
||||
>
|
||||
{ampelLabel[latest.ampel]}
|
||||
</span>
|
||||
{latest.trend && <span className="text-xs text-[var(--color-ink-muted)]">Trend: {latest.trend}</span>}
|
||||
{latest.trend && (
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">Trend: {latest.trend}</span>
|
||||
)}
|
||||
{latest.externSichtbar && (
|
||||
<span className="rounded-full bg-black/5 px-2 py-0.5 text-[10px] text-[var(--color-ink-muted)]">extern</span>
|
||||
<span className="rounded-full bg-black/5 px-2 py-0.5 text-[10px] text-[var(--color-ink-muted)]">
|
||||
extern
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-[var(--color-ink-faint)]">
|
||||
{formatDay(latest.createdAt)}
|
||||
@@ -803,7 +920,9 @@ function StatusSection({
|
||||
) : histQ.isPending ? (
|
||||
<p className="text-sm text-[var(--color-ink-faint)]">Status wird geladen…</p>
|
||||
) : histQ.isError ? (
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">Status konnte nicht geladen werden.</p>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">
|
||||
Status konnte nicht geladen werden.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">Noch kein Status gesetzt.</p>
|
||||
)}
|
||||
@@ -816,11 +935,16 @@ function StatusSection({
|
||||
<ul className="mt-2 space-y-2 text-sm">
|
||||
{history.slice(1).map((s) => (
|
||||
<li key={s.id} className="flex items-start gap-2">
|
||||
<span className={clsx('mt-0.5 inline-block h-2.5 w-2.5 shrink-0 rounded-full', {
|
||||
green: 'bg-[var(--color-ampel-green-fg)]',
|
||||
amber: 'bg-[var(--color-ampel-amber-fg)]',
|
||||
red: 'bg-[var(--color-ampel-red-fg)]',
|
||||
}[s.ampel])} />
|
||||
<span
|
||||
className={clsx(
|
||||
'mt-0.5 inline-block h-2.5 w-2.5 shrink-0 rounded-full',
|
||||
{
|
||||
green: 'bg-[var(--color-ampel-green-fg)]',
|
||||
amber: 'bg-[var(--color-ampel-amber-fg)]',
|
||||
red: 'bg-[var(--color-ampel-red-fg)]',
|
||||
}[s.ampel],
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
<span className="text-xs text-[var(--color-ink-faint)]">
|
||||
{formatDay(s.createdAt)}
|
||||
@@ -874,11 +998,25 @@ function ChangeLogSection({ projectId }: { projectId: string }) {
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!titel.trim()) return;
|
||||
add.mutate({ projectId, titel: titel.trim(), auswirkung: auswirkung.trim() || undefined });
|
||||
add.mutate({
|
||||
projectId,
|
||||
titel: titel.trim(),
|
||||
auswirkung: auswirkung.trim() || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input value={titel} onChange={(e) => setTitel(e.target.value)} placeholder="Was ändert sich?" className={inputCls} />
|
||||
<input value={auswirkung} onChange={(e) => setAuswirkung(e.target.value)} placeholder="Auswirkung aufs Zieldreieck (Leistung/Zeit/Aufwand)" className={inputCls} />
|
||||
<input
|
||||
value={titel}
|
||||
onChange={(e) => setTitel(e.target.value)}
|
||||
placeholder="Was ändert sich?"
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
value={auswirkung}
|
||||
onChange={(e) => setAuswirkung(e.target.value)}
|
||||
placeholder="Auswirkung aufs Zieldreieck (Leistung/Zeit/Aufwand)"
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setAdding(false)}>
|
||||
Abbrechen
|
||||
@@ -891,7 +1029,8 @@ function ChangeLogSection({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
{list.length === 0 && !adding ? (
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">
|
||||
Keine Änderungen. Leitfaden: keine stille Erweiterung des Umfangs — jede Änderung bewusst entscheiden.
|
||||
Keine Änderungen. Leitfaden: keine stille Erweiterung des Umfangs — jede Änderung
|
||||
bewusst entscheiden.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -916,7 +1055,11 @@ function ChangeRow({ projectId, change: c }: { projectId: string; change: Change
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{c.titel}</p>
|
||||
{c.auswirkung && <p className="mt-0.5 text-xs text-[var(--color-ink-muted)]">Auswirkung: {c.auswirkung}</p>}
|
||||
{c.auswirkung && (
|
||||
<p className="mt-0.5 text-xs text-[var(--color-ink-muted)]">
|
||||
Auswirkung: {c.auswirkung}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-[var(--color-ink-faint)]">
|
||||
{formatDay(c.createdAt)}
|
||||
{c.authorName ? ` · ${c.authorName}` : ''}
|
||||
@@ -931,7 +1074,9 @@ function ChangeRow({ projectId, change: c }: { projectId: string; change: Change
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<select
|
||||
value={c.entscheidung}
|
||||
onChange={(e) => update.mutate({ id: c.id, entscheidung: e.target.value as ChangeEntscheidung })}
|
||||
onChange={(e) =>
|
||||
update.mutate({ id: c.id, entscheidung: e.target.value as ChangeEntscheidung })
|
||||
}
|
||||
className={clsx('rounded-full border-0 px-2 py-0.5 text-xs font-medium', meta.badge)}
|
||||
>
|
||||
{CHANGE_ENTSCHEIDUNGEN.map((en) => (
|
||||
@@ -940,7 +1085,12 @@ function ChangeRow({ projectId, change: c }: { projectId: string; change: Change
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={() => remove.mutate({ id: c.id })} aria-label="Entfernen" className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove.mutate({ id: c.id })}
|
||||
aria-label="Entfernen"
|
||||
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -997,7 +1147,10 @@ function StakeholderMatrix({ list }: { list: Stakeholder[] }) {
|
||||
<div className="grid grid-cols-[auto_repeat(3,1fr)] gap-1 text-xs">
|
||||
<div />
|
||||
{cols.map((c) => (
|
||||
<div key={c} className="px-1 pb-1 text-center font-medium text-[var(--color-ink-muted)]">
|
||||
<div
|
||||
key={c}
|
||||
className="px-1 pb-1 text-center font-medium text-[var(--color-ink-muted)]"
|
||||
>
|
||||
Interesse {LEVEL_META[c].label.toLowerCase()}
|
||||
</div>
|
||||
))}
|
||||
@@ -1036,7 +1189,12 @@ function FragmentRow({ r, cols, list }: { r: Level; cols: Level[]; list: Stakeho
|
||||
key={s.id}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-black/5 px-1.5 py-0.5 text-[11px]"
|
||||
>
|
||||
<span className={clsx('h-1.5 w-1.5 rounded-full', HALTUNG_META[s.haltung as Haltung].dot)} />
|
||||
<span
|
||||
className={clsx(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
HALTUNG_META[s.haltung as Haltung].dot,
|
||||
)}
|
||||
/>
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
@@ -1094,13 +1252,27 @@ function StakeholderRow({
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name *" className={inputCls} />
|
||||
<input value={rolle} onChange={(e) => setRolle(e.target.value)} placeholder="Rolle" className={inputCls} />
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name *"
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
value={rolle}
|
||||
onChange={(e) => setRolle(e.target.value)}
|
||||
placeholder="Rolle"
|
||||
className={inputCls}
|
||||
/>
|
||||
<LevelSelect label="Interesse" value={interesse} onChange={setInteresse} />
|
||||
<LevelSelect label="Einfluss" value={einfluss} onChange={setEinfluss} />
|
||||
<label className="text-xs text-[var(--color-ink-muted)]">
|
||||
Haltung
|
||||
<select value={haltung} onChange={(e) => setHaltung(e.target.value as Haltung)} className={inputCls}>
|
||||
<select
|
||||
value={haltung}
|
||||
onChange={(e) => setHaltung(e.target.value as Haltung)}
|
||||
className={inputCls}
|
||||
>
|
||||
{HALTUNGEN.map((h) => (
|
||||
<option key={h} value={h}>
|
||||
{HALTUNG_META[h].label}
|
||||
@@ -1109,8 +1281,18 @@ function StakeholderRow({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<input value={beduerfnis} onChange={(e) => setBeduerfnis(e.target.value)} placeholder="Bedürfnis / was braucht die Person?" className={inputCls} />
|
||||
<input value={notiz} onChange={(e) => setNotiz(e.target.value)} placeholder="Notiz" className={inputCls} />
|
||||
<input
|
||||
value={beduerfnis}
|
||||
onChange={(e) => setBeduerfnis(e.target.value)}
|
||||
placeholder="Bedürfnis / was braucht die Person?"
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
value={notiz}
|
||||
onChange={(e) => setNotiz(e.target.value)}
|
||||
placeholder="Notiz"
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
|
||||
Abbrechen
|
||||
@@ -1129,24 +1311,40 @@ function StakeholderRow({
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<span className={clsx('h-2 w-2 rounded-full', HALTUNG_META[s.haltung as Haltung].dot)} />
|
||||
{s.name}
|
||||
{s.rolle && <span className="font-normal text-[var(--color-ink-muted)]">· {s.rolle}</span>}
|
||||
{s.rolle && (
|
||||
<span className="font-normal text-[var(--color-ink-muted)]">· {s.rolle}</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1 text-xs">
|
||||
<span className={clsx('rounded-full px-2 py-0.5', LEVEL_META[s.interesse as Level].badge)}>
|
||||
<span
|
||||
className={clsx('rounded-full px-2 py-0.5', LEVEL_META[s.interesse as Level].badge)}
|
||||
>
|
||||
Interesse {LEVEL_META[s.interesse as Level].label}
|
||||
</span>
|
||||
<span className={clsx('rounded-full px-2 py-0.5', LEVEL_META[s.einfluss as Level].badge)}>
|
||||
Einfluss {LEVEL_META[s.einfluss as Level].label}
|
||||
</span>
|
||||
</div>
|
||||
{s.beduerfnis && <p className="mt-1 text-xs text-[var(--color-ink-muted)]">Braucht: {s.beduerfnis}</p>}
|
||||
{s.beduerfnis && (
|
||||
<p className="mt-1 text-xs text-[var(--color-ink-muted)]">Braucht: {s.beduerfnis}</p>
|
||||
)}
|
||||
{s.notiz && <p className="mt-0.5 text-xs text-[var(--color-ink-faint)]">{s.notiz}</p>}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<button type="button" onClick={() => setEditing(true)} aria-label="Bearbeiten" className="text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label="Bearbeiten"
|
||||
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => remove.mutate({ id: s.id })} aria-label="Entfernen" className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove.mutate({ id: s.id })}
|
||||
aria-label="Entfernen"
|
||||
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -1186,13 +1384,27 @@ function AddStakeholderForm({ projectId, onDone }: { projectId: string; onDone:
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name *" className={inputCls} />
|
||||
<input value={rolle} onChange={(e) => setRolle(e.target.value)} placeholder="Rolle" className={inputCls} />
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name *"
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
value={rolle}
|
||||
onChange={(e) => setRolle(e.target.value)}
|
||||
placeholder="Rolle"
|
||||
className={inputCls}
|
||||
/>
|
||||
<LevelSelect label="Interesse" value={interesse} onChange={setInteresse} />
|
||||
<LevelSelect label="Einfluss" value={einfluss} onChange={setEinfluss} />
|
||||
<label className="text-xs text-[var(--color-ink-muted)]">
|
||||
Haltung
|
||||
<select value={haltung} onChange={(e) => setHaltung(e.target.value as Haltung)} className={inputCls}>
|
||||
<select
|
||||
value={haltung}
|
||||
onChange={(e) => setHaltung(e.target.value as Haltung)}
|
||||
className={inputCls}
|
||||
>
|
||||
{HALTUNGEN.map((h) => (
|
||||
<option key={h} value={h}>
|
||||
{HALTUNG_META[h].label}
|
||||
@@ -1201,7 +1413,12 @@ function AddStakeholderForm({ projectId, onDone }: { projectId: string; onDone:
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<input value={beduerfnis} onChange={(e) => setBeduerfnis(e.target.value)} placeholder="Bedürfnis / was braucht die Person?" className={inputCls} />
|
||||
<input
|
||||
value={beduerfnis}
|
||||
onChange={(e) => setBeduerfnis(e.target.value)}
|
||||
placeholder="Bedürfnis / was braucht die Person?"
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onDone}>
|
||||
Abbrechen
|
||||
@@ -1226,7 +1443,11 @@ function LevelSelect({
|
||||
return (
|
||||
<label className="text-xs text-[var(--color-ink-muted)]">
|
||||
{label}
|
||||
<select value={value} onChange={(e) => onChange(e.target.value as Level)} className={inputCls}>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as Level)}
|
||||
className={inputCls}
|
||||
>
|
||||
{LEVELS.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{LEVEL_META[l].label}
|
||||
@@ -1256,7 +1477,13 @@ function LeadsSection({ projectId }: { projectId: string }) {
|
||||
<Card.Root>
|
||||
<Card.Header className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
Leads{list.length > 0 && <span className="text-sm font-normal text-[var(--color-ink-muted)]"> ({list.length})</span>}
|
||||
Leads
|
||||
{list.length > 0 && (
|
||||
<span className="text-sm font-normal text-[var(--color-ink-muted)]">
|
||||
{' '}
|
||||
({list.length})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
@@ -1290,13 +1517,23 @@ function LeadsSection({ projectId }: { projectId: string }) {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className={clsx('h-2 w-2 rounded-full', LEAD_STATUS_META[l.status as LeadStatus].dot)}
|
||||
className={clsx(
|
||||
'h-2 w-2 rounded-full',
|
||||
LEAD_STATUS_META[l.status as LeadStatus].dot,
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{l.firma}</span>
|
||||
{l.branche && <span className="text-xs text-[var(--color-ink-muted)]">{l.branche}</span>}
|
||||
{l.branche && (
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">{l.branche}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-xs text-[var(--color-ink-muted)]">
|
||||
<span className={clsx('rounded-full px-2 py-0.5', LEAD_STATUS_META[l.status as LeadStatus].badge)}>
|
||||
<span
|
||||
className={clsx(
|
||||
'rounded-full px-2 py-0.5',
|
||||
LEAD_STATUS_META[l.status as LeadStatus].badge,
|
||||
)}
|
||||
>
|
||||
{LEAD_STATUS_META[l.status as LeadStatus].label}
|
||||
</span>
|
||||
<span className="font-mono tabular-nums">{l.score}/10</span>
|
||||
@@ -1378,7 +1615,13 @@ function RisksSection({ projectId }: { projectId: string }) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{risiken.map((r) => (
|
||||
<RiskRow key={r.id} projectId={projectId} risk={r} users={users} userMap={userMap} />
|
||||
<RiskRow
|
||||
key={r.id}
|
||||
projectId={projectId}
|
||||
risk={r}
|
||||
users={users}
|
||||
userMap={userMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1390,7 +1633,13 @@ function RisksSection({ projectId }: { projectId: string }) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{chancen.map((r) => (
|
||||
<RiskRow key={r.id} projectId={projectId} risk={r} users={users} userMap={userMap} />
|
||||
<RiskRow
|
||||
key={r.id}
|
||||
projectId={projectId}
|
||||
risk={r}
|
||||
users={users}
|
||||
userMap={userMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1457,13 +1706,23 @@ function RiskRow({
|
||||
});
|
||||
}}
|
||||
>
|
||||
<textarea value={title} onChange={(e) => setTitle(e.target.value)} rows={2} placeholder="Ganzer Satz" className={inputCls} />
|
||||
<textarea
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Ganzer Satz"
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<LevelSelect label="Wahrscheinlichkeit" value={wahr} onChange={setWahr} />
|
||||
<LevelSelect label="Auswirkung" value={ausw} onChange={setAusw} />
|
||||
<label className="text-xs text-[var(--color-ink-muted)]">
|
||||
Herkunft
|
||||
<select value={herkunft} onChange={(e) => setHerkunft(e.target.value)} className={inputCls}>
|
||||
<select
|
||||
value={herkunft}
|
||||
onChange={(e) => setHerkunft(e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="intern">Intern</option>
|
||||
<option value="extern">Extern</option>
|
||||
@@ -1471,7 +1730,11 @@ function RiskRow({
|
||||
</label>
|
||||
<label className="text-xs text-[var(--color-ink-muted)]">
|
||||
Status
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value as RiskStatus)} className={inputCls}>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as RiskStatus)}
|
||||
className={inputCls}
|
||||
>
|
||||
{RISK_STATUSES.map((st) => (
|
||||
<option key={st} value={st}>
|
||||
{RISK_STATUS_LABEL[st]}
|
||||
@@ -1484,7 +1747,11 @@ function RiskRow({
|
||||
{!isChance && (
|
||||
<label className="text-xs text-[var(--color-ink-muted)]">
|
||||
Strategie
|
||||
<select value={strategie} onChange={(e) => setStrategie(e.target.value)} className={inputCls}>
|
||||
<select
|
||||
value={strategie}
|
||||
onChange={(e) => setStrategie(e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{RISK_STRATEGIEN.map((st) => (
|
||||
<option key={st} value={st}>
|
||||
@@ -1506,10 +1773,19 @@ function RiskRow({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<input value={massnahme} onChange={(e) => setMassnahme(e.target.value)} placeholder={isChance ? 'Wie nutzen wir die Chance?' : 'Gegenmaßnahme'} className={inputCls} />
|
||||
<input
|
||||
value={massnahme}
|
||||
onChange={(e) => setMassnahme(e.target.value)}
|
||||
placeholder={isChance ? 'Wie nutzen wir die Chance?' : 'Gegenmaßnahme'}
|
||||
className={inputCls}
|
||||
/>
|
||||
{!isChance && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={showstopper} onChange={(e) => setShowstopper(e.target.checked)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showstopper}
|
||||
onChange={(e) => setShowstopper(e.target.checked)}
|
||||
/>
|
||||
Showstopper (bringt das Projekt zum Stillstand)
|
||||
</label>
|
||||
)}
|
||||
@@ -1529,9 +1805,7 @@ function RiskRow({
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-[var(--radius-md)] border p-3 text-sm',
|
||||
r.showstopper
|
||||
? 'border-[var(--color-ampel-red-fg)]'
|
||||
: 'border-[var(--color-border)]',
|
||||
r.showstopper ? 'border-[var(--color-ampel-red-fg)]' : 'border-[var(--color-border)]',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -1544,16 +1818,31 @@ function RiskRow({
|
||||
{r.title}
|
||||
</p>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<button type="button" onClick={() => setEditing(true)} aria-label="Bearbeiten" className="text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label="Bearbeiten"
|
||||
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => remove.mutate({ id: r.id })} aria-label="Entfernen" className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove.mutate({ id: r.id })}
|
||||
aria-label="Entfernen"
|
||||
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1 text-xs">
|
||||
<span className={clsx('rounded-full px-2 py-0.5', LEVEL_META[r.wahrscheinlichkeit as Level].badge)}>
|
||||
<span
|
||||
className={clsx(
|
||||
'rounded-full px-2 py-0.5',
|
||||
LEVEL_META[r.wahrscheinlichkeit as Level].badge,
|
||||
)}
|
||||
>
|
||||
W: {LEVEL_META[r.wahrscheinlichkeit as Level].label}
|
||||
</span>
|
||||
<span className={clsx('rounded-full px-2 py-0.5', LEVEL_META[r.auswirkung as Level].badge)}>
|
||||
@@ -1628,7 +1917,8 @@ function AddRiskForm({
|
||||
auswirkung: ausw,
|
||||
showstopper: kind === 'risiko' ? showstopper : false,
|
||||
massnahme: massnahme.trim() || undefined,
|
||||
strategie: kind === 'risiko' && strategie !== '' ? (strategie as RiskStrategie) : undefined,
|
||||
strategie:
|
||||
kind === 'risiko' && strategie !== '' ? (strategie as RiskStrategie) : undefined,
|
||||
ownerUserId: owner === '' ? undefined : owner,
|
||||
});
|
||||
}}
|
||||
@@ -1638,7 +1928,11 @@ function AddRiskForm({
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
rows={2}
|
||||
placeholder={kind === 'risiko' ? 'z. B. „Interne Ressourcen werden abgezogen.“' : 'z. B. „Kunde will weitere Standorte anbinden.“'}
|
||||
placeholder={
|
||||
kind === 'risiko'
|
||||
? 'z. B. „Interne Ressourcen werden abgezogen.“'
|
||||
: 'z. B. „Kunde will weitere Standorte anbinden.“'
|
||||
}
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -1649,7 +1943,11 @@ function AddRiskForm({
|
||||
{kind === 'risiko' && (
|
||||
<label className="text-xs text-[var(--color-ink-muted)]">
|
||||
Strategie
|
||||
<select value={strategie} onChange={(e) => setStrategie(e.target.value)} className={inputCls}>
|
||||
<select
|
||||
value={strategie}
|
||||
onChange={(e) => setStrategie(e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{RISK_STRATEGIEN.map((st) => (
|
||||
<option key={st} value={st}>
|
||||
@@ -1671,10 +1969,19 @@ function AddRiskForm({
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<input value={massnahme} onChange={(e) => setMassnahme(e.target.value)} placeholder={kind === 'risiko' ? 'Gegenmaßnahme (optional)' : 'Wie nutzen? (optional)'} className={inputCls} />
|
||||
<input
|
||||
value={massnahme}
|
||||
onChange={(e) => setMassnahme(e.target.value)}
|
||||
placeholder={kind === 'risiko' ? 'Gegenmaßnahme (optional)' : 'Wie nutzen? (optional)'}
|
||||
className={inputCls}
|
||||
/>
|
||||
{kind === 'risiko' && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={showstopper} onChange={(e) => setShowstopper(e.target.checked)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showstopper}
|
||||
onChange={(e) => setShowstopper(e.target.checked)}
|
||||
/>
|
||||
Showstopper
|
||||
</label>
|
||||
)}
|
||||
@@ -1780,16 +2087,41 @@ function MilestoneRow({ projectId, milestone: m }: { projectId: string; mileston
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-2">
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Titel *" className={inputCls} />
|
||||
<input type="date" value={due} onChange={(e) => setDue(e.target.value)} className={inputCls} />
|
||||
<select value={status} onChange={(e) => setStatus(e.target.value as Milestone['status'])} className={inputCls}>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Titel *"
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={due}
|
||||
onChange={(e) => setDue(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as Milestone['status'])}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="open">Offen</option>
|
||||
<option value="done">Erledigt</option>
|
||||
<option value="overdue">Überfällig</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea value={ergebnis} onChange={(e) => setErgebnis(e.target.value)} rows={2} placeholder="Ergebnis-Übersicht („Patchnotes“)" className={inputCls} />
|
||||
<input value={kundennutzen} onChange={(e) => setKundennutzen(e.target.value)} placeholder="Konkreter Kundennutzen" className={inputCls} />
|
||||
<textarea
|
||||
value={ergebnis}
|
||||
onChange={(e) => setErgebnis(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Ergebnis-Übersicht („Patchnotes“)"
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
value={kundennutzen}
|
||||
onChange={(e) => setKundennutzen(e.target.value)}
|
||||
placeholder="Konkreter Kundennutzen"
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
|
||||
Abbrechen
|
||||
@@ -1806,11 +2138,17 @@ function MilestoneRow({ projectId, milestone: m }: { projectId: string; mileston
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-3 text-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className={clsx('font-medium', m.status === 'done' && 'text-[var(--color-ink-muted)] line-through')}>
|
||||
<p
|
||||
className={clsx(
|
||||
'font-medium',
|
||||
m.status === 'done' && 'text-[var(--color-ink-muted)] line-through',
|
||||
)}
|
||||
>
|
||||
{m.title}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||||
{m.due ?? 'kein Datum'} · {m.status === 'done' ? 'erledigt' : m.status === 'overdue' ? 'überfällig' : 'offen'}
|
||||
{m.due ?? 'kein Datum'} ·{' '}
|
||||
{m.status === 'done' ? 'erledigt' : m.status === 'overdue' ? 'überfällig' : 'offen'}
|
||||
</p>
|
||||
{warn.length > 0 && (
|
||||
<p className="mt-1 flex items-start gap-1.5 text-xs text-[var(--color-ampel-amber-fg)]">
|
||||
@@ -1820,14 +2158,26 @@ function MilestoneRow({ projectId, milestone: m }: { projectId: string; mileston
|
||||
)}
|
||||
{m.ergebnis && <p className="mt-1 text-xs">Ergebnis: {m.ergebnis}</p>}
|
||||
{m.kundennutzen && (
|
||||
<p className="mt-0.5 text-xs text-[var(--color-ink-muted)]">Kundennutzen: {m.kundennutzen}</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--color-ink-muted)]">
|
||||
Kundennutzen: {m.kundennutzen}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<button type="button" onClick={() => setEditing(true)} aria-label="Bearbeiten" className="text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
aria-label="Bearbeiten"
|
||||
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button type="button" onClick={() => remove.mutate({ milestoneId: m.id })} aria-label="Entfernen" className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove.mutate({ milestoneId: m.id })}
|
||||
aria-label="Entfernen"
|
||||
className="text-[var(--color-ink-faint)] hover:text-[var(--color-ampel-red-fg)]"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -1857,8 +2207,18 @@ function AddMilestoneForm({ projectId, onDone }: { projectId: string; onDone: ()
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Titel *" className={inputCls} />
|
||||
<input type="date" value={due} onChange={(e) => setDue(e.target.value)} className={inputCls} />
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Titel *"
|
||||
className={inputCls}
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={due}
|
||||
onChange={(e) => setDue(e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
{warn.length > 0 && (
|
||||
<p className="flex items-start gap-1.5 text-xs text-[var(--color-ampel-amber-fg)]">
|
||||
@@ -1903,7 +2263,11 @@ function AbschlussDialog({
|
||||
});
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
mutation.mutate({ projectId, lessonsLearnedUrl: url, abschlussvermerk: vermerk.trim() || undefined });
|
||||
mutation.mutate({
|
||||
projectId,
|
||||
lessonsLearnedUrl: url,
|
||||
abschlussvermerk: vermerk.trim() || undefined,
|
||||
});
|
||||
}
|
||||
const checkItems: Array<{ key: keyof typeof checks; label: string }> = [
|
||||
{ key: 'abnahme', label: 'Abnahme — Liefergegenstände ohne offene Beanstandung abgenommen' },
|
||||
@@ -1974,13 +2338,23 @@ function AbschlussDialog({
|
||||
}
|
||||
|
||||
const LINK_FIELDS = [
|
||||
{ key: 'outlineSteckbriefUrl', label: 'Outline-Steckbrief', placeholder: 'https://wiki.tool-ctrl.de/doc/…' },
|
||||
{
|
||||
key: 'outlineSteckbriefUrl',
|
||||
label: 'Outline-Steckbrief',
|
||||
placeholder: 'https://wiki.tool-ctrl.de/doc/…',
|
||||
},
|
||||
{ key: 'planeProjectUrl', label: 'Plane-Board', placeholder: 'https://project.tool-ctrl.de/…' },
|
||||
{ key: 'risikolisteUrl', label: 'Risikoliste', placeholder: 'https://… (z. B. Outline / Plane)' },
|
||||
{ key: 'lessonsLearnedUrl', label: 'Lessons Learned', placeholder: 'https://… (Retro / Doku)' },
|
||||
] as const;
|
||||
|
||||
function VerlinkungenSection({ projectId, project: p }: { projectId: string; project: ProjectDetail }) {
|
||||
function VerlinkungenSection({
|
||||
projectId,
|
||||
project: p,
|
||||
}: {
|
||||
projectId: string;
|
||||
project: ProjectDetail;
|
||||
}) {
|
||||
const utils = trpc.useUtils();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [vals, setVals] = useState<Record<string, string>>({});
|
||||
@@ -2032,7 +2406,9 @@ function VerlinkungenSection({ projectId, project: p }: { projectId: string; pro
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<p className="text-xs text-[var(--color-ink-faint)]">Leer lassen entfernt den Link. URLs müssen mit http(s):// beginnen.</p>
|
||||
<p className="text-xs text-[var(--color-ink-faint)]">
|
||||
Leer lassen entfernt den Link. URLs müssen mit http(s):// beginnen.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
|
||||
Abbrechen
|
||||
|
||||
Reference in new issue
Block a user