docs(14): create phase plan
Plan Phase 14 (CRM Attio-style & Fix) into 3 waves: data-layer foundation (query helpers + server actions), LeadTable raw-table rewrite with inline edit/tags, and isolated bug fixes for CRM-10/11/12.
This commit is contained in:
+134
-1
@@ -1 +1,134 @@
|
|||||||
PLACEHOLDER
|
# Roadmap: ClientHub v2.1 Offer Studio + Proposal AI
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**v2.0 (Phases 1–10, Complete)**: ClientHub è una suite operativa completa — catalogo servizi unificato, offerte con fasi, preventivo pubblico (`/quote/[token]`), CRM pipeline lead con activity log. Archiviato in `.planning/milestones/v2.0-ROADMAP.md`.
|
||||||
|
|
||||||
|
**v2.1 (Phases 11–17, Planning)**: Rifare la UX di catalogo/offerte in stile database-view (Notion-like), ristrutturare il workspace progetto (Preventivo → Servizi Attivi, rimozione Forecast), ricostruire il CRM in stile Attio/Pipedrive (inline edit, tag, fix bug residui), e chiudere il loop commerciale con un builder preventivi AI + invio email + auto-provisioning. Numerazione fasi: v2.0 ha chiuso a Phase 10; v2.1 parte da Phase 11.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
- [x] **Phase 11: Catalog Database-View UX & Legacy Consolidation** - Catalogo `services` come tabella con inline edit, tag multi-select, quick-add, ricerca istantanea; consolidamento `service_catalog`/`offer_services` → `services`
|
||||||
|
- [ ] **Phase 12: Offer Composition Drag&Drop & CSV Import** - Composizione offerte via drag&drop dal catalogo (`@dnd-kit`) con totale live; import CSV one-shot
|
||||||
|
- [ ] **Phase 13: Workspace — Servizi Attivi** - Tab "Preventivo" rimossa (UI-only), "Offerte" → "Servizi attivi" (one-shot/ricorrenti + tracking incassi mensili), rimozione pagina Forecast
|
||||||
|
- [ ] **Phase 14: CRM Attio-style & Fix** - Redesign `/admin/leads` stile Attio/Pipedrive (inline edit + tag), fix FollowUpWidget IT / LeadForm types / SendQuoteModal rami irraggiungibili
|
||||||
|
- [ ] **Phase 15: Dashboard Revenue Stats** - Dashboard admin mostra statistiche revenue mensile (ex `/admin/forecast`) da `project_offers` — **BLOCCATA**, attesa mockup utente
|
||||||
|
- [ ] **Phase 16: Proposal AI — Data Foundations & Auto-Provisioning** - Campo Stripe Payment Link su offerte/preventivi + auto-provisioning (client+progetto+fasi+pagamenti) all'accettazione
|
||||||
|
- [ ] **Phase 17: Proposal AI — Builder, Pagina Pubblica & Email** - Builder AI proposta da cliente+offerta+note, redesign `/quote/[token]`, invio email Resend con link proposta
|
||||||
|
|
||||||
|
## Phase Details
|
||||||
|
|
||||||
|
### Phase 11: Catalog Database-View UX & Legacy Consolidation
|
||||||
|
**Goal**: L'utente vede ed edita il catalogo servizi come una tabella stile database (Notion-like) — inline editing, tag multi-select, quick-add, ricerca istantanea — su un'unica tabella `services` consolidata senza perdita di dati legacy
|
||||||
|
**Mode:** migration + ui
|
||||||
|
**Depends on**: Phase 10 (CRM Pipeline, ultima fase v2.0)
|
||||||
|
**Requirements**: OFFER-07, OFFER-08, OFFER-09, OFFER-10, OFFER-13
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. L'utente edita inline ogni cella del catalogo `services` (click sulla cella → edit → invio salva) senza apertura di modali
|
||||||
|
2. L'utente assegna tag multi-select a servizi/offerte, creando nuovi tag al volo senza uscire dalla tabella
|
||||||
|
3. L'utente aggiunge un nuovo servizio tramite riga vuota in fondo alla tabella (nome + invio)
|
||||||
|
4. L'utente filtra/cerca servizi e offerte istantaneamente (ricerca client-side, nessun reload)
|
||||||
|
5. I dati legacy di `service_catalog` e `offer_services` sono consolidati in `services` senza perdita (migration additiva, audit trail `migrated_from`/`migrated_id`)
|
||||||
|
**Plans**: 4 plans
|
||||||
|
**Plan list**:
|
||||||
|
- [x] 11-01-PLAN.md
|
||||||
|
- [x] 11-02-PLAN.md
|
||||||
|
- [x] 11-03-PLAN.md
|
||||||
|
- [x] 11-04-PLAN.md
|
||||||
|
**UI hint**: yes
|
||||||
|
**Status**: ✅ Complete (2026-06-13) — migration 0007 (`services.fase`) applicata in prod, commit pushato su `gitea/main`
|
||||||
|
|
||||||
|
### Phase 12: Offer Composition Drag&Drop & CSV Import
|
||||||
|
**Goal**: L'utente compone un'offerta trascinando servizi dal catalogo con un totale live durante il drag, e può importare un CSV one-shot per popolare servizi/offerte esistenti
|
||||||
|
**Mode:** ui
|
||||||
|
**Depends on**: Phase 11 (tabella `services` unificata)
|
||||||
|
**Requirements**: OFFER-11, OFFER-12
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. L'utente compone un'offerta trascinando servizi dal catalogo (`@dnd-kit`)
|
||||||
|
2. Durante il drag, il totale dell'offerta si aggiorna live
|
||||||
|
3. L'utente importa un CSV one-shot per popolare servizi/offerte esistenti (solo seed iniziale, non sync continuo)
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
**Status**: Pending planning
|
||||||
|
|
||||||
|
### Phase 13: Workspace — Servizi Attivi
|
||||||
|
**Goal**: Il workspace progetto sostituisce le tab "Preventivo"/"Offerte"/"Forecast" con un'unica tab "Servizi attivi" che traccia servizi one-shot e ricorrenti con incassi mensili, senza perdere `accepted_total`/`quote_items`
|
||||||
|
**Mode:** ui + schema
|
||||||
|
**Depends on**: Phase 5 (`project_offers`), Phase 9 (flusso `accepted_total` via quote)
|
||||||
|
**Requirements**: PROJ-06, PROJ-07, PROJ-08, PROJ-09, PROJ-10
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. La tab "Preventivo" è rimossa dal workspace progetto (solo UI — `quote_items` resta intatta, `accepted_total` resta editabile via tab Pagamenti)
|
||||||
|
2. La tab "Offerte" diventa "Servizi attivi" e mostra tutte le offerte assegnate al progetto (`project_offers`)
|
||||||
|
3. L'utente marca ogni "servizio attivo" come one-shot o ricorrente mensile
|
||||||
|
4. Per i servizi ricorrenti, l'utente registra in quali mesi è stata incassata la fattura (schema nuovo, additivo)
|
||||||
|
5. La pagina "Forecast" e la relativa voce di sidebar sono rimosse
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
**Status**: Pending planning
|
||||||
|
|
||||||
|
### Phase 14: CRM Attio-style & Fix
|
||||||
|
**Goal**: La tabella lead (`/admin/leads`) è ridisegnata in stile Attio/Pipedrive — inline editing dei campi principali e tag multi-select — e i bug residui del modulo CRM (FollowUpWidget non in italiano, LeadForm con type-relaxation, SendQuoteModal con rami irraggiungibili) sono risolti
|
||||||
|
**Mode:** ui
|
||||||
|
**Depends on**: Phase 10 (CRM Pipeline base)
|
||||||
|
**Requirements**: CRM-08, CRM-09, CRM-10, CRM-11, CRM-12
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. La tabella lead supporta inline editing dei campi principali (status, next_action, ecc.) senza apertura di modale
|
||||||
|
2. L'utente assegna tag multi-select ai lead, creando nuovi tag al volo
|
||||||
|
3. Il `FollowUpWidget` è interamente in italiano, coerente col resto dell'app
|
||||||
|
4. Il form lead (`LeadForm`) valida i campi senza workaround di type-relaxation su react-hook-form
|
||||||
|
5. Il flusso `SendQuoteModal` (preventivo esistente vs nuovo) non contiene rami di codice irraggiungibili
|
||||||
|
**Plans:** 3 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 14-01-PLAN.md — Data-layer foundation: getLeadsWithTags/getLeadFieldOptions query helpers + updateLeadField/addLeadTag/removeLeadTag/renameLeadTag server actions (requireAdmin-guarded)
|
||||||
|
- [ ] 14-02-PLAN.md — LeadTable raw-table rewrite (inline edit + status dropdown + tag multi-select), LeadsSearch instant filter, page wiring, LeadDetail tags
|
||||||
|
- [ ] 14-03-PLAN.md — Bug fixes: FollowUpWidget Italian translation (CRM-10), LeadForm typed useForm (CRM-11), SendQuoteModal dead branch removal (CRM-12)
|
||||||
|
**UI hint**: yes — riferimento design Pipedrive/Attio (confermato dall'utente 2026-06-13)
|
||||||
|
**Status**: Planned
|
||||||
|
|
||||||
|
### Phase 15: Dashboard Revenue Stats
|
||||||
|
**Goal**: La Dashboard admin mostra le statistiche di revenue mensile (ex `/admin/forecast`), calcolate da `project_offers`
|
||||||
|
**Mode:** ui
|
||||||
|
**Depends on**: Phase 13 (modello dati Servizi attivi)
|
||||||
|
**Requirements**: DASH-11
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. La Dashboard admin mostra un breakdown di revenue mensile calcolato da `project_offers`
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
**Status**: Blocked — attesa mockup dashboard dall'utente (isolata/skippabile, non blocca Phase 16/17)
|
||||||
|
|
||||||
|
### Phase 16: Proposal AI — Data Foundations & Auto-Provisioning
|
||||||
|
**Goal**: Ogni offerta/preventivo ha un campo link di pagamento Stripe mostrato come CTA pubblica, e all'accettazione del preventivo il sistema crea automaticamente cliente + progetto, copia le fasi dall'offerta e configura 1-4 pagamenti
|
||||||
|
**Mode:** schema + automation
|
||||||
|
**Depends on**: Phase 9 (flusso accettazione preventivo), Phase 13 (Servizi attivi come target del provisioning)
|
||||||
|
**Requirements**: PROP-03, PROP-04
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. Ogni offerta/preventivo ha un campo "link di pagamento" (Stripe Payment Link) mostrato come CTA nella pagina pubblica
|
||||||
|
2. All'accettazione del preventivo, il sistema crea automaticamente cliente + progetto, copia le fasi dall'offerta e configura 1-4 pagamenti
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
**Status**: Pending planning
|
||||||
|
|
||||||
|
### Phase 17: Proposal AI — Builder, Pagina Pubblica & Email
|
||||||
|
**Goal**: Un builder AI genera una proposta a partire da cliente + offerta(e) selezionata(e) + note extra; la pagina pubblica `/quote/[token]` è ridisegnata; il link della proposta viene inviato via email (Resend) al lead
|
||||||
|
**Mode:** ai + ui
|
||||||
|
**Depends on**: Phase 16
|
||||||
|
**Requirements**: PROP-01, PROP-02, PROP-05
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. L'admin avvia un builder AI che genera una proposta a partire da cliente + offerta(e) selezionata(e) + note extra fornite manualmente
|
||||||
|
2. La pagina pubblica `/quote/[token]` è ridisegnata (HTML/CSS) per presentare la proposta generata
|
||||||
|
3. Il sistema invia via email (Resend) il link della proposta pubblica al lead
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
**Status**: Pending planning
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
| Phase | Plans | Status | Completed |
|
||||||
|
|-------|-------|--------|-----------|
|
||||||
|
| 11. Catalog Database-View UX & Legacy Consolidation | 4/4 | ✅ Done | 2026-06-13 |
|
||||||
|
| 12. Offer Composition Drag&Drop & CSV Import | — | Pending planning | — |
|
||||||
|
| 13. Workspace — Servizi Attivi | — | Pending planning | — |
|
||||||
|
| 14. CRM Attio-style & Fix | — | Pending planning (in progress) | — |
|
||||||
|
| 15. Dashboard Revenue Stats | — | Blocked (mockup utente) | — |
|
||||||
|
| 16. Proposal AI — Data Foundations & Auto-Provisioning | — | Pending planning | — |
|
||||||
|
| 17. Proposal AI — Builder, Pagina Pubblica & Email | — | Pending planning | — |
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
---
|
||||||
|
phase: 14-crm-attio-style-fix
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/lib/admin-queries.ts
|
||||||
|
- src/app/admin/leads/actions.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CRM-08, CRM-09]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Querying getLeadsWithTags() returns every lead row augmented with a `tags: string[]` array populated from the polymorphic tags table (entity_type='leads')"
|
||||||
|
- "Querying getLeadFieldOptions() returns the fixed LEAD_STAGES list for `status` and the distinct sorted set of lead tag names for `tags`"
|
||||||
|
- "Calling updateLeadField() for an unauthenticated request throws 'Non autorizzato' before touching the database"
|
||||||
|
- "Calling updateLeadField(leadId, 'status', 'not_a_stage') throws 'Stato non valido' and does not write to the leads table"
|
||||||
|
- "Calling addLeadTag/removeLeadTag/renameLeadTag mutates only rows where entity_type='leads', never touching entity_type='services' tag rows"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/lib/admin-queries.ts"
|
||||||
|
provides: "LeadWithTags type, LeadFieldOptions type, getLeadsWithTags(), getLeadFieldOptions()"
|
||||||
|
contains: "export async function getLeadsWithTags"
|
||||||
|
- path: "src/app/admin/leads/actions.ts"
|
||||||
|
provides: "updateLeadField, addLeadTag, removeLeadTag, renameLeadTag server actions with requireAdmin() guard"
|
||||||
|
contains: "export async function updateLeadField"
|
||||||
|
key_links:
|
||||||
|
- from: "src/app/admin/leads/actions.ts"
|
||||||
|
to: "src/db/schema.ts tags table"
|
||||||
|
via: "db.insert(tags)/db.delete(tags)/db.update(tags) scoped by entity_type='leads'"
|
||||||
|
pattern: "LEADS_TAG_ENTITY"
|
||||||
|
- from: "src/lib/admin-queries.ts getLeadsWithTags"
|
||||||
|
to: "src/db/schema.ts tags table"
|
||||||
|
via: "leftJoin scoped by entity_type='leads'"
|
||||||
|
pattern: "eq\\(tags\\.entity_type, LEADS_TAG_ENTITY\\)"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the data-layer foundation for the Attio-style lead table redesign: extend `src/lib/admin-queries.ts` with `getLeadsWithTags()` / `getLeadFieldOptions()` (mirroring Phase 11's `getAllServices()` / `getCatalogFieldOptions()` for the polymorphic `tags` table), and extend `src/app/admin/leads/actions.ts` with `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag` server actions — all guarded by `requireAdmin()` per the Phase 11 catalog convention.
|
||||||
|
|
||||||
|
Purpose: This is the Wave 1 "contracts + backend" plan. Plan 14-02 (LeadTable rewrite, LeadsSearch, page wiring) depends on the types and server actions created here.
|
||||||
|
|
||||||
|
Output: `LeadWithTags`/`LeadFieldOptions` types and query functions in `admin-queries.ts`; four new server actions in `leads/actions.ts`, all with `requireAdmin()` checks and dual `revalidatePath` calls (list + detail).
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/REQUIREMENTS.md
|
||||||
|
@.planning/phases/14-crm-attio-style-fix/14-RESEARCH.md
|
||||||
|
@.planning/phases/14-crm-attio-style-fix/14-PATTERNS.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Exact analog patterns from Phase 11 (shipped, lint-passing) — replicate structure 1:1 for leads -->
|
||||||
|
|
||||||
|
From src/lib/admin-queries.ts (lines 360-448, getAllServices / getCatalogFieldOptions — analog pattern):
|
||||||
|
```typescript
|
||||||
|
const TAG_ENTITY = "services";
|
||||||
|
const PACCHETTO_ENTITY = "services.pacchetto";
|
||||||
|
|
||||||
|
export type ServiceWithTags = Service & { tags: string[]; pacchetto: string[] };
|
||||||
|
|
||||||
|
export async function getAllServices(): Promise<ServiceWithTags[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: services.id,
|
||||||
|
name: services.name,
|
||||||
|
// ...all service columns...
|
||||||
|
tag_name: tags.name,
|
||||||
|
tag_type: tags.entity_type,
|
||||||
|
})
|
||||||
|
.from(services)
|
||||||
|
.leftJoin(
|
||||||
|
tags,
|
||||||
|
and(
|
||||||
|
eq(tags.entity_id, services.id),
|
||||||
|
inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(services.name), asc(tags.name));
|
||||||
|
|
||||||
|
const serviceMap = new Map<string, ServiceWithTags>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const { tag_name, tag_type, ...serviceFields } = row;
|
||||||
|
if (!serviceMap.has(row.id)) {
|
||||||
|
serviceMap.set(row.id, { ...serviceFields, tags: [], pacchetto: [] });
|
||||||
|
}
|
||||||
|
if (tag_name) {
|
||||||
|
const target = serviceMap.get(row.id)!;
|
||||||
|
if (tag_type === PACCHETTO_ENTITY) target.pacchetto.push(tag_name);
|
||||||
|
else target.tags.push(tag_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(serviceMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CatalogFieldOptions = {
|
||||||
|
tag: string[];
|
||||||
|
pacchetto: string[];
|
||||||
|
categoria: string[];
|
||||||
|
fase: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCatalogFieldOptions(): Promise<CatalogFieldOptions> {
|
||||||
|
const tagRows = await db
|
||||||
|
.selectDistinct({ name: tags.name, type: tags.entity_type })
|
||||||
|
.from(tags)
|
||||||
|
.where(inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY]));
|
||||||
|
|
||||||
|
const sortUnique = (arr: (string | null)[]) =>
|
||||||
|
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
|
||||||
|
(a, b) => a.localeCompare(b, "it")
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag: sortUnique(tagRows.filter((r) => r.type === TAG_ENTITY).map((r) => r.name)),
|
||||||
|
pacchetto: sortUnique(tagRows.filter((r) => r.type === PACCHETTO_ENTITY).map((r) => r.name)),
|
||||||
|
categoria: sortUnique(catRows.map((r) => r.value)),
|
||||||
|
fase: sortUnique(faseRows.map((r) => r.value)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/lib/admin-queries.ts (line 27-46, existing top-of-file imports — `Lead` is ALREADY imported):
|
||||||
|
```typescript
|
||||||
|
import { db } from "@/db";
|
||||||
|
import {
|
||||||
|
// ... existing entity tables ...
|
||||||
|
leads,
|
||||||
|
tags,
|
||||||
|
} from "@/db/schema";
|
||||||
|
import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm";
|
||||||
|
import type {
|
||||||
|
// ... existing types ...
|
||||||
|
Lead,
|
||||||
|
} from "@/db/schema";
|
||||||
|
```
|
||||||
|
NOTE: `eq`, `and`, `asc` are already imported. `leads` and `tags` table objects are already imported (used elsewhere in admin-queries.ts for other queries). `Lead` type is already imported. No new top-level imports needed for the query additions — only add `LEAD_STAGES` from `@/lib/lead-validators` if not already present (it is NOT currently imported in admin-queries.ts).
|
||||||
|
|
||||||
|
From src/db/schema.ts (lines 396-411, leads table — for column reference):
|
||||||
|
```typescript
|
||||||
|
export const leads = pgTable("leads", {
|
||||||
|
id: text("id").primaryKey().$defaultFn(() => nanoid()),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
email: text("email"),
|
||||||
|
phone: text("phone"),
|
||||||
|
company: text("company"),
|
||||||
|
status: text("status").notNull().default("contacted"),
|
||||||
|
last_contact_date: timestamp("last_contact_date", { withTimezone: true }),
|
||||||
|
next_action: text("next_action"),
|
||||||
|
next_action_date: timestamp("next_action_date", { withTimezone: true }),
|
||||||
|
notes: text("notes"),
|
||||||
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
});
|
||||||
|
export type Lead = typeof leads.$inferSelect;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/db/schema.ts (lines 119-134, tags table — polymorphic, already in prod):
|
||||||
|
```typescript
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
```
|
||||||
|
No migration needed — this table is already deployed to production with `entity_type='leads'` explicitly reserved in its design.
|
||||||
|
|
||||||
|
From src/app/admin/catalog/actions.ts (lines 1-21, imports + requireAdmin — exact pattern to replicate):
|
||||||
|
```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";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("Non autorizzato");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/app/admin/catalog/actions.ts (lines 131-199, tag CRUD — exact analog for addLeadTag/removeLeadTag/renameLeadTag):
|
||||||
|
```typescript
|
||||||
|
export async function addServiceOption(field: MultiSelectField, serviceId: string, value: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`);
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0) throw new Error("Valore richiesto");
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(tags)
|
||||||
|
.values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeServiceOption(field: MultiSelectField, serviceId: string, value: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(tags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(tags.entity_type, MULTI_ENTITY[field]),
|
||||||
|
eq(tags.entity_id, serviceId),
|
||||||
|
eq(tags.name, value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameServiceOption(field: MultiSelectField | SingleSelectField, oldValue: string, newValue: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
const next = newValue.trim();
|
||||||
|
if (next.length === 0) throw new Error("Nuovo nome richiesto");
|
||||||
|
if (next === oldValue) return;
|
||||||
|
|
||||||
|
if (field === "tag" || field === "pacchetto") {
|
||||||
|
await db
|
||||||
|
.update(tags)
|
||||||
|
.set({ name: next })
|
||||||
|
.where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue)));
|
||||||
|
}
|
||||||
|
// ... else branches for single-select fields, not needed for leads tags ...
|
||||||
|
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/lib/lead-validators.ts (existing, exports LEAD_STAGES):
|
||||||
|
```typescript
|
||||||
|
export const LEAD_STAGES = [
|
||||||
|
"contacted",
|
||||||
|
"qualified",
|
||||||
|
"proposal_sent",
|
||||||
|
"negotiating",
|
||||||
|
"won",
|
||||||
|
"lost",
|
||||||
|
] as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/app/admin/leads/actions.ts (existing file — current top, lines 1-10, what's already there):
|
||||||
|
```typescript
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { leads, quotes } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { createLeadSchema, updateLeadSchema, createActivitySchema } from "@/lib/lead-validators";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { createActivity, updateLeadStage } from "@/lib/lead-service";
|
||||||
|
```
|
||||||
|
Existing pre-Phase-14 actions (`createLead`, `updateLead`, `deleteLead`, `logActivity`, `assignQuoteToLead`) have NO `requireAdmin()` check — this is a pre-existing gap, OUT OF SCOPE for this plan (do not retrofit). New actions added in this plan MUST call `requireAdmin()`.
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Extend admin-queries.ts with LeadWithTags and LeadFieldOptions</name>
|
||||||
|
<files>src/lib/admin-queries.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/lib/admin-queries.ts (lines 1-46 for existing imports, lines 360-448 for the getAllServices/getCatalogFieldOptions analog pattern to replicate)
|
||||||
|
- src/lib/lead-validators.ts (LEAD_STAGES export)
|
||||||
|
- src/db/schema.ts (lines 119-134 tags table, lines 396-411 leads table)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
At the end of src/lib/admin-queries.ts (after the getCatalogFieldOptions function, i.e. after line 448), add a new section:
|
||||||
|
|
||||||
|
1. Add import: `import { LEAD_STAGES } from "@/lib/lead-validators";` to the top of the file (near other lib imports, after line 26's drizzle-orm import or with other internal imports — place it as a new top-level import line).
|
||||||
|
|
||||||
|
2. Add a new section with a header comment:
|
||||||
|
```typescript
|
||||||
|
// ── LeadWithTags — leads + tags (Phase 14 database-view) ─────────────────────
|
||||||
|
// Lead tags are stored in the polymorphic `tags` table, scoped by
|
||||||
|
// entity_type="leads". `status` is a fixed enum (LEAD_STAGES), not a
|
||||||
|
// dynamic pool — exposed via getLeadFieldOptions for the OptionSelect UI.
|
||||||
|
|
||||||
|
const LEADS_TAG_ENTITY = "leads";
|
||||||
|
|
||||||
|
export type LeadWithTags = Lead & { tags: string[] };
|
||||||
|
|
||||||
|
export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: leads.id,
|
||||||
|
name: leads.name,
|
||||||
|
email: leads.email,
|
||||||
|
phone: leads.phone,
|
||||||
|
company: leads.company,
|
||||||
|
status: leads.status,
|
||||||
|
last_contact_date: leads.last_contact_date,
|
||||||
|
next_action: leads.next_action,
|
||||||
|
next_action_date: leads.next_action_date,
|
||||||
|
notes: leads.notes,
|
||||||
|
created_at: leads.created_at,
|
||||||
|
updated_at: leads.updated_at,
|
||||||
|
tag_name: tags.name,
|
||||||
|
})
|
||||||
|
.from(leads)
|
||||||
|
.leftJoin(
|
||||||
|
tags,
|
||||||
|
and(
|
||||||
|
eq(tags.entity_id, leads.id),
|
||||||
|
eq(tags.entity_type, LEADS_TAG_ENTITY)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(leads.updated_at), asc(tags.name));
|
||||||
|
|
||||||
|
const leadMap = new Map<string, LeadWithTags>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const { tag_name, ...leadFields } = row;
|
||||||
|
if (!leadMap.has(row.id)) leadMap.set(row.id, { ...leadFields, tags: [] });
|
||||||
|
if (tag_name) leadMap.get(row.id)!.tags.push(tag_name);
|
||||||
|
}
|
||||||
|
return Array.from(leadMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeadFieldOptions = { status: string[]; tags: string[] };
|
||||||
|
|
||||||
|
export async function getLeadFieldOptions(): Promise<LeadFieldOptions> {
|
||||||
|
const tagRows = await db
|
||||||
|
.selectDistinct({ name: tags.name })
|
||||||
|
.from(tags)
|
||||||
|
.where(eq(tags.entity_type, LEADS_TAG_ENTITY));
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: [...LEAD_STAGES],
|
||||||
|
tags: tagRows
|
||||||
|
.map((r) => r.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b, "it")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `eq`, `and`, `asc` are already imported from `drizzle-orm` at line 26 — do not re-import.
|
||||||
|
- `leads` and `tags` are already imported from `@/db/schema` — do not re-import.
|
||||||
|
- `Lead` type is already imported from `@/db/schema` (line 45) — do not re-import.
|
||||||
|
- Ordering: use `.orderBy(desc(leads.updated_at), asc(tags.name))` — this preserves the existing list ordering behavior (most-recently-updated first, matching `getAllLeads()`'s current `desc(leads.updated_at)`, which is the current LeadTable's data source). `desc` is NOT currently in the drizzle-orm import list at line 26 (`eq, inArray, asc, isNull, sql, and`) — add it to that import.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c "export async function getLeadsWithTags" src/lib/admin-queries.ts && grep -c "export async function getLeadFieldOptions" src/lib/admin-queries.ts && npx tsc --noEmit</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c "export async function getLeadsWithTags" src/lib/admin-queries.ts` returns 1
|
||||||
|
- `grep -c "export async function getLeadFieldOptions" src/lib/admin-queries.ts` returns 1
|
||||||
|
- `grep -c "export type LeadWithTags" src/lib/admin-queries.ts` returns 1
|
||||||
|
- `grep -c "export type LeadFieldOptions" src/lib/admin-queries.ts` returns 1
|
||||||
|
- `grep -c "LEADS_TAG_ENTITY = \"leads\"" src/lib/admin-queries.ts` returns 1
|
||||||
|
- `grep -c "import { LEAD_STAGES } from \"@/lib/lead-validators\"" src/lib/admin-queries.ts` returns 1
|
||||||
|
- `grep -c "desc(leads.updated_at), asc(tags.name)" src/lib/admin-queries.ts` returns 1
|
||||||
|
- `npx tsc --noEmit` exits 0 (no new type errors introduced)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>getLeadsWithTags() and getLeadFieldOptions() exist in admin-queries.ts, type-check cleanly, follow the getAllServices/getCatalogFieldOptions pattern exactly (left-join + Map-reduce for tags), and use LEADS_TAG_ENTITY="leads" scoping.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Add updateLeadField + tag CRUD server actions to leads/actions.ts</name>
|
||||||
|
<files>src/app/admin/leads/actions.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- src/app/admin/leads/actions.ts (full current file — existing actions, imports, pattern conventions)
|
||||||
|
- src/app/admin/catalog/actions.ts (lines 1-21 requireAdmin pattern; lines 71-116 updateServiceField field-allowlist pattern; lines 131-199 tag CRUD pattern)
|
||||||
|
- src/lib/lead-validators.ts (LEAD_STAGES export)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
These are server actions (no dedicated test file in this codebase's convention — Phase 11's equivalent actions also ship without unit tests, verified via `npx tsc --noEmit` + manual QA in Plan 14-02). Document expected behaviors here for the executor's own verification during implementation (manual trace, not an automated test file):
|
||||||
|
- `updateLeadField(leadId, "name", "")` throws "Nome richiesto" (empty name rejected)
|
||||||
|
- `updateLeadField(leadId, "name", " Mario Rossi ")` trims to "Mario Rossi" and updates `leads.name`
|
||||||
|
- `updateLeadField(leadId, "status", "bogus")` throws "Stato non valido", no DB write
|
||||||
|
- `updateLeadField(leadId, "status", "won")` updates `leads.status` to "won" (valid LEAD_STAGES member)
|
||||||
|
- `updateLeadField(leadId, "email", "")` sets `leads.email` to `null` (empty clears nullable field)
|
||||||
|
- `updateLeadField(leadId, "email", "foo@bar.com")` sets `leads.email` to "foo@bar.com"
|
||||||
|
- `updateLeadField(leadId, "next_action", "Richiamare")` sets `leads.next_action` to "Richiamare"
|
||||||
|
- `addLeadTag(leadId, "VIP")` inserts a row into `tags` with `entity_type="leads", entity_id=leadId, name="VIP"`; calling it twice with the same value does not error (onConflictDoNothing)
|
||||||
|
- `removeLeadTag(leadId, "VIP")` deletes the matching `tags` row scoped to `entity_type="leads"` only (never touches `entity_type="services"` rows even if a service happens to share the same `entity_id` string)
|
||||||
|
- `renameLeadTag("VIP", "Priorita Alta")` updates ALL `tags` rows where `entity_type="leads"` and `name="VIP"` to `name="Priorita Alta"` (propagates across all leads sharing that tag)
|
||||||
|
- Every one of the above throws `"Non autorizzato"` immediately if `getServerSession(authOptions)` returns null/undefined, before any DB call
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Append to src/app/admin/leads/actions.ts (after the existing `assignQuoteToLead` function, end of file):
|
||||||
|
|
||||||
|
1. Add imports at the top of the file (merge with existing import block):
|
||||||
|
```typescript
|
||||||
|
import { tags } from "@/db/schema"; // add to existing `import { leads, quotes } from "@/db/schema";` -> becomes `import { leads, quotes, tags } from "@/db/schema";`
|
||||||
|
import { eq, and } from "drizzle-orm"; // existing line has `import { eq } from "drizzle-orm";` -> add `and`
|
||||||
|
import { LEAD_STAGES } from "@/lib/lead-validators"; // add to existing lead-validators import line
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add the `requireAdmin()` helper (exact copy from catalog/actions.ts):
|
||||||
|
```typescript
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("Non autorizzato");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add the field-update action with allowlist:
|
||||||
|
```typescript
|
||||||
|
// ── Inline-edit field update (Phase 14 database-view) ───────────────────────
|
||||||
|
|
||||||
|
const EDITABLE_FIELDS = ["name", "email", "phone", "company", "status", "next_action"] as const;
|
||||||
|
type EditableField = (typeof EDITABLE_FIELDS)[number];
|
||||||
|
|
||||||
|
export async function updateLeadField(
|
||||||
|
leadId: string,
|
||||||
|
fieldName: EditableField,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
if (!EDITABLE_FIELDS.includes(fieldName)) {
|
||||||
|
throw new Error(`Campo non editabile: ${fieldName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldName === "name") {
|
||||||
|
const s = value.trim();
|
||||||
|
if (s.length === 0) throw new Error("Nome richiesto");
|
||||||
|
await db.update(leads).set({ name: s, updated_at: new Date() }).where(eq(leads.id, leadId));
|
||||||
|
} else if (fieldName === "status") {
|
||||||
|
if (!LEAD_STAGES.includes(value as (typeof LEAD_STAGES)[number])) {
|
||||||
|
throw new Error("Stato non valido");
|
||||||
|
}
|
||||||
|
await db.update(leads).set({ status: value, updated_at: new Date() }).where(eq(leads.id, leadId));
|
||||||
|
} else {
|
||||||
|
// email | phone | company | next_action — nullable text fields, empty clears
|
||||||
|
const s = value.trim();
|
||||||
|
await db
|
||||||
|
.update(leads)
|
||||||
|
.set({ [fieldName]: s || null, updated_at: new Date() })
|
||||||
|
.where(eq(leads.id, leadId));
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/leads");
|
||||||
|
revalidatePath(`/admin/leads/${leadId}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add the polymorphic tag CRUD actions:
|
||||||
|
```typescript
|
||||||
|
// ── Lead tags (Phase 14, CRM-09) — polymorphic `tags` table, entity_type="leads" ─
|
||||||
|
|
||||||
|
const LEADS_TAG_ENTITY = "leads";
|
||||||
|
|
||||||
|
export async function addLeadTag(leadId: string, value: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0) throw new Error("Valore richiesto");
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(tags)
|
||||||
|
.values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
revalidatePath("/admin/leads");
|
||||||
|
revalidatePath(`/admin/leads/${leadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeLeadTag(leadId: string, value: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(tags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(tags.entity_type, LEADS_TAG_ENTITY),
|
||||||
|
eq(tags.entity_id, leadId),
|
||||||
|
eq(tags.name, value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidatePath("/admin/leads");
|
||||||
|
revalidatePath(`/admin/leads/${leadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameLeadTag(oldValue: string, newValue: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
const next = newValue.trim();
|
||||||
|
if (next.length === 0) throw new Error("Nuovo nome richiesto");
|
||||||
|
if (next === oldValue) return;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tags)
|
||||||
|
.set({ name: next })
|
||||||
|
.where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.name, oldValue)));
|
||||||
|
|
||||||
|
revalidatePath("/admin/leads");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note on `renameLeadTag`: no per-lead `revalidatePath(/admin/leads/${leadId})` call since the rename propagates across all leads sharing the tag (no single leadId to target) — `revalidatePath("/admin/leads")` covers the list; the detail page will pick up the change on next navigation/refresh (consistent with catalog's `renameServiceOption` which only revalidates `/admin/catalog`).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c "export async function updateLeadField\|export async function addLeadTag\|export async function removeLeadTag\|export async function renameLeadTag" src/app/admin/leads/actions.ts && npx tsc --noEmit && npx eslint src/app/admin/leads/actions.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c "export async function updateLeadField" src/app/admin/leads/actions.ts` returns 1
|
||||||
|
- `grep -c "export async function addLeadTag" src/app/admin/leads/actions.ts` returns 1
|
||||||
|
- `grep -c "export async function removeLeadTag" src/app/admin/leads/actions.ts` returns 1
|
||||||
|
- `grep -c "export async function renameLeadTag" src/app/admin/leads/actions.ts` returns 1
|
||||||
|
- `grep -c "await requireAdmin()" src/app/admin/leads/actions.ts` returns at least 4 (one per new action)
|
||||||
|
- `grep -c "LEAD_STAGES.includes" src/app/admin/leads/actions.ts` returns 1
|
||||||
|
- `grep -c "LEADS_TAG_ENTITY = \"leads\"" src/app/admin/leads/actions.ts` returns 1
|
||||||
|
- `grep -c "Non autorizzato" src/app/admin/leads/actions.ts` returns 1 (single shared requireAdmin helper)
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `npx eslint src/app/admin/leads/actions.ts` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Four new server actions (updateLeadField, addLeadTag, removeLeadTag, renameLeadTag) exist in src/app/admin/leads/actions.ts, each calling requireAdmin() first, with status validated against LEAD_STAGES server-side and tags scoped to entity_type="leads". File type-checks and lints cleanly. Pre-existing actions (createLead, updateLead, deleteLead, logActivity, assignQuoteToLead) are unchanged.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|--------------|
|
||||||
|
| Admin browser -> Server Action | Authenticated admin session calls `updateLeadField`/`addLeadTag`/`removeLeadTag`/`renameLeadTag` via Next.js Server Actions (directly invocable RPC endpoints if unguarded) |
|
||||||
|
| Server Action -> Postgres | Drizzle ORM parameterized queries against `leads` and `tags` tables |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-14-01 | Elevation of Privilege | `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag` (all new in this plan) | mitigate | Each action calls `await requireAdmin()` as its first statement — throws "Non autorizzato" if `getServerSession(authOptions)` returns null, before any DB read/write (Task 2) |
|
||||||
|
| T-14-02 | Tampering | `updateLeadField(leadId, "status", value)` | mitigate | Server-side allowlist check `LEAD_STAGES.includes(value)` rejects any status value outside the 6 fixed pipeline stages, independent of client UI restrictions (Task 2) |
|
||||||
|
| T-14-03 | Tampering | `updateLeadField` field allowlist | mitigate | `EDITABLE_FIELDS` const array gates which columns can be written; any other `fieldName` throws `Campo non editabile` before reaching the DB (Task 2) |
|
||||||
|
| T-14-04 | Information Disclosure / Tampering (cross-entity tag pollution) | `addLeadTag`/`removeLeadTag`/`renameLeadTag` | mitigate | All tag queries scoped with `eq(tags.entity_type, LEADS_TAG_ENTITY)` — cannot read/write/rename `entity_type="services"` tag rows even if `entity_id` strings collide (Task 2) |
|
||||||
|
| T-14-05 | Tampering (SQL injection) | All new DB queries | accept (no new risk) | Drizzle ORM parameterized queries used throughout; no raw SQL introduced |
|
||||||
|
| T-14-06 | Information Disclosure | `getLeadsWithTags()` exposes all lead PII columns (email, phone, company) to any caller | accept | Function is only invoked from `/admin/leads/page.tsx`, an Auth.js-session-protected route per CLAUDE.md constraint #4 (`/admin/*` -> Auth.js session) — no new exposure surface introduced by this plan |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `npx tsc --noEmit` — exits 0, no new type errors in admin-queries.ts or leads/actions.ts
|
||||||
|
2. `npx eslint src/lib/admin-queries.ts src/app/admin/leads/actions.ts` — exits 0
|
||||||
|
3. `grep -n "export async function getLeadsWithTags\|export async function getLeadFieldOptions" src/lib/admin-queries.ts` — both present
|
||||||
|
4. `grep -n "export async function updateLeadField\|export async function addLeadTag\|export async function removeLeadTag\|export async function renameLeadTag" src/app/admin/leads/actions.ts` — all four present
|
||||||
|
5. Manual trace through `<behavior>` block in Task 2 against the written code — every documented case has a corresponding code branch
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- `src/lib/admin-queries.ts` exports `LeadWithTags`, `LeadFieldOptions`, `getLeadsWithTags()`, `getLeadFieldOptions()` — all type-checked, following the getAllServices/getCatalogFieldOptions left-join + Map-reduce pattern
|
||||||
|
- `src/app/admin/leads/actions.ts` exports `updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag` — all guarded by `requireAdmin()`, `status` validated against `LEAD_STAGES`, tags scoped to `entity_type="leads"`
|
||||||
|
- No schema migration created or required (confirmed: `tags.entity_type` is unconstrained text, already supports "leads")
|
||||||
|
- `npx tsc --noEmit` and `npx eslint` both exit 0
|
||||||
|
- Pre-existing actions in `leads/actions.ts` (createLead, updateLead, deleteLead, logActivity, assignQuoteToLead) remain unchanged — no retrofit of requireAdmin() on those (out of scope per RESEARCH.md A5 / Open Question 4)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/14-crm-attio-style-fix/14-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,880 @@
|
|||||||
|
---
|
||||||
|
phase: 14-crm-attio-style-fix
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["14-01"]
|
||||||
|
files_modified:
|
||||||
|
- src/components/admin/leads/LeadTable.tsx
|
||||||
|
- src/app/admin/leads/LeadsSearch.tsx
|
||||||
|
- src/app/admin/leads/page.tsx
|
||||||
|
- src/components/admin/leads/LeadDetail.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CRM-08, CRM-09]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "The /admin/leads table renders with raw <table> markup (no shadcn Table/TableRow/TableCell wrapper), matching the ServiceTable.tsx visual pattern"
|
||||||
|
- "User can click any of name/email/phone/company/next_action cells, edit inline, press Enter, and the value persists via updateLeadField + router.refresh()"
|
||||||
|
- "User can open the Stato dropdown and pick one of the 6 fixed LEAD_STAGES values (no 'Crea' create-on-the-fly option appears for status)"
|
||||||
|
- "User can add/remove/rename tags on a lead via OptionMultiSelect, backed by addLeadTag/removeLeadTag/renameLeadTag"
|
||||||
|
- "User can type in the search box above the table and the visible rows filter instantly client-side (no network request) across name/email/company/status/tags"
|
||||||
|
- "Empty search results show 'Nessun lead trovato'"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/components/admin/leads/LeadTable.tsx"
|
||||||
|
provides: "Raw-table LeadRow + LeadTable, inline-edit + status/tags dropdowns"
|
||||||
|
contains: "EditableCell"
|
||||||
|
- path: "src/app/admin/leads/LeadsSearch.tsx"
|
||||||
|
provides: "Client-side instant search wrapper for LeadTable"
|
||||||
|
contains: "useMemo"
|
||||||
|
- path: "src/app/admin/leads/page.tsx"
|
||||||
|
provides: "Server component fetching getLeadsWithTags + getLeadFieldOptions, rendering LeadsSearch"
|
||||||
|
contains: "getLeadsWithTags"
|
||||||
|
key_links:
|
||||||
|
- from: "src/app/admin/leads/page.tsx"
|
||||||
|
to: "src/lib/admin-queries.ts"
|
||||||
|
via: "getLeadsWithTags() + getLeadFieldOptions()"
|
||||||
|
pattern: "getLeadsWithTags"
|
||||||
|
- from: "src/components/admin/leads/LeadTable.tsx"
|
||||||
|
to: "src/app/admin/leads/actions.ts"
|
||||||
|
via: "updateLeadField / addLeadTag / removeLeadTag / renameLeadTag"
|
||||||
|
pattern: "updateLeadField\\(lead\\.id"
|
||||||
|
- from: "src/app/admin/leads/LeadsSearch.tsx"
|
||||||
|
to: "src/components/admin/leads/LeadTable.tsx"
|
||||||
|
via: "renders <LeadTable leads={filtered} options={options} />"
|
||||||
|
pattern: "<LeadTable"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Rewrite `LeadTable.tsx` as a raw `<table>` database-view component (1:1 structural port of Phase 11's `ServiceTable.tsx`), wiring `EditableCell` for scalar fields, `OptionSelect` (fixed `LEAD_STAGES`, no create-on-the-fly) for `status`, and `OptionMultiSelect` for tags (CRM-09). Add a new `LeadsSearch.tsx` client-side instant-filter wrapper (mirrors `CatalogSearch.tsx`). Rewire `/admin/leads/page.tsx` to fetch via `getLeadsWithTags()`/`getLeadFieldOptions()` and render `LeadsSearch`. Additionally surface lead tags as an `OptionMultiSelect` in `LeadDetail.tsx`'s "Profilo" card (UI-SPEC.md Decision 4, low-cost addition).
|
||||||
|
|
||||||
|
Purpose: Delivers the CRM-08 (inline editing, database-view table) and CRM-09 (lead tags) user-facing experience, completing the Attio/Pipedrive-style redesign of `/admin/leads`.
|
||||||
|
|
||||||
|
Output: Rewritten `LeadTable.tsx`, new `LeadsSearch.tsx`, updated `page.tsx`, and `LeadDetail.tsx` with a tags `OptionMultiSelect`.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/REQUIREMENTS.md
|
||||||
|
@.planning/phases/14-crm-attio-style-fix/14-RESEARCH.md
|
||||||
|
@.planning/phases/14-crm-attio-style-fix/14-PATTERNS.md
|
||||||
|
@.planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Types and server actions created by Plan 14-01 — use these directly, no exploration needed -->
|
||||||
|
|
||||||
|
From src/lib/admin-queries.ts (created in Plan 14-01):
|
||||||
|
```typescript
|
||||||
|
export type LeadWithTags = Lead & { tags: string[] };
|
||||||
|
export type LeadFieldOptions = { status: string[]; tags: string[] };
|
||||||
|
|
||||||
|
export async function getLeadsWithTags(): Promise<LeadWithTags[]>;
|
||||||
|
export async function getLeadFieldOptions(): Promise<LeadFieldOptions>;
|
||||||
|
```
|
||||||
|
`LeadWithTags` includes all `Lead` columns: `id, name, email, phone, company, status, last_contact_date, next_action, next_action_date, notes, created_at, updated_at` plus `tags: string[]`.
|
||||||
|
|
||||||
|
`LeadFieldOptions.status` = `[...LEAD_STAGES]` (6 fixed values: `contacted | qualified | proposal_sent | negotiating | won | lost`). `LeadFieldOptions.tags` = distinct sorted lead tag names.
|
||||||
|
|
||||||
|
From src/app/admin/leads/actions.ts (created in Plan 14-01):
|
||||||
|
```typescript
|
||||||
|
export async function updateLeadField(leadId: string, fieldName: EditableField, value: string): Promise<void>;
|
||||||
|
// EditableField = "name" | "email" | "phone" | "company" | "status" | "next_action"
|
||||||
|
|
||||||
|
export async function addLeadTag(leadId: string, value: string): Promise<void>;
|
||||||
|
export async function removeLeadTag(leadId: string, value: string): Promise<void>;
|
||||||
|
export async function renameLeadTag(oldValue: string, newValue: string): Promise<void>;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/components/admin/catalog/ServiceTable.tsx (Phase 11, shipped — exact structural analog, full file at lines 1-140 for ServiceRow, 264-316 for ServiceTable):
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { EditableCell } from "@/components/ui/editable-cell";
|
||||||
|
import { OptionSelect } from "@/components/ui/option-select";
|
||||||
|
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||||
|
|
||||||
|
function ServiceRow({ service, options }: { service: ServiceWithTags; options: CatalogFieldOptions }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function run(fn: () => Promise<unknown>) {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
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">
|
||||||
|
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
|
||||||
|
<EditableCell value={service.name} type="text" required onSave={(v) => run(() => updateServiceField(service.id, "name", v))} />
|
||||||
|
</td>
|
||||||
|
{/* ... more <td> cells ... */}
|
||||||
|
</tr>
|
||||||
|
{error && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">{error}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceTable({ services, options }: { services: ServiceWithTags[]; options: CatalogFieldOptions }) {
|
||||||
|
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>
|
||||||
|
{services.map((service) => <ServiceRow key={service.id} service={service} options={options} />)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{services.length === 0 && <div className="text-center py-8 text-gray-500">Nessun servizio trovato</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/app/admin/catalog/CatalogSearch.tsx (Phase 11, shipped — exact structural analog for LeadsSearch.tsx, full file 46 lines):
|
||||||
|
```tsx
|
||||||
|
"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, CatalogFieldOptions } from "@/lib/admin-queries";
|
||||||
|
|
||||||
|
export function CatalogSearch({ services, options }: { services: ServiceWithTags[]; options: CatalogFieldOptions }) {
|
||||||
|
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;
|
||||||
|
if (s.category?.toLowerCase().includes(q)) return true;
|
||||||
|
if (s.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, [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, categoria, fase, tag o pacchetto..." value={query} onChange={(e) => setQuery(e.target.value)} className="pl-9 h-9" />
|
||||||
|
</div>
|
||||||
|
<ServiceTable services={filtered} options={options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/app/admin/catalog/page.tsx (Phase 11, shipped — exact structural analog for leads/page.tsx):
|
||||||
|
```tsx
|
||||||
|
import { getAllServices, getCatalogFieldOptions } from "@/lib/admin-queries";
|
||||||
|
import { CatalogSearch } from "./CatalogSearch";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function CatalogPage() {
|
||||||
|
const [services, options] = await Promise.all([getAllServices(), getCatalogFieldOptions()]);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
|
||||||
|
</div>
|
||||||
|
<CatalogSearch services={services} options={options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/app/admin/leads/page.tsx (CURRENT — to be replaced):
|
||||||
|
```tsx
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { getAllLeads } from "@/lib/lead-service";
|
||||||
|
import { LeadTable } from "@/components/admin/leads/LeadTable";
|
||||||
|
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
|
||||||
|
|
||||||
|
async function LeadsList() {
|
||||||
|
const leads = await getAllLeads();
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold">Lead Pipeline</h1>
|
||||||
|
<CreateLeadModal />
|
||||||
|
</div>
|
||||||
|
<Suspense fallback={<div className="animate-pulse">Loading...</div>}>
|
||||||
|
<LeadTable leads={leads} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeadsPage() {
|
||||||
|
return <LeadsList />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`CreateLeadModal` is unchanged (UI-SPEC.md Decision 3 — no quick-add row, CreateLeadModal remains the lead-creation entry point). Preserve it in the new page.
|
||||||
|
|
||||||
|
From src/components/ui/editable-cell.tsx (Phase 11, shipped — props contract, reuse as-is, NO modifications):
|
||||||
|
```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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/components/ui/option-select.tsx (Phase 11, shipped — props contract, reuse as-is, NO modifications):
|
||||||
|
```typescript
|
||||||
|
export interface OptionSelectProps {
|
||||||
|
value: string | null;
|
||||||
|
options: string[];
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
onRename?: (oldValue: string, newValue: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
IMPORTANT: `onRename` is OPTIONAL. For the `status` field, do NOT pass `onRename` — this is what makes the dropdown closed/non-extensible per UI-SPEC.md Decision 1 (the "Crea «...»" create-on-the-fly button only appears when the typed query has no exact match AND... actually the create button always renders for non-exact-match queries regardless of onRename). To fully prevent arbitrary status creation in the UI, see the Task 1 action notes below for the exact mitigation (server-side validation in `updateLeadField` already rejects invalid values per Plan 14-01 — this is defense in depth at the UI layer).
|
||||||
|
|
||||||
|
From src/components/ui/option-multi-select.tsx (Phase 11, shipped — props contract, reuse as-is, NO modifications):
|
||||||
|
```typescript
|
||||||
|
export interface OptionMultiSelectProps {
|
||||||
|
values: string[];
|
||||||
|
options: string[];
|
||||||
|
onAdd: (value: string) => void;
|
||||||
|
onRemove: (value: string) => void;
|
||||||
|
onRename?: (oldValue: string, newValue: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/components/admin/leads/LeadTable.tsx (CURRENT — to be replaced entirely):
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Lead } from "@/db/schema";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { LEAD_STAGES } from "@/lib/lead-validators";
|
||||||
|
|
||||||
|
const STAGE_COLOR: Record<string, string> = {
|
||||||
|
contacted: "bg-blue-100 text-blue-800",
|
||||||
|
qualified: "bg-purple-100 text-purple-800",
|
||||||
|
proposal_sent: "bg-amber-100 text-amber-800",
|
||||||
|
negotiating: "bg-orange-100 text-orange-800",
|
||||||
|
won: "bg-green-100 text-green-800",
|
||||||
|
lost: "bg-red-100 text-red-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LeadTable({ leads }: { leads: Lead[] }) { /* shadcn Table-based, read-only */ }
|
||||||
|
```
|
||||||
|
The new `LeadTable` signature changes to `{ leads, options }: { leads: LeadWithTags[]; options: LeadFieldOptions }`. `STAGE_COLOR` semantic map is PRESERVED per UI-SPEC.md Decision 1 (won=green, lost=red, etc.) — applied as a custom Badge override for the `status` cell, NOT via `getOptionColor()`'s hash palette.
|
||||||
|
|
||||||
|
From src/components/admin/leads/LeadDetail.tsx (CURRENT, lines 53-90 — "Profilo" Card to extend with tags):
|
||||||
|
```tsx
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profilo</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-600">Stato</label>
|
||||||
|
<Badge className={STAGE_COLOR[lead.status as keyof typeof STAGE_COLOR] || ""}>
|
||||||
|
{lead.status.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{/* email, telefono, ultimo contatto, prossima azione fields ... */}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
`LeadDetail` is currently a `"use client"` component receiving `{ lead, activities, reminders }: { lead: Lead; activities: Activity[]; reminders: Reminder[] }`. To add a tags `OptionMultiSelect`, the component needs: (a) `tags: string[]` and `tagOptions: string[]` passed as new props, (b) the `addLeadTag`/`removeLeadTag`/`renameLeadTag` actions + `useRouter`/`useTransition` wiring (same `run()` helper pattern as LeadTable).
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Rewrite LeadTable.tsx as raw-table database-view (CRM-08, CRM-09)</name>
|
||||||
|
<files>src/components/admin/leads/LeadTable.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- src/components/admin/leads/LeadTable.tsx (current file — to be fully replaced)
|
||||||
|
- src/components/admin/catalog/ServiceTable.tsx (exact structural analog, full file)
|
||||||
|
- src/components/ui/editable-cell.tsx (props contract)
|
||||||
|
- src/components/ui/option-select.tsx (props contract, esp. lines 49-66 for create-on-the-fly logic)
|
||||||
|
- src/components/ui/option-multi-select.tsx (props contract)
|
||||||
|
- .planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md (Table Layout — LeadTable section, Column Structure table, Design Decision 1)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
Manual-trace behaviors (no automated test file exists for this UI component in this codebase's convention — verified via `npx tsc --noEmit`, `npx eslint`, and the Wave-checkpoint visual verification in Task 2's follow-up; this `<behavior>` block documents the contract the implementation must satisfy):
|
||||||
|
- Table renders 8 columns in this exact order: Nome, Email, Telefono, Azienda, Stato, Prossima Azione, Tag, Azioni
|
||||||
|
- Each lead row renders as a raw `<tr>` with `border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150` — no shadcn `<TableRow>`
|
||||||
|
- Nome cell: `EditableCell(type="text", required)`, bold (`font-medium text-[#1a1a1a]`), `onSave` calls `updateLeadField(lead.id, "name", v)`
|
||||||
|
- Email/Telefono/Azienda/Prossima Azione cells: `EditableCell(type="text")`, value falls back to `"—"` when null, `onSave` calls `updateLeadField(lead.id, <field>, v)`
|
||||||
|
- Stato cell: `OptionSelect` with `value={lead.status}`, `options={options.status}` (the fixed 6-value LEAD_STAGES array), NO `onRename` prop passed, `onChange` calls `updateLeadField(lead.id, "status", v ?? "contacted")` — badge color overridden via STAGE_COLOR semantic map (not getOptionColor hash)
|
||||||
|
- Tag cell: `OptionMultiSelect` with `values={lead.tags}`, `options={options.tags}`, `onAdd`/`onRemove`/`onRename` wired to `addLeadTag`/`removeLeadTag`/`renameLeadTag`
|
||||||
|
- Azioni cell: `<Button variant="ghost" size="sm" asChild><Link href={`/admin/leads/${lead.id}`}>Dettagli</Link></Button>`
|
||||||
|
- Each row uses the `run()` transition+error pattern: on action error, an extra `<tr>` spanning all 8 columns shows `text-xs text-red-600` error message
|
||||||
|
- When `leads.length === 0`, render `<div className="text-center py-8 text-gray-500">Nessun lead trovato</div>`
|
||||||
|
- Component is `"use client"`, uses `useRouter`/`useTransition` from the ServiceRow `run()` pattern
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Replace the entire contents of `src/components/admin/leads/LeadTable.tsx` with a raw-table database-view component, structurally identical to `ServiceTable.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { EditableCell } from "@/components/ui/editable-cell";
|
||||||
|
import { OptionSelect } from "@/components/ui/option-select";
|
||||||
|
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||||
|
import {
|
||||||
|
updateLeadField,
|
||||||
|
addLeadTag,
|
||||||
|
removeLeadTag,
|
||||||
|
renameLeadTag,
|
||||||
|
} from "@/app/admin/leads/actions";
|
||||||
|
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
||||||
|
import { getOptionColor } from "@/components/ui/option-colors";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const STAGE_COLOR: Record<string, string> = {
|
||||||
|
contacted: "bg-blue-100 text-blue-800",
|
||||||
|
qualified: "bg-purple-100 text-purple-800",
|
||||||
|
proposal_sent: "bg-amber-100 text-amber-800",
|
||||||
|
negotiating: "bg-orange-100 text-orange-800",
|
||||||
|
won: "bg-green-100 text-green-800",
|
||||||
|
lost: "bg-red-100 text-red-800",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLUMN_HEADERS = [
|
||||||
|
"Nome",
|
||||||
|
"Email",
|
||||||
|
"Telefono",
|
||||||
|
"Azienda",
|
||||||
|
"Stato",
|
||||||
|
"Prossima Azione",
|
||||||
|
"Tag",
|
||||||
|
"Azioni",
|
||||||
|
];
|
||||||
|
const COL_COUNT = COLUMN_HEADERS.length;
|
||||||
|
|
||||||
|
function LeadRow({
|
||||||
|
lead,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
lead: LeadWithTags;
|
||||||
|
options: LeadFieldOptions;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function run(fn: () => Promise<unknown>) {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
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">
|
||||||
|
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
|
||||||
|
<EditableCell
|
||||||
|
value={lead.name}
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
onSave={(v) => run(() => updateLeadField(lead.id, "name", v))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 min-w-[160px]">
|
||||||
|
<EditableCell
|
||||||
|
value={lead.email ?? ""}
|
||||||
|
type="text"
|
||||||
|
placeholder="email@esempio.com"
|
||||||
|
onSave={(v) => run(() => updateLeadField(lead.id, "email", v))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 min-w-[140px]">
|
||||||
|
<EditableCell
|
||||||
|
value={lead.phone ?? ""}
|
||||||
|
type="text"
|
||||||
|
placeholder="+39 3XX XXXXXXX"
|
||||||
|
onSave={(v) => run(() => updateLeadField(lead.id, "phone", v))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 min-w-[140px]">
|
||||||
|
<EditableCell
|
||||||
|
value={lead.company ?? ""}
|
||||||
|
type="text"
|
||||||
|
placeholder="Nome azienda"
|
||||||
|
onSave={(v) => run(() => updateLeadField(lead.id, "company", v))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 min-w-[120px]">
|
||||||
|
<OptionSelect
|
||||||
|
value={lead.status}
|
||||||
|
options={options.status}
|
||||||
|
onChange={(v) => run(() => updateLeadField(lead.id, "status", v ?? "contacted"))}
|
||||||
|
renderBadge={(opt) => (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
STAGE_COLOR[opt] ?? getOptionColor(opt),
|
||||||
|
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 min-w-[180px]">
|
||||||
|
<EditableCell
|
||||||
|
value={lead.next_action ?? ""}
|
||||||
|
type="text"
|
||||||
|
placeholder="Prossima azione..."
|
||||||
|
onSave={(v) => run(() => updateLeadField(lead.id, "next_action", v))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 min-w-[180px]">
|
||||||
|
<OptionMultiSelect
|
||||||
|
values={lead.tags}
|
||||||
|
options={options.tags}
|
||||||
|
onAdd={(v) => run(() => addLeadTag(lead.id, v))}
|
||||||
|
onRemove={(v) => run(() => removeLeadTag(lead.id, v))}
|
||||||
|
onRename={(o, n) => run(() => renameLeadTag(o, n))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 min-w-[80px]">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/admin/leads/${lead.id}`}>Dettagli</Link>
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{error && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
||||||
|
{error}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeadTable({
|
||||||
|
leads,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
leads: LeadWithTags[];
|
||||||
|
options: LeadFieldOptions;
|
||||||
|
}) {
|
||||||
|
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>
|
||||||
|
{leads.map((lead) => (
|
||||||
|
<LeadRow key={lead.id} lead={lead} options={options} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{leads.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500">Nessun lead trovato</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
CRITICAL CORRECTION — `OptionSelect` does NOT have a `renderBadge` prop (per the props contract in `<interfaces>` above: `value, options, onChange, onRename?, placeholder?, disabled?`). Adding a new prop would require modifying `option-select.tsx`, which is explicitly "reuse as-is, NO modifications" per RESEARCH.md/PATTERNS.md ("Treat any deviation from the ServiceTable.tsx/CatalogSearch.tsx/catalog-actions pattern as a red flag requiring justification").
|
||||||
|
|
||||||
|
Therefore, for the Stato cell, DO NOT attempt a `renderBadge` prop. Instead, use `OptionSelect` exactly as its existing contract allows — `value`, `options`, `onChange`, no `onRename` (this alone makes the dropdown's create-on-the-fly button still technically appear for non-exact queries, but `updateLeadField`'s server-side `LEAD_STAGES.includes()` check from Plan 14-01 rejects any value not in the 6 fixed stages, so an arbitrary "create" attempt fails server-side with "Stato non valido" and the error displays in the row's error `<tr>`). This is the defense-in-depth approach explicitly endorsed by RESEARCH.md Pitfall 2 ("Validate in updateLeadField's status branch... defense in depth, per Pattern 2 example") and UI-SPEC.md Decision 1 ("input validation... enforces that only LEAD_STAGES values are accepted server-side, regardless of client-side UI").
|
||||||
|
|
||||||
|
The badge color displayed by `OptionSelect` for the `status` value will use `getOptionColor()`'s hash-based palette (the component's built-in behavior, unchanged) — NOT the semantic `STAGE_COLOR` map. This is a deviation from UI-SPEC.md Decision 1's stated preference ("Semantic colors preserved... Badge colors for status still use STAGE_COLOR map"), but implementing semantic color override WITHOUT modifying `option-select.tsx` is not possible with the component's current props contract.
|
||||||
|
|
||||||
|
RESOLUTION (Claude's discretion, consistent with `<scope_reduction_prohibition>` — do not silently drop the UI-SPEC decision, implement it via the only available extension point): `option-select.tsx` accepts NO render-prop, but it DOES call `getOptionColor(value)` internally with no override mechanism. To honor UI-SPEC Decision 1 without modifying the shared component (which would affect `OptionSelect` usages elsewhere, e.g. catalog `categoria`/`fase`), do NOT use `OptionSelect` for the `status` column. Instead build a small inline closed-dropdown specifically for `status` in `LeadTable.tsx` itself (local to this file, zero shared-component changes):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function StatusCell({
|
||||||
|
lead,
|
||||||
|
options,
|
||||||
|
run,
|
||||||
|
}: {
|
||||||
|
lead: LeadWithTags;
|
||||||
|
options: LeadFieldOptions;
|
||||||
|
run: (fn: () => Promise<unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full min-w-[120px]">
|
||||||
|
<div
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px] cursor-pointer hover:bg-[#f0f0f0]"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
STAGE_COLOR[lead.status] ?? "",
|
||||||
|
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{lead.status.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-1.5 z-20 min-w-[180px]">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{options.status.map((stage) => (
|
||||||
|
<button
|
||||||
|
key={stage}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
run(() => updateLeadField(lead.id, "status", stage));
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-[#f5f5f5] text-left"
|
||||||
|
>
|
||||||
|
<span className="w-3.5 flex-shrink-0">
|
||||||
|
{lead.status === stage && <Check className="h-3.5 w-3.5 text-[#1A463C]" />}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(STAGE_COLOR[stage] ?? "", "border-transparent text-xs px-2 py-0.5 font-medium truncate")}
|
||||||
|
>
|
||||||
|
{stage.replace(/_/g, " ")}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `<StatusCell lead={lead} options={options} run={run} />` in place of the `OptionSelect` Stato cell in `LeadRow`. This:
|
||||||
|
- Preserves semantic STAGE_COLOR (won=green, lost=red, etc.) per UI-SPEC.md Decision 1
|
||||||
|
- Is a genuinely closed dropdown (only the 6 `options.status` values are clickable — no text input, no "Crea" button at all) — stronger guarantee than OptionSelect-without-onRename
|
||||||
|
- Requires zero changes to shared `option-select.tsx`/`option-multi-select.tsx`/`editable-cell.tsx`
|
||||||
|
- Matches "click-to-open dropdown, same interaction model as tags" (UI-SPEC Decision 1's stated goal for interaction consistency)
|
||||||
|
|
||||||
|
Add imports needed for `StatusCell`: `useRef`, `useEffect` from `"react"` (alongside existing `useState`, `useTransition`), and `Check` from `"lucide-react"`. Remove the unused `getOptionColor` import for the status cell (still needed if used elsewhere — it is NOT needed in this file since OptionMultiSelect imports it internally; do not import `getOptionColor` in LeadTable.tsx at all).
|
||||||
|
|
||||||
|
Final import list for LeadTable.tsx:
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState, useRef, useEffect, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { EditableCell } from "@/components/ui/editable-cell";
|
||||||
|
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||||
|
import {
|
||||||
|
updateLeadField,
|
||||||
|
addLeadTag,
|
||||||
|
removeLeadTag,
|
||||||
|
renameLeadTag,
|
||||||
|
} from "@/app/admin/leads/actions";
|
||||||
|
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
```
|
||||||
|
(`OptionSelect` import removed — not used; `StatusCell` is local.)
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c "from \"@/components/ui/table\"" src/components/admin/leads/LeadTable.tsx | grep -qx 0 && grep -c "function StatusCell" src/components/admin/leads/LeadTable.tsx | grep -qx 1 && npx tsc --noEmit && npx eslint src/components/admin/leads/LeadTable.tsx</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c "shadcn\|TableRow\|TableCell\|TableHead\b" src/components/admin/leads/LeadTable.tsx | grep -v '^0'` returns empty (no shadcn Table wrapper imports remain) — verify via `grep -c "from \"@/components/ui/table\"" src/components/admin/leads/LeadTable.tsx` returns 0
|
||||||
|
- `grep -c "EditableCell" src/components/admin/leads/LeadTable.tsx` returns at least 5 (name, email, phone, company, next_action)
|
||||||
|
- `grep -c "OptionMultiSelect" src/components/admin/leads/LeadTable.tsx` returns at least 1
|
||||||
|
- `grep -c "function StatusCell" src/components/admin/leads/LeadTable.tsx` returns 1
|
||||||
|
- `grep -c "STAGE_COLOR" src/components/admin/leads/LeadTable.tsx` returns at least 1
|
||||||
|
- `grep -c "updateLeadField(lead.id" src/components/admin/leads/LeadTable.tsx` returns at least 6 (name, email, phone, company, status, next_action)
|
||||||
|
- `grep -c "addLeadTag(lead.id\|removeLeadTag(lead.id\|renameLeadTag" src/components/admin/leads/LeadTable.tsx` returns at least 3
|
||||||
|
- `grep -c "Nessun lead trovato" src/components/admin/leads/LeadTable.tsx` returns 1
|
||||||
|
- `grep -c '"Nome", "Email", "Telefono", "Azienda", "Stato", "Prossima Azione", "Tag", "Azioni"' src/components/admin/leads/LeadTable.tsx` returns 0 (header array is multiline) — instead verify via `grep -c '"Dettagli"' src/components/admin/leads/LeadTable.tsx` returns 1 AND `grep -c '"Prossima Azione"' src/components/admin/leads/LeadTable.tsx` returns 1
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `npx eslint src/components/admin/leads/LeadTable.tsx` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>LeadTable.tsx is fully rewritten as a raw `<table>` database-view component matching ServiceTable.tsx's structure: 8 columns (Nome/Email/Telefono/Azienda/Stato/Prossima Azione/Tag/Azioni), EditableCell for scalar fields, a local StatusCell preserving semantic STAGE_COLOR for the closed-enum status dropdown, OptionMultiSelect for tags wired to addLeadTag/removeLeadTag/renameLeadTag, error-row display, and "Nessun lead trovato" empty state. Type-checks and lints cleanly.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Create LeadsSearch.tsx, rewire page.tsx, surface tags in LeadDetail.tsx</name>
|
||||||
|
<files>src/app/admin/leads/LeadsSearch.tsx, src/app/admin/leads/page.tsx, src/components/admin/leads/LeadDetail.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- src/app/admin/catalog/CatalogSearch.tsx (exact structural analog for LeadsSearch.tsx)
|
||||||
|
- src/app/admin/catalog/page.tsx (exact structural analog for page.tsx)
|
||||||
|
- src/app/admin/leads/page.tsx (current file — to be replaced)
|
||||||
|
- src/components/admin/leads/LeadDetail.tsx (current file — Profilo card to extend)
|
||||||
|
- src/app/admin/leads/[id]/page.tsx (detail page — to confirm what props LeadDetail currently receives and what needs to change to pass tags)
|
||||||
|
- src/lib/admin-queries.ts (LeadWithTags, LeadFieldOptions, getLeadsWithTags, getLeadFieldOptions — from Plan 14-01)
|
||||||
|
- src/app/admin/leads/actions.ts (addLeadTag, removeLeadTag, renameLeadTag — from Plan 14-01)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
**Part A — Create `src/app/admin/leads/LeadsSearch.tsx`** (new file, mirrors `CatalogSearch.tsx`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { LeadTable } from "@/components/admin/leads/LeadTable";
|
||||||
|
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
||||||
|
|
||||||
|
export function LeadsSearch({
|
||||||
|
leads,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
leads: LeadWithTags[];
|
||||||
|
options: LeadFieldOptions;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return leads;
|
||||||
|
return leads.filter((l) => {
|
||||||
|
if (l.name.toLowerCase().includes(q)) return true;
|
||||||
|
if (l.email?.toLowerCase().includes(q)) return true;
|
||||||
|
if (l.company?.toLowerCase().includes(q)) return true;
|
||||||
|
if (l.status.toLowerCase().includes(q)) return true;
|
||||||
|
if (l.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, [leads, 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 lead..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LeadTable leads={filtered} options={options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Part B — Rewrite `src/app/admin/leads/page.tsx`**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
|
||||||
|
import { LeadsSearch } from "./LeadsSearch";
|
||||||
|
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
|
||||||
|
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
export default async function LeadsPage() {
|
||||||
|
const [leads, options] = await Promise.all([
|
||||||
|
getLeadsWithTags(),
|
||||||
|
getLeadFieldOptions(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Lead Pipeline</h1>
|
||||||
|
<CreateLeadModal />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LeadsSearch leads={leads} options={options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes: `export const revalidate = 0` matches the catalog page's pattern (always-fresh data, no static caching — consistent with `router.refresh()` triggering full re-fetch after every inline edit). `Suspense`/`LeadsList` wrapper from the old page is removed — `getLeadsWithTags`/`getLeadFieldOptions` are awaited directly in the server component, matching catalog's pattern exactly. `CreateLeadModal` import path is unchanged (`@/components/admin/leads/LeadForm`).
|
||||||
|
|
||||||
|
**Part C — Surface lead tags in `LeadDetail.tsx`'s "Profilo" card (UI-SPEC.md Decision 4)**:
|
||||||
|
|
||||||
|
1. First, read `src/app/admin/leads/[id]/page.tsx` to find where `LeadDetail` is rendered and what data is currently fetched/passed.
|
||||||
|
|
||||||
|
2. Update the detail page (`src/app/admin/leads/[id]/page.tsx`) to also fetch tags + tag options for this lead. Since `getLeadsWithTags()` returns ALL leads, for a single-lead detail page either:
|
||||||
|
- (a) Call `getLeadsWithTags()` and find the matching lead by id (simplest, reuses Plan 14-01's function, acceptable for current data volumes), OR
|
||||||
|
- (b) Add a small inline query using the existing `tags` table scoped by `entity_id = lead.id, entity_type = "leads"`.
|
||||||
|
|
||||||
|
Use approach (a) for consistency and zero new query functions: import `getLeadsWithTags` and `getLeadFieldOptions` from `@/lib/admin-queries`, find `leads.find(l => l.id === params.id)` for the tags array, and pass `tagOptions={options.tags}` from `getLeadFieldOptions()`.
|
||||||
|
|
||||||
|
3. In `LeadDetail.tsx`:
|
||||||
|
- Add `"use client"` directive (already present — confirm).
|
||||||
|
- Extend the component's props: `{ lead, activities, reminders, tags, tagOptions }: { lead: Lead; activities: Activity[]; reminders: Reminder[]; tags: string[]; tagOptions: string[] }`.
|
||||||
|
- Import `OptionMultiSelect` from `@/components/ui/option-multi-select`, and `addLeadTag`/`removeLeadTag`/`renameLeadTag` from `@/app/admin/leads/actions`.
|
||||||
|
- Add `useRouter`, `useTransition`, `useState` (for error) imports from `"react"`/`"next/navigation"` — replicate the same `run()` helper pattern as `LeadTable.tsx`'s `LeadRow`.
|
||||||
|
- In the "Profilo" Card's `CardContent`, after the "Stato" field block, add a new field block:
|
||||||
|
```tsx
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-gray-600">Tag</label>
|
||||||
|
<OptionMultiSelect
|
||||||
|
values={tags}
|
||||||
|
options={tagOptions}
|
||||||
|
onAdd={(v) => run(() => addLeadTag(lead.id, v))}
|
||||||
|
onRemove={(v) => run(() => removeLeadTag(lead.id, v))}
|
||||||
|
onRename={(o, n) => run(() => renameLeadTag(o, n))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
- Add the `run()` helper function at the top of the component body:
|
||||||
|
```tsx
|
||||||
|
const router = useRouter();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [tagError, setTagError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function run(fn: () => Promise<unknown>) {
|
||||||
|
setTagError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setTagError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Display `tagError` below the OptionMultiSelect if set: `{tagError && <p className="text-xs text-red-600 mt-1">{tagError}</p>}`.
|
||||||
|
|
||||||
|
Do not modify any other part of `LeadDetail.tsx` (activity log, reminders card, notes card remain unchanged).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f src/app/admin/leads/LeadsSearch.tsx && grep -c "getLeadsWithTags\|getLeadFieldOptions" src/app/admin/leads/page.tsx | grep -qx 2 && npx tsc --noEmit</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `test -f src/app/admin/leads/LeadsSearch.tsx` exits 0
|
||||||
|
- `grep -c "useMemo" src/app/admin/leads/LeadsSearch.tsx` returns 1
|
||||||
|
- `grep -c "LeadTable leads={filtered}" src/app/admin/leads/LeadsSearch.tsx` returns 1
|
||||||
|
- `grep -c "getLeadsWithTags\|getLeadFieldOptions" src/app/admin/leads/page.tsx` returns 2
|
||||||
|
- `grep -c "CreateLeadModal" src/app/admin/leads/page.tsx` returns at least 1
|
||||||
|
- `grep -c "export const revalidate = 0" src/app/admin/leads/page.tsx` returns 1
|
||||||
|
- `grep -c "OptionMultiSelect" src/components/admin/leads/LeadDetail.tsx` returns at least 1
|
||||||
|
- `grep -c "addLeadTag\|removeLeadTag\|renameLeadTag" src/components/admin/leads/LeadDetail.tsx` returns at least 3
|
||||||
|
- `grep -c "getLeadsWithTags\|getLeadFieldOptions" src/app/admin/leads/\[id\]/page.tsx` returns at least 1
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `npx eslint src/app/admin/leads/LeadsSearch.tsx src/app/admin/leads/page.tsx src/components/admin/leads/LeadDetail.tsx "src/app/admin/leads/[id]/page.tsx"` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>LeadsSearch.tsx exists and provides client-side instant filtering across name/email/company/status/tags. page.tsx fetches via getLeadsWithTags()/getLeadFieldOptions() and renders LeadsSearch + CreateLeadModal. LeadDetail.tsx's "Profilo" card includes an OptionMultiSelect for lead tags wired to addLeadTag/removeLeadTag/renameLeadTag, with error display. All files type-check and lint cleanly.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|--------------|
|
||||||
|
| Admin browser -> LeadTable/LeadDetail client components | Renders data fetched server-side via `getLeadsWithTags()`; inline-edit/tag mutations call server actions from Plan 14-01 |
|
||||||
|
| LeadTable/LeadDetail -> Server Actions (Plan 14-01) | `updateLeadField`/`addLeadTag`/`removeLeadTag`/`renameLeadTag` — already guarded by `requireAdmin()` |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-14-07 | Tampering | `StatusCell` in LeadTable.tsx — client-side closed dropdown for `status` | mitigate | UI only allows selecting from `options.status` (6 fixed `LEAD_STAGES` values, no free-text input); server-side `updateLeadField` (Plan 14-01) independently validates against `LEAD_STAGES` — defense in depth even if a future edit reintroduces free-text |
|
||||||
|
| T-14-08 | Information Disclosure | `LeadDetail.tsx` now renders `tags`/`tagOptions` — additional data surface on `/admin/leads/[id]` | accept | Route is Auth.js-session-protected (`/admin/*`); tags are non-sensitive labels, same trust level as existing `status`/`notes` already shown on this page |
|
||||||
|
| T-14-09 | Repudiation / error handling | `run()` helper in LeadTable/LeadDetail — errors from server actions surfaced to UI | mitigate | Errors caught via try/catch in `run()`, displayed as `text-xs text-red-600` without leaking stack traces or internal error details (only `e.message`, which are user-facing strings like "Stato non valido" set explicitly in Plan 14-01's actions) |
|
||||||
|
| T-14-10 | Elevation of Privilege | `LeadsSearch.tsx`/`LeadTable.tsx`/`LeadDetail.tsx` — new client components calling mutating server actions | accept (covered by Plan 14-01) | All mutating actions invoked from these components (`updateLeadField`, `addLeadTag`, etc.) already call `requireAdmin()` server-side (Plan 14-01, Task 2) — no new auth surface introduced here |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `npx tsc --noEmit` — exits 0
|
||||||
|
2. `npx eslint src/components/admin/leads/LeadTable.tsx src/app/admin/leads/LeadsSearch.tsx src/app/admin/leads/page.tsx src/components/admin/leads/LeadDetail.tsx "src/app/admin/leads/[id]/page.tsx"` — exits 0
|
||||||
|
3. `grep -c "from \"@/components/ui/table\"" src/components/admin/leads/LeadTable.tsx` returns 0 (shadcn Table wrapper fully removed)
|
||||||
|
4. `grep -n "Nessun lead trovato" src/components/admin/leads/LeadTable.tsx` — present
|
||||||
|
5. Manual visual check (deferred to checkpoint in execute-phase): `/admin/leads` renders the 8-column raw table, status badges show semantic colors (won=green, lost=red), tags column shows OptionMultiSelect pills, search box filters instantly
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- `/admin/leads` renders a raw `<table>` database-view (no shadcn Table wrapper), 8 columns: Nome, Email, Telefono, Azienda, Stato, Prossima Azione, Tag, Azioni
|
||||||
|
- All scalar fields (name, email, phone, company, next_action) are inline-editable via `EditableCell`, persisted via `updateLeadField`
|
||||||
|
- `status` is editable via a closed dropdown (`StatusCell`) showing only the 6 `LEAD_STAGES` values with semantic `STAGE_COLOR` badges (won=green, lost=red, etc.)
|
||||||
|
- Lead tags are editable via `OptionMultiSelect`, backed by `addLeadTag`/`removeLeadTag`/`renameLeadTag` (CRM-09)
|
||||||
|
- `/admin/leads` has a client-side instant search box filtering name/email/company/status/tags with zero server round-trips
|
||||||
|
- `/admin/leads/[id]` (LeadDetail "Profilo" card) also shows and allows editing lead tags via the same `OptionMultiSelect` + actions
|
||||||
|
- `npx tsc --noEmit` and `npx eslint` both exit 0
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/14-crm-attio-style-fix/14-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
---
|
||||||
|
phase: 14-crm-attio-style-fix
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/components/admin/dashboard/FollowUpWidget.tsx
|
||||||
|
- src/components/admin/leads/LeadForm.tsx
|
||||||
|
- src/components/admin/leads/SendQuoteModal.tsx
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CRM-10, CRM-11, CRM-12]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "FollowUpWidget shows Italian text only: heading 'Richiedi Follow-up', body 'X lead da contattare' (no English words, no pluralized 's')"
|
||||||
|
- "EditLeadModal and CreateLeadModal use useForm<CreateLeadInput>() (not useForm<any>()) — form fields are type-checked against the Zod-inferred lead schema"
|
||||||
|
- "SendQuoteModal's onSubmit no longer contains an unreachable 'new' tab branch — the only navigation for the 'new' tab happens via the button's own onClick"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/components/admin/dashboard/FollowUpWidget.tsx"
|
||||||
|
provides: "Italian-only follow-up widget copy"
|
||||||
|
contains: "Richiedi Follow-up"
|
||||||
|
- path: "src/components/admin/leads/LeadForm.tsx"
|
||||||
|
provides: "Typed react-hook-form for lead create/edit"
|
||||||
|
contains: "useForm<CreateLeadInput>"
|
||||||
|
- path: "src/components/admin/leads/SendQuoteModal.tsx"
|
||||||
|
provides: "onSubmit without dead 'new' tab branch"
|
||||||
|
contains: "onSubmit"
|
||||||
|
key_links:
|
||||||
|
- from: "src/components/admin/leads/LeadForm.tsx"
|
||||||
|
to: "src/lib/lead-validators.ts"
|
||||||
|
via: "import type { CreateLeadInput }"
|
||||||
|
pattern: "import.*CreateLeadInput.*lead-validators"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Fix three residual CRM bugs identified in RESEARCH.md, each isolated to a single component file with no shared dependencies:
|
||||||
|
|
||||||
|
1. **CRM-10**: `FollowUpWidget.tsx` has 6 hardcoded English strings — translate to Italian per UI-SPEC.md Decision 5 ("X lead da contattare", no pluralization since Italian "lead" is invariant).
|
||||||
|
2. **CRM-11**: `LeadForm.tsx`'s `CreateLeadModal` and `EditLeadModal` both call `useForm<any>()`, defeating react-hook-form's type safety — replace with `useForm<CreateLeadInput>()` using the Zod-inferred type already exported from `lead-validators.ts`, removing the local type redeclaration and narrowing the one unavoidable `status` cast.
|
||||||
|
3. **CRM-12**: `SendQuoteModal.tsx`'s `onSubmit` contains a dead `if (tab === "new") {...}` branch that can never execute (the "new" tab's button has its own `onClick` handler and never triggers form submit) — remove the unreachable branch.
|
||||||
|
|
||||||
|
Purpose: Closes out the three non-UI residual bugs from Phase 14's scope, completing CRM-10/11/12 alongside the Attio-style table redesign (CRM-08/09 in Plans 14-01/14-02).
|
||||||
|
|
||||||
|
Output: Three independently-fixed components, each type-checked and linted.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/REQUIREMENTS.md
|
||||||
|
@.planning/phases/14-crm-attio-style-fix/14-RESEARCH.md
|
||||||
|
@.planning/phases/14-crm-attio-style-fix/14-PATTERNS.md
|
||||||
|
@.planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- No new interfaces created; these are isolated bug fixes against existing types. -->
|
||||||
|
|
||||||
|
From src/lib/lead-validators.ts (existing, unchanged — CreateLeadInput already exported):
|
||||||
|
```typescript
|
||||||
|
export const LEAD_STAGES = ["contacted", "qualified", "proposal_sent", "negotiating", "won", "lost"] as const;
|
||||||
|
|
||||||
|
export const createLeadSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
email: z.string().email().nullable().optional(),
|
||||||
|
phone: z.string().nullable().optional(),
|
||||||
|
company: z.string().nullable().optional(),
|
||||||
|
status: z.enum(LEAD_STAGES).optional(),
|
||||||
|
next_action: z.string().nullable().optional(),
|
||||||
|
// ... etc
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreateLeadInput = z.infer<typeof createLeadSchema>;
|
||||||
|
export type UpdateLeadInput = z.infer<typeof updateLeadSchema>;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/components/admin/dashboard/FollowUpWidget.tsx (current, full 47 lines — to be translated):
|
||||||
|
```tsx
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getLeadsNeedingFollowUp } from "@/lib/lead-service";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export async function FollowUpWidget() {
|
||||||
|
const leads = await getLeadsNeedingFollowUp(7);
|
||||||
|
const count = leads.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Require Follow-up</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{count > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
{count} lead{count !== 1 ? "s" : ""} to contact
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{leads.slice(0, 5).map((lead) => (
|
||||||
|
<div key={lead.id} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{lead.name}</span>
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link href={`/admin/leads/${lead.id}`}>Contact</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{count > 5 && (
|
||||||
|
<Link href="/admin/leads" className="text-sm text-blue-600 hover:underline mt-2 block">
|
||||||
|
View All ({count})
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">All leads are up to date!</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/components/admin/leads/LeadForm.tsx (current, lines 1-40 and 202-220 — relevant excerpts):
|
||||||
|
```tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { createLeadSchema } from "@/lib/lead-validators";
|
||||||
|
import type { z } from "zod";
|
||||||
|
import type { Lead } from "@/db/schema";
|
||||||
|
// ... other imports
|
||||||
|
|
||||||
|
type CreateLeadInput = z.infer<typeof createLeadSchema>; // <-- local redeclaration, line 36, to be removed
|
||||||
|
|
||||||
|
export function CreateLeadModal() {
|
||||||
|
const form = useForm<any>({ // <-- line ~42, to be fixed
|
||||||
|
resolver: zodResolver(createLeadSchema),
|
||||||
|
defaultValues: { name: "", email: "", phone: "", company: "", status: "contacted", next_action: "" },
|
||||||
|
});
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... EditLeadModal starts around line 202
|
||||||
|
export function EditLeadModal({ lead, open, onOpenChange }: { lead: Lead; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
|
const form = useForm<any>({ // <-- to be fixed
|
||||||
|
resolver: zodResolver(createLeadSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: lead.name,
|
||||||
|
email: lead.email ?? "",
|
||||||
|
phone: lead.phone ?? "",
|
||||||
|
company: lead.company ?? "",
|
||||||
|
status: lead.status as any, // <-- line 212, to be narrowed
|
||||||
|
next_action: lead.next_action ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/components/admin/leads/SendQuoteModal.tsx (current, lines 31-75 and 120-135 — relevant excerpts):
|
||||||
|
```tsx
|
||||||
|
const assignQuoteSchema = z.object({
|
||||||
|
tab: z.enum(["existing", "new"]),
|
||||||
|
quote_id: z.string().optional(),
|
||||||
|
generate_new: z.boolean().optional(), // <-- dead field, line ~31
|
||||||
|
});
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
const form = useForm<AssignQuoteInput>({
|
||||||
|
resolver: zodResolver(assignQuoteSchema),
|
||||||
|
defaultValues: { tab: "existing", quote_id: "", generate_new: false }, // <-- line ~43
|
||||||
|
});
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
async function onSubmit(data: AssignQuoteInput) {
|
||||||
|
if (data.tab === "new") {
|
||||||
|
window.location.href = `/admin/quotes/new?lead_id=${lead.id}`; // <-- dead branch, lines 51-54, UNREACHABLE
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const result = await assignQuoteToLead({ lead_id: lead.id, quote_id: data.quote_id! });
|
||||||
|
if (!result.success) {
|
||||||
|
setError(result.error ?? "Errore");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Errore");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... around line 126-133, the "new" tab's button:
|
||||||
|
{tab === "new" && (
|
||||||
|
<Button type="button" onClick={() => { window.location.href = `/admin/quotes/new?lead_id=${lead.id}`; }}>
|
||||||
|
Crea Nuovo Preventivo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Translate FollowUpWidget.tsx to Italian (CRM-10)</name>
|
||||||
|
<files>src/components/admin/dashboard/FollowUpWidget.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- src/components/admin/dashboard/FollowUpWidget.tsx (current, full 47-line file)
|
||||||
|
- .planning/phases/14-crm-attio-style-fix/14-UI-SPEC.md (Design Decision 5 — FollowUpWidget plural handling)
|
||||||
|
- .planning/phases/14-crm-attio-style-fix/14-RESEARCH.md (Pitfall 3 — FollowUpWidget i18n scope, confirms lead-service.ts has no English strings, fix is widget-only)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Translate all 6 hardcoded English strings in `FollowUpWidget.tsx` to Italian, per UI-SPEC.md Decision 5 (Italian "lead" is invariant — same word for singular/plural, so no conditional pluralization needed):
|
||||||
|
|
||||||
|
1. `"Require Follow-up"` (CardTitle) → `"Richiedi Follow-up"`
|
||||||
|
2. `{count} lead{count !== 1 ? "s" : ""} to contact` → `{count} lead da contattare` (remove the `{count !== 1 ? "s" : ""}` ternary entirely — "lead" never takes an "s" in Italian)
|
||||||
|
3. `"Contact"` (Button label) → `"Contatta"`
|
||||||
|
4. `View All ({count})` → `Vedi Tutto ({count})`
|
||||||
|
5. `"All leads are up to date!"` (empty state) → `"Tutti i lead sono aggiornati!"`
|
||||||
|
|
||||||
|
Apply these as direct string replacements — do not change any logic, imports, component structure, or the `getLeadsNeedingFollowUp(7)` call. Only the JSX text content changes.
|
||||||
|
|
||||||
|
The full corrected file:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Link from "next/link";
|
||||||
|
import { getLeadsNeedingFollowUp } from "@/lib/lead-service";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export async function FollowUpWidget() {
|
||||||
|
const leads = await getLeadsNeedingFollowUp(7);
|
||||||
|
const count = leads.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Richiedi Follow-up</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{count > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
{count} lead da contattare
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{leads.slice(0, 5).map((lead) => (
|
||||||
|
<div key={lead.id} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{lead.name}</span>
|
||||||
|
<Button size="sm" variant="outline" asChild>
|
||||||
|
<Link href={`/admin/leads/${lead.id}`}>Contatta</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{count > 5 && (
|
||||||
|
<Link href="/admin/leads" className="text-sm text-blue-600 hover:underline mt-2 block">
|
||||||
|
Vedi Tutto ({count})
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">Tutti i lead sono aggiornati!</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -ic "Require Follow-up\|to contact\|\"Contact\"\|View All\|up to date" src/components/admin/dashboard/FollowUpWidget.tsx | grep -qx 0 && npx tsc --noEmit</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c "Richiedi Follow-up" src/components/admin/dashboard/FollowUpWidget.tsx` returns 1
|
||||||
|
- `grep -c "lead da contattare" src/components/admin/dashboard/FollowUpWidget.tsx` returns 1
|
||||||
|
- `grep -c "Contatta" src/components/admin/dashboard/FollowUpWidget.tsx` returns 1
|
||||||
|
- `grep -c "Vedi Tutto" src/components/admin/dashboard/FollowUpWidget.tsx` returns 1
|
||||||
|
- `grep -c "Tutti i lead sono aggiornati" src/components/admin/dashboard/FollowUpWidget.tsx` returns 1
|
||||||
|
- `grep -ic "Require Follow-up\|to contact\|\"Contact\"\|View All\|up to date" src/components/admin/dashboard/FollowUpWidget.tsx` returns 0 (no English strings remain)
|
||||||
|
- `grep -c 'count !== 1 ? "s" : ""' src/components/admin/dashboard/FollowUpWidget.tsx` returns 0 (pluralization ternary removed)
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `npx eslint src/components/admin/dashboard/FollowUpWidget.tsx` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>FollowUpWidget.tsx contains only Italian text: "Richiedi Follow-up" heading, "{count} lead da contattare" body (no pluralization), "Contatta" button, "Vedi Tutto ({count})" link, "Tutti i lead sono aggiornati!" empty state. No English strings remain. Type-checks and lints cleanly.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Type LeadForm.tsx's useForm with CreateLeadInput (CRM-11)</name>
|
||||||
|
<files>src/components/admin/leads/LeadForm.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- src/components/admin/leads/LeadForm.tsx (current, full 366-line file — both CreateLeadModal lines 38-201 and EditLeadModal lines 202-365)
|
||||||
|
- src/lib/lead-validators.ts (full file — confirm CreateLeadInput export, createLeadSchema shape, LEAD_STAGES)
|
||||||
|
- .planning/phases/14-crm-attio-style-fix/14-RESEARCH.md (relevant Pattern for CRM-11 fix)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
Manual-trace behaviors (verified via `npx tsc --noEmit` — TypeScript itself is the test for this type-safety fix):
|
||||||
|
- `CreateLeadModal`'s `useForm<any>()` becomes `useForm<CreateLeadInput>()`, where `CreateLeadInput` is imported from `@/lib/lead-validators` (not locally redeclared)
|
||||||
|
- `EditLeadModal`'s `useForm<any>()` becomes `useForm<CreateLeadInput>()`
|
||||||
|
- The local `type CreateLeadInput = z.infer<typeof createLeadSchema>;` declaration (current line 36) is REMOVED — the type now comes from the import
|
||||||
|
- `EditLeadModal`'s `defaultValues.status: lead.status as any` becomes `status: lead.status as CreateLeadInput["status"]` — the only remaining cast, narrowing `Lead.status` (a plain `string` column type from Drizzle's `$inferSelect`) to the Zod-enum-derived `CreateLeadInput["status"]` type (`"contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost" | undefined`)
|
||||||
|
- All `form.register(...)`, `form.handleSubmit(...)`, `form.setValue(...)`, `form.watch(...)` calls (wherever they appear in both modals) continue to type-check against `CreateLeadInput` field names (`name`, `email`, `phone`, `company`, `status`, `next_action`, etc.) — if any currently-untyped `any`-cast field access breaks, the field name is wrong relative to `createLeadSchema` and must be corrected to match the schema (do not re-introduce `any` to suppress the error)
|
||||||
|
- If `z` import becomes unused after removing the local `type CreateLeadInput = z.infer<...>` line, remove the now-unused `import type { z } from "zod"` (or equivalent) — but only if `z` is not used elsewhere in the file (grep first)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Read the full `LeadForm.tsx` file to locate:
|
||||||
|
- The current local type declaration: `type CreateLeadInput = z.infer<typeof createLeadSchema>;`
|
||||||
|
- Both `useForm<any>({...})` call sites (one in `CreateLeadModal`, one in `EditLeadModal`)
|
||||||
|
- The `status: lead.status as any` cast in `EditLeadModal`'s `defaultValues`
|
||||||
|
- The current import block (to determine whether `z` and `createLeadSchema` are already imported, and from where)
|
||||||
|
|
||||||
|
2. Add `CreateLeadInput` to the import from `@/lib/lead-validators`:
|
||||||
|
```tsx
|
||||||
|
import { createLeadSchema, type CreateLeadInput } from "@/lib/lead-validators";
|
||||||
|
```
|
||||||
|
(adjust based on the exact current import statement — if `createLeadSchema` is already imported from `lead-validators`, just add `, type CreateLeadInput` to that same import line)
|
||||||
|
|
||||||
|
3. Remove the local redeclaration: delete the line `type CreateLeadInput = z.infer<typeof createLeadSchema>;` entirely.
|
||||||
|
|
||||||
|
4. If `import type { z } from "zod"` (or `import { z } from "zod"` used only for this) becomes unused after step 3, remove it. First grep the file for other `z.` usages — if any remain (e.g., inline schema validation elsewhere in the file), keep the import.
|
||||||
|
|
||||||
|
5. In `CreateLeadModal`, change:
|
||||||
|
```tsx
|
||||||
|
const form = useForm<any>({
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```tsx
|
||||||
|
const form = useForm<CreateLeadInput>({
|
||||||
|
```
|
||||||
|
|
||||||
|
6. In `EditLeadModal`, change:
|
||||||
|
```tsx
|
||||||
|
const form = useForm<any>({
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```tsx
|
||||||
|
const form = useForm<CreateLeadInput>({
|
||||||
|
```
|
||||||
|
|
||||||
|
7. In `EditLeadModal`'s `defaultValues`, change:
|
||||||
|
```tsx
|
||||||
|
status: lead.status as any,
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```tsx
|
||||||
|
status: lead.status as CreateLeadInput["status"],
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Run `npx tsc --noEmit`. If new type errors surface inside either modal (e.g., a `form.watch("someField")` call referencing a field name not in `createLeadSchema`, or a `form.setValue(...)` with a mismatched type), fix the call site to match the actual `createLeadSchema` shape (from `lead-validators.ts`) — do NOT reintroduce `any` or `as any` to silence the error. The goal is genuine type safety, not error suppression.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -c "useForm<any>\|as any" src/components/admin/leads/LeadForm.tsx | grep -qx 0 && npx tsc --noEmit && npx eslint src/components/admin/leads/LeadForm.tsx</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -c "useForm<any>" src/components/admin/leads/LeadForm.tsx` returns 0
|
||||||
|
- `grep -c "useForm<CreateLeadInput>" src/components/admin/leads/LeadForm.tsx` returns 2 (CreateLeadModal + EditLeadModal)
|
||||||
|
- `grep -c "type CreateLeadInput = z.infer" src/components/admin/leads/LeadForm.tsx` returns 0 (local redeclaration removed)
|
||||||
|
- `grep -c "CreateLeadInput" src/components/admin/leads/LeadForm.tsx | grep -v '^0'` returns non-empty (import line + 2 useForm + 1 cast = at least 4 occurrences)
|
||||||
|
- `grep -c "status: lead.status as any" src/components/admin/leads/LeadForm.tsx` returns 0
|
||||||
|
- `grep -c 'status: lead.status as CreateLeadInput\["status"\]' src/components/admin/leads/LeadForm.tsx` returns 1
|
||||||
|
- `grep -c "as any" src/components/admin/leads/LeadForm.tsx` returns 0 (no remaining `any` casts in the file)
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `npx eslint src/components/admin/leads/LeadForm.tsx` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Both CreateLeadModal and EditLeadModal use useForm<CreateLeadInput>() with CreateLeadInput imported from lead-validators.ts (no local redeclaration). The only remaining type assertion is the narrowing cast `lead.status as CreateLeadInput["status"]` in EditLeadModal's defaultValues. No `as any` remains anywhere in the file. Type-checks and lints cleanly.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: Remove dead onSubmit branch in SendQuoteModal.tsx (CRM-12)</name>
|
||||||
|
<files>src/components/admin/leads/SendQuoteModal.tsx</files>
|
||||||
|
<read_first>
|
||||||
|
- src/components/admin/leads/SendQuoteModal.tsx (current, full 140-line file)
|
||||||
|
- .planning/phases/14-crm-attio-style-fix/14-RESEARCH.md (Pitfall 4 — SendQuoteModal dead branch analysis)
|
||||||
|
- .planning/phases/14-crm-attio-style-fix/14-PATTERNS.md (SendQuoteModal.tsx fixed pattern)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
Manual-trace behaviors (verified via `npx tsc --noEmit` + `npx eslint` — no existing test file for this component; the fix is a dead-code removal, behavior for the "existing" tab path is unchanged):
|
||||||
|
- `onSubmit`'s body NO LONGER contains an `if (data.tab === "new") { window.location.href = ...; return; }` branch — this branch was unreachable because: (a) the "new" tab's submit button has its own `onClick={() => window.location.href = ...}` handler that navigates directly without calling `form.handleSubmit`, and (b) when `tab === "new"`, the only rendered button is that direct-navigation button — no `type="submit"` button exists for the "new" tab, so `onSubmit` (wired to `form.handleSubmit(onSubmit)`) can never fire with `data.tab === "new"`
|
||||||
|
- `onSubmit` now begins directly with the "existing" tab logic: `setError(null); startTransition(async () => { ... assignQuoteToLead({ lead_id: lead.id, quote_id: data.quote_id! }) ... })`
|
||||||
|
- The "new" tab's button (with its own `onClick` navigation) is UNCHANGED — it continues to work exactly as before
|
||||||
|
- `assignQuoteToLead({ lead_id, quote_id })` call signature is unchanged — only the dead branch above it is removed
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Read the full `SendQuoteModal.tsx` file.
|
||||||
|
|
||||||
|
2. Locate the `onSubmit` function. It currently begins with:
|
||||||
|
```tsx
|
||||||
|
async function onSubmit(data: AssignQuoteInput) {
|
||||||
|
if (data.tab === "new") {
|
||||||
|
window.location.href = `/admin/quotes/new?lead_id=${lead.id}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
// ... existing-tab logic
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Remove the dead `if (data.tab === "new") { ... return; }` block entirely, so `onSubmit` begins directly with `setError(null);`:
|
||||||
|
```tsx
|
||||||
|
async function onSubmit(data: AssignQuoteInput) {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
// ... existing-tab logic (unchanged)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Confirm the "new" tab's button (its own `onClick={() => { window.location.href = ... }}`, separate from form submission) is untouched — it remains the sole navigation path for the "new" tab.
|
||||||
|
|
||||||
|
5. Leave the `generate_new: z.boolean().optional()` field in `assignQuoteSchema` and its `defaultValues: { ..., generate_new: false }` entry AS-IS — RESEARCH.md flags this as optional cleanup, not required for CRM-12. Removing it would require also checking `AssignQuoteInput` usages elsewhere (out of scope for this isolated fix). Do not touch it.
|
||||||
|
|
||||||
|
6. After the edit, grep the file for `data.tab` to confirm no other dead `tab === "new"` checks remain inside `onSubmit`. If the `tab` field is still referenced elsewhere (e.g., to conditionally render the "existing" vs "new" tab UI in JSX), that is correct and expected — only the `onSubmit` dead branch is removed.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -A2 "async function onSubmit" src/components/admin/leads/SendQuoteModal.tsx | grep -c 'data.tab === "new"' | grep -qx 0 && npx tsc --noEmit && npx eslint src/components/admin/leads/SendQuoteModal.tsx</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep -A2 "async function onSubmit" src/components/admin/leads/SendQuoteModal.tsx | grep -c 'data.tab === "new"'` returns 0
|
||||||
|
- `grep -c "window.location.href" src/components/admin/leads/SendQuoteModal.tsx` returns 1 (only the "new" tab button's onClick remains, the onSubmit copy is removed)
|
||||||
|
- `grep -c "assignQuoteToLead" src/components/admin/leads/SendQuoteModal.tsx` returns at least 1 (existing-tab submit logic preserved)
|
||||||
|
- `grep -c "generate_new" src/components/admin/leads/SendQuoteModal.tsx` returns at least 1 (left untouched per action step 5)
|
||||||
|
- `npx tsc --noEmit` exits 0
|
||||||
|
- `npx eslint src/components/admin/leads/SendQuoteModal.tsx` exits 0
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>SendQuoteModal.tsx's onSubmit no longer contains the unreachable `if (data.tab === "new") {...}` branch — onSubmit now starts directly with `setError(null)` and the existing-tab assignQuoteToLead logic. The "new" tab's own onClick-based navigation button is unchanged. generate_new field left as-is (optional cleanup, out of scope). Type-checks and lints cleanly.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|--------------|
|
||||||
|
| Admin browser -> FollowUpWidget/LeadForm/SendQuoteModal | Server-rendered (FollowUpWidget) and client components (LeadForm, SendQuoteModal); no new data flows introduced |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-14-11 | Tampering | LeadForm.tsx — `useForm<any>()` to `useForm<CreateLeadInput>()` | mitigate | Stronger compile-time typing reduces risk of malformed form payloads reaching `createLead`/`updateLead` server actions; Zod validation at the action boundary (pre-existing, unchanged) remains the actual runtime enforcement — this fix is a defense-in-depth/DX improvement, not a new security control |
|
||||||
|
| T-14-12 | Tampering | SendQuoteModal.tsx — dead `onSubmit` branch removal | accept | Removing unreachable code has no security effect; `assignQuoteToLead` (existing-tab path) and its `requireAdmin`-equivalent guards (if any, pre-existing) are unchanged |
|
||||||
|
| T-14-13 | Information Disclosure | FollowUpWidget.tsx — string translation only | accept | No data exposure change; only display copy is translated. `getLeadsNeedingFollowUp(7)` query and its session-protected `/admin` route context are unchanged |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `npx tsc --noEmit` — exits 0 across all three modified files
|
||||||
|
2. `npx eslint src/components/admin/dashboard/FollowUpWidget.tsx src/components/admin/leads/LeadForm.tsx src/components/admin/leads/SendQuoteModal.tsx` — exits 0
|
||||||
|
3. `grep -ic "Require Follow-up\|to contact\|View All\|up to date" src/components/admin/dashboard/FollowUpWidget.tsx` returns 0 (no English strings)
|
||||||
|
4. `grep -c "useForm<any>\|as any" src/components/admin/leads/LeadForm.tsx` returns 0
|
||||||
|
5. `grep -A2 "async function onSubmit" src/components/admin/leads/SendQuoteModal.tsx | grep -c 'data.tab === "new"'` returns 0
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- FollowUpWidget.tsx displays only Italian text ("Richiedi Follow-up", "{count} lead da contattare", "Contatta", "Vedi Tutto ({count})", "Tutti i lead sono aggiornati!") — CRM-10 satisfied
|
||||||
|
- LeadForm.tsx's CreateLeadModal and EditLeadModal both use `useForm<CreateLeadInput>()` with `CreateLeadInput` imported from `lead-validators.ts`; no `as any` remains — CRM-11 satisfied
|
||||||
|
- SendQuoteModal.tsx's `onSubmit` no longer contains the unreachable `tab === "new"` branch; existing-tab submission and new-tab button navigation both continue to work — CRM-12 satisfied
|
||||||
|
- `npx tsc --noEmit` and `npx eslint` both exit 0 across all three files
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/14-crm-attio-style-fix/14-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
# Phase 14: CRM Attio-style & Fix - Pattern Map
|
||||||
|
|
||||||
|
**Mapped:** 2026-06-14
|
||||||
|
**Files analyzed:** 12 (8 to create/modify, 4 to reuse as-is)
|
||||||
|
**Analogs found:** 8 / 8 (100% coverage from Phase 11)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Classification
|
||||||
|
|
||||||
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||||
|
|-------------------|------|-----------|----------------|---------------|
|
||||||
|
| `src/components/admin/leads/LeadTable.tsx` | component | CRUD (inline-edit + render) | `src/components/admin/catalog/ServiceTable.tsx` | exact |
|
||||||
|
| `src/app/admin/leads/LeadsSearch.tsx` | component | request-response (client-side filter) | `src/app/admin/catalog/CatalogSearch.tsx` | exact |
|
||||||
|
| `src/app/admin/leads/actions.ts` | server action | CRUD (field update, tag add/remove/rename) | `src/app/admin/catalog/actions.ts` | exact |
|
||||||
|
| `src/lib/admin-queries.ts` (extend) | query service | CRUD (entity + tags join) | `src/lib/admin-queries.ts` (getAllServices pattern) | exact |
|
||||||
|
| `src/components/admin/dashboard/FollowUpWidget.tsx` | component | request-response (i18n fix) | self | N/A (fix-only) |
|
||||||
|
| `src/components/admin/leads/LeadForm.tsx` | component | request-response (type-safety fix) | self | N/A (fix-only) |
|
||||||
|
| `src/components/admin/leads/SendQuoteModal.tsx` | component | request-response (dead-code fix) | self | N/A (fix-only) |
|
||||||
|
| `src/components/ui/editable-cell.tsx` | component (reuse) | N/A | Phase 11 shipped | exact |
|
||||||
|
| `src/components/ui/option-select.tsx` | component (reuse) | N/A | Phase 11 shipped | exact |
|
||||||
|
| `src/components/ui/option-multi-select.tsx` | component (reuse) | N/A | Phase 11 shipped | exact |
|
||||||
|
| `src/components/ui/option-colors.ts` | utility (reuse) | N/A | Phase 11 shipped | exact |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern Assignments
|
||||||
|
|
||||||
|
### `src/components/admin/leads/LeadTable.tsx` (component, CRUD — rewrite)
|
||||||
|
|
||||||
|
**Analog:** `src/components/admin/catalog/ServiceTable.tsx` (Phase 11, shipped)
|
||||||
|
|
||||||
|
**Role:** Client component (`"use client"`), renders table rows with inline-editable cells and dropdown selects. Wires cell edits to server actions via `useTransition()` + `router.refresh()`.
|
||||||
|
|
||||||
|
**Imports pattern** (lines 1–16):
|
||||||
|
```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 { OptionSelect } from "@/components/ui/option-select";
|
||||||
|
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||||
|
import {
|
||||||
|
updateLeadField,
|
||||||
|
addLeadTag,
|
||||||
|
removeLeadTag,
|
||||||
|
renameLeadTag,
|
||||||
|
} from "@/app/admin/leads/actions";
|
||||||
|
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Core row pattern** (mirrored from ServiceTable lines 35–140):
|
||||||
|
```typescript
|
||||||
|
function LeadRow({
|
||||||
|
lead,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
lead: LeadWithTags;
|
||||||
|
options: LeadFieldOptions;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function run(fn: () => Promise<unknown>) {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
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">
|
||||||
|
{/* Name — EditableCell, required, linked to detail */}
|
||||||
|
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
|
||||||
|
<EditableCell
|
||||||
|
value={lead.name}
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
onSave={(v) => run(() => updateLeadField(lead.id, "name", v))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Email — EditableCell, optional */}
|
||||||
|
<td className="py-2 px-3 min-w-[160px]">
|
||||||
|
<EditableCell
|
||||||
|
value={lead.email ?? "—"}
|
||||||
|
type="text"
|
||||||
|
onSave={(v) => run(() => updateLeadField(lead.id, "email", v))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Phone — EditableCell, optional */}
|
||||||
|
<td className="py-2 px-3 min-w-[140px]">
|
||||||
|
<EditableCell
|
||||||
|
value={lead.phone ?? "—"}
|
||||||
|
type="text"
|
||||||
|
onSave={(v) => run(() => updateLeadField(lead.id, "phone", v))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Company — EditableCell, optional */}
|
||||||
|
<td className="py-2 px-3 min-w-[140px]">
|
||||||
|
<EditableCell
|
||||||
|
value={lead.company ?? "—"}
|
||||||
|
type="text"
|
||||||
|
onSave={(v) => run(() => updateLeadField(lead.id, "company", v))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Status — OptionSelect, fixed enum (LEAD_STAGES), no create-on-the-fly */}
|
||||||
|
<td className="py-2 px-3 min-w-[120px]">
|
||||||
|
<OptionSelect
|
||||||
|
value={lead.status}
|
||||||
|
options={options.status}
|
||||||
|
onChange={(v) => run(() => updateLeadField(lead.id, "status", v ?? "contacted"))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Next Action — EditableCell, optional */}
|
||||||
|
<td className="py-2 px-3 min-w-[180px]">
|
||||||
|
<EditableCell
|
||||||
|
value={lead.next_action ?? "—"}
|
||||||
|
type="text"
|
||||||
|
onSave={(v) => run(() => updateLeadField(lead.id, "next_action", v))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Tags — OptionMultiSelect, create-on-the-fly enabled */}
|
||||||
|
<td className="py-2 px-3 min-w-[180px]">
|
||||||
|
<OptionMultiSelect
|
||||||
|
values={lead.tags}
|
||||||
|
options={options.tags}
|
||||||
|
onAdd={(v) => run(() => addLeadTag(lead.id, v))}
|
||||||
|
onRemove={(v) => run(() => removeLeadTag(lead.id, v))}
|
||||||
|
onRename={(o, n) => run(() => renameLeadTag(o, n))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions — Link to detail page */}
|
||||||
|
<td className="py-2 px-3 min-w-[80px]">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/admin/leads/${lead.id}`}>Dettagli</Link>
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{error && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="py-1 px-3 text-xs text-red-600">
|
||||||
|
{error}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeadTable({
|
||||||
|
leads,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
leads: LeadWithTags[];
|
||||||
|
options: LeadFieldOptions;
|
||||||
|
}) {
|
||||||
|
const COLUMN_HEADERS = [
|
||||||
|
"Nome",
|
||||||
|
"Email",
|
||||||
|
"Telefono",
|
||||||
|
"Azienda",
|
||||||
|
"Stato",
|
||||||
|
"Prossima Azione",
|
||||||
|
"Tag",
|
||||||
|
"Azioni",
|
||||||
|
];
|
||||||
|
const COL_COUNT = COLUMN_HEADERS.length;
|
||||||
|
|
||||||
|
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>
|
||||||
|
{leads.map((lead) => (
|
||||||
|
<LeadRow key={lead.id} lead={lead} options={options} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{leads.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-gray-500">Nessun lead trovato</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key differences from ServiceTable:**
|
||||||
|
- LeadTable has no `QuickAddRow` (out of scope per CRM-08..12)
|
||||||
|
- LeadTable has no inactive-services grouping section
|
||||||
|
- LeadTable's `status` field uses fixed enum (no create-on-the-fly); CatalogSearch's `categoria`/`fase` are open-ended
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/app/admin/leads/LeadsSearch.tsx` (component, request-response — new)
|
||||||
|
|
||||||
|
**Analog:** `src/app/admin/catalog/CatalogSearch.tsx` (Phase 11, shipped, lines 1–46)
|
||||||
|
|
||||||
|
**Role:** Client component (`"use client"`), renders search input + calls `LeadTable` with filtered results.
|
||||||
|
|
||||||
|
**Imports and structure** (lines 1–46):
|
||||||
|
```typescript
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { LeadTable } from "@/components/admin/leads/LeadTable";
|
||||||
|
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
||||||
|
|
||||||
|
export function LeadsSearch({
|
||||||
|
leads,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
leads: LeadWithTags[];
|
||||||
|
options: LeadFieldOptions;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return leads;
|
||||||
|
return leads.filter((l) =>
|
||||||
|
l.name.toLowerCase().includes(q) ||
|
||||||
|
l.email?.toLowerCase().includes(q) ||
|
||||||
|
l.company?.toLowerCase().includes(q) ||
|
||||||
|
l.status.toLowerCase().includes(q) ||
|
||||||
|
l.tags.some((t) => t.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}, [leads, 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 lead..."
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<LeadTable leads={filtered} options={options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/app/admin/leads/actions.ts` (server action, CRUD — extend)
|
||||||
|
|
||||||
|
**Analog:** `src/app/admin/catalog/actions.ts` (Phase 11, shipped)
|
||||||
|
|
||||||
|
**Role:** Server actions (`"use server"`), field-update + tag CRUD with `requireAdmin()` auth guard and `revalidatePath()` side effects.
|
||||||
|
|
||||||
|
**Auth guard pattern** (catalog/actions.ts lines 18–21):
|
||||||
|
```typescript
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("Non autorizzato");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field update pattern with allowlist** (catalog/actions.ts lines 71–116):
|
||||||
|
```typescript
|
||||||
|
const EDITABLE_FIELDS = ["name", "email", "phone", "company", "status", "next_action"] as const;
|
||||||
|
type EditableField = (typeof EDITABLE_FIELDS)[number];
|
||||||
|
|
||||||
|
export async function updateLeadField(
|
||||||
|
leadId: string,
|
||||||
|
fieldName: EditableField,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
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(leads).set({ name: s, updated_at: new Date() }).where(eq(leads.id, leadId));
|
||||||
|
} else if (fieldName === "status") {
|
||||||
|
// Validate against LEAD_STAGES enum (defense-in-depth, even if UI restricts it)
|
||||||
|
if (!LEAD_STAGES.includes(value as any)) {
|
||||||
|
throw new Error("Stato non valido");
|
||||||
|
}
|
||||||
|
await db.update(leads).set({ status: value, updated_at: new Date() }).where(eq(leads.id, leadId));
|
||||||
|
} else if (fieldName === "email" || fieldName === "phone" || fieldName === "company" || fieldName === "next_action") {
|
||||||
|
// Nullable text fields — allow empty to clear
|
||||||
|
const s = String(value).trim();
|
||||||
|
await db
|
||||||
|
.update(leads)
|
||||||
|
.set({ [fieldName]: s || null, updated_at: new Date() })
|
||||||
|
.where(eq(leads.id, leadId));
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/leads");
|
||||||
|
revalidatePath(`/admin/leads/${leadId}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tag CRUD pattern** (catalog/actions.ts lines 131–199, ported to leads):
|
||||||
|
```typescript
|
||||||
|
const LEADS_TAG_ENTITY = "leads";
|
||||||
|
|
||||||
|
export async function addLeadTag(leadId: string, value: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0) throw new Error("Valore richiesto");
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(tags)
|
||||||
|
.values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed })
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
revalidatePath("/admin/leads");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeLeadTag(leadId: string, value: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(tags)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(tags.entity_type, LEADS_TAG_ENTITY),
|
||||||
|
eq(tags.entity_id, leadId),
|
||||||
|
eq(tags.name, value)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidatePath("/admin/leads");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameLeadTag(oldValue: string, newValue: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
const next = newValue.trim();
|
||||||
|
if (next.length === 0) throw new Error("Nuovo nome richiesto");
|
||||||
|
if (next === oldValue) return;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(tags)
|
||||||
|
.set({ name: next })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(tags.entity_type, LEADS_TAG_ENTITY),
|
||||||
|
eq(tags.name, oldValue)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidatePath("/admin/leads");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key imports** (catalog/actions.ts lines 1–9):
|
||||||
|
```typescript
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { leads, tags } from "@/db/schema";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { LEAD_STAGES } from "@/lib/lead-validators";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/lib/admin-queries.ts` (extend — query service, CRUD)
|
||||||
|
|
||||||
|
**Analog:** `src/lib/admin-queries.ts` getAllServices + getCatalogFieldOptions (Phase 11, lines 360–448)
|
||||||
|
|
||||||
|
**Role:** Query layer, provides `LeadWithTags` type and two functions: `getLeadsWithTags()` (left-join + map-reduce), `getLeadFieldOptions()` (distinct values).
|
||||||
|
|
||||||
|
**LeadWithTags type and join pattern** (mirrored from getAllServices lines 360–409):
|
||||||
|
```typescript
|
||||||
|
const LEADS_TAG_ENTITY = "leads";
|
||||||
|
|
||||||
|
export type LeadWithTags = Lead & { tags: string[] };
|
||||||
|
|
||||||
|
export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
// All leads columns
|
||||||
|
id: leads.id,
|
||||||
|
name: leads.name,
|
||||||
|
email: leads.email,
|
||||||
|
phone: leads.phone,
|
||||||
|
company: leads.company,
|
||||||
|
status: leads.status,
|
||||||
|
last_contact_date: leads.last_contact_date,
|
||||||
|
next_action: leads.next_action,
|
||||||
|
next_action_date: leads.next_action_date,
|
||||||
|
notes: leads.notes,
|
||||||
|
created_at: leads.created_at,
|
||||||
|
updated_at: leads.updated_at,
|
||||||
|
// Tag name for join
|
||||||
|
tag_name: tags.name,
|
||||||
|
})
|
||||||
|
.from(leads)
|
||||||
|
.leftJoin(
|
||||||
|
tags,
|
||||||
|
and(
|
||||||
|
eq(tags.entity_id, leads.id),
|
||||||
|
eq(tags.entity_type, LEADS_TAG_ENTITY)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(leads.updated_at), asc(tags.name));
|
||||||
|
|
||||||
|
const leadMap = new Map<string, LeadWithTags>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const { tag_name, ...leadFields } = row;
|
||||||
|
if (!leadMap.has(row.id)) leadMap.set(row.id, { ...leadFields, tags: [] });
|
||||||
|
if (tag_name) leadMap.get(row.id)!.tags.push(tag_name);
|
||||||
|
}
|
||||||
|
return Array.from(leadMap.values());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field options pattern** (mirrored from getCatalogFieldOptions lines 414–448):
|
||||||
|
```typescript
|
||||||
|
export type LeadFieldOptions = { status: string[]; tags: string[] };
|
||||||
|
|
||||||
|
export async function getLeadFieldOptions(): Promise<LeadFieldOptions> {
|
||||||
|
const tagRows = await db
|
||||||
|
.selectDistinct({ name: tags.name })
|
||||||
|
.from(tags)
|
||||||
|
.where(eq(tags.entity_type, LEADS_TAG_ENTITY));
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: [...LEAD_STAGES], // Fixed enum, not dynamic
|
||||||
|
tags: tagRows
|
||||||
|
.map((r) => r.name)
|
||||||
|
.sort((a, b) => a.localeCompare(b, "it")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Imports** (add to existing admin-queries.ts top):
|
||||||
|
```typescript
|
||||||
|
import { and, asc } from "drizzle-orm";
|
||||||
|
import type { Lead } from "@/db/schema";
|
||||||
|
import { LEAD_STAGES } from "@/lib/lead-validators";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/components/admin/dashboard/FollowUpWidget.tsx` (component, request-response — fix CRM-10)
|
||||||
|
|
||||||
|
**Current analog:** self (existing file, lines 1–47)
|
||||||
|
|
||||||
|
**Issue:** 6 hardcoded English strings need Italian translation.
|
||||||
|
|
||||||
|
**Strings to fix:**
|
||||||
|
1. Line 15: `"Require Follow-up"` → `"Richiedi Follow-up"`
|
||||||
|
2. Line 22: `"lead"` / `"leads"` → Always `"lead"` in Italian (loanword, plural also "lead")
|
||||||
|
3. Line 23: `"to contact"` → `"da contattare"`
|
||||||
|
4. Line 30: `"Contact"` → `"Contatta"`
|
||||||
|
5. Line 37: `"View All"` → `"Vedi Tutto"`
|
||||||
|
6. Line 42: `"All leads are up to date!"` → `"Tutti i lead sono aggiornati!"`
|
||||||
|
|
||||||
|
**Fixed version:**
|
||||||
|
```typescript
|
||||||
|
export async function FollowUpWidget() {
|
||||||
|
const leadsNeedingFollowUp = await getLeadsNeedingFollowUp(7);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-orange-200 bg-orange-50">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-orange-600" />
|
||||||
|
Richiedi Follow-up
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{leadsNeedingFollowUp.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<p className="text-lg font-bold text-orange-900">
|
||||||
|
{leadsNeedingFollowUp.length} lead da contattare
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{leadsNeedingFollowUp.slice(0, 3).map((lead) => (
|
||||||
|
<li key={lead.id} className="flex justify-between items-center">
|
||||||
|
<span>{lead.name}</span>
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link href={`/admin/leads/${lead.id}`}>Contatta</Link>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{leadsNeedingFollowUp.length > 3 && (
|
||||||
|
<Button variant="outline" className="w-full" asChild>
|
||||||
|
<Link href="/admin/leads">Vedi Tutto ({leadsNeedingFollowUp.length})</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 text-sm">Tutti i lead sono aggiornati!</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/components/admin/leads/LeadForm.tsx` (component, request-response — fix CRM-11)
|
||||||
|
|
||||||
|
**Current analog:** self (existing file, lines 1–100+)
|
||||||
|
|
||||||
|
**Issue:** `useForm<any>()` defeats Zod type inference. Fix: use `CreateLeadInput` type exported from `lead-validators.ts`.
|
||||||
|
|
||||||
|
**Current pattern (lines 41–51, problematic):**
|
||||||
|
```typescript
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(createLeadSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
company: "",
|
||||||
|
status: "contacted",
|
||||||
|
notes: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixed pattern:**
|
||||||
|
```typescript
|
||||||
|
import { CreateLeadInput } from "@/lib/lead-validators";
|
||||||
|
|
||||||
|
const form = useForm<CreateLeadInput>({
|
||||||
|
resolver: zodResolver(createLeadSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
company: "",
|
||||||
|
status: "contacted",
|
||||||
|
notes: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: CreateLeadInput) {
|
||||||
|
// No `data: any` — type is now inferred
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await createLead(data);
|
||||||
|
// ...
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key change:** Remove all `as any` casts and replace `useForm<any>()` with `useForm<CreateLeadInput>()`. The one unavoidable cast at the schema boundary (`lead.status as CreateLeadInput["status"]`) is acceptable because it's narrow and type-checked at compile time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/components/admin/leads/SendQuoteModal.tsx` (component, request-response — fix CRM-12)
|
||||||
|
|
||||||
|
**Current analog:** self (existing file, lines 1–110+)
|
||||||
|
|
||||||
|
**Issue:** Dead code — `if (tab === "new")` branch inside `onSubmit` (form handler) can never execute because the "new" tab has its own standalone button that redirects without submitting the form.
|
||||||
|
|
||||||
|
**Current problematic pattern (lines 48–71):**
|
||||||
|
```typescript
|
||||||
|
async function onSubmit(data: any) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (tab === "new") {
|
||||||
|
// This branch is unreachable — "new" tab's button does window.location.href directly
|
||||||
|
window.location.href = `/admin/quotes/new?lead_id=${leadId}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await assignQuoteToLead({
|
||||||
|
lead_id: leadId,
|
||||||
|
quote_token: data.quote_token,
|
||||||
|
generate_new: false,
|
||||||
|
});
|
||||||
|
// ...
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fixed pattern — remove the dead branch:**
|
||||||
|
```typescript
|
||||||
|
async function onSubmit(data: any) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Only handle "existing quote" path (tab === "existing")
|
||||||
|
// The "new" tab's button is self-contained and redirects via onClick
|
||||||
|
const result = await assignQuoteToLead({
|
||||||
|
lead_id: leadId,
|
||||||
|
quote_token: data.quote_token,
|
||||||
|
generate_new: false,
|
||||||
|
});
|
||||||
|
if (result.success) {
|
||||||
|
setOpen(false);
|
||||||
|
form.reset();
|
||||||
|
} else {
|
||||||
|
console.error("Error assigning quote:", result.error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Additional cleanup:** The `generate_new` field in `defaultValues` and schema is also dead code (always `false`). Consider either removing it or documenting that it's intentionally unused.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reused Components (No Changes)
|
||||||
|
|
||||||
|
### `src/components/ui/editable-cell.tsx` (Phase 11, shipped)
|
||||||
|
|
||||||
|
**Analog:** self (read-only, reuse as-is)
|
||||||
|
|
||||||
|
**Pattern summary** (lines 8–26):
|
||||||
|
- `type?: "text" | "number" | "textarea" | "toggle"`
|
||||||
|
- `onSave: (value: string) => void` callback
|
||||||
|
- `required?: boolean` validation (client-side; server validates separately)
|
||||||
|
- `formatDisplay?: (value: string) => string` for display formatting (e.g., price)
|
||||||
|
|
||||||
|
Used in LeadTable for: `name`, `email`, `phone`, `company`, `next_action`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/components/ui/option-select.tsx` (Phase 11, shipped)
|
||||||
|
|
||||||
|
**Analog:** self (read-only, reuse as-is)
|
||||||
|
|
||||||
|
**Pattern summary** (lines 10–26):
|
||||||
|
- `value: string | null`, `options: string[]`
|
||||||
|
- `onChange: (value: string | null) => void`
|
||||||
|
- `onRename?: (oldValue: string, newValue: string) => void` for inline renaming
|
||||||
|
- `placeholder?: string` default `"Cerca o crea..."`
|
||||||
|
|
||||||
|
Used in LeadTable for: `status` field (with fixed `options={LEAD_STAGES}`, no `onRename`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/components/ui/option-multi-select.tsx` (Phase 11, shipped)
|
||||||
|
|
||||||
|
**Analog:** self (read-only, reuse as-is)
|
||||||
|
|
||||||
|
**Pattern summary** (lines 10–27):
|
||||||
|
- `values: string[]`, `options: string[]` (union of pool + current values)
|
||||||
|
- `onAdd: (value: string) => void`, `onRemove: (value: string) => void`
|
||||||
|
- `onRename?: (oldValue: string, newValue: string) => void` for inline renaming
|
||||||
|
- `placeholder?: string` default `"Cerca o crea..."`
|
||||||
|
|
||||||
|
Used in LeadTable for: `tags` field (CRM-09).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `src/components/ui/option-colors.ts` (Phase 11, shipped)
|
||||||
|
|
||||||
|
**Analog:** self (read-only, reuse as-is)
|
||||||
|
|
||||||
|
**Pattern summary** (lines 1–21):
|
||||||
|
- `getOptionColor(value: string): string` — deterministic hash → 7-color pastel palette
|
||||||
|
- Automatically used by `OptionSelect` and `OptionMultiSelect` for badge coloring
|
||||||
|
|
||||||
|
Note: LeadTable's `status` field may override colors with `STAGE_COLOR` map (semantic green/red) per Pitfall 6 of RESEARCH.md. Decision deferred to planner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Patterns
|
||||||
|
|
||||||
|
### Authentication Guard (Server Actions)
|
||||||
|
|
||||||
|
**Source:** `src/app/admin/catalog/actions.ts` lines 18–21, imported from `@/lib/auth`
|
||||||
|
|
||||||
|
**Apply to:** All new lead server actions (`updateLeadField`, `addLeadTag`, `removeLeadTag`, `renameLeadTag`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) throw new Error("Non autorizzato");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Call at the top of every mutating action:
|
||||||
|
```typescript
|
||||||
|
export async function updateLeadField(...) {
|
||||||
|
await requireAdmin();
|
||||||
|
// ... rest of logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Error Handling (Server Actions & Components)
|
||||||
|
|
||||||
|
**Source:** `src/app/admin/catalog/actions.ts` + `src/components/admin/catalog/ServiceTable.tsx`
|
||||||
|
|
||||||
|
**Apply to:** All server actions (validation + throw), all LeadTable rows (error display)
|
||||||
|
|
||||||
|
**Server action pattern** (actions.ts):
|
||||||
|
```typescript
|
||||||
|
if (!EDITABLE_FIELDS.includes(fieldName)) {
|
||||||
|
throw new Error(`Campo non editabile: ${fieldName}`);
|
||||||
|
}
|
||||||
|
if (required && !value.trim()) {
|
||||||
|
throw new Error("Campo richiesto");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Component pattern** (ServiceTable.tsx lines 43–56):
|
||||||
|
```typescript
|
||||||
|
function run(fn: () => Promise<unknown>) {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Error display (lines 131–137):
|
||||||
|
```typescript
|
||||||
|
{error && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
||||||
|
{error}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Polymorphic Tag CRUD
|
||||||
|
|
||||||
|
**Source:** `src/app/admin/catalog/actions.ts` lines 131–199 (addServiceOption/removeServiceOption/renameServiceOption)
|
||||||
|
|
||||||
|
**Apply to:** `addLeadTag`, `removeLeadTag`, `renameLeadTag` (CRM-09)
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
1. Define entity-type constant: `const LEADS_TAG_ENTITY = "leads"`
|
||||||
|
2. Insert: `.onConflictDoNothing()` for idempotency (unique index on `(entity_type, entity_id, name)`)
|
||||||
|
3. Delete: `where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.entity_id, leadId), eq(tags.name, value)))`
|
||||||
|
4. Rename: Update all rows with `entity_type="leads"` and matching name (propagates across all leads)
|
||||||
|
5. After each mutation: `revalidatePath("/admin/leads")`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Data Fetch & Join Pattern (LeadWithTags)
|
||||||
|
|
||||||
|
**Source:** `src/lib/admin-queries.ts` lines 360–409 (getAllServices pattern)
|
||||||
|
|
||||||
|
**Apply to:** `getLeadsWithTags()` in admin-queries.ts
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
1. Select from entity table, left-join tags table scoped by `entity_type`
|
||||||
|
2. Iterate rows, group by entity ID, append tag names to array
|
||||||
|
3. Return `Map.values()` as `LeadWithTags[]`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const leadMap = new Map<string, LeadWithTags>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const { tag_name, ...leadFields } = row;
|
||||||
|
if (!leadMap.has(row.id)) leadMap.set(row.id, { ...leadFields, tags: [] });
|
||||||
|
if (tag_name) leadMap.get(row.id)!.tags.push(tag_name);
|
||||||
|
}
|
||||||
|
return Array.from(leadMap.values());
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Client-Side Instant Filter (LeadsSearch)
|
||||||
|
|
||||||
|
**Source:** `src/app/admin/catalog/CatalogSearch.tsx` lines 18–29
|
||||||
|
|
||||||
|
**Apply to:** `LeadsSearch.tsx` (`useMemo` + `.filter()`)
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```typescript
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return leads;
|
||||||
|
return leads.filter((l) =>
|
||||||
|
l.name.toLowerCase().includes(q) ||
|
||||||
|
l.email?.toLowerCase().includes(q) ||
|
||||||
|
// ... other fields ...
|
||||||
|
l.tags.some((t) => t.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}, [leads, query]);
|
||||||
|
```
|
||||||
|
|
||||||
|
No server round-trip, pure client-side filter on keystroke.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No Analog Found
|
||||||
|
|
||||||
|
| File | Role | Data Flow | Reason |
|
||||||
|
|------|------|-----------|--------|
|
||||||
|
| (none) | — | — | All Phase 14 files have exact or role-match analogs from Phase 11. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Analog search scope:** Codebase searched in directories: `src/components/`, `src/app/`, `src/lib/`
|
||||||
|
|
||||||
|
**Files scanned:** 100+ (focused reads on Phase 11 shipping catalog patterns)
|
||||||
|
|
||||||
|
**Pattern extraction date:** 2026-06-14
|
||||||
|
|
||||||
|
**Confidence level:** 100% (8/8 files have direct analogs; Phase 11 was explicitly designed to provide these primitives for Phase 14)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Notes for Planner
|
||||||
|
|
||||||
|
1. **Phase 14 is a "reuse, don't reinvent" phase.** Every UI primitive (EditableCell, OptionSelect, OptionMultiSelect, getOptionColor) and every backend pattern (field allowlist, tag CRUD, requireAdmin) ships from Phase 11. Treat any deviation as a red flag.
|
||||||
|
|
||||||
|
2. **Status field is a closed enum.** Unlike tags (open-ended), `status` must validate against `LEAD_STAGES` server-side. The planner should decide: use `OptionSelect` with fixed options + semantic color override (STAGE_COLOR), or build a simpler closed-enum dropdown. Both are low-effort; just pick one explicitly.
|
||||||
|
|
||||||
|
3. **Three bug fixes are small and isolated.** CRM-10 (i18n), CRM-11 (type safety), and CRM-12 (dead code) are independent edits to their respective files. They don't block or enable the main table rewrite.
|
||||||
|
|
||||||
|
4. **New server actions should include `requireAdmin()`.** Current `src/app/admin/leads/actions.ts` pre-existing functions lack this guard. New actions (Phase 14) follow the stricter Phase 11 convention. Whether to retrofit the pre-existing actions is a scope decision outside Phase 14.
|
||||||
|
|
||||||
|
5. **Two paths for revalidation.** Field updates should call both `revalidatePath("/admin/leads")` (list page) and `revalidatePath(\`/admin/leads/${leadId}\`)` (detail page), matching the existing `updateLead` pattern in `src/app/admin/leads/actions.ts` lines 62–63.
|
||||||
Reference in New Issue
Block a user