feat(CC-092): add Event Tracker page (conference/meetup/workshop, days countdown, prep flag)

This commit is contained in:
admin-valentin 2026-08-02 18:17:10 +00:00
parent eca28e6bac
commit 6aa653ab4a

View file

@ -0,0 +1,214 @@
'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; tags: string[]; targetDate: string | null; createdAt: string; }
const EVENT_TAGS = ['eveniment','event','conferinta','conference','workshop','meetup','webinar','summit','hackathon','networking'];
const EVENT_TYPES: Record<string, string> = {
conferinta:'🎤', conference:'🎤', workshop:'🛠️', meetup:'🤝',
webinar:'💻', summit:'🏔️', hackathon:'⚡', networking:'🌐',
eveniment:'📅', event:'📅',
};
function daysUntil(iso: string | null): number | null {
if (!iso) return null;
return Math.ceil((new Date(iso).getTime() - Date.now()) / 86_400_000);
}
export default function EventsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [tab, setTab] = useState<'upcoming' | 'past'>('upcoming');
const [showAdd, setShowAdd] = useState(false);
const [form, setForm] = useState({ title: '', type: 'conferinta', date: '', location: '', url: '', prep: false });
const { data: tasks = [], isLoading } = useQuery({
queryKey: ['events', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const events = useMemo(() =>
tasks.filter((t) => t.tags.some((tag) => EVENT_TAGS.includes(tag.toLowerCase()))),
[tasks]);
const upcoming = useMemo(() =>
events
.filter((e) => !e.targetDate || new Date(e.targetDate) >= new Date(new Date().toDateString()))
.filter((e) => e.status !== 'cancelled')
.sort((a, b) => (a.targetDate ?? '9999').localeCompare(b.targetDate ?? '9999')),
[events]);
const past = useMemo(() =>
events
.filter((e) => e.targetDate && new Date(e.targetDate) < new Date(new Date().toDateString()))
.sort((a, b) => b.targetDate!.localeCompare(a.targetDate!)),
[events]);
const urgent = upcoming.filter((e) => {
const d = daysUntil(e.targetDate);
return d !== null && d <= 14;
});
const addMut = useMutation({
mutationFn: () => apiFetch('/v1/tasks', { tenantId, method: 'POST', body: {
title: form.title,
description: [form.location && `📍 ${form.location}`, form.url && `🔗 ${form.url}`].filter(Boolean).join('\n') || undefined,
tags: ['eveniment', form.type, ...(form.prep ? ['prep-needed'] : [])],
status: 'todo', priority: 'normal',
targetDate: form.date || undefined,
}}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['events', tenantId] });
setShowAdd(false);
setForm({ title: '', type: 'conferinta', date: '', location: '', url: '', prep: false });
},
});
const patchMut = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) =>
apiFetch(`/v1/tasks/${id}`, { tenantId, method: 'PATCH', body: { status } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['events', tenantId] }),
});
function eventType(t: Task): string {
return t.tags.find((tag) => EVENT_TYPES[tag.toLowerCase()]) ?? 'event';
}
const displayList = tab === 'upcoming' ? upcoming : past;
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">Event Tracker</h1>
<p className="text-sm text-ink-faint mt-1">{upcoming.length} viitoare · {past.length} trecute</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">
+ Eveniment
</button>
</div>
{urgent.length > 0 && (
<div className="card p-4 border-warn/30 bg-warn/5 space-y-2">
<p className="text-xs font-semibold text-warn"> În următoarele 14 zile</p>
{urgent.map((e) => {
const d = daysUntil(e.targetDate);
return (
<div key={e.id} className="flex items-center justify-between">
<p className="text-sm text-ink">{EVENT_TYPES[eventType(e)] ?? '📅'} {e.title}</p>
<span className={`text-xs font-bold shrink-0 ${d !== null && d <= 3 ? 'text-signal-danger' : 'text-warn'}`}>
{d === 0 ? 'Azi!' : d === 1 ? 'Mâine' : `${d} zile`}
</span>
</div>
);
})}
</div>
)}
{showAdd && (
<div className="card p-5 space-y-3">
<p className="text-sm font-semibold text-ink">Eveniment nou</p>
<input placeholder="Titlu eveniment*" 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" />
<div className="grid gap-3 sm:grid-cols-2">
<select value={form.type} onChange={(e) => setForm((p) => ({ ...p, type: 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">
{Object.entries(EVENT_TYPES).filter(([k]) => !['eveniment','event'].includes(k)).map(([k, icon]) => (
<option key={k} value={k}>{icon} {k}</option>
))}
</select>
<input type="date" value={form.date}
onChange={(e) => setForm((p) => ({ ...p, date: 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" />
<input placeholder="Locație (ex: Berlin sau Online)" value={form.location}
onChange={(e) => setForm((p) => ({ ...p, location: 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" />
<input placeholder="URL eveniment (opțional)" value={form.url}
onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))}
className="rounded-lg border bg-background px-3 py-2 text-sm font-mono text-xs text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<label className="flex items-center gap-2 text-sm text-ink cursor-pointer">
<input type="checkbox" checked={form.prep} onChange={(e) => setForm((p) => ({ ...p, prep: e.target.checked }))}
className="rounded" />
Necesită pregătire / prezentare
</label>
<div className="flex gap-2">
<button onClick={() => addMut.mutate()} disabled={!form.title || addMut.isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white disabled:opacity-50">
{addMut.isPending ? '…' : 'Adaugă'}
</button>
<button onClick={() => setShowAdd(false)} className="rounded-lg border px-4 py-2 text-sm text-ink">Anulează</button>
</div>
</div>
)}
{/* Tabs */}
<div className="flex gap-1 border-b border-border/50">
{(['upcoming', 'past'] as const).map((t) => (
<button key={t} onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${tab === t ? 'border-primary text-primary' : 'border-transparent text-ink-faint hover:text-ink'}`}>
{t === 'upcoming' ? `Viitoare (${upcoming.length})` : `Trecute (${past.length})`}
</button>
))}
</div>
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se încarcă</div>
) : displayList.length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-3xl">🗓</p>
<p className="text-sm text-ink-faint">{tab === 'upcoming' ? 'Niciun eveniment viitor.' : 'Niciun eveniment trecut.'}</p>
</div>
) : (
<div className="card divide-y divide-border/50">
{displayList.map((e) => {
const d = daysUntil(e.targetDate);
const icon = EVENT_TYPES[eventType(e)] ?? '📅';
const needsPrep = e.tags.includes('prep-needed');
const [loc, url] = (e.description ?? '').split('\n');
return (
<div key={e.id} className="flex items-start gap-3 p-4">
<span className="text-xl shrink-0 mt-0.5">{icon}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-ink">{e.title}</p>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
{e.targetDate && <p className="text-[10px] text-ink-faint">📅 {new Date(e.targetDate).toLocaleDateString('ro-RO', { weekday:'short', day:'numeric', month:'short' })}</p>}
{loc?.startsWith('📍') && <p className="text-[10px] text-ink-faint">{loc}</p>}
{needsPrep && <span className="text-[10px] text-warn font-medium">🛠 Prep needed</span>}
</div>
{url?.startsWith('🔗') && (
<p className="text-[10px] text-primary truncate mt-0.5">{url.replace('🔗 ', '')}</p>
)}
</div>
<div className="text-right shrink-0 space-y-1">
{d !== null && tab === 'upcoming' && (
<p className={`text-xs font-bold ${d <= 3 ? 'text-signal-danger' : d <= 14 ? 'text-warn' : 'text-ink-faint'}`}>
{d === 0 ? 'Azi' : d === 1 ? 'Mâine' : `${d}z`}
</p>
)}
{tab === 'past' && e.status !== 'completed' && (
<button onClick={() => patchMut.mutate({ id: e.id, status: 'completed' })}
className="rounded border px-2 py-0.5 text-[10px] text-signal-ok border-signal-ok/30 hover:bg-signal-ok/10">
Participat
</button>
)}
{tab === 'past' && e.status === 'completed' && (
<span className="text-[10px] text-signal-ok"> Participat</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}