feat(CC-055): add Decision Workspace page (context/options/assumptions/decision/outcome editor)
This commit is contained in:
parent
1539da43a7
commit
e7b91bd92b
1 changed files with 365 additions and 0 deletions
365
src/app/dashboard/decisions/workspace/page.tsx
Normal file
365
src/app/dashboard/decisions/workspace/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue