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:
@@ -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 & 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>
|
||||
Reference in New Issue
Block a user