diff --git a/src/app/admin/projects/[id]/page.tsx b/src/app/admin/projects/[id]/page.tsx index 83ca08e..5ccab71 100644 --- a/src/app/admin/projects/[id]/page.tsx +++ b/src/app/admin/projects/[id]/page.tsx @@ -35,7 +35,11 @@ export default async function ProjectDetailPage({ notes, activeTimerEntryId, activeTimerStartedAt, + activeTimerPhaseId, + activeTimerTaskId, totalTrackedSeconds, + taskSeconds, + phaseSeconds, projectOffers, availableMicros, offersAcceptedTotal, @@ -76,7 +80,18 @@ export default async function ProjectDetailPage({ } + listView={ + + } phases={phases} clientId={id} /> @@ -118,6 +133,7 @@ export default async function ProjectDetailPage({ acceptedTotal={project.accepted_total ?? "0"} activeTimerEntryId={activeTimerEntryId} activeTimerStartedAt={activeTimerStartedAt} + activeTimerScoped={activeTimerPhaseId !== null || activeTimerTaskId !== null} totalTrackedSeconds={totalTrackedSeconds} targetHourlyRate={targetHourlyRate} recentEntries={recentEntries} diff --git a/src/app/admin/timer-actions.ts b/src/app/admin/timer-actions.ts index fb34331..e4bdc49 100644 --- a/src/app/admin/timer-actions.ts +++ b/src/app/admin/timer-actions.ts @@ -9,11 +9,26 @@ async function requireAdmin() { const session = await getServerSession(authOptions); if (!session) throw new Error("Non autorizzato"); } -import { time_entries, projects } from "@/db/schema"; -import { eq, isNull, asc } from "drizzle-orm"; +import { time_entries } from "@/db/schema"; +import { eq, isNull } from "drizzle-orm"; 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(); // Stop any currently running session before starting a new one const running = await db @@ -38,26 +53,18 @@ export async function startTimer(projectId: string): Promise<{ entryId: string } } 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/projects"); revalidatePath(`/admin/projects/${projectId}`); 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 { await requireAdmin(); const rows = await db diff --git a/src/components/admin/TimerCell.tsx b/src/components/admin/TimerCell.tsx index ce657d7..671b924 100644 --- a/src/components/admin/TimerCell.tsx +++ b/src/components/admin/TimerCell.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useTransition } from "react"; 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 { const h = Math.floor(seconds / 3600); @@ -13,17 +13,24 @@ function formatDuration(seconds: number): string { } export function TimerCell({ - clientId, projectId, + phaseId, + taskId, activeEntryId, activeStartedAt, 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; activeStartedAt: Date | null; totalTrackedSeconds: number; + /** Variante ridotta, per stare in fondo alla riga di un task. */ + compact?: boolean; }) { const router = useRouter(); const [, startTransition] = useTransition(); @@ -48,10 +55,8 @@ export function TimerCell({ startTransition(async () => { if (isRunning && activeEntryId) { await stopTimer(activeEntryId); - } else if (projectId) { - await startTimer(projectId); } else { - await startTimerForClient(clientId); + await startTimer(projectId, { phaseId, taskId }); } router.refresh(); }); @@ -59,9 +64,15 @@ export function TimerCell({ 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 (
- {isRunning ? formatDuration(elapsed) : displayTotal} + {compact && !showTime ? null : isRunning ? formatDuration(elapsed) : displayTotal}
); } \ No newline at end of file diff --git a/src/components/admin/tabs/PhasesTab.tsx b/src/components/admin/tabs/PhasesTab.tsx index 5a94294..55ccd1e 100644 --- a/src/components/admin/tabs/PhasesTab.tsx +++ b/src/components/admin/tabs/PhasesTab.tsx @@ -7,11 +7,19 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton"; +import { TimerCell } from "@/components/admin/TimerCell"; import type { ClientFullDetail } from "@/lib/admin-queries"; type Props = { phases: ClientFullDetail["phases"]; 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; + phaseSeconds?: Record; }; const taskStatusOptions = [ @@ -26,7 +34,25 @@ const phaseStatusOptions = [ { 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 (
{/* Add phase form */} @@ -50,15 +76,25 @@ export async function PhasesTab({ phases, clientId }: Props) { {/* Phases list */} {phases.length === 0 && ( -

Nessuna fase ancora.

+

Nessuna fase ancora.

)} {phases.map((phase) => (
-
-

{phase.title}

+
+
+

{phase.title}

+ {projectId && ( + + {formatHours(phaseSeconds[phase.id] ?? 0)} + + )} +
{ @@ -74,7 +110,7 @@ export async function PhasesTab({ phases, clientId }: Props) { {taskStatusOptions.map((o) => (
); -} \ No newline at end of file +} diff --git a/src/components/admin/tabs/TimerTab.tsx b/src/components/admin/tabs/TimerTab.tsx index 9cc4c6e..29bb97f 100644 --- a/src/components/admin/tabs/TimerTab.tsx +++ b/src/components/admin/tabs/TimerTab.tsx @@ -16,6 +16,7 @@ type TimerTabProps = { acceptedTotal: string; activeTimerEntryId: string | null; activeTimerStartedAt: Date | null; + activeTimerScoped: boolean; totalTrackedSeconds: number; targetHourlyRate: number; recentEntries: TimeEntry[]; @@ -26,19 +27,29 @@ export function TimerTab({ acceptedTotal, activeTimerEntryId, activeTimerStartedAt, + activeTimerScoped, totalTrackedSeconds, targetHourlyRate, recentEntries, }: 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 (
-
-

Timer

+
+

Timer di progetto

+

+ {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»."} +

diff --git a/src/db/migrations/0018_timer_scope_and_due_date.sql b/src/db/migrations/0018_timer_scope_and_due_date.sql new file mode 100644 index 0000000..5d698b3 --- /dev/null +++ b/src/db/migrations/0018_timer_scope_and_due_date.sql @@ -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; diff --git a/src/db/schema.ts b/src/db/schema.ts index 979c4e4..b9145eb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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", { diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index e4fcde0..4c10b27 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -25,7 +25,7 @@ import { clientTranscripts, client_emails, } 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 { LEAD_STAGES } from "@/lib/lead-validators"; import type { @@ -564,7 +564,13 @@ export type ProjectFullDetail = { activeServices: Service[]; activeTimerEntryId: string | 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; + /** Secondi tracciati per task e per fase (id → secondi). Assente = zero. */ + taskSeconds: Record; + phaseSeconds: Record; projectOffers: ProjectOfferWithMicro[]; /** Sum of accepted_total across all active project offers — used as default for payment plan */ offersAcceptedTotal: number; @@ -633,7 +639,7 @@ export async function getProjectFullDetail(id: string): Promise`coalesce(sum(${time_entries.duration_seconds}), 0)` }) .from(time_entries) .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`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`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 db .select({ @@ -733,6 +764,15 @@ export async function getProjectFullDetail(id: string): Promise = {}; + for (const row of taskSecondsRows) { + if (row.task_id) taskSeconds[row.task_id] = parseInt(row.total); + } + const phaseSeconds: Record = {}; + 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, // keep one micro per (macro_id, tier_letter) for the assignment dropdown. const seenTier = new Set(); @@ -763,7 +803,11 @@ export async function getProjectFullDetail(id: string): Promise