--- phase: 04-progetti-multi-project plan: "03" type: execute wave: 2 depends_on: - 04-01-PLAN.md files_modified: - src/app/admin/projects/[id]/page.tsx - src/app/admin/timer-actions.ts - src/components/admin/tabs/TimerTab.tsx - src/components/admin/ProfitabilityCard.tsx - src/app/admin/impostazioni/page.tsx autonomous: true requirements: - PROJ-01 - PROJ-03 - PROJ-05 must_haves: truths: - "La pagina /admin/projects/[id] mostra il workspace con tabs Fasi, Pagamenti, Documenti, Commenti, Preventivo, Timer" - "Il tab Timer mostra il totale ore lavorate e un bottone play/stop funzionante" - "Il tab Timer mostra la ProfitabilityCard con €/h reale, costo ideale, delta guadagno/perdita" - "timer-actions.ts usa project_id invece di client_id per startTimer e stopTimer" - "La pagina /admin/impostazioni esiste e permette di impostare target_hourly_rate" artifacts: - path: "src/app/admin/projects/[id]/page.tsx" provides: "Workspace progetto con tabs" contains: "getProjectFullDetail" - path: "src/app/admin/timer-actions.ts" provides: "Timer actions con project_id" contains: "project_id: projectId" - path: "src/components/admin/tabs/TimerTab.tsx" provides: "Tab timer con TimerCell + ProfitabilityCard" contains: "ProfitabilityCard" - path: "src/components/admin/ProfitabilityCard.tsx" provides: "Card analytics profittabilità" contains: "totalTrackedSeconds / 3600" - path: "src/app/admin/impostazioni/page.tsx" provides: "Pagina impostazioni admin con target hourly rate" contains: "target_hourly_rate" key_links: - from: "src/app/admin/projects/[id]/page.tsx" to: "src/lib/admin-queries.ts" via: "getProjectFullDetail(id)" pattern: "getProjectFullDetail" - from: "src/components/admin/tabs/TimerTab.tsx" to: "src/components/admin/ProfitabilityCard.tsx" via: "ProfitabilityCard component" pattern: "ProfitabilityCard" - from: "src/app/admin/impostazioni/page.tsx" to: "src/lib/settings.ts" via: "updateSetting / getTargetHourlyRate" pattern: "getTargetHourlyRate\|updateSetting" --- Admin project workspace (/admin/projects/[id]) e analytics profittabilità. Clona il workspace di /admin/clients/[id] adattandolo al livello progetto, refactora il timer per usare project_id, crea il TimerTab con ProfitabilityCard, e aggiunge /admin/impostazioni per il target_hourly_rate. Può girare in PARALLELO con 04-02 perché non tocca nessuno degli stessi file. Purpose: Consegna il workspace completo per progetto (PROJ-01 e PROJ-03) e le analytics profittabilità (PROJ-05). Dopo questo piano l'admin ha un workspace funzionale per ogni progetto incluso il timer e le analytics. Output: /admin/projects/[id] funzionale, timer migrato a project_id, analytics card, settings page. @/Users/simonecavalli/.claude/get-shit-done/workflows/execute-plan.md @/Users/simonecavalli/.claude/get-shit-done/templates/summary.md @/Users/simonecavalli/IAMCAVALLI/.planning/PROJECT.md @/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md @/Users/simonecavalli/IAMCAVALLI/CLAUDE.md @/Users/simonecavalli/IAMCAVALLI/.planning/phases/04-progetti-multi-project/04-01-SUMMARY.md Da src/lib/admin-queries.ts: ```typescript export type ProjectFullDetail = { project: Project & { client: { id: string; name: string; brand_name: string; slug: string | null } }; phases: Array }>; payments: Payment[]; documents: Document[]; notes: Note[]; comments: Comment[]; quoteItems: QuoteItemWithLabel[]; activeServices: ServiceCatalog[]; activeTimerEntryId: string | null; activeTimerStartedAt: Date | null; totalTrackedSeconds: number; }; export async function getProjectFullDetail(id: string): Promise; ``` Da src/lib/settings.ts: ```typescript export const SETTINGS_KEYS: { TARGET_HOURLY_RATE: "target_hourly_rate" }; export async function getSetting(key: string): Promise; export async function updateSetting(key: string, value: string): Promise; export async function getTargetHourlyRate(): Promise; ``` Template da replicare per workspace (da src/app/admin/clients/[id]/page.tsx — da leggere in read_first): - Tabs: PhasesTab, PaymentsTab, DocumentsTab, CommentsTab, QuoteTab - PhasesViewToggle per toggle kanban/list - ClientActions (ora diventano ProjectActions) Nota: TimerCell usa il prop `clientId` per la compatibilità con il nome storico — in realtà passiamo il projectId. Il componente TimerCell chiama startTimer(clientId) e stopTimer(entryId) dalle timer-actions. Task 1: Refactoring timer-actions.ts (client_id → project_id) + ProfitabilityCard + TimerTab src/app/admin/timer-actions.ts src/components/admin/ProfitabilityCard.tsx src/components/admin/tabs/TimerTab.tsx - src/app/admin/timer-actions.ts — leggere interamente: startTimer, stopTimer, le loro dipendenze da client_id nel DB - src/components/admin/TimerCell.tsx — leggere le props interface e come chiama startTimer/stopTimer - src/components/admin/tabs/QuoteTab.tsx — pattern "use client" + server action + useTransition per TimerTab - src/app/admin/clients/[id]/page.tsx — vedere come TimerCell è attualmente passato (per capire dove compare il timer e cosa props riceve) **A. Aggiornare src/app/admin/timer-actions.ts** Riscrivere startTimer per usare project_id. Leggere prima l'intero file corrente. Il cambiamento principale: 1. Parametro `clientId: string` → `projectId: string` 2. `db.insert(time_entries).values({ id, client_id: clientId })` → `db.insert(time_entries).values({ id, project_id: projectId })` 3. La query "stop any running session" rimane GLOBALE (non per progetto) — D-15: solo un timer attivo alla volta 4. Aggiornare `revalidatePath` per includere `/admin/projects` ```typescript "use server"; import { revalidatePath } from "next/cache"; import { db } from "@/db"; import { time_entries } from "@/db/schema"; import { eq, isNull, and } from "drizzle-orm"; import { nanoid } from "nanoid"; export async function startTimer(projectId: string): Promise<{ entryId: string }> { // Stop ALL currently running sessions (global: only one timer active at a time — D-15) const running = await db .select({ id: time_entries.id, started_at: time_entries.started_at }) .from(time_entries) .where(isNull(time_entries.ended_at)); for (const r of running) { const now = new Date(); const secs = Math.round((now.getTime() - new Date(r.started_at).getTime()) / 1000); await db .update(time_entries) .set({ ended_at: now, duration_seconds: secs }) .where(eq(time_entries.id, r.id)); } // Create new entry scoped to PROJECT (not client) — D-19 const id = nanoid(); await db.insert(time_entries).values({ id, project_id: projectId }); revalidatePath("/admin/projects"); revalidatePath("/admin"); return { entryId: id }; } export async function stopTimer(entryId: string): Promise { const rows = await db .select({ started_at: time_entries.started_at }) .from(time_entries) .where(eq(time_entries.id, entryId)) .limit(1); if (!rows[0]) return; const now = new Date(); const secs = Math.round((now.getTime() - new Date(rows[0].started_at).getTime()) / 1000); await db .update(time_entries) .set({ ended_at: now, duration_seconds: secs }) .where(eq(time_entries.id, entryId)); revalidatePath("/admin/projects"); revalidatePath("/admin"); } ``` **B. Creare src/components/admin/ProfitabilityCard.tsx** Implementare il componente analytics (D-20). Calcolo: - ore = totalTrackedSeconds / 3600 - €/h reale = accepted_total ÷ ore (se ore > 0, altrimenti mostrare "—") - costo ideale = targetHourlyRate × ore - delta = accepted_total - costo_ideale (positivo = guadagno, negativo = perdita) ```typescript // src/components/admin/ProfitabilityCard.tsx // NO "use client" — questo è un componente server-renderable (solo display, no interactivity) type ProfitabilityCardProps = { acceptedTotal: string; // e.g., "1500.00" totalTrackedSeconds: number; targetHourlyRate: number; // e.g., 50 }; export function ProfitabilityCard({ acceptedTotal, totalTrackedSeconds, targetHourlyRate, }: ProfitabilityCardProps) { const hours = totalTrackedSeconds / 3600; const accepted = parseFloat(acceptedTotal || "0"); const realHourlyRate = hours > 0 ? accepted / hours : null; const idealCost = targetHourlyRate * hours; const delta = accepted - idealCost; const deltaIsProfit = delta >= 0; return (

Profittabilità

Ore lavorate

{hours.toFixed(1)}h

Importo accettato

{accepted > 0 ? `€${accepted.toFixed(2)}` : "Non impostato"}

€/h reale {realHourlyRate !== null ? `€${realHourlyRate.toFixed(2)}/h` : "—"}
€/h target €{targetHourlyRate.toFixed(2)}/h
Costo ideale ({hours.toFixed(1)}h × €{targetHourlyRate}/h) €{idealCost.toFixed(2)}
{hours > 0 && accepted > 0 && (
Delta (guadagno/perdita) {deltaIsProfit ? "+" : ""}€{delta.toFixed(2)}
)} {hours === 0 && (

Avvia il timer per iniziare a tracciare le ore.

)}
); } ``` **C. Creare src/components/admin/tabs/TimerTab.tsx** Il TimerTab mostra il timer (TimerCell) e la ProfitabilityCard. È un Client Component perché TimerCell è "use client". ```typescript "use client"; import { TimerCell } from "@/components/admin/TimerCell"; import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard"; type TimerTabProps = { projectId: string; acceptedTotal: string; activeTimerEntryId: string | null; activeTimerStartedAt: Date | null; totalTrackedSeconds: number; targetHourlyRate: number; }; export function TimerTab({ projectId, acceptedTotal, activeTimerEntryId, activeTimerStartedAt, totalTrackedSeconds, targetHourlyRate, }: TimerTabProps) { return (

Timer

); } ``` NOTA: TimerCell usa il prop `clientId` ma nel contesto progetto gli passiamo `projectId`. Questo è intentionale per mantenere la compatibilità con TimerCell senza modificarlo. TimerCell chiamerà `startTimer(projectId)` — che ora è il parametro corretto. Verificare che TimerCell importi da `@/app/admin/timer-actions` e non da un path relativo. Se usa path relativo, assicurarsi che la risoluzione sia corretta.
npx tsc --noEmit 2>&1 | head -20 - src/app/admin/timer-actions.ts contains `project_id: projectId` nella insert (grep: `grep "project_id: projectId" src/app/admin/timer-actions.ts`) - src/app/admin/timer-actions.ts does NOT contain `client_id:` nella insert (grep: vecchio pattern rimosso) - src/components/admin/ProfitabilityCard.tsx exists e contains `totalTrackedSeconds / 3600` (grep) - src/components/admin/tabs/TimerTab.tsx exists e contains `ProfitabilityCard` (grep) - src/components/admin/tabs/TimerTab.tsx contains `clientId={projectId}` (passa project id a TimerCell) (grep) - TypeScript compila senza errori Timer migrato a project_id, ProfitabilityCard e TimerTab creati
Task 2: /admin/projects/[id] workspace + /admin/impostazioni settings page src/app/admin/projects/[id]/page.tsx src/app/admin/impostazioni/page.tsx - src/app/admin/clients/[id]/page.tsx — LEGGERE INTERAMENTE: questo è il template esatto che cloniamo per projects/[id]; capire tutti i component imports, i pattern params, la struttura Tabs - src/lib/settings.ts — import getTargetHourlyRate, updateSetting, SETTINGS_KEYS - src/components/admin/tabs/TimerTab.tsx — props interface appena creato in Task 1 - src/app/admin/catalog/page.tsx — pattern per la settings page (form con server action inline) **A. Creare src/app/admin/projects/[id]/page.tsx** Clonare src/app/admin/clients/[id]/page.tsx sostituendo: - `getClientFullDetail(id)` → `getProjectFullDetail(id)` (import da @/lib/admin-queries) - Le props dei tab components: sostituire clientId con projectId dove necessario - Aggiungere il tab Timer (nuovo) usando TimerTab - Header: mostrare nome progetto + "← Progetti" come breadcrumb, sottotitolo = nome cliente ```typescript import { notFound } from "next/navigation"; import { getProjectFullDetail } from "@/lib/admin-queries"; import { getTargetHourlyRate } from "@/lib/settings"; 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 { QuoteTab } from "@/components/admin/tabs/QuoteTab"; import { NotesTab } from "@/components/admin/tabs/NotesTab"; // se esiste import { TimerTab } from "@/components/admin/tabs/TimerTab"; import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle"; import Link from "next/link"; export const revalidate = 0; export default async function ProjectDetailPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const [detail, targetHourlyRate] = await Promise.all([ getProjectFullDetail(id), getTargetHourlyRate(), ]); if (!detail) notFound(); const { project, phases, payments, documents, notes, comments, quoteItems, activeServices, activeTimerEntryId, activeTimerStartedAt, totalTrackedSeconds, } = detail; return (
← Progetti

{project.name}

{project.client.name}

Fasi & Task Pagamenti Documenti Note Commenti Preventivo Timer } phases={phases} clientId={id} /> {/* Render NotesTab solo se il component esiste — altrimenti inline */}
{notes.length === 0 && (

Nessuna nota ancora.

)} {notes.map((note) => (

{note.body}

{new Date(note.created_at).toLocaleDateString("it-IT")}

))}
); } ``` NOTA CRITICA: I tab components (PhasesTab, PaymentsTab, DocumentsTab, CommentsTab, QuoteTab) potrebbero avere prop `clientId` che originariamente si riferivano al client.id. In questo contesto, passiamo il project.id come `clientId` — i tab usano quel valore per le loro server actions (addPhase, addPayment, ecc.). Le server actions di fase/pagamento/documento potrebbero ancora cercare client_id nel DB. VERIFICARE leggendo ogni actions file: - Se le actions usano ancora `client_id` nel DB, bisogna aggiornare le actions dei tab per usare `project_id`. Questo è parte dello stesso task. - Leggere src/app/admin/clients/[id]/phase-actions.ts (o simile) e src/app/admin/clients/[id]/payment-actions.ts per capire se fanno insert con client_id. - Aggiornare TUTTI i file di actions che fanno insert/update con client_id su tabelle che ora usano project_id. Specificamente, cercare tutti i file di actions: ```bash grep -r "client_id" src/app/admin/clients/[id]/ --include="*actions*" ``` Per ogni occorrenza che fa insert su phases, payments, documents, notes, quote_items: cambiare il campo da client_id a project_id e aggiornare i revalidatePath da /admin/clients/[id] a /admin/projects/[id]. **B. Creare src/app/admin/impostazioni/page.tsx** ```typescript import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings"; import { revalidatePath } from "next/cache"; export const revalidate = 0; export default async function ImpostazioniPage() { const targetRate = await getTargetHourlyRate(); async function handleSave(fd: FormData) { "use server"; const newRate = fd.get("target_hourly_rate"); if (!newRate || isNaN(parseFloat(String(newRate)))) return; await updateSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE, String(parseFloat(String(newRate)).toFixed(2))); revalidatePath("/admin/impostazioni"); } return (

Impostazioni

Analytics Profittabilità

Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto.

/h
); } ```
npm run build 2>&1 | tail -30 - src/app/admin/projects/[id]/page.tsx exists e contains `getProjectFullDetail` (grep) - src/app/admin/projects/[id]/page.tsx contains `TimerTab` import e usage (grep) - src/app/admin/projects/[id]/page.tsx contains `getTargetHourlyRate` (grep) - src/app/admin/impostazioni/page.tsx exists e contains `SETTINGS_KEYS.TARGET_HOURLY_RATE` (grep) - src/app/admin/impostazioni/page.tsx contains `updateSetting` (grep) - Tutte le actions di fase/pagamento/documento/note/quote che facevano insert con client_id sono state aggiornate a project_id (grep: `grep -r "client_id" src/app/admin/clients/\[id\]/ --include="*actions*"` non deve avere insert su tabelle migrate) - `npm run build` completa senza errori TypeScript - Navigando /admin/projects/[id] (con un progetto esistente) la pagina carica senza 500 errors - Il tab Timer mostra TimerCell e ProfitabilityCard renderizzati /admin/projects/[id] workspace completo con timer e analytics; /admin/impostazioni funzionale
## Trust Boundaries | Boundary | Description | |----------|-------------| | Admin session → timer-actions | startTimer e stopTimer non hanno requireAdmin perché chiamati da TimerCell lato client; il guard è il middleware Auth.js su /admin/* che blocca accesso non autenticato | | Admin session → impostazioni | handleSave inline server action in pagina /admin/impostazioni — il guard Auth.js su /admin/* blocca utenti non autenticati | | project workspace → quote_items | QuoteTab viene passato quoteItems da getProjectFullDetail — non accessibile via client API (D-02 / CLAUDE.md constraint) | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-04-09 | Information Disclosure | getProjectFullDetail — quoteItems | mitigate | quoteItems inclusi solo nella risposta admin (questo workspace); la funzione client-view (Wave 3, 04-04) non deve includere quote_items — invariante CLAUDE.md | | T-04-10 | Tampering | timer-actions.ts — startTimer | accept | Auth.js middleware su /admin/* impedisce accesso anonimo; timer actions non espongono dati sensibili, solo time tracking | | T-04-11 | Information Disclosure | ProfitabilityCard — accepted_total visibile | accept | accepted_total è il totale accettato dal cliente (non il dettaglio dei singoli servizi) — corretto mostrarlo all'admin nel workspace progetto | | T-04-12 | Tampering | updateSetting — target_hourly_rate | accept | Setting è solo un numero (tariffa oraria); nessun rischio sicurezza; Auth.js middleware blocca accesso non autenticato a /admin/impostazioni | | T-04-13 | Tampering | phase-actions / payment-actions migrazione project_id | mitigate | Dopo aggiornamento actions: insert usa project_id con FK constraint → DB rifiuta project_id non validi con constraint violation | ```bash # 1. Timer uses project_id grep "project_id: projectId" src/app/admin/timer-actions.ts # 2. No client_id insert in timer grep -v "project_id" src/app/admin/timer-actions.ts | grep "client_id" # 3. Analytics card exists grep "totalTrackedSeconds / 3600" src/components/admin/ProfitabilityCard.tsx # 4. TimerTab imports ProfitabilityCard grep "ProfitabilityCard" src/components/admin/tabs/TimerTab.tsx # 5. Project workspace uses new query grep "getProjectFullDetail" src/app/admin/projects/\[id\]/page.tsx # 6. Settings key constant used grep "SETTINGS_KEYS" src/app/admin/impostazioni/page.tsx # 7. Build npm run build ``` - /admin/projects/[id] carica senza errori e mostra tutti i tab (Fasi, Pagamenti, Documenti, Note, Commenti, Preventivo, Timer) - Il tab Timer mostra TimerCell (play/stop) e ProfitabilityCard (con ore, €/h reale, costo ideale, delta) - /admin/impostazioni carica e mostra il form con il valore corrente della tariffa (default 50.00 se non impostata) - Salvando un nuovo valore in /admin/impostazioni il valore viene persistito e la pagina mostra il nuovo valore - `npm run build` passa senza errori After completion, create `.planning/phases/04-progetti-multi-project/04-03-SUMMARY.md` following the template at `@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md`. Key items to document: - Quali actions file sono stati aggiornati da client_id a project_id (lista esaustiva) - Come TimerCell è stato adattato per usare project_id (prop naming) - Se NotesTab esiste come component o se le note sono state implementate inline - Valore default inizializzato per target_hourly_rate