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
path
provides
src/app/admin/offers/[id]/edit/page.tsx
Server component: fetches getOfferEditorData(id) + getOfferFieldOptions(), renders OfferEditorClient or notFound()
path
provides
exports
src/components/admin/offers/OfferEditorClient.tsx
Client component: editable name/category/ticket, 4-dimension tags, services matrix with live totals, transformation promise fields, Salva/Annulla/Archivia actions
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.
exporttypeOfferTierData={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"
};exporttypeOfferEditorData={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
};exportasyncfunctiongetOfferEditorData(macroId: string):Promise<OfferEditorData|null>;exporttypeOfferFieldOptions={categoria: string[];ticket: string[];tipo: string[];obiettivo: string[]};exportasyncfunctiongetOfferFieldOptions():Promise<OfferFieldOptions>;
// 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).
exporttypeSaveOfferEditorPayload={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[];};exportasyncfunctionsaveOfferEditor(macroId: string,payload: SaveOfferEditorPayload):Promise<void>;exportasyncfunctiontoggleOfferArchived(macroId: string,archived: boolean):Promise<void>;exportasyncfunctionaddOfferTag(dimension:"tipo"|"obiettivo",macroId: string,value: string):Promise<void>;exportasyncfunctionremoveOfferTag(dimension:"tipo"|"obiettivo",macroId: string,value: string):Promise<void>;exportasyncfunctionrenameOfferOption(field:"categoria"|"ticket"|"tipo"|"obiettivo",oldValue: string,newValue: string):Promise<void>;
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 <OfferEditorClient data={data} fieldOptions={options} />;
}
```
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<string>` or one combined `useState<typeof initialMacro>`
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`) + `<OptionSelect
value={macro.category} options={categoriaOptions} onChange={...} onRename={(old,
next) => { 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" + `<OptionMultiSelect values={tipoTags}
options={tipoOptions} onAdd={(v) => { 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 `<input type="checkbox">` 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 `<input type="number" min="0"
step="0.01">` 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.
<threat_model>
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
</threat_model>
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.
<success_criteria>
/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.
</success_criteria>
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md`