Compare commits

...

3 Commits

Author SHA1 Message Date
simone d6e95ef66a feat(progetti): riepilogo soldi e avanzamento in testa al progetto
Per sapere a che punto era un progetto bisognava aprire tre tab e sommare a
mente. Ora la risposta e' sopra i tab: contrattualizzato, incassato, da
incassare, redditivita' oraria, e una barra con i task fatti sul totale.

Nessuna query nuova: sono tutti dati che getProjectFullDetail restituisce gia'
per i tab sottostanti. E' aritmetica su quello che c'e'.

Una scelta: l'incassato si legge dai `payments`, non da `accepted_total`. Il
contratto dice quanto vale il progetto, le rate dicono quanto e' entrato
davvero, e quando i due non tornano e' un'informazione — non un errore di
calcolo da nascondere pareggiando i conti.

MetricCard esce da admin/page.tsx e diventa un componente condiviso: due copie
della stessa card avrebbero iniziato a divergere alla prima modifica.

Numeri verificati contro il DB di produzione, progetto per progetto. Rossi Inc:
7.000 EUR su 30h tracciate = 233 EUR/h sopra il target di 100, 3 task su 28,
1 fase su 4. Teckell, che ha ore ma nessun contratto, mostra "—" e non uno
zero travestito da dato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:40:48 +02:00
simone a9358da96f 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>
2026-08-19 22:38:06 +02:00
simone 4b135ce67f refactor(progetti): via il tab Commenti e il timer dalla lista
Due rimozioni chieste esplicitamente, che tolgono due doppioni.

Il tab "Commenti" del progetto duplicava /admin/conversazioni. Non era una
scorciatoia: era la seconda copia. `buildEntityMap()` in conversations-queries
cammina clienti -> progetti -> fasi -> task -> deliverable e raccoglie TUTTI i
commenti con l'etichetta dell'entita' di origine, quindi l'inbox e' un
sovrainsieme stretto di quel tab. La lettura non perde niente.

Una cosa la perde, e va detta: dal tab si poteva rispondere sulla singola
entita', mentre `replyToConversation` salva sempre sul thread generale. Non e'
una regressione introdotta qui — e' una scelta di prodotto gia' presa e gia'
annotata in conversazioni/actions.ts — ma da oggi e' l'unica via, e il commento
la' sopra ora lo dice.

Il timer nella lista progetti era l'altro doppione: si avvia e si ferma dentro
il progetto, dove c'e' il contesto per sapere su cosa stai lavorando. Toglierlo
elimina anche una query per pagina (la scansione delle entry aperte).

Cade di conseguenza il codice rimasto senza chiamanti: CommentsTab.tsx,
`postAdminComment`, il campo `comments` di ProjectFullDetail con la sua query, e
i due campi activeTimer* di ProjectWithPayments. `totalTrackedSeconds` resta:
serve al calcolo del EUR/h, che in lista ci sta ancora.

