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"
- />
-
-
-
+ );
+ })}
)}
- {/* Assignment form */}
+ {/* Assignment — step 1: offer, step 2: tier */}
);
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