Files
clienthub/.planning/phases/02-admin-area-interactive-features/02-03-PLAN.md
T
2026-05-15 10:30:27 +02:00

36 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-admin-area-interactive-features 03 execute 3
02-02
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
true
ADMIN-02
truths artifacts key_links
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'
path provides contains
src/app/admin/clients/[id]/page.tsx Client workspace with tabbed layout using @radix-ui/react-tabs Tabs
path provides contains
src/app/admin/clients/[id]/actions.ts Server Actions: addPhase, addTask, updateTaskStatus, addDocument, deleteDocument, updatePaymentStatus, updateAcceptedTotal, postAdminComment addPhase
path provides min_lines
src/components/admin/tabs/PhasesTab.tsx Fasi & Task tab — list phases with tasks, add-phase form, add-task form, task status selector 60
path provides min_lines
src/components/admin/tabs/PaymentsTab.tsx Pagamenti tab — accepted_total field + two payment rows with status selects 40
from to via pattern
src/app/admin/clients/[id]/page.tsx src/lib/admin-queries.ts getClientFullDetail(id) getClientFullDetail
from to via pattern
PhasesTab, PaymentsTab, DocumentsTab src/app/admin/clients/[id]/actions.ts Server Actions bound to form action={} action={
from to via pattern
updatePaymentStatus / updateAcceptedTotal payments / clients tables db.update().set().where() 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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


<!-- From src/lib/admin-queries.ts (02-02 output) -->
```typescript
export async function getClientById(id: string): Promise<Client | null>;
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<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
  payments: Payment[];
  documents: Document[];
  notes: Note[];
  comments: Comment[];
};

export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
  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 (
    <div>
      <div className="mb-4">
        <Link href="/admin" className="text-sm text-gray-500 hover:text-gray-700">
          ← Clienti
        </Link>
      </div>
      <div className="mb-6 flex items-start justify-between">
        <div>
          <h1 className="text-2xl font-bold text-gray-900">{client.name}</h1>
          <p className="text-sm text-gray-500">{client.brand_name}</p>
        </div>
        <a
          href={`/c/${client.token}`}
          target="_blank"
          rel="noopener noreferrer"
          className="text-xs text-blue-600 hover:underline font-mono bg-blue-50 px-2 py-1 rounded"
        >
          Link cliente →
        </a>
      </div>

      <Tabs defaultValue="phases" className="w-full">
        <TabsList className="mb-6">
          <TabsTrigger value="phases">Fasi &amp; Task</TabsTrigger>
          <TabsTrigger value="payments">Pagamenti</TabsTrigger>
          <TabsTrigger value="documents">Documenti</TabsTrigger>
          <TabsTrigger value="comments">Commenti</TabsTrigger>
        </TabsList>

        <TabsContent value="phases">
          <PhasesTab phases={phases} clientId={client.id} />
        </TabsContent>
        <TabsContent value="payments">
          <PaymentsTab
            payments={payments}
            acceptedTotal={client.accepted_total ?? "0"}
            clientId={client.id}
          />
        </TabsContent>
        <TabsContent value="documents">
          <DocumentsTab documents={documents} clientId={client.id} />
        </TabsContent>
        <TabsContent value="comments">
          <CommentsTab comments={comments} phases={phases} clientId={client.id} />
        </TabsContent>
      </Tabs>
    </div>
  );
}
```

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 (
    <div className="space-y-6">
      {/* Add phase form */}
      <form
        action={async (fd) => { "use server"; await addPhase(clientId, fd); }}
        className="flex gap-2"
      >
        <Input name="title" placeholder="Nome nuova fase..." className="max-w-xs" required />
        <Button type="submit" variant="outline" size="sm">+ Fase</Button>
      </form>

      {/* Phases list */}
      {phases.length === 0 && (
        <p className="text-sm text-gray-400">Nessuna fase ancora.</p>
      )}
      {phases.map((phase) => (
        <div key={phase.id} className="border border-gray-200 rounded-lg p-4 bg-white">
          <div className="flex items-center justify-between mb-3">
            <h3 className="font-semibold text-gray-900">{phase.title}</h3>
            <form
              action={async (fd) => {
                "use server";
                await updatePhaseStatus(phase.id, clientId, fd.get("status") as string);
              }}
              className="flex items-center gap-2"
            >
              <select
                name="status"
                defaultValue={phase.status}
                className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
              >
                {phaseStatusOptions.map((o) => (
                  <option key={o.value} value={o.value}>{o.label}</option>
                ))}
              </select>
              <Button type="submit" variant="ghost" size="sm" className="text-xs">Salva</Button>
            </form>
          </div>

          {/* Tasks */}
          <div className="space-y-2 mb-3">
            {phase.tasks.map((task) => (
              <div key={task.id} className="flex items-center justify-between pl-3 border-l-2 border-gray-100">
                <span className="text-sm text-gray-800">{task.title}</span>
                <form
                  action={async (fd) => {
                    "use server";
                    await updateTaskStatus(task.id, clientId, fd.get("status") as string);
                  }}
                  className="flex items-center gap-1"
                >
                  <select
                    name="status"
                    defaultValue={task.status}
                    className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
                  >
                    {taskStatusOptions.map((o) => (
                      <option key={o.value} value={o.value}>{o.label}</option>
                    ))}
                  </select>
                  <Button type="submit" variant="ghost" size="sm" className="text-xs px-1">✓</Button>
                </form>
              </div>
            ))}
          </div>

          {/* Add task form */}
          <form
            action={async (fd) => { "use server"; await addTask(phase.id, clientId, fd); }}
            className="flex gap-2 mt-2"
          >
            <Input name="title" placeholder="Nuovo task..." className="text-sm max-w-xs" required />
            <Button type="submit" variant="ghost" size="sm" className="text-xs">+ Task</Button>
          </form>
        </div>
      ))}
    </div>
  );
}
```

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<string, string> = {
  da_saldare: "Da saldare",
  inviata:    "Inviata",
  saldato:    "Saldato",
};

export function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
  return (
    <div className="space-y-6 max-w-md">
      {/* Accepted total */}
      <div className="bg-white border border-gray-200 rounded-lg p-4">
        <h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
        <form
          action={async (fd) => { "use server"; await updateAcceptedTotal(clientId, fd); }}
          className="flex items-end gap-3"
        >
          <div className="space-y-1 flex-1">
            <Label htmlFor="accepted_total">Importo (€)</Label>
            <Input
              id="accepted_total"
              name="accepted_total"
              type="number"
              step="0.01"
              min="0"
              defaultValue={acceptedTotal}
              className="max-w-xs"
            />
          </div>
          <Button type="submit" size="sm">Salva</Button>
        </form>
        <p className="text-xs text-gray-400 mt-2">
          Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
        </p>
      </div>

      {/* Payment rows */}
      {payments.map((p) => (
        <div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
          <div className="flex items-center justify-between mb-2">
            <h3 className="font-medium text-gray-900">{p.label}</h3>
            <span className="text-sm text-gray-600">
              € {parseFloat(p.amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
            </span>
          </div>
          <form
            action={async (fd) => {
              "use server";
              await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
            }}
            className="flex items-center gap-2"
          >
            <select
              name="status"
              defaultValue={p.status}
              className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
            >
              {Object.entries(statusLabels).map(([val, label]) => (
                <option key={val} value={val}>{label}</option>
              ))}
            </select>
            <Button type="submit" size="sm" variant="outline">Aggiorna</Button>
          </form>
        </div>
      ))}
    </div>
  );
}
```

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 (
    <div className="space-y-6 max-w-lg">
      <form
        action={async (fd) => { "use server"; await addDocument(clientId, fd); }}
        className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
      >
        <h3 className="font-medium text-gray-900">Aggiungi documento</h3>
        <div className="space-y-1">
          <Label htmlFor="doc-label">Nome / etichetta</Label>
          <Input id="doc-label" name="label" placeholder="es. Brief progetto" required />
        </div>
        <div className="space-y-1">
          <Label htmlFor="doc-url">URL (Google Drive, PDF...)</Label>
          <Input id="doc-url" name="url" type="url" placeholder="https://drive.google.com/..." required />
        </div>
        <Button type="submit" size="sm">Aggiungi</Button>
      </form>

      {documents.length === 0 && (
        <p className="text-sm text-gray-400">Nessun documento ancora.</p>
      )}
      <div className="space-y-2">
        {documents.map((doc) => (
          <div key={doc.id} className="flex items-center justify-between bg-white border border-gray-200 rounded-lg px-4 py-3">
            <a
              href={doc.url}
              target="_blank"
              rel="noopener noreferrer"
              className="text-sm text-blue-600 hover:underline"
            >
              {doc.label}
            </a>
            <form action={async () => { "use server"; await deleteDocument(doc.id, clientId); }}>
              <Button type="submit" variant="ghost" size="sm" className="text-red-500 hover:text-red-700 text-xs">
                Rimuovi
              </Button>
            </form>
          </div>
        ))}
      </div>
    </div>
  );
}
```

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<string, string> = {};
  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 (
    <div className="space-y-6 max-w-lg">
      {/* Comment list */}
      {comments.length === 0 && (
        <p className="text-sm text-gray-400">Nessun commento ancora.</p>
      )}
      <div className="space-y-3">
        {comments.map((c) => (
          <div key={c.id} className={`flex gap-3 ${c.author === "admin" ? "flex-row-reverse" : ""}`}>
            <div
              className={`rounded-lg px-3 py-2 text-sm max-w-xs ${
                c.author === "admin"
                  ? "bg-gray-900 text-white"
                  : "bg-white border border-gray-200 text-gray-800"
              }`}
            >
              <p className="text-xs font-medium mb-1 opacity-60">
                {c.author === "admin" ? "iamcavalli" : "Cliente"} — {entityLabels[c.entity_id] ?? c.entity_id}
              </p>
              <p>{c.body}</p>
            </div>
          </div>
        ))}
      </div>

      {/* Admin reply form */}
      {entities.length > 0 && (
        <form
          action={async (fd) => { "use server"; await postAdminComment(clientId, fd); }}
          className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
        >
          <h3 className="font-medium text-gray-900 text-sm">Rispondi come admin</h3>
          <select name="entity" className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white" required>
            {entities.map((e) => (
              <option key={e.id} value={`${e.type}:${e.id}`}>{e.label}</option>
            ))}
          </select>
          <Textarea name="body" placeholder="Scrivi un commento..." rows={3} required />
          <Button type="submit" size="sm">Invia risposta</Button>
        </form>
      )}
    </div>
  );
}
```
test -f src/app/admin/clients/\[id\]/page.tsx && grep -q "Tabs" src/app/admin/clients/\[id\]/page.tsx && echo "Tabs imported in detail page" grep -q "getClientFullDetail" src/app/admin/clients/\[id\]/page.tsx && echo "getClientFullDetail called" test -f src/components/admin/tabs/PhasesTab.tsx && echo "PhasesTab exists" test -f src/components/admin/tabs/PaymentsTab.tsx && echo "PaymentsTab exists" test -f src/components/admin/tabs/DocumentsTab.tsx && echo "DocumentsTab exists" test -f src/components/admin/tabs/CommentsTab.tsx && echo "CommentsTab exists" grep -q "updateAcceptedTotal" src/components/admin/tabs/PaymentsTab.tsx && echo "Payment total update wired" npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" - /admin/clients/[id] renders with Radix Tabs: Fasi & Task, Pagamenti, Documenti, Commenti - PhasesTab: shows phases with task lists; add phase form and add task form work; task status updates work - PaymentsTab: accepted_total editable; payment status selects update and set paid_at on saldato - DocumentsTab: add document (label + URL) and delete document work - CommentsTab: displays all comments chronologically; admin can post reply on any task/deliverable - npm run build passes cleanly

<threat_model>

Trust Boundaries

Boundary Description
Admin browser → Server Actions All mutations server-side; session guard in middleware ensures only authenticated admin reaches these
Server Actions → DB Input validated with Zod or allowlist checks before any write
approved_at field Not touched by any admin action in this plan — immutability enforced by omission

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-02-10 Tampering updateTaskStatus / updatePaymentStatus mitigate Server-side allowlist check on status value before db.update(); invalid values throw before any write
T-02-11 Tampering deleteDocument mitigate Admin-only route protected by middleware session; no client can call deleteDocument without a valid JWT
T-02-12 Information Disclosure getClientFullDetail fetches comments accept Comments are fetched only by admin in this plan; client reads own comments only via client-facing API (Plan 04)
T-02-13 Tampering postAdminComment entity_type parsing mitigate entity_type parsed from "type:id" composite value; only "task" and "deliverable" are valid; invalid type rejected in action
T-02-14 Elevation of Privilege Server Action inline "use server" mitigate Inline "use server" directives in RSC props are Next.js 15 pattern; each closure captures clientId from Server Component scope, preventing cross-client pollution
</threat_model>
After plan execution: 1. `npm run build` — no errors 2. Log in, open /admin/clients/[id] → tabs render 3. Add a phase → appears in Fasi & Task tab after submit 4. Add a task to the phase → appears nested under phase 5. Change task status → badge updates 6. Set accepted_total to 1000 → both payments show 500.00 7. Change payment status to "saldato" → status updates 8. Add a document with URL → appears in list; delete it → removed 9. If comments exist (from Phase 1 seed), they appear in Commenti tab

<success_criteria>

  • Admin can fully manage client phases, tasks, documents, and payments from the detail page
  • All mutations use Server Actions with revalidatePath — no client-side fetch or state
  • accepted_total update correctly sets both payment amounts to 50% each
  • Payment status "saldato" sets paid_at timestamp
  • Tab layout renders correctly with @radix-ui/react-tabs
  • npm run build passes cleanly </success_criteria>
After completion, create `.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md`