From 7733566f5b23f42a0c09ca5a28669b045626f8ee Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Fri, 15 May 2026 20:10:10 +0200 Subject: [PATCH 1/3] feat(02-03): install @radix-ui/react-tabs, add getClientFullDetail, create Server Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add shadcn tabs component (src/components/ui/tabs.tsx) backed by @radix-ui/react-tabs - Extend admin-queries.ts with getClientFullDetail() — fetches client + phases + tasks + deliverables + payments + documents + notes + comments in one call - Create src/app/admin/clients/[id]/actions.ts with all mutations: addPhase, updatePhaseStatus, addTask, updateTaskStatus, addDeliverable, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment - All actions include server-side allowlist validation and revalidatePath - approved_at immutability enforced by omission in addDeliverable --- package-lock.json | 198 ++++++++++++++++++++++++++ package.json | 1 + src/app/admin/clients/[id]/actions.ts | 175 +++++++++++++++++++++++ src/components/ui/tabs.tsx | 55 +++++++ src/lib/admin-queries.ts | 114 ++++++++++++++- 5 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 src/app/admin/clients/[id]/actions.ts create mode 100644 src/components/ui/tabs.tsx 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/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..26eb109 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index d04c110..8777edb 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -1,6 +1,25 @@ import { db } from "@/db"; -import { clients, payments } from "@/db/schema"; -import { eq } from "drizzle-orm"; +import { + clients, + payments, + phases, + tasks, + deliverables, + comments, + documents, + notes, +} from "@/db/schema"; +import { eq, inArray, asc } from "drizzle-orm"; +import type { + Client, + Phase, + Task, + Deliverable, + Payment, + Document, + Note, + Comment, +} from "@/db/schema"; export type ClientWithPayments = { id: string; @@ -55,3 +74,94 @@ export async function getClientById(id: string) { .limit(1); return rows[0] ?? null; } + +// ── ClientFullDetail — used by /admin/clients/[id] workspace ───────────────── + +export type ClientFullDetail = { + client: Client; + phases: Array }>; + payments: Payment[]; + documents: Document[]; + notes: Note[]; + comments: Comment[]; +}; + +export async function getClientFullDetail(id: string): Promise { + const clientRows = await db.select().from(clients).where(eq(clients.id, id)).limit(1); + if (clientRows.length === 0) return null; + const client = clientRows[0]; + + const phasesRows = await db + .select() + .from(phases) + .where(eq(phases.client_id, id)) + .orderBy(asc(phases.sort_order)); + + const phaseIds = phasesRows.map((p) => p.id); + + const tasksRows = + phaseIds.length === 0 + ? [] + : await db + .select() + .from(tasks) + .where(inArray(tasks.phase_id, phaseIds)) + .orderBy(asc(tasks.sort_order)); + + const taskIds = tasksRows.map((t) => t.id); + + const deliverablesRows = + taskIds.length === 0 + ? [] + : await db + .select() + .from(deliverables) + .where(inArray(deliverables.task_id, taskIds)); + + const paymentsRows = await db + .select() + .from(payments) + .where(eq(payments.client_id, id)); + + const documentsRows = await db + .select() + .from(documents) + .where(eq(documents.client_id, id)) + .orderBy(asc(documents.created_at)); + + const notesRows = await db + .select() + .from(notes) + .where(eq(notes.client_id, id)) + .orderBy(asc(notes.created_at)); + + // Fetch all comments for tasks and deliverables belonging to this client + const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)]; + const commentsRows = + allEntityIds.length === 0 + ? [] + : await db + .select() + .from(comments) + .where(inArray(comments.entity_id, allEntityIds)) + .orderBy(asc(comments.created_at)); + + const phasesWithTasks = phasesRows.map((phase) => { + const phaseTasks = tasksRows + .filter((t) => t.phase_id === phase.id) + .map((task) => ({ + ...task, + deliverables: deliverablesRows.filter((d) => d.task_id === task.id), + })); + return { ...phase, tasks: phaseTasks }; + }); + + return { + client, + phases: phasesWithTasks, + payments: paymentsRows, + documents: documentsRows, + notes: notesRows, + comments: commentsRows, + }; +} From 59a46d37fa05c2c40e3d1d72185931b6a866542a Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Fri, 15 May 2026 21:16:10 +0200 Subject: [PATCH 2/3] feat(02-03): build /admin/clients/[id] workspace with tabbed layout and all tab components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create /admin/clients/[id]/page.tsx — Server Component using Radix Tabs (Fasi & Task, Pagamenti, Documenti, Commenti) - Create PhasesTab: phases list with add-phase form, task lists with add-task form, status selects for phases and tasks - Create PaymentsTab: accepted_total editor (splits to 50% on each payment), payment status selects with paid_at on saldato - Create DocumentsTab: add document (label + URL) form, document list with delete action - Create CommentsTab: chronological comment display (admin vs cliente style), admin reply form with entity selector - All mutations via inline Server Action closures bound to action= props; revalidatePath ensures fresh data --- src/app/admin/clients/[id]/page.tsx | 72 ++++++++++ src/components/admin/tabs/CommentsTab.tsx | 105 ++++++++++++++ src/components/admin/tabs/DocumentsTab.tsx | 81 +++++++++++ src/components/admin/tabs/PaymentsTab.tsx | 97 +++++++++++++ src/components/admin/tabs/PhasesTab.tsx | 154 +++++++++++++++++++++ 5 files changed, 509 insertions(+) create mode 100644 src/app/admin/clients/[id]/page.tsx create mode 100644 src/components/admin/tabs/CommentsTab.tsx create mode 100644 src/components/admin/tabs/DocumentsTab.tsx create mode 100644 src/components/admin/tabs/PaymentsTab.tsx create mode 100644 src/components/admin/tabs/PhasesTab.tsx 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 ( +
+
+ + ← Clienti + +
+
+
+

{client.name}

+

{client.brand_name}

+
+ + Link cliente → + +
+ + + + Fasi & Task + Pagamenti + Documenti + Commenti + + + + + + + + + + + + + + + +
+ ); +} \ No newline at end of file diff --git a/src/components/admin/tabs/CommentsTab.tsx b/src/components/admin/tabs/CommentsTab.tsx new file mode 100644 index 0000000..baaa383 --- /dev/null +++ b/src/components/admin/tabs/CommentsTab.tsx @@ -0,0 +1,105 @@ +import { postAdminComment } from "@/app/admin/clients/[id]/actions"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import type { Comment } from "@/db/schema"; +import type { ClientFullDetail } from "@/lib/admin-queries"; + +type Props = { + comments: Comment[]; + phases: ClientFullDetail["phases"]; + clientId: string; +}; + +export async function CommentsTab({ comments, phases, clientId }: Props) { + // Build entity label map for display + const entityLabels: Record = {}; + for (const phase of phases) { + for (const task of phase.tasks) { + entityLabels[task.id] = `Task: ${task.title}`; + for (const d of task.deliverables) { + entityLabels[d.id] = `Deliverable: ${d.title}`; + } + } + } + + // Build list of entities the admin can reply on + const entities: Array<{ id: string; type: string; label: string }> = []; + for (const phase of phases) { + for (const task of phase.tasks) { + entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` }); + for (const d of task.deliverables) { + entities.push({ + id: d.id, + type: "deliverable", + label: `Deliverable: ${d.title}`, + }); + } + } + } + + return ( +
+ {/* Comment list */} + {comments.length === 0 && ( +

Nessun commento ancora.

+ )} +
+ {comments.map((c) => ( +
+
+

+ {c.author === "admin" ? "iamcavalli" : "Cliente"} —{" "} + {entityLabels[c.entity_id] ?? c.entity_id} +

+

{c.body}

+
+
+ ))} +
+ + {/* Admin reply form */} + {entities.length > 0 && ( +
{ + "use server"; + await postAdminComment(clientId, fd); + }} + className="bg-white border border-gray-200 rounded-lg p-4 space-y-3" + > +

+ Rispondi come admin +

+ +