d115465240
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.
871 lines
27 KiB
Markdown
871 lines
27 KiB
Markdown
# 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.
|