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:
@@ -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>
|
||||
Reference in New Issue
Block a user