Build e lint puliti.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:30:58 +02:00
16 changed files with 459 additions and 276 deletions
+5 -16
View File
@@ -18,7 +18,6 @@ import {
payments,
clients,
projects,
comments,
client_emails,
otp_codes,
} from "@/db/schema";
@@ -368,21 +367,11 @@ export async function updateAcceptedTotal(id: string, formData: FormData) {
}
}
// ── COMMENTS (admin reply) ────────────────────────────────────────────────────
export async function postAdminComment(id: string, formData: FormData) {
await requireAdmin();
const entity = formData.get("entity") as string;
const body = (formData.get("body") as string)?.trim();
if (!body || !entity) throw new Error("Dati mancanti");
const [entity_type, entity_id] = entity.split(":");
if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
const allowedTypes = ["task", "deliverable", "phase", "general"];
if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
const { path } = await resolveEntity(id);
revalidatePath(path);
}
// ── COMMENTS ──────────────────────────────────────────────────────────────────
// La risposta dell'admin vive in `replyToConversation`
// (src/app/admin/conversazioni/actions.ts): unica inbox, un solo punto di
// scrittura. Qui c'era `postAdminComment`, che rispondeva sulla singola entità
// dal tab Commenti del progetto — tab rimosso, action con lui.
// ── ACCESSI PORTALE (whitelist OTP) ──────────────────────────────────────────
// La whitelist è l'unico modo per entrare nel portale: nessuna auto-registrazione.
+5 -1
View File
@@ -15,7 +15,11 @@ async function requireAdmin() {
/**
* Admin reply from the Conversazioni inbox. Per project decision, replies are
* saved as a "general" comment on the client (entity_id = clientId), so they
* surface in the client's general chat and in the client detail CommentsTab.
* surface in the client's general chat.
*
* Questa è l'UNICA via di risposta dell'admin da quando il tab Commenti del
* progetto è stato rimosso: i messaggi su fase/task/deliverable si leggono qui
* con la loro etichetta, ma la risposta torna sempre sul thread generale.
*/
export async function replyToConversation(clientId: string, formData: FormData) {
await requireAdmin();
+1 -45
View File
@@ -12,54 +12,10 @@ import { YearSelector } from "@/components/admin/YearSelector";
import { ForecastChart } from "@/components/admin/ForecastChart";
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability";
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
export const revalidate = 0;
function MetricCard({
label,
value,
valueAccent,
delta,
sub,
}: {
label: string;
value: string;
valueAccent?: boolean;
delta?: string;
sub?: string;
}) {
return (
<div className="bg-card p-5 rounded-xl border border-border-light shadow-card">
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
{label}
</span>
<div className="flex items-baseline gap-2 mt-1">
<span
className={`text-2xl font-bold font-mono ${
valueAccent ? "text-emerald-700 dark:text-emerald-400" : "text-foreground"
}`}
>
{value}
</span>
{delta ? (
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{delta}</span>
) : sub ? (
<span className="text-xs text-muted-foreground">{sub}</span>
) : null}
</div>
</div>
);
}
function fmtEur0(n: number) {
return n.toLocaleString("it-IT", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
export default async function AdminDashboard({
searchParams,
}: {
+26 -8
View File
@@ -5,10 +5,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
import { TimerTab } from "@/components/admin/tabs/TimerTab";
import { OffersTab } from "@/components/admin/tabs/OffersTab";
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
import { ProjectSummary } from "@/components/admin/ProjectSummary";
import Link from "next/link";
export const revalidate = 0;
@@ -34,10 +34,13 @@ export default async function ProjectDetailPage({
payments,
documents,
notes,
comments,
activeTimerEntryId,
activeTimerStartedAt,
activeTimerPhaseId,
activeTimerTaskId,
totalTrackedSeconds,
taskSeconds,
phaseSeconds,
projectOffers,
availableMicros,
offersAcceptedTotal,
@@ -66,20 +69,38 @@ export default async function ProjectDetailPage({
</div>
</div>
<ProjectSummary
acceptedTotal={project.accepted_total ?? "0"}
payments={payments}
phases={phases}
totalTrackedSeconds={totalTrackedSeconds}
targetHourlyRate={targetHourlyRate}
/>
<Tabs defaultValue="phases" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="phases">Fasi &amp; Task</TabsTrigger>
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
<TabsTrigger value="documents">Documenti</TabsTrigger>
<TabsTrigger value="notes">Note</TabsTrigger>
<TabsTrigger value="comments">Commenti</TabsTrigger>
<TabsTrigger value="timer">Timer</TabsTrigger>
<TabsTrigger value="offers">Offerte</TabsTrigger>
</TabsList>
<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}
/>
@@ -115,16 +136,13 @@ export default async function ProjectDetailPage({
</div>
</TabsContent>
<TabsContent value="comments">
<CommentsTab comments={comments} phases={phases} clientId={id} />
</TabsContent>
<TabsContent value="timer">
<TimerTab
projectId={id}
acceptedTotal={project.accepted_total ?? "0"}
activeTimerEntryId={activeTimerEntryId}
activeTimerStartedAt={activeTimerStartedAt}
activeTimerScoped={activeTimerPhaseId !== null || activeTimerTaskId !== null}
totalTrackedSeconds={totalTrackedSeconds}
targetHourlyRate={targetHourlyRate}
recentEntries={recentEntries}
-1
View File
@@ -44,7 +44,6 @@ export default async function ProjectsPage() {
<th className="py-4 px-6 text-right">Valore Totale</th>
<th className="py-4 px-6 text-center">Acconto</th>
<th className="py-4 px-6 text-center">Saldo</th>
<th className="py-4 px-6 text-center">Timer</th>
<th className="py-4 px-6 text-right">Redditività (/H)</th>
</tr>
</thead>
+24 -17
View File
@@ -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
+53
View File
@@ -0,0 +1,53 @@
/**
* Riquadro di una metrica. Estratto dalla dashboard admin quando è servito anche
* al riepilogo del singolo progetto: due copie della stessa card avrebbero
* iniziato a divergere alla prima modifica.
*
* `delta` e `sub` occupano lo stesso posto: `delta` vince quando c'è, perché una
* variazione è più interessante di una didascalia.
*/
export function MetricCard({
label,
value,
valueAccent,
delta,
sub,
}: {
label: string;
value: string;
valueAccent?: boolean;
delta?: string;
sub?: string;
}) {
return (
<div className="bg-card p-5 rounded-xl border border-border-light shadow-card">
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
{label}
</span>
<div className="flex items-baseline gap-2 mt-1">
<span
className={`text-2xl font-bold font-mono ${
valueAccent ? "text-emerald-700 dark:text-emerald-400" : "text-foreground"
}`}
>
{value}
</span>
{delta ? (
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{delta}</span>
) : sub ? (
<span className="text-xs text-muted-foreground">{sub}</span>
) : null}
</div>
</div>
);
}
/** Formatta in euro senza decimali — per i totali, dove i centesimi sono rumore. */
export function fmtEur0(n: number) {
return n.toLocaleString("it-IT", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
-13
View File
@@ -1,5 +1,4 @@
import Link from "next/link";
import { TimerCell } from "@/components/admin/TimerCell";
import type { ProjectWithPayments } from "@/lib/admin-queries";
const statusConfig: Record<string, { label: string; className: string }> = {
@@ -80,18 +79,6 @@ export function ProjectRow({ project }: { project: ProjectWithPayments }) {
)}
</td>
<td className="py-4 px-6">
<div className="flex justify-center">
<TimerCell
clientId={project.id}
projectId={project.id}
activeEntryId={project.activeTimerEntryId}
activeStartedAt={project.activeTimerStartedAt}
totalTrackedSeconds={project.totalTrackedSeconds}
/>
</div>
</td>
<td className="py-4 px-6 text-right whitespace-nowrap">
{eurPerHour === null ? (
<span className="text-muted-foreground/40 font-medium"></span>
+122
View File
@@ -0,0 +1,122 @@
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
import type { ProjectFullDetail } from "@/lib/admin-queries";
type Props = {
acceptedTotal: string;
payments: ProjectFullDetail["payments"];
phases: ProjectFullDetail["phases"];
totalTrackedSeconds: number;
targetHourlyRate: number;
};
/**
* Riepilogo in testa al progetto: i soldi e l'avanzamento, prima dei tab.
*
* Nessuna query nuova — sono tutti dati che `getProjectFullDetail` restituisce
* già per i tab sottostanti. È aritmetica su quello che c'è.
*
* L'incassato si legge dai `payments`, non da `accepted_total`: il contratto
* dice quanto vale il progetto, le rate dicono quanto è entrato davvero, e
* quando i due non tornano è un'informazione, non un errore di calcolo.
*/
export function ProjectSummary({
acceptedTotal,
payments,
phases,
totalTrackedSeconds,
targetHourlyRate,
}: Props) {
const contracted = parseFloat(acceptedTotal) || 0;
const collected = payments
.filter((p) => p.status === "saldato")
.reduce((sum, p) => sum + (parseFloat(String(p.amount)) || 0), 0);
const outstanding = payments
.filter((p) => p.status === "da_saldare" || p.status === "inviata")
.reduce((sum, p) => sum + (parseFloat(String(p.amount)) || 0), 0);
const collectedPct = contracted > 0 ? Math.round((collected / contracted) * 100) : 0;
const allTasks = phases.flatMap((p) => p.tasks);
const doneTasks = allTasks.filter((t) => t.status === "done").length;
const progressPct =
allTasks.length > 0 ? Math.round((doneTasks / allTasks.length) * 100) : 0;
const donePhases = phases.filter((p) => p.status === "done").length;
const hours = totalTrackedSeconds / 3600;
const realRate = hours > 0 ? contracted / hours : null;
return (
<section className="flex flex-col gap-4 mb-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Contrattualizzato"
value={fmtEur0(contracted)}
sub={payments.length > 0 ? `${payments.length} rate` : "Nessuna rata"}
/>
<MetricCard
label="Incassato"
value={fmtEur0(collected)}
valueAccent={collected > 0}
sub={contracted > 0 ? `${collectedPct}% del totale` : undefined}
/>
<MetricCard
label="Da incassare"
value={fmtEur0(outstanding)}
sub={outstanding > 0 ? "Rate aperte" : "Tutto saldato"}
/>
<MetricCard
label="Redditività"
value={realRate === null ? "—" : `${fmtEur0(realRate)}/h`}
valueAccent={realRate !== null && realRate >= targetHourlyRate}
sub={
realRate === null
? "Nessuna ora tracciata"
: `${hours.toFixed(1)}h · target ${fmtEur0(targetHourlyRate)}/h`
}
/>
</div>
{/* Avanzamento */}
<div className="bg-card p-5 rounded-xl border border-border-light shadow-card">
<div className="flex items-baseline justify-between gap-3 flex-wrap">
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
Avanzamento
</span>
<span className="text-xs text-muted-foreground">
{allTasks.length === 0 ? (
"Nessun task"
) : (
<>
<span className="font-mono text-foreground font-semibold">
{doneTasks}/{allTasks.length}
</span>{" "}
task · {donePhases}/{phases.length} fasi complete
</>
)}
</span>
</div>
<div className="flex items-center gap-3 mt-3">
<div
className="h-2 flex-1 rounded-full bg-muted overflow-hidden"
role="progressbar"
aria-valuenow={progressPct}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Avanzamento del progetto"
>
<div
className="h-full rounded-full bg-emerald-500 transition-[width]"
style={{ width: `${progressPct}%` }}
/>
</div>
<span className="text-sm font-mono font-bold text-foreground tabular-nums shrink-0">
{progressPct}%
</span>
</div>
</div>
</section>
);
}
+23 -10
View File
@@ -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>
);
}
-109
View File
@@ -1,109 +0,0 @@
import { postAdminComment } from "@/app/admin/clients/[id]/actions";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type { Comment } from "@/db/schema";
import type { ClientFullDetail } from "@/lib/admin-queries";
type Props = {
comments: Comment[];
phases: ClientFullDetail["phases"];
clientId: string;
};
export async function CommentsTab({ comments, phases, clientId }: Props) {
// Build entity label map for display (phases, tasks, deliverables, and general)
const entityLabels: Record<string, string> = {
[clientId]: "Messaggio generale",
};
for (const phase of phases) {
entityLabels[phase.id] = `Fase: ${phase.title}`;
for (const task of phase.tasks) {
entityLabels[task.id] = `Task: ${task.title}`;
for (const d of task.deliverables) {
entityLabels[d.id] = `Deliverable: ${d.title}`;
}
}
}
// Build list of entities the admin can reply on
const entities: Array<{ id: string; type: string; label: string }> = [
{ id: clientId, type: "general", label: "Messaggio generale" },
];
for (const phase of phases) {
entities.push({ id: phase.id, type: "phase", label: `Fase: ${phase.title}` });
for (const task of phase.tasks) {
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
for (const d of task.deliverables) {
entities.push({
id: d.id,
type: "deliverable",
label: `Deliverable: ${d.title}`,
});
}
}
}
return (
<div className="space-y-6 max-w-lg">
{/* Comment list */}
{comments.length === 0 && (
<p className="text-sm text-gray-400">Nessun commento ancora.</p>
)}
<div className="space-y-3">
{comments.map((c) => (
<div
key={c.id}
className={`flex gap-3 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
>
<div
className={`rounded-lg px-3 py-2 text-sm max-w-xs ${
c.author === "admin"
? "bg-gray-900 text-white"
: "bg-white border border-gray-200 text-gray-800"
}`}
>
<p className="text-xs font-medium mb-1 opacity-60">
{c.author === "admin" ? "iamcavalli" : "Cliente"} {" "}
{entityLabels[c.entity_id] ?? c.entity_id}
</p>
<p>{c.body}</p>
</div>
</div>
))}
</div>
{/* Admin reply form */}
<form
action={async (fd: FormData) => {
"use server";
await postAdminComment(clientId, fd);
}}
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
>
<h3 className="font-medium text-gray-900 text-sm">
Rispondi come admin
</h3>
<select
name="entity"
className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white"
required
>
{entities.map((e) => (
<option key={e.id} value={`${e.type}:${e.id}`}>
{e.label}
</option>
))}
</select>
<Textarea
name="body"
placeholder="Scrivi un commento..."
rows={3}
required
/>
<Button type="submit" size="sm">
Invia risposta
</Button>
</form>
</div>
);
}
+61 -10
View File
@@ -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 -5
View File
@@ -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
View File
@@ -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", {
+52 -30
View File
@@ -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 {
@@ -477,8 +477,8 @@ export type ProjectWithPayments = {
archived: boolean;
created_at: Date;
payments: Array<{ id: string; label: string; status: string; amount: string }>;
activeTimerEntryId: string | null;
activeTimerStartedAt: Date | null;
// Niente timer attivo qui: la lista non lo mostra più, si avvia e si ferma
// dentro il progetto. `totalTrackedSeconds` resta perché serve al €/h.
totalTrackedSeconds: number;
};
@@ -506,21 +506,12 @@ export async function getAllProjectsWithPayments(
const projectIds = visible.map((p) => p.id);
const clientIds = [...new Set(visible.map((p) => p.client_id))];
const [allPayments, activeEntries, totals, parentClients] = await Promise.all([
const [allPayments, totals, parentClients] = await Promise.all([
db
.select()
.from(payments)
.where(inArray(payments.project_id, projectIds)),
db
.select({
id: time_entries.id,
project_id: time_entries.project_id,
started_at: time_entries.started_at,
})
.from(time_entries)
.where(isNull(time_entries.ended_at)),
db
.select({
project_id: time_entries.project_id,
@@ -538,7 +529,6 @@ export async function getAllProjectsWithPayments(
return visible.map((project) => {
const projectPayments = allPayments.filter((p) => p.project_id === project.id);
const activeEntry = activeEntries.find((e) => e.project_id === project.id);
const totalRow = totals.find((t) => t.project_id === project.id);
const parentClient = parentClients.find((c) => c.id === project.client_id);
@@ -555,8 +545,6 @@ export async function getAllProjectsWithPayments(
status: p.status,
amount: String(p.amount),
})),
activeTimerEntryId: activeEntry?.id ?? null,
activeTimerStartedAt: activeEntry?.started_at ?? null,
totalTrackedSeconds: totalRow ? parseInt(totalRow.total) : 0,
};
});
@@ -570,12 +558,19 @@ export type ProjectFullDetail = {
payments: Payment[];
documents: Document[];
notes: Note[];
comments: Comment[];
// I commenti NON stanno qui: si leggono e si risponde da /admin/conversazioni,
// che li aggrega per cliente con l'etichetta dell'entità di origine.
quoteItems: QuoteItemWithLabel[];
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;
@@ -644,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)),
@@ -665,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),
@@ -673,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({
@@ -734,16 +754,6 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.orderBy(desc(clientTranscripts.call_date)),
]);
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
const commentsRows =
allEntityIds.length === 0
? []
: await db
.select()
.from(comments)
.where(inArray(comments.entity_id, allEntityIds))
.orderBy(asc(comments.created_at));
const phasesWithTasks = phasesRows.map((phase) => ({
...phase,
tasks: tasksRows
@@ -754,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>();
@@ -780,12 +799,15 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
payments: paymentsRows,
documents: documentsRows,
notes: notesRows,
comments: commentsRows,
quoteItems: quoteItemRows as QuoteItemWithLabel[],
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,