From 5d785a1c1c8ba68e27101c34d7ff2c651542aae7 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Fri, 22 May 2026 11:31:32 +0200 Subject: [PATCH] feat(04-04): slug resolution + multi-project client dashboard + slug edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../04-02-SUMMARY.md | 50 +++ .../04-03-SUMMARY.md | 52 +++ src/app/admin/clients/[id]/actions.ts | 18 +- src/app/admin/clients/[id]/edit/page.tsx | 30 ++ src/app/api/internal/validate-slug/route.ts | 28 ++ src/app/client/[token]/page.tsx | 192 ++++++++-- src/lib/client-view.ts | 342 +++++++++++------- src/proxy.ts | 25 +- 8 files changed, 562 insertions(+), 175 deletions(-) create mode 100644 .planning/phases/04-progetti-multi-project/04-02-SUMMARY.md create mode 100644 .planning/phases/04-progetti-multi-project/04-03-SUMMARY.md create mode 100644 src/app/api/internal/validate-slug/route.ts diff --git a/.planning/phases/04-progetti-multi-project/04-02-SUMMARY.md b/.planning/phases/04-progetti-multi-project/04-02-SUMMARY.md new file mode 100644 index 0000000..d4f4fbe --- /dev/null +++ b/.planning/phases/04-progetti-multi-project/04-02-SUMMARY.md @@ -0,0 +1,50 @@ +--- +plan: "04-02" +phase: "04-progetti-multi-project" +status: complete +completed_at: "2026-05-22" +--- + +# Summary: 04-02 — Admin Projects List + Client Detail Project Cards + +## What Was Built + +**NavBar** — added Progetti (`/admin/projects`) and Impostazioni (`/admin/impostazioni`) links in the correct order: Clienti → Progetti → Statistiche → Catalogo → Impostazioni. + +**ProjectRow** (`src/components/admin/ProjectRow.tsx`) — new table row component cloned from ClientRow, adapted for projects. Columns: Nome+Cliente, Valore, Acconto badge, Saldo badge, TimerCell, €/h. The €/h is computed inline: `accepted_total / (totalTrackedSeconds / 3600)`, showing `—` when no hours tracked. + +**`/admin/projects`** (`src/app/admin/projects/page.tsx`) — table listing all projects via `getAllProjectsWithPayments()`. Empty state shows helpful message. + +**`/admin/projects/new`** (`src/app/admin/projects/new/page.tsx`) — creation form with client `` — React controlled default, works with server components. +- **No `requireAdmin` import**: all server actions files define it locally with the same `getServerSession(authOptions)` pattern — consistent with existing codebase. +- **`clientProjects` query extended**: instead of a separate parallel query for projectBrands/ltv, the existing `clientProjects` fetch was extended to also select `name`, `accepted_total`, `archived` — avoids an extra DB round-trip. + +## Files Modified/Created + +| File | Action | +|------|--------| +| `src/components/admin/NavBar.tsx` | Updated — added 2 links | +| `src/components/admin/ProjectRow.tsx` | Created | +| `src/app/admin/projects/page.tsx` | Created | +| `src/app/admin/projects/new/page.tsx` | Created | +| `src/app/admin/projects/project-actions.ts` | Created | +| `src/app/admin/clients/[id]/page.tsx` | Rewritten | +| `src/app/admin/page.tsx` | Updated — column header | +| `src/lib/admin-queries.ts` | Updated — projectBrands + ltv | +| `src/components/admin/ClientRow.tsx` | Updated — projectBrands + ltv | \ No newline at end of file diff --git a/.planning/phases/04-progetti-multi-project/04-03-SUMMARY.md b/.planning/phases/04-progetti-multi-project/04-03-SUMMARY.md new file mode 100644 index 0000000..93f1467 --- /dev/null +++ b/.planning/phases/04-progetti-multi-project/04-03-SUMMARY.md @@ -0,0 +1,52 @@ +--- +plan: "04-03" +phase: "04-progetti-multi-project" +status: complete +completed_at: "2026-05-22" +--- + +# Summary: 04-03 — Project Workspace + Timer Analytics + Settings + +## What Was Built + +**`/admin/projects/[id]`** — full project workspace with 7 tabs: Fasi & Task, Pagamenti, Documenti, Note, Commenti, Preventivo, Timer. Reuses all existing tab components (PhasesTab, PaymentsTab, DocumentsTab, CommentsTab, QuoteTab) unchanged by passing `projectId` as `clientId`. + +**`ProfitabilityCard`** (`src/components/admin/ProfitabilityCard.tsx`) — pure display component (no `"use client"`). Shows: ore lavorate, importo accettato, €/h reale, €/h target, costo ideale, delta (verde = guadagno, rosso = perdita). Delta shown only when both hours > 0 and accepted > 0. + +**`TimerTab`** (`src/components/admin/tabs/TimerTab.tsx`) — client component wrapping TimerCell + ProfitabilityCard. Passes `projectId` as `clientId` to TimerCell (prop name is legacy; the underlying startTimer already uses project_id). + +**`/admin/impostazioni`** — settings page with target_hourly_rate form. Inline server action calls `updateSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE, ...)`. Default value 50€/h from `getTargetHourlyRate()`. + +## Key Architectural Decision: resolveEntity() + +The tab components (PhasesTab, PaymentsTab, etc.) hardcode imports from `@/app/admin/clients/[id]/actions`. Rather than duplicating tab components or injecting actions as props, added `resolveEntity(id)` helper to both `actions.ts` and `quote-actions.ts`: + +```typescript +async function resolveEntity(id: string): Promise<{ projectId: string | null; path: string }> { + // Checks if id is a projects.id directly (one PK lookup) + // Falls back to client_id lookup if not found + // Returns correct revalidatePath for either context +} +``` + +This makes all existing tab actions work transparently with both clientId and projectId. Zero changes to tab components. + +**`updateAcceptedTotal` in `actions.ts`** — special case: detects project vs client context and updates the correct table (`projects.accepted_total` vs `clients.accepted_total`), with per-project payment stub splitting. + +**`timer-actions.ts`** — added `revalidatePath("/admin/projects")` and `revalidatePath(\`/admin/projects/${projectId}\`)` so the project list and workspace refresh after timer start/stop. + +## Files Changed + +| File | Action | +|------|--------| +| `src/app/admin/clients/[id]/actions.ts` | Updated — added resolveEntity(), all functions now work with projectId | +| `src/app/admin/clients/[id]/quote-actions.ts` | Updated — same resolveEntity pattern | +| `src/app/admin/timer-actions.ts` | Updated — added /admin/projects revalidation | +| `src/components/admin/ProfitabilityCard.tsx` | Created | +| `src/components/admin/tabs/TimerTab.tsx` | Created | +| `src/app/admin/projects/[id]/page.tsx` | Created | +| `src/app/admin/impostazioni/page.tsx` | Created | + +## Notes Tab + +No `NotesTab` component exists — notes rendered inline in the project workspace page. Simple read-only display (no add/edit/delete — consistent with how notes appear in the client dashboard). \ No newline at end of file diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index 94850f5..2acc909 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -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) { diff --git a/src/app/admin/clients/[id]/edit/page.tsx b/src/app/admin/clients/[id]/edit/page.tsx index 2618b7c..0cda1bb 100644 --- a/src/app/admin/clients/[id]/edit/page.tsx +++ b/src/app/admin/clients/[id]/edit/page.tsx @@ -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({

Modifica cliente

+ {error === "slug_taken" && ( +
+ Slug già in uso da un altro cliente. Scegline uno diverso. +
+ )} +
{ "use server"; @@ -64,6 +73,27 @@ export default async function EditClientPage({ /> +
+ +

+ Solo lettere minuscole, numeri e trattini (es. mario-rossi). Min 3, max 50 caratteri. +

+ +

+ Link cliente:{" "} + + /client/{client.slug || client.token} + +

+
+