feat(CC-078): add Commitments & Promises page (tasks with commitment/promise tags)
This commit is contained in:
parent
7c3e79e4b7
commit
84dcdd5880
1 changed files with 185 additions and 0 deletions
185
src/app/dashboard/promises/page.tsx
Normal file
185
src/app/dashboard/promises/page.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
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; dueDate: string | null; tags: string[]; createdAt: string;
|
||||
}
|
||||
|
||||
const PROMISE_TAGS = ['commitment', 'promise', 'angajament', 'promisiune', 'obligatie', 'obligation'];
|
||||
|
||||
function isPromise(task: Task) {
|
||||
const tags = task.tags.map((t) => t.toLowerCase());
|
||||
const title = task.title.toLowerCase();
|
||||
return tags.some((t) => PROMISE_TAGS.includes(t)) ||
|
||||
title.includes('promit') || title.includes('ma angajez') || title.includes('commit');
|
||||
}
|
||||
|
||||
function daysUntil(d: string) { return Math.ceil((new Date(d).getTime() - Date.now()) / 86400_000); }
|
||||
|
||||
const PRIORITY_CLS: Record<string, string> = {
|
||||
urgent: 'text-signal-danger',
|
||||
high: 'text-warn',
|
||||
medium: 'text-ink',
|
||||
low: 'text-ink-faint',
|
||||
};
|
||||
|
||||
export default function PromisesPage() {
|
||||
const { activeTenant } = useSession();
|
||||
const tenantId = activeTenant?.tenantId ?? '';
|
||||
const qc = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<'active' | 'kept' | 'all'>('active');
|
||||
const [form, setForm] = useState({ title: '', description: '', dueDate: '', priority: 'high' });
|
||||
|
||||
const { data: allTasks = [], isLoading } = useQuery({
|
||||
queryKey: ['promises-tasks', tenantId],
|
||||
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
|
||||
enabled: Boolean(tenantId), staleTime: 30_000,
|
||||
});
|
||||
|
||||
const promises = useMemo(() => allTasks.filter(isPromise), [allTasks]);
|
||||
|
||||
const active = promises.filter((t) => t.status !== 'completed' && t.status !== 'cancelled');
|
||||
const kept = promises.filter((t) => t.status === 'completed');
|
||||
const overdue = active.filter((t) => t.dueDate && daysUntil(t.dueDate) < 0);
|
||||
|
||||
const shown = statusFilter === 'active' ? active : statusFilter === 'kept' ? kept : promises;
|
||||
|
||||
const completeMut = useMutation({
|
||||
mutationFn: (id: string) => apiFetch(`/v1/tasks/${id}`, { tenantId, method: 'PATCH', body: { status: 'completed' } }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['promises-tasks', tenantId] }),
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: () => apiFetch('/v1/tasks', { tenantId, method: 'POST', body: {
|
||||
...form, tags: ['commitment', 'promise'], dueDate: form.dueDate || null,
|
||||
}}),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['promises-tasks', tenantId] }); setShowCreate(false); setForm({ title: '', description: '', dueDate: '', priority: 'high' }); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl 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">Angajamente & Promisiuni</h1>
|
||||
<p className="text-sm text-ink-faint mt-1">
|
||||
{isLoading ? 'Se încarcă…' : `${active.length} active · ${kept.length} onorate`}
|
||||
{overdue.length > 0 && <span className="ml-1 text-signal-danger font-semibold">· {overdue.length} depășite</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90">
|
||||
+ Angajament
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{overdue.length > 0 && (
|
||||
<div className="card p-4 border-signal-danger/30 bg-signal-danger/5 space-y-2">
|
||||
<p className="text-sm font-semibold text-signal-danger">⚠ Angajamente depășite ({overdue.length})</p>
|
||||
{overdue.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between text-xs">
|
||||
<span className="text-ink">{t.title}</span>
|
||||
<span className="text-signal-danger">
|
||||
{t.dueDate ? `${Math.abs(daysUntil(t.dueDate))} zile întârziere` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<p className="text-sm font-semibold text-ink">Angajament nou</p>
|
||||
<input placeholder="Ce ai promis / la ce te-ai angajat?" value={form.title}
|
||||
onChange={(e) => setForm((p) => ({ ...p, title: 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" />
|
||||
<textarea placeholder="Față de cine? Context?" value={form.description}
|
||||
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} rows={2}
|
||||
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 className="grid gap-3 sm:grid-cols-2">
|
||||
<input type="date" value={form.dueDate} onChange={(e) => setForm((p) => ({ ...p, dueDate: 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" />
|
||||
<select value={form.priority} onChange={(e) => setForm((p) => ({ ...p, priority: 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">
|
||||
<option value="urgent">Urgent</option>
|
||||
<option value="high">Ridicat</option>
|
||||
<option value="medium">Mediu</option>
|
||||
<option value="low">Scăzut</option>
|
||||
</select>
|
||||
</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={() => setShowCreate(false)} className="rounded-lg border px-4 py-2 text-sm text-ink hover:bg-muted/50">Anulează</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex gap-1 border-b border-border/50">
|
||||
{([['active', `Active (${active.length})`], ['kept', `Onorate (${kept.length})`], ['all', `Toate (${promises.length})`]] as const).map(([key, label]) => (
|
||||
<button key={key} onClick={() => setStatusFilter(key as typeof statusFilter)}
|
||||
className={`pb-2 px-3 text-sm font-medium border-b-2 transition-colors ${statusFilter === key ? 'border-primary text-ink' : 'border-transparent text-ink-faint hover:text-ink'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
||||
) : shown.length === 0 ? (
|
||||
<div className="card p-8 text-center space-y-2">
|
||||
<p className="text-2xl">🤝</p>
|
||||
<p className="text-sm text-ink-faint">
|
||||
{promises.length === 0
|
||||
? 'Niciun angajament. Taskurile cu tagul "commitment" sau "promise" apar aici.'
|
||||
: 'Niciun angajament în această categorie.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card divide-y divide-border/50">
|
||||
{shown.map((task) => {
|
||||
const days = task.dueDate ? daysUntil(task.dueDate) : null;
|
||||
const isOverdue = days !== null && days < 0;
|
||||
const isToday = days === 0;
|
||||
return (
|
||||
<div key={task.id} className="flex items-start gap-3 p-4">
|
||||
<button
|
||||
onClick={() => completeMut.mutate(task.id)}
|
||||
disabled={task.status === 'completed' || completeMut.isPending}
|
||||
className={`mt-0.5 w-5 h-5 rounded-full border-2 shrink-0 flex items-center justify-center transition-colors ${task.status === 'completed' ? 'bg-signal-ok border-signal-ok text-white' : 'border-border hover:border-primary'}`}>
|
||||
{task.status === 'completed' && <span className="text-[10px]">✓</span>}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${task.status === 'completed' ? 'line-through text-ink-faint' : 'text-ink'}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
{task.description && <p className="text-xs text-ink-faint mt-0.5 line-clamp-1">{task.description}</p>}
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{task.priority && (
|
||||
<span className={`text-[10px] font-semibold ${PRIORITY_CLS[task.priority] ?? 'text-ink-faint'}`}>
|
||||
{task.priority.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{task.dueDate && (
|
||||
<span className={`text-[10px] ${isOverdue ? 'text-signal-danger font-semibold' : isToday ? 'text-warn font-semibold' : 'text-ink-faint'}`}>
|
||||
{isOverdue ? `${Math.abs(days!)} zile depășit` : isToday ? 'azi' : `${days} zile`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue