diff --git a/src/app/admin/projects/[id]/page.tsx b/src/app/admin/projects/[id]/page.tsx index 15490e0..8ef0f70 100644 --- a/src/app/admin/projects/[id]/page.tsx +++ b/src/app/admin/projects/[id]/page.tsx @@ -86,6 +86,7 @@ export default async function ProjectDetailPage({ payments={payments} acceptedTotal={project.accepted_total ?? "0"} clientId={id} + projectId={id} /> diff --git a/src/app/admin/projects/project-actions.ts b/src/app/admin/projects/project-actions.ts index f509845..70f62e8 100644 --- a/src/app/admin/projects/project-actions.ts +++ b/src/app/admin/projects/project-actions.ts @@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; import { db } from "@/db"; -import { projects, clients } from "@/db/schema"; +import { projects, clients, payments } from "@/db/schema"; import { eq } from "drizzle-orm"; import { nanoid } from "nanoid"; @@ -51,5 +51,60 @@ export async function unarchiveProject(projectId: string): Promise { export async function updateProjectAcceptedTotal(projectId: string, acceptedTotal: string): Promise { await requireAdmin(); await db.update(projects).set({ accepted_total: acceptedTotal }).where(eq(projects.id, projectId)); + revalidatePath(`/admin/projects/${projectId}`); +} + +// Creates Acconto 50% + Saldo 50% stubs for a project that has no payments yet. +export async function initProjectPayments(projectId: string): Promise { + await requireAdmin(); + const rows = await db + .select({ accepted_total: projects.accepted_total }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + if (!rows[0]) throw new Error("Progetto non trovato"); + const total = parseFloat(rows[0].accepted_total ?? "0"); + const half = (total / 2).toFixed(2); + await db.insert(payments).values([ + { project_id: projectId, label: "Acconto 50%", amount: half, status: "da_saldare" }, + { project_id: projectId, label: "Saldo 50%", amount: half, status: "da_saldare" }, + ]); + revalidatePath(`/admin/projects/${projectId}`); +} + +// Splits one payment into two with custom amounts. The original row keeps +// its label (+ " – Rata 1"), a new row is created with the remainder. +export async function splitPayment( + paymentId: string, + projectId: string, + firstAmountStr: string +): Promise { + await requireAdmin(); + const first = parseFloat(firstAmountStr); + if (isNaN(first) || first <= 0) throw new Error("Importo non valido"); + + const rows = await db.select().from(payments).where(eq(payments.id, paymentId)).limit(1); + if (!rows[0]) throw new Error("Pagamento non trovato"); + + const original = rows[0]; + const total = parseFloat(original.amount); + const second = parseFloat((total - first).toFixed(2)); + if (second <= 0) throw new Error("Il primo importo deve essere inferiore al totale"); + + // Strip any existing " – Rata N" suffix so re-splits stay clean. + const baseLabel = original.label.replace(/ – Rata \d+$/, ""); + + await db + .update(payments) + .set({ amount: first.toFixed(2), label: `${baseLabel} – Rata 1` }) + .where(eq(payments.id, paymentId)); + + await db.insert(payments).values({ + project_id: projectId, + label: `${baseLabel} – Rata 2`, + amount: second.toFixed(2), + status: original.status, + }); + revalidatePath(`/admin/projects/${projectId}`); } \ No newline at end of file diff --git a/src/components/admin/SplitPaymentForm.tsx b/src/components/admin/SplitPaymentForm.tsx new file mode 100644 index 0000000..4abd199 --- /dev/null +++ b/src/components/admin/SplitPaymentForm.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { splitPayment } from "@/app/admin/projects/project-actions"; + +interface Props { + paymentId: string; + projectId: string; + currentAmount: number; +} + +export function SplitPaymentForm({ paymentId, projectId, currentAmount }: Props) { + const [open, setOpen] = useState(false); + const [firstAmount, setFirstAmount] = useState(""); + const [isPending, startTransition] = useTransition(); + const router = useRouter(); + + const first = parseFloat(firstAmount) || 0; + const second = parseFloat((currentAmount - first).toFixed(2)); + const isValid = first > 0 && first < currentAmount; + + function handleOpen() { + setFirstAmount((currentAmount / 2).toFixed(2)); + setOpen(true); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!isValid) return; + startTransition(async () => { + await splitPayment(paymentId, projectId, first.toFixed(2)); + setOpen(false); + setFirstAmount(""); + router.refresh(); + }); + } + + if (!open) { + return ( + + ); + } + + return ( +
+

Imposta il primo importo — il resto diventa la seconda rata:

+
+
+ setFirstAmount(e.target.value)} + className="h-8 text-sm" + required + /> + {isValid && ( +

+ Rata 1: € {first.toLocaleString("it-IT", { minimumFractionDigits: 2 })}  |  + Rata 2: € {second.toLocaleString("it-IT", { minimumFractionDigits: 2 })} +

+ )} + {firstAmount && !isValid && ( +

+ Inserisci un importo tra 0,01 e {(currentAmount - 0.01).toLocaleString("it-IT", { minimumFractionDigits: 2 })} +

+ )} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/admin/tabs/PaymentsTab.tsx b/src/components/admin/tabs/PaymentsTab.tsx index a20bb1b..c9e2908 100644 --- a/src/components/admin/tabs/PaymentsTab.tsx +++ b/src/components/admin/tabs/PaymentsTab.tsx @@ -2,15 +2,18 @@ import { updatePaymentStatus, updateAcceptedTotal, } from "@/app/admin/clients/[id]/actions"; +import { initProjectPayments } from "@/app/admin/projects/project-actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { SplitPaymentForm } from "@/components/admin/SplitPaymentForm"; import type { Payment } from "@/db/schema"; type Props = { payments: Payment[]; acceptedTotal: string; clientId: string; + projectId?: string; // set only from project detail page — enables init & split }; const statusLabels: Record = { @@ -19,7 +22,7 @@ const statusLabels: Record = { saldato: "Saldato", }; -export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) { +export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId }: Props) { return (
{/* Accepted total */} @@ -53,45 +56,76 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props)

- {/* Payment rows */} - {payments.map((p) => ( -
-
-

{p.label}

- - €{" "} - {parseFloat(p.amount).toLocaleString("it-IT", { - minimumFractionDigits: 2, - })} - -
+ {/* Empty state — project has no payments yet */} + {projectId && payments.length === 0 && ( +
+

+ Nessun pagamento configurato. Crea le rate standard Acconto e Saldo. +

{ + action={async () => { "use server"; - await updatePaymentStatus(p.id, clientId, fd.get("status") as string); + await initProjectPayments(projectId); }} - className="flex items-center gap-2" > - -
- ))} + )} + + {/* Payment rows */} + {payments.map((p) => { + const amount = parseFloat(p.amount); + return ( +
+
+

{p.label}

+
+ + €{" "} + {amount.toLocaleString("it-IT", { + minimumFractionDigits: 2, + })} + + {projectId && amount > 0 && ( + + )} +
+
+
{ + "use server"; + await updatePaymentStatus(p.id, clientId, fd.get("status") as string); + }} + className="flex items-center gap-2" + > + + +
+
+ ); + })}
); } \ No newline at end of file