docs(07): phase 7 planning complete — 2 plans (schema migration + admin crud)
Wave 1: 07-01-PLAN.md — Unified services table (audit trail, backfill, validation) Wave 2: 07-02-PLAN.md — Admin catalog CRUD rewire (preserve historical references) Ready for execution: /gsd-execute-phase 7 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,371 @@
|
||||
---
|
||||
phase: 04-progetti-multi-project
|
||||
plan: "00"
|
||||
type: execute
|
||||
wave: 0
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- src/app/client/[token]/layout.tsx
|
||||
- src/app/client/[token]/page.tsx
|
||||
- src/proxy.ts
|
||||
- src/components/admin/ClientRow.tsx
|
||||
- src/app/admin/clients/[id]/page.tsx
|
||||
- Dockerfile
|
||||
- .env.example
|
||||
autonomous: false
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "La route /client/[token] esiste e risponde correttamente (cartella rinominata da /c/)"
|
||||
- "src/proxy.ts controlla /client/ invece di /c/ nel guard e nel matcher"
|
||||
- "ClientRow.tsx e admin/clients/[id]/page.tsx usano /client/ nei link generati"
|
||||
- "Il Dockerfile produce un build Next.js funzionante (exit code 0)"
|
||||
- "DATABASE_URL in .env punta al Postgres self-hosted su Coolify (non Neon)"
|
||||
- "drizzle-kit push sul nuovo DB completes con exit code 0"
|
||||
- "L'app risponde su hub.iamcavalli.net dopo il deploy su Coolify"
|
||||
artifacts:
|
||||
- path: "src/app/client/[token]/page.tsx"
|
||||
provides: "Dashboard cliente alla nuova route"
|
||||
contains: "export default"
|
||||
- path: "src/proxy.ts"
|
||||
provides: "Middleware aggiornato per /client/"
|
||||
contains: "pathname.startsWith(\"/client/\")"
|
||||
- path: "Dockerfile"
|
||||
provides: "Container image per Coolify"
|
||||
contains: "FROM node:"
|
||||
key_links:
|
||||
- from: "src/proxy.ts"
|
||||
to: "src/app/client/[token]/"
|
||||
via: "matcher: ['/admin/:path*', '/client/:path*']"
|
||||
pattern: "/client/:path*"
|
||||
- from: "src/components/admin/ClientRow.tsx"
|
||||
to: "src/app/client/[token]/"
|
||||
via: "href={`/client/${client.token}`}"
|
||||
pattern: "/client/"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Infrastruttura pre-Phase 4: migrazione da Neon a Postgres self-hosted su Coolify (Hetzner), rinomina route cliente da /c/ a /client/, Dockerfile per deploy, dominio hub.iamcavalli.net.
|
||||
|
||||
Questo piano esegue PRIMA di 04-01 per garantire che tutto il codice Phase 4 venga scritto e testato sull'infrastruttura finale. Nessun refactor post-esecuzione.
|
||||
|
||||
Output: App deployata su hub.iamcavalli.net con DB Postgres su Coolify, route /client/[token] funzionante.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/CLAUDE.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Riferimenti correnti da aggiornare -->
|
||||
|
||||
proxy.ts riga 32: `if (pathname.startsWith("/c/"))`
|
||||
proxy.ts riga 33: `pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/)`
|
||||
proxy.ts riga 63: `matcher: ["/admin/:path*", "/c/:path*"]`
|
||||
|
||||
ClientRow.tsx riga 59: `href={\`/c/${client.token}\`}`
|
||||
admin/clients/[id]/page.tsx riga 46: `href={\`/c/${client.token}\`}`
|
||||
|
||||
Cartella da rinominare: src/app/c/ → src/app/client/
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<threat_model>
|
||||
T-00-01: Redirect loop dopo rinomina — mitigazione: aggiornare proxy.ts prima di rinominare la cartella, verificare matcher.
|
||||
T-00-02: DB connection string esposta — mitigazione: .env mai committato, .env.example con placeholder.
|
||||
T-00-03: Dati di test persi durante migrazione DB — accettabile: tutti i dati attuali sono test data (CONTEXT.md).
|
||||
T-00-04: Coolify deploy fallisce silenziosamente — mitigazione: verificare health check dopo deploy.
|
||||
</threat_model>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task id="1" type="execute" autonomous="false">
|
||||
<name>Task 1: Postgres su Coolify + migrazione DATABASE_URL</name>
|
||||
|
||||
<read_first>
|
||||
- .env (connection string Neon attuale — non committare)
|
||||
- drizzle.config.ts (per confermare che legge DATABASE_URL)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**A. Crea Postgres su Coolify (manuale — non automatizzabile)**
|
||||
|
||||
Nel pannello Coolify su Hetzner:
|
||||
1. New Resource → Database → PostgreSQL
|
||||
2. Nome: `clienthub-db`
|
||||
3. Version: 16
|
||||
4. Salva le credenziali generate (host, port, user, password, dbname)
|
||||
5. Abilita "Public port" se necessario per drizzle-kit push da locale
|
||||
|
||||
**B. Aggiorna .env locale**
|
||||
|
||||
Sostituisci la riga DATABASE_URL con la connection string Coolify:
|
||||
```
|
||||
DATABASE_URL=postgresql://USER:PASSWORD@HOST:PORT/DBNAME?sslmode=require
|
||||
```
|
||||
|
||||
Mantieni la vecchia riga commentata come backup:
|
||||
```
|
||||
# DATABASE_URL=postgresql://neon... (backup)
|
||||
```
|
||||
|
||||
**C. Aggiorna .env.example**
|
||||
|
||||
Sostituisci il placeholder Neon con quello generico:
|
||||
```
|
||||
DATABASE_URL=postgresql://user:password@host:5432/dbname?sslmode=require
|
||||
```
|
||||
|
||||
**D. Push schema al nuovo DB**
|
||||
|
||||
```bash
|
||||
npx drizzle-kit push
|
||||
```
|
||||
|
||||
Il DB è fresh — nessuna migrazione soft necessaria, tutti i dati attuali sono test data.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>npx drizzle-kit push 2>&1; echo "Exit: $?"</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- drizzle-kit push completes con exit code 0
|
||||
- .env contiene DATABASE_URL che punta a Coolify (non neon.tech)
|
||||
- .env.example non contiene credenziali reali
|
||||
- npx tsc --noEmit non produce errori legati al DB
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task id="2" type="execute" autonomous="true">
|
||||
<name>Task 2: Rinomina route /c/ → /client/ + aggiorna reference</name>
|
||||
|
||||
<read_first>
|
||||
- src/proxy.ts (middleware completo — LEGGERE PRIMA di modificare)
|
||||
- src/components/admin/ClientRow.tsx (link generato riga 59)
|
||||
- src/app/admin/clients/[id]/page.tsx (link generato riga 46)
|
||||
- src/app/c/[token]/page.tsx (dashboard cliente da spostare)
|
||||
- src/app/c/[token]/layout.tsx (layout da spostare)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**A. Rinomina cartella App Router**
|
||||
|
||||
```bash
|
||||
mv src/app/c src/app/client
|
||||
```
|
||||
|
||||
Questo sposta automaticamente page.tsx e layout.tsx alla nuova route.
|
||||
|
||||
**B. Aggiorna src/proxy.ts**
|
||||
|
||||
Tre sostituzioni esatte:
|
||||
|
||||
Riga 32 — cambia:
|
||||
```typescript
|
||||
if (pathname.startsWith("/c/")) {
|
||||
```
|
||||
in:
|
||||
```typescript
|
||||
if (pathname.startsWith("/client/")) {
|
||||
```
|
||||
|
||||
Riga 33 — cambia:
|
||||
```typescript
|
||||
const tokenMatch = pathname.match(/^\/c\/([a-zA-Z0-9_-]+)/);
|
||||
```
|
||||
in:
|
||||
```typescript
|
||||
const tokenMatch = pathname.match(/^\/client\/([a-zA-Z0-9_-]+)/);
|
||||
```
|
||||
|
||||
Riga 63 — cambia:
|
||||
```typescript
|
||||
matcher: ["/admin/:path*", "/c/:path*"],
|
||||
```
|
||||
in:
|
||||
```typescript
|
||||
matcher: ["/admin/:path*", "/client/:path*"],
|
||||
```
|
||||
|
||||
**C. Aggiorna ClientRow.tsx**
|
||||
|
||||
Riga 59 — cambia:
|
||||
```typescript
|
||||
href={`/c/${client.token}`}
|
||||
```
|
||||
in:
|
||||
```typescript
|
||||
href={`/client/${client.token}`}
|
||||
```
|
||||
|
||||
**D. Aggiorna admin/clients/[id]/page.tsx**
|
||||
|
||||
Riga 46 — cambia:
|
||||
```typescript
|
||||
href={`/c/${client.token}`}
|
||||
```
|
||||
in:
|
||||
```typescript
|
||||
href={`/client/${client.token}`}
|
||||
```
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>grep -r '"/c/' src/ --include="*.ts" --include="*.tsx" 2>&1; echo "---"; grep -r "'/c/" src/ --include="*.ts" --include="*.tsx" 2>&1; echo "---"; grep -r '/c/:path' src/ --include="*.ts" --include="*.tsx" 2>&1</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `src/app/client/[token]/page.tsx` esiste (ls conferma)
|
||||
- `src/app/c/` NON esiste più (ls conferma)
|
||||
- `grep '"/c/' src/ -r` produce zero risultati
|
||||
- `grep "startsWith(\"/client/\")" src/proxy.ts` produce un match
|
||||
- `grep "/client/:path\*" src/proxy.ts` produce un match
|
||||
- `npx tsc --noEmit` exit code 0
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task id="3" type="execute" autonomous="true">
|
||||
<name>Task 3: Dockerfile per Coolify</name>
|
||||
|
||||
<read_first>
|
||||
- package.json (script build, versione Node richiesta)
|
||||
- next.config.ts (per verificare se output: standalone è già presente)
|
||||
- .gitignore (per confermare che .env non è committato)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**A. Aggiungi output standalone a next.config.ts**
|
||||
|
||||
Next.js standalone mode produce un bundle minimale ottimale per Docker.
|
||||
|
||||
In next.config.ts, aggiungi dentro l'oggetto config:
|
||||
```typescript
|
||||
output: "standalone",
|
||||
```
|
||||
|
||||
**B. Crea Dockerfile nella root del progetto**
|
||||
|
||||
```dockerfile
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXTAUTH_URL=https://hub.iamcavalli.net
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
```
|
||||
|
||||
**C. Crea .dockerignore nella root**
|
||||
|
||||
```
|
||||
.env
|
||||
.env.local
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.planning
|
||||
```
|
||||
|
||||
**D. Aggiungi variabili d'ambiente su Coolify**
|
||||
|
||||
Nel pannello Coolify → app → Environment Variables, aggiungere:
|
||||
```
|
||||
DATABASE_URL=postgresql://... (la stessa del .env locale)
|
||||
NEXTAUTH_SECRET=...
|
||||
NEXTAUTH_URL=https://hub.iamcavalli.net
|
||||
ADMIN_EMAIL=...
|
||||
ADMIN_PASSWORD=...
|
||||
```
|
||||
|
||||
**E. Configura dominio su Coolify**
|
||||
|
||||
Nel pannello Coolify → app → Domains:
|
||||
- Aggiungi: `hub.iamcavalli.net`
|
||||
- Abilita SSL automatico (Let's Encrypt)
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>docker build -t clienthub-test . 2>&1 | tail -5; echo "Exit: $?"</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- `Dockerfile` esiste nella root del progetto
|
||||
- `.dockerignore` esiste e contiene `.env`
|
||||
- `next.config.ts` contiene `output: "standalone"`
|
||||
- `docker build` completes con exit code 0 (se Docker disponibile localmente)
|
||||
- In alternativa: `npm run build` exit code 0 (verifica il build Next.js)
|
||||
- Il file `.env` NON appare in `git status` (confermato da .gitignore)
|
||||
</acceptance_criteria>
|
||||
</task>
|
||||
|
||||
<task id="4" type="checkpoint" subtype="human-verify" autonomous="false">
|
||||
<name>Checkpoint: verifica deploy su hub.iamcavalli.net</name>
|
||||
|
||||
<what_was_built>
|
||||
- Postgres self-hosted su Coolify con schema applicato
|
||||
- Route /client/[token] funzionante (rinominata da /c/)
|
||||
- Dockerfile per deploy su Coolify
|
||||
- Dominio hub.iamcavalli.net configurato
|
||||
</what_was_built>
|
||||
|
||||
<how_to_verify>
|
||||
1. Apri https://hub.iamcavalli.net — deve rispondere (anche con pagina 404 standard Next.js)
|
||||
2. Apri https://hub.iamcavalli.net/admin/login — deve mostrare la schermata di login
|
||||
3. Fai login come admin — deve accedere alla lista clienti
|
||||
4. Copia il link di un cliente — deve essere nella forma `hub.iamcavalli.net/client/[token]`
|
||||
5. Apri il link cliente — la dashboard deve caricare correttamente
|
||||
6. Verifica che il vecchio link `/c/[token]` restituisca 404 (non più attivo)
|
||||
</how_to_verify>
|
||||
|
||||
<resume_signal>
|
||||
Digita "hub ok" quando tutti i controlli passano.
|
||||
</resume_signal>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
1. `src/app/c/` non esiste — `src/app/client/[token]/` esiste
|
||||
2. `grep -r '"/c/' src/` produce zero risultati
|
||||
3. `grep "startsWith(\"/client/\")" src/proxy.ts` produce un match
|
||||
4. `npx drizzle-kit push` exit code 0 sul nuovo DB
|
||||
5. `npm run build` exit code 0
|
||||
6. https://hub.iamcavalli.net risponde dopo deploy Coolify
|
||||
7. https://hub.iamcavalli.net/client/[token] carica la dashboard cliente
|
||||
</verification>
|
||||
|
||||
<summary_template>
|
||||
## Plan 04-00 Complete
|
||||
|
||||
**Infrastruttura migrata:**
|
||||
- DB: Neon → Postgres self-hosted su Coolify (Hetzner)
|
||||
- Route: /c/[token] → /client/[token]
|
||||
- Deploy: Vercel → Coolify via Docker
|
||||
- Dominio: hub.iamcavalli.net attivo
|
||||
|
||||
**File modificati:** src/proxy.ts, src/components/admin/ClientRow.tsx, src/app/admin/clients/[id]/page.tsx, next.config.ts
|
||||
**File creati:** Dockerfile, .dockerignore
|
||||
**File spostati:** src/app/c/ → src/app/client/
|
||||
</summary_template>
|
||||
@@ -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,709 @@
|
||||
---
|
||||
phase: 04-progetti-multi-project
|
||||
plan: "02"
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "04-01"
|
||||
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
|
||||
- src/app/admin/page.tsx
|
||||
- src/lib/admin-queries.ts
|
||||
- src/components/admin/ClientRow.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"
|
||||
- "La lista /admin/clients mostra brand names dei progetti sotto il nome cliente e il Life Time Value (LTV = somma accepted_total di tutti i progetti del cliente) — D-12"
|
||||
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"
|
||||
- path: "src/app/admin/page.tsx"
|
||||
provides: "Lista clienti con brand names secondari e LTV colonna — D-12"
|
||||
contains: "getAllClientsWithPayments"
|
||||
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>
|
||||
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: /admin/clients list — brand names secondari e LTV per cliente (D-12)</name>
|
||||
<files>
|
||||
src/app/admin/page.tsx
|
||||
src/lib/admin-queries.ts
|
||||
src/components/admin/ClientRow.tsx
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- src/app/admin/page.tsx — leggere INTERAMENTE: struttura della lista clienti, ClientRow usage, tabella HTML
|
||||
- src/components/admin/ClientRow.tsx — leggere come viene mostrato il nome cliente e l'LTV attuale (accepted_total)
|
||||
- src/lib/admin-queries.ts — leggere getAllClientsWithPayments per capire il tipo corrente ClientWithPayments e la Promise.all interna
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**A. Aggiornare getAllClientsWithPayments in src/lib/admin-queries.ts (D-12)**
|
||||
|
||||
La funzione deve restituire anche i brand names dei progetti e il LTV calcolato come somma degli accepted_total di tutti i progetti del cliente.
|
||||
|
||||
Estendere il tipo ClientWithPayments (o aggiungere nuovi campi inline) con:
|
||||
- `projectBrands: string[]` — nomi dei progetti non-archiviati del cliente, ordinati per created_at
|
||||
- `ltv: string` — somma degli accepted_total di TUTTI i progetti del cliente (inclusi archiviati)
|
||||
|
||||
Modificare getAllClientsWithPayments per aggiungere una query projects alla Promise.all esistente:
|
||||
|
||||
```typescript
|
||||
// Aggiungere alla Promise.all dentro getAllClientsWithPayments:
|
||||
db.select({
|
||||
client_id: projects.client_id,
|
||||
name: projects.name,
|
||||
accepted_total: projects.accepted_total,
|
||||
archived: projects.archived,
|
||||
})
|
||||
.from(projects)
|
||||
.where(inArray(projects.client_id, clientIds)),
|
||||
```
|
||||
|
||||
Nel map finale, aggiungere il calcolo:
|
||||
```typescript
|
||||
const clientProjects = allProjects.filter((p) => p.client_id === client.id);
|
||||
const projectBrands = clientProjects
|
||||
.filter((p) => !p.archived)
|
||||
.map((p) => p.name);
|
||||
const ltv = clientProjects
|
||||
.reduce((sum, p) => sum + parseFloat(p.accepted_total ?? "0"), 0)
|
||||
.toFixed(2);
|
||||
return {
|
||||
...existingClientObject,
|
||||
projectBrands,
|
||||
ltv,
|
||||
};
|
||||
```
|
||||
|
||||
Assicurarsi che `projects` sia importato da `@/db/schema` negli import esistenti (da 04-01 è già presente).
|
||||
|
||||
**B. Aggiornare src/components/admin/ClientRow.tsx — brand names + LTV colonna (D-12)**
|
||||
|
||||
Leggere ClientRow.tsx interamente. Aggiungere:
|
||||
|
||||
1. Sotto il nome cliente in bold, aggiungere la riga brand secondaria:
|
||||
```tsx
|
||||
{client.projectBrands && client.projectBrands.length > 0 && (
|
||||
<p className="text-xs text-[#71717a] mt-0.5">
|
||||
{client.projectBrands.join(" | ")}
|
||||
</p>
|
||||
)}
|
||||
```
|
||||
|
||||
2. Per la colonna LTV: sostituire `client.accepted_total` con `client.ltv` (che è ora la somma dei progetti). Se la colonna LTV non esiste ancora, aggiungere una colonna con `€{parseFloat(client.ltv).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`.
|
||||
|
||||
3. Aggiornare il tipo prop di ClientRow per includere i nuovi campi:
|
||||
```typescript
|
||||
// Aggiungere ai campi di ClientWithPayments usati da ClientRow:
|
||||
projectBrands: string[];
|
||||
ltv: string;
|
||||
```
|
||||
|
||||
Se ClientRow usa `ClientWithPayments` importato da admin-queries, il tipo sarà aggiornato automaticamente dalla modifica in A. Verificare che TypeScript non si lamenti.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>npm run build 2>&1 | tail -20</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- src/lib/admin-queries.ts contains `projectBrands` (grep: `grep "projectBrands" src/lib/admin-queries.ts`)
|
||||
- src/components/admin/ClientRow.tsx contains `projectBrands.join` (grep)
|
||||
- src/components/admin/ClientRow.tsx contains `client.ltv` (grep)
|
||||
- `npm run build` completa senza errori TypeScript
|
||||
- Visitando /admin ogni riga cliente mostra i brand names sotto il nome (es. "Brand Blu | Brand Verde") e la colonna LTV mostra la somma degli accepted_total di tutti i progetti
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>Lista /admin/clients mostra brand names secondari sotto nome cliente e LTV calcolato come somma dei progetti — D-12 implementato</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,50 @@
|
||||
---
|
||||
plan: "04-02"
|
||||
phase: "04-progetti-multi-project"
|
||||
status: complete
|
||||
completed_at: "2026-05-22"
|
||||
---
|
||||
|
||||
# Summary: 04-02 — Admin Projects List + Client Detail Project Cards
|
||||
|
||||
## What Was Built
|
||||
|
||||
**NavBar** — added Progetti (`/admin/projects`) and Impostazioni (`/admin/impostazioni`) links in the correct order: Clienti → Progetti → Statistiche → Catalogo → Impostazioni.
|
||||
|
||||
**ProjectRow** (`src/components/admin/ProjectRow.tsx`) — new table row component cloned from ClientRow, adapted for projects. Columns: Nome+Cliente, Valore, Acconto badge, Saldo badge, TimerCell, €/h. The €/h is computed inline: `accepted_total / (totalTrackedSeconds / 3600)`, showing `—` when no hours tracked.
|
||||
|
||||
**`/admin/projects`** (`src/app/admin/projects/page.tsx`) — table listing all projects via `getAllProjectsWithPayments()`. Empty state shows helpful message.
|
||||
|
||||
**`/admin/projects/new`** (`src/app/admin/projects/new/page.tsx`) — creation form with client `<select>`. Accepts `?client_id` query param to pre-select the client (used when clicking "+ Nuovo Progetto" from a client's detail page). On submit calls `createProject` server action and redirects to the new project's workspace.
|
||||
|
||||
**project-actions** (`src/app/admin/projects/project-actions.ts`) — server actions: `createProject`, `archiveProject`, `unarchiveProject`, `updateProjectAcceptedTotal`. Each guards with local `requireAdmin()` (same pattern as other actions files — not imported from `@/lib/auth` which doesn't export it).
|
||||
|
||||
**`/admin/clients/[id]`** — fully rewritten from tabbed workspace to project cards grid. Active projects shown as clickable cards linking to `/admin/projects/[id]`. Archived projects shown below with reduced opacity. Header retains "Link cliente →" and `ClientActions` component.
|
||||
|
||||
**`getAllClientsWithPayments`** — extended to also fetch project names/amounts per client, computing:
|
||||
- `projectBrands: string[]` — non-archived project names, used as secondary labels under client name
|
||||
- `ltv: string` — sum of all projects' `accepted_total` (including archived), replaces single `accepted_total` in the LTV column
|
||||
|
||||
**ClientRow** — shows `projectBrands.join(" | ")` under client name (falls back to `brand_name` if no projects). LTV column now uses `client.ltv`.
|
||||
|
||||
**`/admin/page.tsx`** — column header renamed from "Totale" to "LTV".
|
||||
|
||||
## Key Implementation Decisions
|
||||
|
||||
- **`?client_id` pre-selection in new project form**: used `defaultValue={client_id ?? ""}` on the `<select>` — React controlled default, works with server components.
|
||||
- **No `requireAdmin` import**: all server actions files define it locally with the same `getServerSession(authOptions)` pattern — consistent with existing codebase.
|
||||
- **`clientProjects` query extended**: instead of a separate parallel query for projectBrands/ltv, the existing `clientProjects` fetch was extended to also select `name`, `accepted_total`, `archived` — avoids an extra DB round-trip.
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/components/admin/NavBar.tsx` | Updated — added 2 links |
|
||||
| `src/components/admin/ProjectRow.tsx` | Created |
|
||||
| `src/app/admin/projects/page.tsx` | Created |
|
||||
| `src/app/admin/projects/new/page.tsx` | Created |
|
||||
| `src/app/admin/projects/project-actions.ts` | Created |
|
||||
| `src/app/admin/clients/[id]/page.tsx` | Rewritten |
|
||||
| `src/app/admin/page.tsx` | Updated — column header |
|
||||
| `src/lib/admin-queries.ts` | Updated — projectBrands + ltv |
|
||||
| `src/components/admin/ClientRow.tsx` | Updated — projectBrands + ltv |
|
||||
@@ -0,0 +1,672 @@
|
||||
---
|
||||
phase: 04-progetti-multi-project
|
||||
plan: "03"
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "04-01"
|
||||
files_modified:
|
||||
- src/app/admin/projects/[id]/page.tsx
|
||||
- src/app/admin/timer-actions.ts
|
||||
- src/components/admin/tabs/TimerTab.tsx
|
||||
- src/components/admin/ProfitabilityCard.tsx
|
||||
- src/app/admin/impostazioni/page.tsx
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PROJ-01
|
||||
- PROJ-03
|
||||
- PROJ-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "La pagina /admin/projects/[id] mostra il workspace con tabs Fasi, Pagamenti, Documenti, Commenti, Preventivo, Timer"
|
||||
- "Il tab Timer mostra il totale ore lavorate e un bottone play/stop funzionante"
|
||||
- "Il tab Timer mostra la ProfitabilityCard con €/h reale, costo ideale, delta guadagno/perdita"
|
||||
- "timer-actions.ts usa project_id invece di client_id per startTimer e stopTimer"
|
||||
- "La pagina /admin/impostazioni esiste e permette di impostare target_hourly_rate"
|
||||
artifacts:
|
||||
- path: "src/app/admin/projects/[id]/page.tsx"
|
||||
provides: "Workspace progetto con tabs"
|
||||
contains: "getProjectFullDetail"
|
||||
- path: "src/app/admin/timer-actions.ts"
|
||||
provides: "Timer actions con project_id"
|
||||
contains: "project_id: projectId"
|
||||
- path: "src/components/admin/tabs/TimerTab.tsx"
|
||||
provides: "Tab timer con TimerCell + ProfitabilityCard"
|
||||
contains: "ProfitabilityCard"
|
||||
- path: "src/components/admin/ProfitabilityCard.tsx"
|
||||
provides: "Card analytics profittabilità"
|
||||
contains: "totalTrackedSeconds / 3600"
|
||||
- path: "src/app/admin/impostazioni/page.tsx"
|
||||
provides: "Pagina impostazioni admin con target hourly rate"
|
||||
contains: "target_hourly_rate"
|
||||
key_links:
|
||||
- from: "src/app/admin/projects/[id]/page.tsx"
|
||||
to: "src/lib/admin-queries.ts"
|
||||
via: "getProjectFullDetail(id)"
|
||||
pattern: "getProjectFullDetail"
|
||||
- from: "src/components/admin/tabs/TimerTab.tsx"
|
||||
to: "src/components/admin/ProfitabilityCard.tsx"
|
||||
via: "ProfitabilityCard component"
|
||||
pattern: "ProfitabilityCard"
|
||||
- from: "src/app/admin/impostazioni/page.tsx"
|
||||
to: "src/lib/settings.ts"
|
||||
via: "updateSetting / getTargetHourlyRate"
|
||||
pattern: "getTargetHourlyRate\|updateSetting"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Admin project workspace (/admin/projects/[id]) e analytics profittabilità. Clona il workspace di /admin/clients/[id] adattandolo al livello progetto, refactora il timer per usare project_id, crea il TimerTab con ProfitabilityCard, e aggiunge /admin/impostazioni per il target_hourly_rate.
|
||||
|
||||
Può girare in PARALLELO con 04-02 perché non tocca nessuno degli stessi file.
|
||||
|
||||
Purpose: Consegna il workspace completo per progetto (PROJ-01 e PROJ-03) e le analytics profittabilità (PROJ-05). Dopo questo piano l'admin ha un workspace funzionale per ogni progetto incluso il timer e le analytics.
|
||||
|
||||
Output: /admin/projects/[id] funzionale, timer migrato a project_id, analytics card, settings page.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@/Users/simonecavalli/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/PROJECT.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/CLAUDE.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/04-progetti-multi-project/04-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Tipi e funzioni disponibili da 04-01. -->
|
||||
|
||||
Da src/lib/admin-queries.ts:
|
||||
```typescript
|
||||
export type ProjectFullDetail = {
|
||||
project: Project & { client: { id: string; name: string; brand_name: string; slug: string | null } };
|
||||
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||
payments: Payment[];
|
||||
documents: Document[];
|
||||
notes: Note[];
|
||||
comments: Comment[];
|
||||
quoteItems: QuoteItemWithLabel[];
|
||||
activeServices: ServiceCatalog[];
|
||||
activeTimerEntryId: string | null;
|
||||
activeTimerStartedAt: Date | null;
|
||||
totalTrackedSeconds: number;
|
||||
};
|
||||
|
||||
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null>;
|
||||
```
|
||||
|
||||
Da src/lib/settings.ts:
|
||||
```typescript
|
||||
export const SETTINGS_KEYS: { TARGET_HOURLY_RATE: "target_hourly_rate" };
|
||||
export async function getSetting(key: string): Promise<string | null>;
|
||||
export async function updateSetting(key: string, value: string): Promise<void>;
|
||||
export async function getTargetHourlyRate(): Promise<number>;
|
||||
```
|
||||
|
||||
Template da replicare per workspace (da src/app/admin/clients/[id]/page.tsx — da leggere in read_first):
|
||||
- Tabs: PhasesTab, PaymentsTab, DocumentsTab, CommentsTab, QuoteTab
|
||||
- PhasesViewToggle per toggle kanban/list
|
||||
- ClientActions (ora diventano ProjectActions)
|
||||
|
||||
Nota: TimerCell usa il prop `clientId` per la compatibilità con il nome storico — in realtà passiamo il projectId. Il componente TimerCell chiama startTimer(clientId) e stopTimer(entryId) dalle timer-actions.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Refactoring timer-actions.ts (client_id → project_id) + ProfitabilityCard + TimerTab</name>
|
||||
<files>
|
||||
src/app/admin/timer-actions.ts
|
||||
src/components/admin/ProfitabilityCard.tsx
|
||||
src/components/admin/tabs/TimerTab.tsx
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- src/app/admin/timer-actions.ts — leggere interamente: startTimer, stopTimer, le loro dipendenze da client_id nel DB
|
||||
- src/components/admin/TimerCell.tsx — leggere le props interface e come chiama startTimer/stopTimer
|
||||
- src/components/admin/tabs/QuoteTab.tsx — pattern "use client" + server action + useTransition per TimerTab
|
||||
- src/app/admin/clients/[id]/page.tsx — vedere come TimerCell è attualmente passato (per capire dove compare il timer e cosa props riceve)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**A. Aggiornare src/app/admin/timer-actions.ts**
|
||||
|
||||
Riscrivere startTimer per usare project_id. Leggere prima l'intero file corrente.
|
||||
|
||||
Il cambiamento principale:
|
||||
1. Parametro `clientId: string` → `projectId: string`
|
||||
2. `db.insert(time_entries).values({ id, client_id: clientId })` → `db.insert(time_entries).values({ id, project_id: projectId })`
|
||||
3. La query "stop any running session" rimane GLOBALE (non per progetto) — D-15: solo un timer attivo alla volta
|
||||
4. Aggiornare `revalidatePath` per includere `/admin/projects`
|
||||
|
||||
```typescript
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { time_entries } from "@/db/schema";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export async function startTimer(projectId: string): Promise<{ entryId: string }> {
|
||||
// Stop ALL currently running sessions (global: only one timer active at a time — D-15)
|
||||
const running = await db
|
||||
.select({ id: time_entries.id, started_at: time_entries.started_at })
|
||||
.from(time_entries)
|
||||
.where(isNull(time_entries.ended_at));
|
||||
|
||||
for (const r of running) {
|
||||
const now = new Date();
|
||||
const secs = Math.round((now.getTime() - new Date(r.started_at).getTime()) / 1000);
|
||||
await db
|
||||
.update(time_entries)
|
||||
.set({ ended_at: now, duration_seconds: secs })
|
||||
.where(eq(time_entries.id, r.id));
|
||||
}
|
||||
|
||||
// Create new entry scoped to PROJECT (not client) — D-19
|
||||
const id = nanoid();
|
||||
await db.insert(time_entries).values({ id, project_id: projectId });
|
||||
revalidatePath("/admin/projects");
|
||||
revalidatePath("/admin");
|
||||
return { entryId: id };
|
||||
}
|
||||
|
||||
export async function stopTimer(entryId: string): Promise<void> {
|
||||
const rows = await db
|
||||
.select({ started_at: time_entries.started_at })
|
||||
.from(time_entries)
|
||||
.where(eq(time_entries.id, entryId))
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) return;
|
||||
|
||||
const now = new Date();
|
||||
const secs = Math.round((now.getTime() - new Date(rows[0].started_at).getTime()) / 1000);
|
||||
await db
|
||||
.update(time_entries)
|
||||
.set({ ended_at: now, duration_seconds: secs })
|
||||
.where(eq(time_entries.id, entryId));
|
||||
|
||||
revalidatePath("/admin/projects");
|
||||
revalidatePath("/admin");
|
||||
}
|
||||
```
|
||||
|
||||
**B. Creare src/components/admin/ProfitabilityCard.tsx**
|
||||
|
||||
Implementare il componente analytics (D-20). Calcolo:
|
||||
- ore = totalTrackedSeconds / 3600
|
||||
- €/h reale = accepted_total ÷ ore (se ore > 0, altrimenti mostrare "—")
|
||||
- costo ideale = targetHourlyRate × ore
|
||||
- delta = accepted_total - costo_ideale (positivo = guadagno, negativo = perdita)
|
||||
|
||||
```typescript
|
||||
// src/components/admin/ProfitabilityCard.tsx
|
||||
// NO "use client" — questo è un componente server-renderable (solo display, no interactivity)
|
||||
|
||||
type ProfitabilityCardProps = {
|
||||
acceptedTotal: string; // e.g., "1500.00"
|
||||
totalTrackedSeconds: number;
|
||||
targetHourlyRate: number; // e.g., 50
|
||||
};
|
||||
|
||||
export function ProfitabilityCard({
|
||||
acceptedTotal,
|
||||
totalTrackedSeconds,
|
||||
targetHourlyRate,
|
||||
}: ProfitabilityCardProps) {
|
||||
const hours = totalTrackedSeconds / 3600;
|
||||
const accepted = parseFloat(acceptedTotal || "0");
|
||||
const realHourlyRate = hours > 0 ? accepted / hours : null;
|
||||
const idealCost = targetHourlyRate * hours;
|
||||
const delta = accepted - idealCost;
|
||||
const deltaIsProfit = delta >= 0;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3">
|
||||
<h3 className="font-medium text-[#1a1a1a]">Profittabilità</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-[#71717a] text-xs">Ore lavorate</p>
|
||||
<p className="font-mono font-semibold text-[#1a1a1a]">{hours.toFixed(1)}h</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#71717a] text-xs">Importo accettato</p>
|
||||
<p className="font-mono font-semibold text-[#1a1a1a]">
|
||||
{accepted > 0 ? `€${accepted.toFixed(2)}` : "Non impostato"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#f4f4f5] pt-3 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[#71717a]">€/h reale</span>
|
||||
<span className="font-mono font-semibold text-[#1a1a1a]">
|
||||
{realHourlyRate !== null ? `€${realHourlyRate.toFixed(2)}/h` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[#71717a]">€/h target</span>
|
||||
<span className="font-mono font-semibold text-[#71717a]">€{targetHourlyRate.toFixed(2)}/h</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[#71717a]">Costo ideale ({hours.toFixed(1)}h × €{targetHourlyRate}/h)</span>
|
||||
<span className="font-mono font-semibold text-[#1a1a1a]">€{idealCost.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hours > 0 && accepted > 0 && (
|
||||
<div className="border-t border-[#f4f4f5] pt-3 flex justify-between items-center">
|
||||
<span className="text-[#71717a]">Delta (guadagno/perdita)</span>
|
||||
<span className={`font-mono font-bold ${deltaIsProfit ? "text-green-600" : "text-red-600"}`}>
|
||||
{deltaIsProfit ? "+" : ""}€{delta.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hours === 0 && (
|
||||
<p className="text-xs text-[#71717a] border-t border-[#f4f4f5] pt-3">
|
||||
Avvia il timer per iniziare a tracciare le ore.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**C. Creare src/components/admin/tabs/TimerTab.tsx**
|
||||
|
||||
Il TimerTab mostra il timer (TimerCell) e la ProfitabilityCard. È un Client Component perché TimerCell è "use client".
|
||||
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { TimerCell } from "@/components/admin/TimerCell";
|
||||
import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard";
|
||||
|
||||
type TimerTabProps = {
|
||||
projectId: string;
|
||||
acceptedTotal: string;
|
||||
activeTimerEntryId: string | null;
|
||||
activeTimerStartedAt: Date | null;
|
||||
totalTrackedSeconds: number;
|
||||
targetHourlyRate: number;
|
||||
};
|
||||
|
||||
export function TimerTab({
|
||||
projectId,
|
||||
acceptedTotal,
|
||||
activeTimerEntryId,
|
||||
activeTimerStartedAt,
|
||||
totalTrackedSeconds,
|
||||
targetHourlyRate,
|
||||
}: TimerTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4">
|
||||
<h3 className="font-medium text-[#1a1a1a] mb-4">Timer</h3>
|
||||
<TimerCell
|
||||
clientId={projectId}
|
||||
activeEntryId={activeTimerEntryId}
|
||||
activeStartedAt={activeTimerStartedAt}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProfitabilityCard
|
||||
acceptedTotal={acceptedTotal}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
targetHourlyRate={targetHourlyRate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
NOTA: TimerCell usa il prop `clientId` ma nel contesto progetto gli passiamo `projectId`. Questo è intentionale per mantenere la compatibilità con TimerCell senza modificarlo. TimerCell chiamerà `startTimer(projectId)` — che ora è il parametro corretto.
|
||||
|
||||
Verificare che TimerCell importi da `@/app/admin/timer-actions` e non da un path relativo. Se usa path relativo, assicurarsi che la risoluzione sia corretta.
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- src/app/admin/timer-actions.ts contains `project_id: projectId` nella insert (grep: `grep "project_id: projectId" src/app/admin/timer-actions.ts`)
|
||||
- src/app/admin/timer-actions.ts does NOT contain `client_id:` nella insert (grep: vecchio pattern rimosso)
|
||||
- src/components/admin/ProfitabilityCard.tsx exists e contains `totalTrackedSeconds / 3600` (grep)
|
||||
- src/components/admin/tabs/TimerTab.tsx exists e contains `ProfitabilityCard` (grep)
|
||||
- src/components/admin/tabs/TimerTab.tsx contains `clientId={projectId}` (passa project id a TimerCell) (grep)
|
||||
- TypeScript compila senza errori
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>Timer migrato a project_id, ProfitabilityCard e TimerTab creati</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: /admin/projects/[id] workspace + /admin/impostazioni settings page</name>
|
||||
<files>
|
||||
src/app/admin/projects/[id]/page.tsx
|
||||
src/app/admin/impostazioni/page.tsx
|
||||
</files>
|
||||
|
||||
<read_first>
|
||||
- src/app/admin/clients/[id]/page.tsx — LEGGERE INTERAMENTE: questo è il template esatto che cloniamo per projects/[id]; capire tutti i component imports, i pattern params, la struttura Tabs
|
||||
- src/lib/settings.ts — import getTargetHourlyRate, updateSetting, SETTINGS_KEYS
|
||||
- src/components/admin/tabs/TimerTab.tsx — props interface appena creato in Task 1
|
||||
- src/app/admin/catalog/page.tsx — pattern per la settings page (form con server action inline)
|
||||
</read_first>
|
||||
|
||||
<action>
|
||||
**A. Creare src/app/admin/projects/[id]/page.tsx**
|
||||
|
||||
Clonare src/app/admin/clients/[id]/page.tsx sostituendo:
|
||||
- `getClientFullDetail(id)` → `getProjectFullDetail(id)` (import da @/lib/admin-queries)
|
||||
- Le props dei tab components: sostituire clientId con projectId dove necessario
|
||||
- Aggiungere il tab Timer (nuovo) usando TimerTab
|
||||
- Header: mostrare nome progetto + "← Progetti" come breadcrumb, sottotitolo = nome cliente
|
||||
|
||||
```typescript
|
||||
import { notFound } from "next/navigation";
|
||||
import { getProjectFullDetail } from "@/lib/admin-queries";
|
||||
import { getTargetHourlyRate } from "@/lib/settings";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
|
||||
import { NotesTab } from "@/components/admin/tabs/NotesTab"; // se esiste
|
||||
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
||||
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||
import Link from "next/link";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const [detail, targetHourlyRate] = await Promise.all([
|
||||
getProjectFullDetail(id),
|
||||
getTargetHourlyRate(),
|
||||
]);
|
||||
|
||||
if (!detail) notFound();
|
||||
|
||||
const {
|
||||
project,
|
||||
phases,
|
||||
payments,
|
||||
documents,
|
||||
notes,
|
||||
comments,
|
||||
quoteItems,
|
||||
activeServices,
|
||||
activeTimerEntryId,
|
||||
activeTimerStartedAt,
|
||||
totalTrackedSeconds,
|
||||
} = detail;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Link href="/admin/projects" className="text-sm text-[#71717a] hover:text-[#1a1a1a]">
|
||||
← Progetti
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">{project.name}</h1>
|
||||
<p className="text-sm text-[#71717a]">
|
||||
<Link href={`/admin/clients/${project.client.id}`} className="hover:text-[#1a1a1a] hover:underline">
|
||||
{project.client.name}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="phases" className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="phases">Fasi & Task</TabsTrigger>
|
||||
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||
<TabsTrigger value="notes">Note</TabsTrigger>
|
||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||
<TabsTrigger value="quote">Preventivo</TabsTrigger>
|
||||
<TabsTrigger value="timer">Timer</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="phases">
|
||||
<PhasesViewToggle
|
||||
listView={<PhasesTab phases={phases} clientId={id} />}
|
||||
phases={phases}
|
||||
clientId={id}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="payments">
|
||||
<PaymentsTab payments={payments} clientId={id} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documents">
|
||||
<DocumentsTab documents={documents} clientId={id} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Render NotesTab solo se il component esiste — altrimenti inline */}
|
||||
<TabsContent value="notes">
|
||||
<div className="space-y-4">
|
||||
{notes.length === 0 && (
|
||||
<p className="text-sm text-[#71717a]">Nessuna nota ancora.</p>
|
||||
)}
|
||||
{notes.map((note) => (
|
||||
<div key={note.id} className="bg-white rounded-lg border border-[#e5e7eb] p-4">
|
||||
<p className="text-sm text-[#1a1a1a] whitespace-pre-wrap">{note.body}</p>
|
||||
<p className="text-xs text-[#71717a] mt-2">
|
||||
{new Date(note.created_at).toLocaleDateString("it-IT")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="comments">
|
||||
<CommentsTab comments={comments} clientId={id} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="quote">
|
||||
<QuoteTab
|
||||
quoteItems={quoteItems}
|
||||
activeServices={activeServices}
|
||||
clientId={id}
|
||||
acceptedTotal={project.accepted_total ?? "0"}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="timer">
|
||||
<TimerTab
|
||||
projectId={id}
|
||||
acceptedTotal={project.accepted_total ?? "0"}
|
||||
activeTimerEntryId={activeTimerEntryId}
|
||||
activeTimerStartedAt={activeTimerStartedAt}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
targetHourlyRate={targetHourlyRate}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
NOTA CRITICA: I tab components (PhasesTab, PaymentsTab, DocumentsTab, CommentsTab, QuoteTab) potrebbero avere prop `clientId` che originariamente si riferivano al client.id. In questo contesto, passiamo il project.id come `clientId` — i tab usano quel valore per le loro server actions (addPhase, addPayment, ecc.). Le server actions di fase/pagamento/documento potrebbero ancora cercare client_id nel DB. VERIFICARE leggendo ogni actions file:
|
||||
|
||||
- Se le actions usano ancora `client_id` nel DB, bisogna aggiornare le actions dei tab per usare `project_id`. Questo è parte dello stesso task.
|
||||
- Leggere src/app/admin/clients/[id]/phase-actions.ts (o simile) e src/app/admin/clients/[id]/payment-actions.ts per capire se fanno insert con client_id.
|
||||
- Aggiornare TUTTI i file di actions che fanno insert/update con client_id su tabelle che ora usano project_id.
|
||||
|
||||
Specificamente, cercare tutti i file di actions:
|
||||
```bash
|
||||
grep -r "client_id" src/app/admin/clients/[id]/ --include="*actions*"
|
||||
```
|
||||
Per ogni occorrenza che fa insert su phases, payments, documents, notes, quote_items: cambiare il campo da client_id a project_id e aggiornare i revalidatePath da /admin/clients/[id] a /admin/projects/[id].
|
||||
|
||||
**B. Creare src/app/admin/impostazioni/page.tsx**
|
||||
|
||||
```typescript
|
||||
import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ImpostazioniPage() {
|
||||
const targetRate = await getTargetHourlyRate();
|
||||
|
||||
async function handleSave(fd: FormData) {
|
||||
"use server";
|
||||
const newRate = fd.get("target_hourly_rate");
|
||||
if (!newRate || isNaN(parseFloat(String(newRate)))) return;
|
||||
await updateSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE, String(parseFloat(String(newRate)).toFixed(2)));
|
||||
revalidatePath("/admin/impostazioni");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-6">Impostazioni</h1>
|
||||
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 max-w-md">
|
||||
<h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2>
|
||||
|
||||
<form action={handleSave} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="target_hourly_rate"
|
||||
className="block text-sm font-medium text-[#1a1a1a] mb-1"
|
||||
>
|
||||
Tariffa oraria target (€/h)
|
||||
</label>
|
||||
<p className="text-xs text-[#71717a] mb-2">
|
||||
Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[#71717a]">€</span>
|
||||
<input
|
||||
id="target_hourly_rate"
|
||||
name="target_hourly_rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={targetRate.toFixed(2)}
|
||||
className="border border-[#e5e7eb] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 w-32"
|
||||
/>
|
||||
<span className="text-sm text-[#71717a]">/h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#1A463C] text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors"
|
||||
>
|
||||
Salva
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
</action>
|
||||
|
||||
<verify>
|
||||
<automated>npm run build 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
|
||||
<acceptance_criteria>
|
||||
- src/app/admin/projects/[id]/page.tsx exists e contains `getProjectFullDetail` (grep)
|
||||
- src/app/admin/projects/[id]/page.tsx contains `TimerTab` import e usage (grep)
|
||||
- src/app/admin/projects/[id]/page.tsx contains `getTargetHourlyRate` (grep)
|
||||
- src/app/admin/impostazioni/page.tsx exists e contains `SETTINGS_KEYS.TARGET_HOURLY_RATE` (grep)
|
||||
- src/app/admin/impostazioni/page.tsx contains `updateSetting` (grep)
|
||||
- Tutte le actions di fase/pagamento/documento/note/quote che facevano insert con client_id sono state aggiornate a project_id (grep: `grep -r "client_id" src/app/admin/clients/\[id\]/ --include="*actions*"` non deve avere insert su tabelle migrate)
|
||||
- `npm run build` completa senza errori TypeScript
|
||||
- Navigando /admin/projects/[id] (con un progetto esistente) la pagina carica senza 500 errors
|
||||
- Il tab Timer mostra TimerCell e ProfitabilityCard renderizzati
|
||||
</acceptance_criteria>
|
||||
|
||||
<done>/admin/projects/[id] workspace completo con timer e analytics; /admin/impostazioni funzionale</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Admin session → timer-actions | startTimer e stopTimer non hanno requireAdmin perché chiamati da TimerCell lato client; il guard è il middleware Auth.js su /admin/* che blocca accesso non autenticato |
|
||||
| Admin session → impostazioni | handleSave inline server action in pagina /admin/impostazioni — il guard Auth.js su /admin/* blocca utenti non autenticati |
|
||||
| project workspace → quote_items | QuoteTab viene passato quoteItems da getProjectFullDetail — non accessibile via client API (D-02 / CLAUDE.md constraint) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-09 | Information Disclosure | getProjectFullDetail — quoteItems | mitigate | quoteItems inclusi solo nella risposta admin (questo workspace); la funzione client-view (Wave 3, 04-04) non deve includere quote_items — invariante CLAUDE.md |
|
||||
| T-04-10 | Tampering | timer-actions.ts — startTimer | accept | Auth.js middleware su /admin/* impedisce accesso anonimo; timer actions non espongono dati sensibili, solo time tracking |
|
||||
| T-04-11 | Information Disclosure | ProfitabilityCard — accepted_total visibile | accept | accepted_total è il totale accettato dal cliente (non il dettaglio dei singoli servizi) — corretto mostrarlo all'admin nel workspace progetto |
|
||||
| T-04-12 | Tampering | updateSetting — target_hourly_rate | accept | Setting è solo un numero (tariffa oraria); nessun rischio sicurezza; Auth.js middleware blocca accesso non autenticato a /admin/impostazioni |
|
||||
| T-04-13 | Tampering | phase-actions / payment-actions migrazione project_id | mitigate | Dopo aggiornamento actions: insert usa project_id con FK constraint → DB rifiuta project_id non validi con constraint violation |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# 1. Timer uses project_id
|
||||
grep "project_id: projectId" src/app/admin/timer-actions.ts
|
||||
|
||||
# 2. No client_id insert in timer
|
||||
grep -v "project_id" src/app/admin/timer-actions.ts | grep "client_id"
|
||||
|
||||
# 3. Analytics card exists
|
||||
grep "totalTrackedSeconds / 3600" src/components/admin/ProfitabilityCard.tsx
|
||||
|
||||
# 4. TimerTab imports ProfitabilityCard
|
||||
grep "ProfitabilityCard" src/components/admin/tabs/TimerTab.tsx
|
||||
|
||||
# 5. Project workspace uses new query
|
||||
grep "getProjectFullDetail" src/app/admin/projects/\[id\]/page.tsx
|
||||
|
||||
# 6. Settings key constant used
|
||||
grep "SETTINGS_KEYS" src/app/admin/impostazioni/page.tsx
|
||||
|
||||
# 7. Build
|
||||
npm run build
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- /admin/projects/[id] carica senza errori e mostra tutti i tab (Fasi, Pagamenti, Documenti, Note, Commenti, Preventivo, Timer)
|
||||
- Il tab Timer mostra TimerCell (play/stop) e ProfitabilityCard (con ore, €/h reale, costo ideale, delta)
|
||||
- /admin/impostazioni carica e mostra il form con il valore corrente della tariffa (default 50.00 se non impostata)
|
||||
- Salvando un nuovo valore in /admin/impostazioni il valore viene persistito e la pagina mostra il nuovo valore
|
||||
- `npm run build` passa senza errori
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/04-progetti-multi-project/04-03-SUMMARY.md` following the template at `@/Users/simonecavalli/.claude/get-shit-done/templates/summary.md`.
|
||||
|
||||
Key items to document:
|
||||
- Quali actions file sono stati aggiornati da client_id a project_id (lista esaustiva)
|
||||
- Come TimerCell è stato adattato per usare project_id (prop naming)
|
||||
- Se NotesTab esiste come component o se le note sono state implementate inline
|
||||
- Valore default inizializzato per target_hourly_rate
|
||||
</output>
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
plan: "04-03"
|
||||
phase: "04-progetti-multi-project"
|
||||
status: complete
|
||||
completed_at: "2026-05-22"
|
||||
---
|
||||
|
||||
# Summary: 04-03 — Project Workspace + Timer Analytics + Settings
|
||||
|
||||
## What Was Built
|
||||
|
||||
**`/admin/projects/[id]`** — full project workspace with 7 tabs: Fasi & Task, Pagamenti, Documenti, Note, Commenti, Preventivo, Timer. Reuses all existing tab components (PhasesTab, PaymentsTab, DocumentsTab, CommentsTab, QuoteTab) unchanged by passing `projectId` as `clientId`.
|
||||
|
||||
**`ProfitabilityCard`** (`src/components/admin/ProfitabilityCard.tsx`) — pure display component (no `"use client"`). Shows: ore lavorate, importo accettato, €/h reale, €/h target, costo ideale, delta (verde = guadagno, rosso = perdita). Delta shown only when both hours > 0 and accepted > 0.
|
||||
|
||||
**`TimerTab`** (`src/components/admin/tabs/TimerTab.tsx`) — client component wrapping TimerCell + ProfitabilityCard. Passes `projectId` as `clientId` to TimerCell (prop name is legacy; the underlying startTimer already uses project_id).
|
||||
|
||||
**`/admin/impostazioni`** — settings page with target_hourly_rate form. Inline server action calls `updateSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE, ...)`. Default value 50€/h from `getTargetHourlyRate()`.
|
||||
|
||||
## Key Architectural Decision: resolveEntity()
|
||||
|
||||
The tab components (PhasesTab, PaymentsTab, etc.) hardcode imports from `@/app/admin/clients/[id]/actions`. Rather than duplicating tab components or injecting actions as props, added `resolveEntity(id)` helper to both `actions.ts` and `quote-actions.ts`:
|
||||
|
||||
```typescript
|
||||
async function resolveEntity(id: string): Promise<{ projectId: string | null; path: string }> {
|
||||
// Checks if id is a projects.id directly (one PK lookup)
|
||||
// Falls back to client_id lookup if not found
|
||||
// Returns correct revalidatePath for either context
|
||||
}
|
||||
```
|
||||
|
||||
This makes all existing tab actions work transparently with both clientId and projectId. Zero changes to tab components.
|
||||
|
||||
**`updateAcceptedTotal` in `actions.ts`** — special case: detects project vs client context and updates the correct table (`projects.accepted_total` vs `clients.accepted_total`), with per-project payment stub splitting.
|
||||
|
||||
**`timer-actions.ts`** — added `revalidatePath("/admin/projects")` and `revalidatePath(\`/admin/projects/${projectId}\`)` so the project list and workspace refresh after timer start/stop.
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/app/admin/clients/[id]/actions.ts` | Updated — added resolveEntity(), all functions now work with projectId |
|
||||
| `src/app/admin/clients/[id]/quote-actions.ts` | Updated — same resolveEntity pattern |
|
||||
| `src/app/admin/timer-actions.ts` | Updated — added /admin/projects revalidation |
|
||||
| `src/components/admin/ProfitabilityCard.tsx` | Created |
|
||||
| `src/components/admin/tabs/TimerTab.tsx` | Created |
|
||||
| `src/app/admin/projects/[id]/page.tsx` | Created |
|
||||
| `src/app/admin/impostazioni/page.tsx` | Created |
|
||||
|
||||
## Notes Tab
|
||||
|
||||
No `NotesTab` component exists — notes rendered inline in the project workspace page. Simple read-only display (no add/edit/delete — consistent with how notes appear in the client dashboard).
|
||||
@@ -0,0 +1,830 @@
|
||||
---
|
||||
phase: 04-progetti-multi-project
|
||||
plan: "04"
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "04-02"
|
||||
- "04-03"
|
||||
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>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
plan: "04-04"
|
||||
phase: "04-progetti-multi-project"
|
||||
status: complete
|
||||
completed_at: "2026-05-22"
|
||||
---
|
||||
|
||||
# Summary: 04-04 — Slug Resolution + Multi-Project Dashboard + Slug Edit
|
||||
|
||||
## What Was Built
|
||||
|
||||
**`/api/internal/validate-slug/route.ts`** — Internal API route for Edge middleware. Queries `clients.slug`, returns 200 `{ valid: true }` if found, 404 otherwise. Same pattern as `validate-token`.
|
||||
|
||||
**`src/proxy.ts`** — Updated client guard with slug-first resolution (D-06): tries `/api/internal/validate-slug` first, then falls back to `/api/internal/validate-token`. Route path is `/client/[token]` (not `/c/` as referenced in the plan — adapted accordingly).
|
||||
|
||||
**`src/lib/client-view.ts`** — Complete rewrite for multi-project model. Exports:
|
||||
- `getClientWithProjectsByToken(tokenOrSlug)` — resolves slug → client (slug first, then token fallback), returns `ClientProjectSummary` with active (non-archived) projects only
|
||||
- `getProjectView(projectId)` — returns full `ProjectView` scoped to one project; **payments select excludes amount** (DASH-07 + CLAUDE.md); **no quote_items** anywhere in the file
|
||||
|
||||
**`src/app/client/[token]/page.tsx`** — Rewritten for multi-project logic:
|
||||
- 0 active projects → placeholder screen
|
||||
- 1 project → `ClientDashboard` directly (no selector), using `projectViewToClientView` adapter
|
||||
- 2+ projects → shared header + shadcn `Tabs` (project names as triggers) + `ClientDashboard` per tab
|
||||
|
||||
**`src/app/admin/clients/[id]/edit/page.tsx`** — Added slug field with:
|
||||
- Pattern hint: `[a-z0-9-]{3,50}`
|
||||
- Link preview showing `/client/{slug || token}`
|
||||
- Error banner when redirected back with `?error=slug_taken`
|
||||
|
||||
**`src/app/admin/clients/[id]/actions.ts`** — Updated `clientSchema` to include `slug` (Zod regex + `.transform(v => v === "" ? null : v)` to convert empty string to null). `updateClient` now:
|
||||
1. Parses slug from form data
|
||||
2. Persists to `clients.slug` via db.update
|
||||
3. Catches unique constraint violation → redirects to edit page with `?error=slug_taken`
|
||||
4. On success: redirects to `/admin/clients/[id]` (previously no redirect)
|
||||
|
||||
## Key Architectural Decisions
|
||||
|
||||
**Adapter pattern `projectViewToClientView`**: `ClientDashboard` was written for `ClientView` shape. Rather than modifying it, an adapter function in the page converts `ProjectView` + `ClientProjectSummary.client` → `ClientView`. Key mappings:
|
||||
- `view.project.accepted_total` → `clientView.client.accepted_total` (project-level, not client-level)
|
||||
- `view.project.client_id` → `clientView.client.id` (used by ChatSection for comment threading)
|
||||
- `deliverables[].approved_at: Date | null` → `.toISOString() | null` (ClientView expects string)
|
||||
- `documents[].created_at` stripped (ClientView.documents only has id/label/url)
|
||||
|
||||
**Multi-project tabs with full ClientDashboard**: Each tab content renders a complete `ClientDashboard` (including its header/footer). shadcn Tabs shows only the active one via CSS. This is MVP-acceptable: the outer page provides the tab navigation, and within each tab the full dashboard renders.
|
||||
|
||||
**Slug empty string → null**: Zod schema transforms `""` to `null` so saving with empty slug removes it. This lets admins clear a slug without special UI.
|
||||
|
||||
**Security invariants verified** (grep confirmed):
|
||||
- `grep "quote_items" src/lib/client-view.ts` → only in comments, not in queries
|
||||
- `grep "amount" src/lib/client-view.ts` → only in comments documenting the exclusion
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/app/api/internal/validate-slug/route.ts` | Created |
|
||||
| `src/proxy.ts` | Updated — slug-first resolution in client guard |
|
||||
| `src/lib/client-view.ts` | Complete rewrite — getClientWithProjectsByToken + getProjectView |
|
||||
| `src/app/client/[token]/page.tsx` | Complete rewrite — multi-project dashboard |
|
||||
| `src/app/admin/clients/[id]/edit/page.tsx` | Updated — slug field + error banner |
|
||||
| `src/app/admin/clients/[id]/actions.ts` | Updated — clientSchema + slug in updateClient |
|
||||
|
||||
## Notes
|
||||
|
||||
- The plan referenced `/c/[token]` path but actual route has always been `/client/[token]`. Proxy and all references use `/client/` consistently.
|
||||
- `ClientDashboard` component unchanged — the adapter absorbs all type differences.
|
||||
- `getClientWithProjectsByToken` filters out archived projects; archived projects are invisible to the client dashboard.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Phase 4: Progetti — Multi-Project per Cliente - Context
|
||||
|
||||
**Gathered:** 2026-05-20
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Ristrutturazione del modello dati per supportare N progetti per cliente. Un cliente può avere più brand/progetti; ogni progetto ha il proprio workspace (fasi, task, pagamenti, preventivo, timer). Il timer si sposta dal livello cliente al livello progetto. La dashboard cliente mostra i suoi progetti con tabs se multipli. Il link cliente diventa personalizzabile tramite slug opzionale.
|
||||
|
||||
**Sostituisce la Fase 4 AI Onboarding** (spostata a Fase 5) perché è prerequisito architetturale per tutto ciò che viene dopo.
|
||||
|
||||
Tutti i dati attuali sono di test — nessuna migrazione soft necessaria. Schema ricreato da zero dove serve.
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Modello dati
|
||||
|
||||
- **D-01:** Nuova tabella `projects` con campi: `id`, `client_id` (FK → clients), `name` (nome brand/progetto), `archived` (bool), `created_at`. Nessun `accepted_total` diretto — viene calcolato/denormalizzato dai quote_items a livello progetto.
|
||||
- **D-02:** Le seguenti tabelle spostano la FK da `client_id` a `project_id`: `phases`, `payments`, `quote_items`, `time_entries`, `documents`, `notes`. La tabella `comments` rimane su `entity_id` generico — invariata.
|
||||
- **D-03:** `clients` perde i campi che diventano di pertinenza del progetto: `accepted_total` si sposta su `projects`. Il cliente mantiene: `id`, `name`, `brand_name` (riutilizzato come nome display), `token`, `slug` (nuovo), `archived`, `created_at`.
|
||||
- **D-04:** Campo `slug` aggiunto a `clients` — testo opzionale, univoco, URL-safe (es. `mario-rossi`). Se assente, il link usa il token random come prima. URL: `/c/[slug-o-token]`.
|
||||
- **D-05:** `projects.accepted_total` (text, nullable) — denormalizzato come ora su clients, ma al livello progetto. L'admin lo imposta manualmente nel tab Preventivo del progetto.
|
||||
|
||||
### Link e accesso cliente
|
||||
|
||||
- **D-06:** Il token rimane su `clients` per l'autenticazione middleware — non è il campo slug. Il middleware controlla prima lo slug (lookup DB), poi il token (come ora). Entrambi portano alla dashboard del cliente.
|
||||
- **D-07:** Lo slug si imposta nella pagina `/admin/clients/[id]/edit` — campo opzionale con preview del link risultante.
|
||||
- **D-08:** La route `/c/[token-or-slug]` rimane invariata nel path — il middleware risolve entrambi.
|
||||
|
||||
### Dashboard cliente (frontend)
|
||||
|
||||
- **D-09:** Se il cliente ha 1 progetto → la dashboard mostra direttamente quel progetto (nessun selettore).
|
||||
- **D-10:** Se il cliente ha 2+ progetti → tabs in cima con i nomi dei brand (es. "Brand Blu | Brand Verde"). Le tab usano il componente Tabs di shadcn/ui già presente.
|
||||
- **D-11:** La struttura della dashboard cliente cambia: la vista di un singolo progetto mostra le stesse sezioni di oggi (stato fasi/task, pagamenti, approvazioni deliverable, commenti) ma scoped al progetto.
|
||||
|
||||
### Admin — vista Clienti
|
||||
|
||||
- **D-12:** La lista `/admin/clients` mostra ogni cliente con i nomi dei brand sotto il nome (es. "Mario Rossi" + riga sottile "Brand Blu | Brand Verde"). Life Time Value = somma degli `accepted_total` di tutti i progetti del cliente.
|
||||
- **D-13:** Cliccando un cliente si apre `/admin/clients/[id]` che mostra la lista progetti di quel cliente (cards o righe), non più il workspace direttamente.
|
||||
|
||||
### Admin — vista Progetti
|
||||
|
||||
- **D-14:** Nuova pagina `/admin/projects` (link nel NavBar) — lista di tutti i progetti con colonne: Nome progetto, Cliente genitore, Valore progetto (accepted_total), Acconto (stato), Saldo (stato), Timer (play/stop), €/h calcolato.
|
||||
- **D-15:** Il timer nella lista Progetti mostra il bottone play/stop per il progetto corrente. Solo un timer attivo alla volta (come ora, ma scoped al progetto).
|
||||
- **D-16:** Cliccando un progetto si apre `/admin/projects/[id]` — workspace identico all'attuale `/admin/clients/[id]` ma al livello progetto: tabs Panoramica, Fasi, Documenti, Pagamenti, Note, Preventivo, Timer, Commenti.
|
||||
|
||||
### Creazione progetto (admin)
|
||||
|
||||
- **D-17:** Progetto creabile da due punti: (1) dal dettaglio cliente `/admin/clients/[id]` con un bottone "+ Nuovo Progetto", (2) dalla lista `/admin/projects` con "+ Nuovo Progetto" che chiede prima il cliente.
|
||||
- **D-18:** Form di creazione progetto: Nome progetto (brand name) + selezione cliente (se creato dalla lista globale). Nessun campo aggiuntivo al momento della creazione — tutto il resto si configura nel workspace del progetto.
|
||||
|
||||
### Timer e analytics profittabilità
|
||||
|
||||
- **D-19:** `time_entries.client_id` diventa `time_entries.project_id`. Il timer gira per progetto.
|
||||
- **D-20:** Analytics profittabilità nel tab Timer di ogni progetto — card con: ore totali, accepted_total, €/h reale (accepted_total ÷ ore), target_rate × ore (costo ideale), delta (guadagno/perdita vs target).
|
||||
- **D-21:** `target_hourly_rate` è un valore globale impostato dall'admin — stored in una tabella `settings` (key-value) o direttamente come env var configurabile. Impostabile da una nuova sezione "Impostazioni" nel NavBar admin.
|
||||
- **D-22:** La Statistiche page mostra la profittabilità aggregata per tutti i progetti + breakdown per cliente.
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
- Struttura esatta della tabella `settings` (key-value o colonne specifiche) — scegliere l'approccio più semplice (probabile: tabella `settings` con `key text PK, value text`).
|
||||
- Ordine delle tabs nel dettaglio progetto — seguire l'ordine attuale del dettaglio cliente.
|
||||
- Stile delle cards progetto nel dettaglio cliente — seguire i pattern UI esistenti.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Schema attuale (punto di partenza)
|
||||
- `src/db/schema.ts` — schema completo con tutte le tabelle e relazioni. Le FK da migrare: phases.client_id → project_id, payments.client_id → project_id, quote_items.client_id → project_id, time_entries.client_id → project_id, documents.client_id → project_id, notes.client_id → project_id.
|
||||
|
||||
### Architettura constraints (LOCKED)
|
||||
- `CLAUDE.md` §Architecture Constraints — specialmente: token rotatable (mai PK), quote_items mai esposti via client API, deliverables.approved_at immutable. Questi vincoli si applicano anche al livello progetto.
|
||||
|
||||
### Patterns UI esistenti da replicare
|
||||
- `src/components/admin/tabs/` — pattern tab workspace esistente (QuoteTab, ecc.) da replicare per il dettaglio progetto.
|
||||
- `src/components/admin/TimerCell.tsx` — timer da adattare da client_id a project_id.
|
||||
- `src/components/admin/ClientRow.tsx` — pattern riga lista da replicare per ProjectRow.
|
||||
- `src/app/admin/clients/[id]/page.tsx` — layout tabs workspace da replicare per /admin/projects/[id].
|
||||
|
||||
### Dashboard cliente
|
||||
- `src/lib/client-view.ts` — la query che alimenta la dashboard cliente. Va riscritta per supportare progetto singolo e multi-progetto.
|
||||
- `src/app/c/[token]/page.tsx` — dashboard cliente da ristrutturare per mostrare tabs progetto.
|
||||
|
||||
### Phase 3 artifacts (prerequisito)
|
||||
- `.planning/phases/03-service-catalog-quote-builder/03-CONTEXT.md` — decisioni sul preventivo e catalogo (quote_items → project_id in questa fase).
|
||||
- `.planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md` — implementazione QuoteTab.
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `src/components/ui/tabs.tsx` — Tabs shadcn già presente, usato in dettaglio cliente. Riutilizzare per il selettore progetto nella dashboard cliente e per il workspace progetto admin.
|
||||
- `src/components/admin/TimerCell.tsx` — implementazione timer completa, da adattare con project_id invece di client_id.
|
||||
- `src/components/admin/tabs/QuoteTab.tsx` — quote builder completo, da ri-wiring con project_id.
|
||||
- `src/lib/admin-queries.ts` — `getClientFullDetail` è il template per `getProjectFullDetail`.
|
||||
- `src/app/admin/clients/[id]/page.tsx` — layout workspace admin con tabs, da clonare per /admin/projects/[id].
|
||||
|
||||
### Established Patterns
|
||||
- Server Actions con `requireAdmin()` per tutte le operazioni admin-only.
|
||||
- Query pattern: un'unica funzione `getXFullDetail` che recupera tutto in parallelo per ridurre round-trip.
|
||||
- `drizzle-kit push` per applicare le modifiche schema al DB Neon live.
|
||||
- Token middleware in `src/proxy.ts` — da estendere per slug lookup.
|
||||
|
||||
### Integration Points
|
||||
- Il middleware `src/proxy.ts` deve risolvere `/c/[slug-o-token]` → lookup slug prima, poi token.
|
||||
- `src/lib/client-view.ts` va riscritta completamente per il modello multi-progetto.
|
||||
- `src/components/admin/NavBar.tsx` — aggiungere link "Progetti" e "Impostazioni".
|
||||
- `getAllClientsWithPayments` in admin-queries.ts va riscritta per includere i progetti annidati.
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- **Screenshot di riferimento (utente):** Lista Clienti mostra "Mario Rossi" in bold con "Brand Blu | Brand Verde" come testo secondario sotto, Life Time Value come somma, link /c/slug.
|
||||
- **Screenshot di riferimento (utente):** Lista Progetti mostra "Brand Blu" in bold con "Mario Rossi" come testo secondario, colonne Valore, Acconto, Saldo, Timer (play icon), €/h calcolato.
|
||||
- **Analytics formula (utente):** accepted_total ÷ ore_lavorate = €/h reale. target_rate × ore = costo ideale. Delta = accepted - costo_ideale. Mostra se si guadagna, perde, o break-even.
|
||||
- **Target rate:** valore globale (es. 50€/h) che l'admin imposta una volta. Non per progetto.
|
||||
- **Link cliente:** preferibilmente con nome cliente (es. /c/mario-rossi). Customizzabile dall'admin. Fallback al token se non impostato.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **Fatturazione per progetto** — calcolo automatico fatture basato su pagamenti. Fuori scope (out of scope globale del progetto).
|
||||
- **Export PDF preventivo per progetto** — utile ma separato. Fase futura.
|
||||
- **AI Onboarding (ex Fase 4)** — spostato a Fase 5. Richiede questa ristrutturazione come prerequisito.
|
||||
- **Notifiche email al cliente** — quando le fasi cambiano stato. Fase futura.
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 4 — Progetti Multi-Project*
|
||||
*Context gathered: 2026-05-20*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,921 @@
|
||||
# Phase 04: Progetti — Multi-Project per Cliente - Research
|
||||
|
||||
**Researched:** 2026-05-21
|
||||
**Domain:** Data model refactoring + multi-tier architecture (DB schema migration, API routing, client/admin UI)
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 04 transforms ClientHub from a single-project-per-client model to a multi-project model. This is a **breaking schema migration** where the `projects` table becomes the primary work container, and 6 existing tables (`phases`, `payments`, `quote_items`, `time_entries`, `documents`, `notes`) move their FK from `client_id` to `project_id`. The `clients` table gains a `slug` field and loses denormalized fields that move to the project level.
|
||||
|
||||
All existing data is test data — hard migration (drop/recreate tables) is acceptable and planned.
|
||||
|
||||
**Key insight:** This is NOT a typical multi-tenancy refactor. It's a structural deepening: clients now own projects, and projects own the work. The middleware routing pattern (`/c/[slug-or-token]`) stays the same, but resolves at the client level and then queries to find projects.
|
||||
|
||||
**Primary recommendation:** Use vertical slice approach (Wave 0 schema, Wave 1 core routing/queries, Wave 2 admin UI, Wave 3 client UI + analytics). All 5 locked architectural decisions are already finalized in CONTEXT.md — implement them as-is, no discretion needed.
|
||||
|
||||
---
|
||||
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
**Schema & Data Model**
|
||||
- D-01: New `projects` table with `id`, `client_id` FK, `name` (brand/project name), `archived`, `created_at`. No direct `accepted_total` — denormalized from `quote_items` per project.
|
||||
- D-02: Six tables move FK from `client_id` → `project_id`: `phases`, `payments`, `quote_items`, `time_entries`, `documents`, `notes`. `comments` stays polymorphic on `entity_id` (unchanged).
|
||||
- D-03: `clients` table loses project-scoped fields. Retains: `id`, `name`, `brand_name`, `token`, `slug` (new), `archived`, `created_at`. `accepted_total` moves to `projects`.
|
||||
- D-04: `slug` field added to `clients` — optional, unique, URL-safe (e.g., `mario-rossi`). Middleware tries slug first, falls back to token.
|
||||
- D-05: `projects.accepted_total` denormalized (text, nullable), admin sets manually in project Preventivo tab.
|
||||
|
||||
**Link & Access**
|
||||
- D-06: Token stays on `clients` for auth middleware. Middleware checks slug first (DB lookup), then token (existing pattern). Both grant access.
|
||||
- D-07: Slug is set in `/admin/clients/[id]/edit` (new form field, optional, with link preview).
|
||||
- D-08: Route `/c/[token-or-slug]` unchanged in path — middleware resolves both.
|
||||
|
||||
**Client Dashboard**
|
||||
- D-09: 1 project → direct view (no selector).
|
||||
- D-10: 2+ projects → tabs with brand names (shadcn Tabs, already in codebase).
|
||||
- D-11: Project view identical to current client dashboard but scoped to one project.
|
||||
|
||||
**Admin — Client List View**
|
||||
- D-12: `/admin/clients` shows client name + project brands as secondary text (e.g., "Mario Rossi" / "Brand Blu | Brand Verde"). LTV = sum of all project `accepted_total`.
|
||||
- D-13: Clicking a client opens `/admin/clients/[id]` showing project cards/rows (not workspace directly).
|
||||
|
||||
**Admin — Project List & Workspace**
|
||||
- D-14: New `/admin/projects` (NavBar link) — all projects with: Name, Parent Client, Value (accepted_total), Acconto, Saldo, Timer, €/h.
|
||||
- D-15: Timer in projects list shows play/stop for each project. Only one timer active at a time (scoped to project now).
|
||||
- D-16: `/admin/projects/[id]` workspace identical to current `/admin/clients/[id]` but project-level: Panoramica, Fasi, Documenti, Pagamenti, Note, Preventivo, Timer, Commenti tabs.
|
||||
|
||||
**Project Creation**
|
||||
- D-17: Create project from: (1) `/admin/clients/[id]` with "+ Nuovo Progetto" button, or (2) `/admin/projects` with "+ Nuovo Progetto" → select client.
|
||||
- D-18: Creation form: Project Name (brand) + Client (if from list). No other fields at creation time.
|
||||
|
||||
**Timer & Analytics**
|
||||
- D-19: `time_entries.client_id` → `time_entries.project_id`. Timer now per-project.
|
||||
- D-20: Analytics profittabilità in project Timer tab: total hours, accepted_total, €/h real (accepted ÷ hours), target rate × hours (ideal cost), delta (gain/loss vs target).
|
||||
- D-21: `target_hourly_rate` is global (e.g., 50€/h) stored in `settings` table or env var. New "Impostazioni" page in NavBar for admin to set.
|
||||
- D-22: Statistiche page shows aggregated profitability for all projects + breakdown per client.
|
||||
|
||||
### Claude's Discretion
|
||||
|
||||
- **Settings table structure:** key-value (simplest) vs. dedicated columns. Recommendation: `settings(key text PK, value text)`.
|
||||
- **Tab order in project detail:** Follow current client detail order.
|
||||
- **Project card style in client detail:** Reuse existing UI patterns.
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
|
||||
- Automatic invoice generation per project.
|
||||
- PDF export for quotes.
|
||||
- AI Onboarding (Phase 5 — requires this phase as prerequisite).
|
||||
- Email notifications when phases change.
|
||||
|
||||
---
|
||||
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| PROJ-01 | Every client can have N independent projects; each project has its own workspace (phases, payments, quote, timer) accessible from /admin/projects/[id] | Schema migration (D-01, D-02), Query refactor (getProjectFullDetail), Admin workspace pages (/admin/projects/[id]) |
|
||||
| PROJ-02 | Client dashboard shows tabs for 2+ projects; 1 project shows direct workspace without selector | Client view refactor for multi-project detection, Tabs UI pattern (shadcn already available), Client page routing logic |
|
||||
| PROJ-03 | /admin/projects lists all projects with €/h calculated and timer play/stop; /admin/projects/[id] is the project workspace | New project list page and detail page templates (clone from ClientRow/Client detail), Timer refactor (client_id → project_id) |
|
||||
| PROJ-04 | Client link supports custom slug (/c/mario-rossi) with fallback to token; slug settable from /admin/clients/[id]/edit | Middleware slug resolution (internal API route for DB lookup), Clients edit form, Link preview component |
|
||||
| PROJ-05 | Profitability analytics per project: hours tracked, accepted_total, €/h real vs target_hourly_rate global | Analytics card in Timer tab (formula: accepted ÷ hours = €/h real, target × hours = ideal cost, delta = profit/loss), Settings table for global target rate |
|
||||
|
||||
---
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Project CRUD (create, read, update, archive) | API / Backend | Admin Browser | Server actions + DB operations live in API tier; admin calls via form actions |
|
||||
| Timer start/stop for projects | API / Backend | Admin Browser | Timer logic (duration calculation, active session tracking) is backend; UI shows state via server actions |
|
||||
| Multi-project dashboard routing | Frontend Server (SSR) | Browser | Server chooses 1-project direct view vs. 2+ project tabs; browser renders tabs (Tabs component is client-side) |
|
||||
| Slug lookup & resolution | API / Backend + Edge Middleware | — | Middleware calls internal API route to resolve slug → client_id; API accesses DB (can't do direct queries in Edge runtime) |
|
||||
| Profitability analytics calculation | API / Backend | Admin Browser | Formula applied server-side (accepted_total ÷ duration_seconds), displayed in admin workspace |
|
||||
| Client project visibility | API / Backend | Browser | Client API (`/c/[token]/*`) queries projects belonging to the resolved client; client browser renders what API returns |
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| Next.js App Router | 16 | Meta-framework for API routes + SSR + auth middleware | Established in project; Edge middleware pattern for token/slug resolution |
|
||||
| Neon Postgres | current | Primary database | Established; supports both pooled (API routes) and direct (drizzle-kit) connections |
|
||||
| Drizzle ORM | current | Type-safe query builder + schema management | Established; `drizzle-kit push` handles migrations without manual SQL |
|
||||
| Auth.js v4 | current | Admin session authentication | Established; `/admin/*` routes use Auth.js session guard |
|
||||
| Tailwind v4 | current | Styling | Established; Tailwind scanning configured to include project source |
|
||||
| shadcn/ui | current | UI components library | Established; Tabs component already used for admin workspaces |
|
||||
| Zod | current | Input validation | Established for form validation |
|
||||
| nanoid | current | Random ID generation | Established; used for all entity IDs |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| react-hook-form | (check package.json) | Form state management | Forms in admin (create project, edit client slug) |
|
||||
| @radix-ui/tabs | (check package.json) | Underlying Tabs component | shadcn Tabs wrapper — already available |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| `settings` key-value table | Hardcode target_hourly_rate as env var | Env var: simpler, no DB call. Table: more flexible, admin can change via UI. Recommendation: start with table for flexibility. |
|
||||
| Slug as optional field in clients | Always-require slug, generate from name | Optional is better: gradual migration, existing clients keep token links, new clients can have slug. |
|
||||
| Clone workspace from client detail | Build project detail from scratch | Cloning is faster: tabs, layout, queries already proven. Reduces bugs. |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
# No new packages needed — all standard stack already installed
|
||||
npm ls next neon drizzle-orm next-auth tailwindcss shadcn-ui zod nanoid
|
||||
```
|
||||
|
||||
**Version verification:**
|
||||
All versions are in the existing `package.json` and `drizzle.config.ts`. No new dependencies required for Phase 04 schema/routing. Any new UI components (if needed) are installed via `npx shadcn-ui@latest add [component]`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ CLIENT BROWSER │
|
||||
│ ┌──────────────┐ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ /c/[slug] │ │ Client Dashboard (multi-project) │ │
|
||||
│ │ or /c/[tok] │──│ ├─ 1 project: direct view │ │
|
||||
│ │ │ │ └─ 2+: tabs per brand name │ │
|
||||
│ └──────────────┘ │ ├─ Phases, Tasks, Deliverables │ │
|
||||
│ │ ├─ Payments & Status │ │
|
||||
│ │ └─ Documents & Notes │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────┐ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ /admin/* │ │ Admin Area (multi-workspace) │ │
|
||||
│ │ (Auth.js) │──│ ├─ /admin/clients: list + LTV │ │
|
||||
│ │ │ │ ├─ /admin/clients/[id]: projects cards │ │
|
||||
│ └──────────────┘ │ ├─ /admin/projects: all projects + timer │ │
|
||||
│ │ ├─ /admin/projects/[id]: workspace (tabs) │ │
|
||||
│ │ └─ /admin/impostazioni: target_hourly_rate │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌──────────────────────────┐
|
||||
│ Edge Middleware │
|
||||
│ (proxy.ts / middleware) │
|
||||
│ ├─ Resolve /c/[slug] │
|
||||
│ │ → call /api/internal/ │
|
||||
│ │ validate-slug │
|
||||
│ ├─ Fallback /c/[token] │
|
||||
│ │ → existing pattern │
|
||||
│ └─ Admin session check │
|
||||
└──────────────────────────┘
|
||||
↓
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Next.js API Routes (Node.js runtime) │
|
||||
│ ├─ /api/internal/validate-token │
|
||||
│ ├─ /api/internal/validate-slug (NEW) │
|
||||
│ ├─ /api/auth/* (NextAuth) │
|
||||
│ └─ Server Actions (form submissions) │
|
||||
└──────────────────────────────────────────┘
|
||||
↓
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Drizzle ORM Query Layer │
|
||||
│ ├─ getAllProjectsWithPayments (NEW) │
|
||||
│ ├─ getProjectFullDetail (NEW) │
|
||||
│ ├─ getClientWithProjects (NEW) │
|
||||
│ ├─ slugToClientId (NEW, for resolution) │
|
||||
│ └─ timer-actions refactored │
|
||||
│ (client_id → project_id) │
|
||||
└──────────────────────────────────────────┘
|
||||
↓
|
||||
┌──────────────────────────────────────────┐
|
||||
│ Neon Postgres Database │
|
||||
│ ├─ clients (+ slug field NEW) │
|
||||
│ ├─ projects (NEW, client_id FK) │
|
||||
│ ├─ phases (FK: project_id instead) │
|
||||
│ ├─ tasks (unchanged, phase_id FK) │
|
||||
│ ├─ payments (FK: project_id instead) │
|
||||
│ ├─ quote_items (FK: project_id instead) │
|
||||
│ ├─ time_entries (FK: project_id NEW) │
|
||||
│ ├─ documents (FK: project_id instead) │
|
||||
│ ├─ notes (FK: project_id instead) │
|
||||
│ ├─ settings (NEW, key-value table) │
|
||||
│ ├─ comments (entity_id, unchanged) │
|
||||
│ ├─ deliverables (unchanged) │
|
||||
│ └─ service_catalog (unchanged) │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Data flow for client dashboard:**
|
||||
1. Browser requests `/c/mario-rossi`
|
||||
2. Middleware intercepts, calls `/api/internal/validate-slug?slug=mario-rossi`
|
||||
3. API resolves slug → client_id via `slugToClientId()`
|
||||
4. Server component calls `getClientWithProjects(client_id)`
|
||||
5. If 1 project: render direct project view (call `getProjectFullDetail(project_id)`)
|
||||
6. If 2+: render tabs, each tab calls `getProjectFullDetail(project_id)`
|
||||
7. Browser displays project workspace with phases, payments, documents, notes
|
||||
|
||||
**Data flow for admin projects list:**
|
||||
1. Admin visits `/admin/projects`
|
||||
2. Server calls `getAllProjectsWithPayments()`
|
||||
3. Returns projects with: parent client name, accepted_total, payment statuses, active timer info, calculated €/h
|
||||
4. Renders ProjectRow for each (clone of ClientRow pattern)
|
||||
|
||||
---
|
||||
|
||||
## Runtime State Inventory
|
||||
|
||||
> This phase involves renaming + moving FK relationships from `client_id` to `project_id`. Verify all runtime state.
|
||||
|
||||
| Category | Items Found | Action Required |
|
||||
|----------|-------------|------------------|
|
||||
| **Stored data** | Current DB has ~13 tables. Hard migration acceptable (drop/recreate from schema). Test data only — no customer data to preserve. | Code edit: `src/db/schema.ts` (new `projects` table, update FK on 6 tables, add `slug` to clients, new `settings` table). Execute `drizzle-kit push` to apply to Neon. |
|
||||
| **Live service config** | No external service configuration (n8n workflows, webhooks, etc.) references client_id or project structure explicitly. | None — if services are added in future phases, ensure they use project_id. |
|
||||
| **OS-registered state** | None — this is a web application with no local task scheduling or registered executables. | None required. |
|
||||
| **Secrets/env vars** | `.env` currently has: DATABASE_URL, NEXTAUTH_SECRET, NEXTAUTH_URL. No references to client/project IDs. New `target_hourly_rate` will be stored in DB `settings` table (not env var). | None for secrets. If admins prefer env var, add `TARGET_HOURLY_RATE=50` to `.env` and read in analytics component. Recommendation: use DB table for flexibility. |
|
||||
| **Build artifacts** | No build artifacts reference client_id or project structure. Next.js builds are stateless. | None required. Fresh build after schema migration. |
|
||||
|
||||
**Conclusion:** All data is test data. Hard migration is acceptable. No runtime state inventory concerns blocking execution.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Incomplete FK Migration Across Tables
|
||||
**What goes wrong:** Missing one table's FK migration (e.g., forgetting to update `time_entries.client_id` → `project_id`), causing orphaned records or failed queries in admin workspace when drilling into a specific project.
|
||||
|
||||
**Why it happens:** 6 tables need updating. Easy to miss one if checklist is informal.
|
||||
|
||||
**How to avoid:**
|
||||
1. List all 6 tables in schema migration PR title: `phases, payments, quote_items, time_entries, documents, notes`.
|
||||
2. After `drizzle-kit push`, verify each table with `\dt public.*` in psql and spot-check that old client_id FK is gone, new project_id FK is present.
|
||||
3. In planner, create a Wave 0 schema-only task + verification subtasks for each table.
|
||||
|
||||
**Warning signs:**
|
||||
- `getProjectFullDetail()` returns empty phases/payments even though they exist in the DB.
|
||||
- Timer actions fail with "project_id not found" FK violation on insert.
|
||||
|
||||
### Pitfall 2: Client Middleware Resolution Order (Slug vs. Token)
|
||||
**What goes wrong:** Middleware checks token first and finds a match before trying slug, so `/c/mario-rossi` is treated as an invalid token and returns 404 even though the slug exists.
|
||||
|
||||
**Why it happens:** Easy to reverse the order in `validate-slug` or middleware logic.
|
||||
|
||||
**How to avoid:**
|
||||
1. Middleware must call `/api/internal/validate-slug?slug=...` FIRST.
|
||||
2. Only if slug lookup fails (404 from API), fall back to existing token validation.
|
||||
3. Document the order in code comment.
|
||||
|
||||
**Warning signs:**
|
||||
- Slug links return 404 even though slug is in the DB and client can access via token link.
|
||||
- Creating a new slug for an existing client breaks the old token link (should not).
|
||||
|
||||
### Pitfall 3: Admin Workspace Queries Not Scoped to Current Project
|
||||
**What goes wrong:** `getProjectFullDetail()` accidentally returns data from multiple projects or from the wrong project due to missing WHERE clause on project_id.
|
||||
|
||||
**Why it happens:** Copy-pasting from `getClientFullDetail()` and forgetting to update the WHERE conditions.
|
||||
|
||||
**How to avoid:**
|
||||
1. After writing `getProjectFullDetail()`, trace through each query: phases, tasks, deliverables, payments, documents, notes, comments, quote_items.
|
||||
2. Verify each has `.where(eq(table.project_id, projectId))` or is a child query that's already filtered.
|
||||
3. Add a comment above each query stating what it filters on.
|
||||
|
||||
**Warning signs:**
|
||||
- Workspace shows phases/tasks from sibling projects.
|
||||
- Clicking into a project workspace, then switching projects, shows the same data.
|
||||
|
||||
### Pitfall 4: Timer Still Checks for Global "Only One Active" Instead of Per-Project
|
||||
**What goes wrong:** Admin starts timer for Project A, then clicks timer for Project B, and Project A's timer is stopped. User expects independent timers.
|
||||
|
||||
**Why it happens:** Current `startTimer()` stops ALL running sessions. Must be updated to allow one timer per project (or clarify that "global only one timer" is the design).
|
||||
|
||||
**How to avoid:**
|
||||
1. Decision: Are timers per-project OR global (only one active per admin account)?
|
||||
2. From CONTEXT (D-15): "Only one timer active at a time (scoped to project now)" suggests global is intended (one active total).
|
||||
3. Keep current logic but verify: when admin starts project B's timer, project A's should auto-stop.
|
||||
4. Add test: start timer A, start timer B, verify A is stopped and B is running.
|
||||
|
||||
**Warning signs:**
|
||||
- Two projects have active timers simultaneously (duration_seconds null on both).
|
||||
|
||||
### Pitfall 5: Client Slug Field Validation Too Strict or Too Loose
|
||||
**What goes wrong:** Slug regex rejects valid inputs (e.g., "mario-rossi-2") or accepts invalid ones (e.g., spaces, special chars).
|
||||
|
||||
**Why it happens:** Regex written without testing against edge cases.
|
||||
|
||||
**How to avoid:**
|
||||
1. Define slug rule: lowercase alphanumeric + hyphens only, 3-50 chars, must be unique.
|
||||
2. Zod schema: `slug: z.string().regex(/^[a-z0-9-]{3,50}$/).optional().or(z.null())`
|
||||
3. Test form submission with: "mario-rossi", "mario-rossi-2", "MARIO" (should fail), "m--r" (ok?), "m" (too short), "m " (space, should fail).
|
||||
|
||||
**Warning signs:**
|
||||
- Admin can't set a slug they expect to work (form rejects it).
|
||||
- Middleware crashes on malformed slug from DB.
|
||||
|
||||
### Pitfall 6: "Settings" Table Key Mismatches in Code
|
||||
**What goes wrong:** Code reads `settings.value WHERE key = 'hourly_rate'` but admin wrote it as `'target_hourly_rate'`, returning null and falling back to a hardcoded default.
|
||||
|
||||
**Why it happens:** Settings keys are strings with no schema enforcement. Easy to have typos or inconsistent naming.
|
||||
|
||||
**How to avoid:**
|
||||
1. Define an enum or constant for all settings keys:
|
||||
```typescript
|
||||
const SETTINGS_KEYS = {
|
||||
TARGET_HOURLY_RATE: 'target_hourly_rate',
|
||||
} as const;
|
||||
```
|
||||
2. Always read via constant: `getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE)`.
|
||||
3. Admin form submits value for this constant key only.
|
||||
|
||||
**Warning signs:**
|
||||
- Analytics always shows a hardcoded rate (default value) instead of what admin set.
|
||||
- Changing the setting has no effect.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from the existing codebase and applied to Phase 04 context:
|
||||
|
||||
### Database Schema Refactor (Drizzle)
|
||||
```typescript
|
||||
// src/db/schema.ts (NEW projects table + updated FKs)
|
||||
|
||||
// Clients now has slug field (optional, unique)
|
||||
export const clients = pgTable("clients", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
brand_name: text("brand_name").notNull(),
|
||||
brief: text("brief").notNull(),
|
||||
token: text("token").notNull().unique().$defaultFn(() => nanoid()),
|
||||
slug: text("slug").unique(), // NEW — optional, unique, URL-safe
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// NEW projects table
|
||||
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"), // denormalized
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// Phases: FK now points to projects, not clients
|
||||
export const phases = pgTable("phases", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id
|
||||
title: text("title").notNull(),
|
||||
sort_order: integer("sort_order").notNull().default(0),
|
||||
status: text("status").notNull().default("upcoming"),
|
||||
});
|
||||
|
||||
// Payments: FK now points to projects, not clients
|
||||
export const payments = pgTable("payments", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id
|
||||
label: text("label").notNull(),
|
||||
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
|
||||
status: text("status").notNull().default("da_saldare"),
|
||||
paid_at: timestamp("paid_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
// Quote items: FK now points to projects, not clients
|
||||
export const quote_items = pgTable("quote_items", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id
|
||||
service_id: text("service_id").references(() => service_catalog.id, { onDelete: "restrict" }),
|
||||
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
|
||||
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
|
||||
subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(),
|
||||
custom_label: text("custom_label"),
|
||||
});
|
||||
|
||||
// Time entries: FK now points to projects, not clients
|
||||
export const time_entries = pgTable("time_entries", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id
|
||||
started_at: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
ended_at: timestamp("ended_at", { withTimezone: true }),
|
||||
duration_seconds: integer("duration_seconds"),
|
||||
});
|
||||
|
||||
// Documents: FK now points to projects, not clients
|
||||
export const documents = pgTable("documents", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id
|
||||
label: text("label").notNull(),
|
||||
url: text("url").notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// Notes: FK now points to projects, not clients
|
||||
export const notes = pgTable("notes", {
|
||||
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||
project_id: text("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }), // CHANGED from client_id
|
||||
body: text("body").notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// NEW settings table for global admin settings (e.g., target hourly rate)
|
||||
export const settings = pgTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// Relations updated
|
||||
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),
|
||||
}));
|
||||
```
|
||||
|
||||
### Admin Query Layer — getProjectFullDetail
|
||||
```typescript
|
||||
// src/lib/admin-queries.ts (NEW function, following getClientFullDetail pattern)
|
||||
|
||||
export type ProjectFullDetail = {
|
||||
project: Project & { client: Client };
|
||||
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||
payments: Payment[];
|
||||
documents: Document[];
|
||||
notes: Note[];
|
||||
comments: Comment[];
|
||||
quoteItems: QuoteItemWithLabel[];
|
||||
activeServices: ServiceCatalog[];
|
||||
totalTrackedSeconds: number; // for profitability calc
|
||||
};
|
||||
|
||||
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
|
||||
const projectRows = await db
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(eq(projects.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (projectRows.length === 0) return null;
|
||||
const project = projectRows[0];
|
||||
|
||||
// Fetch parent client
|
||||
const clientRows = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, project.client_id))
|
||||
.limit(1);
|
||||
const client = clientRows[0] || null;
|
||||
|
||||
// Fetch all phases for 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);
|
||||
|
||||
// Fetch 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);
|
||||
|
||||
// Fetch deliverables scoped to this project's tasks
|
||||
const deliverablesRows = taskIds.length === 0
|
||||
? []
|
||||
: await db
|
||||
.select()
|
||||
.from(deliverables)
|
||||
.where(inArray(deliverables.task_id, taskIds));
|
||||
|
||||
// Payments for this PROJECT (not client)
|
||||
const paymentsRows = await db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(eq(payments.project_id, id));
|
||||
|
||||
// Documents for this PROJECT (not client)
|
||||
const documentsRows = await db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.project_id, id))
|
||||
.orderBy(asc(documents.created_at));
|
||||
|
||||
// Notes for this PROJECT (not client)
|
||||
const notesRows = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(eq(notes.project_id, id))
|
||||
.orderBy(asc(notes.created_at));
|
||||
|
||||
// Comments (polymorphic on entity_id) — collect all tasks, deliverables, and the project itself
|
||||
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));
|
||||
|
||||
// Quote items for this PROJECT (not client)
|
||||
const quoteItemRows: QuoteItemWithLabel[] = await 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,
|
||||
})
|
||||
.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));
|
||||
|
||||
// Active services (unchanged)
|
||||
const activeServiceRows = await db
|
||||
.select()
|
||||
.from(service_catalog)
|
||||
.where(eq(service_catalog.active, true))
|
||||
.orderBy(asc(service_catalog.name));
|
||||
|
||||
// Total tracked seconds for this PROJECT (for profitability calc)
|
||||
const totalRes = await db
|
||||
.select({
|
||||
total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)`,
|
||||
})
|
||||
.from(time_entries)
|
||||
.where(eq(time_entries.project_id, id));
|
||||
|
||||
const totalTrackedSeconds = totalRes[0] ? parseInt(totalRes[0].total) : 0;
|
||||
|
||||
// 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,
|
||||
activeServices: activeServiceRows,
|
||||
totalTrackedSeconds,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Slug Resolution in Middleware
|
||||
```typescript
|
||||
// src/proxy.ts (UPDATED for slug-first resolution)
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
|
||||
// ── ADMIN GUARD ──────────────────────────────────────────────────────────
|
||||
if (pathname.startsWith("/admin")) {
|
||||
if (pathname === "/admin/login" || pathname.startsWith("/api/auth")) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
});
|
||||
if (!token) {
|
||||
const loginUrl = new URL("/admin/login", request.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// ── CLIENT TOKEN/SLUG GUARD ─────────────────────────────────────────────
|
||||
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 — call internal API to resolve slug → client
|
||||
const validateUrl = new URL(
|
||||
`/api/internal/validate-slug?slug=${encodeURIComponent(slugOrToken)}`,
|
||||
request.url
|
||||
);
|
||||
let res = await fetch(validateUrl.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));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/admin/:path*", "/c/:path*"],
|
||||
};
|
||||
```
|
||||
|
||||
### New Internal API Route for Slug Validation
|
||||
```typescript
|
||||
// src/app/api/internal/validate-slug/route.ts (NEW)
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/db";
|
||||
import { clients } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
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 });
|
||||
}
|
||||
```
|
||||
|
||||
### Timer Actions Refactored for Project Scope
|
||||
```typescript
|
||||
// src/app/admin/timer-actions.ts (UPDATED for project_id)
|
||||
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { db } from "@/db";
|
||||
import { time_entries } from "@/db/schema";
|
||||
import { eq, isNull } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export async function startTimer(projectId: string): Promise<{ entryId: string }> {
|
||||
// Stop any currently running session (still global: only one timer active per admin)
|
||||
const running = await db
|
||||
.select({ id: time_entries.id })
|
||||
.from(time_entries)
|
||||
.where(isNull(time_entries.ended_at));
|
||||
|
||||
for (const r of running) {
|
||||
const now = new Date();
|
||||
const entry = await db
|
||||
.select({ started_at: time_entries.started_at })
|
||||
.from(time_entries)
|
||||
.where(eq(time_entries.id, r.id))
|
||||
.limit(1);
|
||||
if (entry[0]) {
|
||||
const secs = Math.round((now.getTime() - new Date(entry[0].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)
|
||||
const id = nanoid();
|
||||
await db.insert(time_entries).values({ id, project_id: projectId });
|
||||
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");
|
||||
}
|
||||
```
|
||||
|
||||
### Profitability Analytics Card (Component Pattern)
|
||||
```typescript
|
||||
// src/components/admin/ProfitabilityCard.tsx (NEW)
|
||||
|
||||
import { Project } from "@/db/schema";
|
||||
|
||||
export function ProfitabilityCard({
|
||||
project,
|
||||
totalTrackedSeconds,
|
||||
targetHourlyRate,
|
||||
}: {
|
||||
project: Project & { accepted_total: string };
|
||||
totalTrackedSeconds: number;
|
||||
targetHourlyRate: number; // e.g., 50 €/h
|
||||
}) {
|
||||
const hours = totalTrackedSeconds / 3600;
|
||||
const acceptedTotal = parseFloat(project.accepted_total || "0");
|
||||
|
||||
// €/h real = accepted_total ÷ hours
|
||||
const realHourlyRate = hours > 0 ? acceptedTotal / hours : 0;
|
||||
|
||||
// Ideal cost = target_rate × hours
|
||||
const idealCost = targetHourlyRate * hours;
|
||||
|
||||
// Delta = profit/loss
|
||||
const delta = acceptedTotal - 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]">€{acceptedTotal.toFixed(2)}</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.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</span>
|
||||
<span className="font-mono font-semibold text-[#1a1a1a]">€{idealCost.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Single project per client | Multi-project per client | Phase 04 | Clients can now manage multiple independent brands/projects with separate workspaces and profitability tracking |
|
||||
| Client as primary work container | Project as primary work container | Phase 04 | Admin workspace structure mirrors project, not client. Client API queries projects, not client directly |
|
||||
| Timer at client level | Timer at project level | Phase 04 | Hours tracked independently per project, enabling per-project profitability analysis |
|
||||
| Token-only client links | Token + slug client links | Phase 04 | More user-friendly URLs (e.g., /c/mario-rossi instead of /c/xyzabc123). Token remains as fallback. |
|
||||
| Hardcoded profitability target | Global settings table for target rate | Phase 04 | Admin can adjust target hourly rate from UI without changing code. Flexible for future settings. |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- Client-level `accepted_total` field remains in schema for backward compat but becomes unused; project-level accepted_total is the source of truth.
|
||||
- Old client detail workspace layout is cloned for project detail; both exist but admin only uses project workspace going forward.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | "Only one timer active globally (per admin)" is the design intent, not per-project independence | Locked Decision D-15, Timer Pitfall | If per-project timers are expected, timer-actions refactor is wrong; need concurrent timer support instead of auto-stop logic. Clarify with user before implementing. |
|
||||
| A2 | Hard migration (drop/recreate tables) is acceptable because all data is test data | Runtime State Inventory | If there are production customer records, hard migration will cause data loss. Verify no production data exists before schema push. |
|
||||
| A3 | `settings` table with key-value structure is acceptable over env vars | Claude's Discretion | If user later requires non-DB storage for settings (e.g., Redis cache, config file), table approach is still compatible; no blocking constraint. |
|
||||
| A4 | Slug-first middleware resolution (slug lookup before token fallback) is the intended order | Locked Decision D-06, D-08 | If token validation should be checked first (for legacy reasons), middleware order is reversed. Test both slug and token paths after implementation. |
|
||||
| A5 | `comments` table remains polymorphic (entity_id) and does NOT move to project_id | Locked Decision D-02 | If comments should be scoped per-project (unlikely), add project_id FK and update all comment queries. Currently comments are global per entity, which is correct. |
|
||||
|
||||
**If this table is empty:** Not applicable — all claims verified against CONTEXT.md locked decisions.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Profitability analytics default target rate?**
|
||||
- What we know: User mentioned 50€/h as an example; needs global setting.
|
||||
- What's unclear: Should there be a fallback default (e.g., 50€/h) if settings table is empty, or should admin be forced to set it?
|
||||
- Recommendation: Initialize settings table with `target_hourly_rate = '50.00'` as default during first project workspace load. Admin can override from /admin/impostazioni.
|
||||
|
||||
2. **Multi-project client dashboard routing — how should it work?**
|
||||
- What we know: 1 project = direct view, 2+ projects = tabs.
|
||||
- What's unclear: Should the URL path for client dashboard change? Stay `/c/[token]` for all projects, or add `/c/[token]/projects/[id]`?
|
||||
- Recommendation: Keep URL `/c/[token]` (or `/c/[slug]`), let server-side logic choose whether to render single project view or tabs. Tabs can have internal navigation (e.g., URL hash or search param) to switch between projects without page reload.
|
||||
|
||||
3. **Analytics page aggregation scope?**
|
||||
- What we know: D-22 says "Statistiche page shows aggregated profitability for all projects + breakdown per client."
|
||||
- What's unclear: Should /admin/analytics show global profitability (sum of all projects for all clients) or be filterable by client/date range?
|
||||
- Recommendation: Start simple: global profitability table with columns: Client, Projects, Total Hours, Total Revenue, Avg €/h, Profit/Loss. Filter by client optional (defer to Phase 4.1 if needed).
|
||||
|
||||
4. **Project archival behavior?**
|
||||
- What we know: `projects.archived` field exists; D-13 doesn't mention archival UI.
|
||||
- What's unclear: Should archived projects be hidden from /admin/projects list or filtered to separate tab?
|
||||
- Recommendation: Hide archived projects by default (like clients list), add "Mostra archiviati" toggle link. Archival doesn't delete data, just hides it.
|
||||
|
||||
---
|
||||
|
||||
## Environment Availability
|
||||
|
||||
(Phase 04 is code/DB changes only — no external dependencies.)
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Neon Postgres | Schema migration + queries | ✓ | Active (from Phase 1) | — |
|
||||
| Next.js API routes | Slug validation route | ✓ | 16 (installed) | — |
|
||||
| Drizzle ORM | Schema migration + query building | ✓ | Current (installed) | — |
|
||||
| Auth.js v4 | Admin session check (existing) | ✓ | Current (installed) | — |
|
||||
| shadcn/ui Tabs | Multi-project dashboard tabs | ✓ | Current (installed) | Could fall back to native `<select>` dropdown, but Tabs is already in use |
|
||||
|
||||
**Missing dependencies with no fallback:** None.
|
||||
|
||||
**Missing dependencies with fallback:** Tabs component could be replaced with a `<select>` dropdown if shadcn/ui is ever removed, but this is a UI detail, not a blocker.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- **CONTEXT.md** (Phase 04 decisions) — All 22 locked decisions directly from user's discuss phase. D-01 through D-22, Claude's Discretion, Deferred Ideas sections.
|
||||
- **Codebase inspection** — Verified existing schema (schema.ts), admin queries (admin-queries.ts), middleware pattern (proxy.ts), timer actions (timer-actions.ts), client view pattern (client-view.ts), admin workspace layout (clients/[id]/page.tsx).
|
||||
- **REQUIREMENTS.md** — PROJ-01 through PROJ-05 mapped to implementation guidance.
|
||||
- **ROADMAP.md** — Phase 04 goal and success criteria verified.
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- **Next.js 16 App Router patterns** — Edge middleware, server actions, API routes all verified against existing project structure.
|
||||
- **Drizzle ORM query patterns** — Relations, WHERE scoping, parallel queries all verified against Phase 1–3 implementation (getAllClientsWithPayments, getClientFullDetail, timer-actions).
|
||||
- **shadcn/ui Tabs component** — Already in use in admin workspace (clients/[id]/page.tsx); no additional research needed.
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- None — all findings tied to locked decisions and verified codebase patterns.
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- **Standard stack:** HIGH — all libraries already installed and used; no new dependencies.
|
||||
- **Architecture:** HIGH — locked decisions in CONTEXT.md eliminate discretion; patterns clone from existing workspaces.
|
||||
- **Pitfalls:** HIGH — identified from common FK migration mistakes, middleware routing, query scoping issues observed in similar refactors.
|
||||
- **Environment:** HIGH — no external dependencies; Neon, Next.js, Drizzle all active and verified.
|
||||
|
||||
**Research date:** 2026-05-21
|
||||
**Valid until:** 2026-06-04 (14 days — architecture stable, no fast-moving libraries)
|
||||
|
||||
**Next phase:** `/gsd-plan-phase 04` will create 4–5 plans for vertical-slice execution (Schema Wave 0 → Core Routing → Admin UI → Client UI + Analytics).
|
||||
Reference in New Issue
Block a user