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
+18 -111
View File
@@ -1,70 +1,11 @@
import type { ClientView } from '@/lib/client-view';
import { Progress } from '@/components/ui/progress';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ApproveButton } from './client/ApproveButton';
import { PhaseCard } from './client/PhaseCard';
interface PhaseTimelineProps {
phases: ClientView['phases'];
token: string;
}
function PhaseStatusIcon({ status }: { status: 'upcoming' | 'active' | 'done' }) {
if (status === 'done') {
return (
<svg className="w-5 h-5 text-[#16a34a]" 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-[#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 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-[#16a34a] 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-[#DEF168] 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>
);
}
const phaseStatusLabel: Record<'upcoming' | 'active' | 'done', string> = {
upcoming: 'In arrivo',
active: 'In corso',
done: 'Completata',
};
const phaseStatusStyle: Record<'upcoming' | 'active' | 'done', string> = {
upcoming: 'border-transparent bg-[#999999] text-white',
active: 'border-transparent bg-[#1A463C] text-white',
done: 'border-transparent bg-[#16a34a] text-white',
};
export function PhaseTimeline({ phases, token }: PhaseTimelineProps) {
if (phases.length === 0) {
return <p className="text-sm text-[#999999] italic">Nessuna fase ancora configurata.</p>;
@@ -73,71 +14,37 @@ export function PhaseTimeline({ phases, token }: PhaseTimelineProps) {
return (
<div className="space-y-0">
{phases.map((phase, index) => {
const doneCount = phase.tasks.filter((t) => t.status === 'done').length;
const isLast = index === phases.length - 1;
// done phases start collapsed; active and upcoming start expanded
const defaultOpen = phase.status !== 'done';
return (
<div key={phase.id} className="flex gap-5">
<div className="flex flex-col items-center">
<div className="w-10 h-10 rounded-full bg-white border-2 border-[#e5e5e5] flex items-center justify-center shrink-0 z-10">
<PhaseStatusIcon status={phase.status} />
{phase.status === 'done' ? (
<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>
) : phase.status === 'active' ? (
<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>
) : (
<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>
)}
</div>
{!isLast && <div className="flex-1 w-px bg-[#e5e5e5] my-2" style={{ minHeight: '2rem' }} />}
</div>
<div className={`flex-1 ${isLast ? 'pb-0' : 'pb-6'}`}>
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-5">
<div className="flex items-start justify-between gap-3 mb-4">
<h3 className="text-base font-bold text-[#1a1a1a] leading-snug">{phase.title}</h3>
<Badge className={`text-xs shrink-0 ${phaseStatusStyle[phase.status]}`}>
{phaseStatusLabel[phase.status]}
</Badge>
</div>
<div className="mb-5">
<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>
{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>
)}
</Card>
<PhaseCard phase={phase} token={token} defaultOpen={defaultOpen} />
</div>
</div>
);
})}
</div>
);
}
}