feat(CC-055): add Decision Workspace page (context/options/assumptions/decision/outcome editor)

This commit is contained in:
admin-valentin 2026-08-01 20:51:21 +00:00
parent 1539da43a7
commit e7b91bd92b

View file

@ -0,0 +1,365 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
import type { Decision } from '../../../../lib/api';
export default function DecisionWorkspacePage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [context, setContext] = useState('');
const [options, setOptions] = useState<string[]>(['', '']);
const [assumptions, setAssumptions] = useState<string[]>(['']);
const [selectedOption, setSelectedOption] = useState('');
const [outcomeReview, setOutcomeReview] = useState('');
const [reviewDueAt, setReviewDueAt] = useState('');
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data: decisions = [], isLoading } = useQuery({
queryKey: ['decisions', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=50', { tenantId }),
enabled: Boolean(tenantId),
});
useEffect(() => {
if (!selectedId) return;
const d = decisions.find((d) => d.id === selectedId);
if (!d) return;
setContext(d.context);
setOptions(d.options.length ? d.options : ['', '']);
setAssumptions(d.assumptions.length ? d.assumptions : ['']);
setSelectedOption(d.selectedOption ?? '');
setOutcomeReview(d.outcomeReview ?? '');
setReviewDueAt(d.reviewDueAt ? d.reviewDueAt.slice(0, 10) : '');
setDirty(false);
setSaveError(null);
}, [selectedId, decisions]);
async function handleNew() {
if (!tenantId) return;
try {
const created = await apiFetch<Decision>('/v1/decisions', {
method: 'POST',
body: { context: 'Situație nouă — actualizează contextul', options: [], assumptions: [] },
tenantId,
});
await qc.invalidateQueries({ queryKey: ['decisions', tenantId] });
setSelectedId(created.id);
} catch (e) {
console.error('[workspace] create failed', e);
}
}
async function handleSave() {
if (!selectedId || !tenantId) return;
setSaving(true);
setSaveError(null);
try {
await apiFetch(`/v1/decisions/${selectedId}`, {
method: 'PATCH',
body: {
context,
options: options.filter((o) => o.trim()),
assumptions: assumptions.filter((a) => a.trim()),
selectedOption: selectedOption || null,
...(outcomeReview ? { outcomeReview } : {}),
...(reviewDueAt ? { reviewDueAt: new Date(reviewDueAt).toISOString() } : { reviewDueAt: null }),
},
tenantId,
});
await qc.invalidateQueries({ queryKey: ['decisions', tenantId] });
setDirty(false);
} catch (e) {
setSaveError(e instanceof Error ? e.message : 'Eroare la salvare');
} finally {
setSaving(false);
}
}
const selected = decisions.find((d) => d.id === selectedId);
const inProgress = decisions.filter((d) => !d.selectedOption);
const decided = decisions.filter((d) => !!d.selectedOption);
const validOptions = options.filter((o) => o.trim());
return (
<div className="flex gap-0" style={{ height: 'calc(100vh - 7rem)' }}>
{/* Sidebar */}
<aside className="w-60 shrink-0 border-r border-border/50 flex flex-col overflow-hidden">
<div className="p-4 border-b border-border/50 shrink-0">
<h1 className="font-semibold text-sm text-ink">Decision Workspace</h1>
<p className="text-xs text-ink-faint mt-0.5">Analizează și documentează decizii</p>
</div>
<div className="p-3 shrink-0">
<button
onClick={handleNew}
className="w-full rounded-lg bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
+ Decizie nouă
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-4 space-y-1">
{isLoading && (
<p className="px-2 py-4 text-xs text-ink-faint text-center">Se încarcă</p>
)}
{!isLoading && decisions.length === 0 && (
<p className="px-2 py-4 text-xs text-ink-faint text-center">
Nicio decizie.<br />Apasă + Decizie nouă
</p>
)}
{inProgress.length > 0 && (
<>
<p className="px-2 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-ink-faint">
În analiză ({inProgress.length})
</p>
{inProgress.map((d) => (
<button
key={d.id}
onClick={() => setSelectedId(d.id)}
className={`w-full text-left rounded-lg px-3 py-2.5 transition-colors ${
selectedId === d.id ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50 text-ink'
}`}
>
<p className="text-xs font-medium line-clamp-2 leading-snug">{d.context}</p>
<p className="text-[10px] text-ink-faint mt-0.5">
{new Date(d.createdAt).toLocaleDateString('ro-RO', { day: 'numeric', month: 'short' })}
</p>
</button>
))}
</>
)}
{decided.length > 0 && (
<>
<p className="px-2 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-ink-faint">
Decise ({decided.length})
</p>
{decided.map((d) => (
<button
key={d.id}
onClick={() => setSelectedId(d.id)}
className={`w-full text-left rounded-lg px-3 py-2.5 transition-colors ${
selectedId === d.id ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50 text-ink-faint'
}`}
>
<p className="text-xs line-clamp-2 leading-snug">{d.context}</p>
</button>
))}
</>
)}
</div>
</aside>
{/* Main editor */}
{!selectedId ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-3">
<p className="text-4xl"></p>
<p className="text-sm text-ink-faint max-w-xs">
Selectează o decizie din stânga sau creează una nouă pentru a documenta analiza
</p>
<button
onClick={handleNew}
className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
+ Decizie nouă
</button>
</div>
</div>
) : (
<main className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full ${
selected?.selectedOption
? 'bg-signal-ok/10 text-signal-ok'
: 'bg-signal-warn/10 text-signal-warn'
}`}>
{selected?.selectedOption ? '✓ Decisă' : '○ În analiză'}
</span>
{selected?.reviewDueAt && (
<span className="text-[11px] text-ink-faint">
Review {new Date(selected.reviewDueAt).toLocaleDateString('ro-RO')}
</span>
)}
</div>
<div className="flex items-center gap-3">
{saveError && <span className="text-xs text-signal-danger">{saveError}</span>}
{dirty ? (
<button
onClick={handleSave}
disabled={saving}
className="rounded-lg bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{saving ? 'Se salvează…' : 'Salvează'}
</button>
) : (
<span className="text-xs text-ink-faint">Salvat</span>
)}
</div>
</div>
{/* Context */}
<section className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-wider text-ink-faint">
Context & Situație
</label>
<textarea
value={context}
onChange={(e) => { setContext(e.target.value); setDirty(true); }}
rows={4}
placeholder="Descrie situația completă, ce trebuie decis și de ce contează…"
className="w-full rounded-lg border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
/>
</section>
{/* Options */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold uppercase tracking-wider text-ink-faint">
Opțiuni ({validOptions.length})
</label>
<button
onClick={() => { setOptions([...options, '']); setDirty(true); }}
className="text-xs text-bronze-deep hover:underline"
>
+ Adaugă
</button>
</div>
<div className="space-y-2">
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-2">
<span className="text-xs text-ink-faint font-mono w-4 shrink-0 text-right">{i + 1}.</span>
<input
value={opt}
onChange={(e) => {
const next = [...options];
next[i] = e.target.value;
setOptions(next);
setDirty(true);
}}
placeholder={`Opțiunea ${i + 1}`}
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{options.length > 1 && (
<button
onClick={() => { setOptions(options.filter((_, j) => j !== i)); setDirty(true); }}
className="text-xs text-signal-danger/60 hover:text-signal-danger"
>
</button>
)}
</div>
))}
</div>
</section>
{/* Assumptions */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-semibold uppercase tracking-wider text-ink-faint">
Ipoteze & Riscuri
</label>
<button
onClick={() => { setAssumptions([...assumptions, '']); setDirty(true); }}
className="text-xs text-bronze-deep hover:underline"
>
+ Adaugă
</button>
</div>
<div className="space-y-2">
{assumptions.map((a, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={a}
onChange={(e) => {
const next = [...assumptions];
next[i] = e.target.value;
setAssumptions(next);
setDirty(true);
}}
placeholder={`Ipoteză sau risc ${i + 1}`}
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
{assumptions.length > 1 && (
<button
onClick={() => { setAssumptions(assumptions.filter((_, j) => j !== i)); setDirty(true); }}
className="text-xs text-signal-danger/60 hover:text-signal-danger"
>
</button>
)}
</div>
))}
</div>
</section>
{/* Decision box */}
<section className="rounded-xl border-2 border-primary/25 bg-primary/5 p-5 space-y-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-primary/80">
Concluzie & Decizie
</h3>
<div className="space-y-1">
<label className="block text-xs text-ink-faint">Opțiunea aleasă</label>
{validOptions.length > 0 ? (
<select
value={selectedOption}
onChange={(e) => { setSelectedOption(e.target.value); setDirty(true); }}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value=""> Decizie nepronunțată </option>
{validOptions.map((opt, i) => (
<option key={i} value={opt}>{opt}</option>
))}
</select>
) : (
<input
value={selectedOption}
onChange={(e) => { setSelectedOption(e.target.value); setDirty(true); }}
placeholder="Introdu decizia luată…"
className="w-full rounded-lg border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
)}
</div>
<div className="space-y-1">
<label className="block text-xs text-ink-faint">Data de review a rezultatelor</label>
<input
type="date"
value={reviewDueAt}
onChange={(e) => { setReviewDueAt(e.target.value); setDirty(true); }}
className="rounded-lg border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</section>
{/* Outcome review */}
{selectedOption && (
<section className="space-y-2">
<label className="block text-xs font-semibold uppercase tracking-wider text-ink-faint">
Review Rezultat
</label>
<p className="text-xs text-ink-faint">
Ce s-a întâmplat? Ce ipoteză s-a dovedit greșită? Ce ai învățat?
</p>
<textarea
value={outcomeReview}
onChange={(e) => { setOutcomeReview(e.target.value); setDirty(true); }}
rows={4}
placeholder="Retrospectivă după ce decizia a produs efecte…"
className="w-full rounded-lg border bg-background px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring resize-none"
/>
</section>
)}
</div>
</main>
)}
</div>
);
}