docs(11): create phase plan — 4 plans, 4 waves

Catalog Database-View UX & Legacy Consolidation (OFFER-07/08/09/10/13)
- 11-01: tags table + polymorphic junction + additive legacy migration
- 11-02: getAllServices tag join + inline-edit/tag/quick-add server actions
- 11-03: EditableCell + TagMultiSelect components
- 11-04: database-view ServiceTable + client-side search

Verified by gsd-plan-checker (VERIFICATION PASSED).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 15:07:17 +02:00
parent 03898f2a59
commit d1b1047368
5 changed files with 2016 additions and 176 deletions
+56 -176
View File
@@ -1,215 +1,95 @@
--- ---
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v2.0 milestone: v2.1
milestone_name: Business Operations Suite milestone_name: milestone
status: executing status: executing
last_updated: "2026-06-11T19:00:00Z" stopped_at: Phase 11 context gathered
last_activity: 2026-06-11 last_updated: "2026-06-13T13:00:38.635Z"
last_activity: 2026-06-13 -- Phase 11 planning complete
progress: progress:
total_phases: 5 total_phases: 11
completed_phases: 4 completed_phases: 8
total_plans: 11 total_plans: 37
completed_plans: 11 completed_plans: 30
percent: 80 percent: 81
--- ---
# Project State # Project State
## Project Reference ## Project Reference
See: .planning/PROJECT.md (updated 2026-05-09) See: .planning/PROJECT.md (updated 2026-06-13)
**Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti. **Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
**Current focus:** Phase 11 — Catalog Database-View UX & Legacy Consolidation
## Current Position ## Current Position
Phase: 10-crm-pipeline-activity-logging (redo complete, deployed) Phase: 11 of 17 (Catalog Database-View UX & Legacy Consolidation)
Plans: 07 (complete) | 08 (complete) | 09-01..03 (complete) | 10-01..03 (complete, redeployed 2026-06-11) Plan: — (not yet planned)
Status: Phase 7 ✓ | Phase 8 ✓ | Phase 9 ✓ | Phase 10 ✓ (staged redo after rollback) Status: Ready to execute
Last activity: 2026-06-11T19:00:00Z — Phase 10 staged redo deployed; production DB migrations 0001/0003/0004/0005 applied Last activity: 2026-06-13 -- Phase 11 planning complete
## What Was Built (Phase 10 — CRM Pipeline & Activity Logging, redo) Progress: [░░░░░░░░░░] 0%
### Root cause of the original Phase 10 production crash (resolved) ## Performance Metrics
Production Postgres (Coolify container) had **no migrations applied after 0000**`services`, `leads`, `offer_phases`, `offer_phase_services`, `quotes` did not exist. Phase 7/8/9 summaries said "migration pending connectivity" and were never followed up. Any page querying those tables 500'd: catalog (services), quote builder + /quote/[token] (quotes), and after Phase 10 deploy also dashboard (FollowUpWidget → leads). The Phase 10 rollback only masked the dashboard symptom. **Velocity:**
**Fix (2026-06-11):** migrations 0001+0003+0004+0005 applied atomically (single transaction, additive-only, verified zero DROP/TRUNCATE) via SSH → docker exec psql on container `xwkk0040w0kk0gsgcgog8owk`, db `clienthub`. Protected rows verified intact post-migration (4 clients, 5 projects, 13 payments, 6 phases). `/quote/[token]` 500 → 404 (correct behavior) confirmed. - Total plans completed: 0 (v2.1)
- Average duration: —
- Total execution time: —
### Delivered (redo commits 5aa6614 + 008a434) **By Phase:**
- **Stage A** (`5aa6614`): deps `@radix-ui/react-dialog`, `date-fns` + shared `ui/dialog.tsx`, `ui/form.tsx` | Phase | Plans | Total | Avg/Plan |
- **Stage B**: production DB schema aligned (see above) — script `scripts/push-phase10-migration.ts` for future reuse |-------|-------|-------|----------|
- **Stage C** (`008a434`): schema.ts CRM tables (leads expansion, activities, reminders + relations), lead-service/lead-validators, `/admin/leads` list+detail+actions, LeadTable/LeadDetail/LeadForm, LogActivityModal, SendQuoteModal, FollowUpWidget on dashboard, sidebar link | - | - | - | - |
## What Was Built (Phase 9 — Quote Builder UI & Public Pages) **Recent Trend:**
### 09-01: Quote Validators and Service Layer ✓ - Last 5 plans: —
- Trend: —
- **Validators:** Zod schemas for quote creation (client_id, offer_micro_id, accepted_total) *Updated after each plan completion*
- **Service Layer:** Public/admin query separation; token-gated access without exposing line items
- **Immutability Guard:** isQuoteAccepted() prevents double-accept of quotes
- **Migration:** 0004_phase-9-quotes-validators.sql (additive-only, backward compatible)
- **Status:** DONE — Schema and validators tested, ready for UI integration
### 09-02: Admin Quote Builder UI ✓ ## Accumulated Context
- **Components:** 4 new components in src/components/admin/quotes/ ### Decisions
- **QuoteBuilderForm.tsx** (140 lines): Two-column form (left: inputs, right: preview) with full state management
- **OfferSelector.tsx** (25 lines): Grouped dropdown for macro/micro selection
- **PriceOverrideInput.tsx** (45 lines): Price input with visual validation feedback
- **QuotePreview.tsx** (45 lines): Live preview showing offer details and calculated total
- **Server Actions:** createQuote() in src/lib/quote-actions.ts
- Validates client and offer existence in DB
- Generates nanoid(21) token (collision-free, ~122-bit entropy)
- Returns public `/quote/[token]` link for sharing
- Atomic DB transaction: quote header insert only (items follow in Phase 9-04)
- **Page:** /admin/quotes/new with form rendering and data population
- **Query:** getAllOfferMacrosWithMicros() for form dropdown population
- **Status:** DONE — Page builds, form renders, server action tested. Ready for public page (Phase 9-03)
### 09-03: Public Quote Page with Token-Gated Access & Rate Limiting ✓ Decisions are logged in PROJECT.md Key Decisions table.
Recent decisions affecting current work:
- **Rate Limiting:** Enhanced src/proxy.ts with rate limit check for /quote/[token] routes - v2.1 roadmap: Offer Studio (Phases 11-15) sequenced before Proposal AI (Phases 16-17) — clean/fast data UX before the AI builder
- 3 views per minute per IP (via existing rateLimit utility) - Phase 11 bundles catalog database-view UX (OFFER-07..10) with legacy consolidation (OFFER-13) since the new UX should be built on a single unified `services` table, not on top of legacy `service_catalog`/`offer_services`
- Returns 429 Too Many Requests when limit exceeded - Phase 13 (Workspace — Servizi Attivi) is independent of Phases 11/12 — can execute in parallel order if useful, but numbered after for narrative flow
- IP extraction from x-forwarded-for header (Docker-aware) - Phase 15 (Dashboard Revenue Stats / DASH-11) is isolated and BLOCKED on user-provided mockup; no other phase depends on it — can be deferred/skipped without blocking Phase 16/17
- **Multistep Wizard Components:** 4 components totaling 495 lines - Phase 16/17 split: schema/automation (payment link field + auto-provisioning) first, then AI builder + public page redesign + email — keeps the AI-dependent work last
- **QuoteMultistep.tsx** (121 lines): Parent managing step state (1-3) with visual progress indicator
- **QuoteStep1Overview.tsx** (70 lines): Read-only offer name, total price (EUR), phase summary
- **QuoteStep2Selection.tsx** (95 lines): Phase/service listing (read-only MVP) with total recap
- **QuoteStep3Summary.tsx** (210 lines): Summary + optional email/notes form, Accept/Reject buttons
- **Page Structure:**
- **layout.tsx** (28 lines): Public layout with gradient background, no auth header
- **page.tsx** (80 lines): Token validation (21-char nanoid format), quote state detection (draft/accepted/rejected)
- **actions.ts** (108 lines): acceptQuote() and rejectQuote() server actions with immutability enforcement
- **Security:** quote_items never exposed; only accepted_total and phase summary visible to client
- **Status:** DONE — Route visible in build output, 3 commits, all tests passing (npm run build: 0 errors)
## What Was Built (Phase 8 — Quote Builder Schema Foundation) ✓ ### Pending Todos
### 08-01: Offer Phases & Quote Tables Schema ✓ [From .planning/todos/pending/ — ideas captured during sessions]
- **Schema:** 4 new tables (offer_phases, offer_phase_services, quotes, leads) + 7 columns added to existing tables None yet.
- **offer_phases:** Hierarchical breakdown of offer_micros (Discovery → Strategy → Execution phases)
- **offer_phase_services:** Junction linking phases to unified services table (Phase 7)
- **quotes:** Separate quote headers from line items with token-based public access
- **Extensions:** quote_items (quote_id, offer_micro_id, offer_phase_id), projects (offer_id, created_from_lead_id), phases (offer_phase_id)
- **TypeScript:** OfferPhase, OfferPhaseService, Quote, Lead types exported
- **Drizzle Relations:** 6 new relations for proper ORM traversal
- **Status:** Schema complete, migration script ready, validation checks implemented
- **Data Safety:** Additive-only migration (no drops, no truncates) — fully reversible
### 08-02: Migration & Validation Scripts ✓ ### Blockers/Concerns
- **Migration:** src/db/migrations/0003_offer_phases_quote_templates.sql (89 lines, additive-only) - **Phase 15 (DASH-11)**: Blocked pending dashboard mockup from user. Do not start until mockup provided; safe to skip in execution order without impacting Phase 16/17.
- **Indexes:** 11 created for performance (micro_id, service_id, token, client_id, status, etc.) - **Migrations**: Per CLAUDE.md, every phase touching schema (11 legacy consolidation, 13 recurring-revenue tracking, 16 payment link + provisioning) MUST have its migration applied to prod via SSH+docker exec BEFORE pushing dependent code.
- **Validation:** scripts/validate-phase8-migration.ts with 10 checks (all 4 tables + 6 columns verified)
- **Status:** READY FOR DEPLOYMENT when Postgres is reachable
### 08-03: Query Layer for Quote Builder ✓
- **Query Functions:** 5 new async functions added to admin-queries.ts
- getOfferPhases(microId) — get all phases for an offer micro
- getOfferPhaseServices(phaseId) — get all services assigned to a phase
- getQuoteById(quoteId) — full quote header by ID
- getQuoteByToken(token) — quote by public token (for /quote/[token])
- getQuotesByClient(clientId) — all quotes for a client
- **Build Status:** npm run build passes with zero TypeScript errors
- **Ready for:** Phase 9 quote builder UI, public quote pages
## What Was Built (Phase 7 — v2.0)
### 07-01: Unified Service Catalog (Expand Phase) ✓
- **Schema:** `services` table with migrated_from/migrated_id audit trail for rollback safety
- **TypeScript:** Service and NewService types exported
- **Backfill:** Idempotent migration script (21 service_catalog rows + 35 offer_services rows → services table)
- **Collision Resolution:** ' (Offer)' suffix prevents silent overwrites of duplicate names
- **Validation:** 6-check suite proves zero data loss (row counts, FK integrity, orphaned references, net-new services count)
- **Status:** Code complete; database migration pending connectivity (schema, scripts ready to apply)
- **Data Safety:** Legacy tables (service_catalog, offer_services) untouched — rollback possible by not using services table
### 07-02: Admin Catalog CRUD + Quote Builder Rewire ✓
- **Query Layer:** getAllServices() and activeServices queries updated to read from unified `services` table
- **Admin CRUD:** `/admin/catalog` list/add/edit/soft-delete fully operational on `services` table with category field
- **Service Form:** Added optional category input field for new/edited services
- **Service Table:** Category column rendering; net-new services tracked with migrated_from=null
- **Quote Builder:** QuoteTab service picker reads from `services` via activeServices
- **Backward Compatibility:** Historical quote_items → service_catalog join preserved for existing quote item labels
- **Type Safety:** All components (ServiceTable, ServiceForm, QuoteTab) typed against Service instead of ServiceCatalog
- **Status:** Code complete, TypeScript verified (npm run build passes), manual testing documented
- **ROADMAP Success Criteria:** #3 (/admin/catalog operational on services) and #4 (old catalog queries explicit) satisfied
## What Was Built (Phase 5)
- **05-01**: Schema migration — 5 nuove tabelle offer in DB + tipi TypeScript
- **05-02**: `/admin/offers` CRUD — macro, micro, servizi + assegnazione checkbox
- **05-03**: OffersTab nel workspace progetto + `getClientActiveOffers()` + sezione Offerte Attive nel dettaglio cliente
- **05-04**: `OffersSection` nella dashboard cliente (solo `public_name`) + `/admin/forecast` 12 mesi
## What Was Built (Phase 4)
- **04-00**: Infra — route /c/ → /client/, Dockerfile, deploy Gitea → Coolify
- **04-01**: DB schema migration — projects, settings, FK pivot
- **04-02**: Admin projects list + client detail project cards
- **04-03**: Project workspace + timer analytics + settings page
- **04-04**: Slug resolution + multi-project client dashboard + slug edit
- **04-05/06**: Post-deploy bug fixes
- **04-07**: Debug code removed, public page restored
- **security**: Full hardening — auth guards, rate limiting, security headers
- **payments**: Init payments + split payment in project workspace
## Production State
- App: hub.iamcavalli.net (Coolify, Hetzner)
- DB: Postgres self-hosted on Coolify
- Auth: Auth.js (admin) + token middleware (clients)
- Routes: /admin/* (admin), /client/[token]/* (clients)
## Decisions (All Phases)
- `clients.token` è campo separato (non la PK) — rotazionabile via single UPDATE
- `clients.accepted_total` denormalizzato — client API non tocca mai `quote_items`
- `deliverables.approved_at` immutabile — audit trail dal giorno uno
- Edge middleware (`proxy.ts`) usa fetch() a route interna — postgres-js non può girare nell'Edge runtime
- Tailwind v4 auto-detection allargata — aggiunto `@source not` per escludere `.01_projects/` e `.claude/`
- Authelia va solo davanti ad /admin — /client/[token] deve bypassare per accesso via link token
## Deferred Items ## Deferred Items
| Category | Item | Status | Items acknowledged and carried forward from previous milestone close:
|----------|------|--------|
| v2 | Claude AI onboarding (CLAUDE-01, CLAUDE-02, CLAUDE-03) | Backlog | | Category | Item | Status | Deferred At |
| Infrastructure | Authelia SSO davanti ad /admin | After Phase 4 (now ready) | |----------|------|--------|-------------|
| Infrastructure | Higgsfield clone deploy (nuovo progetto Coolify) | Pending | | v2 | OFFER-14 — Sezioni analitiche stile Notion (psicologia/rating/performance) | Backlog | v2.1 kickoff |
| SEO | WordPress auto-publish articoli | Mentioned, not planned | | v2 | AUTH-OTP-01 — Accesso dashboard cliente via OTP email | Design ready, deferred | v2.1 kickoff |
| v2 | ARCH-01 — Split modulo "compartimento stagno" in deploy separato | Backlog (only if module grows) | v2.1 kickoff |
## Session Continuity ## Session Continuity
Last session: 2026-06-11T19:00:00Z Last session: 2026-06-13T10:02:08.473Z
Phase 10 staged redo complete: production DB migrations applied (0001/0003/0004/0005), CRM module deployed. Stopped at: Phase 11 context gathered
Dangling work recovered from `phase10-wip` branch (anchor of reflog commit 8e2752a — branch can be deleted once redo verified in production). Resume file: .planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md
Lesson recorded: migrations are applied manually in this project — every deploy touching schema MUST apply the migration to prod BEFORE pushing code.
## Operator Next Steps
### Immediate
- Verify in browser (authenticated): /admin (FollowUpWidget), /admin/leads, /admin/catalog, /admin/quotes/new
- Re-run `/gsd-plan-phase 11` and `/gsd-plan-phase 12` (planning folders never committed, must be regenerated)
- Optionally cherry-pick sidebar "App shortcuts" dangling commit 5d75752 if still wanted
### Phase 10 (Concurrent Activities)
- Email integration (Resend) for quote acceptance confirmation
- CRM leads table implementation (currently placeholder)
- Activity logging for quote events (viewed, accepted, rejected)
- Auto-provisioning webhook listener (triggers project creation on acceptance)
### Phase 11 (Auto-Provisioning)
- Consume quote acceptance event
- Automatically create project phases from offer_phases structure
- Provision services based on accepted quote items
@@ -0,0 +1,462 @@
---
phase: 11-catalog-database-view-ux-legacy-consolidation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- src/db/schema.ts
- src/db/migrations/0002_add_tags_table.sql
- src/db/migrations/meta/_journal.json
- scripts/push-11-tags-migration.ts
- scripts/migrate-tags.ts
- scripts/validate-tags-migration.ts
autonomous: true
requirements: [OFFER-13]
must_haves:
truths:
- "La tabella `tags` esiste nel DB locale con junction polimorfica (entity_type/entity_id)"
- "Le righe storiche di service_catalog e offer_services sono confluite in services (migrated_from popolato), verificabile via conteggio righe"
- "Le righe migrate da offer_services hanno il tag 'Offerta' assegnato in tags"
- "Nessuna riga di clients/projects/payments/phases/service_catalog/offer_services/offer_micro_services è stata droppata o troncata"
artifacts:
- path: "src/db/schema.ts"
provides: "tags pgTable (id, entity_type, entity_id, name, created_at) + Tag/NewTag types"
contains: "export const tags = pgTable(\"tags\""
- path: "src/db/migrations/0002_add_tags_table.sql"
provides: "Drizzle-generated CREATE TABLE tags migration"
contains: "CREATE TABLE"
- path: "scripts/push-11-tags-migration.ts"
provides: "Idempotent local migration runner for tags table"
contains: "CREATE TABLE IF NOT EXISTS"
- path: "scripts/migrate-tags.ts"
provides: "Assigns 'Offerta' tag to services rows where migrated_from='offer_services'"
contains: "Offerta"
- path: "scripts/validate-tags-migration.ts"
provides: "Row-count + orphan validation for tags + services consolidation (OFFER-13)"
contains: "ALL CHECKS PASSED"
key_links:
- from: "scripts/migrate-tags.ts"
to: "src/db/schema.ts (tags, services)"
via: "drizzle insert/select on tags + services.migrated_from"
pattern: "migrated_from.*offer_services"
- from: "scripts/push-11-tags-migration.ts"
to: "src/db/migrations/0002_add_tags_table.sql"
via: "reads and executes the generated SQL idempotently"
pattern: "0002_add_tags_table"
---
<objective>
Add the polymorphic `tags` table to the schema (foundation for OFFER-08, reused by Phase 14 for CRM-09), generate and apply its migration to the local dev database, and complete the additive legacy consolidation (OFFER-13) by running the existing Phase 7 migration/validation scripts plus a new tag-assignment script that marks every service migrated from `offer_services` with the "Offerta" tag (D-02).
Purpose: This is the schema/data foundation Plans 02-04 build on. Without the `tags` table existing locally, the query layer (Plan 02) and UI (Plans 03-04) cannot be typechecked or tested against a live DB. Without the consolidation scripts running, OFFER-13 ("dati storici confluiti senza perdita") is not satisfied.
Output:
- `tags` table + Drizzle types in `src/db/schema.ts`
- Generated migration `src/db/migrations/0002_add_tags_table.sql`
- `scripts/push-11-tags-migration.ts` (idempotent, additive-only)
- `scripts/migrate-tags.ts` + `scripts/validate-tags-migration.ts`
- Local dev DB updated: `tags` table exists, `services` rows from `service_catalog`/`offer_services` are present with `migrated_from` set, and `migrated_from='offer_services'` rows carry the "Offerta" tag
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md
@.planning/DESIGN-SYSTEM.md
</context>
<interfaces>
<!-- Polymorphic junction precedent — comments table in src/db/schema.ts (lines 100-111) -->
```typescript
export const comments = pgTable("comments", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
entity_type: text("entity_type").notNull(), // task | deliverable
entity_id: text("entity_id").notNull(),
author: text("author").notNull(), // client | admin
body: text("body").notNull(),
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
```
<!-- services table audit columns (src/db/schema.ts lines 183-195) — migrated_from/migrated_id already exist -->
```typescript
export const services = pgTable("services", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
name: text("name").notNull(),
description: text("description"),
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
category: text("category"),
active: boolean("active").notNull().default(true),
migrated_from: text("migrated_from"), // "service_catalog" | "offer_services" | null
migrated_id: text("migrated_id"),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
```
<!-- Existing Phase 7 migration scripts — scripts/migrate-services.ts (idempotent, already implements OFFER-13 row-copy for service_catalog + offer_services -> services) -->
<!-- scripts/validate-services-migration.ts already checks row counts + orphan refs for this consolidation -->
<!-- These scripts are RUN (not rewritten) by Task 2 of this plan, since they were written in Phase 7 but the
consolidation was deferred to Phase 11 (per ROADMAP Phase 7 status note). -->
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Add `tags` table to schema, generate migration, push to local DB [BLOCKING]</name>
<files>
src/db/schema.ts
src/db/migrations/0002_add_tags_table.sql (generated by drizzle-kit)
src/db/migrations/meta/_journal.json (updated by drizzle-kit)
scripts/push-11-tags-migration.ts
</files>
<read_first>
src/db/schema.ts (existing comments table at lines 100-111 for the polymorphic pattern; services table at lines 183-195 for audit-column conventions; end of file for type-export conventions at lines 567-618)
scripts/push-services-migration.ts (idempotent CREATE TABLE IF NOT EXISTS pattern to replicate)
.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md (D-05, D-06, D-07, D-08)
</read_first>
<action>
1. In `src/db/schema.ts`, add a new `tags` table immediately after the `comments` table definition (after line 111), following the exact polymorphic pattern of `comments`. Do NOT use a composite primaryKey for `(entity_type, entity_id, id)``id` is already the primary key (text, nanoid default). Instead add a **unique index** on `(entity_type, entity_id, name)` to prevent duplicate tag names per entity, using Drizzle's `uniqueIndex` from `drizzle-orm/pg-core`:
```typescript
// ============ TAGS (polymorphic — services now, leads in Phase 14) ============
// entity_type scopes the tag pool (D-06): "services" tags and "leads" tags are
// separate pools even though they share this table. No `color` column — badge
// color is derived deterministically from `name` via hash (D-07).
export const tags = pgTable(
"tags",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
entity_type: text("entity_type").notNull(), // "services" | "leads" (Phase 14)
entity_id: text("entity_id").notNull(),
name: text("name").notNull(),
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => ({
entityTagUnique: uniqueIndex("tags_entity_name_unique").on(
t.entity_type,
t.entity_id,
t.name
),
entityIdx: index("tags_entity_idx").on(t.entity_type, t.entity_id),
})
);
```
2. Add `uniqueIndex` and `index` to the import list at the top of `src/db/schema.ts` (extend the existing `drizzle-orm/pg-core` import that currently includes `pgTable, text, integer, numeric, timestamp, boolean, primaryKey`).
3. Add a `tagsRelations` export near the other polymorphic relation (`commentsRelations` at line ~459-461), following the same "no direct FK — entity_type/entity_id at query time" comment pattern:
```typescript
export const tagsRelations = relations(tags, (_) => ({
// Polymorphic: no direct FK relation — entity_type + entity_id used at query time
}));
```
4. Add `Tag`/`NewTag` TypeScript types at the end of the file alongside the other type exports (after `export type Comment = ...` / `export type NewComment = ...` around line 579-580):
```typescript
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;
```
5. Run `npx drizzle-kit generate` from the project root. This produces `src/db/migrations/0002_add_tags_table.sql` (or the next sequential number — check `src/db/migrations/meta/_journal.json` first; existing entries go up to `0001_add_services_table`, but files 0003/0004/0005 already exist on disk without journal entries — drizzle-kit will pick the next number based on the highest existing migration file, likely `0006_*`. Whatever number drizzle-kit generates, use that exact filename for the push script in step 6). Confirm the generated SQL contains `CREATE TABLE "tags"` with columns `id`, `entity_type`, `entity_id`, `name`, `created_at` and a unique index on `(entity_type, entity_id, name)`.
6. Create `scripts/push-11-tags-migration.ts` following the exact idempotent pattern of `scripts/push-services-migration.ts` (postgres client, `process.env.DATABASE_URL`, `CREATE TABLE IF NOT EXISTS`, catches "already exists" and exits 0):
```typescript
import postgres from "postgres";
async function push() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error("DATABASE_URL environment variable is required");
process.exit(1);
}
const client = postgres(databaseUrl);
try {
console.log("Pushing tags table migration...");
await client`
CREATE TABLE IF NOT EXISTS tags (
id text PRIMARY KEY,
entity_type text NOT NULL,
entity_id text NOT NULL,
name text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
)
`;
await client`
CREATE UNIQUE INDEX IF NOT EXISTS tags_entity_name_unique
ON tags (entity_type, entity_id, name)
`;
await client`
CREATE INDEX IF NOT EXISTS tags_entity_idx
ON tags (entity_type, entity_id)
`;
console.log("✓ tags table created successfully");
process.exit(0);
} catch (err: unknown) {
if (err instanceof Error) {
if (err.message.includes("already exists")) {
console.log("✓ tags table already exists (skipped)");
process.exit(0);
}
console.error("Error pushing migration:", err.message);
} else {
console.error("Unknown error:", err);
}
process.exit(1);
}
}
push();
```
7. Run `npx tsx scripts/push-11-tags-migration.ts` against the local `DATABASE_URL` (dev DB). This is the [BLOCKING] step — Plan 02's query layer and Plan 04's UI require `tags` to exist locally for typecheck/build to reflect reality.
8. **Production migration note (do NOT execute):** Per CLAUDE.md Data Safety and project memory (Gitea→Coolify, prod Postgres only via SSH+docker exec), this migration (`CREATE TABLE tags` + 2 indexes, purely additive) MUST be applied to production manually via SSH+docker exec BEFORE the schema-dependent code (Plans 02-04) is deployed. Add a comment block at the top of `scripts/push-11-tags-migration.ts` documenting this:
```typescript
// PRODUCTION DEPLOY NOTE: This migration is additive-only (CREATE TABLE IF NOT EXISTS +
// 2 indexes, no drops/truncates). Per CLAUDE.md Data Safety, apply to production via
// SSH+docker exec BEFORE pushing Phase 11 schema-dependent code (Plans 02-04).
// Run: npx tsx scripts/push-11-tags-migration.ts (with prod DATABASE_URL)
```
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | grep -i "schema.ts" ; echo "exit: $?"</automated>
</verify>
<acceptance_criteria>
- `grep -c "export const tags = pgTable" src/db/schema.ts` returns `1`
- `grep -c "export type Tag = typeof tags" src/db/schema.ts` returns `1`
- `grep -c "export type NewTag = typeof tags" src/db/schema.ts` returns `1`
- A file matching `src/db/migrations/*_*.sql` newly created by this task contains `CREATE TABLE "tags"` (verify via `grep -l "CREATE TABLE \"tags\"" src/db/migrations/*.sql`)
- `src/db/migrations/meta/_journal.json` contains a new entry for the tags migration (check via `grep -c "tags" src/db/migrations/meta/_journal.json` returns >= 1)
- `grep -c "CREATE TABLE IF NOT EXISTS tags" scripts/push-11-tags-migration.ts` returns `1`
- Running `npx tsx scripts/push-11-tags-migration.ts` exits 0 and prints either "✓ tags table created successfully" or "✓ tags table already exists (skipped)"
- After running the push script, querying `information_schema.tables` for `table_name = 'tags'` returns one row (verify via a one-off `psql` or `postgres` client query against `DATABASE_URL`)
- `npx tsc --noEmit` does not report new errors originating from `src/db/schema.ts`
</acceptance_criteria>
<done>
`tags` table + `Tag`/`NewTag` types exist in schema.ts, migration SQL generated, push script created and successfully run against local dev DB — `tags` table physically exists with the unique index on (entity_type, entity_id, name).
</done>
</task>
<task type="auto">
<name>Task 2: Run legacy consolidation (OFFER-13) + assign "Offerta" tag to migrated offer_services rows [BLOCKING]</name>
<files>
scripts/migrate-tags.ts
scripts/validate-tags-migration.ts
</files>
<read_first>
scripts/migrate-services.ts (existing Phase 7 script — already implements the service_catalog + offer_services -> services row-copy with migrated_from/migrated_id; idempotent, skips already-migrated rows)
scripts/validate-services-migration.ts (existing Phase 7 validation — row counts + orphan checks for services consolidation)
src/db/schema.ts (tags table added in Task 1; services.migrated_from column)
.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md (D-01, D-02, D-04)
</read_first>
<action>
Per D-01, the additive consolidation of `service_catalog`/`offer_services` into `services` is the row-copy logic already written in Phase 7's `scripts/migrate-services.ts` (with `migrated_from`/`migrated_id` audit columns) — that script was written but its execution was deferred to Phase 11 (see ROADMAP.md Phase 7 status: "consolidamento finale rinviato a v2.1 Phase 11"). This task RUNS that existing script (it is idempotent — already-migrated rows are skipped via the `migrated_from`+`migrated_id` existence check), runs its validator, then adds the new "Offerta" tag-assignment script for D-02.
1. Run `npx tsx scripts/migrate-services.ts` against the local dev DB. This copies any not-yet-migrated rows from `service_catalog` and `offer_services` into `services` with `migrated_from`/`migrated_id` set (idempotent — already-migrated rows produce "skipped" output, not duplicates). Capture the console output (inserted/skipped counts) for the SUMMARY.
2. Run `npx tsx scripts/validate-services-migration.ts` against the local dev DB. Confirm it prints `ALL CHECKS PASSED` (PASS on: service_catalog fully migrated, offer_services fully migrated, no orphaned migrated_id references, quote_items.service_id -> service_catalog FK intact, offer_micro_services.service_id -> offer_services FK intact). If any check fails, STOP and report — do not proceed to step 3, since OFFER-13 depends on this passing first.
3. Create `scripts/migrate-tags.ts` implementing D-02 (assign "Offerta" tag to every `services` row where `migrated_from = 'offer_services'`), following the idempotent pattern of `scripts/migrate-services.ts`:
```typescript
import { db } from "@/db";
import { services, tags } from "@/db/schema";
import { eq, and } from "drizzle-orm";
async function migrate() {
console.log("Starting tags migration: assigning 'Offerta' tag to services migrated from offer_services...\n");
const offerServices = await db
.select({ id: services.id })
.from(services)
.where(eq(services.migrated_from, "offer_services"));
let taggedCount = 0;
let skippedCount = 0;
for (const service of offerServices) {
const existing = await db
.select()
.from(tags)
.where(
and(
eq(tags.entity_type, "services"),
eq(tags.entity_id, service.id),
eq(tags.name, "Offerta")
)
)
.limit(1);
if (existing.length > 0) {
skippedCount++;
continue;
}
await db.insert(tags).values({
entity_type: "services",
entity_id: service.id,
name: "Offerta",
});
taggedCount++;
}
console.log(`Assigned 'Offerta' tag: ${taggedCount} services, ${skippedCount} already tagged`);
console.log("\nMigration complete. Run scripts/validate-tags-migration.ts next.");
process.exit(0);
}
migrate().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});
```
4. Create `scripts/validate-tags-migration.ts` implementing the row-count + orphan checks for OFFER-13's tag dimension, following the pattern of `scripts/validate-services-migration.ts`:
```typescript
import { db } from "@/db";
import { services, tags } from "@/db/schema";
import { eq, and, sql } from "drizzle-orm";
async function validate() {
let failures = 0;
const [offerServicesCount] = await db
.select({ n: sql<number>`count(*)::int` })
.from(services)
.where(eq(services.migrated_from, "offer_services"));
const [offerTagCount] = await db
.select({ n: sql<number>`count(*)::int` })
.from(tags)
.where(and(eq(tags.entity_type, "services"), eq(tags.name, "Offerta")));
console.log(`offer_services-derived services: ${offerServicesCount.n} | Offerta tags: ${offerTagCount.n}`);
if (offerServicesCount.n !== offerTagCount.n) {
console.log("FAIL: Offerta tag count does not match offer_services-derived services count");
failures++;
} else {
console.log("PASS: all offer_services-derived services have the Offerta tag");
}
const orphanedTags = await db.execute(sql`
SELECT COUNT(*)::int AS n FROM tags t
WHERE t.entity_type = 'services'
AND NOT EXISTS (SELECT 1 FROM services s WHERE s.id = t.entity_id)
`);
const orphanedTagsCount = (orphanedTags as unknown as Array<{ n: number }>)[0]?.n ?? 0;
if (orphanedTagsCount > 0) {
console.log(`FAIL: ${orphanedTagsCount} tags reference non-existent services`);
failures++;
} else {
console.log("PASS: no orphaned tag references");
}
const tagCounts = await db.execute(sql`
SELECT entity_type, COUNT(*)::int AS n FROM tags GROUP BY entity_type
`);
const counts = tagCounts as unknown as Array<{ entity_type: string; n: number }>;
for (const row of counts) {
console.log(`INFO: ${row.n} tags for entity_type=${row.entity_type}`);
}
console.log(`\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`);
process.exit(failures === 0 ? 0 : 1);
}
validate().catch((err) => {
console.error("Validation failed:", err);
process.exit(1);
});
```
5. Run `npx tsx scripts/migrate-tags.ts` then `npx tsx scripts/validate-tags-migration.ts` against the local dev DB. Confirm `ALL CHECKS PASSED`.
6. **Deferred low-risk note (per CONTEXT.md "Claude's Discretion"):** `src/lib/admin-queries.ts` (lines ~335/343 and ~531/539) still does `leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))` for the `quote_items` label, but `quote_items.service_id` is typed in `schema.ts` (line ~213-214) as `references(() => services.id, ...)` — i.e. it points at `services.id`, not `service_catalog.id`. For any `quote_items` row created after Phase 8 (where `service_id` references a `services.id`), this JOIN will not match and `label` will fall back to `COALESCE(..., quote_items.custom_label)`, which may be `null` for non-custom items. This is a PRE-EXISTING issue, not introduced by Phase 11, and per CONTEXT.md is explicitly NOT blocking for Phase 11's success criteria. Record this in the plan's SUMMARY.md as a "Known issue — deferred" note: "JOIN `quote_items.service_id` -> `service_catalog.id` in admin-queries.ts (lines ~335/343, ~531/539) is stale post-Phase-8 (FK now points to services.id). Low risk, low frequency (admin-only label display in client/project workspace quote_items list). Recommended fix: change `leftJoin(service_catalog, ...)` to `leftJoin(services, eq(quote_items.service_id, services.id))` and `service_catalog.name` to `services.name` in both COALESCE expressions — trivial 4-line change, candidate for Phase 12 cleanup or a standalone hotfix." Do NOT make this code change in Phase 11 — out of scope per CONTEXT.md.
</action>
<verify>
<automated>npx tsx scripts/validate-services-migration.ts 2>&1 | tail -1 | grep -c "ALL CHECKS PASSED" && npx tsx scripts/validate-tags-migration.ts 2>&1 | tail -1 | grep -c "ALL CHECKS PASSED"</automated>
</verify>
<acceptance_criteria>
- `npx tsx scripts/migrate-services.ts` exits 0
- `npx tsx scripts/validate-services-migration.ts` exits 0 and output contains `ALL CHECKS PASSED`
- `grep -c "Offerta" scripts/migrate-tags.ts` returns >= 1
- `grep -c "migrated_from, \"offer_services\"" scripts/migrate-tags.ts` returns >= 1 (or equivalent `eq(services.migrated_from, "offer_services")`)
- `npx tsx scripts/migrate-tags.ts` exits 0
- `npx tsx scripts/validate-tags-migration.ts` exits 0 and output contains `ALL CHECKS PASSED`
- A direct query of `tags` where `entity_type='services' AND name='Offerta'` returns a row count equal to the count of `services` where `migrated_from='offer_services'`
- No rows were deleted from `service_catalog`, `offer_services`, `offer_micro_services`, `clients`, `projects`, `payments`, or `phases` (spot-check row counts before/after are identical for these tables)
</acceptance_criteria>
<done>
`service_catalog` and `offer_services` rows are fully represented in `services` (migrated_from/migrated_id populated, validated via row-count script), and every `services` row with `migrated_from='offer_services'` has the "Offerta" tag in the new `tags` table. Both validation scripts report `ALL CHECKS PASSED`. The stale `quote_items` <-> `service_catalog` JOIN is documented as a deferred, non-blocking known issue.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| One-off scripts -> Postgres | `scripts/*.ts` run with full `DATABASE_URL` credentials, executed manually by the developer (not exposed via HTTP) |
| Drizzle schema -> migration SQL | `drizzle-kit generate` produces SQL applied to a live database; incorrect schema changes could alter production data shape |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-11-01 | Tampering | `scripts/push-11-tags-migration.ts` | mitigate | Use `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` only — no `DROP`/`ALTER ... DROP COLUMN`/`TRUNCATE`. Idempotent: safe to re-run, catches "already exists" and exits 0. |
| T-11-02 | Tampering | `scripts/migrate-tags.ts` / `migrate-services.ts` | mitigate | Both scripts only INSERT new rows; existence checks via `migrated_from`/`migrated_id` (services) and `(entity_type, entity_id, name)` (tags) prevent duplicate inserts on re-run. No UPDATE/DELETE on `service_catalog`, `offer_services`, `offer_micro_services`. |
| T-11-03 | Repudiation | Migration audit trail | accept | `migrated_from`/`migrated_id` on `services` provide traceability for rollback; no separate audit log needed for an additive-only operation. |
| T-11-04 | Information Disclosure | `tags` table polymorphic `entity_id` | mitigate | `entity_type` is constrained at the application layer to a known enum (`"services"`, future `"leads"`) — enforced in Plan 02's server actions (not at DB level in this plan), preventing cross-entity tag pollution. Flagged here for Plan 02 to implement the validation. |
| T-11-05 | Denial of Service | Migration scripts run against prod | accept | Scripts are run manually via SSH+docker exec per project convention, not triggered by any HTTP-reachable endpoint. Out of scope for automated threat mitigation. |
All HIGH-severity items (T-11-01, T-11-02) are mitigated via idempotent additive-only SQL. No threats in this plan are left unmitigated/unaccepted.
</threat_model>
<verification>
1. `npx tsc --noEmit` passes (no new type errors from schema.ts changes)
2. `tags` table exists in local dev DB with columns `id, entity_type, entity_id, name, created_at` and unique index `tags_entity_name_unique`
3. `scripts/validate-services-migration.ts` and `scripts/validate-tags-migration.ts` both exit 0 with `ALL CHECKS PASSED`
4. Row counts for `clients`, `projects`, `payments`, `phases`, `service_catalog`, `offer_services`, `offer_micro_services` are unchanged before/after running all scripts in this plan
</verification>
<success_criteria>
- `tags` table + types committed to `src/db/schema.ts`, migration generated and applied locally
- OFFER-13 satisfied: `service_catalog` + `offer_services` rows fully present in `services` with `migrated_from`/`migrated_id`, validated via row-count script (zero data loss)
- D-02 satisfied: every `services` row with `migrated_from='offer_services'` carries the "Offerta" tag
- Production migration documented as a manual pre-deploy step (not executed by this plan)
- Stale `quote_items` <-> `service_catalog` JOIN documented as a non-blocking known issue in SUMMARY.md
</success_criteria>
<output>
After completion, create `.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-01-SUMMARY.md`
</output>
@@ -0,0 +1,424 @@
---
phase: 11-catalog-database-view-ux-legacy-consolidation
plan: 02
type: execute
wave: 2
depends_on: ["11-01"]
files_modified:
- src/lib/admin-queries.ts
- src/app/admin/catalog/actions.ts
autonomous: true
requirements: [OFFER-07, OFFER-08, OFFER-09]
must_haves:
truths:
- "getAllServices() returns each service together with its assigned tag names"
- "An admin-only server action exists to update any single editable field of a service (name, description, category, unit_price, active)"
- "An admin-only server action exists to add/remove a tag from a service, scoped to entity_type='services', creating the tag row if it doesn't exist"
- "An admin-only server action exists to quick-add a new service with unit_price=0 from just a name"
artifacts:
- path: "src/lib/admin-queries.ts"
provides: "ServiceWithTags type + getAllServices() returning Service & { tags: string[] }"
contains: "export type ServiceWithTags"
- path: "src/app/admin/catalog/actions.ts"
provides: "updateServiceField, addTagToService, removeTagFromService, quickAddService server actions"
exports: ["updateServiceField", "addTagToService", "removeTagFromService", "quickAddService", "createService", "updateService", "toggleServiceActive"]
key_links:
- from: "src/app/admin/catalog/actions.ts"
to: "src/db/schema.ts (tags table)"
via: "drizzle insert/delete with entity_type='services'"
pattern: "entity_type.*services"
- from: "src/lib/admin-queries.ts getAllServices"
to: "src/db/schema.ts (tags table)"
via: "leftJoin on tags where entity_type='services' and entity_id=services.id"
pattern: "leftJoin\\(tags"
---
<objective>
Extend the catalog query layer and server actions to support the database-view UX: `getAllServices()` now returns each service's assigned tags, and four new server actions (`updateServiceField`, `addTagToService`, `removeTagFromService`, `quickAddService`) provide the inline-edit, tag-assignment, and quick-add primitives that Plans 03-04 wire into the UI.
Purpose: Interface-first foundation — Plan 03 (EditableCell/TagMultiSelect components) and Plan 04 (ServiceTable rewrite) import `ServiceWithTags` and these four actions directly, with no further query-layer changes needed.
Output:
- `src/lib/admin-queries.ts`: `ServiceWithTags` type + rewritten `getAllServices()`
- `src/app/admin/catalog/actions.ts`: 4 new server actions, all behind `requireAdmin()`, all calling `revalidatePath("/admin/catalog")`
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md
@.planning/DESIGN-SYSTEM.md
</context>
<interfaces>
<!-- From src/db/schema.ts (after Plan 01 adds tags table) -->
```typescript
export const tags = pgTable("tags", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
entity_type: text("entity_type").notNull(), // "services" | "leads"
entity_id: text("entity_id").notNull(),
name: text("name").notNull(),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;
export const services = pgTable("services", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
name: text("name").notNull(),
description: text("description"),
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
category: text("category"),
active: boolean("active").notNull().default(true),
migrated_from: text("migrated_from"),
migrated_id: text("migrated_id"),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export type Service = typeof services.$inferSelect;
export type NewService = typeof services.$inferInsert;
```
<!-- Current getAllServices() in src/lib/admin-queries.ts (line 359-364) -->
```typescript
export async function getAllServices(): Promise<Service[]> {
return db
.select()
.from(services)
.orderBy(asc(services.name));
}
```
<!-- Current imports in src/lib/admin-queries.ts (lines 1-45) — extend these, do not replace -->
```typescript
import { db } from "@/db";
import {
clients, projects, payments, phases, tasks, deliverables, comments, documents,
notes, time_entries, quote_items, service_catalog, services, settings,
offer_micros, offer_macros, project_offers, offer_phases, offer_phase_services,
quotes, leads,
} from "@/db/schema";
import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm";
import type {
Client, Project, Phase, Task, Deliverable, Payment, Document, Note, Comment,
ServiceCatalog, Service, OfferMicro, OfferMacro, ProjectOffer, OfferPhase,
OfferPhaseService, Quote, Lead,
} from "@/db/schema";
```
<!-- Current actions.ts pattern (full file, 67 lines) -->
```typescript
"use server";
import { db } from "@/db";
import { services } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
const serviceSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
description: z.string().optional(),
unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"),
category: z.string().optional(),
});
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
export async function createService(formData: FormData) { ... }
export async function updateService(serviceId: string, formData: FormData) { ... }
export async function toggleServiceActive(serviceId: string, active: boolean) { ... }
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Extend getAllServices() with tags join (ServiceWithTags)</name>
<files>src/lib/admin-queries.ts</files>
<read_first>
src/lib/admin-queries.ts (full imports block lines 1-45; getAllServices at lines 359-364; existing leftJoin pattern for quote_items/service_catalog at lines 332-345 as a syntax reference for leftJoin + grouping)
src/db/schema.ts (tags table from Plan 01; services table)
</read_first>
<behavior>
- Test 1 (manual/typecheck): `getAllServices()` return type is `Promise<ServiceWithTags[]>` where `ServiceWithTags = Service & { tags: string[] }`
- Test 2: A service with 2 tags ("Offerta", "Premium") returns `tags: ["Offerta", "Premium"]` (order by `tags.name asc`)
- Test 3: A service with 0 tags returns `tags: []` (not `[null]` or `[""]` — left join nulls must be filtered out)
- Test 4: Services remain ordered by `services.name asc` regardless of tag count
</behavior>
<action>
1. Add `tags` to the import from `@/db/schema` (extend the existing destructured import on lines 2-24 — add `tags` to the list alongside `services`).
2. Add `and` is already imported from `drizzle-orm` (line 25) — no change needed there.
3. Immediately before the existing `getAllServices` function (line 359), add the new type:
```typescript
// ── ServiceWithTags — services + assigned tag names (Phase 11 database-view) ──
export type ServiceWithTags = Service & { tags: string[] };
```
4. Replace the body of `getAllServices` (lines 359-364) with:
```typescript
export async function getAllServices(): Promise<ServiceWithTags[]> {
const rows = await db
.select({
id: services.id,
name: services.name,
description: services.description,
unit_price: services.unit_price,
category: services.category,
active: services.active,
migrated_from: services.migrated_from,
migrated_id: services.migrated_id,
created_at: services.created_at,
tag_name: tags.name,
})
.from(services)
.leftJoin(
tags,
and(eq(tags.entity_type, "services"), eq(tags.entity_id, services.id))
)
.orderBy(asc(services.name), asc(tags.name));
const serviceMap = new Map<string, ServiceWithTags>();
for (const row of rows) {
const { tag_name, ...serviceFields } = row;
if (!serviceMap.has(row.id)) {
serviceMap.set(row.id, { ...serviceFields, tags: [] });
}
if (tag_name) {
serviceMap.get(row.id)!.tags.push(tag_name);
}
}
return Array.from(serviceMap.values());
}
```
5. `getClientFullDetail` and `getProjectFullDetail` both have an `activeServices: Service[]` field populated from `db.select().from(services).where(eq(services.active, true))...` (lines ~251-255 and ~542) — these queries are UNCHANGED in this plan (they don't need tags; they feed the project workspace service-assignment dropdown, out of scope for Phase 11 per CONTEXT.md deferred items). Do NOT modify these.
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | grep -i "admin-queries" ; echo "exit: $?"</automated>
</verify>
<acceptance_criteria>
- `grep -c "export type ServiceWithTags = Service & { tags: string\[\] }" src/lib/admin-queries.ts` returns `1`
- `grep -c "export async function getAllServices(): Promise<ServiceWithTags\[\]>" src/lib/admin-queries.ts` returns `1`
- `grep -c "leftJoin(\s*tags" src/lib/admin-queries.ts` (or `leftJoin(\n tags`) returns >= 1 — verify with `grep -A1 "leftJoin(" src/lib/admin-queries.ts | grep -c tags`
- `grep -c "tags," src/lib/admin-queries.ts` >= 1 in the import block (line 2-24 region)
- `npx tsc --noEmit` produces zero errors referencing `src/lib/admin-queries.ts`
- `getClientFullDetail` and `getProjectFullDetail` function bodies are byte-for-byte unchanged except for surrounding context (verify via `git diff src/lib/admin-queries.ts` shows no changes inside those two functions)
</acceptance_criteria>
<done>
`getAllServices()` returns `ServiceWithTags[]` with each service's tags as a string array (empty array when no tags), ordered by service name then tag name. Typecheck passes.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add inline-edit, tag, and quick-add server actions</name>
<files>src/app/admin/catalog/actions.ts</files>
<read_first>
src/app/admin/catalog/actions.ts (full file — requireAdmin pattern lines 18-21, serviceSchema lines 11-16, existing CRUD actions lines 23-67)
src/db/schema.ts (tags table from Plan 01)
.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md (D-06, D-08, D-12)
</read_first>
<behavior>
- Test 1: `updateServiceField(id, "name", "New Name")` updates only the `name` column, calls `revalidatePath("/admin/catalog")`
- Test 2: `updateServiceField(id, "unit_price", "150.5")` validates as a positive number, stores as `"150.50"` (2 decimals)
- Test 3: `updateServiceField(id, "name", "")` throws `"Nome richiesto"` (required field validation)
- Test 4: `updateServiceField(id, "active", false)` updates the boolean `active` column
- Test 5: `addTagToService(serviceId, "Premium")` inserts a row into `tags` with `entity_type="services"`, `entity_id=serviceId`, `name="Premium"` (trimmed); calling it again with the same name does NOT create a duplicate (`onConflictDoNothing`)
- Test 6: `addTagToService(serviceId, " ")` throws (empty/whitespace tag name rejected)
- Test 7: `removeTagFromService(serviceId, "Premium")` deletes the matching row from `tags` scoped to `entity_type="services"` and `entity_id=serviceId`
- Test 8: `quickAddService("Nuovo servizio")` inserts a `services` row with `unit_price="0.00"`, `active=true`, `migrated_from=null`
- Test 9: `quickAddService("")` throws `"Nome richiesto"`
- Test 10: All 4 new actions call `requireAdmin()` first (throw `"Non autorizzato"` when no session)
</behavior>
<action>
1. Update imports at the top of `src/app/admin/catalog/actions.ts`:
```typescript
"use server";
import { db } from "@/db";
import { services, tags } from "@/db/schema";
import { revalidatePath } from "next/cache";
import { eq, and } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
```
2. Keep the existing `serviceSchema`, `requireAdmin`, `createService`, `updateService`, `toggleServiceActive` UNCHANGED (lines 11-67).
3. Append the following 4 new server actions at the end of the file.
**updateServiceField** — single-field inline edit, used by `EditableCell` (Plan 03/04):
```typescript
const EDITABLE_FIELDS = ["name", "description", "category", "unit_price", "active"] as const;
type EditableField = (typeof EDITABLE_FIELDS)[number];
export async function updateServiceField(
serviceId: string,
fieldName: EditableField,
value: string | boolean
) {
await requireAdmin();
if (!EDITABLE_FIELDS.includes(fieldName)) {
throw new Error(`Campo non editabile: ${fieldName}`);
}
if (fieldName === "name") {
const s = String(value).trim();
if (s.length === 0) throw new Error("Nome richiesto");
await db.update(services).set({ name: s }).where(eq(services.id, serviceId));
} else if (fieldName === "description") {
const s = String(value).trim();
await db.update(services).set({ description: s || null }).where(eq(services.id, serviceId));
} else if (fieldName === "category") {
const s = String(value).trim();
await db.update(services).set({ category: s || null }).where(eq(services.id, serviceId));
} else if (fieldName === "unit_price") {
const num = parseFloat(String(value));
if (isNaN(num) || num < 0) throw new Error("Prezzo invalido");
await db.update(services).set({ unit_price: num.toFixed(2) }).where(eq(services.id, serviceId));
} else if (fieldName === "active") {
const boolValue = typeof value === "boolean" ? value : value === "true";
await db.update(services).set({ active: boolValue }).where(eq(services.id, serviceId));
}
revalidatePath("/admin/catalog");
}
```
Note: `unit_price` allows `0` here (>= 0, not > 0 like `serviceSchema`) — this is intentional per D-12 (quick-add creates services with `unit_price=0`, and the user must be able to leave it at 0 or correct it inline without hitting the `0.01` minimum from the create form).
**addTagToService / removeTagFromService** — tag assignment scoped to `entity_type="services"` (D-06), used by `TagMultiSelect` (Plan 03/04):
```typescript
export async function addTagToService(serviceId: string, tagName: string) {
await requireAdmin();
const trimmed = tagName.trim();
if (trimmed.length === 0) throw new Error("Nome tag richiesto");
await db
.insert(tags)
.values({
entity_type: "services",
entity_id: serviceId,
name: trimmed,
})
.onConflictDoNothing();
revalidatePath("/admin/catalog");
}
export async function removeTagFromService(serviceId: string, tagName: string) {
await requireAdmin();
await db
.delete(tags)
.where(
and(
eq(tags.entity_type, "services"),
eq(tags.entity_id, serviceId),
eq(tags.name, tagName)
)
);
revalidatePath("/admin/catalog");
}
```
Note on `onConflictDoNothing()`: this relies on the unique index `tags_entity_name_unique` on `(entity_type, entity_id, name)` created in Plan 01. Drizzle's `.onConflictDoNothing()` without a `target` argument falls back to "do nothing on any conflict" — verify this compiles; if Drizzle requires an explicit target for this version, use `.onConflictDoNothing({ target: [tags.entity_type, tags.entity_id, tags.name] })`.
**quickAddService** — OFFER-09 quick-add row, used by `ServiceTable` (Plan 04):
```typescript
export async function quickAddService(name: string) {
await requireAdmin();
const trimmed = name.trim();
if (trimmed.length === 0) throw new Error("Nome richiesto");
await db.insert(services).values({
name: trimmed,
unit_price: "0.00",
active: true,
});
revalidatePath("/admin/catalog");
}
```
Per D-12: `unit_price="0.00"` is intentional — the row becomes a normal editable row immediately after creation, and the user corrects the price inline via `updateServiceField`.
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | grep -i "actions.ts" ; echo "exit: $?"</automated>
</verify>
<acceptance_criteria>
- `grep -c "export async function updateServiceField" src/app/admin/catalog/actions.ts` returns `1`
- `grep -c "export async function addTagToService" src/app/admin/catalog/actions.ts` returns `1`
- `grep -c "export async function removeTagFromService" src/app/admin/catalog/actions.ts` returns `1`
- `grep -c "export async function quickAddService" src/app/admin/catalog/actions.ts` returns `1`
- `grep -c "await requireAdmin()" src/app/admin/catalog/actions.ts` returns `7` (3 existing + 4 new)
- `grep -c "entity_type: \"services\"" src/app/admin/catalog/actions.ts` returns `2` (addTagToService insert + removeTagFromService where)
- `grep -c "revalidatePath(\"/admin/catalog\")" src/app/admin/catalog/actions.ts` returns `7` (3 existing + 4 new)
- `npx tsc --noEmit` produces zero errors referencing `src/app/admin/catalog/actions.ts`
- Existing `createService`, `updateService`, `toggleServiceActive` exports remain present and unchanged (`grep -c "export async function createService\|export async function updateService\|export async function toggleServiceActive" src/app/admin/catalog/actions.ts` returns `3`)
</acceptance_criteria>
<done>
Four new server actions exist, each calling `requireAdmin()` and `revalidatePath("/admin/catalog")`. Tag operations are scoped to `entity_type="services"`. `updateServiceField` validates per-field (required name, non-negative price). `quickAddService` creates a `unit_price="0.00"` row. Typecheck passes.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| Admin browser -> Server Actions | All 4 new actions are Next.js Server Actions invoked from `/admin/catalog` client components; session-authenticated |
| Server Actions -> Postgres | Drizzle queries with user-supplied field values (tag names, service field values) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-11-06 | Spoofing/Elevation of Privilege | `updateServiceField`, `addTagToService`, `removeTagFromService`, `quickAddService` | mitigate | Every action calls `await requireAdmin()` as its first statement, throwing `"Non autorizzato"` if no Auth.js session exists — identical to existing `createService`/`updateService`/`toggleServiceActive` pattern. |
| T-11-07 | Tampering | `updateServiceField` field whitelist | mitigate | `EDITABLE_FIELDS` is a closed TypeScript union (`"name" \| "description" \| "category" \| "unit_price" \| "active"`) checked at runtime via `EDITABLE_FIELDS.includes(fieldName)` — prevents arbitrary column writes via a crafted `fieldName` string from a compromised client bundle. |
| T-11-08 | Tampering | `addTagToService`/`removeTagFromService` `entity_type` | mitigate | `entity_type` is hardcoded to the literal `"services"` in both actions — never accepted as a parameter from the client — so a crafted call cannot write/delete tags for `entity_type="leads"` (Phase 14's future pool) via this catalog action set. |
| T-11-09 | Tampering | SQL injection via tag name / field values | accept | All values pass through Drizzle's parameterized query builder (`.values()`, `.set()`, `eq()`) — no raw `sql` string interpolation of user input in this plan's actions. |
| T-11-10 | Denial of Service | Duplicate tag inserts | mitigate | `onConflictDoNothing()` on the `(entity_type, entity_id, name)` unique index (from Plan 01) makes `addTagToService` idempotent — no unbounded duplicate rows from repeated clicks. |
All HIGH-severity items (T-11-06, T-11-07, T-11-08) are mitigated.
</threat_model>
<verification>
1. `npx tsc --noEmit` passes with zero errors in `src/lib/admin-queries.ts` and `src/app/admin/catalog/actions.ts`
2. `getAllServices()` callable from a server component and returns `ServiceWithTags[]`
3. All 4 new actions present, admin-gated, and exported
</verification>
<success_criteria>
- `ServiceWithTags` type + `getAllServices()` join with `tags` implemented and typechecked
- `updateServiceField`, `addTagToService`, `removeTagFromService`, `quickAddService` implemented, admin-gated, revalidating `/admin/catalog`
- Tag operations strictly scoped to `entity_type="services"` (D-06 enforced at the action layer)
- Existing catalog actions (`createService`, `updateService`, `toggleServiceActive`) unchanged
</success_criteria>
<output>
After completion, create `.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-02-SUMMARY.md`
</output>
@@ -0,0 +1,533 @@
---
phase: 11-catalog-database-view-ux-legacy-consolidation
plan: 03
type: execute
wave: 3
depends_on: ["11-02"]
files_modified:
- src/components/ui/editable-cell.tsx
- src/components/ui/tag-multi-select.tsx
autonomous: true
requirements: [OFFER-07, OFFER-08]
must_haves:
truths:
- "Clicking a cell in display mode transitions it to an editable input/textarea/checkbox with a visible focus ring"
- "Pressing Enter (non-textarea) or blurring the field saves the value via the onSave callback"
- "Pressing Escape reverts to the previous value without calling onSave"
- "Tag badges display with deterministic colors derived from tag name (same name = same color, no stored color column)"
- "Typing a new tag name and pressing Enter in the TagMultiSelect dropdown calls addTagToService and the badge appears without a full page reload (via revalidation)"
artifacts:
- path: "src/components/ui/editable-cell.tsx"
provides: "EditableCell component — click-to-edit text/number/textarea/toggle cell"
contains: "export function EditableCell"
- path: "src/components/ui/tag-multi-select.tsx"
provides: "TagMultiSelect component — tag badges + add/remove dropdown with deterministic color hashing"
contains: "export function TagMultiSelect"
key_links:
- from: "src/components/ui/tag-multi-select.tsx"
to: "src/app/admin/catalog/actions.ts"
via: "addTagToService / removeTagFromService server action calls"
pattern: "addTagToService|removeTagFromService"
- from: "src/components/ui/editable-cell.tsx"
to: "src/components/ui/input.tsx, src/components/ui/textarea.tsx"
via: "renders Input/Textarea in edit mode with ring-1 ring-primary"
pattern: "ring-1 ring-primary"
---
<objective>
Build the two new shared UI primitives the database-view table (Plan 04) depends on: `EditableCell` (generic click-to-edit cell for text/number/textarea/toggle, per D-11/D-14 and DESIGN-SYSTEM.md inline-edit pattern) and `TagMultiSelect` (tag badges with deterministic color hashing per D-07, inline "+" dropdown to add/remove tags per D-06/D-08).
Purpose: Interface-first — these are pure, reusable components with no ServiceTable-specific logic. Plan 04 imports and wires them into `ServiceRow`/`ServiceTable` without needing to touch their internals.
Output:
- `src/components/ui/editable-cell.tsx``EditableCell` component + `EditableCellProps` type
- `src/components/ui/tag-multi-select.tsx``TagMultiSelect` component + color-hash utility
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md
@.planning/DESIGN-SYSTEM.md
</context>
<interfaces>
<!-- From src/app/admin/catalog/actions.ts (Plan 02) — TagMultiSelect calls these directly -->
```typescript
export async function addTagToService(serviceId: string, tagName: string): Promise<void>;
export async function removeTagFromService(serviceId: string, tagName: string): Promise<void>;
```
<!-- src/lib/utils.ts — cn() helper, used by both new components -->
```typescript
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
<!-- src/components/ui/input.tsx — forwardRef<HTMLInputElement, ComponentProps<"input">> -->
<!-- src/components/ui/textarea.tsx — forwardRef<HTMLTextAreaElement, ComponentProps<"textarea">> -->
<!-- src/components/ui/badge.tsx — Badge component, accepts className override via cn() -->
```typescript
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps): JSX.Element;
```
<!-- lucide-react v1.14.0 is installed — X and Plus icons available -->
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Build EditableCell component (text/number/textarea/toggle)</name>
<files>src/components/ui/editable-cell.tsx</files>
<read_first>
src/components/admin/catalog/ServiceTable.tsx (current ServiceRow inline-edit state pattern, lines 11-35, for the useState/useTransition conventions to mirror — NOT the form-based editing, but the local state + save/cancel idiom)
src/components/ui/input.tsx (Input forwardRef signature)
src/components/ui/textarea.tsx (Textarea forwardRef signature)
.planning/DESIGN-SYSTEM.md (inline edit pattern: "click su cella -> diventa input/select borderless con ring-1 ring-primary on focus -> Enter salva, Esc annulla, blur salva")
</read_first>
<behavior>
- Test 1 (display mode): renders `value` as plain text (or "—" if empty/falsy for text/textarea; "✓ Attivo"/"✗ Disattivato" for toggle)
- Test 2 (click to edit): clicking the display div sets `isEditing=true` and renders the appropriate input (`Input` for text/number, `Textarea` for textarea, `<input type="checkbox">` for toggle), auto-focused
- Test 3 (Enter saves, non-textarea): pressing Enter in a text/number input calls `onSave(tempValue)` and exits edit mode
- Test 4 (Escape cancels): pressing Escape reverts `tempValue` to the original `value` and exits edit mode WITHOUT calling `onSave`
- Test 5 (blur saves): blurring any input calls `onSave(tempValue)` and exits edit mode
- Test 6 (required validation): if `required=true` and `tempValue.trim() === ""`, `onSave` is NOT called and an inline error message renders
- Test 7 (disabled): if `disabled=true`, clicking the display div does NOT enter edit mode (no `cursor-pointer`, has `cursor-not-allowed opacity-50`)
- Test 8 (toggle type): for `type="toggle"`, `onSave` receives `"true"` or `"false"` as a string based on checkbox state
</behavior>
<action>
Create `src/components/ui/editable-cell.tsx`:
```typescript
"use client";
import { useState, useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
export interface EditableCellProps {
value: string | number | boolean;
type?: "text" | "number" | "textarea" | "toggle";
onSave: (value: string) => void;
required?: boolean;
placeholder?: string;
disabled?: boolean;
formatDisplay?: (value: string) => string;
}
export function EditableCell({
value,
type = "text",
onSave,
required,
placeholder,
disabled,
formatDisplay,
}: EditableCellProps) {
const [isEditing, setIsEditing] = useState(false);
const [tempValue, setTempValue] = useState(String(value));
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
useEffect(() => {
setTempValue(String(value));
}, [value]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
if ("select" in inputRef.current) {
inputRef.current.select();
}
}
}, [isEditing]);
function startEdit() {
if (disabled) return;
setError(null);
setTempValue(String(value));
setIsEditing(true);
}
function commit() {
if (required && tempValue.trim().length === 0) {
setError("Campo richiesto");
return;
}
setError(null);
onSave(tempValue);
setIsEditing(false);
}
function cancel() {
setTempValue(String(value));
setError(null);
setIsEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) {
if (e.key === "Enter" && type !== "textarea") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}
if (!isEditing) {
let display: string;
if (type === "toggle") {
display = tempValue === "true" ? "✓ Attivo" : "✗ Disattivato";
} else {
const raw = String(value);
display = formatDisplay ? formatDisplay(raw) : raw || "—";
}
return (
<div
onClick={startEdit}
className={cn(
"px-2 py-1 rounded transition-colors duration-150 text-sm",
disabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:bg-[#f0f0f0]",
type === "textarea" && "line-clamp-2 max-w-md"
)}
>
{display}
</div>
);
}
return (
<div className="flex flex-col gap-1">
{type === "textarea" ? (
<Textarea
ref={inputRef as React.Ref<HTMLTextAreaElement>}
value={tempValue}
onChange={(e) => setTempValue(e.target.value)}
onBlur={commit}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={cn("ring-1 ring-primary resize-none text-sm", error && "ring-2 ring-red-500")}
rows={3}
/>
) : type === "toggle" ? (
<input
ref={inputRef as React.Ref<HTMLInputElement>}
type="checkbox"
checked={tempValue === "true"}
onChange={(e) => {
const next = e.target.checked ? "true" : "false";
setTempValue(next);
onSave(next);
setIsEditing(false);
}}
onBlur={commit}
onKeyDown={handleKeyDown}
className="h-4 w-4 cursor-pointer accent-[#1A463C]"
/>
) : (
<Input
ref={inputRef as React.Ref<HTMLInputElement>}
type={type}
value={tempValue}
onChange={(e) => setTempValue(e.target.value)}
onBlur={commit}
onKeyDown={handleKeyDown}
placeholder={placeholder}
step={type === "number" ? "0.01" : undefined}
min={type === "number" ? "0" : undefined}
className={cn("ring-1 ring-primary h-8 text-sm", error && "ring-2 ring-red-500")}
/>
)}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
);
}
```
Design notes implemented per DESIGN-SYSTEM.md:
- Display mode: `cursor-pointer hover:bg-[#f0f0f0] transition-colors duration-150`
- Edit mode: `ring-1 ring-primary`, borderless via `Input`/`Textarea`'s existing border being visually overridden by the ring (acceptable — `Input`/`Textarea` retain their `border-input` class; the ring sits alongside it, which is consistent with shadcn focus-ring conventions used elsewhere in this codebase)
- Toggle type saves immediately on change (no separate commit step) since a checkbox's `onChange` IS the user's deliberate action — this matches D-14 ("stato attivo/disattivo diventa una cella inline (toggle/checkbox cliccabile")
- `formatDisplay` prop allows the consumer (Plan 04's `ServiceRow`) to format prices as `€{...}` without baking currency logic into this generic component
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | grep -i "editable-cell" ; echo "exit: $?"</automated>
</verify>
<acceptance_criteria>
- `grep -c "export function EditableCell" src/components/ui/editable-cell.tsx` returns `1`
- `grep -c "export interface EditableCellProps" src/components/ui/editable-cell.tsx` returns `1`
- `grep -c "ring-1 ring-primary" src/components/ui/editable-cell.tsx` returns >= 2 (Input and Textarea edit modes)
- `grep -c "case \"Escape\"\|e.key === \"Escape\"" src/components/ui/editable-cell.tsx` returns >= 1
- `grep -c "e.key === \"Enter\"" src/components/ui/editable-cell.tsx` returns >= 1
- `grep -c "hover:bg-\[#f0f0f0\]" src/components/ui/editable-cell.tsx` returns >= 1
- `npx tsc --noEmit` produces zero errors referencing `src/components/ui/editable-cell.tsx`
</acceptance_criteria>
<done>
`EditableCell` renders display mode by default, enters edit mode on click (unless disabled), supports text/number/textarea/toggle types, saves on Enter (non-textarea) and blur, cancels on Escape without saving, and validates required fields with an inline error. Typecheck passes.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Build TagMultiSelect component with deterministic color hashing</name>
<files>src/components/ui/tag-multi-select.tsx</files>
<read_first>
src/components/ui/badge.tsx (Badge component + BadgeProps, cn-based className override)
src/app/admin/catalog/actions.ts (addTagToService/removeTagFromService signatures from Plan 02 — read after Plan 02 completes)
.planning/DESIGN-SYSTEM.md (tag pattern: "Badge con colori derivati da una palette fissa a rotazione (6-8 colori pastello su sfondo, testo scuro per contrasto AA) + pulsante + inline per creare un nuovo tag senza uscire dalla riga")
.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md (D-07: hash-derived color, no color column; D-08: "Offerta" is a normal tag)
</read_first>
<behavior>
- Test 1 (color determinism): `getTagColorIndex("Offerta")` always returns the same index across calls; `getTagColorIndex("Premium")` may return a different index (hash-based, not random)
- Test 2 (palette size): the color palette array has between 6 and 8 entries (per DESIGN-SYSTEM.md), all using `bg-*-100 text-*-900` pastel pairs for AA contrast
- Test 3 (display, no tags): renders a placeholder "—" when `tags=[]`
- Test 4 (display, with tags): renders one `Badge` per tag name, each colored via `TAG_COLORS[getTagColorIndex(tagName)]`
- Test 5 (open dropdown): clicking the cell container toggles `isOpen`, revealing an input + "+" button
- Test 6 (add tag on Enter): typing a name and pressing Enter in the dropdown input calls `addTagToService(serviceId, name)`, clears the input, and triggers `onTagsChanged` (revalidation)
- Test 7 (add tag, empty input): pressing Enter with an empty/whitespace input does NOT call `addTagToService`
- Test 8 (remove tag): clicking the "X" on a badge calls `removeTagFromService(serviceId, tagName)` and `onTagsChanged`, without opening the dropdown (event propagation stopped)
- Test 9 (click outside closes dropdown): clicking outside the component while `isOpen=true` sets `isOpen=false`
- Test 10 (Escape closes dropdown): pressing Escape in the dropdown input sets `isOpen=false` without calling `addTagToService`
</behavior>
<action>
Create `src/components/ui/tag-multi-select.tsx`:
```typescript
"use client";
import { useState, useRef, useEffect, useTransition } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { X, Plus } from "lucide-react";
import { cn } from "@/lib/utils";
import { addTagToService, removeTagFromService } from "@/app/admin/catalog/actions";
// D-07: deterministic name -> color index, no stored `color` column.
// 7-color pastel palette, bg-*-100/text-*-900 pairs for AA contrast on white.
const TAG_COLORS = [
"bg-blue-100 text-blue-900",
"bg-green-100 text-green-900",
"bg-purple-100 text-purple-900",
"bg-pink-100 text-pink-900",
"bg-amber-100 text-amber-900",
"bg-teal-100 text-teal-900",
"bg-orange-100 text-orange-900",
];
export function getTagColorIndex(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash << 5) - hash + name.charCodeAt(i);
hash |= 0; // 32-bit int
}
return Math.abs(hash) % TAG_COLORS.length;
}
export interface TagMultiSelectProps {
tags: string[];
serviceId: string;
onTagsChanged?: () => void;
}
export function TagMultiSelect({ tags, serviceId, onTagsChanged }: TagMultiSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [newTag, setNewTag] = useState("");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus();
}
}, [isOpen]);
function handleAddTag() {
const trimmed = newTag.trim();
if (!trimmed) return;
setError(null);
startTransition(async () => {
try {
await addTagToService(serviceId, trimmed);
setNewTag("");
onTagsChanged?.();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
function handleRemoveTag(tagName: string) {
startTransition(async () => {
try {
await removeTagFromService(serviceId, tagName);
onTagsChanged?.();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nella rimozione");
}
});
}
return (
<div ref={containerRef} className="relative w-full min-w-[140px]">
<div
onClick={() => setIsOpen((v) => !v)}
className="flex flex-wrap gap-1 items-center px-2 py-1 rounded cursor-pointer hover:bg-[#f0f0f0] transition-colors duration-150 min-h-[28px]"
>
{tags.length === 0 ? (
<span className="text-xs text-[#71717a]">—</span>
) : (
tags.map((tag) => (
<Badge
key={tag}
variant="outline"
className={cn(
TAG_COLORS[getTagColorIndex(tag)],
"border-transparent text-xs px-2 py-0.5 gap-1 font-medium"
)}
>
{tag}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveTag(tag);
}}
disabled={isPending}
className="hover:opacity-70 disabled:opacity-30"
aria-label={`Rimuovi tag ${tag}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))
)}
<Plus className="h-3.5 w-3.5 text-[#71717a]" />
</div>
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-2 z-10 min-w-[200px]">
<div className="flex gap-1">
<Input
ref={inputRef}
type="text"
placeholder="Nome tag..."
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTag();
}
if (e.key === "Escape") {
e.preventDefault();
setIsOpen(false);
}
}}
className="text-sm h-8 ring-1 ring-primary"
disabled={isPending}
/>
<Button
type="button"
size="sm"
onClick={handleAddTag}
disabled={isPending || !newTag.trim()}
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90 h-8 px-2"
>
+
</Button>
</div>
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
)}
</div>
);
}
```
Design notes per DESIGN-SYSTEM.md / D-07 / D-08:
- 7-color palette (within the 6-8 range specified), `bg-*-100 text-*-900` for AA contrast on white backgrounds
- `getTagColorIndex` exported for potential reuse/testing — pure function, no I/O
- "Offerta" tag (D-08) is rendered identically to any other tag — no special-casing, no protection logic
- Dropdown closes on outside click and Escape; transition 150ms per checklist
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | grep -i "tag-multi-select" ; echo "exit: $?"</automated>
</verify>
<acceptance_criteria>
- `grep -c "export function TagMultiSelect" src/components/ui/tag-multi-select.tsx` returns `1`
- `grep -c "export function getTagColorIndex" src/components/ui/tag-multi-select.tsx` returns `1`
- `grep -c "const TAG_COLORS" src/components/ui/tag-multi-select.tsx` returns `1`, and the array literal has between 6 and 8 string entries (verify with `grep -A8 "const TAG_COLORS" src/components/ui/tag-multi-select.tsx | grep -c "bg-.*-100 text-.*-900"` returns a number between 6 and 8)
- `grep -c "addTagToService\|removeTagFromService" src/components/ui/tag-multi-select.tsx` returns >= 2
- `grep -c "handleClickOutside" src/components/ui/tag-multi-select.tsx` returns >= 1
- `grep -c "e.key === \"Escape\"" src/components/ui/tag-multi-select.tsx` returns >= 1
- `npx tsc --noEmit` produces zero errors referencing `src/components/ui/tag-multi-select.tsx`
</acceptance_criteria>
<done>
`TagMultiSelect` renders tag badges colored via deterministic hash (no DB color column), supports adding a new tag via Enter/+ button (calling `addTagToService`), removing a tag via the badge's X (calling `removeTagFromService`), closes its dropdown on outside-click/Escape, and surfaces server errors inline. Typecheck passes.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| Admin browser -> TagMultiSelect -> Server Actions | Client component invokes `addTagToService`/`removeTagFromService` (Plan 02, already admin-gated) directly |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-11-11 | Information Disclosure | `EditableCell` error messages | accept | Errors shown are generic ("Campo richiesto", "Errore nel salvataggio") — no stack traces or internal details surfaced to the browser. |
| T-11-12 | Tampering | `TagMultiSelect` calling `addTagToService`/`removeTagFromService` with arbitrary `serviceId` | accept | Authorization is enforced server-side in Plan 02's `requireAdmin()` — this plan's components are presentation-only and inherit that protection. A malicious client could theoretically call these actions with any `serviceId`, but the action itself does not leak cross-entity data (it only inserts/deletes a `tags` row scoped to `entity_type="services"`), and the admin session requirement limits this to authenticated admins (single-admin app per REQUIREMENTS.md "Out of Scope: Multi-utente"). |
| T-11-13 | Denial of Service | Rapid-fire tag add/remove clicks | mitigate | `useTransition` + `isPending` disables the input/button and remove-buttons during in-flight requests, preventing duplicate concurrent submissions from a single user action. |
No HIGH-severity unmitigated threats — both new components are presentation-layer, deferring authorization to Plan 02's server actions.
</threat_model>
<verification>
1. `npx tsc --noEmit` passes with zero errors in both new files
2. `EditableCell` exports `EditableCell` + `EditableCellProps`
3. `TagMultiSelect` exports `TagMultiSelect`, `TagMultiSelectProps`, `getTagColorIndex`
4. Both components are client components (`"use client"` directive present)
</verification>
<success_criteria>
- `EditableCell` supports text/number/textarea/toggle with click-to-edit, Enter/blur save, Escape cancel, required validation, disabled state (D-11, D-14)
- `TagMultiSelect` renders deterministically-colored tag badges (D-07), supports add-on-the-fly and remove (D-08, OFFER-08), scoped via Plan 02's actions
- Both components have zero ServiceTable-specific logic — pure, reusable primitives ready for Plan 04
</success_criteria>
<output>
After completion, create `.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-03-SUMMARY.md`
</output>
@@ -0,0 +1,541 @@
---
phase: 11-catalog-database-view-ux-legacy-consolidation
plan: 04
type: execute
wave: 4
depends_on: ["11-03"]
files_modified:
- src/components/admin/catalog/ServiceTable.tsx
- src/app/admin/catalog/page.tsx
- src/components/admin/catalog/ServiceForm.tsx
autonomous: true
requirements: [OFFER-07, OFFER-08, OFFER-09, OFFER-10]
must_haves:
truths:
- "L'utente clicca su una cella della tabella services, la modifica inline e il salvataggio avviene con invio (no modali, no reload)"
- "L'utente assegna tag multi-select a un servizio, creando nuovi tag al volo senza uscire dalla tabella"
- "L'utente aggiunge un nuovo servizio scrivendo in una riga vuota in fondo alla tabella e premendo invio"
- "L'utente filtra/cerca i servizi istantaneamente (client-side, nessun reload pagina)"
- "Servizi disattivati restano visibili, attenuati, ordinati in fondo sotto una riga divisoria"
- "Nessuna colonna azioni — tutte le modifiche avvengono via celle inline (toggle per active)"
artifacts:
- path: "src/components/admin/catalog/ServiceTable.tsx"
provides: "Database-view table: inline-editable rows, TagMultiSelect column, quick-add row, active/inactive split"
contains: "EditableCell"
- path: "src/app/admin/catalog/page.tsx"
provides: "Catalog page with client-side search/filter bar above the table"
contains: "ServiceTable"
- path: "src/components/admin/catalog/ServiceForm.tsx"
provides: "Removed or reduced to no-op — quick-add row in ServiceTable replaces it"
key_links:
- from: "src/app/admin/catalog/page.tsx"
to: "src/components/admin/catalog/ServiceTable.tsx"
via: "passes ServiceWithTags[] + search query to filter"
pattern: "ServiceWithTags"
- from: "src/components/admin/catalog/ServiceTable.tsx"
to: "src/components/ui/editable-cell.tsx, src/components/ui/tag-multi-select.tsx"
via: "renders EditableCell per column, TagMultiSelect for tags column"
pattern: "EditableCell|TagMultiSelect"
- from: "src/components/admin/catalog/ServiceTable.tsx"
to: "src/app/admin/catalog/actions.ts"
via: "updateServiceField, quickAddService calls on save"
pattern: "updateServiceField|quickAddService"
---
<objective>
Rewrite `/admin/catalog` as a Notion/Airtable-style database view: every cell is click-to-edit via `EditableCell`, tags are managed via `TagMultiSelect`, a quick-add row at the bottom creates new services with just a name + Enter, a search bar above the table filters client-side instantly, and inactive services sink below a divider, attenuated. No "Actions" column — the active/inactive state is itself an inline toggle cell (D-14).
Purpose: This is the user-facing payload of Phase 11 — OFFER-07, OFFER-08, OFFER-09, OFFER-10 all manifest here, consuming Plan 02's query/actions and Plan 03's `EditableCell`/`TagMultiSelect`.
Output:
- `src/components/admin/catalog/ServiceTable.tsx` — full rewrite (ServiceRow + ServiceTable + quick-add row + active/inactive split)
- `src/app/admin/catalog/page.tsx` — adds client-side search bar, passes `ServiceWithTags[]`
- `src/components/admin/catalog/ServiceForm.tsx` — removed (quick-add row replaces its functionality); `page.tsx` no longer imports it
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md
@.planning/DESIGN-SYSTEM.md
</context>
<interfaces>
<!-- From src/lib/admin-queries.ts (Plan 02) -->
```typescript
export type ServiceWithTags = Service & { tags: string[] };
export async function getAllServices(): Promise<ServiceWithTags[]>;
```
<!-- From src/app/admin/catalog/actions.ts (Plan 02) -->
```typescript
export async function updateServiceField(
serviceId: string,
fieldName: "name" | "description" | "category" | "unit_price" | "active",
value: string | boolean
): Promise<void>;
export async function quickAddService(name: string): Promise<void>;
// addTagToService / removeTagFromService consumed internally by TagMultiSelect — not called directly here
```
<!-- From src/components/ui/editable-cell.tsx (Plan 03) -->
```typescript
export interface EditableCellProps {
value: string | number | boolean;
type?: "text" | "number" | "textarea" | "toggle";
onSave: (value: string) => void;
required?: boolean;
placeholder?: string;
disabled?: boolean;
formatDisplay?: (value: string) => string;
}
export function EditableCell(props: EditableCellProps): JSX.Element;
```
<!-- From src/components/ui/tag-multi-select.tsx (Plan 03) -->
```typescript
export interface TagMultiSelectProps {
tags: string[];
serviceId: string;
onTagsChanged?: () => void;
}
export function TagMultiSelect(props: TagMultiSelectProps): JSX.Element;
```
<!-- src/components/ui/input.tsx — Input (forwardRef<HTMLInputElement>) for the search bar -->
<!-- lucide-react v1.14.0 — Search icon available for the search bar per DESIGN-SYSTEM.md -->
<!-- Current src/app/admin/catalog/page.tsx (full file, 29 lines) — to be modified -->
```typescript
import { getAllServices } from "@/lib/admin-queries";
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
import { ServiceForm } from "@/components/admin/catalog/ServiceForm";
export const revalidate = 0;
export default async function CatalogPage() {
const services = await getAllServices();
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
</div>
<div className="mb-6">
<ServiceForm />
</div>
{services.length === 0 ? (
<p className="text-sm text-[#71717a]">Nessun servizio nel catalogo. Aggiungi il primo servizio qui sopra.</p>
) : (
<ServiceTable services={services} />
)}
</div>
);
}
```
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Rewrite ServiceTable as database-view (inline edit, tags, quick-add, active/inactive split)</name>
<files>src/components/admin/catalog/ServiceTable.tsx</files>
<read_first>
src/components/admin/catalog/ServiceTable.tsx (current full file, 174 lines — note the existing column order: Nome, Descrizione, Categoria, Prezzo, Stato, Azioni; the price formatting at lines 124-125; the opacity-50 inactive styling at line 114)
src/components/ui/editable-cell.tsx (Plan 03 output — EditableCellProps)
src/components/ui/tag-multi-select.tsx (Plan 03 output — TagMultiSelectProps)
src/app/admin/catalog/actions.ts (Plan 02 output — updateServiceField, quickAddService signatures)
.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md (D-11 description column, D-12 quick-add row, D-13 inactive divider, D-14 no actions column)
.planning/DESIGN-SYSTEM.md (row height ~40px, no vertical borders, sticky header, hover bg-muted/50)
</read_first>
<behavior>
- Test 1 (columns): table header has exactly 6 columns: Nome, Descrizione, Categoria, Prezzo, Tag, Stato — NO "Azioni" column
- Test 2 (inline edit, name): clicking the Nome cell of a service renders `EditableCell` with `type="text"`, `required=true`; saving calls `updateServiceField(service.id, "name", value)`
- Test 3 (inline edit, description): Descrizione cell uses `EditableCell` with `type="textarea"`, full-width, truncated with `line-clamp-2` in display mode (D-11)
- Test 4 (inline edit, category): Categoria cell uses `EditableCell` with `type="text"`, not required
- Test 5 (inline edit, price): Prezzo cell uses `EditableCell` with `type="number"` and `formatDisplay` rendering `€{toLocaleString("it-IT", {minimumFractionDigits: 2})}`; saving calls `updateServiceField(service.id, "unit_price", value)`
- Test 6 (tags column): Tag cell renders `TagMultiSelect` with `tags={service.tags}` and `serviceId={service.id}`
- Test 7 (status toggle): Stato cell uses `EditableCell` with `type="toggle"`, value `service.active ? "true" : "false"`; saving calls `updateServiceField(service.id, "active", value)`
- Test 8 (quick-add row): a row at the bottom of the active-services section has a borderless `Input` with placeholder "+ Aggiungi servizio"; pressing Enter with non-empty text calls `quickAddService(name)`, clears the input, and other cells in that row are empty (`colSpan` or empty `<td>`s)
- Test 9 (active/inactive split): services are partitioned into `activeServices` (active=true) and `inactiveServices` (active=false); active services render first (in `services.name asc` order from the query), then a divider row with text "Servizi disattivati", then inactive services with `opacity-50` applied to the row
- Test 10 (empty state): if `services.length === 0`, render a message instead of an empty table (handled by `page.tsx`, but `ServiceTable` should not crash on `services=[]`)
- Test 11 (refresh after save): every `onSave`/`onTagsChanged` callback calls `router.refresh()` after the server action resolves
- Test 12 (row styling): each `<tr>` has `border-b border-[#e5e7eb]` (no vertical borders), `hover:bg-[#f9f9f9] transition-colors duration-150`
</behavior>
<action>
Replace the entire contents of `src/components/admin/catalog/ServiceTable.tsx` with:
```typescript
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { EditableCell } from "@/components/ui/editable-cell";
import { TagMultiSelect } from "@/components/ui/tag-multi-select";
import { updateServiceField, quickAddService } from "@/app/admin/catalog/actions";
import type { ServiceWithTags } from "@/lib/admin-queries";
function formatPrice(raw: string): string {
const num = parseFloat(raw);
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
}
function ServiceRow({ service }: { service: ServiceWithTags }) {
const router = useRouter();
const [, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function saveField(
field: "name" | "description" | "category" | "unit_price" | "active",
value: string
) {
setError(null);
startTransition(async () => {
try {
await updateServiceField(service.id, field, value);
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
return (
<tr
className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${
!service.active ? "opacity-50" : ""
}`}
>
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
<EditableCell
value={service.name}
type="text"
required
onSave={(v) => saveField("name", v)}
/>
</td>
<td className="py-2 px-3 text-[#71717a] min-w-[220px]">
<EditableCell
value={service.description ?? ""}
type="textarea"
placeholder="Descrizione..."
onSave={(v) => saveField("description", v)}
/>
</td>
<td className="py-2 px-3 text-[#71717a] min-w-[120px]">
<EditableCell
value={service.category ?? ""}
type="text"
placeholder="Categoria..."
onSave={(v) => saveField("category", v)}
/>
</td>
<td className="py-2 px-3 tabular-nums min-w-[100px]">
<EditableCell
value={service.unit_price}
type="number"
formatDisplay={formatPrice}
onSave={(v) => saveField("unit_price", v)}
/>
</td>
<td className="py-2 px-3 min-w-[180px]">
<TagMultiSelect
tags={service.tags}
serviceId={service.id}
onTagsChanged={() => router.refresh()}
/>
</td>
<td className="py-2 px-3 min-w-[100px]">
<EditableCell
value={service.active ? "true" : "false"}
type="toggle"
onSave={(v) => saveField("active", v)}
/>
</td>
{error && (
<td className="py-1 px-3 text-xs text-red-600" colSpan={6}>
{error}
</td>
)}
</tr>
);
}
function QuickAddRow() {
const [name, setName] = useState("");
const [, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const router = useRouter();
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key !== "Enter") return;
const trimmed = name.trim();
if (!trimmed) return;
e.preventDefault();
setError(null);
startTransition(async () => {
try {
await quickAddService(trimmed);
setName("");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Errore nel salvataggio");
}
});
}
return (
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
<td className="py-2 px-3">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="+ Aggiungi servizio"
className="border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary"
/>
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</td>
<td colSpan={5}></td>
</tr>
);
}
const COLUMN_HEADERS = ["Nome", "Descrizione", "Categoria", "Prezzo", "Tag", "Stato"];
export function ServiceTable({ services }: { services: ServiceWithTags[] }) {
const activeServices = services.filter((s) => s.active);
const inactiveServices = services.filter((s) => !s.active);
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
<tr>
{COLUMN_HEADERS.map((header) => (
<th
key={header}
className="text-left py-2 px-3 font-semibold text-[#71717a]"
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{activeServices.map((s) => (
<ServiceRow key={s.id} service={s} />
))}
<QuickAddRow />
{inactiveServices.length > 0 && (
<>
<tr className="border-b border-t-2 border-t-[#e5e7eb] border-[#e5e7eb]">
<td colSpan={6} className="py-1.5 px-3 text-xs font-medium text-[#71717a] uppercase tracking-wide">
Servizi disattivati
</td>
</tr>
{inactiveServices.map((s) => (
<ServiceRow key={s.id} service={s} />
))}
</>
)}
</tbody>
</table>
</div>
</div>
);
}
```
Notes:
- Row height target ~40px is achieved via `py-2` (8px top/bottom padding) on `text-sm` cells — close to the 40px spec without being cramped; adjust to `py-1.5` if visual QA in Plan output finds rows too tall.
- The error message row (`colSpan={6}`) only renders when a save fails — does not affect row height in the success path.
- `QuickAddRow` is placed between active and inactive services per D-12/D-13 ordering (new services are active by default, so the add row visually belongs with the active group).
- The "Servizi disattivati" divider uses `border-t-2` for visual separation per D-13, with `uppercase tracking-wide` for a small-caps-like label per DESIGN-SYSTEM.md ("small-caps ok, ALL-CAPS pesante no" — `uppercase` + `text-xs` + `tracking-wide` on a short label is the lightweight interpretation).
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | grep -i "ServiceTable" ; echo "exit: $?"</automated>
</verify>
<acceptance_criteria>
- `grep -c "EditableCell" src/components/admin/catalog/ServiceTable.tsx` returns >= 5 (name, description, category, price, status)
- `grep -c "TagMultiSelect" src/components/admin/catalog/ServiceTable.tsx` returns >= 1
- `grep -c "Azioni" src/components/admin/catalog/ServiceTable.tsx` returns `0` (no actions column)
- `grep -c "quickAddService" src/components/admin/catalog/ServiceTable.tsx` returns >= 1
- `grep -c "updateServiceField" src/components/admin/catalog/ServiceTable.tsx` returns >= 1
- `grep -c "Servizi disattivati" src/components/admin/catalog/ServiceTable.tsx` returns `1`
- `grep -c "opacity-50" src/components/admin/catalog/ServiceTable.tsx` returns >= 1
- `grep -c "router.refresh()" src/components/admin/catalog/ServiceTable.tsx` returns >= 2
- `grep -c "\"Nome\", \"Descrizione\", \"Categoria\", \"Prezzo\", \"Tag\", \"Stato\"" src/components/admin/catalog/ServiceTable.tsx` returns `1`
- `npx tsc --noEmit` produces zero errors referencing `src/components/admin/catalog/ServiceTable.tsx`
</acceptance_criteria>
<done>
`/admin/catalog` table has 6 columns (no Azioni), every cell is an `EditableCell` or `TagMultiSelect`, a quick-add row creates new services on Enter, inactive services sink below a "Servizi disattivati" divider with `opacity-50`, and all mutations trigger `router.refresh()`. Typecheck passes.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add client-side search bar to page.tsx, remove ServiceForm</name>
<files>
src/app/admin/catalog/page.tsx
src/components/admin/catalog/ServiceForm.tsx
</files>
<read_first>
src/app/admin/catalog/page.tsx (current full file, 29 lines)
src/components/admin/catalog/ServiceForm.tsx (current full file, 102 lines — to be deleted/emptied)
src/components/admin/catalog/ServiceTable.tsx (Task 1 output — ServiceWithTags prop)
src/components/ui/input.tsx
.planning/DESIGN-SYSTEM.md (search bar: "input singolo con icona search (Lucide), filtro client-side istantaneo su nome/tag — NO bottone Cerca, NO reload")
</read_first>
<behavior>
- Test 1 (server component fetches data): `page.tsx` remains an async Server Component calling `getAllServices()` — returns `ServiceWithTags[]`
- Test 2 (client filter component): a new client component (`CatalogSearch` or inline in a client wrapper) holds the search query state and filters the `services` array before passing to `ServiceTable`
- Test 3 (filter match): typing a query filters services where `service.name` OR any `service.tags[]` entry contains the query (case-insensitive substring match)
- Test 4 (empty query): empty search input shows all services (active+inactive split unaffected)
- Test 5 (no reload): filtering happens via React state — no `router.push`/`router.refresh`/form submission
- Test 6 (ServiceForm removed): `page.tsx` no longer imports or renders `ServiceForm`; `ServiceForm.tsx` is deleted (its quick-add functionality is now in `ServiceTable`'s `QuickAddRow`)
- Test 7 (empty catalog message): if `getAllServices()` returns `[]`, show the existing "Nessun servizio..." message (still works even with 0 services, since `ServiceTable` itself renders fine with an empty array but the page-level empty state is friendlier for first-run)
</behavior>
<action>
1. Delete `src/components/admin/catalog/ServiceForm.tsx` entirely (its "+ Aggiungi servizio" quick-add is now `QuickAddRow` inside `ServiceTable`, and its full-form fields — description/category/price at creation time — are no longer needed since D-12 quick-add creates with `unit_price=0` and the user fills in the rest inline immediately after).
2. Since filtering needs client-side state, create the filter UI as a client component co-located in `page.tsx`'s directory. Create `src/app/admin/catalog/CatalogSearch.tsx`:
```typescript
"use client";
import { useState, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
import type { ServiceWithTags } from "@/lib/admin-queries";
export function CatalogSearch({ services }: { services: ServiceWithTags[] }) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return services;
return services.filter((s) => {
if (s.name.toLowerCase().includes(q)) return true;
return s.tags.some((tag) => tag.toLowerCase().includes(q));
});
}, [services, query]);
return (
<div className="space-y-4">
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
<Input
type="text"
placeholder="Cerca per nome o tag..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 h-9"
/>
</div>
<ServiceTable services={filtered} />
</div>
);
}
```
3. Replace `src/app/admin/catalog/page.tsx` with:
```typescript
import { getAllServices } from "@/lib/admin-queries";
import { CatalogSearch } from "./CatalogSearch";
export const revalidate = 0;
export default async function CatalogPage() {
const services = await getAllServices();
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
</div>
{services.length === 0 ? (
<p className="text-sm text-[#71717a]">
Nessun servizio nel catalogo. Scrivi un nome nella riga in fondo alla tabella e premi invio per crearne uno.
</p>
) : (
<CatalogSearch services={services} />
)}
</div>
);
}
```
Note: when `services.length === 0`, `CatalogSearch`/`ServiceTable` is not rendered, so the quick-add row (which lives inside `ServiceTable`) is not visible on a completely empty catalog. This matches the existing "Nessun servizio" UX from before Phase 11, but the message now points the user at the table's quick-add row. If this is undesirable in practice (catalog is currently non-empty per Phase 7 migration, so this is an edge case), it can be revisited — not blocking for Phase 11 success criteria, which describe filtering/quick-add behavior on a populated catalog.
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | grep -i "catalog" ; echo "exit: $?"</automated>
</verify>
<acceptance_criteria>
- `test -f src/components/admin/catalog/ServiceForm.tsx; echo $?` returns `1` (file deleted)
- `grep -c "ServiceForm" src/app/admin/catalog/page.tsx` returns `0`
- `test -f src/app/admin/catalog/CatalogSearch.tsx; echo $?` returns `0`
- `grep -c "export function CatalogSearch" src/app/admin/catalog/CatalogSearch.tsx` returns `1`
- `grep -c "useState\|useMemo" src/app/admin/catalog/CatalogSearch.tsx` returns >= 2
- `grep -c "s.tags.some" src/app/admin/catalog/CatalogSearch.tsx` returns `1`
- `grep -c "router.push\|router.refresh\|<form" src/app/admin/catalog/CatalogSearch.tsx` returns `0` (purely client-state filtering, no navigation/reload)
- `grep -c "getAllServices" src/app/admin/catalog/page.tsx` returns `1`
- `npx tsc --noEmit` produces zero errors referencing `src/app/admin/catalog/`
- `npx next build` (or `npm run build`) completes without errors related to `/admin/catalog`
</acceptance_criteria>
<done>
`/admin/catalog` has a search input above the table that filters services by name or tag, client-side, with zero reload. `ServiceForm.tsx` is deleted; its create-service UX is fully replaced by `ServiceTable`'s quick-add row. Build succeeds.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|--------------|
| Admin browser -> ServiceTable/CatalogSearch -> Server Actions | All mutations (`updateServiceField`, `quickAddService`, tag actions via `TagMultiSelect`) flow through Plan 02's admin-gated actions |
| Client-side search filter | Pure presentational filter over already-fetched `ServiceWithTags[]` — no new data fetched based on query input |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-11-14 | Information Disclosure | `CatalogSearch` client-side filter | accept | All `services` (including inactive ones) are sent to the client regardless of search query — this is the existing behavior (ServiceTable already received the full list pre-Phase-11) and is required for the active/inactive split + instant client-side search (OFFER-10). No new data is exposed: `/admin/catalog` is already behind Auth.js session (middleware admin guard). |
| T-11-15 | Tampering | `QuickAddRow` repeated Enter presses | mitigate | `startTransition` + the input is not cleared until the action resolves successfully; a failed `quickAddService` call surfaces an inline error without creating a partial row, since the DB insert is atomic. |
| T-11-16 | Denial of Service | Large catalog (many services) rendered client-side | accept | Catalog size is bounded by manual admin entry (single-admin app, no bulk import in this phase per CONTEXT.md — CSV import is Phase 12). Acceptable for current and near-future scale. |
No HIGH-severity unmitigated threats. Authorization for all mutating operations is enforced in Plan 02's server actions, inherited transitively by this plan's UI.
</threat_model>
<verification>
1. `npx tsc --noEmit` passes with zero errors
2. `npm run build` (or `npx next build`) completes successfully
3. Manual smoke test (covered by checkpoint below): `/admin/catalog` renders the database-view table with inline edit, tags, quick-add row, search bar, and active/inactive split
</verification>
<success_criteria>
- OFFER-07: every cell (name, description, category, price, status) is click-to-edit via `EditableCell`, saving on Enter/blur, no modal/reload
- OFFER-08: tags column uses `TagMultiSelect`, supports creating new tags on the fly
- OFFER-09: quick-add row at the bottom of the active section creates a new service (unit_price=0) on Enter
- OFFER-10: search bar filters by name/tag, client-side, instant, no reload
- D-13/D-14: inactive services sink below a divider with reduced opacity; no "Azioni" column anywhere
- Build passes (`npm run build`)
</success_criteria>
<output>
After completion, create `.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-04-SUMMARY.md`
</output>