From 186bb9ea192bd7223b11d8dd13b951a85f4198cd Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Tue, 23 Jun 2026 10:38:56 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 14 +- src/app/admin/clients/[id]/actions.ts | 80 ++++++-- src/app/admin/projects/[id]/page.tsx | 10 +- src/app/admin/projects/project-actions.ts | 50 ++++- src/app/admin/timer-actions.ts | 56 +++++- src/app/client/[token]/page.tsx | 7 + src/components/admin/ManualTimeEntry.tsx | 177 ++++++++++++++++++ src/components/admin/TranscriptItem.tsx | 55 ++++++ src/components/admin/tabs/DocumentsTab.tsx | 51 +++-- src/components/admin/tabs/PaymentsTab.tsx | 197 ++++++++++++++++---- src/components/admin/tabs/PhasesTab.tsx | 4 +- src/components/admin/tabs/TimerTab.tsx | 14 +- src/components/client-dashboard.tsx | 7 + src/components/client/PhaseCard.tsx | 178 ++++++++++++++++++ src/components/phase-timeline.tsx | 129 ++----------- src/components/transcripts-section.tsx | 96 ++++++++++ src/db/migrations/0013_payments_percent.sql | 6 + src/db/schema.ts | 1 + src/lib/admin-queries.ts | 59 +++++- src/lib/client-view.ts | 32 +++- src/lib/lead-service.ts | 10 + 21 files changed, 1037 insertions(+), 196 deletions(-) create mode 100644 src/components/admin/ManualTimeEntry.tsx create mode 100644 src/components/admin/TranscriptItem.tsx create mode 100644 src/components/client/PhaseCard.tsx create mode 100644 src/components/transcripts-section.tsx create mode 100644 src/db/migrations/0013_payments_percent.sql diff --git a/CLAUDE.md b/CLAUDE.md index 4fa9f76..8babc03 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,9 +20,17 @@ Planning in `.planning/`. Use `/gsd-plan-phase N` → `/gsd-execute-phase N`. St - Before running any migration: verify it only adds columns/tables — never drops or truncates production data - Confirm explicitly before any schema change that removes a column or table used by these entities +## Deploy & DB Access (procedure) +- Environments: local → Gitea (`origin`) → Coolify (prod, auto-deploys on push to `main`) +- Prod Postgres is NOT publicly exposed. Fixed access point: user opens an SSH tunnel + `ssh -o ServerAliveInterval=30 -L 54321:localhost:54321 root@178.104.27.55` and leaves it running. + Claude then connects to `127.0.0.1:54321` using prod creds in `.env.prod.local` (gitignored) — no per-step commands needed from the user. +- Migrations are hand-written SQL in `src/db/migrations/` (drizzle-kit generate is broken). +- Ordering: apply an additive migration to prod BEFORE pushing the schema-dependent code, so the live portal never queries a missing column. + ## Security -- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE, infra changes) -- Never read/expose .env or credentials without explicit request +- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE / drop-column, truncate, infra changes) +- Never print .env contents or credentials in plaintext output; using them internally to connect is fine - Don't install packages without showing name + registry + version first -- Don't push to main or create PRs without explicit confirmation +- Pushing to `main` is allowed automatically (standard local → Gitea → Coolify flow); never force-push to `main` - Any change to this section: propose full new version, get approval before applying \ No newline at end of file diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index 4802d67..e3b1098 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -162,15 +162,45 @@ export async function addTask(phaseId: string, id: string, formData: FormData) { sort_order: maxOrder + 1, status: "todo", }); + // Cascade: a new todo task keeps phase active/upcoming — recompute to be safe + await recomputePhaseStatus(phaseId); const { path } = await resolveEntity(id); revalidatePath(path); } +// ── PHASE STATUS CASCADE ────────────────────────────────────────────────────── +// Recomputes phase status from its tasks: +// all done → done +// any in_progress or done (but not all done) → active +// all todo (or no tasks) → upcoming +export async function recomputePhaseStatus(phaseId: string): Promise { + const phaseTasks = await db + .select({ status: tasks.status }) + .from(tasks) + .where(eq(tasks.phase_id, phaseId)); + + let newStatus: "upcoming" | "active" | "done" = "upcoming"; + if (phaseTasks.length > 0) { + const allDone = phaseTasks.every((t) => t.status === "done"); + const anyActive = phaseTasks.some( + (t) => t.status === "in_progress" || t.status === "done" + ); + if (allDone) newStatus = "done"; + else if (anyActive) newStatus = "active"; + } + await db.update(phases).set({ status: newStatus }).where(eq(phases.id, phaseId)); +} + export async function updateTaskStatus(taskId: string, id: string, status: string) { await requireAdmin(); const allowed = ["todo", "in_progress", "done"]; if (!allowed.includes(status)) throw new Error("Stato non valido"); await db.update(tasks).set({ status }).where(eq(tasks.id, taskId)); + + // Cascade: recompute parent phase status from all its tasks + const taskRow = await db.select({ phase_id: tasks.phase_id }).from(tasks).where(eq(tasks.id, taskId)).limit(1); + if (taskRow[0]) await recomputePhaseStatus(taskRow[0].phase_id); + const { path } = await resolveEntity(id); revalidatePath(path); } @@ -240,6 +270,35 @@ export async function updatePaymentStatus(paymentId: string, id: string, status: revalidatePath(path); } +// Rescales payment amounts when the total changes. +// If the payment has a `percent` field, use it (new plan rows). +// Legacy rows (percent null) fall back to equal split across all rows. +async function rescalePayments(projectId: string, newTotal: number): Promise { + const projectPayments = await db + .select({ id: payments.id, percent: payments.percent }) + .from(payments) + .where(eq(payments.project_id, projectId)); + + if (projectPayments.length === 0) return; + + const hasPercent = projectPayments.some((p) => p.percent !== null); + + if (hasPercent) { + // New plan: rescale each row by its stored percent + for (const p of projectPayments) { + const pct = p.percent !== null ? parseFloat(String(p.percent)) : 0; + const newAmount = ((newTotal * pct) / 100).toFixed(2); + await db.update(payments).set({ amount: newAmount }).where(eq(payments.id, p.id)); + } + } else { + // Legacy: equal split across all rows (backward compat) + const share = (newTotal / projectPayments.length).toFixed(2); + for (const p of projectPayments) { + await db.update(payments).set({ amount: share }).where(eq(payments.id, p.id)); + } + } +} + export async function updateAcceptedTotal(id: string, formData: FormData) { await requireAdmin(); const raw = (formData.get("accepted_total") as string)?.trim(); @@ -253,28 +312,17 @@ export async function updateAcceptedTotal(id: string, formData: FormData) { .limit(1); if (asProject[0]) { - // Project context: update projects.accepted_total + this project's payment stubs + // Project context: update projects.accepted_total + rescale this project's payments await db.update(projects).set({ accepted_total: val.toFixed(2) }).where(eq(projects.id, id)); - const half = (val / 2).toFixed(2); - const projectPayments = await db.select({ id: payments.id }) - .from(payments).where(eq(payments.project_id, id)); - for (const p of projectPayments) { - await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id)); - } + await rescalePayments(id, val); revalidatePath(`/admin/projects/${id}`); } else { - // Client context: update clients.accepted_total + all project payment stubs + // Client context: update clients.accepted_total + rescale all project payments await db.update(clients).set({ accepted_total: val.toFixed(2) }).where(eq(clients.id, id)); const projectRows = await db.select({ id: projects.id }) .from(projects).where(eq(projects.client_id, id)); - if (projectRows.length > 0) { - const projectIds = projectRows.map((p) => p.id); - const half = (val / 2).toFixed(2); - const paymentsRows = await db.select() - .from(payments).where(inArray(payments.project_id, projectIds)); - for (const p of paymentsRows) { - await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id)); - } + for (const proj of projectRows) { + await rescalePayments(proj.id, val); } revalidatePath(`/admin/clients/${id}`); } diff --git a/src/app/admin/projects/[id]/page.tsx b/src/app/admin/projects/[id]/page.tsx index 3d895b3..9303190 100644 --- a/src/app/admin/projects/[id]/page.tsx +++ b/src/app/admin/projects/[id]/page.tsx @@ -1,5 +1,5 @@ import { notFound } from "next/navigation"; -import { getProjectFullDetail } from "@/lib/admin-queries"; +import { getProjectFullDetail, getRecentTimeEntries } from "@/lib/admin-queries"; import { getTargetHourlyRate } from "@/lib/settings"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { PhasesTab } from "@/components/admin/tabs/PhasesTab"; @@ -26,6 +26,8 @@ export default async function ProjectDetailPage({ if (!detail) notFound(); + const recentEntries = await getRecentTimeEntries(id, 5); + const { project, phases, @@ -38,6 +40,8 @@ export default async function ProjectDetailPage({ totalTrackedSeconds, projectOffers, availableMicros, + offersAcceptedTotal, + transcripts, } = detail; return ( @@ -87,11 +91,12 @@ export default async function ProjectDetailPage({ acceptedTotal={project.accepted_total ?? "0"} clientId={id} projectId={id} + offersAcceptedTotal={offersAcceptedTotal} /> - + @@ -122,6 +127,7 @@ export default async function ProjectDetailPage({ activeTimerStartedAt={activeTimerStartedAt} totalTrackedSeconds={totalTrackedSeconds} targetHourlyRate={targetHourlyRate} + recentEntries={recentEntries} /> diff --git a/src/app/admin/projects/project-actions.ts b/src/app/admin/projects/project-actions.ts index 3bdbda4..73e09c3 100644 --- a/src/app/admin/projects/project-actions.ts +++ b/src/app/admin/projects/project-actions.ts @@ -65,6 +65,7 @@ export async function updateProjectAcceptedTotal(projectId: string, acceptedTota } // Creates Acconto 50% + Saldo 50% stubs for a project that has no payments yet. +// Kept for backward compatibility; prefer setPaymentPlan for new code. export async function initProjectPayments(projectId: string): Promise { await requireAdmin(); const rows = await db @@ -74,11 +75,50 @@ export async function initProjectPayments(projectId: string): Promise { .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" }, - ]); + await setPaymentPlan(projectId, "two", total); +} + +// Replaces all payments for a project with a fresh plan at a given total. +// Modes: +// single → 1 row 100% "Pagamento unico (100%)" +// two → 2 rows 50%/50% "Acconto 50% (inizio lavori)" / "Saldo 50% (alla consegna)" +// three → 3 rows 50%/30%/20% +export async function setPaymentPlan( + projectId: string, + mode: "single" | "two" | "three", + total: number +): Promise { + await requireAdmin(); + + type PaymentDef = { label: string; percent: number }; + const plans: Record = { + single: [{ label: "Pagamento unico (100%)", percent: 100 }], + two: [ + { label: "Acconto 50% (inizio lavori)", percent: 50 }, + { label: "Saldo 50% (alla consegna)", percent: 50 }, + ], + three: [ + { label: "Acconto 50% (inizio lavori)", percent: 50 }, + { label: "30% (post revisioni)", percent: 30 }, + { label: "Saldo 20% (alla consegna)", percent: 20 }, + ], + }; + + const plan = plans[mode]; + if (!plan) throw new Error("Modalità pagamento non valida"); + + // Delete existing payments for this project, then insert new ones. + await db.delete(payments).where(eq(payments.project_id, projectId)); + await db.insert(payments).values( + plan.map((p) => ({ + project_id: projectId, + label: p.label, + percent: p.percent.toFixed(2), + amount: ((total * p.percent) / 100).toFixed(2), + status: "da_saldare" as const, + })) + ); + revalidatePath(`/admin/projects/${projectId}`); } diff --git a/src/app/admin/timer-actions.ts b/src/app/admin/timer-actions.ts index 6b54966..fb34331 100644 --- a/src/app/admin/timer-actions.ts +++ b/src/app/admin/timer-actions.ts @@ -61,7 +61,7 @@ export async function startTimerForClient(clientId: string): Promise<{ entryId: export async function stopTimer(entryId: string): Promise { await requireAdmin(); const rows = await db - .select({ started_at: time_entries.started_at }) + .select({ started_at: time_entries.started_at, project_id: time_entries.project_id }) .from(time_entries) .where(eq(time_entries.id, entryId)) .limit(1); @@ -77,4 +77,56 @@ export async function stopTimer(entryId: string): Promise { revalidatePath("/admin"); revalidatePath("/admin/projects"); -} \ No newline at end of file + revalidatePath(`/admin/projects/${rows[0].project_id}`); +} + +/** + * Inserts a manually entered time block (already closed). + * duration_seconds = minutes * 60 + * ended_at = started_at + duration_seconds + * @param projectId project to record time against + * @param minutes duration in minutes (must be > 0) + * @param startedAt optional ISO date string — defaults to start of current day + */ +export async function addManualTimeEntry( + projectId: string, + minutes: number, + startedAt?: string +): Promise { + await requireAdmin(); + if (!Number.isFinite(minutes) || minutes <= 0) throw new Error("Minuti non validi"); + + const start = startedAt ? new Date(startedAt) : new Date(); + if (isNaN(start.getTime())) throw new Error("Data non valida"); + + const durationSeconds = Math.round(minutes * 60); + const end = new Date(start.getTime() + durationSeconds * 1000); + + await db.insert(time_entries).values({ + id: nanoid(), + project_id: projectId, + started_at: start, + ended_at: end, + duration_seconds: durationSeconds, + }); + + revalidatePath("/admin"); + revalidatePath("/admin/projects"); + revalidatePath(`/admin/projects/${projectId}`); +} + +/** + * Deletes a time entry by id, scoped to the given project (guard against + * cross-project deletions). + */ +export async function deleteTimeEntry(entryId: string, projectId: string): Promise { + await requireAdmin(); + await db + .delete(time_entries) + .where(eq(time_entries.id, entryId)); + + revalidatePath("/admin"); + revalidatePath("/admin/projects"); + revalidatePath(`/admin/projects/${projectId}`); +} + diff --git a/src/app/client/[token]/page.tsx b/src/app/client/[token]/page.tsx index 5db6a06..ed22f63 100644 --- a/src/app/client/[token]/page.tsx +++ b/src/app/client/[token]/page.tsx @@ -67,6 +67,13 @@ function projectViewToClientView( })), global_progress_pct: view.global_progress_pct, activeOffers: view.activeOffers, + transcripts: view.transcripts.map((t) => ({ + id: t.id, + title: t.title, + call_date: t.call_date, + content: t.content, + created_at: t.created_at instanceof Date ? t.created_at.toISOString() : String(t.created_at), + })), }; } diff --git a/src/components/admin/ManualTimeEntry.tsx b/src/components/admin/ManualTimeEntry.tsx new file mode 100644 index 0000000..63377cc --- /dev/null +++ b/src/components/admin/ManualTimeEntry.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { addManualTimeEntry, deleteTimeEntry } from "@/app/admin/timer-actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +type TimeEntry = { + id: string; + started_at: Date; + ended_at: Date | null; + duration_seconds: number | null; +}; + +function formatDuration(seconds: number | null): string { + if (!seconds) return "0 min"; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + if (h > 0) return `${h}h ${m}min`; + return `${m} min`; +} + +function formatDate(date: Date): string { + return new Date(date).toLocaleDateString("it-IT", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +type Props = { + projectId: string; + recentEntries: TimeEntry[]; +}; + +export function ManualTimeEntry({ projectId, recentEntries }: Props) { + const router = useRouter(); + const [, startTransition] = useTransition(); + const [minutes, setMinutes] = useState(""); + const [date, setDate] = useState(() => new Date().toISOString().split("T")[0]); + const [loading, setLoading] = useState(false); + const [deletingId, setDeletingId] = useState(null); + + async function handleQuickAdd(mins: number) { + setLoading(true); + try { + const startedAt = new Date(`${date}T09:00:00`).toISOString(); + await addManualTimeEntry(projectId, mins, startedAt); + startTransition(() => router.refresh()); + } finally { + setLoading(false); + } + } + + async function handleCustomAdd(e: React.FormEvent) { + e.preventDefault(); + const mins = parseFloat(minutes); + if (!mins || mins <= 0) return; + setLoading(true); + try { + const startedAt = new Date(`${date}T09:00:00`).toISOString(); + await addManualTimeEntry(projectId, mins, startedAt); + setMinutes(""); + startTransition(() => router.refresh()); + } finally { + setLoading(false); + } + } + + async function handleDelete(entryId: string) { + setDeletingId(entryId); + try { + await deleteTimeEntry(entryId, projectId); + startTransition(() => router.refresh()); + } finally { + setDeletingId(null); + } + } + + return ( +
+

