feat(02-03): build /admin/clients/[id] workspace with tabbed layout and all tab components
- Create /admin/clients/[id]/page.tsx — Server Component using Radix Tabs (Fasi & Task, Pagamenti, Documenti, Commenti) - Create PhasesTab: phases list with add-phase form, task lists with add-task form, status selects for phases and tasks - Create PaymentsTab: accepted_total editor (splits to 50% on each payment), payment status selects with paid_at on saldato - Create DocumentsTab: add document (label + URL) form, document list with delete action - Create CommentsTab: chronological comment display (admin vs cliente style), admin reply form with entity selector - All mutations via inline Server Action closures bound to action= props; revalidatePath ensures fresh data
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getClientFullDetail } from "@/lib/admin-queries";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||||
|
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||||
|
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||||
|
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function ClientDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const detail = await getClientFullDetail(id);
|
||||||
|
if (!detail) notFound();
|
||||||
|
|
||||||
|
const { client, phases, payments, documents, notes, comments } = detail;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Link href="/admin" className="text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
← Clienti
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{client.name}</h1>
|
||||||
|
<p className="text-sm text-gray-500">{client.brand_name}</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={`/c/${client.token}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-blue-600 hover:underline font-mono bg-blue-50 px-2 py-1 rounded"
|
||||||
|
>
|
||||||
|
Link cliente →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="phases" className="w-full">
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
<TabsTrigger value="phases">Fasi & Task</TabsTrigger>
|
||||||
|
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
||||||
|
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||||
|
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="phases">
|
||||||
|
<PhasesTab phases={phases} clientId={client.id} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="payments">
|
||||||
|
<PaymentsTab
|
||||||
|
payments={payments}
|
||||||
|
acceptedTotal={client.accepted_total ?? "0"}
|
||||||
|
clientId={client.id}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="documents">
|
||||||
|
<DocumentsTab documents={documents} clientId={client.id} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="comments">
|
||||||
|
<CommentsTab comments={comments} phases={phases} clientId={client.id} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { postAdminComment } from "@/app/admin/clients/[id]/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import type { Comment } from "@/db/schema";
|
||||||
|
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
comments: Comment[];
|
||||||
|
phases: ClientFullDetail["phases"];
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function CommentsTab({ comments, phases, clientId }: Props) {
|
||||||
|
// Build entity label map for display
|
||||||
|
const entityLabels: Record<string, string> = {};
|
||||||
|
for (const phase of phases) {
|
||||||
|
for (const task of phase.tasks) {
|
||||||
|
entityLabels[task.id] = `Task: ${task.title}`;
|
||||||
|
for (const d of task.deliverables) {
|
||||||
|
entityLabels[d.id] = `Deliverable: ${d.title}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build list of entities the admin can reply on
|
||||||
|
const entities: Array<{ id: string; type: string; label: string }> = [];
|
||||||
|
for (const phase of phases) {
|
||||||
|
for (const task of phase.tasks) {
|
||||||
|
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
|
||||||
|
for (const d of task.deliverables) {
|
||||||
|
entities.push({
|
||||||
|
id: d.id,
|
||||||
|
type: "deliverable",
|
||||||
|
label: `Deliverable: ${d.title}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-lg">
|
||||||
|
{/* Comment list */}
|
||||||
|
{comments.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400">Nessun commento ancora.</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{comments.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
className={`flex gap-3 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`rounded-lg px-3 py-2 text-sm max-w-xs ${
|
||||||
|
c.author === "admin"
|
||||||
|
? "bg-gray-900 text-white"
|
||||||
|
: "bg-white border border-gray-200 text-gray-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium mb-1 opacity-60">
|
||||||
|
{c.author === "admin" ? "iamcavalli" : "Cliente"} —{" "}
|
||||||
|
{entityLabels[c.entity_id] ?? c.entity_id}
|
||||||
|
</p>
|
||||||
|
<p>{c.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin reply form */}
|
||||||
|
{entities.length > 0 && (
|
||||||
|
<form
|
||||||
|
action={async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
await postAdminComment(clientId, fd);
|
||||||
|
}}
|
||||||
|
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium text-gray-900 text-sm">
|
||||||
|
Rispondi come admin
|
||||||
|
</h3>
|
||||||
|
<select
|
||||||
|
name="entity"
|
||||||
|
className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{entities.map((e) => (
|
||||||
|
<option key={e.id} value={`${e.type}:${e.id}`}>
|
||||||
|
{e.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Textarea
|
||||||
|
name="body"
|
||||||
|
placeholder="Scrivi un commento..."
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Invia risposta
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { addDocument, deleteDocument } from "@/app/admin/clients/[id]/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { Document } from "@/db/schema";
|
||||||
|
|
||||||
|
type Props = { documents: Document[]; clientId: string };
|
||||||
|
|
||||||
|
export async function DocumentsTab({ documents, clientId }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-lg">
|
||||||
|
<form
|
||||||
|
action={async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
await addDocument(clientId, fd);
|
||||||
|
}}
|
||||||
|
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
|
||||||
|
>
|
||||||
|
<h3 className="font-medium text-gray-900">Aggiungi documento</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="doc-label">Nome / etichetta</Label>
|
||||||
|
<Input
|
||||||
|
id="doc-label"
|
||||||
|
name="label"
|
||||||
|
placeholder="es. Brief progetto"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="doc-url">URL (Google Drive, PDF...)</Label>
|
||||||
|
<Input
|
||||||
|
id="doc-url"
|
||||||
|
name="url"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://drive.google.com/..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Aggiungi
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{documents.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400">Nessun documento ancora.</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{documents.map((doc) => (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className="flex items-center justify-between bg-white border border-gray-200 rounded-lg px-4 py-3"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={doc.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
{doc.label}
|
||||||
|
</a>
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
"use server";
|
||||||
|
await deleteDocument(doc.id, clientId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-500 hover:text-red-700 text-xs"
|
||||||
|
>
|
||||||
|
Rimuovi
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
updatePaymentStatus,
|
||||||
|
updateAcceptedTotal,
|
||||||
|
} from "@/app/admin/clients/[id]/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { Payment } from "@/db/schema";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
payments: Payment[];
|
||||||
|
acceptedTotal: string;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
da_saldare: "Da saldare",
|
||||||
|
inviata: "Inviata",
|
||||||
|
saldato: "Saldato",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-md">
|
||||||
|
{/* Accepted total */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
|
||||||
|
<form
|
||||||
|
action={async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
await updateAcceptedTotal(clientId, fd);
|
||||||
|
}}
|
||||||
|
className="flex items-end gap-3"
|
||||||
|
>
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<Label htmlFor="accepted_total">Importo (€)</Label>
|
||||||
|
<Input
|
||||||
|
id="accepted_total"
|
||||||
|
name="accepted_total"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
defaultValue={acceptedTotal}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Salva
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment rows */}
|
||||||
|
{payments.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
className="bg-white border border-gray-200 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
€{" "}
|
||||||
|
{parseFloat(p.amount).toLocaleString("it-IT", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
action={async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={p.status}
|
||||||
|
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
|
||||||
|
>
|
||||||
|
{Object.entries(statusLabels).map(([val, label]) => (
|
||||||
|
<option key={val} value={val}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button type="submit" size="sm" variant="outline">
|
||||||
|
Aggiorna
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import {
|
||||||
|
addPhase,
|
||||||
|
addTask,
|
||||||
|
updateTaskStatus,
|
||||||
|
updatePhaseStatus,
|
||||||
|
} from "@/app/admin/clients/[id]/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
phases: ClientFullDetail["phases"];
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskStatusOptions = [
|
||||||
|
{ value: "todo", label: "Da fare" },
|
||||||
|
{ value: "in_progress", label: "In corso" },
|
||||||
|
{ value: "done", label: "Fatto" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const phaseStatusOptions = [
|
||||||
|
{ value: "upcoming", label: "In arrivo" },
|
||||||
|
{ value: "active", label: "Attiva" },
|
||||||
|
{ value: "done", label: "Completata" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function PhasesTab({ phases, clientId }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Add phase form */}
|
||||||
|
<form
|
||||||
|
action={async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
await addPhase(clientId, fd);
|
||||||
|
}}
|
||||||
|
className="flex gap-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name="title"
|
||||||
|
placeholder="Nome nuova fase..."
|
||||||
|
className="max-w-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
+ Fase
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Phases list */}
|
||||||
|
{phases.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-400">Nessuna fase ancora.</p>
|
||||||
|
)}
|
||||||
|
{phases.map((phase) => (
|
||||||
|
<div
|
||||||
|
key={phase.id}
|
||||||
|
className="border border-gray-200 rounded-lg p-4 bg-white"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
|
||||||
|
<form
|
||||||
|
action={async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
await updatePhaseStatus(
|
||||||
|
phase.id,
|
||||||
|
clientId,
|
||||||
|
fd.get("status") as string
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={phase.status}
|
||||||
|
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||||
|
>
|
||||||
|
{phaseStatusOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button type="submit" variant="ghost" size="sm" className="text-xs">
|
||||||
|
Salva
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
{phase.tasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="flex items-center justify-between pl-3 border-l-2 border-gray-100"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-800">{task.title}</span>
|
||||||
|
<form
|
||||||
|
action={async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
await updateTaskStatus(
|
||||||
|
task.id,
|
||||||
|
clientId,
|
||||||
|
fd.get("status") as string
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={task.status}
|
||||||
|
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
|
||||||
|
>
|
||||||
|
{taskStatusOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs px-1"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add task form */}
|
||||||
|
<form
|
||||||
|
action={async (fd: FormData) => {
|
||||||
|
"use server";
|
||||||
|
await addTask(phase.id, clientId, fd);
|
||||||
|
}}
|
||||||
|
className="flex gap-2 mt-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name="title"
|
||||||
|
placeholder="Nuovo task..."
|
||||||
|
className="text-sm max-w-xs"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="ghost" size="sm" className="text-xs">
|
||||||
|
+ Task
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user