feat(CC-066): add Action Plans page (status filter, priority, quick activate/complete)
This commit is contained in:
parent
26aed6d737
commit
d7d8c91ad5
1 changed files with 175 additions and 0 deletions
175
src/app/dashboard/action-plans/page.tsx
Normal file
175
src/app/dashboard/action-plans/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue