feat(CC-084): add Applications & Deadlines page (task-based, auto-detect by tags)
This commit is contained in:
parent
89dc2cae72
commit
3a9245b186
1 changed files with 217 additions and 0 deletions
217
src/app/dashboard/applications/page.tsx
Normal file
217
src/app/dashboard/applications/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue