diff --git a/src/app/admin/offers/actions.ts b/src/app/admin/offers/actions.ts index e4f1e25..ba6e20c 100644 --- a/src/app/admin/offers/actions.ts +++ b/src/app/admin/offers/actions.ts @@ -156,6 +156,7 @@ const saveOfferEditorSchema = z.object({ description: z.string().optional(), category: z.string().optional(), ticket: z.string().optional(), + offer_type: z.enum(["una_tantum", "retainer"]).optional(), cliente_ideale: z.string().optional(), risultato: z.string().optional(), tempo: z.string().optional(), @@ -189,6 +190,7 @@ export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorP description: data.description || null, category: data.category || null, ticket: data.ticket || null, + offer_type: data.offer_type ?? "una_tantum", cliente_ideale: data.cliente_ideale || null, risultato: data.risultato || null, tempo: data.tempo || null, diff --git a/src/components/admin/offers/OfferEditorClient.tsx b/src/components/admin/offers/OfferEditorClient.tsx index 44574c4..af4e8d8 100644 --- a/src/components/admin/offers/OfferEditorClient.tsx +++ b/src/components/admin/offers/OfferEditorClient.tsx @@ -45,12 +45,15 @@ function formatUnitPrice(raw: string): string { return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } +type OfferType = "una_tantum" | "retainer"; + type MacroFields = { internal_name: string; public_name: string; description: string; category: string | null; ticket: string | null; + offer_type: OfferType; cliente_ideale: string; risultato: string; tempo: string; @@ -75,6 +78,7 @@ export function OfferEditorClient({ description: data.macro.description ?? "", category: data.macro.category, ticket: data.macro.ticket, + offer_type: (data.macro.offer_type === "retainer" ? "retainer" : "una_tantum") as OfferType, cliente_ideale: data.macro.cliente_ideale ?? "", risultato: data.macro.risultato ?? "", tempo: data.macro.tempo ?? "", @@ -166,6 +170,7 @@ export function OfferEditorClient({ description: macro.description, category: macro.category ?? undefined, ticket: macro.ticket ?? undefined, + offer_type: macro.offer_type, cliente_ideale: macro.cliente_ideale, risultato: macro.risultato, tempo: macro.tempo, @@ -319,6 +324,29 @@ export function OfferEditorClient({ /> +
+ Modalità +
+ {([ + { value: "una_tantum", label: "Una tantum" }, + { value: "retainer", label: "Retainer" }, + ] as const).map((opt) => ( + + ))} +
+
+
Tipo + {typeLabel(offerType)} + + ); } export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) { const [isPending, startTransition] = useTransition(); const router = useRouter(); - const formRef = useRef(null); - function handleAssign(formData: FormData) { + const [macroId, setMacroId] = useState(""); + const [tierId, setTierId] = useState(""); + const [acceptedTotal, setAcceptedTotal] = useState(""); + const [importPhases, setImportPhases] = useState(true); + + // Group tiers by offer (macro), preserving query order. + const offers = useMemo(() => { + const map = new Map< + string, + { macro_id: string; name: string; category: string | null; offer_type: string; tiers: AvailableMicro[] } + >(); + for (const m of availableMicros) { + let entry = map.get(m.macro_id); + if (!entry) { + entry = { + macro_id: m.macro_id, + name: m.macro_internal_name, + category: m.macro_category, + offer_type: m.macro_offer_type, + tiers: [], + }; + map.set(m.macro_id, entry); + } + entry.tiers.push(m); + } + return Array.from(map.values()); + }, [availableMicros]); + + const selectedOffer = offers.find((o) => o.macro_id === macroId) ?? null; + + function selectMacro(id: string) { + setMacroId(id); + setTierId(""); + } + + function handleAssign() { + if (!tierId) return; + const fd = new FormData(); + fd.set("project_id", projectId); + fd.set("micro_id", tierId); + if (acceptedTotal.trim()) fd.set("accepted_total", acceptedTotal.trim()); + if (importPhases) fd.set("import_phases", "on"); startTransition(async () => { - await assignOfferToProject(formData); - formRef.current?.reset(); + await assignOfferToProject(fd); + setMacroId(""); + setTierId(""); + setAcceptedTotal(""); + setImportPhases(true); router.refresh(); }); } function handleRemove(offerId: string) { + if (!window.confirm("Rimuovere questa offerta dal progetto?")) return; startTransition(async () => { await removeProjectOffer(offerId, projectId); router.refresh(); @@ -56,7 +134,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT } return ( -
+
{/* Active assignments */}

Offerte Attive

@@ -64,129 +142,166 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT

Nessuna offerta assegnata a questo progetto.

) : (
- {projectOffers.map((offer) => ( -
-
-

- {offer.macro_internal_name} - {offer.tier_letter && ( - - Tier {offer.tier_letter} - - )} -

-

- Pubblico: {offer.micro_public_name} · {durationLabel(offer.micro_duration_months)} -

-

- Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")} -

- {/* Accepted total inline edit */} -
- - { - const val = e.currentTarget.value.trim(); - if (val !== (offer.accepted_total ?? "")) { - handleTotalUpdate(offer.id, val); - } - }} - className="w-24 border rounded px-2 py-0.5 text-xs" - /> -
-
- -
- ))} +
+
+

{offer.macro_internal_name}

+ {offer.tier_letter && ( + + Tier {offer.tier_letter} + + )} + +
+ {pub &&

Pubblico: {offer.micro_public_name}

} +
+ + { + const val = e.currentTarget.value.trim(); + if (val !== (offer.accepted_total ?? "")) handleTotalUpdate(offer.id, val); + }} + className="w-28 border border-[#e5e7eb] rounded-md px-2 py-1 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15" + /> +
+
+ +
+ ); + })}
)}
- {/* Assignment form */} + {/* Assignment — step 1: offer, step 2: tier */}

