feat(CC-075): add Follow-ups page (tasks tagged follow-up, grouped overdue/today/upcoming)
This commit is contained in:
parent
cad9026312
commit
7fab4a0527
1 changed files with 215 additions and 0 deletions
215
src/app/dashboard/follow-ups/page.tsx
Normal file
215
src/app/dashboard/follow-ups/page.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
'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 Task {
|
||||
id: string; title: string; status: string; priority: string;
|
||||
dueDate: string | null; tags: string[]; description: string | null;
|
||||
assignedTo: string | null; createdAt: string;
|
||||
}
|
||||
|
||||
const FOLLOWUP_TAGS = ['follow-up', 'followup', 'follow_up', 'urmarire', 'callback', 'recontact'];
|
||||
|
||||
function isFollowUp(task: Task) {
|
||||
return task.tags.some((t) => FOLLOWUP_TAGS.includes(t.toLowerCase()))
|
||||
|| task.title.toLowerCase().includes('follow') || task.title.toLowerCase().includes('urmarire')
|
||||
|| task.title.toLowerCase().includes('contact');
|
||||
}
|
||||
|
||||
function daysUntil(dateStr: string) {
|
||||
return Math.ceil((new Date(dateStr).getTime() - Date.now()) / 86400_000);
|
||||
}
|
||||
|
||||
const PRIORITY_DOT: Record<string, string> = {
|
||||
critical: 'bg-signal-danger', high: 'bg-warn', medium: 'bg-primary', low: 'bg-muted-foreground',
|
||||
};
|
||||
|
||||
export default function FollowUpsPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [tab, setTab] = useState<'pending' | 'done'>('pending');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ title: '', dueDate: '', priority: 'medium', assignedTo: '', description: '' });
|
||||
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ['followups', tenantId],
|
||||
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 30_000,
|
||||
});
|
||||
|
||||
const followups = tasks.filter(isFollowUp);
|
||||
const pending = followups.filter((t) => t.status !== 'completed' && t.status !== 'cancelled');
|
||||
const done = followups.filter((t) => t.status === 'completed');
|
||||
|
||||
const overdue = pending.filter((t) => t.dueDate && daysUntil(t.dueDate) < 0);
|
||||
const today = pending.filter((t) => t.dueDate && daysUntil(t.dueDate) === 0);
|
||||
const upcoming = pending.filter((t) => t.dueDate && daysUntil(t.dueDate) > 0);
|
||||
const noDate = pending.filter((t) => !t.dueDate);
|
||||
|
||||
const completeMut = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch<Task>(`/v1/tasks/${id}`, { tenantId, method: 'PATCH', body: { status: 'completed' } }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['followups', tenantId] }),
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, string | string[]>) =>
|
||||
apiFetch<Task>('/v1/tasks', { tenantId, method: 'POST', body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['followups', tenantId] });
|
||||
setShowCreate(false);
|
||||
setForm({ title: '', dueDate: '', priority: 'medium', assignedTo: '', description: '' });
|
||||
},
|
||||
});
|
||||
|
||||
const renderTask = (t: Task) => {
|
||||
const days = t.dueDate ? daysUntil(t.dueDate) : null;
|
||||
return (
|
||||
<div key={t.id} className={`flex items-center gap-3 px-4 py-3 ${days !== null && days < 0 ? 'bg-signal-danger/3' : ''}`}>
|
||||
<button onClick={() => completeMut.mutate(t.id)}
|
||||
className="w-5 h-5 shrink-0 rounded-full border-2 border-border hover:border-signal-ok hover:bg-signal-ok/10 transition-colors" />
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${PRIORITY_DOT[t.priority] ?? 'bg-muted'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-ink">{t.title}</p>
|
||||
{t.description && (
|
||||
<p className="text-[10px] text-ink-faint truncate">{t.description}</p>
|
||||
)}
|
||||
{t.assignedTo && (
|
||||
<p className="text-[10px] text-ink-faint">→ {t.assignedTo}</p>
|
||||
)}
|
||||
</div>
|
||||
{days !== null && (
|
||||
<span className={`text-[10px] shrink-0 font-medium ${days < 0 ? 'text-signal-danger' : days === 0 ? 'text-warn font-bold' : 'text-ink-faint'}`}>
|
||||
{days < 0 ? `−${Math.abs(days)}z` : days === 0 ? 'azi' : `+${days}z`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6 p-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl font-semibold text-ink">Follow-ups</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{pending.length} în așteptare
|
||||
{overdue.length > 0 && <span className="ml-1 text-signal-danger font-semibold">· {overdue.length} depășite</span>}
|
||||
{today.length > 0 && <span className="ml-1 text-warn font-semibold">· {today.length} azi</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(!showCreate)}
|
||||
className="btn btn-primary px-4 py-2 text-sm rounded-lg">
|
||||
+ Follow-up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-border/50">
|
||||
<button onClick={() => setTab('pending')}
|
||||
className={`pb-2 px-3 text-sm font-medium border-b-2 transition-colors ${tab === 'pending' ? 'border-primary text-ink' : 'border-transparent text-ink-faint hover:text-ink'}`}>
|
||||
În așteptare ({pending.length})
|
||||
</button>
|
||||
<button onClick={() => setTab('done')}
|
||||
className={`pb-2 px-3 text-sm font-medium border-b-2 transition-colors ${tab === 'done' ? 'border-primary text-ink' : 'border-transparent text-ink-faint hover:text-ink'}`}>
|
||||
Finalizate ({done.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h2 className="text-sm font-semibold text-ink">Follow-up nou</h2>
|
||||
<input placeholder="Titlu * (ex: Contactează clientul X despre propunere)" 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 className="grid grid-cols-3 gap-3">
|
||||
<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" />
|
||||
<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}>{p}</option>)}
|
||||
</select>
|
||||
<input placeholder="Persoana responsabilă" value={form.assignedTo}
|
||||
onChange={(e) => setForm({ ...form, assignedTo: 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>
|
||||
<input placeholder="Detalii (opțional)" value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: 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 className="flex gap-2">
|
||||
<button
|
||||
onClick={() => createMut.mutate({ title: form.title, priority: form.priority, dueDate: form.dueDate || undefined, assignedTo: form.assignedTo || undefined, description: form.description || undefined, tags: ['follow-up'] } as Record<string,string|string[]>)}
|
||||
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ă…' : 'Salvează'}
|
||||
</button>
|
||||
<button onClick={() => setShowCreate(false)} className="text-sm text-ink-faint hover:text-ink px-2">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||||
) : tab === 'pending' ? (
|
||||
<div className="space-y-3">
|
||||
{overdue.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-signal-danger mb-1">Depășite ({overdue.length})</p>
|
||||
<div className="card divide-y divide-border/50 border-signal-danger/20">{overdue.map(renderTask)}</div>
|
||||
</div>
|
||||
)}
|
||||
{today.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-warn mb-1">Azi ({today.length})</p>
|
||||
<div className="card divide-y divide-border/50 border-warn/20">{today.map(renderTask)}</div>
|
||||
</div>
|
||||
)}
|
||||
{upcoming.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-faint mb-1">Urmează ({upcoming.length})</p>
|
||||
<div className="card divide-y divide-border/50">{upcoming.map(renderTask)}</div>
|
||||
</div>
|
||||
)}
|
||||
{noDate.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-ink-faint mb-1">Fără termen ({noDate.length})</p>
|
||||
<div className="card divide-y divide-border/50">{noDate.map(renderTask)}</div>
|
||||
</div>
|
||||
)}
|
||||
{pending.length === 0 && (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">📞</p>
|
||||
<p className="text-sm text-ink-faint">Niciun follow-up în așteptare. Adaugă cu butonul de sus.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50 opacity-70">
|
||||
{done.slice(0, 20).map((t) => (
|
||||
<div key={t.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="w-5 h-5 shrink-0 rounded-full bg-signal-ok/20 flex items-center justify-center text-[10px] text-signal-ok">✓</div>
|
||||
<p className="flex-1 text-sm text-ink-faint line-through">{t.title}</p>
|
||||
{t.dueDate && (
|
||||
<span className="text-[10px] text-ink-faint">
|
||||
{new Date(t.dueDate).toLocaleDateString('ro-RO', { day: '2-digit', month: 'short' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{done.length === 0 && (
|
||||
<div className="p-8 text-center">
|
||||
<p className="text-sm text-ink-faint">Niciun follow-up finalizat.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue