feat: init payments + split payment in project workspace

- initProjectPayments: crea Acconto 50%/Saldo 50% se il progetto non ha pagamenti
- splitPayment: divide un pagamento in due rate con importi custom
- SplitPaymentForm: form client interattivo con anteprima live della rata 2
- PaymentsTab: stato vuoto con CTA + pulsante Dividi per ogni riga
- projectId prop opzionale per attivare le feature solo nel contesto progetto
This commit is contained in:
2026-05-23 10:18:06 +02:00
parent 0571040d2f
commit 75097ccfa4
4 changed files with 215 additions and 34 deletions
+1
View File
@@ -86,6 +86,7 @@ export default async function ProjectDetailPage({
payments={payments}
acceptedTotal={project.accepted_total ?? "0"}
clientId={id}
projectId={id}
/>
</TabsContent>
+56 -1
View File
@@ -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<void> {
export async function updateProjectAcceptedTotal(projectId: string, acceptedTotal: string): Promise<void> {
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<void> {
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<void> {
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}`);
}
+91
View File
@@ -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 (
<Button type="button" size="sm" variant="ghost" className="text-xs h-7 px-2" onClick={handleOpen}>
Dividi
</Button>
);
}
return (
<form onSubmit={handleSubmit} className="mt-3 pt-3 border-t border-gray-100">
<p className="text-xs text-gray-500 mb-2">Imposta il primo importo il resto diventa la seconda rata:</p>
<div className="flex items-start gap-2">
<div className="flex-1 space-y-1">
<Input
type="number"
step="0.01"
min="0.01"
max={(currentAmount - 0.01).toFixed(2)}
value={firstAmount}
onChange={(e) => setFirstAmount(e.target.value)}
className="h-8 text-sm"
required
/>
{isValid && (
<p className="text-xs text-[#71717a]">
Rata 1: {first.toLocaleString("it-IT", { minimumFractionDigits: 2 })} &nbsp;|&nbsp;
Rata 2: {second.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</p>
)}
{firstAmount && !isValid && (
<p className="text-xs text-red-500">
Inserisci un importo tra 0,01 e {(currentAmount - 0.01).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</p>
)}
</div>
<Button type="submit" size="sm" className="h-8" disabled={!isValid || isPending}>
{isPending ? "…" : "Conferma"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-8"
onClick={() => { setOpen(false); setFirstAmount(""); }}
>
Annulla
</Button>
</div>
</form>
);
}
+67 -33
View File
@@ -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<string, string> = {
@@ -19,7 +22,7 @@ const statusLabels: Record<string, string> = {
saldato: "Saldato",
};
export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId }: Props) {
return (
<div className="space-y-6 max-w-md">
{/* Accepted total */}
@@ -53,45 +56,76 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props)
</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>
{/* Empty state — project has no payments yet */}
{projectId && payments.length === 0 && (
<div className="bg-white border border-dashed border-gray-300 rounded-lg p-6 text-center">
<p className="text-sm text-[#71717a] mb-4">
Nessun pagamento configurato. Crea le rate standard Acconto e Saldo.
</p>
<form
action={async (fd: FormData) => {
action={async () => {
"use server";
await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
await initProjectPayments(projectId);
}}
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 type="submit" size="sm">
Crea Acconto &amp; Saldo
</Button>
</form>
</div>
))}
)}
{/* Payment rows */}
{payments.map((p) => {
const amount = parseFloat(p.amount);
return (
<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>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">
{" "}
{amount.toLocaleString("it-IT", {
minimumFractionDigits: 2,
})}
</span>
{projectId && amount > 0 && (
<SplitPaymentForm
paymentId={p.id}
projectId={projectId}
currentAmount={amount}
/>
)}
</div>
</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>
);
}