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:
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ApproveButton } from "@/components/client/ApproveButton";
|
||||
import type { ClientView } from "@/lib/client-view";
|
||||
|
||||
type Phase = ClientView["phases"][number];
|
||||
|
||||
const phaseStatusLabel: Record<"upcoming" | "active" | "done", string> = {
|
||||
upcoming: "Da iniziare",
|
||||
active: "In corso",
|
||||
done: "Completata",
|
||||
};
|
||||
|
||||
const phaseStatusStyle: Record<"upcoming" | "active" | "done", string> = {
|
||||
upcoming: "border-transparent bg-[#999999] text-white",
|
||||
active: "border-transparent bg-[#3b82f6] text-white",
|
||||
done: "border-transparent bg-[#1A463C] text-white",
|
||||
};
|
||||
|
||||
function PhaseStatusIcon({ status }: { status: "upcoming" | "active" | "done" }) {
|
||||
if (status === "done") {
|
||||
return (
|
||||
<svg className="w-5 h-5 text-[#1A463C]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (status === "active") {
|
||||
return (
|
||||
<svg className="w-5 h-5 text-[#3b82f6]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-5 h-5 text-[#999999]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx={12} cy={12} r={9} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) {
|
||||
if (status === "done") {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-[#1A463C] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (status === "in_progress") {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-[#3b82f6] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-4 h-4 text-[#999999] shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx={12} cy={12} r={9} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PhaseCard({
|
||||
phase,
|
||||
token,
|
||||
defaultOpen,
|
||||
}: {
|
||||
phase: Phase;
|
||||
token: string;
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const doneCount = phase.tasks.filter((t) => t.status === "done").length;
|
||||
|
||||
return (
|
||||
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-5">
|
||||
{/* Header — always visible, click to toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<PhaseStatusIcon status={phase.status} />
|
||||
<h3 className="text-base font-bold text-[#1a1a1a] leading-snug">{phase.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge className={`text-xs ${phaseStatusStyle[phase.status]}`}>
|
||||
{phaseStatusLabel[phase.status]}
|
||||
</Badge>
|
||||
<svg
|
||||
className={`w-4 h-4 text-[#999999] transition-transform ${open ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar — always visible */}
|
||||
<div className="mb-1">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<p className="text-xs text-[#666666] font-medium">{doneCount} di {phase.tasks.length} task</p>
|
||||
<p className="text-xs font-semibold text-[#1a1a1a]">{phase.progress_pct}%</p>
|
||||
</div>
|
||||
<Progress value={phase.progress_pct} className="h-1.5" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Collapsible task list */}
|
||||
{open && (
|
||||
<div className="mt-4">
|
||||
{phase.tasks.length === 0 ? (
|
||||
<p className="text-xs text-[#999999] italic">Nessun task ancora configurato.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{phase.tasks.map((task) => (
|
||||
<li key={task.id} className="flex items-start gap-2.5">
|
||||
<TaskStatusIcon status={task.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm leading-snug ${
|
||||
task.status === "done"
|
||||
? "line-through text-[#999999]"
|
||||
: "text-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
{task.description && (
|
||||
<p className="text-xs text-[#999999] mt-0.5 leading-snug">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
{task.deliverables.length > 0 && (
|
||||
<ul className="mt-1.5 space-y-2">
|
||||
{task.deliverables.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="bg-[#f9f9f9] rounded px-3 py-2 flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="text-xs text-[#666666] truncate font-medium">
|
||||
{d.title}
|
||||
</span>
|
||||
{(d.status === "pending" ||
|
||||
d.status === "submitted" ||
|
||||
d.approved_at !== null) && (
|
||||
<ApproveButton
|
||||
deliverableId={d.id}
|
||||
token={token}
|
||||
approvedAt={d.approved_at}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user