feat(decisions): add Decisions Register page — create, list, select option, outcome review
This commit is contained in:
parent
b5548abf37
commit
643fe527a2
1 changed files with 326 additions and 0 deletions
326
src/app/dashboard/decisions/register/page.tsx
Normal file
326
src/app/dashboard/decisions/register/page.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
'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 { 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 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() {
|
||||||
|
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 }),
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn-primary" onClick={() => setIsFormOpen((v) => !v)}>
|
||||||
|
{isFormOpen ? 'Anulează' : 'Decizie nouă'}
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
</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={"Angajăm intern
|
||||||
|
Externalizăm parțial
|
||||||
|
Menținem 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue