import { cache } from "react"; import { notFound } from "next/navigation"; import { getClientWithProjectsByToken, getProjectView, type ProjectView, type ClientView, type ClientProjectSummary, } from "@/lib/client-view"; import { getClientGate } from "@/lib/client-gate"; import { ClientDashboard } from "@/components/client-dashboard"; import { OtpGate } from "@/components/client/OtpGate"; import { PreviewBanner } from "@/components/client/PreviewBanner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { normalizeTaskStatus } from "@/lib/task-status"; import { getAdminIdentity } from "@/lib/settings"; export const revalidate = 0; const getCachedClientData = cache(getClientWithProjectsByToken); // Adapter: converts ProjectView + client info into ClientView shape for ClientDashboard reuse function projectViewToClientView( client: ClientProjectSummary["client"], view: ProjectView ): ClientView { return { client: { id: view.project.client_id, name: client.name, brand_name: client.brand_name, brief: "", accepted_total: view.project.accepted_total, }, phases: view.phases.map((phase) => ({ id: phase.id, title: phase.title, status: phase.status as "upcoming" | "active" | "done", sort_order: phase.sort_order, tasks: phase.tasks.map((task) => ({ id: task.id, title: task.title, description: task.description, status: normalizeTaskStatus(task.status), sort_order: task.sort_order, deliverables: task.deliverables.map((d) => ({ id: d.id, title: d.title, url: d.url, status: d.status as "pending" | "submitted" | "approved", // approved_at is immutable once set — CLAUDE.md constraint LOCKED approved_at: d.approved_at instanceof Date ? d.approved_at.toISOString() : null, })), })), progress_pct: phase.progress_pct, })), payments: view.payments.map((p) => ({ id: p.id, label: p.label, status: p.status as "da_saldare" | "inviata" | "saldato", due_date: p.due_date instanceof Date ? p.due_date.toISOString() : null, paid_at: p.paid_at instanceof Date ? p.paid_at.toISOString() : null, })), documents: view.documents.map((d) => ({ id: d.id, label: d.label, url: d.url, })), notes: view.notes.map((n) => ({ id: n.id, body: n.body, created_at: n.created_at instanceof Date ? n.created_at.toISOString() : String(n.created_at), })), global_progress_pct: view.global_progress_pct, activeOffers: view.activeOffers, transcripts: view.transcripts.map((t) => ({ id: t.id, title: t.title, call_date: t.call_date, content: t.content, created_at: t.created_at instanceof Date ? t.created_at.toISOString() : String(t.created_at), })), }; } export async function generateMetadata({ params, }: { params: Promise<{ token: string }>; }) { const { token } = await params; console.log("[generateMetadata] token:", token); const clientData = await getCachedClientData(token); if (!clientData) return { title: "Not Found" }; return { title: `${clientData.client.brand_name} — Stato Progetto | iamcavalli`, description: "Dashboard stato progetto", }; } /** * Il canale chiesto con `?chat=`, se appartiene a QUESTO progetto. * * La validazione non e' cosmetica: senza, un id qualsiasi nella query aprirebbe * la chat su un canale vuoto — e con piu' progetti a tab, la fase di un progetto * spalancherebbe il pannello anche negli altri. */ function resolveChatChannel( requested: string | undefined, view: ProjectView ): string | null { if (!requested) return null; if (requested === view.project.client_id) return requested; return view.phases.some((p) => p.id === requested) ? requested : null; } export default async function ClientPage({ params, searchParams, }: { params: Promise<{ token: string }>; searchParams: Promise<{ preview?: string; chat?: string }>; }) { const { token } = await params; const { preview: previewParam, chat: chatParam } = await searchParams; // ⚠️ Il gate va PRIMA di ogni query sui dati del progetto: se si interroga il // DB e poi si decide di mostrare il form, i dati sono già nel payload RSC // dell'HTML anche se non compaiono a schermo. Vedi src/lib/client-gate.ts. const { client: identity, session, preview } = await getClientGate(token, { previewRequested: previewParam === "1", }); if (identity && !session && !preview) { return ; } const clientData = await getCachedClientData(token); if (!clientData) notFound(); const { client, projects } = clientData; const banner = preview ? : null; if (projects.length === 0) { return ( <> {banner}

{client.name}

Nessun progetto disponibile al momento.

); } // Come si firma chi risponde in chat. Una lettura sola per pagina, condivisa // da tutti i progetti: è la stessa persona in ogni tab. const admin = await getAdminIdentity(); if (projects.length === 1) { // D-09: single project → direct view without selector const view = await getProjectView(projects[0].id); if (!view) notFound(); return ( <> {banner} ); } // D-10: 2+ projects → tabs with project names const projectViews = await Promise.all(projects.map((p) => getProjectView(p.id))); return (
{banner}
iamcavalli | Client Portal

{client.brand_name}

Area Riservata Protetta
{projects.map((p) => ( {p.name} ))} {projects.map((p, i) => { const view = projectViews[i]; return ( {view ? ( ) : (

Progetto non disponibile.

)}
); })}
); }