Files
clienthub/src/app/client/[token]/page.tsx
T
simone 186bb9ea19 feat: payments plans, phase auto-cascade, transcripts, manual timer
- Pagamenti: totale ereditato dalla somma accepted_total delle offerte
  attive (con override) + selettore schema rate 1/2/3 step; nuova colonna
  payments.percent per rescalare gli importi preservando label/stato
- Fasi & Task: recomputePhaseStatus auto-cascade (task -> fase) su
  updateTaskStatus/addTask, fix UI stale, etichette Da iniziare/In corso/Completata
- Pagina pubblica: colori fasi (grigio/blu/#1A463C) + fasi collassabili
- Transcript del cliente visibili in tab Documenti admin e dashboard pubblica
- Timer: inserimento manuale (+30/+60, minuti liberi, data) + elimina entry
- CLAUDE.md: procedura Deploy & DB Access; push su main automatico

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:38:56 +02:00

188 lines
6.0 KiB
TypeScript

import { cache } from "react";
import { notFound } from "next/navigation";
import {
getClientWithProjectsByToken,
getProjectView,
type ProjectView,
type ClientView,
type ClientProjectSummary,
} from "@/lib/client-view";
import { ClientDashboard } from "@/components/client-dashboard";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Comment } from "@/db/schema";
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: task.status as "todo" | "in_progress" | "done",
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",
})),
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",
};
}
export default async function ClientPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
const { token } = await params;
const clientData = await getCachedClientData(token);
if (!clientData) notFound();
const { client, projects } = clientData;
if (projects.length === 0) {
return (
<div className="min-h-screen bg-[#f9f9f9] flex items-center justify-center">
<div className="text-center">
<h1 className="text-xl font-bold text-[#1a1a1a]">{client.name}</h1>
<p className="text-sm text-[#71717a] mt-2">Nessun progetto disponibile al momento.</p>
</div>
</div>
);
}
if (projects.length === 1) {
// D-09: single project → direct view without selector
const view = await getProjectView(projects[0].id);
if (!view) notFound();
return (
<ClientDashboard
view={projectViewToClientView(client, view)}
token={client.token}
comments={view.comments as unknown as Comment[]}
/>
);
}
// D-10: 2+ projects → tabs with project names
const projectViews = await Promise.all(projects.map((p) => getProjectView(p.id)));
return (
<div className="min-h-screen bg-white">
<header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-5">
<div className="flex items-center">
<span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0">
iamcavalli
</span>
<h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight">
{client.brand_name}
</h1>
<div className="w-28 shrink-0" />
</div>
</div>
</header>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
<Tabs defaultValue={projects[0].id} className="w-full">
<TabsList className="mb-6">
{projects.map((p) => (
<TabsTrigger key={p.id} value={p.id}>
{p.name}
</TabsTrigger>
))}
</TabsList>
{projects.map((p, i) => {
const view = projectViews[i];
return (
<TabsContent key={p.id} value={p.id}>
{view ? (
<ClientDashboard
view={projectViewToClientView(client, view)}
token={client.token}
comments={view.comments as unknown as Comment[]}
/>
) : (
<p className="text-sm text-[#71717a]">Progetto non disponibile.</p>
)}
</TabsContent>
);
})}
</Tabs>
</div>
<footer className="border-t border-[#e5e5e5] bg-[#f9f9f9] mt-10">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
<p className="text-xs text-[#999999] text-center">
Questa è la tua dashboard privata non condividere il link.
</p>
</div>
</footer>
</div>
);
}