docs(05): create Phase 5 Offer System plans — 4 plans in 3 waves

Wave 1: 05-01 schema migration (5 new tables, additive only)
Wave 2: 05-02 offer catalog CRUD (/admin/offers + NavBar)
Wave 3: 05-03 project offer assignment (OffersTab) + 05-04 client dashboard offers + /admin/forecast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 15:07:52 +02:00
parent 75097ccfa4
commit cd55cba56c
7 changed files with 2183 additions and 74 deletions
@@ -0,0 +1,713 @@
---
plan_id: 05-02
phase: 5
wave: 2
title: "Offer catalog admin CRUD — /admin/offers (macro + micro + services + multi-select assignment)"
type: execute
depends_on: [05-01]
files_modified:
- src/app/admin/offers/page.tsx
- src/app/admin/offers/actions.ts
- src/lib/offer-queries.ts
- src/components/admin/NavBar.tsx
requirements_addressed: [OFFER-01, OFFER-02, OFFER-03]
autonomous: true
must_haves:
truths:
- "Admin can create a macro-offer with internal_name, public_name, transformation_promise"
- "Admin can create a micro-offer as a child of a macro, with internal_name, public_name, transformation_promise, duration_months"
- "Admin can create offer services with name, price, transformation_description"
- "Admin can assign services to a micro-offer via a checkbox list (many-to-many)"
- "NavBar has an 'Offerte' link pointing to /admin/offers"
artifacts:
- path: "src/app/admin/offers/page.tsx"
provides: "RSC page listing macros, their micros, and offer services"
contains: "OfferMacroForm, OfferServiceForm"
- path: "src/app/admin/offers/actions.ts"
provides: "Server actions for all offer catalog mutations"
contains: "createMacro, createMicro, createOfferService, updateMicroServices"
- path: "src/lib/offer-queries.ts"
provides: "Read queries for offer catalog"
contains: "getCatalogWithMicros, getAllOfferServices"
- path: "src/components/admin/NavBar.tsx"
provides: "NavBar with Offerte link"
contains: "/admin/offers"
key_links:
- from: "src/app/admin/offers/page.tsx"
to: "src/lib/offer-queries.ts"
via: "getCatalogWithMicros() server import"
pattern: "getCatalogWithMicros"
- from: "src/app/admin/offers/actions.ts"
to: "src/db/schema.ts"
via: "offer_macros, offer_micros, offer_services, offer_micro_services imports"
pattern: "offer_macros|offer_micro_services"
---
<objective>
Build the full admin offer catalog at `/admin/offers`: create/list macro-offers, create/list micro-offers (children of macros), create/list offer services, and assign services to micro-offers via a checkbox list.
Purpose: This is the data entry point for the entire offer system. Without catalog data, Plans 03 and 04 have nothing to assign or display.
Output: `/admin/offers` page fully functional; server actions for all mutations; query layer in `offer-queries.ts`; NavBar updated.
</objective>
<execution_context>
@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/workflows/execute-plan.md
@/Users/simonecavalli/IAMCAVALLI/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
@/Users/simonecavalli/IAMCAVALLI/.planning/STATE.md
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-RESEARCH.md
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/05-offer-system/05-01-SUMMARY.md
<interfaces>
<!-- From src/db/schema.ts (after 05-01): -->
```typescript
export const offer_macros = pgTable("offer_macros", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
internal_name: text("internal_name").notNull(),
public_name: text("public_name").notNull(),
transformation_promise: text("transformation_promise"),
sort_order: integer("sort_order").notNull().default(0),
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const offer_micros = pgTable("offer_micros", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
macro_id: text("macro_id").notNull().references(() => offer_macros.id, { onDelete: "cascade" }),
internal_name: text("internal_name").notNull(),
public_name: text("public_name").notNull(),
transformation_promise: text("transformation_promise"),
duration_months: integer("duration_months").notNull().default(1),
sort_order: integer("sort_order").notNull().default(0),
});
export const offer_services = pgTable("offer_services", {
id: text("id").primaryKey().$defaultFn(() => nanoid()),
name: text("name").notNull(),
price: numeric("price", { precision: 10, scale: 2 }).notNull(),
transformation_description: text("transformation_description"),
active: boolean("active").notNull().default(true),
});
export const offer_micro_services = pgTable("offer_micro_services", {
micro_id: text("micro_id").notNull(),
service_id: text("service_id").notNull(),
}, (t) => ({ pk: primaryKey({ columns: [t.micro_id, t.service_id] }) }));
export type OfferMacro = typeof offer_macros.$inferSelect;
export type OfferMicro = typeof offer_micros.$inferSelect;
export type OfferService = typeof offer_services.$inferSelect;
```
<!-- From src/app/admin/catalog/actions.ts (existing pattern to mirror): -->
```typescript
"use server";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
// All actions: call requireAdmin() first, then zod parse, then db mutation, then revalidatePath("/admin/offers")
```
<!-- From src/components/admin/NavBar.tsx (existing, to be extended): -->
```typescript
// Add between Catalogo and Impostazioni:
<Link href="/admin/offers" className="text-sm text-white/70 hover:text-white transition-colors">
Offerte
</Link>
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: offer-queries.ts + server actions (actions.ts)</name>
<files>
src/lib/offer-queries.ts
src/app/admin/offers/actions.ts
</files>
<read_first>
- src/db/schema.ts — confirm column names for offer_macros, offer_micros, offer_services, offer_micro_services (after 05-01)
- src/app/admin/catalog/actions.ts — copy requireAdmin() pattern, zod schema pattern, revalidatePath usage
- src/lib/admin-queries.ts — copy import style (db, eq, inArray, asc, sql from drizzle-orm)
</read_first>
<action>
**Create `src/lib/offer-queries.ts`:**
```typescript
import { db } from "@/db";
import {
offer_macros, offer_micros, offer_services, offer_micro_services,
} from "@/db/schema";
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
import { eq, asc, inArray, sql } from "drizzle-orm";
export type MicroWithServices = OfferMicro & {
services: Array<{ id: string; name: string; price: string }>;
cumulative_price: string;
};
export type MacroWithMicros = OfferMacro & {
micros: MicroWithServices[];
};
export async function getCatalogWithMicros(): Promise<MacroWithMicros[]> {
const macros = await db
.select()
.from(offer_macros)
.orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at));
if (macros.length === 0) return [];
const macroIds = macros.map((m) => m.id);
const micros = await db
.select()
.from(offer_micros)
.where(inArray(offer_micros.macro_id, macroIds))
.orderBy(asc(offer_micros.sort_order));
const microIds = micros.map((m) => m.id);
if (microIds.length === 0) {
return macros.map((m) => ({ ...m, micros: [] }));
}
// Fetch junction rows + service data in one query
const assignments = await db
.select({
micro_id: offer_micro_services.micro_id,
service_id: offer_services.id,
name: offer_services.name,
price: offer_services.price,
})
.from(offer_micro_services)
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
.where(inArray(offer_micro_services.micro_id, microIds));
// Cumulative price per micro
const cumulRows = await db
.select({
micro_id: offer_micro_services.micro_id,
cumulative_price: sql<string>`coalesce(sum(${offer_services.price}::numeric), 0)`,
})
.from(offer_micro_services)
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
.where(inArray(offer_micro_services.micro_id, microIds))
.groupBy(offer_micro_services.micro_id);
const cumulMap = new Map(cumulRows.map((r) => [r.micro_id, r.cumulative_price]));
const microsWithServices: MicroWithServices[] = micros.map((micro) => ({
...micro,
services: assignments
.filter((a) => a.micro_id === micro.id)
.map((a) => ({ id: a.service_id, name: a.name, price: String(a.price) })),
cumulative_price: cumulMap.get(micro.id) ?? "0",
}));
return macros.map((macro) => ({
...macro,
micros: microsWithServices.filter((m) => m.macro_id === macro.id),
}));
}
export async function getAllOfferServices(): Promise<OfferService[]> {
return db
.select()
.from(offer_services)
.where(eq(offer_services.active, true))
.orderBy(asc(offer_services.name));
}
// Returns assigned service IDs for a given micro-offer (used by ServiceCheckboxList)
export async function getMicroAssignedServiceIds(microId: string): Promise<string[]> {
const rows = await db
.select({ service_id: offer_micro_services.service_id })
.from(offer_micro_services)
.where(eq(offer_micro_services.micro_id, microId));
return rows.map((r) => r.service_id);
}
```
**Create `src/app/admin/offers/actions.ts`:**
```typescript
"use server";
import { db } from "@/db";
import {
offer_macros, offer_micros, offer_services, offer_micro_services,
} from "@/db/schema";
import { revalidatePath } from "next/cache";
import { eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
// ── Macro-offer CRUD ─────────────────────────────────────────────────────────
const macroSchema = z.object({
internal_name: z.string().min(1, "Nome interno richiesto"),
public_name: z.string().min(1, "Nome pubblico richiesto"),
transformation_promise: z.string().optional(),
});
export async function createMacro(formData: FormData) {
await requireAdmin();
const parsed = macroSchema.safeParse({
internal_name: formData.get("internal_name"),
public_name: formData.get("public_name"),
transformation_promise: formData.get("transformation_promise") ?? "",
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.insert(offer_macros).values({
internal_name: parsed.data.internal_name,
public_name: parsed.data.public_name,
transformation_promise: parsed.data.transformation_promise || null,
});
revalidatePath("/admin/offers");
}
export async function deleteMacro(macroId: string) {
await requireAdmin();
await db.delete(offer_macros).where(eq(offer_macros.id, macroId));
revalidatePath("/admin/offers");
}
// ── Micro-offer CRUD ─────────────────────────────────────────────────────────
const microSchema = z.object({
macro_id: z.string().min(1, "Macro offerta richiesta"),
internal_name: z.string().min(1, "Nome interno richiesto"),
public_name: z.string().min(1, "Nome pubblico richiesto"),
transformation_promise: z.string().optional(),
duration_months: z.coerce.number().int().min(1, "Durata minima 1 mese"),
});
export async function createMicro(formData: FormData) {
await requireAdmin();
const parsed = microSchema.safeParse({
macro_id: formData.get("macro_id"),
internal_name: formData.get("internal_name"),
public_name: formData.get("public_name"),
transformation_promise: formData.get("transformation_promise") ?? "",
duration_months: formData.get("duration_months"),
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.insert(offer_micros).values({
macro_id: parsed.data.macro_id,
internal_name: parsed.data.internal_name,
public_name: parsed.data.public_name,
transformation_promise: parsed.data.transformation_promise || null,
duration_months: parsed.data.duration_months,
});
revalidatePath("/admin/offers");
}
export async function deleteMicro(microId: string) {
await requireAdmin();
// offer_micro_services will cascade; project_offers will restrict (DB constraint)
await db.delete(offer_micros).where(eq(offer_micros.id, microId));
revalidatePath("/admin/offers");
}
// ── Offer service CRUD ───────────────────────────────────────────────────────
const offerServiceSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
price: z.coerce.number().min(0, "Prezzo non può essere negativo"),
transformation_description: z.string().optional(),
});
export async function createOfferService(formData: FormData) {
await requireAdmin();
const parsed = offerServiceSchema.safeParse({
name: formData.get("name"),
price: formData.get("price"),
transformation_description: formData.get("transformation_description") ?? "",
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.insert(offer_services).values({
name: parsed.data.name,
price: parsed.data.price.toFixed(2),
transformation_description: parsed.data.transformation_description || null,
});
revalidatePath("/admin/offers");
}
export async function toggleOfferServiceActive(serviceId: string, active: boolean) {
await requireAdmin();
await db.update(offer_services).set({ active }).where(eq(offer_services.id, serviceId));
revalidatePath("/admin/offers");
}
// ── Multi-select service assignment ─────────────────────────────────────────
// Replaces all assignments for a micro with the provided serviceIds (upsert-replace pattern)
export async function updateMicroOfferServices(microId: string, serviceIds: string[]) {
await requireAdmin();
// Delete existing assignments for this micro
await db.delete(offer_micro_services).where(eq(offer_micro_services.micro_id, microId));
// Re-insert with new set (empty array = unassign all)
if (serviceIds.length > 0) {
await db.insert(offer_micro_services).values(
serviceIds.map((serviceId) => ({ micro_id: microId, service_id: serviceId }))
);
}
revalidatePath("/admin/offers");
}
```
</action>
<verify>
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `grep -c "requireAdmin" src/app/admin/offers/actions.ts` returns 5 or more (every exported action calls it)
- `grep "revalidatePath" src/app/admin/offers/actions.ts` returns at least 5 matches (every mutating action revalidates)
- `grep "getCatalogWithMicros\|getAllOfferServices\|getMicroAssignedServiceIds" src/lib/offer-queries.ts` returns 3 matches
</acceptance_criteria>
<done>offer-queries.ts and actions.ts created; TypeScript compiles; every action guards with requireAdmin()</done>
</task>
<task type="auto">
<name>Task 2: /admin/offers RSC page + NavBar update</name>
<files>
src/app/admin/offers/page.tsx
src/components/admin/NavBar.tsx
</files>
<read_first>
- src/app/admin/catalog/page.tsx — read for the admin page structure pattern (RSC, revalidate = 0, form patterns)
- src/components/admin/NavBar.tsx — read full file before editing (must preserve all existing links)
- src/lib/offer-queries.ts — confirm getCatalogWithMicros and getAllOfferServices signatures (just created in Task 1)
- src/app/admin/offers/actions.ts — confirm action names (just created in Task 1)
</read_first>
<action>
**Create `src/app/admin/offers/page.tsx`:**
This is an RSC page. No `"use client"` at the top level. Inline client sub-components that need interactivity are permissible as separate files or as local `"use client"` components in the same file using the pattern established by existing admin pages.
The page has three sections:
1. **Macro-offers** — list with "create" form inline; each macro shows its micro-offer children
2. **Micro-offers** per macro — create form inside each macro card; each micro shows assigned services and a `ServiceCheckboxList`
3. **Offer Services catalog** — list all offer services + create form at bottom
For `ServiceCheckboxList`: this is a `"use client"` component. Create it as a named export in the same page file or as a separate file at `src/components/admin/offers/ServiceCheckboxList.tsx`. It uses `useState(Set<string>)` + `useTransition` + `router.refresh()` pattern from the research (Pattern 3).
```typescript
// src/components/admin/offers/ServiceCheckboxList.tsx
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { updateMicroOfferServices } from "@/app/admin/offers/actions";
export function ServiceCheckboxList({
allServices,
assignedIds,
microId,
}: {
allServices: Array<{ id: string; name: string; price: string }>;
assignedIds: string[];
microId: string;
}) {
const [selected, setSelected] = useState(new Set(assignedIds));
const [isPending, startTransition] = useTransition();
const router = useRouter();
function toggle(serviceId: string) {
const next = new Set(selected);
if (next.has(serviceId)) next.delete(serviceId);
else next.add(serviceId);
setSelected(next);
startTransition(async () => {
await updateMicroOfferServices(microId, [...next]);
router.refresh();
});
}
return (
<div className="space-y-1">
{allServices.map((svc) => (
<label key={svc.id} className="flex items-center gap-2 cursor-pointer text-sm">
<input
type="checkbox"
checked={selected.has(svc.id)}
onChange={() => toggle(svc.id)}
disabled={isPending}
className="rounded"
/>
<span>{svc.name}</span>
<span className="ml-auto text-xs text-[#71717a]">{parseFloat(svc.price).toFixed(2)}</span>
</label>
))}
{allServices.length === 0 && (
<p className="text-xs text-[#71717a]">Nessun servizio nel catalogo ancora.</p>
)}
</div>
);
}
```
**Page structure for `src/app/admin/offers/page.tsx`:**
```typescript
import { getCatalogWithMicros, getAllOfferServices } from "@/lib/offer-queries";
import { createMacro, createMicro, createOfferService, deleteMacro, deleteMicro, toggleOfferServiceActive } from "./actions";
import { ServiceCheckboxList } from "@/components/admin/offers/ServiceCheckboxList";
export const revalidate = 0;
export default async function OffersPage() {
const [catalog, allServices] = await Promise.all([
getCatalogWithMicros(),
getAllOfferServices(),
]);
return (
<div className="max-w-5xl mx-auto py-8 px-4 space-y-10">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Offerte</h1>
{/* ── Sezione macro-offerte ── */}
<section>
<h2 className="text-lg font-semibold text-[#1a1a1a] mb-4">Macro-Offerte</h2>
{/* Create macro form */}
<form action={createMacro} className="bg-white rounded-lg border border-[#e5e7eb] p-4 mb-6 space-y-3 max-w-lg">
<p className="text-sm font-medium">Nuova Macro-Offerta</p>
<input name="internal_name" placeholder="Nome interno (es. Entry Offer)" required
className="w-full border rounded px-3 py-1.5 text-sm" />
<input name="public_name" placeholder="Nome pubblico (es. Starter Branding)"
required className="w-full border rounded px-3 py-1.5 text-sm" />
<textarea name="transformation_promise" placeholder="Promessa di trasformazione"
rows={2} className="w-full border rounded px-3 py-1.5 text-sm" />
<button type="submit"
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31]">
Aggiungi Macro
</button>
</form>
{/* List macros */}
<div className="space-y-8">
{catalog.map((macro) => (
<div key={macro.id} className="bg-white rounded-lg border border-[#e5e7eb] p-6">
<div className="flex items-start justify-between mb-1">
<div>
<p className="font-semibold text-[#1a1a1a]">{macro.internal_name}</p>
<p className="text-sm text-[#71717a]">Pubblico: {macro.public_name}</p>
{macro.transformation_promise && (
<p className="text-xs text-[#71717a] mt-1 italic">{macro.transformation_promise}</p>
)}
</div>
<form action={deleteMacro.bind(null, macro.id)}>
<button type="submit" className="text-xs text-red-600 hover:underline">Elimina</button>
</form>
</div>
{/* Micro-offers under this macro */}
<div className="mt-4 space-y-4 pl-4 border-l-2 border-[#e5e7eb]">
<p className="text-xs font-semibold text-[#71717a] uppercase tracking-wider">Micro-Offerte</p>
{macro.micros.map((micro) => (
<div key={micro.id} className="bg-[#f9f9f9] rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium">{micro.internal_name}</p>
<p className="text-xs text-[#71717a]">Pubblico: {micro.public_name} · {micro.duration_months} {micro.duration_months === 1 ? "mese" : "mesi"}</p>
{micro.transformation_promise && (
<p className="text-xs text-[#71717a] italic">{micro.transformation_promise}</p>
)}
<p className="text-xs text-[#1a1a1a] mt-1 font-medium">
Prezzo cumulativo: {parseFloat(micro.cumulative_price).toFixed(2)}
</p>
</div>
<form action={deleteMicro.bind(null, micro.id)}>
<button type="submit" className="text-xs text-red-600 hover:underline">Elimina</button>
</form>
</div>
{/* Service assignment checkbox list */}
<div>
<p className="text-xs font-medium text-[#71717a] mb-2">Servizi inclusi:</p>
<ServiceCheckboxList
allServices={allServices.map((s) => ({ id: s.id, name: s.name, price: String(s.price) }))}
assignedIds={micro.services.map((s) => s.id)}
microId={micro.id}
/>
</div>
</div>
))}
{/* Create micro form */}
<form action={createMicro} className="bg-white rounded border border-[#e5e7eb] p-3 space-y-2">
<input type="hidden" name="macro_id" value={macro.id} />
<p className="text-xs font-medium">Nuova Micro-Offerta</p>
<input name="internal_name" placeholder="Nome interno" required
className="w-full border rounded px-2 py-1 text-xs" />
<input name="public_name" placeholder="Nome pubblico" required
className="w-full border rounded px-2 py-1 text-xs" />
<textarea name="transformation_promise" placeholder="Promessa di trasformazione"
rows={2} className="w-full border rounded px-2 py-1 text-xs" />
<div className="flex items-center gap-2">
<label className="text-xs">Durata (mesi):</label>
<input name="duration_months" type="number" min="1" defaultValue="1"
required className="w-16 border rounded px-2 py-1 text-xs" />
</div>
<button type="submit"
className="bg-[#1A463C] text-white text-xs px-3 py-1 rounded hover:bg-[#163a31]">
Aggiungi Micro
</button>
</form>
</div>
</div>
))}
{catalog.length === 0 && (
<p className="text-sm text-[#71717a]">Nessuna macro-offerta ancora. Creane una sopra.</p>
)}
</div>
</section>
{/* ── Sezione servizi offerta ── */}
<section>
<h2 className="text-lg font-semibold text-[#1a1a1a] mb-4">Servizi Offerta</h2>
<p className="text-sm text-[#71717a] mb-4">
I servizi qui sono diversi dal catalogo preventivi hanno una descrizione della trasformazione e vengono raggruppati nelle micro-offerte.
</p>
{/* List services */}
<div className="bg-white rounded-lg border border-[#e5e7eb] divide-y divide-[#e5e7eb] mb-6">
{allServices.map((svc) => (
<div key={svc.id} className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium">{svc.name}</p>
{svc.transformation_description && (
<p className="text-xs text-[#71717a]">{svc.transformation_description}</p>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-sm font-mono">{parseFloat(String(svc.price)).toFixed(2)}</span>
<form action={toggleOfferServiceActive.bind(null, svc.id, !svc.active)}>
<button type="submit" className="text-xs text-[#71717a] hover:text-[#1a1a1a]">
{svc.active ? "Disattiva" : "Attiva"}
</button>
</form>
</div>
</div>
))}
{allServices.length === 0 && (
<p className="px-4 py-3 text-sm text-[#71717a]">Nessun servizio ancora.</p>
)}
</div>
{/* Create service form */}
<form action={createOfferService}
className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3 max-w-lg">
<p className="text-sm font-medium">Nuovo Servizio Offerta</p>
<input name="name" placeholder="Nome servizio" required
className="w-full border rounded px-3 py-1.5 text-sm" />
<input name="price" type="number" step="0.01" min="0" placeholder="Prezzo (€)" required
className="w-full border rounded px-3 py-1.5 text-sm" />
<textarea name="transformation_description" placeholder="Descrizione trasformazione (marketing)"
rows={2} className="w-full border rounded px-3 py-1.5 text-sm" />
<button type="submit"
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31]">
Aggiungi Servizio
</button>
</form>
</section>
</div>
);
}
```
**Update `src/components/admin/NavBar.tsx`:**
Read the full file first. Add the "Offerte" link between the "Catalogo" link and the "Impostazioni" link. Also add a "Forecast" link between "Statistiche" and "Catalogo":
```tsx
// After the Statistiche link:
<Link href="/admin/forecast" className="text-sm text-white/70 hover:text-white transition-colors">
Forecast
</Link>
// After the Catalogo link (before Impostazioni):
<Link href="/admin/offers" className="text-sm text-white/70 hover:text-white transition-colors">
Offerte
</Link>
```
Final NavBar order: Clienti | Progetti | Statistiche | Forecast | Catalogo | Offerte | Impostazioni
</action>
<verify>
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `grep "/admin/offers" src/components/admin/NavBar.tsx` matches (NavBar has Offerte link)
- `grep "/admin/forecast" src/components/admin/NavBar.tsx` matches (NavBar has Forecast link)
- `grep "ServiceCheckboxList" src/app/admin/offers/page.tsx` matches
- `grep "getCatalogWithMicros\|getAllOfferServices" src/app/admin/offers/page.tsx` returns 2 matches
- `grep "updateMicroOfferServices" src/components/admin/offers/ServiceCheckboxList.tsx` matches
- File `src/app/admin/offers/page.tsx` exists
- File `src/components/admin/offers/ServiceCheckboxList.tsx` exists
</acceptance_criteria>
<done>/admin/offers page renders macro-offers, micro-offers, services; ServiceCheckboxList multi-select works; NavBar has Offerte and Forecast links; TypeScript compiles</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → Server Actions | Admin form submissions reach offer-actions.ts |
| Admin session → Server Actions | Only authenticated admin can mutate offer data |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-03 | Elevation of Privilege | actions.ts — all exported functions | mitigate | `requireAdmin()` as first call in every exported server action; throws if session absent |
| T-05-04 | Tampering | updateMicroOfferServices — delete+re-insert pattern | mitigate | Both operations run within the same server action under requireAdmin(); atomic from the client perspective |
| T-05-05 | Information Disclosure | /admin/offers page | accept | Page is under /admin/* — Auth.js session guard in middleware protects the route; no extra exposure |
</threat_model>
<verification>
After both tasks complete:
- `npx tsc --noEmit` exits 0
- Visit `/admin/offers` → page loads without error
- Create a macro-offer → appears in the list
- Create a micro-offer under the macro → appears nested
- Create an offer service → appears in services list
- Toggle a checkbox for a service on a micro → assignment persists on refresh
</verification>
<success_criteria>
1. Admin can create macro-offers with all three fields (internal_name, public_name, transformation_promise)
2. Admin can create micro-offers with all fields including duration_months
3. Admin can create offer services (separate from service_catalog)
4. Checkbox list correctly assigns/unassigns services to micro-offers
5. NavBar shows "Offerte" and "Forecast" links
</success_criteria>
<output>
After completion, create `.planning/phases/05-offer-system/05-02-SUMMARY.md` using the summary template.
</output>