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:
@@ -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({
|
||||
|
||||
<TabsContent value="phases">
|
||||
<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}
|
||||
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}
|
||||
|
||||
@@ -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<void> {
|
||||
await requireAdmin();
|
||||
const rows = await db
|
||||
|
||||
@@ -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 (
|
||||
<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
|
||||
? "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"
|
||||
@@ -70,7 +81,9 @@ export function TimerCell({
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
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
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600"
|
||||
: "bg-foreground/10 text-foreground hover:bg-foreground/20"
|
||||
@@ -90,7 +103,7 @@ export function TimerCell({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isRunning ? formatDuration(elapsed) : displayTotal}
|
||||
{compact && !showTime ? null : isRunning ? formatDuration(elapsed) : displayTotal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
phaseSeconds?: Record<string, number>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Add phase form */}
|
||||
@@ -50,15 +76,25 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
|
||||
{/* Phases list */}
|
||||
{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) => (
|
||||
<div
|
||||
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">
|
||||
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
|
||||
<div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
|
||||
<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">
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
@@ -74,7 +110,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
<select
|
||||
name="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) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
@@ -95,10 +131,25 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
{phase.tasks.map((task) => (
|
||||
<div
|
||||
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">
|
||||
{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
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
@@ -113,7 +164,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
<select
|
||||
name="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) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
@@ -158,4 +209,4 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6 max-w-sm">
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4">
|
||||
<h3 className="font-medium text-[#1a1a1a] mb-4">Timer</h3>
|
||||
<div className="bg-card rounded-lg border border-border p-4">
|
||||
<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
|
||||
clientId={projectId}
|
||||
projectId={projectId}
|
||||
activeEntryId={activeTimerEntryId}
|
||||
activeStartedAt={activeTimerStartedAt}
|
||||
activeEntryId={projectEntryId}
|
||||
activeStartedAt={projectEntryId ? activeTimerStartedAt : null}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
/>
|
||||
</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
@@ -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", {
|
||||
|
||||
@@ -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<string, number>;
|
||||
phaseSeconds: Record<string, number>;
|
||||
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<ProjectFullDetai
|
||||
.from(deliverables)
|
||||
.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([
|
||||
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)),
|
||||
@@ -654,7 +660,12 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
.orderBy(asc(quote_items.id)),
|
||||
db.select().from(services).where(eq(services.active, true)).orderBy(asc(services.name)),
|
||||
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)
|
||||
.where(and(eq(time_entries.project_id, id), isNull(time_entries.ended_at)))
|
||||
.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)` })
|
||||
.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<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
|
||||
db
|
||||
.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,
|
||||
// keep one micro per (macro_id, tier_letter) for the assignment dropdown.
|
||||
const seenTier = new Set<string>();
|
||||
@@ -763,7 +803,11 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
activeServices: activeServiceRows,
|
||||
activeTimerEntryId: activeEntryRows[0]?.id ?? 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,
|
||||
taskSeconds,
|
||||
phaseSeconds,
|
||||
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
||||
offersAcceptedTotal,
|
||||
availableMicros: dedupedMicros,
|
||||
|
||||
Reference in New Issue
Block a user