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:
@@ -86,6 +86,7 @@ export default async function ProjectDetailPage({
|
|||||||
payments={payments}
|
payments={payments}
|
||||||
acceptedTotal={project.accepted_total ?? "0"}
|
acceptedTotal={project.accepted_total ?? "0"}
|
||||||
clientId={id}
|
clientId={id}
|
||||||
|
projectId={id}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { projects, clients } from "@/db/schema";
|
import { projects, clients, payments } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
@@ -53,3 +53,58 @@ export async function updateProjectAcceptedTotal(projectId: string, acceptedTota
|
|||||||
await db.update(projects).set({ accepted_total: acceptedTotal }).where(eq(projects.id, projectId));
|
await db.update(projects).set({ accepted_total: acceptedTotal }).where(eq(projects.id, projectId));
|
||||||
revalidatePath(`/admin/projects/${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}`);
|
||||||
|
}
|
||||||
@@ -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 })} |
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,15 +2,18 @@ import {
|
|||||||
updatePaymentStatus,
|
updatePaymentStatus,
|
||||||
updateAcceptedTotal,
|
updateAcceptedTotal,
|
||||||
} from "@/app/admin/clients/[id]/actions";
|
} from "@/app/admin/clients/[id]/actions";
|
||||||
|
import { initProjectPayments } from "@/app/admin/projects/project-actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { SplitPaymentForm } from "@/components/admin/SplitPaymentForm";
|
||||||
import type { Payment } from "@/db/schema";
|
import type { Payment } from "@/db/schema";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
payments: Payment[];
|
payments: Payment[];
|
||||||
acceptedTotal: string;
|
acceptedTotal: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
projectId?: string; // set only from project detail page — enables init & split
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
@@ -19,7 +22,7 @@ const statusLabels: Record<string, string> = {
|
|||||||
saldato: "Saldato",
|
saldato: "Saldato",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
|
export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-md">
|
<div className="space-y-6 max-w-md">
|
||||||
{/* Accepted total */}
|
{/* Accepted total */}
|
||||||
@@ -53,20 +56,50 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props)
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 () => {
|
||||||
|
"use server";
|
||||||
|
await initProjectPayments(projectId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Crea Acconto & Saldo
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Payment rows */}
|
{/* Payment rows */}
|
||||||
{payments.map((p) => (
|
{payments.map((p) => {
|
||||||
|
const amount = parseFloat(p.amount);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className="bg-white border border-gray-200 rounded-lg p-4"
|
className="bg-white border border-gray-200 rounded-lg p-4"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-gray-600">
|
||||||
€{" "}
|
€{" "}
|
||||||
{parseFloat(p.amount).toLocaleString("it-IT", {
|
{amount.toLocaleString("it-IT", {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
{projectId && amount > 0 && (
|
||||||
|
<SplitPaymentForm
|
||||||
|
paymentId={p.id}
|
||||||
|
projectId={projectId}
|
||||||
|
currentAmount={amount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
action={async (fd: FormData) => {
|
action={async (fd: FormData) => {
|
||||||
@@ -91,7 +124,8 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props)
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user