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:
+28
-6
@@ -15,7 +15,8 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
- [x] **Phase 1: Foundation & Client Dashboard** - DB schema, token API, dashboard read-only per il cliente con link segreto condivisibile
|
- [x] **Phase 1: Foundation & Client Dashboard** - DB schema, token API, dashboard read-only per il cliente con link segreto condivisibile
|
||||||
- [ ] **Phase 2: Admin Area & Interactive Features** - Auth admin, CRUD completo clienti/fasi/task/deliverable/pagamenti, approvazioni e commenti
|
- [ ] **Phase 2: Admin Area & Interactive Features** - Auth admin, CRUD completo clienti/fasi/task/deliverable/pagamenti, approvazioni e commenti
|
||||||
- [ ] **Phase 3: Service Catalog & Quote Builder** - Catalogo servizi riutilizzabile e costruttore preventivi (admin-only, cliente vede solo il totale)
|
- [ ] **Phase 3: Service Catalog & Quote Builder** - Catalogo servizi riutilizzabile e costruttore preventivi (admin-only, cliente vede solo il totale)
|
||||||
- [ ] **Phase 4: Claude AI Onboarding (v2)** - Flusso guidato step-by-step per onboarding cliente e generazione assistita del piano/preventivo
|
- [ ] **Phase 4: Progetti — Multi-Project per Cliente** - Modello dati multi-progetto per cliente; dashboard con tabs; /admin/projects; slug link; analytics €/h
|
||||||
|
- [ ] **Phase 5: Claude AI Onboarding (v2)** - Flusso guidato step-by-step per onboarding cliente e generazione assistita del piano/preventivo
|
||||||
|
|
||||||
## Phase Details
|
## Phase Details
|
||||||
|
|
||||||
@@ -77,10 +78,30 @@ Decimal phases appear between their surrounding integers in numeric order.
|
|||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
**Status**: Planned — ready for execution
|
**Status**: Planned — ready for execution
|
||||||
|
|
||||||
### Phase 4: Claude AI Onboarding (v2)
|
### Phase 4: Progetti — Multi-Project per Cliente
|
||||||
**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude
|
**Goal**: Ogni cliente può avere N progetti indipendenti; l'admin gestisce i progetti separatamente; la dashboard cliente mostra tabs per progetti multipli; analytics di profittabilità per progetto
|
||||||
**Mode:** mvp
|
**Mode:** mvp
|
||||||
**Depends on**: Phase 3
|
**Depends on**: Phase 3
|
||||||
|
**Requirements**: PROJ-01, PROJ-02, PROJ-03, PROJ-04, PROJ-05
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. L'admin può creare più progetti per un cliente; ogni progetto ha il proprio workspace (fasi, pagamenti, preventivo, timer) accessibile da /admin/projects/[id]
|
||||||
|
2. La dashboard cliente mostra tabs per 2+ progetti; con 1 solo progetto mostra direttamente il workspace senza selettore
|
||||||
|
3. La pagina /admin/projects elenca tutti i progetti con €/h calcolato e bottone timer play/stop
|
||||||
|
4. Il link cliente supporta slug personalizzato (/c/mario-rossi) con fallback al token; slug impostabile da /admin/clients/[id]/edit
|
||||||
|
5. Il tab Timer di ogni progetto mostra analytics profittabilità: ore lavorate, accepted_total, €/h reale vs target_hourly_rate globale
|
||||||
|
**Plans**: 4 plans
|
||||||
|
**Plan list**:
|
||||||
|
- [ ] 04-01-PLAN.md — Schema migration (projects, slug, settings, FK migration) + drizzle-kit push + query layer
|
||||||
|
- [ ] 04-02-PLAN.md — Admin projects list (/admin/projects) + ProjectRow + client detail project cards
|
||||||
|
- [ ] 04-03-PLAN.md — Admin project workspace (/admin/projects/[id]) + TimerTab + ProfitabilityCard + /admin/impostazioni
|
||||||
|
- [ ] 04-04-PLAN.md — Slug resolution middleware + client dashboard multi-project + slug edit
|
||||||
|
**UI hint**: yes
|
||||||
|
**Status**: Planning complete
|
||||||
|
|
||||||
|
### Phase 5: Claude AI Onboarding (v2)
|
||||||
|
**Goal**: L'admin può usare un flusso chat guidato per onboardare un nuovo cliente e generare un piano a fasi e un preventivo assistito da Claude
|
||||||
|
**Mode:** mvp
|
||||||
|
**Depends on**: Phase 4
|
||||||
**Requirements**: CLAUDE-01, CLAUDE-02, CLAUDE-03
|
**Requirements**: CLAUDE-01, CLAUDE-02, CLAUDE-03
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
1. L'admin avvia il flusso Claude inserendo il brief del cliente; Claude guida step-by-step la raccolta delle informazioni necessarie
|
1. L'admin avvia il flusso Claude inserendo il brief del cliente; Claude guida step-by-step la raccolta delle informazioni necessarie
|
||||||
@@ -98,6 +119,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4
|
|||||||
| Phase | Plans | Status | Completed |
|
| Phase | Plans | Status | Completed |
|
||||||
|-------|-------|--------|-----------|
|
|-------|-------|--------|-----------|
|
||||||
| 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 |
|
| 1. Foundation & Client Dashboard | 5/5 | ✅ Done | 2026-05-14 |
|
||||||
| 2. Admin Area & Interactive Features | 4/4 | Planned | - |
|
| 2. Admin Area & Interactive Features | 4/4 | ✅ Done | 2026-05-15 |
|
||||||
| 3. Service Catalog & Quote Builder | 4/4 | Planned | - |
|
| 3. Service Catalog & Quote Builder | 4/4 | ✅ Done | 2026-05-19 |
|
||||||
| 4. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
|
| 4. Progetti — Multi-Project per Cliente | 0/TBD | Pending | - |
|
||||||
|
| 5. Claude AI Onboarding (v2) | 0/TBD | Pending | - |
|
||||||
@@ -0,0 +1,761 @@
|
|||||||
|
---
|
||||||
|
phase: 04-progetti-multi-project
|
||||||
|
plan: "01"
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/db/schema.ts
|
||||||
|
- src/lib/admin-queries.ts
|
||||||
|
- src/lib/settings.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- PROJ-01
|
||||||
|
- PROJ-03
|
||||||
|
- PROJ-05
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "La tabella projects esiste nel DB con colonne id, client_id, name, accepted_total, archived, created_at"
|
||||||
|
- "Le tabelle phases, payments, quote_items, time_entries, documents, notes usano project_id (non client_id) come FK"
|
||||||
|
- "La tabella clients ha il campo slug opzionale e univoco"
|
||||||
|
- "La tabella settings esiste nel DB con colonne key, value, updated_at"
|
||||||
|
- "drizzle-kit push completes con exit code 0"
|
||||||
|
- "getAllProjectsWithPayments, getProjectFullDetail, getClientWithProjects sono funzioni esportate da admin-queries.ts"
|
||||||
|
- "getSetting, updateSetting, getTargetHourlyRate sono funzioni esportate da settings.ts"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/db/schema.ts"
|
||||||
|
provides: "Schema completo con projects table, slug su clients, settings table, FK migrated"
|
||||||
|
contains: "export const projects = pgTable"
|
||||||
|
- path: "src/lib/admin-queries.ts"
|
||||||
|
provides: "Query layer per progetti"
|
||||||
|
exports:
|
||||||
|
- getAllProjectsWithPayments
|
||||||
|
- getProjectFullDetail
|
||||||
|
- getClientWithProjects
|
||||||
|
- path: "src/lib/settings.ts"
|
||||||
|
provides: "Utility per settings key-value"
|
||||||
|
exports:
|
||||||
|
- getSetting
|
||||||
|
- updateSetting
|
||||||
|
- getTargetHourlyRate
|
||||||
|
key_links:
|
||||||
|
- from: "src/lib/admin-queries.ts"
|
||||||
|
to: "src/db/schema.ts"
|
||||||
|
via: "import projects, settings from @/db/schema"
|
||||||
|
pattern: "from.*@/db/schema"
|
||||||
|
- from: "src/lib/settings.ts"
|
||||||
|
to: "src/db/schema.ts"
|
||||||
|
via: "import settings from @/db/schema"
|
||||||
|
pattern: "import.*settings.*from"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Migrazione schema database da modello single-project a multi-project. Aggiunge la tabella `projects` come contenitore principale del lavoro, migra 6 FK da `client_id` a `project_id`, aggiunge `slug` ai clients, crea la tabella `settings`, ed esegue il push al DB Neon. Refactora il query layer admin per supportare le nuove relazioni.
|
||||||
|
|
||||||
|
Purpose: Fondamenta bloccanti per tutte le Wave 2 e 3. Nessuna UI può essere costruita finché il DB non ha la struttura corretta e le query non restituiscono dati project-scoped.
|
||||||
|
|
||||||
|
Output: Schema applicato al DB live, tre nuove funzioni query esportate, utility settings.
|
||||||
|
</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/.planning/STATE.md
|
||||||
|
@/Users/simonecavalli/IAMCAVALLI/CLAUDE.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Schema corrente — punto di partenza per la migrazione. Estratto da src/db/schema.ts. -->
|
||||||
|
|
||||||
|
Tabelle con FK da clients che devono diventare project_id (D-02):
|
||||||
|
- phases: client_id → project_id (references projects.id)
|
||||||
|
- payments: client_id → project_id (references projects.id)
|
||||||
|
- quote_items: client_id → project_id (references projects.id)
|
||||||
|
- time_entries: client_id → project_id (references projects.id)
|
||||||
|
- documents: client_id → project_id (references projects.id)
|
||||||
|
- notes: client_id → project_id (references projects.id)
|
||||||
|
|
||||||
|
clients mantiene: id, name, brand_name, brief, token, archived, created_at
|
||||||
|
clients perde: accepted_total (si sposta su projects — il campo clients.accepted_total rimane ma diventa unused)
|
||||||
|
clients aggiunge: slug text().unique() (D-04)
|
||||||
|
|
||||||
|
comments rimane su entity_id generico — NON toccato (D-02).
|
||||||
|
tasks rimane su phase_id — NON toccato.
|
||||||
|
deliverables rimane su task_id — NON toccato.
|
||||||
|
service_catalog rimane invariato.
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="false">
|
||||||
|
<name>Task 1: Schema migration — projects table, slug, settings, FK migration</name>
|
||||||
|
<files>src/db/schema.ts</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/db/schema.ts — leggere l'intero file prima di modificarlo; capire l'ordine attuale delle tabelle, i pattern di import, la sezione RELATIONS e la sezione TYPESCRIPT TYPES
|
||||||
|
- CLAUDE.md — Architecture Constraints: token mai PK, quote_items mai esposti via client API, deliverables.approved_at immutable
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
Modificare src/db/schema.ts con le seguenti operazioni PRECISE, nell'ordine:
|
||||||
|
|
||||||
|
**1. Aggiungere slug a clients (D-04)**
|
||||||
|
Dopo la riga `token: text("token").notNull().unique().$defaultFn(() => nanoid()),` aggiungere:
|
||||||
|
```
|
||||||
|
// slug è opzionale, univoco, URL-safe (es. mario-rossi) — se assente, il link usa il token
|
||||||
|
slug: text("slug").unique(),
|
||||||
|
```
|
||||||
|
LASCIARE accepted_total su clients — rimane nel schema per compatibilità ma diventa unused (D-03 dice che accepted_total si sposta su projects, non che viene rimosso da clients).
|
||||||
|
|
||||||
|
**2. Inserire la tabella projects DOPO clients e PRIMA di phases (D-01)**
|
||||||
|
```typescript
|
||||||
|
// ============ PROJECTS ============
|
||||||
|
export const projects = pgTable("projects", {
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
client_id: text("client_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(), // brand/project name
|
||||||
|
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }).default("0"),
|
||||||
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Migrare FK in phases (D-02)** — cambiare:
|
||||||
|
```typescript
|
||||||
|
client_id: text("client_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: "cascade" }),
|
||||||
|
```
|
||||||
|
con:
|
||||||
|
```typescript
|
||||||
|
project_id: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Migrare FK in payments (D-02)** — stesso pattern: sostituire `client_id` → `project_id` con references(() => projects.id, { onDelete: "cascade" })
|
||||||
|
|
||||||
|
**5. Migrare FK in documents (D-02)** — stesso pattern
|
||||||
|
|
||||||
|
**6. Migrare FK in notes (D-02)** — stesso pattern
|
||||||
|
|
||||||
|
**7. Migrare FK in time_entries (D-19)** — stesso pattern: `client_id` → `project_id`
|
||||||
|
|
||||||
|
**8. Migrare FK in quote_items (D-02)** — stesso pattern. MANTENERE il commento "NEVER exposed via client API" sul campo.
|
||||||
|
|
||||||
|
**9. Aggiungere tabella settings ALLA FINE prima della sezione RELATIONS (D-21 — Claude's Discretion: key-value)**
|
||||||
|
```typescript
|
||||||
|
// ============ SETTINGS (global admin settings — key-value store) ============
|
||||||
|
export const settings = pgTable("settings", {
|
||||||
|
key: text("key").primaryKey(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**10. Aggiornare la sezione RELATIONS**
|
||||||
|
|
||||||
|
Aggiungere projectsRelations:
|
||||||
|
```typescript
|
||||||
|
export const projectsRelations = relations(projects, ({ one, many }) => ({
|
||||||
|
client: one(clients, { fields: [projects.client_id], references: [clients.id] }),
|
||||||
|
phases: many(phases),
|
||||||
|
payments: many(payments),
|
||||||
|
documents: many(documents),
|
||||||
|
notes: many(notes),
|
||||||
|
quote_items: many(quote_items),
|
||||||
|
time_entries: many(time_entries),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggiornare clientsRelations — aggiungere `projects: many(projects)`, rimuovere le relazioni che si spostano (phases, payments, documents, notes, quote_items rimangono su clients? NO — seguire il nuovo schema: phases/payments/etc. puntano ora a projects, non a clients). clientsRelations diventa:
|
||||||
|
```typescript
|
||||||
|
export const clientsRelations = relations(clients, ({ many }) => ({
|
||||||
|
projects: many(projects),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggiornare phasesRelations — cambiare `client` in `project`:
|
||||||
|
```typescript
|
||||||
|
export const phasesRelations = relations(phases, ({ one, many }) => ({
|
||||||
|
project: one(projects, { fields: [phases.project_id], references: [projects.id] }),
|
||||||
|
tasks: many(tasks),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggiornare paymentsRelations:
|
||||||
|
```typescript
|
||||||
|
export const paymentsRelations = relations(payments, ({ one }) => ({
|
||||||
|
project: one(projects, { fields: [payments.project_id], references: [projects.id] }),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggiornare documentsRelations:
|
||||||
|
```typescript
|
||||||
|
export const documentsRelations = relations(documents, ({ one }) => ({
|
||||||
|
project: one(projects, { fields: [documents.project_id], references: [projects.id] }),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggiornare notesRelations:
|
||||||
|
```typescript
|
||||||
|
export const notesRelations = relations(notes, ({ one }) => ({
|
||||||
|
project: one(projects, { fields: [notes.project_id], references: [projects.id] }),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggiornare quoteItemsRelations:
|
||||||
|
```typescript
|
||||||
|
export const quoteItemsRelations = relations(quote_items, ({ one }) => ({
|
||||||
|
project: one(projects, { fields: [quote_items.project_id], references: [projects.id] }),
|
||||||
|
service: one(service_catalog, {
|
||||||
|
fields: [quote_items.service_id],
|
||||||
|
references: [service_catalog.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggiungere timeEntriesRelations:
|
||||||
|
```typescript
|
||||||
|
export const timeEntriesRelations = relations(time_entries, ({ one }) => ({
|
||||||
|
project: one(projects, { fields: [time_entries.project_id], references: [projects.id] }),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
**11. Aggiornare la sezione TYPESCRIPT TYPES**
|
||||||
|
Aggiungere dopo le type esistenti:
|
||||||
|
```typescript
|
||||||
|
export type Project = typeof projects.$inferSelect;
|
||||||
|
export type NewProject = typeof projects.$inferInsert;
|
||||||
|
export type Setting = typeof settings.$inferSelect;
|
||||||
|
export type NewSetting = typeof settings.$inferInsert;
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>npx tsc --noEmit 2>&1 | head -30</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/db/schema.ts contains `export const projects = pgTable("projects"`
|
||||||
|
- src/db/schema.ts contains `slug: text("slug").unique()`
|
||||||
|
- src/db/schema.ts contains `export const settings = pgTable("settings"`
|
||||||
|
- src/db/schema.ts contains `project_id: text("project_id")` (at least 6 occurrences for the 6 migrated tables)
|
||||||
|
- src/db/schema.ts does NOT contain `client_id` in phases, payments, documents, notes, time_entries, quote_items tables (grep: `grep -n "client_id" src/db/schema.ts` — solo clients table e projects table devono avere client_id)
|
||||||
|
- src/db/schema.ts contains `export type Project = typeof projects.$inferSelect`
|
||||||
|
- TypeScript compila senza errori su src/db/schema.ts
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>schema.ts aggiornato con tutte le nuove tabelle e FK migrate, TypeScript pulito</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: [BLOCKING] drizzle-kit push — apply schema to Neon DB</name>
|
||||||
|
<files>src/db/schema.ts</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- drizzle.config.ts — verificare la configurazione del push (URL, schema path, out dir)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
Eseguire il push del nuovo schema al database Neon live.
|
||||||
|
|
||||||
|
IMPORTANTE: All data is test data — hard migration (drop/recreate tables) is acceptable per A2 dell'RESEARCH.md Assumptions Log. Confermare eventuali prompt interattivi di drizzle-kit con "yes" se richiesto.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx drizzle-kit push
|
||||||
|
```
|
||||||
|
|
||||||
|
Se drizzle-kit chiede conferma per operazioni distruttive (drop/recreate di colonne), rispondere "yes" — tutti i dati sono di test.
|
||||||
|
|
||||||
|
Dopo il push, verificare che le tabelle esistano nel DB con la struttura corretta.
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>npx drizzle-kit push 2>&1; echo "Exit code: $?"</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- Il comando `npx drizzle-kit push` completa con exit code 0
|
||||||
|
- L'output non contiene "error" o "failed" (case-insensitive)
|
||||||
|
- L'output conferma che le tabelle sono state create/aggiornate
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>Schema applicato al DB Neon live, tutte le nuove tabelle presenti</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Query layer — getAllProjectsWithPayments, getProjectFullDetail, getClientWithProjects + settings.ts</name>
|
||||||
|
<files>
|
||||||
|
src/lib/admin-queries.ts
|
||||||
|
src/lib/settings.ts
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/lib/admin-queries.ts — leggere l'intero file per capire i pattern di query, i tipi esistenti (ClientWithPayments, QuoteItemWithLabel), getAllClientsWithPayments e getClientFullDetail che sono i template esatti per le nuove funzioni
|
||||||
|
- src/db/schema.ts — verificare i nuovi tipi disponibili: Project, Setting, e le FK corrette (project_id)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**A. Aggiornare src/lib/admin-queries.ts**
|
||||||
|
|
||||||
|
Aggiungere in cima agli import `projects` e `settings` dalla schema:
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
clients,
|
||||||
|
projects,
|
||||||
|
payments,
|
||||||
|
phases,
|
||||||
|
tasks,
|
||||||
|
deliverables,
|
||||||
|
comments,
|
||||||
|
documents,
|
||||||
|
notes,
|
||||||
|
time_entries,
|
||||||
|
quote_items,
|
||||||
|
service_catalog,
|
||||||
|
settings,
|
||||||
|
} from "@/db/schema";
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggiungere import dei nuovi tipi:
|
||||||
|
```typescript
|
||||||
|
import type {
|
||||||
|
Client,
|
||||||
|
Project,
|
||||||
|
Phase,
|
||||||
|
Task,
|
||||||
|
Deliverable,
|
||||||
|
Payment,
|
||||||
|
Document,
|
||||||
|
Note,
|
||||||
|
Comment,
|
||||||
|
ServiceCatalog,
|
||||||
|
} from "@/db/schema";
|
||||||
|
```
|
||||||
|
|
||||||
|
**1. Aggiungere tipo ProjectWithPayments e funzione getAllProjectsWithPayments**
|
||||||
|
|
||||||
|
Seguire ESATTAMENTE il pattern di ClientWithPayments e getAllClientsWithPayments (linee 28-105 del file corrente), sostituendo clients con projects e client_id con project_id:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ProjectWithPayments = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
client: { id: string; name: string; slug: string | null };
|
||||||
|
accepted_total: string;
|
||||||
|
archived: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
payments: Array<{ id: string; label: string; status: string; amount: string }>;
|
||||||
|
activeTimerEntryId: string | null;
|
||||||
|
activeTimerStartedAt: Date | null;
|
||||||
|
totalTrackedSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAllProjectsWithPayments(
|
||||||
|
includeArchived = false
|
||||||
|
): Promise<ProjectWithPayments[]> {
|
||||||
|
// 1. Fetch all projects with their parent client
|
||||||
|
const allProjects = await db
|
||||||
|
.select({
|
||||||
|
id: projects.id,
|
||||||
|
name: projects.name,
|
||||||
|
client_id: projects.client_id,
|
||||||
|
accepted_total: projects.accepted_total,
|
||||||
|
archived: projects.archived,
|
||||||
|
created_at: projects.created_at,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.orderBy(projects.created_at);
|
||||||
|
|
||||||
|
const visible = includeArchived
|
||||||
|
? allProjects
|
||||||
|
: allProjects.filter((p) => !p.archived);
|
||||||
|
|
||||||
|
if (visible.length === 0) return [];
|
||||||
|
|
||||||
|
const projectIds = visible.map((p) => p.id);
|
||||||
|
const clientIds = [...new Set(visible.map((p) => p.client_id))];
|
||||||
|
|
||||||
|
// 2. Parallel: payments, active timer, totals, parent clients
|
||||||
|
const [allPayments, activeEntries, totals, parentClients] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(inArray(payments.project_id, projectIds)),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: time_entries.id,
|
||||||
|
project_id: time_entries.project_id,
|
||||||
|
started_at: time_entries.started_at,
|
||||||
|
})
|
||||||
|
.from(time_entries)
|
||||||
|
.where(isNull(time_entries.ended_at)),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
project_id: time_entries.project_id,
|
||||||
|
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
|
||||||
|
})
|
||||||
|
.from(time_entries)
|
||||||
|
.where(inArray(time_entries.project_id, projectIds))
|
||||||
|
.groupBy(time_entries.project_id),
|
||||||
|
|
||||||
|
db
|
||||||
|
.select({ id: clients.id, name: clients.name, slug: clients.slug })
|
||||||
|
.from(clients)
|
||||||
|
.where(inArray(clients.id, clientIds)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Build result map
|
||||||
|
return visible.map((project) => {
|
||||||
|
const projectPayments = allPayments.filter((p) => p.project_id === project.id);
|
||||||
|
const activeEntry = activeEntries.find((e) => e.project_id === project.id);
|
||||||
|
const totalRow = totals.find((t) => t.project_id === project.id);
|
||||||
|
const parentClient = parentClients.find((c) => c.id === project.client_id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
client: parentClient ?? { id: project.client_id, name: "—", slug: null },
|
||||||
|
accepted_total: project.accepted_total ?? "0",
|
||||||
|
archived: project.archived,
|
||||||
|
created_at: project.created_at,
|
||||||
|
payments: projectPayments.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
status: p.status,
|
||||||
|
amount: String(p.amount),
|
||||||
|
})),
|
||||||
|
activeTimerEntryId: activeEntry?.id ?? null,
|
||||||
|
activeTimerStartedAt: activeEntry?.started_at ?? null,
|
||||||
|
totalTrackedSeconds: totalRow ? parseInt(totalRow.total) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Aggiungere tipo ProjectFullDetail e funzione getProjectFullDetail**
|
||||||
|
|
||||||
|
Seguire il pattern di getClientFullDetail (template completo nel RESEARCH.md alle linee 455-586). Ogni query DEVE avere `.where(eq(table.project_id, id))` — verificare uno per uno:
|
||||||
|
|
||||||
|
```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> {
|
||||||
|
// 1. Fetch project
|
||||||
|
const projectRows = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (projectRows.length === 0) return null;
|
||||||
|
const project = projectRows[0];
|
||||||
|
|
||||||
|
// 2. Fetch parent client
|
||||||
|
const clientRows = await db
|
||||||
|
.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name, slug: clients.slug })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, project.client_id))
|
||||||
|
.limit(1);
|
||||||
|
const client = clientRows[0] ?? { id: project.client_id, name: "—", brand_name: "—", slug: null };
|
||||||
|
|
||||||
|
// 3. Phases scoped to THIS project (not client)
|
||||||
|
const phasesRows = await db
|
||||||
|
.select()
|
||||||
|
.from(phases)
|
||||||
|
.where(eq(phases.project_id, id))
|
||||||
|
.orderBy(asc(phases.sort_order));
|
||||||
|
|
||||||
|
const phaseIds = phasesRows.map((p) => p.id);
|
||||||
|
|
||||||
|
// 4. Tasks scoped to this project's phases
|
||||||
|
const tasksRows = phaseIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(inArray(tasks.phase_id, phaseIds))
|
||||||
|
.orderBy(asc(tasks.sort_order));
|
||||||
|
|
||||||
|
const taskIds = tasksRows.map((t) => t.id);
|
||||||
|
|
||||||
|
// 5. Deliverables scoped to this project's tasks
|
||||||
|
const deliverablesRows = taskIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(deliverables)
|
||||||
|
.where(inArray(deliverables.task_id, taskIds));
|
||||||
|
|
||||||
|
// 6. Parallel: payments, documents, notes, comments, quote items, active services, timer
|
||||||
|
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntry, totalRes] =
|
||||||
|
await Promise.all([
|
||||||
|
db.select().from(payments).where(eq(payments.project_id, id)),
|
||||||
|
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
|
||||||
|
db.select().from(notes).where(eq(notes.project_id, id)).orderBy(asc(notes.created_at)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: quote_items.id,
|
||||||
|
label: sql<string>`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
|
||||||
|
custom_label: quote_items.custom_label,
|
||||||
|
service_id: quote_items.service_id,
|
||||||
|
quantity: quote_items.quantity,
|
||||||
|
unit_price: quote_items.unit_price,
|
||||||
|
subtotal: quote_items.subtotal,
|
||||||
|
project_id: quote_items.project_id,
|
||||||
|
})
|
||||||
|
.from(quote_items)
|
||||||
|
.leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
|
||||||
|
.where(eq(quote_items.project_id, id))
|
||||||
|
.orderBy(asc(quote_items.id)),
|
||||||
|
db.select().from(service_catalog).where(eq(service_catalog.active, true)).orderBy(asc(service_catalog.name)),
|
||||||
|
db
|
||||||
|
.select({ id: time_entries.id, started_at: time_entries.started_at })
|
||||||
|
.from(time_entries)
|
||||||
|
.where(eq(time_entries.project_id, id))
|
||||||
|
.where(isNull(time_entries.ended_at))
|
||||||
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
|
||||||
|
.from(time_entries)
|
||||||
|
.where(eq(time_entries.project_id, id)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 7. Comments (polymorphic) — collect entity IDs from this project
|
||||||
|
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||||
|
const commentsRows = allEntityIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(comments)
|
||||||
|
.where(inArray(comments.entity_id, allEntityIds))
|
||||||
|
.orderBy(asc(comments.created_at));
|
||||||
|
|
||||||
|
// 8. Rebuild hierarchy
|
||||||
|
const phasesWithTasks = phasesRows.map((phase) => ({
|
||||||
|
...phase,
|
||||||
|
tasks: tasksRows
|
||||||
|
.filter((t) => t.phase_id === phase.id)
|
||||||
|
.map((task) => ({
|
||||||
|
...task,
|
||||||
|
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: { ...project, client } as any,
|
||||||
|
phases: phasesWithTasks,
|
||||||
|
payments: paymentsRows,
|
||||||
|
documents: documentsRows,
|
||||||
|
notes: notesRows,
|
||||||
|
comments: commentsRows,
|
||||||
|
quoteItems: quoteItemRows as QuoteItemWithLabel[],
|
||||||
|
activeServices: activeServiceRows,
|
||||||
|
activeTimerEntryId: activeEntry[0]?.id ?? null,
|
||||||
|
activeTimerStartedAt: activeEntry[0]?.started_at ?? null,
|
||||||
|
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTA: La chiamata `.where()` doppia nella query activeEntry non è valida in Drizzle — combinare con `and()`:
|
||||||
|
```typescript
|
||||||
|
import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm";
|
||||||
|
// ...
|
||||||
|
.where(and(eq(time_entries.project_id, id), isNull(time_entries.ended_at)))
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Aggiungere tipo ClientWithProjects e funzione getClientWithProjects**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type ClientWithProjects = Client & {
|
||||||
|
projects: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
accepted_total: string;
|
||||||
|
archived: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getClientWithProjects(clientId: string): Promise<ClientWithProjects | null> {
|
||||||
|
const clientRows = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (clientRows.length === 0) return null;
|
||||||
|
const client = clientRows[0];
|
||||||
|
|
||||||
|
const projectRows = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.client_id, clientId))
|
||||||
|
.orderBy(asc(projects.created_at));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...client,
|
||||||
|
projects: projectRows.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
accepted_total: p.accepted_total ?? "0",
|
||||||
|
archived: p.archived,
|
||||||
|
created_at: p.created_at,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Creare src/lib/settings.ts (nuovo file)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { settings } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
// Constant for all known settings keys — prevents typos at call sites
|
||||||
|
export const SETTINGS_KEYS = {
|
||||||
|
TARGET_HOURLY_RATE: "target_hourly_rate",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export async function getSetting(key: string): Promise<string | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ value: settings.value })
|
||||||
|
.from(settings)
|
||||||
|
.where(eq(settings.key, key))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0]?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSetting(key: string, value: string): Promise<void> {
|
||||||
|
const existing = await getSetting(key);
|
||||||
|
if (existing !== null) {
|
||||||
|
await db
|
||||||
|
.update(settings)
|
||||||
|
.set({ value, updated_at: new Date() })
|
||||||
|
.where(eq(settings.key, key));
|
||||||
|
} else {
|
||||||
|
await db.insert(settings).values({ key, value });
|
||||||
|
}
|
||||||
|
revalidatePath("/admin/impostazioni");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTargetHourlyRate(): Promise<number> {
|
||||||
|
const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
|
||||||
|
// Default 50€/h if never set by admin
|
||||||
|
return value ? parseFloat(value) : 50;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>npx tsc --noEmit 2>&1 | head -40</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/lib/admin-queries.ts exports `getAllProjectsWithPayments` (grep: `grep "export async function getAllProjectsWithPayments" src/lib/admin-queries.ts`)
|
||||||
|
- src/lib/admin-queries.ts exports `getProjectFullDetail` (grep: `grep "export async function getProjectFullDetail" src/lib/admin-queries.ts`)
|
||||||
|
- src/lib/admin-queries.ts exports `getClientWithProjects` (grep: `grep "export async function getClientWithProjects" src/lib/admin-queries.ts`)
|
||||||
|
- src/lib/settings.ts exists (grep: `ls src/lib/settings.ts`)
|
||||||
|
- src/lib/settings.ts exports `getSetting`, `updateSetting`, `getTargetHourlyRate` (grep: `grep "export async function" src/lib/settings.ts`)
|
||||||
|
- src/lib/settings.ts contains `SETTINGS_KEYS` constant (grep: `grep "SETTINGS_KEYS" src/lib/settings.ts`)
|
||||||
|
- `npx tsc --noEmit` completa senza errori
|
||||||
|
- `npm run build` completa senza errori TypeScript
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>Query layer aggiornato con tutte le funzioni project-scoped; settings.ts creato con SETTINGS_KEYS constant</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Admin → DB | Tutte le operazioni di schema e query avvengono lato server; nessun dato raw esposto al browser in questo piano |
|
||||||
|
| quote_items isolation | quote_items ora scoped a project_id — getProjectFullDetail li include solo nelle query admin, mai nel client-view path |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-04-01 | Information Disclosure | getProjectFullDetail | mitigate | Ogni sub-query ha WHERE eq(table.project_id, id) — verificare che nessuna query usi client_id al posto di project_id come scope; commento esplicito su ogni query |
|
||||||
|
| T-04-02 | Information Disclosure | quote_items | mitigate | quote_items inclusi SOLO in getProjectFullDetail (admin path); la funzione client-view (Wave 3) NON deve mai includere quote_items — invariante CLAUDE.md preserved |
|
||||||
|
| T-04-03 | Tampering | drizzle-kit push | accept | Hard migration su dati di test; nessun dato production a rischio (A2 assunto e verificato) |
|
||||||
|
| T-04-04 | Elevation of Privilege | settings table | accept | Settings contengono solo target_hourly_rate (non dati sensibili); letta in admin context; nessun dato cliente esposto |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Dopo il completamento di tutti e 3 i task:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Schema check
|
||||||
|
grep -c "project_id" src/db/schema.ts
|
||||||
|
|
||||||
|
# 2. Verify no client_id FK in migrated tables (solo clients e projects devono averlo)
|
||||||
|
grep -n "client_id" src/db/schema.ts
|
||||||
|
|
||||||
|
# 3. New tables present
|
||||||
|
grep "export const projects\|export const settings" src/db/schema.ts
|
||||||
|
|
||||||
|
# 4. Query functions exported
|
||||||
|
grep "export async function" src/lib/admin-queries.ts
|
||||||
|
|
||||||
|
# 5. Settings utility complete
|
||||||
|
grep "export" src/lib/settings.ts
|
||||||
|
|
||||||
|
# 6. TypeScript clean
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# 7. Build clean
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- `grep "export const projects = pgTable" src/db/schema.ts` → output presente
|
||||||
|
- `grep "export const settings = pgTable" src/db/schema.ts` → output presente
|
||||||
|
- `grep -n "client_id" src/db/schema.ts` → solo righe in clients table e projects table (client_id come FK verso clients)
|
||||||
|
- `grep "export async function getAllProjectsWithPayments\|export async function getProjectFullDetail\|export async function getClientWithProjects" src/lib/admin-queries.ts` → 3 match
|
||||||
|
- `npx drizzle-kit push` ha completato con exit code 0
|
||||||
|
- `npx tsc --noEmit` → no errori
|
||||||
|
- `npm run build` → no errori
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/04-progetti-multi-project/04-01-SUMMARY.md` following the template at `@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md`.
|
||||||
|
|
||||||
|
Key items to document:
|
||||||
|
- Exact schema changes made (nuovi campi, nuove tabelle, FK migrate)
|
||||||
|
- drizzle-kit push output (conferma che le tabelle sono state create)
|
||||||
|
- Eventuali TypeScript errors risolti e come
|
||||||
|
- Nuove funzioni query esportate con le loro signature
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
---
|
||||||
|
phase: 04-progetti-multi-project
|
||||||
|
plan: "02"
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- 04-01-PLAN.md
|
||||||
|
files_modified:
|
||||||
|
- src/components/admin/NavBar.tsx
|
||||||
|
- src/components/admin/ProjectRow.tsx
|
||||||
|
- src/app/admin/projects/page.tsx
|
||||||
|
- src/app/admin/projects/new/page.tsx
|
||||||
|
- src/app/admin/projects/project-actions.ts
|
||||||
|
- src/app/admin/clients/[id]/page.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- PROJ-01
|
||||||
|
- PROJ-03
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "La navbar admin mostra i link Progetti e Impostazioni oltre a Clienti e Catalogo"
|
||||||
|
- "La pagina /admin/projects elenca tutti i progetti con colonne Nome (+ cliente), Valore, Acconto, Saldo, Timer, €/h"
|
||||||
|
- "Il bottone '+ Nuovo Progetto' in /admin/projects apre un form che chiede nome e selezione cliente"
|
||||||
|
- "La pagina /admin/clients/[id] mostra cards dei progetti del cliente con bottone '+ Nuovo Progetto'"
|
||||||
|
- "Cliccando una card progetto si naviga a /admin/projects/[id]"
|
||||||
|
- "createProject e archiveProject sono server actions funzionanti"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/components/admin/NavBar.tsx"
|
||||||
|
provides: "NavBar con link Progetti e Impostazioni"
|
||||||
|
contains: "href=\"/admin/projects\""
|
||||||
|
- path: "src/components/admin/ProjectRow.tsx"
|
||||||
|
provides: "Riga progetto per la lista /admin/projects"
|
||||||
|
contains: "ProjectWithPayments"
|
||||||
|
- path: "src/app/admin/projects/page.tsx"
|
||||||
|
provides: "Pagina lista tutti i progetti"
|
||||||
|
contains: "getAllProjectsWithPayments"
|
||||||
|
- path: "src/app/admin/projects/new/page.tsx"
|
||||||
|
provides: "Form creazione progetto con selezione cliente"
|
||||||
|
contains: "createProject"
|
||||||
|
- path: "src/app/admin/projects/project-actions.ts"
|
||||||
|
provides: "Server actions: createProject, archiveProject, updateProjectAcceptedTotal"
|
||||||
|
contains: "export async function createProject"
|
||||||
|
- path: "src/app/admin/clients/[id]/page.tsx"
|
||||||
|
provides: "Pagina cliente modificata per mostrare project cards"
|
||||||
|
contains: "getClientWithProjects"
|
||||||
|
key_links:
|
||||||
|
- from: "src/app/admin/projects/page.tsx"
|
||||||
|
to: "src/lib/admin-queries.ts"
|
||||||
|
via: "getAllProjectsWithPayments()"
|
||||||
|
pattern: "getAllProjectsWithPayments"
|
||||||
|
- from: "src/app/admin/clients/[id]/page.tsx"
|
||||||
|
to: "src/lib/admin-queries.ts"
|
||||||
|
via: "getClientWithProjects(id)"
|
||||||
|
pattern: "getClientWithProjects"
|
||||||
|
- from: "src/components/admin/ProjectRow.tsx"
|
||||||
|
to: "src/app/admin/timer-actions.ts"
|
||||||
|
via: "TimerCell with project_id"
|
||||||
|
pattern: "TimerCell"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Admin projects list e client detail rewrite. Consegna la prima slice verticale visibile: l'admin può vedere tutti i progetti in /admin/projects, creare nuovi progetti da /admin/clients/[id] o dal form globale, e navigare ai workspace progetto.
|
||||||
|
|
||||||
|
Purpose: Rende operativa la struttura multi-project nell'area admin senza ancora richiedere il workspace completo del progetto (quello viene in 04-03). Dopo questo piano l'admin può creare e navigare progetti.
|
||||||
|
|
||||||
|
Output: NavBar aggiornata, /admin/projects funzionale, /admin/clients/[id] mostra project cards.
|
||||||
|
</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 — usare direttamente, no esplorazione codebase necessaria. -->
|
||||||
|
|
||||||
|
Da src/lib/admin-queries.ts (creato in 04-01):
|
||||||
|
```typescript
|
||||||
|
export type ProjectWithPayments = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
client: { id: string; name: string; slug: string | null };
|
||||||
|
accepted_total: string;
|
||||||
|
archived: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
payments: Array<{ id: string; label: string; status: string; amount: string }>;
|
||||||
|
activeTimerEntryId: string | null;
|
||||||
|
activeTimerStartedAt: Date | null;
|
||||||
|
totalTrackedSeconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAllProjectsWithPayments(includeArchived?: boolean): Promise<ProjectWithPayments[]>;
|
||||||
|
export async function getClientWithProjects(clientId: string): Promise<ClientWithProjects | null>;
|
||||||
|
|
||||||
|
export type ClientWithProjects = Client & {
|
||||||
|
projects: Array<{ id: string; name: string; accepted_total: string; archived: boolean; created_at: Date }>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Da src/app/admin/timer-actions.ts (da aggiornare in 04-03, ma TimerCell già usato):
|
||||||
|
```typescript
|
||||||
|
// TimerCell props (da src/components/admin/TimerCell.tsx):
|
||||||
|
// clientId: string ← NOTA: questo è un nome legacy, in ProjectRow passiamo project.id
|
||||||
|
// activeEntryId: string | null
|
||||||
|
// activeStartedAt: Date | null
|
||||||
|
// totalTrackedSeconds: number
|
||||||
|
```
|
||||||
|
|
||||||
|
Pattern ClientRow (da clonare per ProjectRow):
|
||||||
|
- src/components/admin/ClientRow.tsx — usa statusConfig, Badge, TimerCell, Link
|
||||||
|
- Colonne ClientRow: nome, token/link, LTV (accepted_total), acconto badge, saldo badge, timer
|
||||||
|
- Colonne ProjectRow (D-14): Nome+Cliente, Valore, Acconto, Saldo, Timer, €/h
|
||||||
|
|
||||||
|
€/h in lista = accepted_total ÷ (totalTrackedSeconds / 3600). Se ore = 0, mostrare "—".
|
||||||
|
|
||||||
|
Pattern admin page (da src/app/admin/page.tsx):
|
||||||
|
- export const revalidate = 0
|
||||||
|
- Server component asincrono, chiama query, passa a Row component
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: NavBar + ProjectRow + server actions (createProject, archiveProject, updateProjectAcceptedTotal)</name>
|
||||||
|
<files>
|
||||||
|
src/components/admin/NavBar.tsx
|
||||||
|
src/components/admin/ProjectRow.tsx
|
||||||
|
src/app/admin/projects/project-actions.ts
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/components/admin/NavBar.tsx — leggere struttura attuale (link presenti, stili, imports)
|
||||||
|
- src/components/admin/ClientRow.tsx — leggere interamente: questo è il template ESATTO per ProjectRow
|
||||||
|
- src/components/admin/TimerCell.tsx — leggere per capire la prop interface (clientId, activeEntryId, activeStartedAt, totalTrackedSeconds)
|
||||||
|
- src/app/admin/clients/[id]/quote-actions.ts — pattern server action (requireAdmin, revalidatePath)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**A. Aggiornare src/components/admin/NavBar.tsx**
|
||||||
|
|
||||||
|
Aggiungere i link "Progetti" e "Impostazioni" al NavBar esistente. Leggere il file per trovare dove sono i link esistenti (Clienti, Statistiche, Catalogo) e aggiungere nell'ordine:
|
||||||
|
- Clienti (/admin)
|
||||||
|
- Progetti (/admin/projects) ← NUOVO
|
||||||
|
- Statistiche (/admin/analytics)
|
||||||
|
- Catalogo (/admin/catalog)
|
||||||
|
- Impostazioni (/admin/impostazioni) ← NUOVO
|
||||||
|
|
||||||
|
Ogni link usa il pattern esistente: `<Link href="..." className="text-sm text-white/70 hover:text-white transition-colors">`.
|
||||||
|
|
||||||
|
**B. Creare src/components/admin/ProjectRow.tsx**
|
||||||
|
|
||||||
|
Clonare ClientRow.tsx sostituendo:
|
||||||
|
- `ClientWithPayments` → `ProjectWithPayments` (import da @/lib/admin-queries)
|
||||||
|
- Colonna nome: `project.name` in bold, `project.client.name` in testo secondario xs
|
||||||
|
- Rimuovere colonna token/link cliente (non si mostra il link pubblico nella lista progetti)
|
||||||
|
- Colonna valore: `€{parseFloat(project.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`
|
||||||
|
- Colonna Acconto: badge per `project.payments.find(p => p.label.toLowerCase().includes("acconto"))`
|
||||||
|
- Colonna Saldo: badge per `project.payments.find(p => p.label.toLowerCase().includes("saldo"))`
|
||||||
|
- Colonna Timer: `<TimerCell clientId={project.id} activeEntryId={project.activeTimerEntryId} activeStartedAt={project.activeTimerStartedAt} totalTrackedSeconds={project.totalTrackedSeconds} />`
|
||||||
|
- Colonna €/h: calcolo inline — `const hours = project.totalTrackedSeconds / 3600; const eurPerHour = hours > 0 ? parseFloat(project.accepted_total) / hours : null;` — mostrare `€{eurPerHour.toFixed(2)}/h` oppure `—` se null
|
||||||
|
|
||||||
|
Link cliccabile sul nome: `<Link href={"/admin/projects/" + project.id}>`.
|
||||||
|
|
||||||
|
Usare gli stessi statusConfig di ClientRow per i badge pagamento.
|
||||||
|
|
||||||
|
**C. Creare src/app/admin/projects/project-actions.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireAdmin } from "@/lib/auth"; // stesso pattern delle altre actions
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { projects, clients } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
|
export async function createProject(fd: FormData): Promise<{ projectId: string }> {
|
||||||
|
await requireAdmin();
|
||||||
|
const name = String(fd.get("name") ?? "").trim();
|
||||||
|
const clientId = String(fd.get("client_id") ?? "").trim();
|
||||||
|
|
||||||
|
if (!name) throw new Error("Nome progetto obbligatorio");
|
||||||
|
if (!clientId) throw new Error("Cliente obbligatorio");
|
||||||
|
|
||||||
|
// Verify client exists
|
||||||
|
const clientRows = await db
|
||||||
|
.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, clientId))
|
||||||
|
.limit(1);
|
||||||
|
if (clientRows.length === 0) throw new Error("Cliente non trovato");
|
||||||
|
|
||||||
|
const id = nanoid();
|
||||||
|
await db.insert(projects).values({ id, client_id: clientId, name });
|
||||||
|
|
||||||
|
revalidatePath("/admin/projects");
|
||||||
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
|
return { projectId: id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveProject(projectId: string): Promise<void> {
|
||||||
|
await requireAdmin();
|
||||||
|
await db.update(projects).set({ archived: true }).where(eq(projects.id, projectId));
|
||||||
|
revalidatePath("/admin/projects");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unarchiveProject(projectId: string): Promise<void> {
|
||||||
|
await requireAdmin();
|
||||||
|
await db.update(projects).set({ archived: false }).where(eq(projects.id, projectId));
|
||||||
|
revalidatePath("/admin/projects");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProjectAcceptedTotal(projectId: string, acceptedTotal: string): Promise<void> {
|
||||||
|
await requireAdmin();
|
||||||
|
await db.update(projects).set({ accepted_total: acceptedTotal }).where(eq(projects.id, projectId));
|
||||||
|
revalidatePath(`/admin/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTA: Verificare il path di `requireAdmin` leggendo un altro actions file (es. quote-actions.ts) — usare lo stesso import esatto.
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/components/admin/NavBar.tsx contains `href="/admin/projects"` (grep)
|
||||||
|
- src/components/admin/NavBar.tsx contains `href="/admin/impostazioni"` (grep)
|
||||||
|
- src/components/admin/ProjectRow.tsx exists e contains `ProjectWithPayments` (grep)
|
||||||
|
- src/components/admin/ProjectRow.tsx contains `totalTrackedSeconds / 3600` (formula €/h) (grep)
|
||||||
|
- src/app/admin/projects/project-actions.ts exports `createProject`, `archiveProject`, `unarchiveProject`, `updateProjectAcceptedTotal` (grep: `grep "export async function" src/app/admin/projects/project-actions.ts`)
|
||||||
|
- TypeScript compila senza errori
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>NavBar aggiornata, ProjectRow pronto, server actions create</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: /admin/projects list page + /admin/projects/new form + /admin/clients/[id] project cards</name>
|
||||||
|
<files>
|
||||||
|
src/app/admin/projects/page.tsx
|
||||||
|
src/app/admin/projects/new/page.tsx
|
||||||
|
src/app/admin/clients/[id]/page.tsx
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/app/admin/page.tsx — template esatto per la struttura della lista (revalidate, table, map su rows)
|
||||||
|
- src/app/admin/clients/[id]/page.tsx — leggere INTERO FILE: va riscritto per mostrare project cards invece del workspace tab
|
||||||
|
- src/app/admin/catalog/page.tsx — pattern admin page con form inline (per /admin/projects/new)
|
||||||
|
- src/app/admin/clients/[id]/quote-actions.ts — per capire come il form usa server actions con redirect
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**A. Creare src/app/admin/projects/page.tsx**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getAllProjectsWithPayments } from "@/lib/admin-queries";
|
||||||
|
import { ProjectRow } from "@/components/admin/ProjectRow";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function ProjectsPage() {
|
||||||
|
const projects = await getAllProjectsWithPayments();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Progetti</h1>
|
||||||
|
<Link
|
||||||
|
href="/admin/projects/new"
|
||||||
|
className="text-sm bg-[#1A463C] text-white px-4 py-2 rounded-lg hover:bg-[#1A463C]/90 transition-colors"
|
||||||
|
>
|
||||||
|
+ Nuovo Progetto
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-xl border border-[#e5e7eb] p-12 text-center">
|
||||||
|
<p className="text-[#71717a]">Nessun progetto ancora. Creane uno dal dettaglio di un cliente.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">Progetto</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">Valore</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">Acconto</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">Saldo</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">Timer</th>
|
||||||
|
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">€/h</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectRow key={project.id} project={project} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Creare src/app/admin/projects/new/page.tsx**
|
||||||
|
|
||||||
|
Form che permette di creare un progetto scegliendo il cliente da una select. Il form si sottomette con createProject e redirige al progetto appena creato.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getAllClientsWithPayments } from "@/lib/admin-queries";
|
||||||
|
import { createProject } from "@/app/admin/projects/project-actions";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function NewProjectPage() {
|
||||||
|
const clients = await getAllClientsWithPayments();
|
||||||
|
const activeClients = clients.filter((c) => !c.archived);
|
||||||
|
|
||||||
|
async function handleCreate(fd: FormData) {
|
||||||
|
"use server";
|
||||||
|
const result = await createProject(fd);
|
||||||
|
redirect(`/admin/projects/${result.projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Nuovo Progetto</h1>
|
||||||
|
<p className="text-sm text-[#71717a] mt-1">Crea un nuovo progetto per un cliente esistente.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
|
||||||
|
<form action={handleCreate} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-[#1a1a1a] mb-1">
|
||||||
|
Nome Progetto (Brand)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="es. Brand Blu"
|
||||||
|
className="w-full border border-[#e5e7eb] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="client_id" className="block text-sm font-medium text-[#1a1a1a] mb-1">
|
||||||
|
Cliente
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="client_id"
|
||||||
|
name="client_id"
|
||||||
|
required
|
||||||
|
className="w-full border border-[#e5e7eb] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20"
|
||||||
|
>
|
||||||
|
<option value="">Seleziona cliente...</option>
|
||||||
|
{activeClients.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 bg-[#1A463C] text-white py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors"
|
||||||
|
>
|
||||||
|
Crea Progetto
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/admin/projects"
|
||||||
|
className="flex-1 text-center border border-[#e5e7eb] py-2 rounded-lg text-sm text-[#71717a] hover:bg-[#f9f9f9] transition-colors"
|
||||||
|
>
|
||||||
|
Annulla
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**C. Riscrivere src/app/admin/clients/[id]/page.tsx**
|
||||||
|
|
||||||
|
Questo file va RISCRITTO per mostrare project cards invece del workspace tab. Leggere il file corrente per capire gli import e adattarli.
|
||||||
|
|
||||||
|
Il nuovo file deve:
|
||||||
|
1. Chiamare `getClientWithProjects(id)` invece di `getClientFullDetail(id)`
|
||||||
|
2. Mostrare le cards dei progetti con link a /admin/projects/[id]
|
||||||
|
3. Mostrare un bottone "+ Nuovo Progetto" che naviga a /admin/projects/new?client_id=[id]
|
||||||
|
4. Mantenere i link di edit e archivio cliente (ClientActions component se esiste, altrimenti link semplici)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getClientWithProjects } from "@/lib/admin-queries";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function ClientDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await getClientWithProjects(id);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const { projects, ...client } = data;
|
||||||
|
const activeProjects = projects.filter((p) => !p.archived);
|
||||||
|
const archivedProjects = projects.filter((p) => p.archived);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Link href="/admin" className="text-sm text-[#71717a] hover:text-[#1a1a1a]">
|
||||||
|
← Clienti
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
|
||||||
|
<p className="text-sm text-[#71717a]">{client.brand_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/admin/projects/new?client_id=${id}`}
|
||||||
|
className="text-sm bg-[#1A463C] text-white px-4 py-2 rounded-lg hover:bg-[#1A463C]/90 transition-colors"
|
||||||
|
>
|
||||||
|
+ Nuovo Progetto
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/admin/clients/${id}/edit`}
|
||||||
|
className="text-sm border border-[#e5e7eb] px-4 py-2 rounded-lg text-[#71717a] hover:bg-[#f9f9f9] transition-colors"
|
||||||
|
>
|
||||||
|
Modifica Cliente
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeProjects.length === 0 && (
|
||||||
|
<div className="bg-white rounded-xl border border-[#e5e7eb] p-12 text-center">
|
||||||
|
<p className="text-[#71717a] mb-4">Nessun progetto ancora per questo cliente.</p>
|
||||||
|
<Link
|
||||||
|
href={`/admin/projects/new?client_id=${id}`}
|
||||||
|
className="text-sm text-[#1A463C] hover:underline"
|
||||||
|
>
|
||||||
|
+ Crea il primo progetto
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeProjects.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{activeProjects.map((project) => (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
href={`/admin/projects/${project.id}`}
|
||||||
|
className="bg-white border border-[#e5e7eb] rounded-xl p-5 hover:shadow-md hover:border-[#1A463C]/20 transition-all"
|
||||||
|
>
|
||||||
|
<h3 className="font-bold text-[#1a1a1a] mb-1">{project.name}</h3>
|
||||||
|
<p className="text-sm text-[#71717a]">
|
||||||
|
{project.accepted_total && parseFloat(project.accepted_total) > 0
|
||||||
|
? `€${parseFloat(project.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`
|
||||||
|
: "Preventivo non impostato"}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{archivedProjects.length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<p className="text-xs text-[#71717a] font-semibold uppercase tracking-wider mb-3">
|
||||||
|
Archiviati ({archivedProjects.length})
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 opacity-60">
|
||||||
|
{archivedProjects.map((project) => (
|
||||||
|
<Link
|
||||||
|
key={project.id}
|
||||||
|
href={`/admin/projects/${project.id}`}
|
||||||
|
className="bg-white border border-[#e5e7eb] rounded-xl p-5 hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
<h3 className="font-bold text-[#1a1a1a] mb-1">{project.name}</h3>
|
||||||
|
<p className="text-xs text-[#71717a]">Archiviato</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTA: Se il file corrente ha altri import (ClientActions, tabs, ecc.) che non servono più, rimuoverli per evitare TS errors.
|
||||||
|
|
||||||
|
**D. Aggiornare /admin/projects/new per gestire il query param client_id**
|
||||||
|
|
||||||
|
Il link "+ Nuovo Progetto" da /admin/clients/[id] passa `?client_id=[id]`. Aggiornare la NewProjectPage per pre-selezionare il cliente se il param è presente:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Aggiungere searchParams alle props:
|
||||||
|
export default async function NewProjectPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ client_id?: string }>;
|
||||||
|
}) {
|
||||||
|
const { client_id } = await searchParams;
|
||||||
|
// ...
|
||||||
|
// Nella select, aggiungere defaultValue o usare selected su ogni option:
|
||||||
|
// <option key={c.id} value={c.id} selected={c.id === client_id}>{c.name}</option>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>npm run build 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/app/admin/projects/page.tsx exists e contains `getAllProjectsWithPayments` (grep)
|
||||||
|
- src/app/admin/projects/page.tsx contains `ProjectRow` (grep)
|
||||||
|
- src/app/admin/projects/new/page.tsx exists e contains `createProject` (grep)
|
||||||
|
- src/app/admin/clients/[id]/page.tsx contains `getClientWithProjects` (grep)
|
||||||
|
- src/app/admin/clients/[id]/page.tsx contains `href={\`/admin/projects/${` (grep — link alle cards progetto)
|
||||||
|
- src/app/admin/clients/[id]/page.tsx does NOT contain `getClientFullDetail` (grep — vecchia funzione rimossa)
|
||||||
|
- `npm run build` completa senza errori TypeScript
|
||||||
|
- Accedendo a /admin/projects (dopo `npm run dev`) la pagina carica senza 500 error
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>/admin/projects funzionale con lista e form creazione; /admin/clients/[id] mostra project cards</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Admin browser → Server Actions | createProject, archiveProject, updateProjectAcceptedTotal chiamati da form con requireAdmin() |
|
||||||
|
| Admin → /admin/projects/[id] | Link navigazione — il workspace progetto (04-03) avrà il suo guard; questo piano non espone dati sensibili |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-04-05 | Elevation of Privilege | createProject server action | mitigate | `requireAdmin()` all'inizio di ogni server action — verifica sessione Auth.js prima di qualsiasi DB write |
|
||||||
|
| T-04-06 | Tampering | archiveProject / updateProjectAcceptedTotal | mitigate | `requireAdmin()` guarda entrambe le actions; projectId viene da path param (non da query string non validata) |
|
||||||
|
| T-04-07 | Information Disclosure | /admin/clients/[id] project cards | accept | Dati mostrati sono solo nome progetto e accepted_total — nessun dato sensibile (quote_items mai esposti) |
|
||||||
|
| T-04-08 | Tampering | createProject con client_id da form | mitigate | Action verifica che il client_id esista nel DB prima di inserire — previene inserimento di progetti orfani su client_id inventato |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
# 1. NavBar has new links
|
||||||
|
grep "admin/projects\|admin/impostazioni" src/components/admin/NavBar.tsx
|
||||||
|
|
||||||
|
# 2. ProjectRow exists and has formula
|
||||||
|
grep "totalTrackedSeconds / 3600" src/components/admin/ProjectRow.tsx
|
||||||
|
|
||||||
|
# 3. Server actions have requireAdmin
|
||||||
|
grep "requireAdmin" src/app/admin/projects/project-actions.ts
|
||||||
|
|
||||||
|
# 4. Client detail uses new query
|
||||||
|
grep "getClientWithProjects" src/app/admin/clients/\[id\]/page.tsx
|
||||||
|
|
||||||
|
# 5. Build clean
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- /admin/projects mostra tabella vuota (o con dati se il seed ha creato progetti) senza errori
|
||||||
|
- /admin/projects/new mostra form con select clienti
|
||||||
|
- /admin/clients/[id] mostra grid cards progetti con bottone "+ Nuovo Progetto"
|
||||||
|
- Cliccando una card naviga a /admin/projects/[id] (che mostra 404 finché 04-03 non crea la pagina)
|
||||||
|
- `npm run build` passa senza errori TypeScript
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/04-progetti-multi-project/04-02-SUMMARY.md` following the template at `@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md`.
|
||||||
|
|
||||||
|
Key items to document:
|
||||||
|
- Nuovi file creati e loro funzione
|
||||||
|
- Come viene passato il client_id pre-selezionato nel form nuovo progetto
|
||||||
|
- Eventuali componenti legacy rimossi da clients/[id]/page.tsx
|
||||||
|
</output>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,830 @@
|
|||||||
|
---
|
||||||
|
phase: 04-progetti-multi-project
|
||||||
|
plan: "04"
|
||||||
|
type: execute
|
||||||
|
wave: 3
|
||||||
|
depends_on:
|
||||||
|
- 04-02-PLAN.md
|
||||||
|
- 04-03-PLAN.md
|
||||||
|
files_modified:
|
||||||
|
- src/app/api/internal/validate-slug/route.ts
|
||||||
|
- src/proxy.ts
|
||||||
|
- src/lib/client-view.ts
|
||||||
|
- src/app/c/[token]/page.tsx
|
||||||
|
- src/app/admin/clients/[id]/edit/page.tsx
|
||||||
|
autonomous: false
|
||||||
|
requirements:
|
||||||
|
- PROJ-02
|
||||||
|
- PROJ-04
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Accedendo a /c/mario-rossi (dove mario-rossi è lo slug di un cliente) la dashboard si apre correttamente"
|
||||||
|
- "Accedendo a /c/[token] (token storico) la dashboard continua a funzionare come prima"
|
||||||
|
- "Se il cliente ha 1 progetto la dashboard mostra direttamente il workspace senza tabs"
|
||||||
|
- "Se il cliente ha 2+ progetti la dashboard mostra tabs con i nomi dei progetti"
|
||||||
|
- "Lo slug è impostabile da /admin/clients/[id]/edit con preview del link risultante"
|
||||||
|
- "La dashboard cliente NON espone mai quote_items (CLAUDE.md constraint)"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/app/api/internal/validate-slug/route.ts"
|
||||||
|
provides: "API route che risolve slug → clientId"
|
||||||
|
contains: "clients.slug"
|
||||||
|
- path: "src/proxy.ts"
|
||||||
|
provides: "Middleware con slug-first resolution"
|
||||||
|
contains: "validate-slug"
|
||||||
|
- path: "src/lib/client-view.ts"
|
||||||
|
provides: "Query functions per dashboard multi-progetto"
|
||||||
|
exports: ["getClientWithProjectsByToken", "getProjectView"]
|
||||||
|
- path: "src/app/c/[token]/page.tsx"
|
||||||
|
provides: "Dashboard cliente con logica single/multi-project"
|
||||||
|
contains: "projects.length === 1"
|
||||||
|
- path: "src/app/admin/clients/[id]/edit/page.tsx"
|
||||||
|
provides: "Form edit con campo slug e link preview"
|
||||||
|
contains: "slug"
|
||||||
|
key_links:
|
||||||
|
- from: "src/proxy.ts"
|
||||||
|
to: "src/app/api/internal/validate-slug/route.ts"
|
||||||
|
via: "fetch /api/internal/validate-slug?slug=..."
|
||||||
|
pattern: "validate-slug"
|
||||||
|
- from: "src/app/c/[token]/page.tsx"
|
||||||
|
to: "src/lib/client-view.ts"
|
||||||
|
via: "getClientWithProjectsByToken(token)"
|
||||||
|
pattern: "getClientWithProjectsByToken"
|
||||||
|
- from: "src/lib/client-view.ts"
|
||||||
|
to: "src/db/schema.ts"
|
||||||
|
via: "query phases/payments/etc con project_id"
|
||||||
|
pattern: "project_id"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Slug resolution middleware, dashboard cliente multi-progetto, e campo slug nell'edit cliente. Consegna la funzionalità lato cliente: link personalizzato /c/mario-rossi, dashboard con tabs per 2+ progetti o vista diretta per 1 progetto.
|
||||||
|
|
||||||
|
Purpose: Completa il ciclo end-to-end della fase 4 — l'admin imposta lo slug, il cliente accede con il link personalizzato, vede i propri progetti organizzati per tab.
|
||||||
|
|
||||||
|
Output: Middleware slug-first, client-view.ts riscritto per multi-project, dashboard cliente con tabs, edit page con slug field.
|
||||||
|
</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
|
||||||
|
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/04-progetti-multi-project/04-02-SUMMARY.md
|
||||||
|
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/04-progetti-multi-project/04-03-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Tutto il necessario per implementare senza esplorare il codebase. -->
|
||||||
|
|
||||||
|
Middleware attuale (src/proxy.ts):
|
||||||
|
- Check admin: getToken → redirect a /admin/login se assente
|
||||||
|
- Check client: match /c/[token], chiama /api/internal/validate-token?token=...
|
||||||
|
- Se validate-token risponde !ok → rewrite /not-found
|
||||||
|
- MODIFICA: before validate-token, try validate-slug first (D-06)
|
||||||
|
|
||||||
|
API route validate-token (usato come template esatto per validate-slug):
|
||||||
|
- Path: src/app/api/internal/validate-token/route.ts (leggere per avere il pattern preciso)
|
||||||
|
- Pattern: GET, query param, db.select where eq(clients.token, token), return 200/404 json
|
||||||
|
|
||||||
|
Schema clients (da 04-01):
|
||||||
|
```typescript
|
||||||
|
clients: { id, name, brand_name, brief, token, slug (nullable unique), accepted_total, archived, created_at }
|
||||||
|
```
|
||||||
|
|
||||||
|
client-view.ts attuale:
|
||||||
|
- getClientView(token: string) → ClientView (fasi, pagamenti, documenti, note per il cliente)
|
||||||
|
- DA RISCRIVERE COMPLETAMENTE per multi-project model
|
||||||
|
|
||||||
|
Nuove funzioni necessarie in client-view.ts:
|
||||||
|
1. getClientWithProjectsByToken(tokenOrSlug: string) — trova il client (via token), restituisce { client, projects[] }
|
||||||
|
NOTA: il param si chiama tokenOrSlug perché la page /c/[token] riceve il valore del path — potrebbe essere token o slug. Il middleware ha già validato l'accesso, ma la page deve fare il lookup corretto.
|
||||||
|
Lookup order: prima per slug, poi per token.
|
||||||
|
2. getProjectView(projectId: string) → ProjectView — dati di un singolo progetto per la dashboard cliente
|
||||||
|
CRITICAL: NON includere quote_items. Includere: phases+tasks+deliverables, payments (solo status, NON unit_price/subtotal), documents, notes.
|
||||||
|
|
||||||
|
shadcn Tabs già presente per multi-project tabs (D-10):
|
||||||
|
- import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
- Tabs è un Client Component (ha "use client" internamente)
|
||||||
|
|
||||||
|
Slug validation rule (D-04, Pitfall 5):
|
||||||
|
- Regex: /^[a-z0-9-]{3,50}$/
|
||||||
|
- Formato: lowercase, numeri, hyphens, min 3 max 50 chars
|
||||||
|
- Zod: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().nullable()
|
||||||
|
|
||||||
|
Edit page cliente attuale (src/app/admin/clients/[id]/edit/page.tsx):
|
||||||
|
- Leggere il file per capire il form attuale e aggiungere il campo slug
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Slug API route + middleware slug-first resolution + client-view.ts rewrite</name>
|
||||||
|
<files>
|
||||||
|
src/app/api/internal/validate-slug/route.ts
|
||||||
|
src/proxy.ts
|
||||||
|
src/lib/client-view.ts
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/app/api/internal/validate-token/route.ts — template ESATTO per validate-slug (stesso pattern, stesso formato risposta)
|
||||||
|
- src/proxy.ts — leggere INTERAMENTE: capire la struttura attuale del client token guard per inserire slug-first prima del token check
|
||||||
|
- src/lib/client-view.ts — leggere INTERAMENTE prima di riscriverlo: capire ClientView type e getClientView pattern, specialmente cosa è incluso/escluso
|
||||||
|
- CLAUDE.md Architecture Constraints — ricordare: quote_items MAI esposti via client API; deliverables.approved_at immutable
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**A. Creare src/app/api/internal/validate-slug/route.ts**
|
||||||
|
|
||||||
|
Clonare validate-token/route.ts sostituendo il lookup token con slug:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { clients } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
// Called by Edge middleware to resolve slug → client existence
|
||||||
|
// Returns 200 + { clientId } if found, 404 if not
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const slug = request.nextUrl.searchParams.get("slug");
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
return NextResponse.json({ error: "slug required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: clients.id })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.slug, slug))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ clientId: rows[0].id }, { status: 200 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Aggiornare src/proxy.ts — slug-first resolution (D-06)**
|
||||||
|
|
||||||
|
Modificare il blocco `if (pathname.startsWith("/c/"))` esistente:
|
||||||
|
|
||||||
|
PRIMA (attuale):
|
||||||
|
```
|
||||||
|
const clientToken = tokenMatch[1];
|
||||||
|
// chiama solo validate-token
|
||||||
|
```
|
||||||
|
|
||||||
|
DOPO (nuovo):
|
||||||
|
```typescript
|
||||||
|
if (pathname.startsWith("/c/")) {
|
||||||
|
const slugOrTokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
||||||
|
if (!slugOrTokenMatch) {
|
||||||
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugOrToken = slugOrTokenMatch[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// TRY SLUG FIRST (D-06) — slug lookup before token fallback
|
||||||
|
// Rationale: slugs are user-friendly names; tokens are fallback for existing links
|
||||||
|
const validateSlugUrl = new URL(
|
||||||
|
`/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`,
|
||||||
|
request.url
|
||||||
|
);
|
||||||
|
let res = await fetch(validateSlugUrl.toString());
|
||||||
|
|
||||||
|
// If slug not found, fall back to TOKEN validation (existing pattern)
|
||||||
|
if (!res.ok) {
|
||||||
|
const validateTokenUrl = new URL(
|
||||||
|
`/api/internal/validate-token?token=${encodeURIComponent(slugOrToken)}`,
|
||||||
|
request.url
|
||||||
|
);
|
||||||
|
res = await fetch(validateTokenUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.rewrite(new URL("/not-found", request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Il resto del file (admin guard, config) rimane invariato.
|
||||||
|
|
||||||
|
**C. Riscrivere src/lib/client-view.ts per multi-project model**
|
||||||
|
|
||||||
|
Riscrivere COMPLETAMENTE il file. Le nuove funzioni sostituiscono getClientView.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { db } from "@/db";
|
||||||
|
import {
|
||||||
|
clients,
|
||||||
|
projects,
|
||||||
|
phases,
|
||||||
|
tasks,
|
||||||
|
deliverables,
|
||||||
|
payments,
|
||||||
|
documents,
|
||||||
|
notes,
|
||||||
|
comments,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import { eq, inArray, asc, or } from "drizzle-orm";
|
||||||
|
|
||||||
|
// ── TYPES ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ProjectView {
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
client_id: string;
|
||||||
|
accepted_total: string;
|
||||||
|
};
|
||||||
|
phases: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
sort_order: number;
|
||||||
|
tasks: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
sort_order: number;
|
||||||
|
deliverables: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string | null;
|
||||||
|
status: string;
|
||||||
|
approved_at: Date | null; // immutable once set — CLAUDE.md constraint
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
progress_pct: number;
|
||||||
|
}>;
|
||||||
|
payments: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: string;
|
||||||
|
// amount and unit_price are NOT included — client sees only status (DASH-07)
|
||||||
|
}>;
|
||||||
|
documents: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
created_at: Date;
|
||||||
|
}>;
|
||||||
|
notes: Array<{
|
||||||
|
id: string;
|
||||||
|
body: string;
|
||||||
|
created_at: Date;
|
||||||
|
}>;
|
||||||
|
comments: Array<{
|
||||||
|
id: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: string;
|
||||||
|
author: string;
|
||||||
|
body: string;
|
||||||
|
created_at: Date;
|
||||||
|
}>;
|
||||||
|
global_progress_pct: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientProjectSummary {
|
||||||
|
client: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand_name: string;
|
||||||
|
token: string;
|
||||||
|
slug: string | null;
|
||||||
|
};
|
||||||
|
projects: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
archived: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QUERIES ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a token-or-slug to a client and returns the client's active projects.
|
||||||
|
* Called by /c/[token] page to determine: 1 project (direct view) vs 2+ (tabs).
|
||||||
|
* Lookup order: slug first, then token — mirrors middleware order (D-06).
|
||||||
|
*/
|
||||||
|
export async function getClientWithProjectsByToken(
|
||||||
|
tokenOrSlug: string
|
||||||
|
): Promise<ClientProjectSummary | null> {
|
||||||
|
// Try slug first
|
||||||
|
let clientRows = await db
|
||||||
|
.select({
|
||||||
|
id: clients.id,
|
||||||
|
name: clients.name,
|
||||||
|
brand_name: clients.brand_name,
|
||||||
|
token: clients.token,
|
||||||
|
slug: clients.slug,
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.slug, tokenOrSlug))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// Fall back to token
|
||||||
|
if (clientRows.length === 0) {
|
||||||
|
clientRows = await db
|
||||||
|
.select({
|
||||||
|
id: clients.id,
|
||||||
|
name: clients.name,
|
||||||
|
brand_name: clients.brand_name,
|
||||||
|
token: clients.token,
|
||||||
|
slug: clients.slug,
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.token, tokenOrSlug))
|
||||||
|
.limit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientRows.length === 0) return null;
|
||||||
|
const client = clientRows[0];
|
||||||
|
|
||||||
|
const projectRows = await db
|
||||||
|
.select({ id: projects.id, name: projects.name, archived: projects.archived })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.client_id, client.id))
|
||||||
|
.orderBy(asc(projects.created_at));
|
||||||
|
|
||||||
|
// Only active (non-archived) projects shown to client
|
||||||
|
const activeProjects = projectRows.filter((p) => !p.archived);
|
||||||
|
|
||||||
|
return { client, projects: activeProjects };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns full project data for the client dashboard.
|
||||||
|
* CRITICAL: Does NOT include quote_items — client API never exposes them (CLAUDE.md constraint).
|
||||||
|
* payments include status only, NOT amount or unit_price (DASH-07).
|
||||||
|
*/
|
||||||
|
export async function getProjectView(projectId: string): Promise<ProjectView | null> {
|
||||||
|
const projectRows = await db
|
||||||
|
.select({
|
||||||
|
id: projects.id,
|
||||||
|
name: projects.name,
|
||||||
|
client_id: projects.client_id,
|
||||||
|
accepted_total: projects.accepted_total,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (projectRows.length === 0) return null;
|
||||||
|
const project = projectRows[0];
|
||||||
|
|
||||||
|
// Phases scoped to THIS project
|
||||||
|
const phasesRows = await db
|
||||||
|
.select()
|
||||||
|
.from(phases)
|
||||||
|
.where(eq(phases.project_id, projectId))
|
||||||
|
.orderBy(asc(phases.sort_order));
|
||||||
|
|
||||||
|
const phaseIds = phasesRows.map((p) => p.id);
|
||||||
|
|
||||||
|
// Tasks scoped to this project's phases
|
||||||
|
const tasksRows = phaseIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(inArray(tasks.phase_id, phaseIds))
|
||||||
|
.orderBy(asc(tasks.sort_order));
|
||||||
|
|
||||||
|
const taskIds = tasksRows.map((t) => t.id);
|
||||||
|
|
||||||
|
// Deliverables — approved_at included (immutable audit trail — CLAUDE.md)
|
||||||
|
const deliverablesRows = taskIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select({
|
||||||
|
id: deliverables.id,
|
||||||
|
title: deliverables.title,
|
||||||
|
url: deliverables.url,
|
||||||
|
status: deliverables.status,
|
||||||
|
approved_at: deliverables.approved_at,
|
||||||
|
task_id: deliverables.task_id,
|
||||||
|
})
|
||||||
|
.from(deliverables)
|
||||||
|
.where(inArray(deliverables.task_id, taskIds));
|
||||||
|
|
||||||
|
// Payments — status only, NO amount (D-07 / DASH-07)
|
||||||
|
const paymentsRows = await db
|
||||||
|
.select({
|
||||||
|
id: payments.id,
|
||||||
|
label: payments.label,
|
||||||
|
status: payments.status,
|
||||||
|
// amount intentionally excluded — client sees only status
|
||||||
|
})
|
||||||
|
.from(payments)
|
||||||
|
.where(eq(payments.project_id, projectId));
|
||||||
|
|
||||||
|
// Documents
|
||||||
|
const documentsRows = await db
|
||||||
|
.select({
|
||||||
|
id: documents.id,
|
||||||
|
label: documents.label,
|
||||||
|
url: documents.url,
|
||||||
|
created_at: documents.created_at,
|
||||||
|
})
|
||||||
|
.from(documents)
|
||||||
|
.where(eq(documents.project_id, projectId))
|
||||||
|
.orderBy(asc(documents.created_at));
|
||||||
|
|
||||||
|
// Notes (decision log — admin writes, client reads)
|
||||||
|
const notesRows = await db
|
||||||
|
.select({ id: notes.id, body: notes.body, created_at: notes.created_at })
|
||||||
|
.from(notes)
|
||||||
|
.where(eq(notes.project_id, projectId))
|
||||||
|
.orderBy(asc(notes.created_at));
|
||||||
|
|
||||||
|
// Comments (polymorphic — tasks and deliverables for this project)
|
||||||
|
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||||
|
const commentsRows = allEntityIds.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select()
|
||||||
|
.from(comments)
|
||||||
|
.where(inArray(comments.entity_id, allEntityIds))
|
||||||
|
.orderBy(asc(comments.created_at));
|
||||||
|
|
||||||
|
// Rebuild hierarchy + calculate per-phase progress
|
||||||
|
const phasesWithTasks = phasesRows.map((phase) => {
|
||||||
|
const phaseTasks = tasksRows
|
||||||
|
.filter((t) => t.phase_id === phase.id)
|
||||||
|
.map((task) => ({
|
||||||
|
...task,
|
||||||
|
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const doneCount = phaseTasks.filter((t) => t.status === "done").length;
|
||||||
|
const progress_pct = phaseTasks.length > 0
|
||||||
|
? Math.round((doneCount / phaseTasks.length) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { ...phase, tasks: phaseTasks, progress_pct };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global progress across all phases
|
||||||
|
const allTasks = tasksRows;
|
||||||
|
const doneTasks = allTasks.filter((t) => t.status === "done").length;
|
||||||
|
const global_progress_pct = allTasks.length > 0
|
||||||
|
? Math.round((doneTasks / allTasks.length) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
client_id: project.client_id,
|
||||||
|
accepted_total: project.accepted_total ?? "0",
|
||||||
|
},
|
||||||
|
phases: phasesWithTasks,
|
||||||
|
payments: paymentsRows,
|
||||||
|
documents: documentsRows,
|
||||||
|
notes: notesRows,
|
||||||
|
comments: commentsRows,
|
||||||
|
global_progress_pct,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTA CRITICA sulla security: In getProjectView, il select di payments NON include `amount`. Aggiungere un commento esplicito: `// amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md constraint + DASH-07)`. Questo è l'invariante principale da non rompere.
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/app/api/internal/validate-slug/route.ts exists e contains `clients.slug` (grep)
|
||||||
|
- src/proxy.ts contains `validate-slug` (grep — slug check aggiunto)
|
||||||
|
- src/proxy.ts contains slug check BEFORE token check nell'ordine del codice (grep -n "validate-slug\|validate-token" src/proxy.ts — slug deve avere numero di riga inferiore a token)
|
||||||
|
- src/lib/client-view.ts contains `getClientWithProjectsByToken` (grep)
|
||||||
|
- src/lib/client-view.ts contains `getProjectView` (grep)
|
||||||
|
- src/lib/client-view.ts does NOT contain `quote_items` (grep — security invariant)
|
||||||
|
- src/lib/client-view.ts payments select does NOT contain `amount` field (grep: `grep "amount" src/lib/client-view.ts` deve essere assente nel select payments)
|
||||||
|
- TypeScript compila senza errori
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>Slug API route e middleware aggiornato; client-view.ts riscritto per multi-project senza quote_items e senza payment amounts</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Dashboard cliente multi-project (/c/[token]/page.tsx) + slug field in edit cliente</name>
|
||||||
|
<files>
|
||||||
|
src/app/c/[token]/page.tsx
|
||||||
|
src/app/admin/clients/[id]/edit/page.tsx
|
||||||
|
</files>
|
||||||
|
|
||||||
|
<read_first>
|
||||||
|
- src/app/c/[token]/page.tsx — leggere INTERAMENTE: capire la struttura attuale (ClientView types, componenti usati, come vengono passati i dati ai componenti UI della dashboard)
|
||||||
|
- src/app/admin/clients/[id]/edit/page.tsx — leggere INTERAMENTE: capire il form esistente (campi attuali, actions usate, pattern Zod/form)
|
||||||
|
- src/lib/client-view.ts — appena riscritto in Task 1: capire i tipi ProjectView e ClientProjectSummary
|
||||||
|
- src/components/ui/tabs.tsx — verificare che il componente Tabs sia disponibile e capirne le props (TabsList, TabsTrigger, TabsContent)
|
||||||
|
</read_first>
|
||||||
|
|
||||||
|
<action>
|
||||||
|
**A. Riscrivere src/app/c/[token]/page.tsx**
|
||||||
|
|
||||||
|
Logica D-09/D-10: se 1 progetto → vista diretta; se 2+ → tabs con nomi brand.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getClientWithProjectsByToken, getProjectView } from "@/lib/client-view";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function ClientPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ token: string }>;
|
||||||
|
}) {
|
||||||
|
const { token } = await params;
|
||||||
|
|
||||||
|
// Resolve token or slug to client + projects list (D-08/D-09)
|
||||||
|
const clientData = await getClientWithProjectsByToken(token);
|
||||||
|
if (!clientData) notFound();
|
||||||
|
|
||||||
|
const { client, projects } = clientData;
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
// No active projects — show placeholder
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f9f9f9] flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-xl font-bold text-[#1a1a1a]">{client.name}</h1>
|
||||||
|
<p className="text-sm text-[#71717a] mt-2">Nessun progetto disponibile al momento.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length === 1) {
|
||||||
|
// D-09: 1 project → direct view without selector
|
||||||
|
const view = await getProjectView(projects[0].id);
|
||||||
|
if (!view) notFound();
|
||||||
|
|
||||||
|
return <ClientDashboardView client={client} view={view} token={token} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// D-10: 2+ projects → tabs with brand names
|
||||||
|
// Fetch all project views in parallel
|
||||||
|
const projectViews = await Promise.all(
|
||||||
|
projects.map(async (p) => ({
|
||||||
|
project: p,
|
||||||
|
view: await getProjectView(p.id),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#f9f9f9]">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue={projects[0].id} className="w-full">
|
||||||
|
<TabsList className="mb-6">
|
||||||
|
{projects.map((p) => (
|
||||||
|
<TabsTrigger key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{projectViews.map(({ project, view }) => (
|
||||||
|
<TabsContent key={project.id} value={project.id}>
|
||||||
|
{view ? (
|
||||||
|
<ClientDashboardView client={client} view={view} token={token} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[#71717a]">Progetto non disponibile.</p>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per `ClientDashboardView`: leggere il file attuale di /c/[token]/page.tsx per capire come è strutturata la dashboard corrente. Il componente `ClientDashboardView` è probabilmente già esistente o il rendering è inline. Adattare seguendo ESATTAMENTE la struttura attuale:
|
||||||
|
- Se il file corrente ha un componente separato (es. ClientDashboard o simile) → riutilizzarlo, passando `view` invece di `clientView`
|
||||||
|
- Se il rendering è inline → estrarlo in una funzione helper `ClientDashboardView` nello stesso file
|
||||||
|
- I dati che `ClientDashboardView` riceve vengono ora da `ProjectView` invece di `ClientView` — adattare le prop references
|
||||||
|
|
||||||
|
CRITICO: verificare che `ClientDashboardView` NON abbia accesso a quote_items — deve usare solo i dati di `ProjectView` (phases, payments con solo status, documents, notes, comments).
|
||||||
|
|
||||||
|
Il campo `accepted_total` da mostrare viene da `view.project.accepted_total` (non dal client-level).
|
||||||
|
|
||||||
|
**B. Aggiornare src/app/admin/clients/[id]/edit/page.tsx**
|
||||||
|
|
||||||
|
Aggiungere il campo slug con:
|
||||||
|
1. Input field con label "Slug personalizzato"
|
||||||
|
2. Validazione Zod: `slug: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().or(z.literal(""))` — stringa vuota = nessuno slug
|
||||||
|
3. Preview del link risultante: `/{slug || client.token}`
|
||||||
|
4. Testo help: "Solo lettere minuscole, numeri e trattini (es. mario-rossi). Min 3, max 50 caratteri."
|
||||||
|
|
||||||
|
Leggere il file per trovare il form attuale e aggiungere il campo slug nel form esistente. L'action di salvataggio deve aggiornare `clients.slug` oltre ai campi esistenti.
|
||||||
|
|
||||||
|
Schema Zod da aggiungere/aggiornare per il campo slug:
|
||||||
|
```typescript
|
||||||
|
const updateClientSchema = z.object({
|
||||||
|
// ... existing fields ...
|
||||||
|
slug: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().or(z.literal("")).transform(v => v === "" ? null : v),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Nel form HTML:
|
||||||
|
```html
|
||||||
|
<div>
|
||||||
|
<label htmlFor="slug">Slug personalizzato (opzionale)</label>
|
||||||
|
<p className="text-xs text-[#71717a] mb-1">Solo lettere minuscole, numeri e trattini (es. mario-rossi). Min 3, max 50 caratteri.</p>
|
||||||
|
<input
|
||||||
|
id="slug"
|
||||||
|
name="slug"
|
||||||
|
type="text"
|
||||||
|
defaultValue={client.slug ?? ""}
|
||||||
|
pattern="[a-z0-9-]{3,50}"
|
||||||
|
placeholder="mario-rossi"
|
||||||
|
className="..."
|
||||||
|
/>
|
||||||
|
{/* Link preview */}
|
||||||
|
<p className="text-xs text-[#71717a] mt-1">
|
||||||
|
Link cliente: /c/{client.slug || client.token}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Nella server action che salva, aggiungere l'update di `clients.slug`:
|
||||||
|
```typescript
|
||||||
|
// Se slug è stringa vuota, settarlo a null (rimuove lo slug)
|
||||||
|
await db.update(clients).set({
|
||||||
|
// ...existing fields...
|
||||||
|
slug: parsed.slug ?? null,
|
||||||
|
}).where(eq(clients.id, clientId));
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggiungere anche gestione errore per unique constraint violation (se lo slug è già usato da un altro cliente), mostrando un messaggio user-friendly.
|
||||||
|
</action>
|
||||||
|
|
||||||
|
<verify>
|
||||||
|
<automated>npm run build 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
|
||||||
|
<acceptance_criteria>
|
||||||
|
- src/app/c/[token]/page.tsx contains `getClientWithProjectsByToken` (grep)
|
||||||
|
- src/app/c/[token]/page.tsx contains `projects.length === 1` (grep — single project direct view logic)
|
||||||
|
- src/app/c/[token]/page.tsx contains `Tabs` import (grep — multi-project tabs)
|
||||||
|
- src/app/c/[token]/page.tsx does NOT contain `quote_items` anywhere (grep)
|
||||||
|
- src/app/admin/clients/[id]/edit/page.tsx contains `slug` input field (grep: `grep "name=\"slug\"" src/app/admin/clients/\[id\]/edit/page.tsx`)
|
||||||
|
- src/app/admin/clients/[id]/edit/page.tsx contains `/^[a-z0-9-]{3,50}$/` validation pattern (grep)
|
||||||
|
- `npm run build` completa senza errori TypeScript
|
||||||
|
</acceptance_criteria>
|
||||||
|
|
||||||
|
<done>Dashboard cliente funziona con singolo progetto (vista diretta) e multi-progetto (tabs); slug impostabile dall'admin</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<what-built>
|
||||||
|
Funzionalità complete di Phase 04:
|
||||||
|
1. Schema multi-project con FK migrate (04-01)
|
||||||
|
2. Admin projects list + create + client detail con project cards (04-02)
|
||||||
|
3. Admin project workspace con timer project-scoped e analytics profittabilità (04-03)
|
||||||
|
4. Slug resolution middleware + dashboard cliente multi-project + slug edit (questo piano)
|
||||||
|
</what-built>
|
||||||
|
|
||||||
|
<how-to-verify>
|
||||||
|
Eseguire `npm run dev` e verificare manualmente:
|
||||||
|
|
||||||
|
**Test 1 — Admin projects list (/admin/projects)**
|
||||||
|
- Aprire /admin/projects
|
||||||
|
- Verificare che la pagina carichi senza errori
|
||||||
|
- Verificare colonne: Progetto (con nome cliente sotto), Valore, Acconto, Saldo, Timer, €/h
|
||||||
|
|
||||||
|
**Test 2 — Creazione progetto**
|
||||||
|
- Aprire /admin e cliccare su un cliente
|
||||||
|
- Verificare che /admin/clients/[id] mostri project cards (non più il workspace tab)
|
||||||
|
- Cliccare "+ Nuovo Progetto" e creare un progetto
|
||||||
|
- Verificare che il redirect vada a /admin/projects/[id]
|
||||||
|
|
||||||
|
**Test 3 — Workspace progetto (/admin/projects/[id])**
|
||||||
|
- Aprire /admin/projects/[id] per il progetto appena creato
|
||||||
|
- Verificare tutti i tabs: Fasi & Task, Pagamenti, Documenti, Note, Commenti, Preventivo, Timer
|
||||||
|
- Nel tab Timer: verificare play/stop funziona, ProfitabilityCard mostra ore lavorate, €/h, costo ideale, delta
|
||||||
|
|
||||||
|
**Test 4 — Impostazioni (/admin/impostazioni)**
|
||||||
|
- Aprire /admin/impostazioni
|
||||||
|
- Verificare form con campo tariffa oraria target (default 50.00)
|
||||||
|
- Cambiare il valore, salvare, ricaricare — verificare che il nuovo valore sia persistito
|
||||||
|
- Aprire /admin/projects/[id] → tab Timer → verificare che la tariffa target aggiornata appaia nella ProfitabilityCard
|
||||||
|
|
||||||
|
**Test 5 — Slug cliente**
|
||||||
|
- Aprire /admin/clients/[id]/edit per un cliente
|
||||||
|
- Impostare slug "mario-rossi" (o simile)
|
||||||
|
- Salvare e verificare che non ci siano errori
|
||||||
|
- Aprire /c/mario-rossi → verificare che carichi la dashboard del cliente corretto
|
||||||
|
|
||||||
|
**Test 6 — Fallback token**
|
||||||
|
- Con lo stesso cliente che ha lo slug impostato, aprire /c/[token-originale]
|
||||||
|
- Verificare che carichi correttamente (fallback token deve funzionare)
|
||||||
|
|
||||||
|
**Test 7 — Dashboard multi-progetto**
|
||||||
|
- Per il cliente di test, creare un secondo progetto
|
||||||
|
- Aprire /c/[token-o-slug] del cliente
|
||||||
|
- Verificare che appaiano le tabs con i nomi dei due progetti
|
||||||
|
- Cliccare tra i tabs e verificare che i dati siano scoped al progetto corretto
|
||||||
|
|
||||||
|
**Test 8 — Dashboard singolo progetto**
|
||||||
|
- Per un cliente con 1 solo progetto, aprire /c/[token]
|
||||||
|
- Verificare che NON appaiano tabs — la dashboard si apre direttamente sul progetto
|
||||||
|
</how-to-verify>
|
||||||
|
|
||||||
|
<resume-signal>
|
||||||
|
Digitare "approvato" se tutti i test passano, oppure descrivere gli errori trovati per correzione.
|
||||||
|
</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Public internet → /c/[slug-or-token] | Chiunque con il link accede alla dashboard; il middleware valida prima slug poi token — accesso bloccato se entrambi falliscono |
|
||||||
|
| Client dashboard → DB | getProjectView NON espone quote_items né payment amounts — invarianti CLAUDE.md + DASH-07 |
|
||||||
|
| Admin edit → clients.slug | Il campo slug è validato con regex e aggiornato solo in sessione admin autenticata |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-04-14 | Information Disclosure | getProjectView — payments | mitigate | SELECT include solo id, label, status — amount escluso esplicitamente. Commento nel codice documenta il motivo (DASH-07 + CLAUDE.md). grep di test in acceptance criteria verifica l'assenza di amount |
|
||||||
|
| T-04-15 | Information Disclosure | getProjectView — quote_items | mitigate | quote_items NON importato in client-view.ts. Acceptance criteria include grep check `grep "quote_items" src/lib/client-view.ts` → deve essere assente |
|
||||||
|
| T-04-16 | Tampering | clients.slug — unique constraint | mitigate | DB unique constraint su clients.slug previene slug duplicati; server action cattura unique violation e mostra errore user-friendly |
|
||||||
|
| T-04-17 | Spoofing | Slug collisione con token esistente | accept | Slug regex [a-z0-9-]{3,50} non può collidere con nanoid tokens (che usano anche maiuscole e caratteri speciali); middleware prova prima slug poi token nell'ordine corretto (D-06) |
|
||||||
|
| T-04-18 | Information Disclosure | Dashboard multi-project tabs — dati cross-project | mitigate | Ogni getProjectView(projectId) è scoped con WHERE eq(phases.project_id, projectId) — un cliente non può vedere dati di un altro cliente perché l'accesso è gate-kept dal client.id risolto dal token |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
# 1. Slug API route exists
|
||||||
|
ls src/app/api/internal/validate-slug/route.ts
|
||||||
|
|
||||||
|
# 2. Middleware has slug-first
|
||||||
|
grep -n "validate-slug\|validate-token" src/proxy.ts
|
||||||
|
|
||||||
|
# 3. client-view.ts has new functions
|
||||||
|
grep "export async function" src/lib/client-view.ts
|
||||||
|
|
||||||
|
# 4. client-view.ts security invariants
|
||||||
|
grep "quote_items" src/lib/client-view.ts # must be empty
|
||||||
|
grep "amount" src/lib/client-view.ts # must not appear in payments select
|
||||||
|
|
||||||
|
# 5. Dashboard has tabs logic
|
||||||
|
grep "projects.length === 1" src/app/c/\[token\]/page.tsx
|
||||||
|
|
||||||
|
# 6. Edit page has slug field
|
||||||
|
grep "name=\"slug\"" src/app/admin/clients/\[id\]/edit/page.tsx
|
||||||
|
|
||||||
|
# 7. Build clean
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- /c/[slug] risolve correttamente alla dashboard del cliente → stesso comportamento di /c/[token]
|
||||||
|
- /c/[token] continua a funzionare come fallback per i link esistenti
|
||||||
|
- Dashboard con 1 progetto → nessun selettore/tabs, vista diretta
|
||||||
|
- Dashboard con 2+ progetti → shadcn Tabs con nomi brand, switch funziona
|
||||||
|
- /admin/impostazioni persiste il target_hourly_rate e la ProfitabilityCard nel workspace progetto lo usa
|
||||||
|
- `npm run build` → 0 errori TypeScript
|
||||||
|
- `grep "quote_items" src/lib/client-view.ts` → nessun output (security invariant verificato)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/04-progetti-multi-project/04-04-SUMMARY.md` following the template at `@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md`.
|
||||||
|
|
||||||
|
Key items to document:
|
||||||
|
- Come è stata implementata la logica single/multi-project nella dashboard
|
||||||
|
- Come la edit page gestisce slug vuoto → null (rimozione slug)
|
||||||
|
- Eventuali adattamenti al componente ClientDashboardView per lavorare con ProjectView invece di ClientView
|
||||||
|
- Conferma dei security invariants (no quote_items, no payment amounts in client-view.ts)
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user