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:
2026-06-23 10:38:56 +02:00
parent 988f6f425a
commit 186bb9ea19
21 changed files with 1037 additions and 196 deletions
+157 -40
View File
@@ -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 &amp; Saldo
</Button>
</form>
Crea Acconto &amp; 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>
);
}
}