docs(04): create phase plan — 4 plans in 2 waves for multi-project architecture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 11:02:56 +02:00
parent 3e3b34bbe2
commit d210cf6202
5 changed files with 2898 additions and 6 deletions
@@ -0,0 +1,672 @@
---
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"
---
<objective>
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.
</objective>
<execution_context>
@/Users/simonecavalli/.claude/get-shit-done/workflows/execute-plan.md
@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@/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
<interfaces>
<!-- Tipi e funzioni disponibili da 04-01. -->
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<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
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<ProjectFullDetail | null>;
```
Da src/lib/settings.ts:
```typescript
export const SETTINGS_KEYS: { TARGET_HOURLY_RATE: "target_hourly_rate" };
export async function getSetting(key: string): Promise<string | null>;
export async function updateSetting(key: string, value: string): Promise<void>;
export async function getTargetHourlyRate(): Promise<number>;
```
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.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Refactoring timer-actions.ts (client_id → project_id) + ProfitabilityCard + TimerTab</name>
<files>
src/app/admin/timer-actions.ts
src/components/admin/ProfitabilityCard.tsx
src/components/admin/tabs/TimerTab.tsx
</files>
<read_first>
- 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)
</read_first>
<action>
**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<void> {
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 (
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3">
<h3 className="font-medium text-[#1a1a1a]">Profittabilità</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-[#71717a] text-xs">Ore lavorate</p>
<p className="font-mono font-semibold text-[#1a1a1a]">{hours.toFixed(1)}h</p>
</div>
<div>
<p className="text-[#71717a] text-xs">Importo accettato</p>
<p className="font-mono font-semibold text-[#1a1a1a]">
{accepted > 0 ? `${accepted.toFixed(2)}` : "Non impostato"}
</p>
</div>
</div>
<div className="border-t border-[#f4f4f5] pt-3 space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-[#71717a]">/h reale</span>
<span className="font-mono font-semibold text-[#1a1a1a]">
{realHourlyRate !== null ? `${realHourlyRate.toFixed(2)}/h` : "—"}
</span>
</div>
<div className="flex justify-between">
<span className="text-[#71717a]">/h target</span>
<span className="font-mono font-semibold text-[#71717a]">{targetHourlyRate.toFixed(2)}/h</span>
</div>
<div className="flex justify-between">
<span className="text-[#71717a]">Costo ideale ({hours.toFixed(1)}h × {targetHourlyRate}/h)</span>
<span className="font-mono font-semibold text-[#1a1a1a]">{idealCost.toFixed(2)}</span>
</div>
</div>
{hours > 0 && accepted > 0 && (
<div className="border-t border-[#f4f4f5] pt-3 flex justify-between items-center">
<span className="text-[#71717a]">Delta (guadagno/perdita)</span>
<span className={`font-mono font-bold ${deltaIsProfit ? "text-green-600" : "text-red-600"}`}>
{deltaIsProfit ? "+" : ""}{delta.toFixed(2)}
</span>
</div>
)}
{hours === 0 && (
<p className="text-xs text-[#71717a] border-t border-[#f4f4f5] pt-3">
Avvia il timer per iniziare a tracciare le ore.
</p>
)}
</div>
);
}
```
**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 (
<div className="space-y-6">
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4">
<h3 className="font-medium text-[#1a1a1a] mb-4">Timer</h3>
<TimerCell
clientId={projectId}
activeEntryId={activeTimerEntryId}
activeStartedAt={activeTimerStartedAt}
totalTrackedSeconds={totalTrackedSeconds}
/>
</div>
<ProfitabilityCard
acceptedTotal={acceptedTotal}
totalTrackedSeconds={totalTrackedSeconds}
targetHourlyRate={targetHourlyRate}
/>
</div>
);
}
```
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.
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>Timer migrato a project_id, ProfitabilityCard e TimerTab creati</done>
</task>
<task type="auto">
<name>Task 2: /admin/projects/[id] workspace + /admin/impostazioni settings page</name>
<files>
src/app/admin/projects/[id]/page.tsx
src/app/admin/impostazioni/page.tsx
</files>
<read_first>
- 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)
</read_first>
<action>
**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 (
<div>
<div className="mb-4">
<Link href="/admin/projects" className="text-sm text-[#71717a] hover:text-[#1a1a1a]">
Progetti
</Link>
</div>
<div className="mb-6 flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold text-[#1a1a1a]">{project.name}</h1>
<p className="text-sm text-[#71717a]">
<Link href={`/admin/clients/${project.client.id}`} className="hover:text-[#1a1a1a] hover:underline">
{project.client.name}
</Link>
</p>
</div>
</div>
<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="quote">Preventivo</TabsTrigger>
<TabsTrigger value="timer">Timer</TabsTrigger>
</TabsList>
<TabsContent value="phases">
<PhasesViewToggle
listView={<PhasesTab phases={phases} clientId={id} />}
phases={phases}
clientId={id}
/>
</TabsContent>
<TabsContent value="payments">
<PaymentsTab payments={payments} clientId={id} />
</TabsContent>
<TabsContent value="documents">
<DocumentsTab documents={documents} clientId={id} />
</TabsContent>
{/* Render NotesTab solo se il component esiste — altrimenti inline */}
<TabsContent value="notes">
<div className="space-y-4">
{notes.length === 0 && (
<p className="text-sm text-[#71717a]">Nessuna nota ancora.</p>
)}
{notes.map((note) => (
<div key={note.id} className="bg-white rounded-lg border border-[#e5e7eb] p-4">
<p className="text-sm text-[#1a1a1a] whitespace-pre-wrap">{note.body}</p>
<p className="text-xs text-[#71717a] mt-2">
{new Date(note.created_at).toLocaleDateString("it-IT")}
</p>
</div>
))}
</div>
</TabsContent>
<TabsContent value="comments">
<CommentsTab comments={comments} clientId={id} />
</TabsContent>
<TabsContent value="quote">
<QuoteTab
quoteItems={quoteItems}
activeServices={activeServices}
clientId={id}
acceptedTotal={project.accepted_total ?? "0"}
/>
</TabsContent>
<TabsContent value="timer">
<TimerTab
projectId={id}
acceptedTotal={project.accepted_total ?? "0"}
activeTimerEntryId={activeTimerEntryId}
activeTimerStartedAt={activeTimerStartedAt}
totalTrackedSeconds={totalTrackedSeconds}
targetHourlyRate={targetHourlyRate}
/>
</TabsContent>
</Tabs>
</div>
);
}
```
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 (
<div>
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-6">Impostazioni</h1>
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 max-w-md">
<h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2>
<form action={handleSave} className="space-y-4">
<div>
<label
htmlFor="target_hourly_rate"
className="block text-sm font-medium text-[#1a1a1a] mb-1"
>
Tariffa oraria target (/h)
</label>
<p className="text-xs text-[#71717a] mb-2">
Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto.
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-[#71717a]"></span>
<input
id="target_hourly_rate"
name="target_hourly_rate"
type="number"
step="0.01"
min="0"
defaultValue={targetRate.toFixed(2)}
className="border border-[#e5e7eb] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 w-32"
/>
<span className="text-sm text-[#71717a]">/h</span>
</div>
</div>
<button
type="submit"
className="bg-[#1A463C] text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors"
>
Salva
</button>
</form>
</div>
</div>
);
}
```
</action>
<verify>
<automated>npm run build 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>/admin/projects/[id] workspace completo con timer e analytics; /admin/impostazioni funzionale</done>
</task>
</tasks>
<threat_model>
## 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 |
</threat_model>
<verification>
```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
```
</verification>
<success_criteria>
- /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
</success_criteria>
<output>
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
</output>