Files
clienthub/.planning/phases/04-progetti-multi-project/04-03-PLAN.md
T
simone 49ef45da83 fix(04): revision 1 — depends_on format + D-12 client list coverage
- 04-02/03/04: change depends_on from filename format to plan ID format
  ('04-01-PLAN.md' → '04-01') to match orchestrator expectations and
  Phase 3 precedent
- 04-02: add Task 3 implementing D-12 — /admin/clients list shows brand
  names below client name and LTV as sum of project accepted_totals;
  update getAllClientsWithPayments query and ClientRow component;
  add src/app/admin/page.tsx, src/lib/admin-queries.ts,
  src/components/admin/ClientRow.tsx to files_modified

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:10:25 +02:00

27 KiB
Raw Blame History


phase: 04-progetti-multi-project plan: "03" type: execute wave: 2 depends_on:

  • "04-01" 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
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.

<execution_context> @/Users/simonecavalli/.claude/get-shit-done/workflows/execute-plan.md @/Users/simonecavalli/.claude/get-shit-done/templates/summary.md </execution_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

Da src/lib/admin-queries.ts:

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:

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.

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

<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>

**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: stringprojectId: 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
"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)
// 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".

"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.

npx tsc --noEmit 2>&1 | head -20

<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>

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

<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>

**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
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:

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

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>
  );
}
npm run build 2>&1 | tail -30

<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>

/admin/projects/[id] workspace completo con timer e analytics; /admin/impostazioni funzionale

<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>
```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>