feat(timer): il tempo si imputa a fase e task, non solo al progetto

"Quanto e' costata questa fase" non era una domanda che si potesse fare:
time_entries aveva la sola project_id.

Migration 0018 (gia' applicata a prod): phase_id e task_id su time_entries,
piu' due indici, piu' projects.due_date che serve al blocco successivo. Solo
ADD COLUMN e CREATE INDEX.

Due scelte che vale la pena spiegare:

- ON DELETE SET NULL, non CASCADE. Cancellare un task NON deve cancellare le
  ore lavorate su di esso: sono storico fatturabile. L'entry ricade a livello
  progetto e il totale del progetto non cambia mai. Con CASCADE, ripulire una
  fase avrebbe silenziosamente abbassato il fatturato tracciato. Verificato
  sul DB di produzione dentro una transazione con ROLLBACK: cancellato il
  task, l'entry sopravvive con task_id NULL, phase_id intatto e i secondi
  invariati.
- Il timer su un task scrive ENTRAMBE le colonne. Cosi' il totale di una fase
  e' un group-by diretto su phase_id, senza risalire dai task, e comprende
  anche il tempo imputato alla fase ma a nessun task in particolare.

Resta un solo timer attivo in tutto l'hub. Da qui una conseguenza in UI: se
sta girando su un task, il timer del tab "Timer" NON si mostra acceso —
mostrarlo acceso farebbe credere che siano due cronometri diversi. Il tab lo
dice a parole e avvisa che avviarlo fermerebbe l'altro.

Le 8 entry esistenti restano valide con entrambe le colonne a NULL, cioe'
"tempo di progetto": e' esattamente cio' che sono.

PhasesTab passa ai token semantici mentre lo si tocca. Non e' zelo: ci si
infila dentro una TimerCell che i token li usa gia', e in dark mode un badge a
token dentro una card bg-white si vede. Un pezzo di DEBT-01 in meno.

Cade startTimerForClient, senza chiamanti da quando la lista progetti non ha
piu' il timer.

Build pulito. L'avvio/arresto dal browser non e' ancora stato provato: si
verifica in produzione, che e' l'unico posto dove esiste il DB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:38:06 +02:00
parent 4b135ce67f
commit a9358da96f
8 changed files with 259 additions and 57 deletions
+17 -1
View File
@@ -35,7 +35,11 @@ export default async function ProjectDetailPage({
notes, notes,
activeTimerEntryId, activeTimerEntryId,
activeTimerStartedAt, activeTimerStartedAt,
activeTimerPhaseId,
activeTimerTaskId,
totalTrackedSeconds, totalTrackedSeconds,
taskSeconds,
phaseSeconds,
projectOffers, projectOffers,
availableMicros, availableMicros,
offersAcceptedTotal, offersAcceptedTotal,
@@ -76,7 +80,18 @@ export default async function ProjectDetailPage({
<TabsContent value="phases"> <TabsContent value="phases">
<PhasesViewToggle <PhasesViewToggle
listView={<PhasesTab phases={phases} clientId={id} />} listView={
<PhasesTab
phases={phases}
clientId={id}
projectId={id}
activeTimerEntryId={activeTimerEntryId}
activeTimerStartedAt={activeTimerStartedAt}
activeTimerTaskId={activeTimerTaskId}
taskSeconds={taskSeconds}
phaseSeconds={phaseSeconds}
/>
}
phases={phases} phases={phases}
clientId={id} clientId={id}
/> />
@@ -118,6 +133,7 @@ export default async function ProjectDetailPage({
acceptedTotal={project.accepted_total ?? "0"} acceptedTotal={project.accepted_total ?? "0"}
activeTimerEntryId={activeTimerEntryId} activeTimerEntryId={activeTimerEntryId}
activeTimerStartedAt={activeTimerStartedAt} activeTimerStartedAt={activeTimerStartedAt}
activeTimerScoped={activeTimerPhaseId !== null || activeTimerTaskId !== null}
totalTrackedSeconds={totalTrackedSeconds} totalTrackedSeconds={totalTrackedSeconds}
targetHourlyRate={targetHourlyRate} targetHourlyRate={targetHourlyRate}
recentEntries={recentEntries} recentEntries={recentEntries}
+24 -17
View File
@@ -9,11 +9,26 @@ async function requireAdmin() {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato"); if (!session) throw new Error("Non autorizzato");
} }
import { time_entries, projects } from "@/db/schema"; import { time_entries } from "@/db/schema";
import { eq, isNull, asc } from "drizzle-orm"; import { eq, isNull } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
export async function startTimer(projectId: string): Promise<{ entryId: string }> { /**
* Avvia il timer su un progetto e, facoltativamente, sulla fase e sul task su
* cui si sta lavorando.
*
* Quando si cronometra un task si valorizzano ENTRAMBI `phaseId` e `taskId`:
* il totale di una fase diventa così un semplice raggruppamento su `phase_id`,
* senza dover risalire dai task. Il tempo imputato alla fase ma a nessun task
* (phaseId senza taskId) resta possibile ed entra nello stesso totale.
*
* Resta valida la regola di prima: un solo timer attivo alla volta, in tutto
* l'hub. Vale globalmente, non per task — non si lavora su due cose insieme.
*/
export async function startTimer(
projectId: string,
scope?: { phaseId?: string; taskId?: string }
): Promise<{ entryId: string }> {
await requireAdmin(); await requireAdmin();
// Stop any currently running session before starting a new one // Stop any currently running session before starting a new one
const running = await db const running = await db
@@ -38,26 +53,18 @@ export async function startTimer(projectId: string): Promise<{ entryId: string }
} }
const id = nanoid(); const id = nanoid();
await db.insert(time_entries).values({ id, project_id: projectId }); await db.insert(time_entries).values({
id,
project_id: projectId,
phase_id: scope?.phaseId ?? null,
task_id: scope?.taskId ?? null,
});
revalidatePath("/admin"); revalidatePath("/admin");
revalidatePath("/admin/projects"); revalidatePath("/admin/projects");
revalidatePath(`/admin/projects/${projectId}`); revalidatePath(`/admin/projects/${projectId}`);
return { entryId: id }; return { entryId: id };
} }
export async function startTimerForClient(clientId: string): Promise<{ entryId: string }> {
await requireAdmin();
const projectRows = await db
.select({ id: projects.id })
.from(projects)
.where(eq(projects.client_id, clientId))
.orderBy(asc(projects.created_at))
.limit(1);
const projectId = projectRows[0]?.id;
if (!projectId) throw new Error("Nessun progetto trovato per questo cliente");
return startTimer(projectId);
}
export async function stopTimer(entryId: string): Promise<void> { export async function stopTimer(entryId: string): Promise<void> {
await requireAdmin(); await requireAdmin();
const rows = await db const rows = await db
+23 -10
View File
@@ -2,7 +2,7 @@
import { useState, useEffect, useTransition } from "react"; import { useState, useEffect, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { startTimer, startTimerForClient, stopTimer } from "@/app/admin/timer-actions"; import { startTimer, stopTimer } from "@/app/admin/timer-actions";
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
@@ -13,17 +13,24 @@ function formatDuration(seconds: number): string {
} }
export function TimerCell({ export function TimerCell({
clientId,
projectId, projectId,
phaseId,
taskId,
activeEntryId, activeEntryId,
activeStartedAt, activeStartedAt,
totalTrackedSeconds, totalTrackedSeconds,
compact = false,
}: { }: {
clientId: string; projectId: string;
projectId?: string; /** Fase e task su cui imputare il tempo. Assenti = tempo di progetto. */
phaseId?: string;
taskId?: string;
/** Non-null solo se il timer attivo gira su QUESTO scope. */
activeEntryId: string | null; activeEntryId: string | null;
activeStartedAt: Date | null; activeStartedAt: Date | null;
totalTrackedSeconds: number; totalTrackedSeconds: number;
/** Variante ridotta, per stare in fondo alla riga di un task. */
compact?: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
@@ -48,10 +55,8 @@ export function TimerCell({
startTransition(async () => { startTransition(async () => {
if (isRunning && activeEntryId) { if (isRunning && activeEntryId) {
await stopTimer(activeEntryId); await stopTimer(activeEntryId);
} else if (projectId) {
await startTimer(projectId);
} else { } else {
await startTimerForClient(clientId); await startTimer(projectId, { phaseId, taskId });
} }
router.refresh(); router.refresh();
}); });
@@ -59,9 +64,15 @@ export function TimerCell({
const displayTotal = formatDuration(totalTrackedSeconds + (isRunning ? elapsed : 0)); const displayTotal = formatDuration(totalTrackedSeconds + (isRunning ? elapsed : 0));
// In compact il tempo si mostra solo se c'è: una riga di task con "0:00"
// accanto a ogni voce è rumore, e con venti task diventa una colonna di zeri.
const showTime = isRunning || totalTrackedSeconds > 0;
return ( return (
<div <div
className={`inline-flex items-center gap-2 rounded-full border pl-2 pr-3 py-1 text-xs font-mono tabular-nums transition-colors ${ className={`inline-flex items-center gap-2 rounded-full border transition-colors font-mono tabular-nums ${
compact ? "pl-1 pr-2 py-0.5 text-[11px]" : "pl-2 pr-3 py-1 text-xs"
} ${
isRunning isRunning
? "bg-emerald-50/60 border-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:border-emerald-900 dark:text-emerald-300 font-semibold" ? "bg-emerald-50/60 border-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:border-emerald-900 dark:text-emerald-300 font-semibold"
: "bg-muted border-border text-muted-foreground" : "bg-muted border-border text-muted-foreground"
@@ -70,7 +81,9 @@ export function TimerCell({
<button <button
onClick={handleToggle} onClick={handleToggle}
title={isRunning ? "Ferma timer" : "Avvia timer"} title={isRunning ? "Ferma timer" : "Avvia timer"}
className={`w-5 h-5 rounded-full flex items-center justify-center transition-colors shrink-0 ${ className={`rounded-full flex items-center justify-center transition-colors shrink-0 ${
compact ? "w-4 h-4" : "w-5 h-5"
} ${
isRunning isRunning
? "bg-emerald-500 text-white hover:bg-emerald-600" ? "bg-emerald-500 text-white hover:bg-emerald-600"
: "bg-foreground/10 text-foreground hover:bg-foreground/20" : "bg-foreground/10 text-foreground hover:bg-foreground/20"
@@ -90,7 +103,7 @@ export function TimerCell({
)} )}
</button> </button>
{isRunning ? formatDuration(elapsed) : displayTotal} {compact && !showTime ? null : isRunning ? formatDuration(elapsed) : displayTotal}
</div> </div>
); );
} }
+60 -9
View File
@@ -7,11 +7,19 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton"; import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
import { TimerCell } from "@/components/admin/TimerCell";
import type { ClientFullDetail } from "@/lib/admin-queries"; import type { ClientFullDetail } from "@/lib/admin-queries";
type Props = { type Props = {
phases: ClientFullDetail["phases"]; phases: ClientFullDetail["phases"];
clientId: string; clientId: string;
/** Assenti nella vista cliente: là le fasi si leggono, non si cronometrano. */
projectId?: string;
activeTimerEntryId?: string | null;
activeTimerStartedAt?: Date | null;
activeTimerTaskId?: string | null;
taskSeconds?: Record<string, number>;
phaseSeconds?: Record<string, number>;
}; };
const taskStatusOptions = [ const taskStatusOptions = [
@@ -26,7 +34,25 @@ const phaseStatusOptions = [
{ value: "done", label: "Completata" }, { value: "done", label: "Completata" },
]; ];
export async function PhasesTab({ phases, clientId }: Props) { /** "3h 20m" · "45m" · "—" quando non c'è tempo tracciato. */
function formatHours(seconds: number): string {
if (seconds <= 0) return "—";
const h = Math.floor(seconds / 3600);
const m = Math.round((seconds % 3600) / 60);
if (h === 0) return `${m}m`;
return m === 0 ? `${h}h` : `${h}h ${m}m`;
}
export async function PhasesTab({
phases,
clientId,
projectId,
activeTimerEntryId = null,
activeTimerStartedAt = null,
activeTimerTaskId = null,
taskSeconds = {},
phaseSeconds = {},
}: Props) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Add phase form */} {/* Add phase form */}
@@ -50,15 +76,25 @@ export async function PhasesTab({ phases, clientId }: Props) {
{/* Phases list */} {/* Phases list */}
{phases.length === 0 && ( {phases.length === 0 && (
<p className="text-sm text-gray-400">Nessuna fase ancora.</p> <p className="text-sm text-muted-foreground">Nessuna fase ancora.</p>
)} )}
{phases.map((phase) => ( {phases.map((phase) => (
<div <div
key={phase.id} key={phase.id}
className="border border-gray-200 rounded-lg p-4 bg-white" className="border border-border rounded-lg p-4 bg-card"
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
<h3 className="font-semibold text-gray-900">{phase.title}</h3> <div className="flex items-baseline gap-3">
<h3 className="font-semibold text-foreground">{phase.title}</h3>
{projectId && (
<span
className="text-xs font-mono tabular-nums text-muted-foreground"
title="Tempo tracciato su questa fase"
>
{formatHours(phaseSeconds[phase.id] ?? 0)}
</span>
)}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<form <form
action={async (fd: FormData) => { action={async (fd: FormData) => {
@@ -74,7 +110,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
<select <select
name="status" name="status"
defaultValue={phase.status} defaultValue={phase.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white" className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground"
> >
{phaseStatusOptions.map((o) => ( {phaseStatusOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
@@ -95,10 +131,25 @@ export async function PhasesTab({ phases, clientId }: Props) {
{phase.tasks.map((task) => ( {phase.tasks.map((task) => (
<div <div
key={task.id} key={task.id}
className="flex items-center justify-between pl-3 border-l-2 border-gray-100" className="flex items-center justify-between gap-3 pl-3 border-l-2 border-border"
> >
<span className="text-sm text-gray-800">{task.title}</span> <span className="text-sm text-foreground">{task.title}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{projectId && (
<TimerCell
projectId={projectId}
phaseId={phase.id}
taskId={task.id}
activeEntryId={
activeTimerTaskId === task.id ? activeTimerEntryId : null
}
activeStartedAt={
activeTimerTaskId === task.id ? activeTimerStartedAt : null
}
totalTrackedSeconds={taskSeconds[task.id] ?? 0}
compact
/>
)}
<form <form
action={async (fd: FormData) => { action={async (fd: FormData) => {
"use server"; "use server";
@@ -113,7 +164,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
<select <select
name="status" name="status"
defaultValue={task.status} defaultValue={task.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white" className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground"
> >
{taskStatusOptions.map((o) => ( {taskStatusOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
+16 -5
View File
@@ -16,6 +16,7 @@ type TimerTabProps = {
acceptedTotal: string; acceptedTotal: string;
activeTimerEntryId: string | null; activeTimerEntryId: string | null;
activeTimerStartedAt: Date | null; activeTimerStartedAt: Date | null;
activeTimerScoped: boolean;
totalTrackedSeconds: number; totalTrackedSeconds: number;
targetHourlyRate: number; targetHourlyRate: number;
recentEntries: TimeEntry[]; recentEntries: TimeEntry[];
@@ -26,19 +27,29 @@ export function TimerTab({
acceptedTotal, acceptedTotal,
activeTimerEntryId, activeTimerEntryId,
activeTimerStartedAt, activeTimerStartedAt,
activeTimerScoped,
totalTrackedSeconds, totalTrackedSeconds,
targetHourlyRate, targetHourlyRate,
recentEntries, recentEntries,
}: TimerTabProps) { }: TimerTabProps) {
// Il timer attivo è uno solo in tutto l'hub. Se sta girando su un task, qui
// NON va mostrato come acceso: questo è il timer di progetto, e mostrarlo
// acceso farebbe credere che siano due cronometri diversi.
const projectEntryId = activeTimerScoped ? null : activeTimerEntryId;
return ( return (
<div className="space-y-6 max-w-sm"> <div className="space-y-6 max-w-sm">
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4"> <div className="bg-card rounded-lg border border-border p-4">
<h3 className="font-medium text-[#1a1a1a] mb-4">Timer</h3> <h3 className="font-medium text-foreground mb-1">Timer di progetto</h3>
<p className="text-xs text-muted-foreground mb-4">
{activeTimerScoped
? "Un timer sta girando su un task, in «Fasi & Task». Avviando questo, quello si ferma."
: "Il tempo avviato qui non è imputato a nessuna fase. Per attribuirlo, usa il timer sul singolo task in «Fasi & Task»."}
</p>
<TimerCell <TimerCell
clientId={projectId}
projectId={projectId} projectId={projectId}
activeEntryId={activeTimerEntryId} activeEntryId={projectEntryId}
activeStartedAt={activeTimerStartedAt} activeStartedAt={projectEntryId ? activeTimerStartedAt : null}
totalTrackedSeconds={totalTrackedSeconds} totalTrackedSeconds={totalTrackedSeconds}
/> />
</div> </div>
@@ -0,0 +1,40 @@
-- Additive: timer per fase/task e data di consegna attesa sul progetto.
--
-- ── time_entries.phase_id / task_id ──────────────────────────────────────────
-- Il timer nasce a livello di progetto: time_entries aveva la sola project_id,
-- quindi "quanto e' costata questa fase" non era una domanda che si potesse
-- fare. Le due colonne sono NULLABLE e project_id resta obbligatoria: ogni
-- entry e' sempre attribuita a un progetto, il dettaglio e' un di piu'.
-- Le righe esistenti restano valide con entrambe a NULL, cioe' "tempo di
-- progetto, non imputato a una fase" — che e' esattamente cio' che sono.
--
-- ON DELETE SET NULL, non CASCADE, ed e' la scelta che conta qui: cancellare un
-- task NON deve cancellare il tempo tracciato su di esso. Sono ore lavorate, e
-- quindi storico fatturabile; l'entry ricade a livello progetto e il totale del
-- progetto non cambia mai. Con CASCADE, ripulire una fase avrebbe silenziosamente
-- abbassato il fatturato tracciato.
--
-- ── projects.due_date ────────────────────────────────────────────────────────
-- La consegna attesa e' normalmente DERIVATA: project_offers.start_date +
-- offer_micros.duration_months. Questa colonna e' l'override manuale, e vince
-- sulla derivata quando e' valorizzata. Nullable per forza: la stragrande
-- maggioranza dei progetti continuera' a non averla, ed e' giusto cosi' —
-- un campo obbligatorio in piu' su ogni progetto e' un campo che invecchia.
--
-- Nessun DROP, nessun TRUNCATE, nessuna colonna rimossa o modificata.
-- Applicare a prod via SSH+docker exec PRIMA di pushare il codice dipendente.
-- Idempotente: safe to re-run.
ALTER TABLE time_entries
ADD COLUMN IF NOT EXISTS phase_id text REFERENCES phases(id) ON DELETE SET NULL;
ALTER TABLE time_entries
ADD COLUMN IF NOT EXISTS task_id text REFERENCES tasks(id) ON DELETE SET NULL;
-- Il rollup per fase raggruppa su queste due colonne a ogni apertura del
-- progetto: senza indice diventa una scansione piena appena le entry crescono.
CREATE INDEX IF NOT EXISTS time_entries_phase_idx ON time_entries (phase_id);
CREATE INDEX IF NOT EXISTS time_entries_task_idx ON time_entries (task_id);
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS due_date timestamptz;
+31 -11
View File
@@ -109,6 +109,10 @@ export const projects = pgTable("projects", {
offer_id: text("offer_id") offer_id: text("offer_id")
.references(() => offer_micros.id, { onDelete: "set null" }), .references(() => offer_micros.id, { onDelete: "set null" }),
created_from_lead_id: text("created_from_lead_id"), created_from_lead_id: text("created_from_lead_id"),
// Consegna attesa. Normalmente si DERIVA da project_offers.start_date +
// offer_micros.duration_months; questa colonna è l'override manuale e vince
// sulla derivata quando è valorizzata (migration 0018).
due_date: timestamp("due_date", { withTimezone: true }),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}); });
@@ -242,17 +246,33 @@ export const notes = pgTable("notes", {
}); });
// ============ TIME ENTRIES (admin time tracking per project) ============ // ============ TIME ENTRIES (admin time tracking per project) ============
export const time_entries = pgTable("time_entries", { // project_id è obbligatoria: ogni entry è sempre attribuita a un progetto.
id: text("id") // phase_id/task_id (migration 0018) sono il dettaglio facoltativo — NULL su
.primaryKey() // tutte le righe precedenti, che sono "tempo di progetto" e restano tali.
.$defaultFn(() => nanoid()), //
project_id: text("project_id") // ON DELETE SET NULL, non cascade: cancellare un task non deve cancellare le
.notNull() // ore lavorate su di esso. L'entry ricade a livello progetto, il totale non
.references(() => projects.id, { onDelete: "cascade" }), // cambia mai.
started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(), export const time_entries = pgTable(
ended_at: timestamp("ended_at", { withTimezone: true }), "time_entries",
duration_seconds: integer("duration_seconds"), // set on stop {
}); id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
project_id: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
phase_id: text("phase_id").references(() => phases.id, { onDelete: "set null" }),
task_id: text("task_id").references(() => tasks.id, { onDelete: "set null" }),
started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
ended_at: timestamp("ended_at", { withTimezone: true }),
duration_seconds: integer("duration_seconds"), // set on stop
},
(t) => [
index("time_entries_phase_idx").on(t.phase_id),
index("time_entries_task_idx").on(t.task_id),
]
);
// ============ SERVICE CATALOG (admin-only, used for quote generation) ============ // ============ SERVICE CATALOG (admin-only, used for quote generation) ============
export const service_catalog = pgTable("service_catalog", { export const service_catalog = pgTable("service_catalog", {
+47 -3
View File
@@ -25,7 +25,7 @@ import {
clientTranscripts, clientTranscripts,
client_emails, client_emails,
} from "@/db/schema"; } from "@/db/schema";
import { eq, ne, inArray, asc, desc, isNull, sql, and } from "drizzle-orm"; import { eq, ne, inArray, asc, desc, isNull, isNotNull, sql, and } from "drizzle-orm";
import { getPool } from "@/lib/taxonomy"; import { getPool } from "@/lib/taxonomy";
import { LEAD_STAGES } from "@/lib/lead-validators"; import { LEAD_STAGES } from "@/lib/lead-validators";
import type { import type {
@@ -564,7 +564,13 @@ export type ProjectFullDetail = {
activeServices: Service[]; activeServices: Service[];
activeTimerEntryId: string | null; activeTimerEntryId: string | null;
activeTimerStartedAt: Date | null; activeTimerStartedAt: Date | null;
/** Su quale fase/task sta girando il timer attivo — null se è a livello progetto. */
activeTimerPhaseId: string | null;
activeTimerTaskId: string | null;
totalTrackedSeconds: number; totalTrackedSeconds: number;
/** Secondi tracciati per task e per fase (id → secondi). Assente = zero. */
taskSeconds: Record<string, number>;
phaseSeconds: Record<string, number>;
projectOffers: ProjectOfferWithMicro[]; projectOffers: ProjectOfferWithMicro[];
/** Sum of accepted_total across all active project offers — used as default for payment plan */ /** Sum of accepted_total across all active project offers — used as default for payment plan */
offersAcceptedTotal: number; offersAcceptedTotal: number;
@@ -633,7 +639,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.from(deliverables) .from(deliverables)
.where(inArray(deliverables.task_id, taskIds)); .where(inArray(deliverables.task_id, taskIds));
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows, transcriptsRows] = const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, taskSecondsRows, phaseSecondsRows, projectOffersRows, availableMicrosRows, transcriptsRows] =
await Promise.all([ await Promise.all([
db.select().from(payments).where(eq(payments.project_id, id)), db.select().from(payments).where(eq(payments.project_id, id)),
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)), db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
@@ -654,7 +660,12 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.orderBy(asc(quote_items.id)), .orderBy(asc(quote_items.id)),
db.select().from(services).where(eq(services.active, true)).orderBy(asc(services.name)), db.select().from(services).where(eq(services.active, true)).orderBy(asc(services.name)),
db db
.select({ id: time_entries.id, started_at: time_entries.started_at }) .select({
id: time_entries.id,
started_at: time_entries.started_at,
phase_id: time_entries.phase_id,
task_id: time_entries.task_id,
})
.from(time_entries) .from(time_entries)
.where(and(eq(time_entries.project_id, id), isNull(time_entries.ended_at))) .where(and(eq(time_entries.project_id, id), isNull(time_entries.ended_at)))
.limit(1), .limit(1),
@@ -662,6 +673,26 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` }) .select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
.from(time_entries) .from(time_entries)
.where(eq(time_entries.project_id, id)), .where(eq(time_entries.project_id, id)),
// Rollup per task e per fase. Il timer su un task scrive ENTRAMBE le
// colonne (vedi startTimer), quindi il totale di fase è un group-by
// diretto su phase_id e comprende anche il tempo imputato alla fase ma a
// nessun task in particolare.
db
.select({
task_id: time_entries.task_id,
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
})
.from(time_entries)
.where(and(eq(time_entries.project_id, id), isNotNull(time_entries.task_id)))
.groupBy(time_entries.task_id),
db
.select({
phase_id: time_entries.phase_id,
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
})
.from(time_entries)
.where(and(eq(time_entries.project_id, id), isNotNull(time_entries.phase_id)))
.groupBy(time_entries.phase_id),
// Query A: project offers for this project joined with micro + macro info // Query A: project offers for this project joined with micro + macro info
db db
.select({ .select({
@@ -733,6 +764,15 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
})), })),
})); }));
const taskSeconds: Record<string, number> = {};
for (const row of taskSecondsRows) {
if (row.task_id) taskSeconds[row.task_id] = parseInt(row.total);
}
const phaseSeconds: Record<string, number> = {};
for (const row of phaseSecondsRows) {
if (row.phase_id) phaseSeconds[row.phase_id] = parseInt(row.total);
}
// Defensive dedup: until the DB cleanup removes genuine duplicate tiers, // Defensive dedup: until the DB cleanup removes genuine duplicate tiers,
// keep one micro per (macro_id, tier_letter) for the assignment dropdown. // keep one micro per (macro_id, tier_letter) for the assignment dropdown.
const seenTier = new Set<string>(); const seenTier = new Set<string>();
@@ -763,7 +803,11 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
activeServices: activeServiceRows, activeServices: activeServiceRows,
activeTimerEntryId: activeEntryRows[0]?.id ?? null, activeTimerEntryId: activeEntryRows[0]?.id ?? null,
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null, activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
activeTimerPhaseId: activeEntryRows[0]?.phase_id ?? null,
activeTimerTaskId: activeEntryRows[0]?.task_id ?? null,
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0, totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
taskSeconds,
phaseSeconds,
projectOffers: projectOffersRows as ProjectOfferWithMicro[], projectOffers: projectOffersRows as ProjectOfferWithMicro[],
offersAcceptedTotal, offersAcceptedTotal,
availableMicros: dedupedMicros, availableMicros: dedupedMicros,