feat: offer_type (una tantum/retainer), dedup tiers, redesigned Offerte tab
- offer_macros.offer_type ('una_tantum'|'retainer') + editor "Modalità" toggle
(migration 0012, applied to prod)
- migration 0012 also adds UNIQUE(macro_id, tier_letter) — prevents duplicate
tiers; ran after a one-time dedup of Web Domination's duplicate A/B/C tiers
- OffersTab redesigned: two-step assign (Offer → Tier cards with price), type
badge "Una tantum/Ricorrente" instead of misleading "X mesi", no redundant
"· A" when public_name == tier letter, cleaner active-offers cards
- getProjectFullDetail: availableMicros grouped by macro + defensive dedup;
projectOffers/availableMicros carry offer_type/category/price
- proposal deck PricingSection shows offer type (fallback to duration for
pre-existing proposals)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Modalità</span>
|
||||
<div className="inline-flex rounded-lg border border-[#e5e7eb] bg-[#fafafa] p-0.5">
|
||||
{([
|
||||
{ value: "una_tantum", label: "Una tantum" },
|
||||
{ value: "retainer", label: "Retainer" },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => updateMacro("offer_type", opt.value)}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
macro.offer_type === opt.value
|
||||
? "bg-[#1A463C] text-white font-medium"
|
||||
: "text-[#71717a] hover:text-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Tipo</span>
|
||||
<OptionMultiSelect
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition, useRef } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Trash2 } from "lucide-react";
|
||||
import {
|
||||
assignOfferToProject,
|
||||
removeProjectOffer,
|
||||
@@ -15,7 +16,11 @@ type AvailableMicro = {
|
||||
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;
|
||||
};
|
||||
|
||||
interface OffersTabProps {
|
||||
@@ -24,24 +29,97 @@ interface OffersTabProps {
|
||||
availableMicros: AvailableMicro[];
|
||||
}
|
||||
|
||||
function durationLabel(months: number): string {
|
||||
return `${months} ${months === 1 ? "mese" : "mesi"}`;
|
||||
function typeLabel(offerType: string): string {
|
||||
return offerType === "retainer" ? "Ricorrente" : "Una tantum";
|
||||
}
|
||||
|
||||
function formatEuro(raw: string | null): string | null {
|
||||
if (raw == null || raw === "") return null;
|
||||
const n = parseFloat(raw);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return `€${n.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
// public_name is only meaningful when it differs from the tier letter (it
|
||||
// defaults to the letter when unset).
|
||||
function meaningfulPublicName(m: { public_name: string; tier_letter: string | null }): string | null {
|
||||
const p = m.public_name?.trim();
|
||||
if (!p) return null;
|
||||
if (m.tier_letter && p.toUpperCase() === m.tier_letter.toUpperCase()) return null;
|
||||
return p;
|
||||
}
|
||||
|
||||
function TypeBadge({ offerType }: { offerType: string }) {
|
||||
const retainer = offerType === "retainer";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded text-[11px] font-medium px-1.5 py-0.5 ${
|
||||
retainer ? "bg-blue-50 text-blue-700" : "bg-[#f4f4f5] text-[#52525b]"
|
||||
}`}
|
||||
>
|
||||
{typeLabel(offerType)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
function handleAssign(formData: FormData) {
|
||||
const [macroId, setMacroId] = useState<string>("");
|
||||
const [tierId, setTierId] = useState<string>("");
|
||||
const [acceptedTotal, setAcceptedTotal] = useState<string>("");
|
||||
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 (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
{/* Active assignments */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
|
||||
@@ -64,29 +142,29 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{projectOffers.map((offer) => (
|
||||
{projectOffers.map((offer) => {
|
||||
const pub = meaningfulPublicName({
|
||||
public_name: offer.micro_public_name,
|
||||
tier_letter: offer.tier_letter,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={offer.id}
|
||||
className="bg-white rounded-lg border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
|
||||
className="bg-white rounded-xl border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">
|
||||
{offer.macro_internal_name}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{offer.macro_internal_name}</p>
|
||||
{offer.tier_letter && (
|
||||
<span className="ml-2 inline-block rounded bg-[#1A463C]/10 text-[#1A463C] text-[11px] font-semibold px-1.5 py-0.5 align-middle">
|
||||
<span className="inline-block rounded bg-[#1A463C]/10 text-[#1A463C] text-[11px] font-semibold px-1.5 py-0.5">
|
||||
Tier {offer.tier_letter}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Pubblico: {offer.micro_public_name} · {durationLabel(offer.micro_duration_months)}
|
||||
</p>
|
||||
<p className="text-xs text-[#71717a] mt-1">
|
||||
Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}
|
||||
</p>
|
||||
{/* Accepted total inline edit */}
|
||||
<TypeBadge offerType={offer.macro_offer_type} />
|
||||
</div>
|
||||
{pub && <p className="text-xs text-[#71717a] mt-1">Pubblico: {offer.micro_public_name}</p>}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<label className="text-xs text-[#71717a]">Totale accettato €:</label>
|
||||
<label className="text-xs text-[#71717a]">Totale accettato €</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
@@ -95,11 +173,9 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
placeholder="0.00"
|
||||
onBlur={(e) => {
|
||||
const val = e.currentTarget.value.trim();
|
||||
if (val !== (offer.accepted_total ?? "")) {
|
||||
handleTotalUpdate(offer.id, val);
|
||||
}
|
||||
if (val !== (offer.accepted_total ?? "")) handleTotalUpdate(offer.id, val);
|
||||
}}
|
||||
className="w-24 border rounded px-2 py-0.5 text-xs"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,61 +183,103 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
type="button"
|
||||
onClick={() => handleRemove(offer.id)}
|
||||
disabled={isPending}
|
||||
className="text-xs text-red-600 hover:underline shrink-0"
|
||||
aria-label="Rimuovi offerta"
|
||||
className="shrink-0 rounded-md p-1.5 text-[#a1a1aa] hover:bg-red-50 hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Rimuovi
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assignment form */}
|
||||
{/* Assignment — step 1: offer, step 2: tier */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Offerta</h3>
|
||||
<form
|
||||
ref={formRef}
|
||||
action={handleAssign}
|
||||
className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3"
|
||||
>
|
||||
<input type="hidden" name="project_id" value={projectId} />
|
||||
|
||||
{offers.length === 0 ? (
|
||||
<p className="text-sm text-[#71717a]">
|
||||
Nessuna offerta disponibile. Crea prima un'offerta in{" "}
|
||||
<a href="/admin/offers" className="text-[#1A463C] underline">Offerte</a>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-4 space-y-4">
|
||||
{/* Step 1 — choose offer */}
|
||||
<div>
|
||||
<label className="text-xs text-[#71717a] block mb-1">Offerta</label>
|
||||
<select
|
||||
name="micro_id"
|
||||
required
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
value={macroId}
|
||||
onChange={(e) => selectMacro(e.target.value)}
|
||||
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15"
|
||||
>
|
||||
<option value="">Seleziona offerta...</option>
|
||||
{availableMicros.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.macro_internal_name}
|
||||
{m.tier_letter ? ` — Tier ${m.tier_letter}` : ""} · {m.public_name} (
|
||||
{durationLabel(m.duration_months)})
|
||||
{offers.map((o) => (
|
||||
<option key={o.macro_id} value={o.macro_id}>
|
||||
{o.name}
|
||||
{o.category ? ` · ${o.category}` : ""} · {typeLabel(o.offer_type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Step 2 — choose tier */}
|
||||
{selectedOffer && (
|
||||
<div>
|
||||
<label className="text-xs text-[#71717a] block mb-1.5">Tier</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{selectedOffer.tiers.map((t) => {
|
||||
const active = tierId === t.id;
|
||||
const price = formatEuro(t.public_price);
|
||||
const pub = meaningfulPublicName(t);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTierId(t.id)}
|
||||
className={`text-left rounded-lg border p-3 transition-colors ${
|
||||
active
|
||||
? "border-[#1A463C] bg-[#1A463C]/5 ring-1 ring-[#1A463C]/20"
|
||||
: "border-[#e5e7eb] hover:border-[#1A463C]/30 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-[#1a1a1a]">
|
||||
{t.tier_letter ? `Tier ${t.tier_letter}` : t.public_name || "Tier"}
|
||||
</span>
|
||||
{active && <Check className="h-4 w-4 text-[#1A463C]" />}
|
||||
</div>
|
||||
{pub && <p className="text-xs text-[#71717a] mt-0.5 truncate">{pub}</p>}
|
||||
<p className="text-sm text-[#1a1a1a] mt-1 tabular-nums">{price ?? "—"}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options + submit */}
|
||||
{selectedOffer && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<div>
|
||||
<label className="text-xs text-[#71717a] block mb-1">Totale accettato € (opzionale)</label>
|
||||
<input
|
||||
name="accepted_total"
|
||||
value={acceptedTotal}
|
||||
onChange={(e) => setAcceptedTotal(e.target.value)}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-xs text-[#1a1a1a] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="import_phases"
|
||||
defaultChecked
|
||||
checked={importPhases}
|
||||
onChange={(e) => setImportPhases(e.target.checked)}
|
||||
className="mt-0.5 h-3.5 w-3.5 accent-[#1A463C]"
|
||||
/>
|
||||
<span>
|
||||
@@ -173,20 +291,17 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || availableMicros.length === 0}
|
||||
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31] disabled:opacity-50"
|
||||
type="button"
|
||||
onClick={handleAssign}
|
||||
disabled={isPending || !tierId}
|
||||
className="bg-[#1A463C] text-white text-sm font-medium px-4 py-2 rounded-md hover:bg-[#1A463C]/90 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{isPending ? "Salvataggio..." : "Assegna Offerta"}
|
||||
{isPending ? "Assegnazione..." : "Assegna Offerta"}
|
||||
</button>
|
||||
|
||||
{availableMicros.length === 0 && (
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Nessuna offerta disponibile. Crea prima un'offerta in{" "}
|
||||
<a href="/admin/offers" className="underline">Offerte</a>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -87,7 +87,7 @@ export function ProposalDeck({ proposal, onAccept, onReject }: Props) {
|
||||
{ id: "timeline", component: <TimelineSection timeline={ai.timeline} /> },
|
||||
// Cap.05 — Le opzioni
|
||||
{ id: "ch05", component: <ChapterDivider number="05" title="Le opzioni." subtitle="Il valore di mercato delle attività, le tre opzioni progressive con il loro pricing, e il processo per partire." /> },
|
||||
{ id: "pricing", component: <PricingSection tiers={offer.tiers} /> },
|
||||
{ id: "pricing", component: <PricingSection tiers={offer.tiers} offerType={offer.offerType} /> },
|
||||
// Stages recap + comparison
|
||||
{ id: "stages", component: <StagesRecapSection stages={ai.stagesRecap.stages} /> },
|
||||
{ id: "comparison", component: <ComparisonMatrixSection matrix={ai.comparisonMatrix} tiers={offer.tiers} /> },
|
||||
|
||||
@@ -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 (
|
||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
|
||||
<div>
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -36,7 +49,7 @@ export function PricingSection({ tiers }: Props) {
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
|
||||
Opzione {tier.tierLetter} / {durationLabel}
|
||||
Opzione {tier.tierLetter} / {eyebrowMeta}
|
||||
</p>
|
||||
<h3 className="text-xl font-medium text-foreground mt-1">{tier.publicName}</h3>
|
||||
</div>
|
||||
@@ -47,9 +60,7 @@ export function PricingSection({ tiers }: Props) {
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-3xl font-light text-primary">{formatEur(price)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tier.durationMonths > 1 ? `${tier.durationMonths} mesi` : "pagamento unico"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{priceNote}</p>
|
||||
</div>
|
||||
|
||||
{/* Servizi inclusi */}
|
||||
|
||||
@@ -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;
|
||||
@@ -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]"
|
||||
|
||||
@@ -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<ProjectFullDetai
|
||||
micro_duration_months: offer_micros.duration_months,
|
||||
tier_letter: offer_micros.tier_letter,
|
||||
macro_internal_name: offer_macros.internal_name,
|
||||
macro_offer_type: offer_macros.offer_type,
|
||||
start_date: project_offers.start_date,
|
||||
accepted_total: project_offers.accepted_total,
|
||||
created_at: project_offers.created_at,
|
||||
@@ -653,7 +659,9 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
||||
.where(eq(project_offers.project_id, id))
|
||||
.orderBy(asc(project_offers.created_at)),
|
||||
// Query B: all tiers of non-archived offers (for the assignment dropdown)
|
||||
// Query B: all tiers of non-archived offers (for the assignment dropdown).
|
||||
// ordered oldest-first per (macro, tier) so client-side dedup keeps the
|
||||
// oldest until the DB cleanup removes genuine duplicates.
|
||||
db
|
||||
.select({
|
||||
id: offer_micros.id,
|
||||
@@ -661,12 +669,20 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
public_name: offer_micros.public_name,
|
||||
duration_months: offer_micros.duration_months,
|
||||
tier_letter: offer_micros.tier_letter,
|
||||
public_price: offer_micros.public_price,
|
||||
macro_id: offer_micros.macro_id,
|
||||
macro_internal_name: offer_macros.internal_name,
|
||||
macro_category: offer_macros.category,
|
||||
macro_offer_type: offer_macros.offer_type,
|
||||
})
|
||||
.from(offer_micros)
|
||||
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
||||
.where(eq(offer_macros.is_archived, false))
|
||||
.orderBy(asc(offer_macros.internal_name), asc(offer_micros.tier_letter)),
|
||||
.orderBy(
|
||||
asc(offer_macros.internal_name),
|
||||
asc(offer_micros.tier_letter),
|
||||
asc(offer_micros.id)
|
||||
),
|
||||
]);
|
||||
|
||||
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||
@@ -689,6 +705,16 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
})),
|
||||
}));
|
||||
|
||||
// Defensive dedup: until the DB cleanup removes genuine duplicate tiers,
|
||||
// keep one micro per (macro_id, tier_letter) for the assignment dropdown.
|
||||
const seenTier = new Set<string>();
|
||||
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<ProjectFullDetai
|
||||
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
|
||||
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
||||
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
||||
availableMicros: availableMicrosRows,
|
||||
availableMicros: dedupedMicros,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export type AssembledProposal = {
|
||||
offer: {
|
||||
macroId: string;
|
||||
publicName: string;
|
||||
offerType: string; // una_tantum | retainer
|
||||
tiers: Array<{
|
||||
id: string;
|
||||
tierLetter: string;
|
||||
@@ -99,6 +100,7 @@ export function assembleProposal(input: AssembleInput): AssembledProposal {
|
||||
offer: {
|
||||
macroId: input.offer.macro.id,
|
||||
publicName: offerTitle,
|
||||
offerType: input.offer.macro.offer_type ?? "una_tantum",
|
||||
tiers,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user