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

22 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 02 execute 2
02-01
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
true
ADMIN-01
ADMIN-02
truths artifacts key_links
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
path provides contains
src/app/admin/page.tsx Admin client list — Server Component fetching all clients with payments export default async function
path provides contains
src/app/admin/layout.tsx Admin layout with minimal NavBar (logo + Clienti link + logout button) NavBar
path provides min_lines
src/app/admin/clients/new/page.tsx New client form page 30
path provides contains
src/app/admin/clients/new/actions.ts Server Action: createClient() — inserts client + 2 payment rows createClient
path provides contains
src/lib/admin-queries.ts Admin-side DB query functions (getAllClientsWithPayments) getAllClientsWithPayments
from to via pattern
src/app/admin/page.tsx src/lib/admin-queries.ts getAllClientsWithPayments() getAllClientsWithPayments
from to via pattern
src/app/admin/clients/new/page.tsx src/app/admin/clients/new/actions.ts createClient Server Action createClient
from to via pattern
createClient action clients + payments tables db.insert(clients) + db.insert(payments) x2 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 ```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<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>
  );
}
```
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<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>
  );
}
```
test -f src/app/admin/page.tsx && grep -q "getAllClientsWithPayments" src/app/admin/page.tsx && echo "Admin page fetches clients" grep -q "ClientRow" src/app/admin/page.tsx && echo "ClientRow used in table" 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" grep -q "db.insert(clients)" src/app/admin/clients/new/actions.ts && echo "client insert present" 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" grep -q "createClientSchema" src/app/admin/clients/new/actions.ts && echo "Zod validation present" 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" npm run build 2>&1 | grep -v "warning" | grep -qi "error" && echo "BUILD ERRORS" || echo "TypeScript OK" - /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

<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>
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)

<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>
After completion, create `.planning/phases/02-admin-area-interactive-features/02-02-SUMMARY.md`