--- phase: 12 plan: 05 type: execute wave: 3 depends_on: [1, 3] files_modified: - src/app/admin/offers/[id]/edit/page.tsx - src/components/admin/offers/OfferEditorClient.tsx autonomous: true requirements: [OFFER-11, OFFER-15, OFFER-16, OFFER-17] must_haves: truths: - "Admin can open /admin/offers/[id]/edit and see the offer's name, category/ticket chips, 4 tag dimensions, services matrix, transformation promise, and Salva/Annulla/Archivia actions" - "Admin can select Categoria and Ticket (single-select), and add/remove Tipo and Obiettivo tags (multi-select, creatable on the fly) — OFFER-15" - "Admin sees a service x tier (A/B/C) checkbox matrix pre-filtered to services matching the offer's category, with live-updating 'Totale Servizi' per tier as checkboxes toggle — OFFER-11" - "Admin can enter an independent manual 'Prezzo Pubblico' per tier, separate from 'Totale Servizi' — OFFER-16" - "Admin can edit 5 transformation-promise fields (Aiuto/A ottenere/In/Senza/Grazie a) — OFFER-17" - "Clicking 'Salva Offerta' persists all changes (macro fields, tier data, tier-service assignments, tags) via saveOfferEditor and redirects to /admin/offers" - "Clicking 'Archivia' toggles is_archived (with confirmation) via toggleOfferArchived" artifacts: - path: "src/app/admin/offers/[id]/edit/page.tsx" provides: "Server component: fetches getOfferEditorData(id) + getOfferFieldOptions(), renders OfferEditorClient or notFound()" - path: "src/components/admin/offers/OfferEditorClient.tsx" provides: "Client component: editable name/category/ticket, 4-dimension tags, services matrix with live totals, transformation promise fields, Salva/Annulla/Archivia actions" exports: ["OfferEditorClient"] key_links: - from: "src/components/admin/offers/OfferEditorClient.tsx (Salva Offerta)" to: "saveOfferEditor server action (Plan 03)" via: "useTransition + router.push('/admin/offers') on success" pattern: "saveOfferEditor" - from: "src/components/admin/offers/OfferEditorClient.tsx (services matrix)" to: "OfferEditorData.tiers[].assignedServiceIds + availableServices" via: "client-side state, checkbox toggles update local tier.assignedServiceIds, Totale Servizi recomputed via useMemo from unit_price sums" pattern: "Totale Servizi|assignedServiceIds" - from: "src/components/admin/offers/OfferEditorClient.tsx (Archivia)" to: "toggleOfferArchived server action (Plan 03)" via: "confirm dialog + useTransition + router.push('/admin/offers')" pattern: "toggleOfferArchived" --- Build the Offer Editor detail page at `/admin/offers/[id]/edit`: a full-page editor covering the offer's name/category/ticket, 4-dimension tags (Categoria/Ticket single-select, Tipo/Obiettivo multi-select creatable on the fly), the service x tier (A/B/C) checkbox matrix pre-filtered by category with live "Totale Servizi" totals, independent manual "Prezzo Pubblico" per tier, the 5-field transformation promise block, and Salva/Annulla/Archivia actions. Purpose: Deliver OFFER-11 (3-tier checkbox matrix with live totals), OFFER-15 (4-dimension tags), OFFER-16 (manual public price per tier), and OFFER-17 (structured transformation promise) per the locked UI-SPEC section 2 and CONTEXT.md decisions D-2 through D-5. Output: `src/app/admin/offers/[id]/edit/page.tsx` (new server component) and `src/components/admin/offers/OfferEditorClient.tsx` (new client component) implementing the full editor. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/STATE.md @.planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md @src/components/admin/catalog/ServiceTable.tsx @src/components/ui/option-select.tsx @src/components/ui/option-multi-select.tsx @src/components/ui/editable-cell.tsx ```typescript export type OfferTierData = { id: string; tier_letter: string | null; // "A" | "B" | "C" | null internal_name: string; public_name: string; duration_months: number; public_price: string | null; // numeric as string, e.g. "499.00" assignedServiceIds: string[]; servicesTotal: string; // pre-computed sum, numeric as string e.g. "350.00" }; export type OfferEditorData = { macro: OfferMacro; // includes id, internal_name, public_name, description, // category, ticket, is_archived, cliente_ideale, // risultato, tempo, pain, metodo (all from Plan 01 schema) tiers: OfferTierData[]; // ordered A, B, C (0-3 entries) availableServices: Array<{ id: string; name: string; unit_price: string; category: string | null }>; tipoTags: string[]; // current Tipo tag values for this offer obiettivoTags: string[]; // current Obiettivo tag values for this offer }; export async function getOfferEditorData(macroId: string): Promise; export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] }; export async function getOfferFieldOptions(): Promise; ``` ```typescript // Zod-validated; tiers array max 3, each tier has tier_letter "A"|"B"|"C", // internal_name, public_name, duration_months (int >= 1), public_price (number >= 0 | null), // assignedServiceIds (string[]). id optional per tier (omit for new tier, include to update). export type SaveOfferEditorPayload = { internal_name: string; public_name: string; description?: string; category?: string; ticket?: string; cliente_ideale?: string; risultato?: string; tempo?: string; pain?: string; metodo?: string; tiers: Array<{ id?: string; tier_letter: "A" | "B" | "C"; internal_name: string; public_name: string; duration_months: number; public_price?: number | null; assignedServiceIds: string[]; }>; tipoTags: string[]; obiettivoTags: string[]; }; export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorPayload): Promise; export async function toggleOfferArchived(macroId: string, archived: boolean): Promise; export async function addOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise; export async function removeOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise; export async function renameOfferOption(field: "categoria" | "ticket" | "tipo" | "obiettivo", oldValue: string, newValue: string): Promise; ``` ```typescript export interface OptionSelectProps { value: string | null; options: string[]; onChange: (value: string | null) => void; onRename?: (oldValue: string, newValue: string) => void; placeholder?: string; disabled?: boolean; } export function OptionSelect(props: OptionSelectProps): JSX.Element; 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; } export function OptionMultiSelect(props: OptionMultiSelectProps): JSX.Element; ``` ```typescript export interface EditableCellProps { value: string; type?: "text" | "number"; onSave: (value: string) => void | Promise; required?: boolean; placeholder?: string; disabled?: boolean; formatDisplay?: (value: string) => string; } export function EditableCell(props: EditableCellProps): JSX.Element; ``` Task 1: Create editor route + page.tsx server component src/app/admin/offers/[id]/edit/page.tsx - src/app/admin/offers/page.tsx (current, post-Plan-04 — server component fetch pattern) - src/lib/offer-queries.ts (Plan 03 — getOfferEditorData, getOfferFieldOptions signatures, confirm null-handling) Create `src/app/admin/offers/[id]/edit/page.tsx` as a server component (Next.js 16 App Router, async `params`): ```typescript import { notFound } from "next/navigation"; import { getOfferEditorData, getOfferFieldOptions } from "@/lib/offer-queries"; import { OfferEditorClient } from "@/components/admin/offers/OfferEditorClient"; export const revalidate = 0; export default async function OfferEditPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; const [data, options] = await Promise.all([ getOfferEditorData(id), getOfferFieldOptions(), ]); if (!data) { notFound(); } return ; } ``` Use the Next.js 16 async `params` pattern (consistent with other dynamic routes in this project — confirm by checking an existing `[id]` route if one exists, e.g. under `/admin/leads/[id]` from Phase 14, otherwise this signature is correct for Next 16 App Router). cd /Users/simonecavalli/Vault/IAMCAVALLI && grep -c "getOfferEditorData\|getOfferFieldOptions\|OfferEditorClient\|notFound" src/app/admin/offers/\[id\]/edit/page.tsx - File exists at `src/app/admin/offers/[id]/edit/page.tsx` - Server component (no `"use client"` directive) - Calls `getOfferEditorData(id)` and `getOfferFieldOptions()` via `Promise.all` - Calls `notFound()` when `data === null` - Renders `` page.tsx exists, fetches editor data + field options, handles not-found, delegates to OfferEditorClient. Task 2: Build OfferEditorClient — tags, services matrix with live totals, promise block, actions src/components/admin/offers/OfferEditorClient.tsx - src/components/admin/catalog/ServiceTable.tsx (EditableCell/OptionSelect/OptionMultiSelect usage patterns, table layout conventions) - .planning/phases/12-offer-composition-drag-drop-csv-import/12-UI-SPEC.md section 2 (full editor layout, color tokens, copywriting contract table, visual states) Create `src/components/admin/offers/OfferEditorClient.tsx`: ```typescript "use client"; import { useState, useMemo, useTransition } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { saveOfferEditor, toggleOfferArchived, addOfferTag, removeOfferTag, renameOfferOption, type SaveOfferEditorPayload, } from "@/app/admin/offers/actions"; import { OptionSelect } from "@/components/ui/option-select"; import { OptionMultiSelect } from "@/components/ui/option-multi-select"; import { EditableCell } from "@/components/ui/editable-cell"; import type { OfferEditorData, OfferFieldOptions } from "@/lib/offer-queries"; export function OfferEditorClient({ data, fieldOptions, }: { data: OfferEditorData; fieldOptions: OfferFieldOptions; }) { // ... implementation below } ``` Implementation requirements (per UI-SPEC section 2 + copywriting contract table — all literal strings verbatim Italian): 1. **Local editable state** (all derived from `data` on mount, no re-fetch on change): - `macro` fields: `internal_name`, `public_name`, `description`, `category`, `ticket`, `cliente_ideale`, `risultato`, `tempo`, `pain`, `metodo` — each as individual `useState` or one combined `useState` object updated via spread. - `tiers: OfferTierData[]` state, seeded from `data.tiers`. If `data.tiers.length < 3`, pad to exactly 3 tiers (A, B, C) with empty placeholders: `{ id: "", tier_letter: "A"|"B"|"C", internal_name: "", public_name: "", duration_months: 1, public_price: null, assignedServiceIds: [], servicesTotal: "0" }` for any missing letter, so the matrix always renders 3 columns. - `tipoTags: string[]`, `obiettivoTags: string[]` state, seeded from `data.tipoTags`/`data.obiettivoTags`. - `categoriaOptions`/`ticketOptions`/`tipoOptions`/`obiettivoOptions` from `fieldOptions`, kept in local state so `renameOfferOption` can update them optimistically. 2. **Header & navigation**: - "← Indietro" link (text, `#71717a`) -> `Link href="/admin/offers"`. - "Modifica Offerta" as h2 (20px/600). - Offer name as `EditableCell` (text, required) bound to `macro.internal_name`, styled as h3 (16px/600). 3. **Tags block** ("Tag" h3 section label, 16px between rows): - **Categoria** row: label "Categoria" (12px/400, `#71717a`) + ` { renameOfferOption("categoria", old, next); /* update local options + macro.category if it matched old */ }} placeholder="Seleziona categoria..." />`. When `macro.category` changes, the services matrix (step 5) must re-filter `availableServices` by the new category (D-4) — implement via `useMemo` keyed on `macro.category`. - **Ticket** row: same pattern with `macro.ticket` / `ticketOptions` / `renameOfferOption("ticket", ...)`, placeholder "Seleziona ticket...". - **Tipo** row: label "Tipo" + ` { setTipoTags([...tipoTags, v]); if (!tipoOptions.includes(v)) setTipoOptions([...tipoOptions, v]); }} onRemove={(v) => setTipoTags(tipoTags.filter(t => t !== v))} onRename={(old, next) => renameOfferOption("tipo", old, next)} placeholder="+ Crea Tipo" />`. Tag additions/removals are tracked in local state only and persisted on "Salva Offerta" via `tipoTags` in the payload — do NOT call `addOfferTag`/ `removeOfferTag` on every click (avoids partial-save inconsistency); these two actions exist for potential future inline-only flows but this editor batches via `saveOfferEditor`. - **Obiettivo** row: same pattern as Tipo, using `obiettivoTags`/`obiettivoOptions`, placeholder "+ Crea Obiettivo". 4. **Services matrix** ("Servizi Inclusi" h3 section label): - `filteredServices = useMemo(() => macro.category ? data.availableServices.filter(s => s.category === macro.category) : data.availableServices, [macro.category, data.availableServices])`. - Table: header row "Servizio" (left) | "Prezzo" (right-aligned) | "A" | "B" | "C" (centered), header bg `#f9f9f9`, border-bottom `#e5e7eb`. - One row per `filteredServices` item: service `name` (left), `unit_price` formatted as "€X,XX" (right-aligned, tabular-nums), then one `` per tier (A/B/C), checked if `tiers[tierIdx].assignedServiceIds.includes(service.id)`. On change: toggle the service id in/out of that tier's `assignedServiceIds` array (immutable update via `setTiers`). Checkbox accent color `#1A463C` (use `accent-[#1A463C]` Tailwind class or inline style `accentColor: "#1A463C"`). Row height ~40px, hover bg `#f9f9f9`, border-bottom `#e5e7eb`. - If `filteredServices.length === 0`: render a single placeholder row spanning all columns with text "Nessun servizio disponibile per questa categoria" (`#71717a`), table headers still visible. - **Totale Servizi row** (sticky/bold, bottom of table, computed via `useMemo`): label "Totale Servizi" (bold 14px/600, `#1a1a1a`), then per tier: `tiers[i].assignedServiceIds.reduce((sum, id) => sum + Number(filteredServices.find(s => s.id === id)?.unit_price ?? 0), 0)` formatted as "€X.XXX,XX" (Italian locale, e.g. `toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })`). Recomputes instantly on every checkbox toggle (no network call) — this is the OFFER-11 live-total requirement. - **Prezzo Pubblico row** (bold label, bottom of table, below Totale Servizi): label "Prezzo Pubblico" (bold 14px/600), then per tier an `` bound to `tiers[i].public_price` (string | null -> controlled value `tiers[i].public_price ?? ""`), placeholder "€0,00". On change, update `tiers[i].public_price` in state (store as string; convert to number on save). Independent of Totale Servizi (D-5) — no auto-sync between the two rows. 5. **Transformation Promise block** ("Promessa di Trasformazione" h3 section label, 16px gap between fields): 5 rows, each `label (12px/400, #71717a) + EditableCell(text, optional)`: - "Aiuto" -> `macro.cliente_ideale`, placeholder "Aggiungi cliente ideale" - "A ottenere" -> `macro.risultato`, placeholder "Aggiungi risultato" - "In" -> `macro.tempo`, placeholder "Aggiungi durata (es. 3 mesi)" - "Senza" -> `macro.pain`, placeholder "Aggiungi pain point" - "Grazie a" -> `macro.metodo`, placeholder "Aggiungi metodo" All optional (no `required` prop), `onSave` updates the corresponding `macro.*` state field. 6. **Action buttons** (bottom, 8px gap): - **"Salva Offerta"** (primary, bg `#1A463C`, text white, 14px/600, padding `16px 24px`): disabled if NO tier has `assignedServiceIds.length > 0` (tooltip "Seleziona almeno un servizio" via `title` attribute when disabled). On click: `startTransition(async () => { const payload: SaveOfferEditorPayload = { ...macro fields, tiers: tiers.map(t => ({ id: t.id || undefined, tier_letter: t.tier_letter, internal_name: t.internal_name || t.tier_letter (e.g. "Tier A"), public_name: t.public_name || t.tier_letter, duration_months: t.duration_months || 1, public_price: t.public_price ? Number(t.public_price) : null, assignedServiceIds: t.assignedServiceIds })), tipoTags, obiettivoTags }; try { await saveOfferEditor(data.macro.id, payload); router.push("/admin/offers"); } catch (e) { /* show inline error text "Errore nel salvataggio. Verifica i campi." */ } })`. - **"Annulla"** (secondary, border `#e5e7eb`, text `#1a1a1a`): `Link href="/admin/offers"` — no dirty-check confirmation in this iteration (acceptable simplification per UI-SPEC note "Auto-save optional (planner decides)" — explicit Salva is the persistence model, Annulla is a plain navigation link). - **"Archivia"** (destructive, text `#dc2626`, no fill), visible only if `!macro_initial.is_archived` (track initial archived state separately from any local toggle): on click, `window.confirm("Sei sicuro? L'offerta non sarà visibile nella lista.")`, if confirmed `startTransition(async () => { await toggleOfferArchived(data.macro.id, true); router.push("/admin/offers"); })`. All literal copy strings (Italian) per the UI-SPEC copywriting contract table: "← Indietro", "Modifica Offerta", "Tag", "Categoria", "Ticket", "Tipo", "Obiettivo", "+ Crea Tipo", "+ Crea Obiettivo", "Servizi Inclusi", "Servizio", "Prezzo", "A", "B", "C", "Totale Servizi", "Prezzo Pubblico", "Promessa di Trasformazione", "Aiuto", "A ottenere", "In", "Senza", "Grazie a", "Salva Offerta", "Annulla", "Archivia", "Sei sicuro? L'offerta non sarà visibile nella lista.", "Nessun servizio disponibile per questa categoria", "Errore nel salvataggio. Verifica i campi.", "Seleziona almeno un servizio". cd /Users/simonecavalli/Vault/IAMCAVALLI && npx tsc --noEmit 2>&1 | tail -30 && npx eslint src/app/admin/offers/\[id\]/edit/page.tsx src/components/admin/offers/OfferEditorClient.tsx 2>&1 | tail -30 - `npx tsc --noEmit` passes with zero errors for the full project - `npx eslint src/app/admin/offers/[id]/edit/page.tsx src/components/admin/offers/OfferEditorClient.tsx` reports zero errors - `grep -c "saveOfferEditor\|toggleOfferArchived" src/components/admin/offers/OfferEditorClient.tsx` >= 2 - `grep -c "OptionSelect\|OptionMultiSelect" src/components/admin/offers/OfferEditorClient.tsx` >= 2 (Categoria/Ticket via OptionSelect, Tipo/Obiettivo via OptionMultiSelect) - `grep -c "Totale Servizi\|Prezzo Pubblico" src/components/admin/offers/OfferEditorClient.tsx` >= 2 - `grep -c "Promessa di Trasformazione\|cliente_ideale\|risultato\|tempo\|pain\|metodo" src/components/admin/offers/OfferEditorClient.tsx` >= 6 - `grep -c "type=\"checkbox\"" src/components/admin/offers/OfferEditorClient.tsx` >= 1 - `grep -c "Nessun servizio disponibile per questa categoria" src/components/admin/offers/OfferEditorClient.tsx` == 1 OfferEditorClient renders the full editor per UI-SPEC section 2: tags (4 dimensions), category-filtered services matrix with live Totale Servizi + independent Prezzo Pubblico per tier, transformation promise block, and working Salva/Annulla/Archivia actions; project typechecks and lints clean. ## Trust Boundaries | Boundary | Description | |----------|-------------| | Admin browser -> saveOfferEditor | Free-text macro fields, tier data, service-id arrays, and tag arrays cross into `offer_macros`/`offer_micros`/`offer_tier_services`/`tags` rows | | Admin browser -> toggleOfferArchived | Boolean flag crosses into `offer_macros.is_archived` | | URL param `[id]` -> getOfferEditorData | Route param crosses into a DB lookup; returns `null` (404) for unknown/foreign ids | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-12-12 | Tampering | "Salva Offerta" payload (tier_letter, public_price, assignedServiceIds, tag arrays) | mitigate | `saveOfferEditor` (Plan 03) Zod-validates `tier_letter` against `z.enum(["A","B","C"])`, `public_price` against `z.coerce.number().min(0)`, and the CHECK constraint on `offer_micros.tier_letter` (Plan 01) provides DB-level defense in depth | | T-12-13 | Tampering | `assignedServiceIds` referencing services outside the offer's category or inactive services | accept | `availableServices` is server-fetched and category-filtered (D-4); a malicious client could submit arbitrary service ids in the payload, but `offer_tier_services` has FK `ON DELETE CASCADE` to `services.id` (Plan 01) — invalid ids fail the FK constraint, no data corruption possible; low-value target (admin-only route) | | T-12-14 | Elevation of Privilege | All editor mutations (`saveOfferEditor`, `toggleOfferArchived`, `renameOfferOption`) | mitigate | Plan 03 wraps every action in `requireAdmin()`; this plan adds no new server-side surface, only UI calling the already-gated actions | | T-12-15 | Information Disclosure | `/admin/offers/[id]/edit` exposes full offer internals (internal_name, all tier data, transformation promise) | accept | Route is under `/admin/*`, Auth.js session-gated middleware (existing project-wide constraint); consistent with Plan 04's list page disposition (T-12-11) | | T-12-16 | Denial of Service | Unbounded `[id]` route param triggers a DB query for every request | accept | `getOfferEditorData` returns `null` -> `notFound()` for any non-matching id; standard Next.js 404 handling, no amplification risk on an admin-only route | 1. `npx tsc --noEmit` passes for the full project. 2. `npx eslint` clean for both modified/created files. 3. `/admin/offers/[id]/edit` renders the offer name, Categoria/Ticket single-select chips, Tipo/Obiettivo multi-select chips (with "+ Crea Tipo"/"+ Crea Obiettivo" inline creation), and the services matrix pre-filtered by `macro.category`. 4. Toggling a service checkbox in any tier column instantly updates that tier's "Totale Servizi" value (client-side, no network call). 5. Entering a value in "Prezzo Pubblico" for a tier does not affect "Totale Servizi" for that tier (independent fields, D-5). 6. All 5 transformation-promise fields (Aiuto/A ottenere/In/Senza/Grazie a) are editable via EditableCell and map to `cliente_ideale`/`risultato`/`tempo`/`pain`/`metodo`. 7. "Salva Offerta" is disabled (with tooltip) when no tier has any assigned service; when enabled, clicking it calls `saveOfferEditor` and navigates back to `/admin/offers`. 8. "Archivia" (visible only for non-archived offers) prompts for confirmation, then calls `toggleOfferArchived(id, true)` and navigates back to `/admin/offers`. 9. "Annulla" navigates back to `/admin/offers` without persisting changes. - `/admin/offers/[id]/edit` matches the UI-SPEC section 2 layout: header/back-link, name + tag dimensions, category-filtered services matrix with live totals and independent public price, transformation promise block, and Salva/Annulla/Archivia actions. - OFFER-11 satisfied: 3-tier (A/B/C) checkbox matrix with live "Totale Servizi" recalculation. - OFFER-15 satisfied: Categoria/Ticket single-select, Tipo/Obiettivo multi-select with on-the-fly creation, all backed by `getOfferFieldOptions`/`renameOfferOption`/`saveOfferEditor`. - OFFER-16 satisfied: "Prezzo Pubblico" per tier is a manual numeric input independent of "Totale Servizi". - OFFER-17 satisfied: all 5 transformation-promise fields editable and persisted via `saveOfferEditor`. After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md`