docs(02-admin-area-interactive-features): complete phase 2 planning with 4-plan structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simone Cavalli
2026-05-15 10:30:27 +02:00
parent 904849178d
commit 56dd18b0c2
7 changed files with 3716 additions and 6 deletions
@@ -0,0 +1,561 @@
---
phase: "02-admin-area-interactive-features"
plan: 02
type: execute
wave: 2
depends_on:
- "02-01"
files_modified:
- src/app/admin/page.tsx
- src/app/admin/layout.tsx
- src/app/admin/clients/new/page.tsx
- src/app/admin/clients/new/actions.ts
- src/lib/admin-queries.ts
- src/components/admin/ClientRow.tsx
- src/components/admin/NavBar.tsx
autonomous: true
requirements:
- ADMIN-01
- ADMIN-02
must_haves:
truths:
- "Admin can see a list of all clients at /admin with name, brand, and payment status badges"
- "Admin can create a new client via /admin/clients/new form; on submit the client row + two payment rows are inserted and the secret link (token) is auto-generated"
- "After creating a client, admin is redirected to /admin (or /admin/clients/[id] for detail)"
- "The new client's shareable link /c/[token] is visible to the admin immediately after creation"
- "Payment status badges for Acconto and Saldo are visible in the client list row"
artifacts:
- path: "src/app/admin/page.tsx"
provides: "Admin client list — Server Component fetching all clients with payments"
contains: "export default async function"
- path: "src/app/admin/layout.tsx"
provides: "Admin layout with minimal NavBar (logo + Clienti link + logout button)"
contains: "NavBar"
- path: "src/app/admin/clients/new/page.tsx"
provides: "New client form page"
min_lines: 30
- path: "src/app/admin/clients/new/actions.ts"
provides: "Server Action: createClient() — inserts client + 2 payment rows"
contains: "createClient"
- path: "src/lib/admin-queries.ts"
provides: "Admin-side DB query functions (getAllClientsWithPayments)"
contains: "getAllClientsWithPayments"
key_links:
- from: "src/app/admin/page.tsx"
to: "src/lib/admin-queries.ts"
via: "getAllClientsWithPayments()"
pattern: "getAllClientsWithPayments"
- from: "src/app/admin/clients/new/page.tsx"
to: "src/app/admin/clients/new/actions.ts"
via: "createClient Server Action"
pattern: "createClient"
- from: "createClient action"
to: "clients + payments tables"
via: "db.insert(clients) + db.insert(payments) x2"
pattern: "db.insert"
---
<objective>
**Admin Client List + Create Client:** Build the admin home page (client list with payment badges) and the new client creation form. The create form auto-generates the nanoid secret token, inserts the client row, and creates two payment rows (Acconto 50% / Saldo 50%) in a single Server Action.
Purpose: Deliver the first end-to-end admin capability — admin can enter a client's details and immediately get a shareable /c/[token] link. Implements ADMIN-01 (client list with status) and the creation half of ADMIN-02 (per D-05 Server Actions, D-07 list→detail layout, D-09 minimal nav).
Output: /admin shows all clients with payment badges; /admin/clients/new creates a client and two payment stubs.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md
@.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md
<interfaces>
<!-- Exact schema exports from src/db/schema.ts (do not re-read the file) -->
```typescript
export const clients = pgTable("clients", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
name: text("name").notNull(),
brand_name: text("brand_name").notNull(),
brief: text("brief").notNull(),
token: text("token").notNull().unique().$defaultFn(() => nanoid()),
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const payments = pgTable("payments", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
client_id: text("client_id").notNull().references(() => clients.id, { onDelete: "cascade" }),
label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%"
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
paid_at: timestamp("paid_at", { withTimezone: true }),
});
export type Client = typeof clients.$inferSelect;
export type NewClient = typeof clients.$inferInsert;
export type Payment = typeof payments.$inferSelect;
```
From src/db/index.ts:
```typescript
export const db = drizzle(client); // drizzle-orm/postgres-js
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create src/lib/admin-queries.ts and admin layout + NavBar component</name>
<files>
src/lib/admin-queries.ts
src/app/admin/layout.tsx
src/components/admin/NavBar.tsx
</files>
<action>
Create `src/lib/admin-queries.ts` — all admin-side DB reads live here:
```typescript
import { db } from "@/db";
import { clients, payments } from "@/db/schema";
import { eq } from "drizzle-orm";
export type ClientWithPayments = {
id: string;
name: string;
brand_name: string;
token: string;
accepted_total: string;
created_at: Date;
payments: Array<{
id: string;
label: string;
status: string;
amount: string;
}>;
};
export async function getAllClientsWithPayments(): Promise<ClientWithPayments[]> {
const allClients = await db
.select()
.from(clients)
.orderBy(clients.created_at);
if (allClients.length === 0) return [];
const allPayments = await db
.select()
.from(payments);
return allClients.map((c) => ({
id: c.id,
name: c.name,
brand_name: c.brand_name,
token: c.token,
accepted_total: c.accepted_total ?? "0",
created_at: c.created_at,
payments: allPayments
.filter((p) => p.client_id === c.id)
.map((p) => ({
id: p.id,
label: p.label,
status: p.status,
amount: p.amount,
})),
}));
}
export async function getClientById(id: string) {
const rows = await db
.select()
.from(clients)
.where(eq(clients.id, id))
.limit(1);
return rows[0] ?? null;
}
```
Create `src/components/admin/NavBar.tsx` — minimal nav per D-09 (no sidebar):
```typescript
"use client";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
export function NavBar() {
return (
<nav className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-6">
<span className="font-semibold text-gray-900">ClientHub</span>
<Link
href="/admin"
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
Clienti
</Link>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => signOut({ callbackUrl: "/admin/login" })}
className="text-sm text-gray-500"
>
Esci
</Button>
</nav>
);
}
```
Create `src/app/admin/layout.tsx` — wraps all /admin/* pages:
```typescript
import { NavBar } from "@/components/admin/NavBar";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
<NavBar />
<main className="max-w-5xl mx-auto px-6 py-8">{children}</main>
</div>
);
}
```
</action>
<verify>
<automated>test -f src/lib/admin-queries.ts && grep -q "getAllClientsWithPayments" src/lib/admin-queries.ts && echo "admin-queries.ts created"</automated>
<automated>grep -q "getClientById" src/lib/admin-queries.ts && echo "getClientById exported"</automated>
<automated>test -f src/components/admin/NavBar.tsx && grep -q "signOut" src/components/admin/NavBar.tsx && echo "NavBar with logout"</automated>
<automated>test -f src/app/admin/layout.tsx && grep -q "NavBar" src/app/admin/layout.tsx && echo "Admin layout wraps NavBar"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
</verify>
<done>
- src/lib/admin-queries.ts exports getAllClientsWithPayments() and getClientById()
- NavBar renders with "Clienti" link and "Esci" button
- Admin layout wraps all /admin/* pages with NavBar + centered main content area
- npm run build passes
</done>
</task>
<task type="auto">
<name>Task 2: Build /admin client list page and /admin/clients/new create-client flow</name>
<files>
src/app/admin/page.tsx
src/components/admin/ClientRow.tsx
src/app/admin/clients/new/page.tsx
src/app/admin/clients/new/actions.ts
</files>
<action>
Create `src/components/admin/ClientRow.tsx` — single row in client list table:
```typescript
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import type { ClientWithPayments } from "@/lib/admin-queries";
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
da_saldare: { label: "Da saldare", variant: "destructive" },
inviata: { label: "Inviata", variant: "secondary" },
saldato: { label: "Saldato", variant: "default" },
};
export function ClientRow({ client }: { client: ClientWithPayments }) {
const acconto = client.payments.find((p) => p.label.includes("Acconto"));
const saldo = client.payments.find((p) => p.label.includes("Saldo"));
return (
<tr className="border-b border-gray-100 hover:bg-gray-50 transition-colors">
<td className="py-3 px-4">
<Link
href={`/admin/clients/${client.id}`}
className="font-medium text-gray-900 hover:underline"
>
{client.name}
</Link>
<p className="text-xs text-gray-400">{client.brand_name}</p>
</td>
<td className="py-3 px-4 text-sm text-gray-600">
€ {parseFloat(client.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</td>
<td className="py-3 px-4">
{acconto && (
<Badge variant={statusConfig[acconto.status]?.variant ?? "outline"}>
Acconto: {statusConfig[acconto.status]?.label ?? acconto.status}
</Badge>
)}
</td>
<td className="py-3 px-4">
{saldo && (
<Badge variant={statusConfig[saldo.status]?.variant ?? "outline"}>
Saldo: {statusConfig[saldo.status]?.label ?? saldo.status}
</Badge>
)}
</td>
<td className="py-3 px-4">
<a
href={`/c/${client.token}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline font-mono"
>
/c/{client.token.slice(0, 10)}…
</a>
</td>
</tr>
);
}
```
Create `src/app/admin/page.tsx` — Server Component, no client state:
```typescript
import Link from "next/link";
import { getAllClientsWithPayments } from "@/lib/admin-queries";
import { ClientRow } from "@/components/admin/ClientRow";
import { Button } from "@/components/ui/button";
export const revalidate = 0; // always fresh — admin needs real-time data
export default async function AdminDashboard() {
const clients = await getAllClientsWithPayments();
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900">Clienti</h1>
<Button asChild>
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
</Button>
</div>
{clients.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<p>Nessun cliente ancora.</p>
<p className="mt-2">
<Link href="/admin/clients/new" className="text-blue-600 hover:underline">
Crea il primo cliente
</Link>
</p>
</div>
) : (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left py-3 px-4 font-medium text-gray-600">Cliente</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Totale</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Acconto</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Saldo</th>
<th className="text-left py-3 px-4 font-medium text-gray-600">Link</th>
</tr>
</thead>
<tbody>
{clients.map((client) => (
<ClientRow key={client.id} client={client} />
))}
</tbody>
</table>
</div>
)}
</div>
);
}
```
Create `src/app/admin/clients/new/actions.ts` — Server Action (per D-05):
```typescript
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/db";
import { clients, payments } from "@/db/schema";
const createClientSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Nome brand richiesto"),
brief: z.string().min(1, "Brief richiesto"),
});
export async function createClient(formData: FormData) {
const raw = {
name: formData.get("name") as string,
brand_name: formData.get("brand_name") as string,
brief: formData.get("brief") as string,
};
const parsed = createClientSchema.safeParse(raw);
if (!parsed.success) {
// In v1 return errors as thrown string — form displays validation inline
throw new Error(parsed.error.issues.map((i) => i.message).join(", "));
}
// Insert client — token and id are auto-generated by $defaultFn(() => nanoid())
const [newClient] = await db
.insert(clients)
.values({
name: parsed.data.name,
brand_name: parsed.data.brand_name,
brief: parsed.data.brief,
})
.returning({ id: clients.id, token: clients.token });
// Always create two payment stubs per client — Acconto 50% and Saldo 50%
// Amounts default to 0 until admin sets accepted_total; admin updates separately
await db.insert(payments).values([
{
client_id: newClient.id,
label: "Acconto 50%",
amount: "0",
status: "da_saldare",
},
{
client_id: newClient.id,
label: "Saldo 50%",
amount: "0",
status: "da_saldare",
},
]);
revalidatePath("/admin");
redirect(`/admin/clients/${newClient.id}`);
}
```
Create `src/app/admin/clients/new/page.tsx` — form using the Server Action:
```typescript
import { createClient } from "./actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
export default function NewClientPage() {
return (
<div className="max-w-xl">
<div className="mb-6">
<Link href="/admin" className="text-sm text-gray-500 hover:text-gray-700">
← Clienti
</Link>
</div>
<Card>
<CardHeader>
<CardTitle>Nuovo cliente</CardTitle>
</CardHeader>
<CardContent>
<form action={createClient} className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name">Nome cliente</Label>
<Input
id="name"
name="name"
type="text"
placeholder="es. Marco Rossi"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="brand_name">Nome brand</Label>
<Input
id="brand_name"
name="brand_name"
type="text"
placeholder="es. Rossi Studio"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="brief">Brief del progetto</Label>
<Textarea
id="brief"
name="brief"
placeholder="Descrizione del progetto e degli obiettivi..."
rows={5}
required
/>
</div>
<div className="flex gap-3 pt-2">
<Button type="submit">Crea cliente</Button>
<Button variant="outline" asChild>
<Link href="/admin">Annulla</Link>
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
```
</action>
<verify>
<automated>test -f src/app/admin/page.tsx && grep -q "getAllClientsWithPayments" src/app/admin/page.tsx && echo "Admin page fetches clients"</automated>
<automated>grep -q "ClientRow" src/app/admin/page.tsx && echo "ClientRow used in table"</automated>
<automated>test -f src/app/admin/clients/new/actions.ts && grep -q '"use server"' src/app/admin/clients/new/actions.ts && echo "Server Action directive present"</automated>
<automated>grep -q "db.insert(clients)" src/app/admin/clients/new/actions.ts && echo "client insert present"</automated>
<automated>grep -q "Acconto 50%" src/app/admin/clients/new/actions.ts && grep -q "Saldo 50%" src/app/admin/clients/new/actions.ts && echo "both payment stubs inserted"</automated>
<automated>grep -q "createClientSchema" src/app/admin/clients/new/actions.ts && echo "Zod validation present"</automated>
<automated>test -f src/app/admin/clients/new/page.tsx && grep -q "action={createClient}" src/app/admin/clients/new/page.tsx && echo "form wired to Server Action"</automated>
<automated>npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK"</automated>
</verify>
<done>
- /admin shows table of clients with name, brand, totale, acconto badge, saldo badge, client link
- Empty state shows "Nessun cliente ancora" with link to create
- /admin/clients/new shows form with name, brand_name, brief fields
- Submitting the form inserts client row (token auto-generated by nanoid) + 2 payment stubs
- After creation, admin is redirected to /admin/clients/[id] (detail page — stub until Plan 03)
- npm run build passes
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin browser → Server Action | createClient() runs server-side; input validated with Zod before any DB write |
| Admin browser → /admin/* | Middleware session guard (02-01) prevents unauthenticated access to all admin pages and Server Actions |
| Client token → DB | Token generated server-side by nanoid(), never user-supplied; cannot be guessed |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-06 | Tampering | createClient Server Action | mitigate | Zod validates all input fields before DB insert; malformed input throws, no partial writes |
| T-02-07 | Information Disclosure | Client list page | mitigate | /admin/* protected by middleware session guard from 02-01; unauthenticated requests never reach this Server Component |
| T-02-08 | Tampering | token generation | mitigate | token is $defaultFn(() => nanoid()) — server-generated, cryptographically random, never derived from user input |
| T-02-09 | Information Disclosure | ClientRow renders full token | accept | Token is shown truncated in UI for usability; full token accessible via /c/[token] link only — acceptable since admin has session auth |
</threat_model>
<verification>
After plan execution:
1. `npm run build` — no errors
2. Log in as admin, visit /admin — client table renders (empty or with seeded data)
3. Click "+ Nuovo cliente" → /admin/clients/new loads with form
4. Submit form with valid data → redirects to /admin/clients/[id] (stub page acceptable at this point)
5. Return to /admin → new client appears in table with "Da saldare" badges for Acconto and Saldo
6. Click the /c/[token] link in the table → opens client dashboard (Phase 1 output)
</verification>
<success_criteria>
- /admin shows all clients with payment status badges; empty state handled gracefully
- New client can be created via form; token is auto-generated server-side
- Two payment stubs (Acconto 50% / Saldo 50%) are created automatically on client creation
- Admin can immediately share /c/[token] after creating a client
- npm run build passes cleanly
</success_criteria>
<output>
After completion, create `.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md`
</output>