365 lines
15 KiB
TypeScript
365 lines
15 KiB
TypeScript
'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>
|
||
);
|
||
}
|