feat(CC-092): add Event Tracker page (conference/meetup/workshop, days countdown, prep flag)
This commit is contained in:
parent
eca28e6bac
commit
6aa653ab4a
1 changed files with 214 additions and 0 deletions
214
src/app/dashboard/events/page.tsx
Normal file
214
src/app/dashboard/events/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue