56dd18b0c2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
826 lines
36 KiB
Markdown
826 lines
36 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
**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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
|
|
@.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md
|
|
|
|
<interfaces>
|
|
<!-- From src/db/schema.ts — all types relevant to this plan -->
|
|
```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>;
|
|
```
|
|
|
|
<!-- New function to add to admin-queries.ts in this plan -->
|
|
<!-- getClientFullDetail(id) must return client + phases + tasks + deliverables + payments + documents + notes + comments -->
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Install @radix-ui/react-tabs + shadcn tabs; add getClientFullDetail() to admin-queries; create Server Actions</name>
|
|
<files>
|
|
package.json
|
|
src/components/ui/tabs.tsx
|
|
src/lib/admin-queries.ts
|
|
src/app/admin/clients/[id]/actions.ts
|
|
</files>
|
|
<action>
|
|
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}`);
|
|
}
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>test -f src/components/ui/tabs.tsx && echo "shadcn tabs component installed"</automated>
|
|
<automated>grep -q "getClientFullDetail" src/lib/admin-queries.ts && echo "getClientFullDetail added to admin-queries"</automated>
|
|
<automated>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"</automated>
|
|
<automated>grep -q "addPhase\|addTask\|updatePaymentStatus\|updateAcceptedTotal\|postAdminComment" src/app/admin/clients/\[id\]/actions.ts && echo "all major actions present"</automated>
|
|
<automated>grep -q "revalidatePath" src/app/admin/clients/\[id\]/actions.ts && echo "revalidatePath called in actions"</automated>
|
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
|
</verify>
|
|
<done>
|
|
- 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
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Build /admin/clients/[id] detail page with all four tab components</name>
|
|
<files>
|
|
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
|
|
</files>
|
|
<action>
|
|
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 & 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>
|
|
);
|
|
}
|
|
```
|
|
|
|
</action>
|
|
<verify>
|
|
<automated>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"</automated>
|
|
<automated>grep -q "getClientFullDetail" src/app/admin/clients/\[id\]/page.tsx && echo "getClientFullDetail called"</automated>
|
|
<automated>test -f src/components/admin/tabs/PhasesTab.tsx && echo "PhasesTab exists"</automated>
|
|
<automated>test -f src/components/admin/tabs/PaymentsTab.tsx && echo "PaymentsTab exists"</automated>
|
|
<automated>test -f src/components/admin/tabs/DocumentsTab.tsx && echo "DocumentsTab exists"</automated>
|
|
<automated>test -f src/components/admin/tabs/CommentsTab.tsx && echo "CommentsTab exists"</automated>
|
|
<automated>grep -q "updateAcceptedTotal" src/components/admin/tabs/PaymentsTab.tsx && echo "Payment total update wired"</automated>
|
|
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
|
|
</verify>
|
|
<done>
|
|
- /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
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/02-admin-area-interactive-features/02-03-SUMMARY.md`
|
|
</output>
|