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:
+31
-11
@@ -109,6 +109,10 @@ export const projects = pgTable("projects", {
|
||||
offer_id: text("offer_id")
|
||||
.references(() => offer_micros.id, { onDelete: "set null" }),
|
||||
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(),
|
||||
});
|
||||
|
||||
@@ -242,17 +246,33 @@ export const notes = pgTable("notes", {
|
||||
});
|
||||
|
||||
// ============ TIME ENTRIES (admin time tracking per project) ============
|
||||
export const time_entries = pgTable("time_entries", {
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
project_id: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
ended_at: timestamp("ended_at", { withTimezone: true }),
|
||||
duration_seconds: integer("duration_seconds"), // set on stop
|
||||
});
|
||||
// project_id è obbligatoria: ogni entry è sempre attribuita a un progetto.
|
||||
// phase_id/task_id (migration 0018) sono il dettaglio facoltativo — NULL su
|
||||
// tutte le righe precedenti, che sono "tempo di progetto" e restano tali.
|
||||
//
|
||||
// ON DELETE SET NULL, non cascade: cancellare un task non deve cancellare le
|
||||
// ore lavorate su di esso. L'entry ricade a livello progetto, il totale non
|
||||
// cambia mai.
|
||||
export const time_entries = pgTable(
|
||||
"time_entries",
|
||||
{
|
||||
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) ============
|
||||
export const service_catalog = pgTable("service_catalog", {
|
||||
|
||||
Reference in New Issue
Block a user