feat(cc-053): Decision Dashboard page
This commit is contained in:
parent
4c84f090ca
commit
9dacd0e535
1 changed files with 149 additions and 147 deletions
|
|
@ -1,175 +1,177 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { apiFetch, type Decision } from '../../../lib/api';
|
import { apiFetch } from '../../../lib/api';
|
||||||
|
import type { Decision } from '../../../lib/api';
|
||||||
import { useSession } from '../../../components/session-provider';
|
import { useSession } from '../../../components/session-provider';
|
||||||
|
|
||||||
export default function DecisionsDashboardPage() {
|
function fmtDate(s: string | null) {
|
||||||
|
if (!s) return '—';
|
||||||
|
return new Date(s).toLocaleDateString('ro-RO', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DecisionsPage() {
|
||||||
const { activeTenant } = useSession();
|
const { activeTenant } = useSession();
|
||||||
const tenantId = activeTenant?.tenantId ?? '';
|
const tenantId = activeTenant?.tenantId ?? '';
|
||||||
|
const B = '/dashboard';
|
||||||
|
|
||||||
const { data: decisions = [], isLoading } = useQuery({
|
const { data: decisions = [], isLoading, refetch } = useQuery({
|
||||||
queryKey: ['decisions-dashboard', tenantId],
|
queryKey: ['decisions', tenantId],
|
||||||
queryFn: () => apiFetch<Decision[]>('/v1/decisions', { tenantId }),
|
queryFn: () => apiFetch<Decision[]>('/v1/decisions', { tenantId }),
|
||||||
enabled: Boolean(tenantId),
|
enabled: Boolean(tenantId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date();
|
const [showForm, setShowForm] = useState(false);
|
||||||
const pending = decisions.filter((d) => !d.selectedOption);
|
const [form, setForm] = useState({ context: '', options: '', reviewDueAt: '' });
|
||||||
const decided = decisions.filter((d) => d.selectedOption);
|
const [saving, setSaving] = useState(false);
|
||||||
const overdueReviews = decisions.filter(
|
|
||||||
(d) => d.selectedOption && d.reviewDueAt && new Date(d.reviewDueAt) < now,
|
|
||||||
);
|
|
||||||
|
|
||||||
const stats = [
|
const open = decisions.filter((d) => !d.selectedOption);
|
||||||
{ label: 'Total', value: decisions.length, color: 'text-ink' },
|
const decided = decisions.filter((d) => d.selectedOption && !d.outcomeReview);
|
||||||
{
|
const reviewed = decisions.filter((d) => d.outcomeReview);
|
||||||
label: 'În așteptare',
|
|
||||||
value: pending.length,
|
async function handleCreate(e: React.FormEvent) {
|
||||||
color: pending.length > 0 ? 'text-signal-warn' : 'text-ink',
|
e.preventDefault();
|
||||||
},
|
if (!form.context) return;
|
||||||
{
|
setSaving(true);
|
||||||
label: 'Decise',
|
const options = form.options ? form.options.split('\n').map((o) => o.trim()).filter(Boolean) : [];
|
||||||
value: decided.length,
|
await apiFetch('/v1/decisions', {
|
||||||
color: decided.length > 0 ? 'text-signal-ok' : 'text-ink',
|
tenantId, method: 'POST',
|
||||||
},
|
body: { context: form.context, options, reviewDueAt: form.reviewDueAt || undefined },
|
||||||
{
|
});
|
||||||
label: 'Review întârziat',
|
setForm({ context: '', options: '', reviewDueAt: '' });
|
||||||
value: overdueReviews.length,
|
setShowForm(false);
|
||||||
color: overdueReviews.length > 0 ? 'text-signal-danger' : 'text-ink',
|
setSaving(false);
|
||||||
},
|
refetch();
|
||||||
];
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl space-y-6">
|
<div className="max-w-5xl space-y-6">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-2xl font-semibold text-ink">Decizii</h1>
|
<h1 className="font-display text-2xl font-semibold text-ink">Decision Dashboard</h1>
|
||||||
<p className="mt-1 text-sm text-ink-faint">
|
<p className="mt-1 text-sm text-ink-faint">
|
||||||
Vizualizare agregată a registrului de decizii.
|
Log de decizii cu context, opțiuni și review de rezultat.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<div className="flex gap-2">
|
||||||
href="/dashboard/decisions/register"
|
<Link href={`${B}/decisions/register`} className="rounded-lg border border-paper-border px-4 py-2 text-sm text-ink-faint hover:border-ink-faint">
|
||||||
className="rounded-lg bg-bronze-deep px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
Registru complet
|
||||||
>
|
|
||||||
Deschide registrul →
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
<button
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
className="rounded-lg bg-bronze-deep px-4 py-2 text-sm font-medium text-white hover:bg-bronze-deep/90"
|
||||||
{stats.map(({ label, value, color }) => (
|
|
||||||
<div key={label} className="card p-5">
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wider text-ink-faint">{label}</p>
|
|
||||||
<p className={`mt-1 font-display text-3xl font-semibold ${color}`}>{value}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<p className="text-sm text-ink-faint">Se încarcă…</p>
|
|
||||||
) : decisions.length === 0 ? (
|
|
||||||
<div className="card p-10 text-center">
|
|
||||||
<p className="text-sm font-medium text-ink">Nicio decizie înregistrată</p>
|
|
||||||
<p className="mt-1 text-xs text-ink-faint">
|
|
||||||
Documentează deciziile importante pentru a le putea revizui ulterior.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/dashboard/decisions/register"
|
|
||||||
className="mt-4 inline-block rounded-lg bg-bronze-deep px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
|
||||||
>
|
>
|
||||||
Adaugă prima decizie
|
+ Decizie nouă
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{overdueReviews.length > 0 && (
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-signal-danger">
|
|
||||||
Review întârziat ({overdueReviews.length})
|
|
||||||
</h2>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{overdueReviews.map((d) => (
|
|
||||||
<li
|
|
||||||
key={d.id}
|
|
||||||
className="card flex items-start gap-4 border-l-2 border-signal-danger px-4 py-3"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-medium text-ink">{d.context}</p>
|
|
||||||
<p className="mt-0.5 text-[11px] text-signal-danger">
|
|
||||||
Review due: {new Date(d.reviewDueAt!).toLocaleDateString('ro-RO')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 rounded-full bg-bronze-wash px-2 py-0.5 text-[10px] font-medium text-bronze-deep">
|
|
||||||
{d.selectedOption}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pending.length > 0 && (
|
{showForm && (
|
||||||
<section>
|
<form onSubmit={handleCreate} className="card p-5 space-y-4">
|
||||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-signal-warn">
|
<h2 className="text-sm font-semibold text-ink">Decizie nouă</h2>
|
||||||
În așteptare ({pending.length})
|
<div>
|
||||||
</h2>
|
<label className="block text-xs font-medium text-ink-faint mb-1">Context / Problemă *</label>
|
||||||
<ul className="space-y-2">
|
<textarea
|
||||||
{pending.slice(0, 6).map((d) => (
|
rows={3}
|
||||||
<li key={d.id} className="card flex items-center gap-4 px-4 py-3">
|
className="w-full rounded-lg border border-paper-border bg-paper px-3 py-2 text-sm text-ink placeholder:text-ink-line focus:border-bronze-deep focus:outline-none"
|
||||||
<span className="h-2 w-2 shrink-0 rounded-full bg-signal-warn" />
|
placeholder="Descrie contextul și ce decizie trebuie luată..."
|
||||||
<p className="flex-1 truncate text-sm text-ink">{d.context}</p>
|
value={form.context}
|
||||||
<span className="shrink-0 text-[11px] text-ink-faint">
|
onChange={(e) => setForm((f) => ({ ...f, context: e.target.value }))}
|
||||||
{new Date(d.createdAt).toLocaleDateString('ro-RO')}
|
required
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-ink-faint">
|
|
||||||
Toate deciziile ({decisions.length})
|
|
||||||
</h2>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{decisions.slice(0, 10).map((d) => (
|
|
||||||
<li key={d.id} className="card flex items-center gap-4 px-4 py-3">
|
|
||||||
<span
|
|
||||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
|
||||||
d.selectedOption ? 'bg-signal-ok' : 'bg-paper-sunken border border-ink-line'
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<p className="flex-1 truncate text-sm text-ink">{d.context}</p>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
{d.selectedOption ? (
|
|
||||||
<span className="rounded-full bg-bronze-wash px-2 py-0.5 text-[10px] font-medium text-bronze-deep">
|
|
||||||
Decis
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="rounded-full bg-paper-sunken px-2 py-0.5 text-[10px] font-medium text-ink-faint">
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[11px] text-ink-line">
|
|
||||||
{new Date(d.createdAt).toLocaleDateString('ro-RO')}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-ink-faint mb-1">Opțiuni (una per linie)</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-lg border border-paper-border bg-paper px-3 py-2 text-sm text-ink placeholder:text-ink-line focus:border-bronze-deep focus:outline-none"
|
||||||
|
placeholder="Opțiunea A\nOpțiunea B\nOpțiunea C"
|
||||||
|
value={form.options}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, options: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-ink-faint mb-1">Termen review</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className="rounded-lg border border-paper-border bg-paper px-3 py-2 text-sm text-ink focus:border-bronze-deep focus:outline-none"
|
||||||
|
value={form.reviewDueAt}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, reviewDueAt: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={saving} className="rounded-lg bg-bronze-deep px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
|
||||||
|
{saving ? 'Se salvează…' : 'Salvează'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setShowForm(false)} className="rounded-lg border border-paper-border px-4 py-2 text-sm text-ink-faint">Anulare</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-sm text-ink-faint">Se încarcă…</p> : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Deschise', count: open.length, color: 'text-signal-warn' },
|
||||||
|
{ label: 'Decise, nereviewed', count: decided.length, color: 'text-bronze-deep' },
|
||||||
|
{ label: 'Revizuite', count: reviewed.length, color: 'text-signal-ok' },
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s.label} className="card p-5">
|
||||||
|
<p className="text-xs text-ink-faint">{s.label}</p>
|
||||||
|
<p className={`mt-1 font-display text-3xl font-semibold ${s.color}`}>{s.count}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{decisions.length === 0 ? (
|
||||||
|
<div className="card p-12 text-center">
|
||||||
|
<p className="font-semibold text-ink">Nicio decizie înregistrată</p>
|
||||||
|
<p className="mt-1 text-sm text-ink-faint">Adaugă prima decizie pentru a menține un log structurat.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Recente</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{decisions.slice(0, 10).map((d) => (
|
||||||
|
<div key={d.id} className="card p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-ink leading-snug line-clamp-2">{d.context}</p>
|
||||||
|
{d.options.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{d.options.map((opt, i) => (
|
||||||
|
<span key={i} className={`rounded px-2 py-0.5 text-[11px] ${d.selectedOption === opt ? 'bg-bronze-deep/10 text-bronze-deep font-semibold border border-bronze-deep/30' : 'bg-paper-sunken text-ink-faint'}`}>
|
||||||
|
{opt}
|
||||||
|
</span>
|
||||||
))}
|
))}
|
||||||
</ul>
|
|
||||||
{decisions.length > 10 && (
|
|
||||||
<div className="mt-3 text-right">
|
|
||||||
<Link
|
|
||||||
href="/dashboard/decisions/register"
|
|
||||||
className="text-xs font-medium text-bronze-deep hover:underline"
|
|
||||||
>
|
|
||||||
Toate {decisions.length} deciziile în registru →
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{d.outcomeReview && (
|
||||||
|
<p className="mt-2 text-xs text-ink-faint italic line-clamp-1">Review: {d.outcomeReview}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right space-y-1">
|
||||||
|
<span className={`block rounded-full px-2 py-0.5 text-[10px] font-medium ${d.outcomeReview ? 'bg-signal-ok/10 text-signal-ok' : d.selectedOption ? 'bg-bronze-wash text-bronze-deep' : 'bg-signal-warn/10 text-signal-warn'}`}>
|
||||||
|
{d.outcomeReview ? 'revizuit' : d.selectedOption ? 'decis' : 'deschis'}
|
||||||
|
</span>
|
||||||
|
<p className="text-[10px] text-ink-line">{fmtDate(d.createdAt)}</p>
|
||||||
|
{d.reviewDueAt && !d.outcomeReview && (
|
||||||
|
<p className="text-[10px] text-ink-line">review: {fmtDate(d.reviewDueAt)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{decisions.length > 10 && (
|
||||||
|
<Link href={`/dashboard/decisions/register`} className="block card p-3 text-center text-xs text-ink-faint hover:border-bronze-deep">
|
||||||
|
Încă {decisions.length - 10} decizii în registru →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue