feat(04-04): slug resolution + multi-project client dashboard + slug edit
- validate-slug API route: resolves clients.slug for Edge middleware - proxy.ts: slug-first resolution (D-06) — tries slug then falls back to token - client-view.ts: complete rewrite — getClientWithProjectsByToken + getProjectView - No quote_items, no payment amounts in client API (CLAUDE.md security invariants) - client/[token]/page.tsx: multi-project dashboard — 1 project = direct view, 2+ projects = shadcn Tabs with project names (D-09/D-10) - edit/page.tsx: slug field with link preview + unique constraint error handling - actions.ts: updateClient now persists slug, redirects on success/slug_taken Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,12 @@ const clientSchema = z.object({
|
||||
name: z.string().min(1, "Nome richiesto"),
|
||||
brand_name: z.string().min(1, "Brand name richiesto"),
|
||||
brief: z.string(),
|
||||
slug: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]{3,50}$/, "Slug non valido (es. mario-rossi, min 3 max 50 caratteri)")
|
||||
.optional()
|
||||
.or(z.literal(""))
|
||||
.transform((v) => (v === "" || v === undefined ? null : v)),
|
||||
});
|
||||
|
||||
export async function updateClient(clientId: string, formData: FormData) {
|
||||
@@ -54,11 +60,21 @@ export async function updateClient(clientId: string, formData: FormData) {
|
||||
name: formData.get("name"),
|
||||
brand_name: formData.get("brand_name"),
|
||||
brief: formData.get("brief") ?? "",
|
||||
slug: formData.get("slug") ?? "",
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId));
|
||||
try {
|
||||
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId));
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("unique") || msg.includes("duplicate")) {
|
||||
redirect(`/admin/clients/${clientId}/edit?error=slug_taken`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
revalidatePath("/admin");
|
||||
redirect(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function deleteClient(clientId: string) {
|
||||
|
||||
@@ -11,10 +11,13 @@ import Link from "next/link";
|
||||
|
||||
export default async function EditClientPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { error } = await searchParams;
|
||||
const [client] = await db.select().from(clients).where(eq(clients.id, id)).limit(1);
|
||||
if (!client) notFound();
|
||||
|
||||
@@ -31,6 +34,12 @@ export default async function EditClientPage({
|
||||
|
||||
<h1 className="text-xl font-bold text-[#1a1a1a] mb-6">Modifica cliente</h1>
|
||||
|
||||
{error === "slug_taken" && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
|
||||
Slug già in uso da un altro cliente. Scegline uno diverso.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
@@ -64,6 +73,27 @@ export default async function EditClientPage({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="slug">Slug personalizzato (opzionale)</Label>
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Solo lettere minuscole, numeri e trattini (es. mario-rossi). Min 3, max 50 caratteri.
|
||||
</p>
|
||||
<Input
|
||||
id="slug"
|
||||
name="slug"
|
||||
type="text"
|
||||
defaultValue={client.slug ?? ""}
|
||||
pattern="[a-z0-9-]{3,50}"
|
||||
placeholder="mario-rossi"
|
||||
/>
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Link cliente:{" "}
|
||||
<span className="font-mono text-[#1a1a1a]">
|
||||
/client/{client.slug || client.token}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="submit">Salva modifiche</Button>
|
||||
<Button asChild variant="ghost">
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { clients } from "@/db/schema";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const slug = request.nextUrl.searchParams.get("slug");
|
||||
|
||||
if (!slug) {
|
||||
return NextResponse.json({ valid: false }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return NextResponse.json({ valid: false }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ valid: true }, { status: 200 });
|
||||
} catch {
|
||||
return NextResponse.json({ valid: false }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+155
-37
@@ -1,16 +1,73 @@
|
||||
import { cache } from 'react';
|
||||
import { getClientView } from '@/lib/client-view';
|
||||
import { ClientDashboard } from '@/components/client-dashboard';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { db } from '@/db';
|
||||
import { comments } from '@/db/schema';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import type { Comment } from '@/db/schema';
|
||||
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; // Always revalidate — comments and approvals must be fresh
|
||||
export const revalidate = 0;
|
||||
|
||||
// React cache deduplicates DB calls within the same render
|
||||
const getCachedClientView = cache(getClientView);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -18,15 +75,11 @@ export async function generateMetadata({
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
const view = await getCachedClientView(token);
|
||||
|
||||
if (!view) {
|
||||
return { title: 'Not Found' };
|
||||
}
|
||||
|
||||
const clientData = await getCachedClientData(token);
|
||||
if (!clientData) return { title: "Not Found" };
|
||||
return {
|
||||
title: `${view.client.brand_name} — Stato Progetto | iamcavalli`,
|
||||
description: view.client.brief || 'Dashboard stato progetto',
|
||||
title: `${clientData.client.brand_name} — Stato Progetto | iamcavalli`,
|
||||
description: "Dashboard stato progetto",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,26 +89,91 @@ export default async function ClientPage({
|
||||
params: Promise<{ token: string }>;
|
||||
}) {
|
||||
const { token } = await params;
|
||||
const view = await getCachedClientView(token);
|
||||
|
||||
if (!view) {
|
||||
notFound();
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch comments: tasks, deliverables, and general (entity_id = clientId)
|
||||
const allTaskIds = view.phases.flatMap((p) => p.tasks.map((t) => t.id));
|
||||
const allDeliverableIds = view.phases.flatMap((p) =>
|
||||
p.tasks.flatMap((t) => t.deliverables.map((d) => d.id))
|
||||
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={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={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>
|
||||
);
|
||||
const allEntityIds = [view.client.id, ...allTaskIds, ...allDeliverableIds];
|
||||
|
||||
const allComments: Comment[] =
|
||||
allEntityIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(inArray(comments.entity_id, allEntityIds))
|
||||
: [];
|
||||
|
||||
return <ClientDashboard view={view} token={token} comments={allComments} />;
|
||||
}
|
||||
Reference in New Issue
Block a user