feat(CC-084): add Applications & Deadlines page (task-based, auto-detect by tags)

This commit is contained in:
admin-valentin 2026-08-02 17:31:33 +00:00
parent 89dc2cae72
commit 3a9245b186

View file

@ -0,0 +1,217 @@
'use client';
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Task {
id: string; title: string; description: string | null; status: string;
priority: string | null; tags: string[]; dueDate: string | null;
assignedTo: string | null; createdAt: string;
}
const APP_TAGS = ['application', 'aplicatie', 'candidatura', 'grant', 'bursă', 'bursa', 'concurs', 'tender'];
const STATUS_LABELS: Record<string, { label: string; cls: string }> = {
todo: { label: 'De făcut', cls: 'bg-muted text-ink-faint' },
in_progress: { label: 'În lucru', cls: 'bg-primary/10 text-primary' },
review: { label: 'Review', cls: 'bg-warn/10 text-warn' },
completed: { label: 'Depus', cls: 'bg-signal-ok/10 text-signal-ok' },
cancelled: { label: 'Anulat', cls: 'bg-signal-danger/10 text-signal-danger' },
};
function daysLeft(due: string | null): number | null {
if (!due) return null;
return Math.ceil((new Date(due).getTime() - Date.now()) / 86400_000);
}
export default function ApplicationsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [statusFilter, setStatusFilter] = useState('all');
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ title: '', description: '', priority: 'normal', dueDate: '', tag: 'application' });
const { data: tasks = [], isLoading } = useQuery({
queryKey: ['apps', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const apps = useMemo(() =>
tasks.filter((t) => t.tags.some((tag) => APP_TAGS.includes(tag.toLowerCase())))
.sort((a, b) => {
if (a.dueDate && b.dueDate) return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
if (a.dueDate) return -1;
if (b.dueDate) return 1;
return 0;
}),
[tasks]);
const filtered = statusFilter === 'all' ? apps : apps.filter((a) => a.status === statusFilter);
const stats = useMemo(() => ({
total: apps.length,
active: apps.filter((a) => !['completed','cancelled'].includes(a.status)).length,
submitted: apps.filter((a) => a.status === 'completed').length,
urgent: apps.filter((a) => { const d = daysLeft(a.dueDate); return d !== null && d <= 7 && d >= 0 && a.status !== 'completed'; }).length,
}), [apps]);
const createMut = useMutation({
mutationFn: () => apiFetch('/v1/tasks', { tenantId, method: 'POST', body: {
title: form.title, description: form.description || undefined,
priority: form.priority, dueDate: form.dueDate || undefined,
tags: [form.tag, 'application'], status: 'todo',
}}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['apps', tenantId] }); setShowAdd(false); setForm({ title: '', description: '', priority: 'normal', dueDate: '', tag: 'application' }); },
});
const patchMut = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
apiFetch(`/v1/tasks/${id}`, { tenantId, method: 'PATCH', body: { status } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps', tenantId] }),
});
return (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Aplicații & Termene</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${apps.length} aplicații urmărite`}
</p>
</div>
<button onClick={() => setShowAdd(true)}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
+ Aplicație nouă
</button>
</div>
{stats.urgent > 0 && (
<div className="card p-3 bg-signal-danger/10 border-signal-danger/30 flex items-center gap-3">
<span className="text-xl"></span>
<p className="text-sm font-semibold text-signal-danger">
{stats.urgent} aplicație/aplicații cu termen în mai puțin de 7 zile!
</p>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Total', value: stats.total },
{ label: 'Active', value: stats.active },
{ label: 'Depuse', value: stats.submitted },
{ label: 'Urgente', value: stats.urgent },
].map((s) => (
<div key={s.label} className="card p-3 text-center">
<p className="text-xl font-bold text-ink">{s.value}</p>
<p className="text-[10px] text-ink-faint">{s.label}</p>
</div>
))}
</div>
{/* Add form */}
{showAdd && (
<div className="card p-5 space-y-3">
<p className="text-sm font-semibold text-ink">Aplicație nouă</p>
<div className="grid gap-3 sm:grid-cols-2">
<input placeholder="Titlu (ex: Bursă Erasmus 2026)" value={form.title}
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring sm:col-span-2" />
<input placeholder="Descriere scurtă" value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring sm:col-span-2" />
<div className="space-y-1">
<label className="text-[10px] text-ink-faint">Termen limită</label>
<input type="date" value={form.dueDate}
onChange={(e) => setForm((p) => ({ ...p, dueDate: e.target.value }))}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div className="space-y-1">
<label className="text-[10px] text-ink-faint">Tip</label>
<select value={form.tag} onChange={(e) => setForm((p) => ({ ...p, tag: e.target.value }))}
className="w-full rounded-lg border bg-background px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
<option value="application">Aplicație generică</option>
<option value="grant">Grant / Finanțare</option>
<option value="bursa">Bursă</option>
<option value="concurs">Concurs / Competiție</option>
<option value="tender">Tender / Licitație</option>
<option value="candidatura">Candidatură job</option>
</select>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => createMut.mutate()} disabled={!form.title || createMut.isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
{createMut.isPending ? 'Se salvează…' : 'Salvează'}
</button>
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
</div>
</div>
)}
{/* Filters */}
<div className="flex gap-2 flex-wrap">
{['all', 'todo', 'in_progress', 'review', 'completed', 'cancelled'].map((s) => (
<button key={s} onClick={() => setStatusFilter(s)}
className={`rounded-full px-3 py-1 text-xs font-medium border transition-colors ${statusFilter === s ? 'bg-primary text-white border-primary' : 'bg-background text-ink-faint border-border hover:border-primary/40'}`}>
{s === 'all' ? 'Toate' : (STATUS_LABELS[s]?.label ?? s)}
</button>
))}
</div>
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : filtered.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">📋</p>
<p className="text-sm text-ink-faint">Nicio aplicație. Adaugă prima aplicație.</p>
<p className="text-xs text-ink-faint">Task-urile cu tag-ul <code className="bg-muted px-1 rounded">application</code>, <code className="bg-muted px-1 rounded">grant</code> sau <code className="bg-muted px-1 rounded">bursa</code> apar automat.</p>
</div>
) : (
<div className="card divide-y divide-border/50">
{filtered.map((app) => {
const dl = daysLeft(app.dueDate);
const urgentFlag = dl !== null && dl <= 7 && dl >= 0 && app.status !== 'completed';
const sl = STATUS_LABELS[app.status] ?? { label: app.status, cls: 'bg-muted text-ink-faint' };
return (
<div key={app.id} className={`p-4 space-y-2 ${urgentFlag ? 'border-l-4 border-l-signal-danger' : ''}`}>
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium text-ink">{app.title}</p>
<span className={`rounded-full px-2 py-0.5 text-[9px] font-bold shrink-0 ${sl.cls}`}>{sl.label}</span>
</div>
<div className="flex flex-wrap items-center gap-3 text-[10px] text-ink-faint">
{app.dueDate && (
<span className={urgentFlag ? 'text-signal-danger font-semibold' : ''}>
📅 {new Date(app.dueDate).toLocaleDateString('ro-RO', { dateStyle: 'medium' })}
{dl !== null && dl >= 0 && ` (${dl}z)`}
{dl !== null && dl < 0 && ' (expirat)'}
</span>
)}
{app.tags.filter((t) => t !== 'application').map((t) => (
<span key={t} className="bg-muted px-1 rounded">{t}</span>
))}
</div>
{app.status !== 'completed' && app.status !== 'cancelled' && (
<div className="flex gap-2">
{app.status === 'todo' && (
<button onClick={() => patchMut.mutate({ id: app.id, status: 'in_progress' })}
className="text-[10px] rounded border px-2 py-0.5 text-ink hover:bg-muted/50">Începe</button>
)}
<button onClick={() => patchMut.mutate({ id: app.id, status: 'completed' })}
className="text-[10px] rounded border px-2 py-0.5 text-signal-ok border-signal-ok/30 hover:bg-signal-ok/10">Marcare depus</button>
<button onClick={() => patchMut.mutate({ id: app.id, status: 'cancelled' })}
className="text-[10px] rounded border px-2 py-0.5 text-ink-faint hover:bg-muted/50">Anulează</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}