diff --git a/.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md b/.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md new file mode 100644 index 0000000..37065f8 --- /dev/null +++ b/.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md @@ -0,0 +1,117 @@ +--- +phase: "02-admin-area-interactive-features" +plan: "03" +subsystem: "admin-workspace" +tags: [admin, tabs, server-actions, phases, tasks, payments, documents, comments] +dependency_graph: + requires: ["02-02"] + provides: ["admin-client-workspace", "getClientFullDetail", "client-detail-mutations"] + affects: ["admin-area", "client-data-management"] +tech_stack: + added: + - "@radix-ui/react-tabs ^1.1.13 — tab primitive for client workspace" + - "shadcn/ui tabs component — src/components/ui/tabs.tsx" + patterns: + - "Inline Server Action closures in RSC props (action={async (fd) => { 'use server'; ... }})" + - "getClientFullDetail() — waterfall DB query assembling full client data in one call" + - "Server-side allowlist validation on all status mutations before db.update()" +key_files: + created: + - src/app/admin/clients/[id]/page.tsx + - src/app/admin/clients/[id]/actions.ts + - src/components/admin/tabs/PhasesTab.tsx + - src/components/admin/tabs/PaymentsTab.tsx + - src/components/admin/tabs/DocumentsTab.tsx + - src/components/admin/tabs/CommentsTab.tsx + - src/components/ui/tabs.tsx + modified: + - src/lib/admin-queries.ts + - package.json + - package-lock.json +decisions: + - "Inline Server Action closures capture clientId/phaseId/taskId from RSC scope — no cross-client pollution (T-02-14)" + - "approved_at immutability enforced by omission in addDeliverable — field not set in insert, never updated" + - "quote_items never queried in getClientFullDetail — accepted_total is the only price surface returned" + - "params in Next.js 16 App Router must be awaited (Promise<{ id: string }>) — applied as deviation fix" +metrics: + duration_minutes: 25 + completed_date: "2026-05-15" + tasks_completed: 2 + tasks_total: 2 + files_created: 7 + files_modified: 3 +--- + +# Phase 2 Plan 03: Admin Client Workspace (Tabs) Summary + +**One-liner:** Full-featured admin client workspace with Radix Tabs covering phases/tasks, payments, documents, and comments — all mutations via inline Server Actions with server-side validation. + +## What Was Built + +The `/admin/clients/[id]` route delivers a complete project management workspace for the admin. Four tabs cover every concern of a client's lifecycle: + +- **Fasi & Task** — Add phases and nested tasks, update phase/task status with select dropdowns +- **Pagamenti** — Edit `accepted_total` (auto-splits to 50% per payment row), update payment status (sets `paid_at` on saldato) +- **Documenti** — Add document links (label + external URL) and delete them +- **Commenti** — Read all client/admin comments chronologically; post admin replies against any task or deliverable + +All mutations are Server Actions in `src/app/admin/clients/[id]/actions.ts`. The page calls `getClientFullDetail()` which assembles client + phases + tasks + deliverables + payments + documents + notes + comments in a single waterfall query sequence. + +## Tasks Completed + +| Task | Name | Commit | Key Files | +|------|------|--------|-----------| +| 1 | Install tabs, add getClientFullDetail, create Server Actions | 7733566 | package.json, admin-queries.ts, [id]/actions.ts, ui/tabs.tsx | +| 2 | Build detail page and all four tab components | 59a46d3 | [id]/page.tsx, PhasesTab, PaymentsTab, DocumentsTab, CommentsTab | + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Next.js 16 params must be awaited** +- **Found during:** Task 2 +- **Issue:** In Next.js 16 (project uses 16.2.6), dynamic route `params` is a `Promise<{ id: string }>` not a plain object. The plan's template used `params.id` directly which would fail at runtime. +- **Fix:** Changed page signature to `params: Promise<{ id: string }>` and added `const { id } = await params;` before use. +- **Files modified:** src/app/admin/clients/[id]/page.tsx + +**2. [Rule 2 - Missing critical functionality] Explicit TypeScript types on Server Action closure params** +- **Found during:** Task 2 +- **Issue:** Inline closures `async (fd) => { "use server"; ... }` lacked explicit `FormData` type annotation, which could cause TypeScript inference issues. +- **Fix:** Added explicit `: FormData` type annotation on all closure parameters. +- **Files modified:** PhasesTab.tsx, PaymentsTab.tsx, DocumentsTab.tsx, CommentsTab.tsx + +## Architecture Constraints Respected + +- `clients.token` — Read-only in this plan. Never used as primary key. +- `quote_items` — Not queried anywhere in this plan. `accepted_total` is the only price value exposed. +- `deliverables.approved_at` — Enforced by omission: `addDeliverable` inserts `status: "pending"` with no `approved_at` field. +- Two independent auth paths — Admin workspace is under `/admin/*`, protected by middleware session from Phase 2 Plan 01. + +## Security Notes (Threat Register Mitigations Applied) + +- **T-02-10:** `updateTaskStatus` and `updatePaymentStatus` both validate status against an allowlist before any `db.update()` call. +- **T-02-11:** `deleteDocument` is only reachable through admin-protected routes; no client can reach it without a valid Auth.js session. +- **T-02-13:** `postAdminComment` parses the composite `"type:id"` entity value and validates `entity_type` is exactly `"task"` or `"deliverable"` before insert. +- **T-02-14:** Inline closures capture `clientId`, `phaseId`, `taskId` from the Server Component's own scope, preventing cross-client data pollution. + +## Known Stubs + +None — all data is live from the database via `getClientFullDetail()`. + +## Threat Flags + +None — no new network endpoints, auth paths, or schema changes introduced beyond what the plan's threat model covers. + +## Self-Check + +- [x] src/app/admin/clients/[id]/page.tsx exists +- [x] src/app/admin/clients/[id]/actions.ts exists +- [x] src/components/admin/tabs/PhasesTab.tsx exists (153 lines > 60 min) +- [x] src/components/admin/tabs/PaymentsTab.tsx exists (96 lines > 40 min) +- [x] src/components/admin/tabs/DocumentsTab.tsx exists +- [x] src/components/admin/tabs/CommentsTab.tsx exists +- [x] Commit 7733566 exists (Task 1) +- [x] Commit 59a46d3 exists (Task 2) +- [x] npm run build passes cleanly + +## Self-Check: PASSED \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7f33658..a87cb03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", @@ -2816,6 +2817,30 @@ } } }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-primitive": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", @@ -2863,6 +2888,93 @@ } } }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", @@ -3003,6 +3115,92 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/package.json b/package.json index f6f0566..d9eabc6 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "drizzle-orm": "^0.45.2", diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts new file mode 100644 index 0000000..2bf835a --- /dev/null +++ b/src/app/admin/clients/[id]/actions.ts @@ -0,0 +1,175 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { db } from "@/db"; +import { + phases, + tasks, + deliverables, + documents, + payments, + clients, + comments, +} from "@/db/schema"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +// ── PHASES ──────────────────────────────────────────────────────────────────── + +export async function addPhase(clientId: string, formData: FormData) { + const title = (formData.get("title") as string)?.trim(); + if (!title) throw new Error("Titolo fase richiesto"); + + const existingPhases = await db + .select({ sort_order: phases.sort_order }) + .from(phases) + .where(eq(phases.client_id, clientId)); + const maxOrder = existingPhases.reduce((max, p) => Math.max(max, p.sort_order), -1); + + await db.insert(phases).values({ + client_id: clientId, + title, + sort_order: maxOrder + 1, + status: "upcoming", + }); + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function updatePhaseStatus( + phaseId: string, + clientId: string, + status: string +) { + const allowed = ["upcoming", "active", "done"]; + if (!allowed.includes(status)) throw new Error("Stato non valido"); + await db.update(phases).set({ status }).where(eq(phases.id, phaseId)); + revalidatePath(`/admin/clients/${clientId}`); +} + +// ── TASKS ───────────────────────────────────────────────────────────────────── + +export async function addTask( + phaseId: string, + clientId: string, + formData: FormData +) { + const title = (formData.get("title") as string)?.trim(); + if (!title) throw new Error("Titolo task richiesto"); + + const existingTasks = await db + .select({ sort_order: tasks.sort_order }) + .from(tasks) + .where(eq(tasks.phase_id, phaseId)); + const maxOrder = existingTasks.reduce((max, t) => Math.max(max, t.sort_order), -1); + + await db.insert(tasks).values({ + phase_id: phaseId, + title, + description: (formData.get("description") as string)?.trim() || null, + sort_order: maxOrder + 1, + status: "todo", + }); + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function updateTaskStatus( + taskId: string, + clientId: string, + status: string +) { + const allowed = ["todo", "in_progress", "done"]; + if (!allowed.includes(status)) throw new Error("Stato non valido"); + await db.update(tasks).set({ status }).where(eq(tasks.id, taskId)); + revalidatePath(`/admin/clients/${clientId}`); +} + +// ── DELIVERABLES ────────────────────────────────────────────────────────────── + +export async function addDeliverable( + taskId: string, + clientId: string, + formData: FormData +) { + const title = (formData.get("title") as string)?.trim(); + const url = (formData.get("url") as string)?.trim() || null; + if (!title) throw new Error("Titolo deliverable richiesto"); + // approved_at is intentionally omitted — immutable constraint: never set by admin here + await db + .insert(deliverables) + .values({ task_id: taskId, title, url, status: "pending" }); + revalidatePath(`/admin/clients/${clientId}`); +} + +// ── DOCUMENTS ───────────────────────────────────────────────────────────────── + +const docSchema = z.object({ + label: z.string().min(1, "Etichetta richiesta"), + url: z.string().url("URL non valido"), +}); + +export async function addDocument(clientId: string, formData: FormData) { + const parsed = docSchema.safeParse({ + label: formData.get("label"), + url: formData.get("url"), + }); + if (!parsed.success) throw new Error(parsed.error.issues[0].message); + await db.insert(documents).values({ client_id: clientId, ...parsed.data }); + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function deleteDocument(documentId: string, clientId: string) { + await db.delete(documents).where(eq(documents.id, documentId)); + revalidatePath(`/admin/clients/${clientId}`); +} + +// ── PAYMENTS ────────────────────────────────────────────────────────────────── + +export async function updatePaymentStatus( + paymentId: string, + clientId: string, + status: string +) { + const allowed = ["da_saldare", "inviata", "saldato"]; + if (!allowed.includes(status)) throw new Error("Stato pagamento non valido"); + const paid_at = status === "saldato" ? new Date() : null; + await db + .update(payments) + .set({ status, paid_at }) + .where(eq(payments.id, paymentId)); + revalidatePath(`/admin/clients/${clientId}`); +} + +export async function updateAcceptedTotal(clientId: string, formData: FormData) { + const raw = (formData.get("accepted_total") as string)?.trim(); + const val = parseFloat(raw); + if (isNaN(val) || val < 0) throw new Error("Importo non valido"); + // Update accepted_total on client row — denormalized field, quote_items never exposed + await db + .update(clients) + .set({ accepted_total: val.toFixed(2) }) + .where(eq(clients.id, clientId)); + // Split evenly between two payment rows (Acconto 50% + Saldo 50%) + const half = (val / 2).toFixed(2); + const paymentsRows = await db + .select() + .from(payments) + .where(eq(payments.client_id, clientId)); + for (const p of paymentsRows) { + await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id)); + } + revalidatePath(`/admin/clients/${clientId}`); +} + +// ── COMMENTS (admin reply) ──────────────────────────────────────────────────── + +export async function postAdminComment(clientId: string, formData: FormData) { + const entity = formData.get("entity") as string; + const body = (formData.get("body") as string)?.trim(); + if (!body || !entity) throw new Error("Dati mancanti"); + const [entity_type, entity_id] = entity.split(":"); + if (!entity_type || !entity_id) throw new Error("Formato entity non valido"); + const allowedTypes = ["task", "deliverable"]; + if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido"); + await db.insert(comments).values({ entity_type, entity_id, author: "admin", body }); + revalidatePath(`/admin/clients/${clientId}`); +} \ No newline at end of file diff --git a/src/app/admin/clients/[id]/page.tsx b/src/app/admin/clients/[id]/page.tsx new file mode 100644 index 0000000..6913b1f --- /dev/null +++ b/src/app/admin/clients/[id]/page.tsx @@ -0,0 +1,72 @@ +import { notFound } from "next/navigation"; +import { getClientFullDetail } from "@/lib/admin-queries"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PhasesTab } from "@/components/admin/tabs/PhasesTab"; +import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab"; +import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab"; +import { CommentsTab } from "@/components/admin/tabs/CommentsTab"; +import Link from "next/link"; + +export const revalidate = 0; + +export default async function ClientDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const detail = await getClientFullDetail(id); + if (!detail) notFound(); + + const { client, phases, payments, documents, notes, comments } = detail; + + return ( +
{client.brand_name}
+Nessun commento ancora.
+ )} ++ {c.author === "admin" ? "iamcavalli" : "Cliente"} —{" "} + {entityLabels[c.entity_id] ?? c.entity_id} +
+{c.body}
+Nessun documento ancora.
+ )} ++ Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna. +
+Nessuna fase ancora.
+ )} + {phases.map((phase) => ( +