Files
simone cd55cba56c 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>
2026-05-30 15:07:52 +02:00

30 KiB

plan_id, phase, wave, title, type, depends_on, files_modified, requirements_addressed, autonomous, must_haves
plan_id phase wave title type depends_on files_modified requirements_addressed autonomous must_haves
05-02 5 2 Offer catalog admin CRUD — /admin/offers (macro + micro + services + multi-select assignment) execute
05-01
src/app/admin/offers/page.tsx
src/app/admin/offers/actions.ts
src/lib/offer-queries.ts
src/components/admin/NavBar.tsx
OFFER-01
OFFER-02
OFFER-03
true
truths artifacts key_links
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
path provides contains
src/app/admin/offers/page.tsx RSC page listing macros, their micros, and offer services OfferMacroForm, OfferServiceForm
path provides contains
src/app/admin/offers/actions.ts Server actions for all offer catalog mutations createMacro, createMicro, createOfferService, updateMicroServices
path provides contains
src/lib/offer-queries.ts Read queries for offer catalog getCatalogWithMicros, getAllOfferServices
path provides contains
src/components/admin/NavBar.tsx NavBar with Offerte link /admin/offers
from to via pattern
src/app/admin/offers/page.tsx src/lib/offer-queries.ts getCatalogWithMicros() server import getCatalogWithMicros
from to via pattern
src/app/admin/offers/actions.ts src/db/schema.ts offer_macros, offer_micros, offer_services, offer_micro_services imports offer_macros|offer_micro_services
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.

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

@/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 ```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")
// Add between Catalogo and Impostazioni:
<Link href="/admin/offers" className="text-sm text-white/70 hover:text-white transition-colors">
  Offerte
</Link>
Task 1: offer-queries.ts + server actions (actions.ts) src/lib/offer-queries.ts src/app/admin/offers/actions.ts

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

**Create `src/lib/offer-queries.ts`:**
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:

"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");
}
cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20

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

offer-queries.ts and actions.ts created; TypeScript compiles; every action guards with requireAdmin()

Task 2: /admin/offers RSC page + NavBar update src/app/admin/offers/page.tsx src/components/admin/NavBar.tsx

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

**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).

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

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

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

cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20

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

/admin/offers page renders macro-offers, micro-offers, services; ServiceCheckboxList multi-select works; NavBar has Offerte and Forecast links; TypeScript compiles

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

<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>
After completion, create `.planning/phases/05-offer-system/05-02-SUMMARY.md` using the summary template.