feat(02-02): add admin client list page and create-client flow
- /admin page: Server Component fetching all clients with payment badges - ClientRow component with Acconto/Saldo status badges and secret link - /admin/clients/new: form wired to createClient Server Action - createClient action: Zod validation, inserts client + 2 payment stubs (Acconto 50%, Saldo 50%) - Token auto-generated server-side via nanoid $defaultFn - Redirects to /admin/clients/[id] after creation; revalidates /admin
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
"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}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user