49ef45da83
- 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>
672 lines
27 KiB
Markdown
672 lines
27 KiB
Markdown
---
|
||
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
|
||
|
||
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 & 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> |