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(),
|
description: z.string().optional(),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
ticket: z.string().optional(),
|
ticket: z.string().optional(),
|
||||||
|
offer_type: z.enum(["una_tantum", "retainer"]).optional(),
|
||||||
cliente_ideale: z.string().optional(),
|
cliente_ideale: z.string().optional(),
|
||||||
risultato: z.string().optional(),
|
risultato: z.string().optional(),
|
||||||
tempo: z.string().optional(),
|
tempo: z.string().optional(),
|
||||||
@@ -189,6 +190,7 @@ export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorP
|
|||||||
description: data.description || null,
|
description: data.description || null,
|
||||||
category: data.category || null,
|
category: data.category || null,
|
||||||
ticket: data.ticket || null,
|
ticket: data.ticket || null,
|
||||||
|
offer_type: data.offer_type ?? "una_tantum",
|
||||||
cliente_ideale: data.cliente_ideale || null,
|
cliente_ideale: data.cliente_ideale || null,
|
||||||
risultato: data.risultato || null,
|
risultato: data.risultato || null,
|
||||||
tempo: data.tempo || 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 })}`;
|
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OfferType = "una_tantum" | "retainer";
|
||||||
|
|
||||||
type MacroFields = {
|
type MacroFields = {
|
||||||
internal_name: string;
|
internal_name: string;
|
||||||
public_name: string;
|
public_name: string;
|
||||||
description: string;
|
description: string;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
ticket: string | null;
|
ticket: string | null;
|
||||||
|
offer_type: OfferType;
|
||||||
cliente_ideale: string;
|
cliente_ideale: string;
|
||||||
risultato: string;
|
risultato: string;
|
||||||
tempo: string;
|
tempo: string;
|
||||||
@@ -75,6 +78,7 @@ export function OfferEditorClient({
|
|||||||
description: data.macro.description ?? "",
|
description: data.macro.description ?? "",
|
||||||
category: data.macro.category,
|
category: data.macro.category,
|
||||||
ticket: data.macro.ticket,
|
ticket: data.macro.ticket,
|
||||||
|
offer_type: (data.macro.offer_type === "retainer" ? "retainer" : "una_tantum") as OfferType,
|
||||||
cliente_ideale: data.macro.cliente_ideale ?? "",
|
cliente_ideale: data.macro.cliente_ideale ?? "",
|
||||||
risultato: data.macro.risultato ?? "",
|
risultato: data.macro.risultato ?? "",
|
||||||
tempo: data.macro.tempo ?? "",
|
tempo: data.macro.tempo ?? "",
|
||||||
@@ -166,6 +170,7 @@ export function OfferEditorClient({
|
|||||||
description: macro.description,
|
description: macro.description,
|
||||||
category: macro.category ?? undefined,
|
category: macro.category ?? undefined,
|
||||||
ticket: macro.ticket ?? undefined,
|
ticket: macro.ticket ?? undefined,
|
||||||
|
offer_type: macro.offer_type,
|
||||||
cliente_ideale: macro.cliente_ideale,
|
cliente_ideale: macro.cliente_ideale,
|
||||||
risultato: macro.risultato,
|
risultato: macro.risultato,
|
||||||
tempo: macro.tempo,
|
tempo: macro.tempo,
|
||||||
@@ -319,6 +324,29 @@ export function OfferEditorClient({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Tipo</span>
|
<span className="text-xs text-[#71717a] w-24 shrink-0">Tipo</span>
|
||||||
<OptionMultiSelect
|
<OptionMultiSelect
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTransition, useRef } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Check, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
assignOfferToProject,
|
assignOfferToProject,
|
||||||
removeProjectOffer,
|
removeProjectOffer,
|
||||||
@@ -15,7 +16,11 @@ type AvailableMicro = {
|
|||||||
public_name: string;
|
public_name: string;
|
||||||
duration_months: number;
|
duration_months: number;
|
||||||
tier_letter: string | null;
|
tier_letter: string | null;
|
||||||
|
public_price: string | null;
|
||||||
|
macro_id: string;
|
||||||
macro_internal_name: string;
|
macro_internal_name: string;
|
||||||
|
macro_category: string | null;
|
||||||
|
macro_offer_type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface OffersTabProps {
|
interface OffersTabProps {
|
||||||
@@ -24,24 +29,97 @@ interface OffersTabProps {
|
|||||||
availableMicros: AvailableMicro[];
|
availableMicros: AvailableMicro[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function durationLabel(months: number): string {
|
function typeLabel(offerType: string): string {
|
||||||
return `${months} ${months === 1 ? "mese" : "mesi"}`;
|
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) {
|
export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const router = useRouter();
|
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 () => {
|
startTransition(async () => {
|
||||||
await assignOfferToProject(formData);
|
await assignOfferToProject(fd);
|
||||||
formRef.current?.reset();
|
setMacroId("");
|
||||||
|
setTierId("");
|
||||||
|
setAcceptedTotal("");
|
||||||
|
setImportPhases(true);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemove(offerId: string) {
|
function handleRemove(offerId: string) {
|
||||||
|
if (!window.confirm("Rimuovere questa offerta dal progetto?")) return;
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
await removeProjectOffer(offerId, projectId);
|
await removeProjectOffer(offerId, projectId);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@@ -56,7 +134,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-8 max-w-2xl">
|
||||||
{/* Active assignments */}
|
{/* Active assignments */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
|
<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>
|
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<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
|
<div
|
||||||
key={offer.id}
|
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">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-[#1a1a1a]">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{offer.macro_internal_name}
|
<p className="text-sm font-medium text-[#1a1a1a]">{offer.macro_internal_name}</p>
|
||||||
{offer.tier_letter && (
|
{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}
|
Tier {offer.tier_letter}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
<TypeBadge offerType={offer.macro_offer_type} />
|
||||||
<p className="text-xs text-[#71717a]">
|
</div>
|
||||||
Pubblico: {offer.micro_public_name} · {durationLabel(offer.micro_duration_months)}
|
{pub && <p className="text-xs text-[#71717a] mt-1">Pubblico: {offer.micro_public_name}</p>}
|
||||||
</p>
|
|
||||||
<p className="text-xs text-[#71717a] mt-1">
|
|
||||||
Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}
|
|
||||||
</p>
|
|
||||||
{/* Accepted total inline edit */}
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@@ -95,11 +173,9 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
|||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const val = e.currentTarget.value.trim();
|
const val = e.currentTarget.value.trim();
|
||||||
if (val !== (offer.accepted_total ?? "")) {
|
if (val !== (offer.accepted_total ?? "")) handleTotalUpdate(offer.id, val);
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,61 +183,103 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemove(offer.id)}
|
onClick={() => handleRemove(offer.id)}
|
||||||
disabled={isPending}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assignment form */}
|
{/* Assignment — step 1: offer, step 2: tier */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Offerta</h3>
|
<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>
|
<div>
|
||||||
<label className="text-xs text-[#71717a] block mb-1">Offerta</label>
|
<label className="text-xs text-[#71717a] block mb-1">Offerta</label>
|
||||||
<select
|
<select
|
||||||
name="micro_id"
|
value={macroId}
|
||||||
required
|
onChange={(e) => selectMacro(e.target.value)}
|
||||||
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 bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15"
|
||||||
>
|
>
|
||||||
<option value="">Seleziona offerta...</option>
|
<option value="">Seleziona offerta...</option>
|
||||||
{availableMicros.map((m) => (
|
{offers.map((o) => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={o.macro_id} value={o.macro_id}>
|
||||||
{m.macro_internal_name}
|
{o.name}
|
||||||
{m.tier_letter ? ` — Tier ${m.tier_letter}` : ""} · {m.public_name} (
|
{o.category ? ` · ${o.category}` : ""} · {typeLabel(o.offer_type)}
|
||||||
{durationLabel(m.duration_months)})
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="text-xs text-[#71717a] block mb-1">Totale accettato € (opzionale)</label>
|
<label className="text-xs text-[#71717a] block mb-1">Totale accettato € (opzionale)</label>
|
||||||
<input
|
<input
|
||||||
name="accepted_total"
|
value={acceptedTotal}
|
||||||
|
onChange={(e) => setAcceptedTotal(e.target.value)}
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="0.00"
|
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>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-start gap-2 text-xs text-[#1a1a1a] cursor-pointer">
|
<label className="flex items-start gap-2 text-xs text-[#1a1a1a] cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name="import_phases"
|
checked={importPhases}
|
||||||
defaultChecked
|
onChange={(e) => setImportPhases(e.target.checked)}
|
||||||
className="mt-0.5 h-3.5 w-3.5 accent-[#1A463C]"
|
className="mt-0.5 h-3.5 w-3.5 accent-[#1A463C]"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
@@ -173,20 +291,17 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
disabled={isPending || availableMicros.length === 0}
|
onClick={handleAssign}
|
||||||
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31] disabled:opacity-50"
|
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>
|
</button>
|
||||||
|
</div>
|
||||||
{availableMicros.length === 0 && (
|
)}
|
||||||
<p className="text-xs text-[#71717a]">
|
</div>
|
||||||
Nessuna offerta disponibile. Crea prima un'offerta in{" "}
|
|
||||||
<a href="/admin/offers" className="underline">Offerte</a>.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function ProposalDeck({ proposal, onAccept, onReject }: Props) {
|
|||||||
{ id: "timeline", component: <TimelineSection timeline={ai.timeline} /> },
|
{ id: "timeline", component: <TimelineSection timeline={ai.timeline} /> },
|
||||||
// Cap.05 — Le opzioni
|
// 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: "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
|
// Stages recap + comparison
|
||||||
{ id: "stages", component: <StagesRecapSection stages={ai.stagesRecap.stages} /> },
|
{ id: "stages", component: <StagesRecapSection stages={ai.stagesRecap.stages} /> },
|
||||||
{ id: "comparison", component: <ComparisonMatrixSection matrix={ai.comparisonMatrix} tiers={offer.tiers} /> },
|
{ id: "comparison", component: <ComparisonMatrixSection matrix={ai.comparisonMatrix} tiers={offer.tiers} /> },
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { AssembledProposal } from "@/lib/proposal/assemble";
|
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 {
|
function formatEur(val: string | null | undefined): string {
|
||||||
if (!val) return "—";
|
if (!val) return "—";
|
||||||
@@ -8,7 +11,8 @@ function formatEur(val: string | null | undefined): string {
|
|||||||
return `€${n.toLocaleString("it-IT")}`;
|
return `€${n.toLocaleString("it-IT")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PricingSection({ tiers }: Props) {
|
export function PricingSection({ tiers, offerType }: Props) {
|
||||||
|
const isRetainer = offerType === "retainer";
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
|
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
|
||||||
<div>
|
<div>
|
||||||
@@ -23,6 +27,15 @@ export function PricingSection({ tiers }: Props) {
|
|||||||
const isHighlighted = i === 1; // tier B highlighted di default
|
const isHighlighted = i === 1; // tier B highlighted di default
|
||||||
const price = tier.publicPrice ?? tier.servicesTotal;
|
const price = tier.publicPrice ?? tier.servicesTotal;
|
||||||
const durationLabel = `${tier.durationMonths} ${tier.durationMonths === 1 ? "mese" : "mesi"}`;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -36,7 +49,7 @@ export function PricingSection({ tiers }: Props) {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
|
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
|
||||||
Opzione {tier.tierLetter} / {durationLabel}
|
Opzione {tier.tierLetter} / {eyebrowMeta}
|
||||||
</p>
|
</p>
|
||||||
<h3 className="text-xl font-medium text-foreground mt-1">{tier.publicName}</h3>
|
<h3 className="text-xl font-medium text-foreground mt-1">{tier.publicName}</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,9 +60,7 @@ export function PricingSection({ tiers }: Props) {
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-3xl font-light text-primary">{formatEur(price)}</p>
|
<p className="text-3xl font-light text-primary">{formatEur(price)}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{priceNote}</p>
|
||||||
{tier.durationMonths > 1 ? `${tier.durationMonths} mesi` : "pagamento unico"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Servizi inclusi */}
|
{/* 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
|
description: text("description"), // short description shown on offer cards
|
||||||
category: text("category"), // single-select "Categoria" (Entry/Signature/Retainer Offer) — OFFER-15/18
|
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
|
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
|
is_archived: boolean("is_archived").notNull().default(false), // OFFER-18 archive flag
|
||||||
cliente_ideale: text("cliente_ideale"), // "Aiuto: [Cliente Ideale]"
|
cliente_ideale: text("cliente_ideale"), // "Aiuto: [Cliente Ideale]"
|
||||||
risultato: text("risultato"), // "A ottenere: [Risultato]"
|
risultato: text("risultato"), // "A ottenere: [Risultato]"
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ export type ProjectOfferWithMicro = {
|
|||||||
micro_duration_months: number;
|
micro_duration_months: number;
|
||||||
tier_letter: string | null;
|
tier_letter: string | null;
|
||||||
macro_internal_name: string;
|
macro_internal_name: string;
|
||||||
|
macro_offer_type: string;
|
||||||
start_date: Date;
|
start_date: Date;
|
||||||
accepted_total: string | null;
|
accepted_total: string | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
@@ -556,7 +557,11 @@ export type ProjectFullDetail = {
|
|||||||
public_name: string;
|
public_name: string;
|
||||||
duration_months: number;
|
duration_months: number;
|
||||||
tier_letter: string | null;
|
tier_letter: string | null;
|
||||||
|
public_price: string | null;
|
||||||
|
macro_id: string;
|
||||||
macro_internal_name: 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,
|
micro_duration_months: offer_micros.duration_months,
|
||||||
tier_letter: offer_micros.tier_letter,
|
tier_letter: offer_micros.tier_letter,
|
||||||
macro_internal_name: offer_macros.internal_name,
|
macro_internal_name: offer_macros.internal_name,
|
||||||
|
macro_offer_type: offer_macros.offer_type,
|
||||||
start_date: project_offers.start_date,
|
start_date: project_offers.start_date,
|
||||||
accepted_total: project_offers.accepted_total,
|
accepted_total: project_offers.accepted_total,
|
||||||
created_at: project_offers.created_at,
|
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))
|
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
||||||
.where(eq(project_offers.project_id, id))
|
.where(eq(project_offers.project_id, id))
|
||||||
.orderBy(asc(project_offers.created_at)),
|
.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
|
db
|
||||||
.select({
|
.select({
|
||||||
id: offer_micros.id,
|
id: offer_micros.id,
|
||||||
@@ -661,12 +669,20 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
public_name: offer_micros.public_name,
|
public_name: offer_micros.public_name,
|
||||||
duration_months: offer_micros.duration_months,
|
duration_months: offer_micros.duration_months,
|
||||||
tier_letter: offer_micros.tier_letter,
|
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_internal_name: offer_macros.internal_name,
|
||||||
|
macro_category: offer_macros.category,
|
||||||
|
macro_offer_type: offer_macros.offer_type,
|
||||||
})
|
})
|
||||||
.from(offer_micros)
|
.from(offer_micros)
|
||||||
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
||||||
.where(eq(offer_macros.is_archived, false))
|
.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)];
|
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 {
|
return {
|
||||||
project: { ...project, client } as ProjectFullDetail["project"],
|
project: { ...project, client } as ProjectFullDetail["project"],
|
||||||
phases: phasesWithTasks,
|
phases: phasesWithTasks,
|
||||||
@@ -702,7 +728,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
|
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
|
||||||
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
||||||
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
||||||
availableMicros: availableMicrosRows,
|
availableMicros: dedupedMicros,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export type AssembledProposal = {
|
|||||||
offer: {
|
offer: {
|
||||||
macroId: string;
|
macroId: string;
|
||||||
publicName: string;
|
publicName: string;
|
||||||
|
offerType: string; // una_tantum | retainer
|
||||||
tiers: Array<{
|
tiers: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
tierLetter: string;
|
tierLetter: string;
|
||||||
@@ -99,6 +100,7 @@ export function assembleProposal(input: AssembleInput): AssembledProposal {
|
|||||||
offer: {
|
offer: {
|
||||||
macroId: input.offer.macro.id,
|
macroId: input.offer.macro.id,
|
||||||
publicName: offerTitle,
|
publicName: offerTitle,
|
||||||
|
offerType: input.offer.macro.offer_type ?? "una_tantum",
|
||||||
tiers,
|
tiers,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user