---
phase: 12
plan: 05
type: execute
wave: 4
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"
---

<objective>
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.
</objective>

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

<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
</context>

<interfaces>
<!-- From Plan 03 (src/lib/offer-queries.ts) — use directly, no further exploration needed. -->

```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<OfferEditorData | null>;

export type OfferFieldOptions = { categoria: string[]; ticket: string[]; tipo: string[]; obiettivo: string[] };
export async function getOfferFieldOptions(): Promise<OfferFieldOptions>;
```

<!-- From Plan 03 (src/app/admin/offers/actions.ts) — use directly. -->

```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<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>;
```

<!-- From src/components/ui/option-select.tsx and option-multi-select.tsx — use directly for
Categoria/Ticket (single-select) and Tipo/Obiettivo (multi-select) dimension rows. -->

```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;
```

<!-- From src/components/ui/editable-cell.tsx — use directly for Offerta Name and
transformation-promise fields. -->

```typescript
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;
```
</interfaces>

<tasks>

<task type="auto">
  <name>Task 1: Create editor route + page.tsx server component</name>
  <files>src/app/admin/offers/[id]/edit/page.tsx</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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).
  </action>
  <verify>
    <automated>cd /Users/simonecavalli/Vault/IAMCAVALLI && grep -c "getOfferEditorData\|getOfferFieldOptions\|OfferEditorClient\|notFound" src/app/admin/offers/\[id\]/edit/page.tsx</automated>
  </verify>
  <acceptance_criteria>
    - 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 `<OfferEditorClient data={data} fieldOptions={options} />`
  </acceptance_criteria>
  <done>page.tsx exists, fetches editor data + field options, handles not-found, delegates to OfferEditorClient.</done>
</task>

<task type="auto" tdd="false">
  <name>Task 2: Build OfferEditorClient — tags, services matrix with live totals, promise block, actions</name>
  <files>src/components/admin/offers/OfferEditorClient.tsx</files>
  <read_first>
    - 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)
  </read_first>
  <action>
    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".
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - `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
  </acceptance_criteria>
  <done>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.</done>
</task>

</tasks>

<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>

<verification>
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.
</verification>

<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>

<output>
After completion, create `.planning/phases/12-offer-composition-drag-drop-csv-import/12-05-SUMMARY.md`
</output>