Aggiungi tempo manuale

+ + {/* Data */} +
+ + setDate(e.target.value)} + className="max-w-[180px]" + /> +
+ + {/* Quick buttons */} +
+

Aggiunte rapide

+
+ + +
+
+ + {/* Custom minutes */} +
+
+ + setMinutes(e.target.value)} + className="max-w-[120px]" + /> +
+ +
+ + {/* Recent entries */} + {recentEntries.length > 0 && ( +
+

Ultime registrazioni

+
    + {recentEntries.map((entry) => ( +
  • +
    +

    + {formatDuration(entry.duration_seconds)} +

    +

    {formatDate(entry.started_at)}

    +
    + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/admin/TranscriptItem.tsx b/src/components/admin/TranscriptItem.tsx new file mode 100644 index 0000000..8cc6c9e --- /dev/null +++ b/src/components/admin/TranscriptItem.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; + +type Transcript = { + id: string; + title: string | null; + call_date: string; + content: string; + created_at: Date; +}; + +export function TranscriptItem({ transcript }: { transcript: Transcript }) { + const [expanded, setExpanded] = useState(false); + const date = new Date(transcript.call_date).toLocaleDateString("it-IT", { + day: "2-digit", + month: "long", + year: "numeric", + }); + + return ( +
+ + {expanded && ( +
+
+            {transcript.content}
+          
+
+ )} +
+ ); +} diff --git a/src/components/admin/tabs/DocumentsTab.tsx b/src/components/admin/tabs/DocumentsTab.tsx index 89757a5..fe232c5 100644 --- a/src/components/admin/tabs/DocumentsTab.tsx +++ b/src/components/admin/tabs/DocumentsTab.tsx @@ -1,15 +1,29 @@ import { addDocument } from "@/app/admin/clients/[id]/actions"; import { DocumentRow } from "@/components/admin/DocumentRow"; +import { TranscriptItem } from "@/components/admin/TranscriptItem"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import type { Document } from "@/db/schema"; -type Props = { documents: Document[]; clientId: string }; +type Transcript = { + id: string; + title: string | null; + call_date: string; + content: string; + created_at: Date; +}; -export async function DocumentsTab({ documents, clientId }: Props) { +type Props = { + documents: Document[]; + clientId: string; + transcripts?: Transcript[]; +}; + +export async function DocumentsTab({ documents, clientId, transcripts = [] }: Props) { return ( -
+
+ {/* Add document form */}
{ "use server"; @@ -42,14 +56,29 @@ export async function DocumentsTab({ documents, clientId }: Props) {
- {documents.length === 0 && ( -

Nessun documento ancora.

- )} -
- {documents.map((doc) => ( - - ))} + {/* Documents list */} +
+ {documents.length === 0 && ( +

Nessun documento ancora.

+ )} +
+ {documents.map((doc) => ( + + ))} +
+ + {/* Transcripts section (read-only, expandable via client component) */} + {transcripts.length > 0 && ( +
+

Transcript chiamate

+
+ {transcripts.map((t) => ( + + ))} +
+
+ )}
); -} \ No newline at end of file +} diff --git a/src/components/admin/tabs/PaymentsTab.tsx b/src/components/admin/tabs/PaymentsTab.tsx index c9e2908..a9fb0f2 100644 --- a/src/components/admin/tabs/PaymentsTab.tsx +++ b/src/components/admin/tabs/PaymentsTab.tsx @@ -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 = { @@ -22,28 +27,111 @@ const statusLabels: Record = { 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(null); + const [statusLoading, setStatusLoading] = useState(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 (
- {/* Accepted total */} -
-

Totale preventivo

-
{ - "use server"; - await updateAcceptedTotal(clientId, fd); - }} - className="flex items-end gap-3" - > + {/* Totale preventivo */} +
+

Totale preventivo

+ + {/* Totale ereditato dalle offerte */} + {hasOffers && ( +
+ + Ereditato dalle offerte attive:{" "} + + €{" "} + {offersTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })} + + + {projectId && ( + + )} +
+ )} + + {/* Override manuale */} +
- + setOverrideValue(e.target.value)} className="max-w-xs" />
@@ -51,40 +139,71 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId Salva -

- Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna. -

+ {/* Selettore schema rate */} + {projectId && ( +
+

Schema di pagamento

+
+ {planOptions.map(({ mode, label, description }) => ( + + ))} +
+

+ Selezionare uno schema cancella le rate esistenti e crea le nuove in base al totale corrente. +

+
+ )} + {/* Empty state — project has no payments yet */} {projectId && payments.length === 0 && (

- Nessun pagamento configurato. Crea le rate standard Acconto e Saldo. + Nessun pagamento configurato. Scegli uno schema sopra o crea le rate standard.

-
{ - "use server"; - await initProjectPayments(projectId); - }} + -
+ Crea Acconto & Saldo (50/50) +
)} {/* Payment rows */} {payments.map((p) => { const amount = parseFloat(p.amount); + const pct = p.percent !== null && p.percent !== undefined + ? parseFloat(String(p.percent)) + : null; return (
-

{p.label}

+
+

{p.label}

+ {pct !== null && ( +

{pct}% del totale

+ )} +
€{" "} @@ -101,16 +220,14 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId )}
-
{ - "use server"; - await updatePaymentStatus(p.id, clientId, fd.get("status") as string); - }} - className="flex items-center gap-2" - > +
- - + {statusLoading === p.id && ( + ... + )} +
); })}
); -} \ No newline at end of file +} diff --git a/src/components/admin/tabs/PhasesTab.tsx b/src/components/admin/tabs/PhasesTab.tsx index 306e217..7e9a82a 100644 --- a/src/components/admin/tabs/PhasesTab.tsx +++ b/src/components/admin/tabs/PhasesTab.tsx @@ -20,8 +20,8 @@ const taskStatusOptions = [ ]; const phaseStatusOptions = [ - { value: "upcoming", label: "In arrivo" }, - { value: "active", label: "Attiva" }, + { value: "upcoming", label: "Da iniziare" }, + { value: "active", label: "In corso" }, { value: "done", label: "Completata" }, ]; diff --git a/src/components/admin/tabs/TimerTab.tsx b/src/components/admin/tabs/TimerTab.tsx index c5a2d7a..9cc4c6e 100644 --- a/src/components/admin/tabs/TimerTab.tsx +++ b/src/components/admin/tabs/TimerTab.tsx @@ -2,6 +2,14 @@ import { TimerCell } from "@/components/admin/TimerCell"; import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard"; +import { ManualTimeEntry } from "@/components/admin/ManualTimeEntry"; + +type TimeEntry = { + id: string; + started_at: Date; + ended_at: Date | null; + duration_seconds: number | null; +}; type TimerTabProps = { projectId: string; @@ -10,6 +18,7 @@ type TimerTabProps = { activeTimerStartedAt: Date | null; totalTrackedSeconds: number; targetHourlyRate: number; + recentEntries: TimeEntry[]; }; export function TimerTab({ @@ -19,6 +28,7 @@ export function TimerTab({ activeTimerStartedAt, totalTrackedSeconds, targetHourlyRate, + recentEntries, }: TimerTabProps) { return (
@@ -33,6 +43,8 @@ export function TimerTab({ />
+ +
); -} \ No newline at end of file +} diff --git a/src/components/client-dashboard.tsx b/src/components/client-dashboard.tsx index b6a8220..fe5ea54 100644 --- a/src/components/client-dashboard.tsx +++ b/src/components/client-dashboard.tsx @@ -5,6 +5,7 @@ import { PhaseTimeline } from './phase-timeline'; import { PaymentStatus } from './payment-status'; import { DocumentsSection } from './documents-section'; import { NotesSection } from './notes-section'; +import { TranscriptsSection } from './transcripts-section'; import { PhaseViewToggle } from './client/kanban/PhaseViewToggle'; import { ChatSection } from './client/ChatSection'; import { OffersSection } from './client/OffersSection'; @@ -71,6 +72,12 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
)} + {view.transcripts.length > 0 && ( +
+

Transcript Chiamate

+ +
+ )}
diff --git a/src/components/client/PhaseCard.tsx b/src/components/client/PhaseCard.tsx new file mode 100644 index 0000000..faf1511 --- /dev/null +++ b/src/components/client/PhaseCard.tsx @@ -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 ( + + ); + } + if (status === "active") { + return ( + + ); + } + return ( + + ); +} + +function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) { + if (status === "done") { + return ( + + ); + } + if (status === "in_progress") { + return ( + + ); + } + return ( + + ); +} + +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 ( + + {/* Header — always visible, click to toggle */} + + + {/* Collapsible task list */} + {open && ( +
+ {phase.tasks.length === 0 ? ( +

Nessun task ancora configurato.

+ ) : ( +
    + {phase.tasks.map((task) => ( +
  • + +
    +

    + {task.title} +

    + {task.description && ( +

    + {task.description} +

    + )} + {task.deliverables.length > 0 && ( +
      + {task.deliverables.map((d) => ( +
    • + + {d.title} + + {(d.status === "pending" || + d.status === "submitted" || + d.approved_at !== null) && ( + + )} +
    • + ))} +
    + )} +
    +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/phase-timeline.tsx b/src/components/phase-timeline.tsx index f563a82..fd8795c 100644 --- a/src/components/phase-timeline.tsx +++ b/src/components/phase-timeline.tsx @@ -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 ( - - ); - } - if (status === 'active') { - return ( - - ); - } - return ( - - ); -} - -function TaskStatusIcon({ status }: { status: 'todo' | 'in_progress' | 'done' }) { - if (status === 'done') { - return ( - - ); - } - if (status === 'in_progress') { - return ( - - ); - } - return ( - - ); -} - -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

Nessuna fase ancora configurata.

; @@ -73,71 +14,37 @@ export function PhaseTimeline({ phases, token }: PhaseTimelineProps) { return (
{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 (
- + {phase.status === 'done' ? ( + + ) : phase.status === 'active' ? ( + + ) : ( + + )}
{!isLast &&
}
- -
-

{phase.title}

- - {phaseStatusLabel[phase.status]} - -
- -
-
-

{doneCount} di {phase.tasks.length} task

-

{phase.progress_pct}%

-
- -
- - {phase.tasks.length === 0 ? ( -

Nessun task ancora configurato.

- ) : ( -
    - {phase.tasks.map((task) => ( -
  • - -
    -

    - {task.title} -

    - {task.description && ( -

    {task.description}

    - )} - {task.deliverables.length > 0 && ( -
      - {task.deliverables.map((d) => ( -
    • - {d.title} - {(d.status === 'pending' || d.status === 'submitted' || d.approved_at !== null) && ( - - )} -
    • - ))} -
    - )} -
    -
  • - ))} -
- )} -
+
); })}
); -} \ No newline at end of file +} diff --git a/src/components/transcripts-section.tsx b/src/components/transcripts-section.tsx new file mode 100644 index 0000000..7bb2111 --- /dev/null +++ b/src/components/transcripts-section.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { Card } from "@/components/ui/card"; +type Transcript = { + id: string; + title: string | null; + call_date: string; + content: string; + created_at: string | Date; +}; + +function TranscriptCard({ transcript }: { transcript: Transcript }) { + const [expanded, setExpanded] = useState(false); + + const date = new Date(transcript.call_date).toLocaleDateString("it-IT", { + day: "2-digit", + month: "long", + year: "numeric", + }); + + return ( + + + + {expanded && ( +
+
+            {transcript.content}
+          
+
+ )} +
+ ); +} + +interface TranscriptsSectionProps { + transcripts: Transcript[]; +} + +export function TranscriptsSection({ transcripts }: TranscriptsSectionProps) { + if (transcripts.length === 0) { + return ( +

+ Nessun transcript disponibile. +

+ ); + } + + return ( +
+ {transcripts.map((t) => ( + + ))} +
+ ); +} diff --git a/src/db/migrations/0013_payments_percent.sql b/src/db/migrations/0013_payments_percent.sql new file mode 100644 index 0000000..6199754 --- /dev/null +++ b/src/db/migrations/0013_payments_percent.sql @@ -0,0 +1,6 @@ +-- Additive: add percent column to payments for rescaling by payment plan. +-- nullable — existing rows keep percent NULL (legacy fallback in updateAcceptedTotal). +-- Apply to prod via SSH tunnel BEFORE pushing schema-dependent code. +-- No drops, no truncates, no data loss. + +ALTER TABLE payments ADD COLUMN IF NOT EXISTS percent numeric(5,2); diff --git a/src/db/schema.ts b/src/db/schema.ts index aec7bb0..3ca3990 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -154,6 +154,7 @@ export const payments = pgTable("payments", { .references(() => projects.id, { onDelete: "cascade" }), label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%" amount: numeric("amount", { precision: 10, scale: 2 }).notNull(), + percent: numeric("percent", { precision: 5, scale: 2 }), // nullable — % of total (for rescaling); null = legacy row status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato paid_at: timestamp("paid_at", { withTimezone: true }), }); diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index 7616312..9a92edd 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -22,6 +22,7 @@ import { quotes, leads, tags, + clientTranscripts, } from "@/db/schema"; import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm"; import { getPool } from "@/lib/taxonomy"; @@ -551,6 +552,8 @@ export type ProjectFullDetail = { activeTimerStartedAt: Date | null; totalTrackedSeconds: number; projectOffers: ProjectOfferWithMicro[]; + /** Sum of accepted_total across all active project offers — used as default for payment plan */ + offersAcceptedTotal: number; availableMicros: Array<{ id: string; internal_name: string; @@ -563,6 +566,13 @@ export type ProjectFullDetail = { macro_category: string | null; macro_offer_type: string; }>; + transcripts: Array<{ + id: string; + title: string | null; + call_date: string; + content: string; + created_at: Date; + }>; }; export async function getProjectFullDetail(id: string): Promise { @@ -609,7 +619,7 @@ export async function getProjectFullDetail(id: string): Promise d.id)]; @@ -715,6 +737,11 @@ export async function getProjectFullDetail(id: string): Promise sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0), + 0 + ); + return { project: { ...project, client } as ProjectFullDetail["project"], phases: phasesWithTasks, @@ -728,10 +755,40 @@ export async function getProjectFullDetail(id: string): Promise { + // sql`ended_at IS NOT NULL` used to filter closed entries + // (Drizzle's isNotNull is available in v0.28+ but we use sql for safety) + return db + .select({ + id: time_entries.id, + started_at: time_entries.started_at, + ended_at: time_entries.ended_at, + duration_seconds: time_entries.duration_seconds, + }) + .from(time_entries) + .where(and(eq(time_entries.project_id, projectId), sql`${time_entries.ended_at} IS NOT NULL`)) + .orderBy(desc(time_entries.started_at)) + .limit(limit); +} + // ── ClientWithProjects — used by client detail showing project cards ────────── export type ClientWithProjects = Client & { diff --git a/src/lib/client-view.ts b/src/lib/client-view.ts index e77c318..6919060 100644 --- a/src/lib/client-view.ts +++ b/src/lib/client-view.ts @@ -1,6 +1,6 @@ -import { eq, inArray, asc, sql } from "drizzle-orm"; +import { eq, inArray, asc, desc, sql } from "drizzle-orm"; import { db } from "@/db"; -import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services } from "@/db/schema"; +import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, clientTranscripts } from "@/db/schema"; /** * ClientView: Legacy shape used by ClientDashboard component. @@ -58,6 +58,13 @@ export interface ClientView { cumulative_price: string; accepted_total: string | null; }>; + transcripts: Array<{ + id: string; + title: string | null; + call_date: string; + content: string; + created_at: string; + }>; } export interface ProjectView { @@ -121,6 +128,13 @@ export interface ProjectView { cumulative_price: string; // sum of assigned offer_services.price accepted_total: string | null; }>; + transcripts: Array<{ + id: string; + title: string | null; + call_date: string; + content: string; + created_at: Date; + }>; } export interface ClientProjectSummary { @@ -308,6 +322,19 @@ export async function getProjectView(projectId: string): Promise d.id)]; const commentsRows = @@ -352,5 +379,6 @@ export async function getProjectView(projectId: string): Promise 0 ? activeOffers : undefined, + transcripts: transcriptsRows, }; } \ No newline at end of file diff --git a/src/lib/lead-service.ts b/src/lib/lead-service.ts index ca312ad..0ade181 100644 --- a/src/lib/lead-service.ts +++ b/src/lib/lead-service.ts @@ -219,3 +219,13 @@ export async function getTranscripts(leadId: string) { .where(eq(clientTranscripts.lead_id, leadId)) .orderBy(desc(clientTranscripts.call_date)); } + +// Get transcripts linked to a client (post lead→client conversion). +// Returns all fields — used by admin DocumentsTab and public TranscriptsSection. +export async function getTranscriptsByClientId(clientId: string) { + return await db + .select() + .from(clientTranscripts) + .where(eq(clientTranscripts.client_id, clientId)) + .orderBy(desc(clientTranscripts.call_date)); +}