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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user