Files
clienthub/.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-PLAN.md
T
simone 640f986967 docs(12): create phase plan
Re-scoped Offer Editor (Tier A/B/C, Tag & Prezzo Pubblico) per 2026-06-14
user decision: 5-plan wave structure (schema migration -> prod apply
checkpoint -> query/action layer -> list + detail editor UI), OFFER-12
CSV/Notion import deferred.
2026-06-14 21:00:32 +02:00

26 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
12 05 execute 3
1
3
src/app/admin/offers/[id]/edit/page.tsx
src/components/admin/offers/OfferEditorClient.tsx
true
OFFER-11
OFFER-15
OFFER-16
OFFER-17
truths artifacts key_links
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
OfferEditorClient
from to via pattern
src/components/admin/offers/OfferEditorClient.tsx (Salva Offerta) saveOfferEditor server action (Plan 03) useTransition + router.push('/admin/offers') on success saveOfferEditor
from to via pattern
src/components/admin/offers/OfferEditorClient.tsx (services matrix) OfferEditorData.tiers[].assignedServiceIds + availableServices client-side state, checkbox toggles update local tier.assignedServiceIds, Totale Servizi recomputed via useMemo from unit_price sums Totale Servizi|assignedServiceIds
from to via pattern
src/components/admin/offers/OfferEditorClient.tsx (Archivia) toggleOfferArchived server action (Plan 03) confirm dialog + useTransition + router.push('/admin/offers') 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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
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<OfferEditorData | null>;

export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] };
export async function getOfferFieldOptions(): 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).
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<void>;
export async function toggleOfferArchived(macroId: string, archived: boolean): Promise<void>;
export async function addOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise<void>;
export async function removeOfferTag(dimension: "tipo" | "obiettivo", macroId: string, value: string): Promise<void>;
export async function renameOfferOption(field: "categoria" | "ticket" | "tipo" | "obiettivo", oldValue: string, newValue: string): Promise<void>;
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;
export interface EditableCellProps {
  value: string;
  type?: "text" | "number";
  onSave: (value: string) => void | Promise<void>;
  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 <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`