Assegna Offerta

-
- -
- - + {offers.length === 0 ? ( +

+ Nessuna offerta disponibile. Crea prima un'offerta in{" "} + Offerte. +

+ ) : ( +
+ {/* Step 1 — choose offer */} +
+ + +
+ + {/* Step 2 — choose tier */} + {selectedOffer && ( +
+ +
+ {selectedOffer.tiers.map((t) => { + const active = tierId === t.id; + const price = formatEuro(t.public_price); + const pub = meaningfulPublicName(t); + return ( + + ); + })} +
+
+ )} + + {/* Options + submit */} + {selectedOffer && ( +
+
+ + setAcceptedTotal(e.target.value)} + type="number" + step="0.01" + min="0" + placeholder="0.00" + className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15" + /> +
+ + + + +
+ )}
- -
- - -
- - - - - - {availableMicros.length === 0 && ( -

- Nessuna offerta disponibile. Crea prima un'offerta in{" "} - Offerte. -

- )} - + )}
); diff --git a/src/components/public/proposal/ProposalDeck.tsx b/src/components/public/proposal/ProposalDeck.tsx index 8f476ce..ce43c1c 100644 --- a/src/components/public/proposal/ProposalDeck.tsx +++ b/src/components/public/proposal/ProposalDeck.tsx @@ -87,7 +87,7 @@ export function ProposalDeck({ proposal, onAccept, onReject }: Props) { { id: "timeline", component: }, // Cap.05 — Le opzioni { id: "ch05", component: }, - { id: "pricing", component: }, + { id: "pricing", component: }, // Stages recap + comparison { id: "stages", component: }, { id: "comparison", component: }, diff --git a/src/components/public/proposal/sections/PricingSection.tsx b/src/components/public/proposal/sections/PricingSection.tsx index e63a5fe..ede769e 100644 --- a/src/components/public/proposal/sections/PricingSection.tsx +++ b/src/components/public/proposal/sections/PricingSection.tsx @@ -1,6 +1,9 @@ import type { AssembledProposal } from "@/lib/proposal/assemble"; -type Props = { tiers: AssembledProposal["offer"]["tiers"] }; +type Props = { + tiers: AssembledProposal["offer"]["tiers"]; + offerType?: string; // una_tantum | retainer — undefined for proposals generated before this field +}; function formatEur(val: string | null | undefined): string { if (!val) return "—"; @@ -8,7 +11,8 @@ function formatEur(val: string | null | undefined): string { return `€${n.toLocaleString("it-IT")}`; } -export function PricingSection({ tiers }: Props) { +export function PricingSection({ tiers, offerType }: Props) { + const isRetainer = offerType === "retainer"; return (
@@ -23,6 +27,15 @@ export function PricingSection({ tiers }: Props) { const isHighlighted = i === 1; // tier B highlighted di default const price = tier.publicPrice ?? tier.servicesTotal; const durationLabel = `${tier.durationMonths} ${tier.durationMonths === 1 ? "mese" : "mesi"}`; + // Prefer the offer type label; fall back to duration for legacy proposals. + const eyebrowMeta = offerType ? (isRetainer ? "Ricorrente" : "Una tantum") : durationLabel; + const priceNote = offerType + ? isRetainer + ? "Ricorrente · rinnovo 12 mesi" + : "pagamento unico" + : tier.durationMonths > 1 + ? `${tier.durationMonths} mesi` + : "pagamento unico"; return (

- Opzione {tier.tierLetter} / {durationLabel} + Opzione {tier.tierLetter} / {eyebrowMeta}

{tier.publicName}

@@ -47,9 +60,7 @@ export function PricingSection({ tiers }: Props) {

{formatEur(price)}

-

- {tier.durationMonths > 1 ? `${tier.durationMonths} mesi` : "pagamento unico"} -

+

{priceNote}

{/* Servizi inclusi */} diff --git a/src/db/migrations/0012_offer_type_and_tier_unique.sql b/src/db/migrations/0012_offer_type_and_tier_unique.sql new file mode 100644 index 0000000..013c1ca --- /dev/null +++ b/src/db/migrations/0012_offer_type_and_tier_unique.sql @@ -0,0 +1,16 @@ +-- Additive: offer_type on offer_macros (una_tantum | retainer) + dedup guard. +-- The UNIQUE constraint must be added AFTER the one-time dedup of duplicate +-- offer_micros (same macro_id + tier_letter), otherwise it fails on existing dupes. +-- NULLS NOT DISTINCT treats two NULL tier_letter rows under the same macro as a +-- conflict too; if that is undesired for legacy non-tier micros, switch to the +-- partial-index variant below. No drops/truncates. + +ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS offer_type text NOT NULL DEFAULT 'una_tantum'; + +-- Run ONLY after dedup. Prevents future duplicate tiers within an offer. +ALTER TABLE offer_micros + ADD CONSTRAINT offer_micros_macro_tier_unique UNIQUE NULLS NOT DISTINCT (macro_id, tier_letter); + +-- Alternative (allows multiple NULL-tier micros per macro): +-- CREATE UNIQUE INDEX offer_micros_macro_tier_unique +-- ON offer_micros (macro_id, tier_letter) WHERE tier_letter IS NOT NULL; diff --git a/src/db/schema.ts b/src/db/schema.ts index f38c01b..aec7bb0 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -279,6 +279,7 @@ export const offer_macros = pgTable("offer_macros", { description: text("description"), // short description shown on offer cards category: text("category"), // single-select "Categoria" (Entry/Signature/Retainer Offer) — OFFER-15/18 ticket: text("ticket"), // single-select "Ticket" (Low/Mid/High Ticket) — OFFER-15 + offer_type: text("offer_type").notNull().default("una_tantum"), // una_tantum | retainer (v2.3) is_archived: boolean("is_archived").notNull().default(false), // OFFER-18 archive flag cliente_ideale: text("cliente_ideale"), // "Aiuto: [Cliente Ideale]" risultato: text("risultato"), // "A ottenere: [Risultato]" diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index 1f1657e..7616312 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -212,6 +212,7 @@ export type ProjectOfferWithMicro = { micro_duration_months: number; tier_letter: string | null; macro_internal_name: string; + macro_offer_type: string; start_date: Date; accepted_total: string | null; created_at: Date; @@ -556,7 +557,11 @@ export type ProjectFullDetail = { public_name: string; duration_months: number; tier_letter: string | null; + public_price: string | null; + macro_id: string; macro_internal_name: string; + macro_category: string | null; + macro_offer_type: string; }>; }; @@ -644,6 +649,7 @@ export async function getProjectFullDetail(id: string): Promise d.id)]; @@ -689,6 +705,16 @@ export async function getProjectFullDetail(id: string): Promise(); + const dedupedMicros = availableMicrosRows.filter((m) => { + const key = `${m.macro_id}::${m.tier_letter ?? `_null_${m.id}`}`; + if (seenTier.has(key)) return false; + seenTier.add(key); + return true; + }); + return { project: { ...project, client } as ProjectFullDetail["project"], phases: phasesWithTasks, @@ -702,7 +728,7 @@ export async function getProjectFullDetail(id: string): Promise