feat(CC-063): add Projects page (status/priority/overdue tracking)

This commit is contained in:
admin-valentin 2026-08-01 21:20:57 +00:00
parent 17852d4dbd
commit 458379524d

View file

@ -0,0 +1,220 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Project {
id: string; name: string; description: string | null;
status: string; priority: string; organizationId: string | null;
startDate: string | null; dueDate: string | null;
tags: string[]; createdAt: string;
}
interface Organization { id: string; name: string; }
const STATUS_META: Record<string, { label: string; cls: string }> = {
planning: { label: 'Planificare', cls: 'bg-sky-500/10 text-sky-700 dark:text-sky-300' },
active: { label: 'Activ', cls: 'bg-signal-ok/10 text-signal-ok' },
on_hold: { label: 'În pauză', cls: 'bg-signal-warn/10 text-signal-warn' },
completed: { label: 'Completat', cls: 'bg-primary/10 text-primary' },
cancelled: { label: 'Anulat', cls: 'bg-muted text-ink-faint' },
};
const PRIORITY_META: Record<string, { label: string; dot: string }> = {
low: { label: 'Scăzut', dot: 'bg-sky-400' },
medium: { label: 'Mediu', dot: 'bg-signal-warn' },
high: { label: 'Ridicat', dot: 'bg-orange-500' },
critical: { label: 'Critic', dot: 'bg-signal-danger' },
};
export default function ProjectsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [filterStatus, setFilterStatus] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState({
name: '', description: '', status: 'planning', priority: 'medium',
startDate: '', dueDate: '',
});
const { data: projects = [], isLoading } = useQuery({
queryKey: ['projects', tenantId, filterStatus],
queryFn: () => apiFetch<Project[]>(`/v1/projects${filterStatus ? `?status=${filterStatus}` : ''}`, { tenantId }),
enabled: Boolean(tenantId),
staleTime: 60_000,
});
const { data: orgs = [] } = useQuery({
queryKey: ['orgs', tenantId],
queryFn: () => apiFetch<Organization[]>('/v1/organizations', { tenantId }),
enabled: Boolean(tenantId),
staleTime: 300_000,
});
const orgMap = Object.fromEntries(orgs.map((o) => [o.id, o.name]));
const { mutate: createProject, isPending } = useMutation({
mutationFn: () => apiFetch<Project>('/v1/projects', { method: 'POST', body: form, tenantId }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['projects', tenantId] });
setShowCreate(false);
setForm({ name: '', description: '', status: 'planning', priority: 'medium', startDate: '', dueDate: '' });
},
});
const now = new Date();
const overdue = projects.filter((p) => p.dueDate && new Date(p.dueDate) < now && p.status !== 'completed' && p.status !== 'cancelled');
return (
<div className="max-w-5xl space-y-6 p-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Proiecte</h1>
<p className="text-sm text-ink-faint mt-1">
{projects.length} proiecte{overdue.length > 0 ? ` · ${overdue.length} întârziate` : ''}
</p>
</div>
<button onClick={() => setShowCreate(true)} className="btn btn-primary text-xs px-4 py-2">
+ Proiect nou
</button>
</div>
{/* Overdue alert */}
{overdue.length > 0 && (
<div className="rounded-xl border border-signal-danger/20 bg-signal-danger/5 p-4 space-y-1">
<p className="text-sm font-semibold text-signal-danger">
{overdue.length} proiect{overdue.length > 1 ? 'e cu termenul depășit' : ' cu termenul depășit'}
</p>
{overdue.slice(0, 3).map((p) => (
<p key={p.id} className="text-xs text-ink-faint">
{p.name} termen {new Date(p.dueDate!).toLocaleDateString('ro-RO')}
</p>
))}
</div>
)}
{/* Create form */}
{showCreate && (
<div className="card p-5 space-y-4 border-primary/30">
<h2 className="text-sm font-semibold text-ink">Proiect nou</h2>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="text-xs text-ink-faint block mb-1">Nume proiect *</label>
<input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="Numele proiectului…"
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
{[
{ key: 'status', label: 'Status', opts: Object.entries(STATUS_META).map(([k, v]) => ({ value: k, label: v.label })) },
{ key: 'priority', label: 'Prioritate', opts: Object.entries(PRIORITY_META).map(([k, v]) => ({ value: k, label: v.label })) },
].map(({ key, label, opts }) => (
<div key={key}>
<label className="text-xs text-ink-faint block mb-1">{label}</label>
<select value={(form as Record<string, string>)[key]}
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring">
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
))}
{[
{ key: 'startDate', label: 'Data start' },
{ key: 'dueDate', label: 'Termen limită' },
].map(({ key, label }) => (
<div key={key}>
<label className="text-xs text-ink-faint block mb-1">{label}</label>
<input type="date" value={(form as Record<string, string>)[key]}
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.value }))}
className="w-full rounded-lg border bg-card px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
))}
<div className="sm:col-span-2">
<label className="text-xs text-ink-faint block mb-1">Descriere</label>
<textarea value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
rows={2} className="w-full rounded-lg border bg-card px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
</div>
<div className="flex gap-3">
<button onClick={() => createProject()} disabled={isPending || !form.name.trim()}
className="btn btn-primary text-xs px-4 py-2 disabled:opacity-50">
{isPending ? 'Se salvează…' : 'Salvează'}
</button>
<button onClick={() => setShowCreate(false)} className="text-xs text-ink-faint hover:text-ink">Anulează</button>
</div>
</div>
)}
{/* Status filter */}
<div className="flex gap-2 flex-wrap">
{[['', 'Toate'], ...Object.entries(STATUS_META).map(([k, v]) => [k, v.label])].map(([s, l]) => (
<button key={s} onClick={() => setFilterStatus(s)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
filterStatus === s ? 'bg-primary text-white border-primary' : 'bg-card text-ink-faint border-border hover:border-primary/40'
}`}>
{l}
</button>
))}
</div>
{isLoading ? (
<div className="card p-8 text-center text-sm text-ink-faint">Se încarcă</div>
) : projects.length === 0 ? (
<div className="card p-12 text-center space-y-2">
<p className="text-3xl">🗂</p>
<p className="text-sm text-ink-faint">Niciun proiect înregistrat.</p>
<button onClick={() => setShowCreate(true)} className="text-xs text-bronze-deep hover:underline">Creează primul proiect </button>
</div>
) : (
<div className="space-y-2">
{projects.map((proj) => {
const status = STATUS_META[proj.status] ?? STATUS_META.planning;
const prio = PRIORITY_META[proj.priority] ?? PRIORITY_META.medium;
const isOverdue = proj.dueDate && new Date(proj.dueDate) < now && proj.status !== 'completed' && proj.status !== 'cancelled';
return (
<div key={proj.id} className={`card p-4 space-y-2 ${isOverdue ? 'border-signal-danger/20' : ''}`}>
<div className="flex items-start gap-3">
<div className={`mt-1.5 w-2 h-2 rounded-full shrink-0 ${prio.dot}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-semibold text-ink">{proj.name}</p>
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${status.cls}`}>
{status.label}
</span>
{isOverdue && (
<span className="text-[10px] text-signal-danger font-medium"> Termen depășit</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-ink-faint mt-0.5 flex-wrap">
{proj.organizationId && orgMap[proj.organizationId] && (
<span>@ {orgMap[proj.organizationId]}</span>
)}
{proj.startDate && <span>Start: {proj.startDate}</span>}
{proj.dueDate && (
<span className={isOverdue ? 'text-signal-danger' : ''}>
Termen: {proj.dueDate}
</span>
)}
<span>{prio.label} prioritate</span>
</div>
{proj.description && (
<p className="text-xs text-ink-faint mt-1 line-clamp-1">{proj.description}</p>
)}
{proj.tags.length > 0 && (
<div className="flex gap-1 mt-1 flex-wrap">
{proj.tags.map((tag) => (
<span key={tag} className="text-[10px] border border-border/50 rounded px-1.5 py-0.5 text-ink-faint">
{tag}
</span>
))}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}