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