--- phase: "02-admin-area-interactive-features" plan: 03 type: execute wave: 3 depends_on: - "02-02" files_modified: - 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/lib/admin-queries.ts autonomous: true requirements: - ADMIN-02 must_haves: truths: - "Admin can open /admin/clients/[id] and see all client data in tabs: Panoramica, Fasi & Task, Documenti, Pagamenti, Commenti" - "Admin can add a phase to a client, add a task to a phase, and change task status — all via Server Actions" - "Admin can add a document (label + URL) and delete it" - "Admin can change the payment status (da_saldare / inviata / saldato) and update the accepted_total on the client row" - "Admin can see all comments left by the client (read-only in this tab) and post a reply as 'admin'" artifacts: - path: "src/app/admin/clients/[id]/page.tsx" provides: "Client workspace with tabbed layout using @radix-ui/react-tabs" contains: "Tabs" - path: "src/app/admin/clients/[id]/actions.ts" provides: "Server Actions: addPhase, addTask, updateTaskStatus, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment" contains: "addPhase" - path: "src/components/admin/tabs/PhasesTab.tsx" provides: "Fasi & Task tab — list phases with tasks, add-phase form, add-task form, task status selector" min_lines: 60 - path: "src/components/admin/tabs/PaymentsTab.tsx" provides: "Pagamenti tab — accepted_total field + two payment rows with status selects" min_lines: 40 key_links: - from: "src/app/admin/clients/[id]/page.tsx" to: "src/lib/admin-queries.ts" via: "getClientFullDetail(id)" pattern: "getClientFullDetail" - from: "PhasesTab, PaymentsTab, DocumentsTab" to: "src/app/admin/clients/[id]/actions.ts" via: "Server Actions bound to form action={}" pattern: "action={" - from: "updatePaymentStatus / updateAcceptedTotal" to: "payments / clients tables" via: "db.update().set().where()" pattern: "db.update" --- **Admin Client Workspace (tabs):** Build the full /admin/clients/[id] detail page with Radix Tabs. Each tab covers one concern: Panoramica (overview), Fasi & Task (add phases/tasks, update status), Documenti (add/delete document links), Pagamenti (update payment status + accepted_total), Commenti (read client comments, post admin reply). All mutations use Server Actions (per D-05). Tabs use @radix-ui/react-tabs + shadcn tabs component (per D-08). Purpose: Deliver ADMIN-02 — complete management of every client's data from a single authenticated workspace. Output: Admin can fully manage a client's project lifecycle without leaving the detail page. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md @.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md ```typescript export type Client = typeof clients.$inferSelect; export type Phase = typeof phases.$inferSelect; export type Task = typeof tasks.$inferSelect; export type Deliverable = typeof deliverables.$inferSelect; export type Comment = typeof comments.$inferSelect; export type Payment = typeof payments.$inferSelect; export type Document = typeof documents.$inferSelect; export type Note = typeof notes.$inferSelect; // phases columns: id, client_id, title, sort_order, status (upcoming|active|done) // tasks columns: id, phase_id, title, description, status (todo|in_progress|done), sort_order // comments columns: id, entity_type (task|deliverable), entity_id, author (client|admin), body, created_at // payments columns: id, client_id, label, amount, status (da_saldare|inviata|saldato), paid_at // documents columns: id, client_id, label, url, created_at ``` ```typescript export async function getClientById(id: string): Promise; ``` Task 1: Install @radix-ui/react-tabs + shadcn tabs; add getClientFullDetail() to admin-queries; create Server Actions package.json src/components/ui/tabs.tsx src/lib/admin-queries.ts src/app/admin/clients/[id]/actions.ts Install Radix tabs and add shadcn tabs component (per D-08): ``` npx shadcn@latest add tabs ``` This installs @radix-ui/react-tabs and creates src/components/ui/tabs.tsx. Extend `src/lib/admin-queries.ts` — add getClientFullDetail() below existing functions. Read the current file first to append without overwriting. Add this function: ```typescript import { clients, phases, tasks, deliverables, comments, payments, documents, notes } from "@/db/schema"; import { eq, inArray, asc } from "drizzle-orm"; 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 this client's tasks and deliverables 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, }; } ``` Create `src/app/admin/clients/[id]/actions.ts` — all mutations for the workspace: ```typescript "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"); // Determine next sort_order 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"); 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), 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 await db.update(clients).set({ accepted_total: raw }).where(eq(clients.id, clientId)); // Update payment amounts to 50% each 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"); if (!["task", "deliverable"].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}`); } ``` test -f src/components/ui/tabs.tsx && echo "shadcn tabs component installed" grep -q "getClientFullDetail" src/lib/admin-queries.ts && echo "getClientFullDetail added to admin-queries" test -f src/app/admin/clients/\[id\]/actions.ts && grep -q '"use server"' src/app/admin/clients/\[id\]/actions.ts && echo "actions.ts is Server Action file" grep -q "addPhase\|addTask\|updatePaymentStatus\|updateAcceptedTotal\|postAdminComment" src/app/admin/clients/\[id\]/actions.ts && echo "all major actions present" grep -q "revalidatePath" src/app/admin/clients/\[id\]/actions.ts && echo "revalidatePath called in actions" npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" - src/components/ui/tabs.tsx exists (shadcn tabs installed) - getClientFullDetail(id) added to admin-queries.ts, returns all nested client data - actions.ts contains addPhase, addTask, updateTaskStatus, addDeliverable, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment — all with revalidatePath - npm run build passes Task 2: Build /admin/clients/[id] detail page with all four tab components src/app/admin/clients/[id]/page.tsx src/components/admin/tabs/PhasesTab.tsx src/components/admin/tabs/PaymentsTab.tsx src/components/admin/tabs/DocumentsTab.tsx src/components/admin/tabs/CommentsTab.tsx Create `src/app/admin/clients/[id]/page.tsx` — Server Component, tab container: ```typescript 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: { id: string }; }) { const detail = await getClientFullDetail(params.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
); } ``` Create `src/components/admin/tabs/PhasesTab.tsx`: ```typescript import { addPhase, addTask, updateTaskStatus, updatePhaseStatus } from "@/app/admin/clients/[id]/actions"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import type { ClientFullDetail } from "@/lib/admin-queries"; type Props = { phases: ClientFullDetail["phases"]; clientId: string; }; const taskStatusOptions = [ { value: "todo", label: "Da fare" }, { value: "in_progress", label: "In corso" }, { value: "done", label: "Fatto" }, ]; const phaseStatusOptions = [ { value: "upcoming", label: "In arrivo" }, { value: "active", label: "Attiva" }, { value: "done", label: "Completata" }, ]; export function PhasesTab({ phases, clientId }: Props) { return (
{/* Add phase form */}
{ "use server"; await addPhase(clientId, fd); }} className="flex gap-2" >
{/* Phases list */} {phases.length === 0 && (

Nessuna fase ancora.

)} {phases.map((phase) => (

{phase.title}

{ "use server"; await updatePhaseStatus(phase.id, clientId, fd.get("status") as string); }} className="flex items-center gap-2" >
{/* Tasks */}
{phase.tasks.map((task) => (
{task.title}
{ "use server"; await updateTaskStatus(task.id, clientId, fd.get("status") as string); }} className="flex items-center gap-1" >
))}
{/* Add task form */}
{ "use server"; await addTask(phase.id, clientId, fd); }} className="flex gap-2 mt-2" >
))}
); } ``` Create `src/components/admin/tabs/PaymentsTab.tsx`: ```typescript import { updatePaymentStatus, updateAcceptedTotal } from "@/app/admin/clients/[id]/actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import type { Payment } from "@/db/schema"; type Props = { payments: Payment[]; acceptedTotal: string; clientId: string; }; const statusLabels: Record = { da_saldare: "Da saldare", inviata: "Inviata", saldato: "Saldato", }; export function PaymentsTab({ payments, acceptedTotal, clientId }: Props) { return (
{/* Accepted total */}

Totale preventivo

{ "use server"; await updateAcceptedTotal(clientId, fd); }} className="flex items-end gap-3" >

Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.

{/* Payment rows */} {payments.map((p) => (

{p.label}

€ {parseFloat(p.amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
{ "use server"; await updatePaymentStatus(p.id, clientId, fd.get("status") as string); }} className="flex items-center gap-2" >
))}
); } ``` Create `src/components/admin/tabs/DocumentsTab.tsx`: ```typescript import { addDocument, deleteDocument } from "@/app/admin/clients/[id]/actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import type { Document } from "@/db/schema"; type Props = { documents: Document[]; clientId: string }; export function DocumentsTab({ documents, clientId }: Props) { return (
{ "use server"; await addDocument(clientId, fd); }} className="bg-white border border-gray-200 rounded-lg p-4 space-y-3" >

Aggiungi documento

{documents.length === 0 && (

Nessun documento ancora.

)}
{documents.map((doc) => (
{doc.label}
{ "use server"; await deleteDocument(doc.id, clientId); }}>
))}
); } ``` Create `src/components/admin/tabs/CommentsTab.tsx`: ```typescript 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 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