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:
@@ -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,129 +142,166 @@ 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) => (
|
||||
<div
|
||||
key={offer.id}
|
||||
className="bg-white rounded-lg 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}
|
||||
{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">
|
||||
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 */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<label className="text-xs text-[#71717a]">Totale accettato €:</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={offer.accepted_total ?? ""}
|
||||
placeholder="0.00"
|
||||
onBlur={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(offer.id)}
|
||||
disabled={isPending}
|
||||
className="text-xs text-red-600 hover:underline shrink-0"
|
||||
{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-xl border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
|
||||
>
|
||||
Rimuovi
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<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="inline-block rounded bg-[#1A463C]/10 text-[#1A463C] text-[11px] font-semibold px-1.5 py-0.5">
|
||||
Tier {offer.tier_letter}
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={offer.accepted_total ?? ""}
|
||||
placeholder="0.00"
|
||||
onBlur={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(offer.id)}
|
||||
disabled={isPending}
|
||||
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"
|
||||
>
|
||||
<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} />
|
||||
|
||||
<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"
|
||||
>
|
||||
<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)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{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
|
||||
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>
|
||||
{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
|
||||
value={acceptedTotal}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-xs text-[#1a1a1a] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importPhases}
|
||||
onChange={(e) => setImportPhases(e.target.checked)}
|
||||
className="mt-0.5 h-3.5 w-3.5 accent-[#1A463C]"
|
||||
/>
|
||||
<span>
|
||||
Importa fasi e task dall'offerta
|
||||
<span className="block text-[#71717a]">
|
||||
Crea le fasi raggruppando i servizi per «Fase»; ogni servizio diventa un task.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
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 ? "Assegnazione..." : "Assegna Offerta"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-[#71717a] block mb-1">Totale accettato € (opzionale)</label>
|
||||
<input
|
||||
name="accepted_total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-xs text-[#1a1a1a] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="import_phases"
|
||||
defaultChecked
|
||||
className="mt-0.5 h-3.5 w-3.5 accent-[#1A463C]"
|
||||
/>
|
||||
<span>
|
||||
Importa fasi e task dall'offerta
|
||||
<span className="block text-[#71717a]">
|
||||
Crea le fasi raggruppando i servizi per «Fase»; ogni servizio diventa un task.
|
||||
</span>
|
||||
</span>
|
||||
</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"
|
||||
>
|
||||
{isPending ? "Salvataggio..." : "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>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user