feat(CC-066): add Action Plans page (status filter, priority, quick activate/complete)

This commit is contained in:
admin-valentin 2026-08-02 12:09:08 +00:00
parent 26aed6d737
commit d7d8c91ad5

View file

@ -0,0 +1,175 @@
'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface ActionPlan {
id: string; title: string; description: string | null;
status: string; priority: string; owner: string | null;
dueDate: string | null; completedAt: string | null;
decisionId: string | null; goalId: string | null; projectId: string | null;
createdAt: string;
}
const STATUS_META: Record<string, { label: string; cls: string; order: number }> = {
draft: { label: 'Draft', cls: 'text-ink-faint bg-muted', order: 0 },
active: { label: 'Activ', cls: 'text-bronze-deep bg-primary/10', order: 1 },
completed: { label: 'Finalizat', cls: 'text-signal-ok bg-signal-ok/10', order: 2 },
cancelled: { label: 'Anulat', cls: 'text-signal-danger bg-signal-danger/10', order: 3 },
};
const PRIORITY_DOT: Record<string, string> = {
low: 'bg-sky-400', medium: 'bg-signal-warn', high: 'bg-orange-500', critical: 'bg-signal-danger',
};
const STATUSES = ['draft', 'active', 'completed', 'cancelled'];
function isOverdue(ap: ActionPlan): boolean {
return Boolean(ap.dueDate && new Date(ap.dueDate) < new Date() && ap.status === 'active');
}
export default function ActionPlansPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [filterStatus, setFilterStatus] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({ title: '', priority: 'medium', status: 'draft', owner: '', dueDate: '' });
const { data: plans = [], isLoading } = useQuery({
queryKey: ['action-plans', tenantId, filterStatus],
queryFn: () => apiFetch<ActionPlan[]>(`/v1/action-plans${filterStatus ? `?status=${filterStatus}` : ''}`, { tenantId }),
enabled: Boolean(tenantId),
staleTime: 30_000,
});
const createMut = useMutation({
mutationFn: (body: Record<string, string>) => apiFetch<ActionPlan>('/v1/action-plans', { tenantId, method: 'POST', body }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['action-plans', tenantId] }); setShowCreate(false); setForm({ title: '', priority: 'medium', status: 'draft', owner: '', dueDate: '' }); },
});
const patchMut = useMutation({
mutationFn: ({ id, ...body }: { id: string; status: string }) => apiFetch(`/v1/action-plans/${id}`, { tenantId, method: 'PATCH', body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['action-plans', tenantId] }),
});
const stats = STATUSES.map((s) => ({ status: s, count: plans.filter((p) => p.status === s).length }));
const overdue = plans.filter(isOverdue).length;
return (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Planuri de Acțiune</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${plans.length} planuri${overdue > 0 ? ` · ${overdue} depășite` : ''}`}
</p>
</div>
<button onClick={() => setShowCreate(!showCreate)} className="btn btn-primary px-4 py-2 text-sm rounded-lg">
+ Plan nou
</button>
</div>
{/* Summary chips */}
<div className="flex gap-3 flex-wrap">
<button onClick={() => setFilterStatus('')}
className={`rounded-lg px-3 py-1.5 text-xs font-medium border transition-colors ${!filterStatus ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint hover:border-primary/40'}`}>
Toate ({plans.length + (filterStatus ? 0 : 0)})
</button>
{stats.map(({ status, count }) => {
const m = STATUS_META[status];
return (
<button key={status} onClick={() => setFilterStatus(status === filterStatus ? '' : status)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium border transition-colors ${filterStatus === status ? 'ring-2 ring-primary' : ''} ${m.cls} border-current/20`}>
{m.label} {count > 0 && `(${count})`}
</button>
);
})}
{overdue > 0 && (
<span className="rounded-lg px-3 py-1.5 text-xs font-medium bg-signal-danger/10 text-signal-danger border border-signal-danger/20">
{overdue} depășite
</span>
)}
</div>
{/* Create form */}
{showCreate && (
<div className="card p-5 space-y-4">
<h2 className="text-sm font-semibold text-ink">Plan nou</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<input placeholder="Titlu plan de acțiune *"
value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<select value={form.priority} onChange={(e) => setForm({ ...form, priority: e.target.value })}
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
{['low','medium','high','critical'].map((p) => <option key={p} value={p}>{p}</option>)}
</select>
<input placeholder="Responsabil" value={form.owner} onChange={(e) => setForm({ ...form, owner: e.target.value })}
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
<input type="date" value={form.dueDate} onChange={(e) => setForm({ ...form, dueDate: e.target.value })}
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div className="flex gap-2">
<button onClick={() => createMut.mutate({ title: form.title, priority: form.priority, owner: form.owner, dueDate: form.dueDate })}
disabled={!form.title || createMut.isPending}
className="btn btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-50">
{createMut.isPending ? 'Se creează…' : 'Crează'}
</button>
<button onClick={() => setShowCreate(false)} className="text-sm text-ink-faint hover:text-ink px-2">Anulează</button>
</div>
</div>
)}
{/* List */}
{!isLoading && plans.length === 0 ? (
<div className="card p-12 text-center text-sm text-ink-faint">
Niciun plan de acțiune. Creează primul plan.
</div>
) : (
<div className="card divide-y divide-border/50">
{plans.map((ap) => {
const overdue = isOverdue(ap);
const m = STATUS_META[ap.status] ?? STATUS_META.draft;
return (
<div key={ap.id} className={`flex items-start gap-4 p-4 ${overdue ? 'bg-signal-danger/5' : ''}`}>
<div className={`mt-1.5 h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[ap.priority] ?? 'bg-muted'}`} title={ap.priority} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-ink">{ap.title}</span>
{overdue && <span className="text-[10px] font-bold text-signal-danger">DEPĂȘIT</span>}
</div>
<div className="flex items-center gap-3 mt-1 flex-wrap">
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${m.cls}`}>{m.label}</span>
{ap.owner && <span className="text-[10px] text-ink-faint">👤 {ap.owner}</span>}
{ap.dueDate && <span className={`text-[10px] ${overdue ? 'text-signal-danger' : 'text-ink-faint'}`}>📅 {ap.dueDate}</span>}
{ap.decisionId && <span className="text-[10px] text-ink-faint/60"> decizie</span>}
{ap.goalId && <span className="text-[10px] text-ink-faint/60"> obiectiv</span>}
</div>
</div>
<div className="flex gap-1 shrink-0">
{ap.status === 'draft' && (
<button onClick={() => patchMut.mutate({ id: ap.id, status: 'active' })}
className="rounded-lg border px-2 py-1 text-[10px] text-bronze-deep hover:bg-primary/5 transition-colors">
Activează
</button>
)}
{ap.status === 'active' && (
<button onClick={() => patchMut.mutate({ id: ap.id, status: 'completed' })}
className="rounded-lg border px-2 py-1 text-[10px] text-signal-ok hover:bg-signal-ok/5 transition-colors">
Finalizat
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}