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,
|
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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,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
@@ -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", {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user