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