fe767899b9
Due cose che il cliente non poteva sapere guardando il portale.
**Task cancellati.** Fino a ieri un'attività tolta dal lavoro poteva solo
sparire (cancellata dal DB) o restare lì a far finta di essere ancora da
fare. Ora `cancelled` è il quinto stato: X dentro il cerchio, titolo barrato,
pill "Cancellata" — l'unico stato chiuso che la porta, perché "fatto" e
"cancellato" sono entrambi barrati e confonderli significa credere consegnato
qualcosa che non esiste.
Esce da tutti i denominatori — fase, progetto, board di consegna, riepilogo
admin — con un unico `countsTowardProgress()` in task-status.ts invece di
cinque `!== "cancelled"` sparsi. Contarlo terrebbe la fase sotto il 100% per
un lavoro che nessuno farà; contarlo come fatto racconterebbe una consegna
mai avvenuta. `recomputePhaseStatus` lo ignora allo stesso modo: senza questo,
cancellare l'ultima voce lasciava la fase "in corso" per sempre.
Nel kanban cliente la colonna compare solo se ha dentro qualcosa — le quattro
che raccontano il lavoro si tengono la larghezza — ma mai se è piena, quindi
nessun task sparisce dalla board. Nell'admin la colonna c'è sempre: è così che
si cancella un task, trascinandocelo.
**Date dei pagamenti** (migration 0023, già applicata in produzione).
`payments` sapeva solo quando una rata era stata incassata, mai quando era
attesa: il portale non poteva rispondere a "quando devo pagare?" e non c'era
niente su cui agganciare il promemoria email. Ora c'è `due_date`, nullable —
una rata senza data concordata è normale, e il portale la mostra solo se c'è.
Il cliente vede in cima al box la prossima scadenza col conto alla rovescia
("tra 12 giorni", "domani", "scaduto da 3 giorni" in rosso), e su ogni riga
la data: "Scade il…" se aperta, "Pagato il…" se saldata. Nessun importo per
riga — LOCKED #2 resta dov'era, le date non sono cifre.
I giorni si contano in `src/lib/payment-dates.ts`, sui giorni civili a Roma e
non sugli istanti: il container gira a UTC e "manca una settimana" non deve
cambiare risposta a seconda del fuso. È lo stesso modulo che userà il
promemoria email, così la mail e il portale non si contraddicono.
Lato admin ogni rata ha il campo Scadenza, e "Incassato nel mese" diventa
"Incassato il" — precisione al giorno, che le analytics (raggruppate per mese)
non notano. Il warning sul cambio schema ora conta anche le scadenze: una rata
"da saldare" con una data è già sotto gli occhi del cliente, e sparirebbe in
silenzio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
8.1 KiB
TypeScript
232 lines
8.1 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 { 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",
|
|
};
|
|
}
|
|
|
|
export default async function ClientPage({
|
|
params,
|
|
searchParams,
|
|
}: {
|
|
params: Promise<{ token: string }>;
|
|
searchParams: Promise<{ preview?: string }>;
|
|
}) {
|
|
const { token } = await params;
|
|
const { preview: previewParam } = 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 <OtpGate token={token} brandName={identity.brand_name} />;
|
|
}
|
|
|
|
const clientData = await getCachedClientData(token);
|
|
if (!clientData) notFound();
|
|
|
|
const { client, projects } = clientData;
|
|
|
|
const banner = preview ? <PreviewBanner brandName={client.brand_name} /> : null;
|
|
|
|
if (projects.length === 0) {
|
|
return (
|
|
<>
|
|
{banner}
|
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
|
<div className="text-center">
|
|
<h1 className="text-xl font-bold text-foreground">{client.name}</h1>
|
|
<p className="text-sm text-muted-foreground mt-2">Nessun progetto disponibile al momento.</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 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}
|
|
<ClientDashboard
|
|
view={projectViewToClientView(client, view)}
|
|
token={client.token}
|
|
chat={{
|
|
projectId: view.project.id,
|
|
messages: view.comments,
|
|
reads: view.channel_reads,
|
|
adminName: admin.name,
|
|
adminAvatarUrl: admin.avatarUrl,
|
|
}}
|
|
preview={preview}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 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-background">
|
|
{banner}
|
|
<header className="sticky top-0 z-50 flex flex-col items-center gap-4 border-b border-border-light bg-card px-6 py-5 shadow-card md:flex-row md:justify-between md:px-8">
|
|
<div className="flex w-full items-center gap-3 md:w-auto">
|
|
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">iamcavalli</span>
|
|
<span className="text-border">|</span>
|
|
<span className="text-xs font-medium text-muted-foreground">Client Portal</span>
|
|
</div>
|
|
<div className="text-center">
|
|
<h1 className="text-xl font-bold tracking-tight text-foreground">{client.brand_name}</h1>
|
|
</div>
|
|
<div className="hidden items-center gap-2 rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-[11px] text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-400 md:flex">
|
|
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
|
Area Riservata Protetta
|
|
</div>
|
|
</header>
|
|
|
|
<div className="max-w-[1400px] 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}
|
|
chat={{
|
|
projectId: view.project.id,
|
|
messages: view.comments,
|
|
reads: view.channel_reads,
|
|
adminName: admin.name,
|
|
adminAvatarUrl: admin.avatarUrl,
|
|
}}
|
|
embedded
|
|
preview={preview}
|
|
/>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">Progetto non disponibile.</p>
|
|
)}
|
|
</TabsContent>
|
|
);
|
|
})}
|
|
</Tabs>
|
|
</div>
|
|
|
|
<footer className="mt-10 py-10 text-center text-xs text-muted-foreground">
|
|
Questa è la tua dashboard privata — non condividere il link.
|
|
</footer>
|
|
</div>
|
|
);
|
|
} |