feat: payments plans, phase auto-cascade, transcripts, manual timer
- Pagamenti: totale ereditato dalla somma accepted_total delle offerte attive (con override) + selettore schema rate 1/2/3 step; nuova colonna payments.percent per rescalare gli importi preservando label/stato - Fasi & Task: recomputePhaseStatus auto-cascade (task -> fase) su updateTaskStatus/addTask, fix UI stale, etichette Da iniziare/In corso/Completata - Pagina pubblica: colori fasi (grigio/blu/#1A463C) + fasi collassabili - Transcript del cliente visibili in tab Documenti admin e dashboard pubblica - Timer: inserimento manuale (+30/+60, minuti liberi, data) + elimina entry - CLAUDE.md: procedura Deploy & DB Access; push su main automatico Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
updatePaymentStatus,
|
||||
updateAcceptedTotal,
|
||||
} from "@/app/admin/clients/[id]/actions";
|
||||
import { initProjectPayments } from "@/app/admin/projects/project-actions";
|
||||
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -14,6 +18,7 @@ type Props = {
|
||||
acceptedTotal: string;
|
||||
clientId: string;
|
||||
projectId?: string; // set only from project detail page — enables init & split
|
||||
offersAcceptedTotal?: number; // sum of accepted_total from active offers
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -22,28 +27,111 @@ const statusLabels: Record<string, string> = {
|
||||
saldato: "Saldato",
|
||||
};
|
||||
|
||||
export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId }: Props) {
|
||||
type PlanMode = "single" | "two" | "three";
|
||||
|
||||
const planOptions: { mode: PlanMode; label: string; description: string }[] = [
|
||||
{ mode: "single", label: "Pagamento unico", description: "100% in un'unica soluzione" },
|
||||
{ mode: "two", label: "2 step", description: "Acconto 50% + Saldo 50%" },
|
||||
{ mode: "three", label: "3 step", description: "50% + 30% + 20%" },
|
||||
];
|
||||
|
||||
export function PaymentsTab({
|
||||
payments,
|
||||
acceptedTotal,
|
||||
clientId,
|
||||
projectId,
|
||||
offersAcceptedTotal = 0,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [overrideValue, setOverrideValue] = useState(acceptedTotal);
|
||||
const [planLoading, setPlanLoading] = useState<PlanMode | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState<string | null>(null);
|
||||
|
||||
const currentTotal = parseFloat(acceptedTotal) || 0;
|
||||
const offersTotal = offersAcceptedTotal;
|
||||
const hasOffers = offersTotal > 0;
|
||||
|
||||
async function handleUseOffersTotal() {
|
||||
if (!projectId) return;
|
||||
setOverrideValue(offersTotal.toFixed(2));
|
||||
const fd = new FormData();
|
||||
fd.set("accepted_total", offersTotal.toFixed(2));
|
||||
await updateAcceptedTotal(projectId, fd);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleSaveTotal(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData();
|
||||
fd.set("accepted_total", overrideValue);
|
||||
await updateAcceptedTotal(clientId, fd);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleSetPlan(mode: PlanMode) {
|
||||
if (!projectId) return;
|
||||
setPlanLoading(mode);
|
||||
try {
|
||||
const total = parseFloat(overrideValue) || currentTotal;
|
||||
await setPaymentPlan(projectId, mode, total);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setPlanLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusUpdate(paymentId: string, status: string) {
|
||||
setStatusLoading(paymentId);
|
||||
try {
|
||||
await updatePaymentStatus(paymentId, clientId, status);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setStatusLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
{/* Totale preventivo */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<h3 className="font-medium text-gray-900">Totale preventivo</h3>
|
||||
|
||||
{/* Totale ereditato dalle offerte */}
|
||||
{hasOffers && (
|
||||
<div className="flex items-center justify-between bg-[#f9f9f9] border border-[#e5e7eb] rounded-md px-3 py-2 gap-3">
|
||||
<span className="text-sm text-[#71717a]">
|
||||
Ereditato dalle offerte attive:{" "}
|
||||
<span className="font-semibold text-[#1a1a1a]">
|
||||
€{" "}
|
||||
{offersTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</span>
|
||||
{projectId && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleUseOffersTotal}
|
||||
className="shrink-0 text-xs"
|
||||
>
|
||||
Usa questo totale
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Override manuale */}
|
||||
<form onSubmit={handleSaveTotal} className="flex items-end gap-3">
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="accepted_total">Importo (€)</Label>
|
||||
<Label htmlFor="accepted_total">Importo override (€)</Label>
|
||||
<Input
|
||||
id="accepted_total"
|
||||
name="accepted_total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={acceptedTotal}
|
||||
value={overrideValue}
|
||||
onChange={(e) => setOverrideValue(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -51,40 +139,71 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
||||
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>
|
||||
|
||||
{/* Selettore schema rate */}
|
||||
{projectId && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<h3 className="font-medium text-gray-900">Schema di pagamento</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{planOptions.map(({ mode, label, description }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => handleSetPlan(mode)}
|
||||
disabled={planLoading !== null}
|
||||
className={`flex-1 min-w-[120px] border rounded-lg px-3 py-2.5 text-left transition-colors ${
|
||||
planLoading === mode
|
||||
? "border-[#1A463C] bg-[#1A463C]/5 opacity-70"
|
||||
: "border-[#e5e7eb] hover:border-[#1A463C] hover:bg-[#1A463C]/5"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{label}</p>
|
||||
<p className="text-xs text-[#71717a] mt-0.5">{description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Selezionare uno schema cancella le rate esistenti e crea le nuove in base al totale corrente.
|
||||
</p>
|
||||
</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.
|
||||
Nessun pagamento configurato. Scegli uno schema sopra o crea le rate standard.
|
||||
</p>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await initProjectPayments(projectId);
|
||||
}}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => handleSetPlan("two")}
|
||||
disabled={planLoading !== null}
|
||||
>
|
||||
<Button type="submit" size="sm">
|
||||
Crea Acconto & Saldo
|
||||
</Button>
|
||||
</form>
|
||||
Crea Acconto & Saldo (50/50)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment rows */}
|
||||
{payments.map((p) => {
|
||||
const amount = parseFloat(p.amount);
|
||||
const pct = p.percent !== null && p.percent !== undefined
|
||||
? parseFloat(String(p.percent))
|
||||
: null;
|
||||
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>
|
||||
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
||||
{pct !== null && (
|
||||
<p className="text-xs text-[#71717a]">{pct}% del totale</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
€{" "}
|
||||
@@ -101,16 +220,14 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={p.status}
|
||||
disabled={statusLoading === p.id}
|
||||
onChange={async (e) => {
|
||||
await handleStatusUpdate(p.id, e.target.value);
|
||||
}}
|
||||
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
|
||||
>
|
||||
{Object.entries(statusLabels).map(([val, label]) => (
|
||||
@@ -119,13 +236,13 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
Aggiorna
|
||||
</Button>
|
||||
</form>
|
||||
{statusLoading === p.id && (
|
||||
<span className="text-xs text-[#71717a]">...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user