56dd18b0c2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
562 lines
22 KiB
Markdown
562 lines
22 KiB
Markdown
---
|
|
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>
|