feat(cc-053): Decision Register page

This commit is contained in:
admin-valentin 2026-08-01 10:43:40 +00:00
parent 9dacd0e535
commit acca1abc01

View file

@ -1,324 +1,85 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { apiFetch, type Decision } from '../../../../lib/api';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../../lib/api';
import type { Decision } from '../../../../lib/api';
import { useSession } from '../../../../components/session-provider';
const createDecisionSchema = z.object({
context: z.string().min(1, 'Contextul este obligatoriu').max(2000),
optionsText: z.string().optional(),
reviewDueAt: z.string().optional(),
});
type CreateDecisionForm = z.infer<typeof createDecisionSchema>;
function parseOptions(text: string): string[] {
return text.split('\n').map((s) => s.trim()).filter(Boolean);
function fmtDate(s: string | null) {
if (!s) return '—';
return new Date(s).toLocaleDateString('ro-RO', { day: 'numeric', month: 'short', year: 'numeric' });
}
function DecisionCard({
decision,
tenantId,
onUpdated,
}: {
decision: Decision;
tenantId: string;
onUpdated: () => Promise<void>;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [selectedOption, setSelectedOption] = useState(decision.selectedOption ?? '');
const [outcomeReview, setOutcomeReview] = useState(decision.outcomeReview ?? '');
const options = (decision.options as string[]).filter(Boolean);
const isDecided = Boolean(decision.selectedOption);
const isOverdue =
decision.reviewDueAt ? new Date(decision.reviewDueAt) < new Date() : false;
const updateMutation = useMutation({
mutationFn: (dto: { selectedOption?: string; outcomeReview?: string }) =>
apiFetch<Decision>(`/v1/decisions/${decision.id}`, {
method: 'PATCH',
tenantId,
body: dto,
}),
onSuccess: async () => {
await onUpdated();
},
});
return (
<li className="card overflow-hidden">
<button
type="button"
onClick={() => setIsExpanded((v) => !v)}
className="w-full p-4 text-left hover:bg-paper-sunken transition-colors"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-ink line-clamp-2">{decision.context}</p>
{decision.reviewDueAt && (
<p className={`mt-1 text-xs ${isOverdue ? 'text-signal-danger' : 'text-ink-faint'}`}>
{isOverdue ? 'Întârziat · ' : 'Review: '}
{new Date(decision.reviewDueAt).toLocaleDateString('ro-RO')}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{isDecided ? (
<span className="rounded-full bg-bronze-wash px-2 py-0.5 text-[10px] font-medium text-bronze-deep">
Decis
</span>
) : options.length > 0 ? (
<span className="rounded-full bg-paper-sunken px-2 py-0.5 text-[10px] font-medium text-ink-faint">
{options.length} opțiuni
</span>
) : null}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`h-3.5 w-3.5 shrink-0 text-ink-line transition-transform ${isExpanded ? 'rotate-180' : ''}`}
>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
</button>
{isExpanded && (
<div className="border-t border-ink-line p-4 space-y-4">
{decision.selectedOption && (
<div className="rounded-lg bg-bronze-wash px-3 py-2 text-sm text-bronze-deep">
<span className="font-medium">Decizie luată:</span> {decision.selectedOption}
</div>
)}
{options.length > 0 && (
<div>
<p className="label mb-2">Selectează opțiunea</p>
<div className="flex flex-wrap gap-2">
{options.map((opt, i) => (
<button
key={i}
type="button"
onClick={() => setSelectedOption(opt === selectedOption ? '' : opt)}
className={`rounded-lg border px-3 py-1.5 text-sm transition-colors ${
selectedOption === opt
? 'border-bronze bg-bronze-wash font-medium text-bronze-deep'
: 'border-ink-line bg-paper-raised text-ink-soft hover:border-ink-faint hover:text-ink'
}`}
>
{opt}
</button>
))}
</div>
</div>
)}
<div>
<label className="label" htmlFor={`outcome-${decision.id}`}>
Outcome review
</label>
<textarea
id={`outcome-${decision.id}`}
className="field min-h-[80px] resize-none"
placeholder="Ce s-a întâmplat în realitate? Ce ipoteză a fost greșită?"
value={outcomeReview}
onChange={(e) => setOutcomeReview(e.target.value)}
/>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() =>
updateMutation.mutate({
selectedOption: selectedOption || undefined,
outcomeReview: outcomeReview || undefined,
})
}
disabled={updateMutation.isPending}
className="btn-primary text-sm"
>
Salvează
</button>
{updateMutation.isError && (
<p className="text-xs text-signal-danger">
{updateMutation.error instanceof Error ? updateMutation.error.message : 'Eroare'}
</p>
)}
{updateMutation.isSuccess && (
<p className="text-xs text-signal-ok">Salvat</p>
)}
</div>
</div>
)}
</li>
);
}
export default function DecisionsRegisterPage() {
export default function DecisionRegisterPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const queryClient = useQueryClient();
const [isFormOpen, setIsFormOpen] = useState(false);
const { data: decisions = [], isLoading } = useQuery({
queryKey: ['decisions', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions', { tenantId }),
queryKey: ['decisions-all', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=200', { tenantId }),
enabled: Boolean(tenantId),
});
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateDecisionForm>({ resolver: zodResolver(createDecisionSchema) });
const createDecision = useMutation({
mutationFn: (values: CreateDecisionForm) =>
apiFetch<Decision>('/v1/decisions', {
method: 'POST',
tenantId,
body: {
context: values.context,
options: values.optionsText ? parseOptions(values.optionsText) : [],
reviewDueAt: values.reviewDueAt || undefined,
},
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['decisions', tenantId] });
reset();
setIsFormOpen(false);
},
});
const invalidate = async () => {
await queryClient.invalidateQueries({ queryKey: ['decisions', tenantId] });
};
const pendingCount = decisions.filter((d) => !d.selectedOption).length;
const overdueCount = decisions.filter(
(d) => d.reviewDueAt && new Date(d.reviewDueAt) < new Date(),
).length;
return (
<div className="max-w-3xl">
<div className="mb-6 flex items-center justify-between">
<div className="max-w-5xl space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Registru Decizii</h1>
<p className="text-sm text-ink-faint">
Context · Opțiuni · Decizie · Outcome înregistrate, nu uitate.
<p className="mt-1 text-sm text-ink-faint">
{decisions.length} decizii înregistrate istoric complet
</p>
</div>
<button type="button" className="btn-primary" onClick={() => setIsFormOpen((v) => !v)}>
{isFormOpen ? 'Anulează' : 'Decizie nouă'}
</button>
<Link href="/dashboard/decisions" className="text-sm text-bronze-deep hover:underline"> Dashboard</Link>
</div>
{decisions.length > 0 && (
<div className="mb-6 grid grid-cols-3 gap-3">
<div className="card p-4">
<p className="label">Total decizii</p>
<p className="font-display text-2xl font-semibold text-ink">{decisions.length}</p>
</div>
<div className="card p-4">
<p className="label">În așteptare</p>
<p className={`font-display text-2xl font-semibold ${pendingCount > 0 ? 'text-signal-warn' : 'text-ink'}`}>
{pendingCount}
</p>
</div>
<div className="card p-4">
<p className="label">Review întârziat</p>
<p className={`font-display text-2xl font-semibold ${overdueCount > 0 ? 'text-signal-danger' : 'text-ink'}`}>
{overdueCount}
</p>
</div>
{isLoading ? (
<p className="text-sm text-ink-faint">Se încarcă</p>
) : decisions.length === 0 ? (
<div className="card p-12 text-center">
<p className="text-sm text-ink-faint">Niciun registru de decizii. Adaugă prima decizie din <Link href="/dashboard/decisions" className="text-bronze-deep hover:underline">Decision Dashboard</Link>.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-paper-border">
{['Context', 'Opțiuni', 'Selectat', 'Review due', 'Outcome', 'Data'].map((h) => (
<th key={h} className="py-3 px-3 text-left text-xs font-semibold uppercase tracking-wider text-ink-faint first:pl-0">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-paper-border">
{decisions.map((d) => (
<tr key={d.id} className="hover:bg-paper-sunken/50">
<td className="py-3 px-3 first:pl-0">
<p className="max-w-xs truncate font-medium text-ink">{d.context}</p>
</td>
<td className="py-3 px-3">
<span className="text-ink-faint">{d.options.length}</span>
</td>
<td className="py-3 px-3">
<span className="max-w-32 truncate block text-ink-faint">{d.selectedOption ?? '—'}</span>
</td>
<td className="py-3 px-3">
<span className={`text-ink-faint ${d.reviewDueAt && !d.outcomeReview && new Date(d.reviewDueAt) < new Date() ? 'text-signal-danger' : ''}`}>
{fmtDate(d.reviewDueAt)}
</span>
</td>
<td className="py-3 px-3">
<span className={`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>
</td>
<td className="py-3 px-3 font-mono text-xs text-ink-line">
{fmtDate(d.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{isFormOpen && (
<form
onSubmit={handleSubmit((values) => createDecision.mutate(values))}
noValidate
className="card mb-6 space-y-4 p-5"
>
<div>
<label className="label" htmlFor="dec-context">
Context și decizie necesară
</label>
<textarea
id="dec-context"
className="field min-h-[100px] resize-none"
placeholder="Ce decizie trebuie luată? Care e contextul, presiunile, termenul?"
{...register('context')}
/>
{errors.context && (
<p className="mt-1 text-xs text-signal-danger">{errors.context.message}</p>
)}
</div>
<div>
<label className="label" htmlFor="dec-options">
Opțiuni disponibile (câte una pe linie, opțional)
</label>
<textarea
id="dec-options"
className="field min-h-[72px] resize-none"
placeholder="ex: Angajăm intern / Externalizăm parțial / Status quo"
{...register('optionsText')}
/>
</div>
<div>
<label className="label" htmlFor="dec-review-date">
Dată de review outcome (opțional)
</label>
<input
id="dec-review-date"
type="date"
className="field"
{...register('reviewDueAt')}
/>
</div>
{createDecision.isError && (
<p className="text-sm text-signal-danger">
{createDecision.error instanceof Error ? createDecision.error.message : 'Eroare'}
</p>
)}
<button
type="submit"
disabled={isSubmitting || createDecision.isPending}
className="btn-primary"
>
Înregistrează decizia
</button>
</form>
)}
{isLoading && <p className="text-sm text-ink-faint">Se încarcă</p>}
{!isLoading && decisions.length === 0 && (
<div className="card p-8 text-center">
<p className="mb-1 text-sm font-medium text-ink">Nicio decizie înregistrată</p>
<p className="text-xs text-ink-faint">
Un registru de decizii bun documentează contextul, nu doar rezultatul.
</p>
</div>
)}
<ul className="space-y-2">
{decisions.map((decision) => (
<DecisionCard
key={decision.id}
decision={decision}
tenantId={tenantId}
onUpdated={invalidate}
/>
))}
</ul>
</div>
);
}