--- 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" --- **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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/ROADMAP.md @.planning/phases/02-admin-area-interactive-features/02-CONTEXT.md @.planning/phases/02-admin-area-interactive-features/02-01-SUMMARY.md ```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 ``` Task 1: Create src/lib/admin-queries.ts and admin layout + NavBar component src/lib/admin-queries.ts src/app/admin/layout.tsx src/components/admin/NavBar.tsx 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 { 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 ( ); } ``` 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 (
{children}
); } ```
test -f src/lib/admin-queries.ts && grep -q "getAllClientsWithPayments" src/lib/admin-queries.ts && echo "admin-queries.ts created" grep -q "getClientById" src/lib/admin-queries.ts && echo "getClientById exported" test -f src/components/admin/NavBar.tsx && grep -q "signOut" src/components/admin/NavBar.tsx && echo "NavBar with logout" test -f src/app/admin/layout.tsx && grep -q "NavBar" src/app/admin/layout.tsx && echo "Admin layout wraps NavBar" npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" - 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
Task 2: Build /admin client list page and /admin/clients/new create-client flow src/app/admin/page.tsx src/components/admin/ClientRow.tsx src/app/admin/clients/new/page.tsx src/app/admin/clients/new/actions.ts 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 = { 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 ( {client.name}

{client.brand_name}

€ {parseFloat(client.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })} {acconto && ( Acconto: {statusConfig[acconto.status]?.label ?? acconto.status} )} {saldo && ( Saldo: {statusConfig[saldo.status]?.label ?? saldo.status} )} /c/{client.token.slice(0, 10)}… ); } ``` 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 (

Clienti

{clients.length === 0 ? (

Nessun cliente ancora.

Crea il primo cliente

) : (
{clients.map((client) => ( ))}
Cliente Totale Acconto Saldo Link
)}
); } ``` 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 (
← Clienti
Nuovo cliente