fix(offer-editor): fix category bug, add ripristina, always-saveable
- Bug: remove server-side category pre-filter from getOfferEditorData; services now loaded unfiltered, client-side filter handles display → changing category no longer wipes the service list - Fix tierTotals to use full service map (not filtered subset) so totals stay correct when category is edited mid-session - Add Ripristina button for archived offers (calls toggleOfferArchived false) - Remove canSave gate: always allow saving; button is "Salva Bozza" (secondary style, stays on page) when no services assigned, "Salva Offerta" (primary, redirects to list) when at least one tier has services - Show "Bozza salvata." inline feedback after draft save Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,7 @@ export function OfferEditorClient({
|
||||
const [obiettivoOptions, setObiettivoOptions] = useState<string[]>(fieldOptions.obiettivo);
|
||||
|
||||
const [initialArchived] = useState<boolean>(data.macro.is_archived);
|
||||
const [savedDraft, setSavedDraft] = useState(false);
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
return macro.category
|
||||
@@ -99,16 +100,22 @@ export function OfferEditorClient({
|
||||
: data.availableServices;
|
||||
}, [macro.category, data.availableServices]);
|
||||
|
||||
// Totals use the full catalog (not filtered) so changing category doesn't zero them.
|
||||
const serviceById = useMemo(
|
||||
() => new Map(data.availableServices.map((s) => [s.id, s])),
|
||||
[data.availableServices]
|
||||
);
|
||||
|
||||
const tierTotals = useMemo(() => {
|
||||
return tiers.map((tier) =>
|
||||
tier.assignedServiceIds.reduce((sum, id) => {
|
||||
const service = filteredServices.find((s) => s.id === id);
|
||||
const service = serviceById.get(id);
|
||||
return sum + Number(service?.unit_price ?? 0);
|
||||
}, 0)
|
||||
);
|
||||
}, [tiers, filteredServices]);
|
||||
}, [tiers, serviceById]);
|
||||
|
||||
const canSave = tiers.some((t) => t.assignedServiceIds.length > 0);
|
||||
const isDraft = !tiers.some((t) => t.assignedServiceIds.length > 0);
|
||||
|
||||
function updateMacro<K extends keyof MacroFields>(key: K, value: MacroFields[K]) {
|
||||
setMacro((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -164,7 +171,12 @@ export function OfferEditorClient({
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await saveOfferEditor(data.macro.id, payload);
|
||||
if (isDraft) {
|
||||
setSavedDraft(true);
|
||||
router.refresh();
|
||||
} else {
|
||||
router.push("/admin/offers");
|
||||
}
|
||||
} catch {
|
||||
setSaveError("Errore nel salvataggio. Verifica i campi.");
|
||||
}
|
||||
@@ -183,6 +195,18 @@ export function OfferEditorClient({
|
||||
});
|
||||
}
|
||||
|
||||
function handleRestore() {
|
||||
if (!window.confirm("Ripristinare l'offerta? Tornerà visibile nella lista.")) return;
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await toggleOfferArchived(data.macro.id, false);
|
||||
router.push("/admin/offers");
|
||||
} catch {
|
||||
setSaveError("Errore nel salvataggio. Verifica i campi.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRenameCategoria(oldValue: string, newValue: string) {
|
||||
setCategoriaOptions((prev) => prev.map((o) => (o === oldValue ? newValue : o)));
|
||||
if (macro.category === oldValue) updateMacro("category", newValue);
|
||||
@@ -468,16 +492,29 @@ export function OfferEditorClient({
|
||||
|
||||
{/* Action buttons */}
|
||||
{saveError && <p className="text-sm text-red-600">{saveError}</p>}
|
||||
{savedDraft && !saveError && (
|
||||
<p className="text-sm text-[#1A463C]">Bozza salvata.</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{isDraft ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || isPending}
|
||||
title={!canSave ? "Seleziona almeno un servizio" : undefined}
|
||||
disabled={isPending}
|
||||
className="border border-[#e5e7eb] text-[#1a1a1a] text-sm font-semibold px-6 py-4 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#f9f9f9] transition-colors duration-150"
|
||||
>
|
||||
Salva Bozza
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isPending}
|
||||
className="bg-[#1A463C] text-white text-sm font-semibold px-6 py-4 rounded disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[#163a31] transition-colors duration-150"
|
||||
>
|
||||
Salva Offerta
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href="/admin/offers"
|
||||
className="border border-[#e5e7eb] text-[#1a1a1a] text-sm font-semibold px-6 py-4 rounded hover:bg-[#f9f9f9] transition-colors duration-150"
|
||||
@@ -494,6 +531,16 @@ export function OfferEditorClient({
|
||||
Archivia
|
||||
</button>
|
||||
)}
|
||||
{initialArchived && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestore}
|
||||
disabled={isPending}
|
||||
className="text-[#1A463C] text-sm font-semibold px-6 py-4 rounded hover:bg-[#f0f7f4] transition-colors duration-150 disabled:opacity-50"
|
||||
>
|
||||
Ripristina
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -206,23 +206,17 @@ export async function getOfferEditorData(macroId: string): Promise<OfferEditorDa
|
||||
servicesTotal: totalsMap.get(tier.id) ?? "0",
|
||||
}));
|
||||
|
||||
// Category-filtered service catalog for the composition matrix (D-4
|
||||
// pre-filter, best-effort). If macro.category is unset, return all active
|
||||
// services unfiltered.
|
||||
const availableServicesQuery = db
|
||||
// Full service catalog (unfiltered by category) — category filtering happens
|
||||
// client-side so changing the category field doesn't wipe the service list.
|
||||
const availableServices = await db
|
||||
.select({
|
||||
id: services.id,
|
||||
name: services.name,
|
||||
unit_price: services.unit_price,
|
||||
category: services.category,
|
||||
})
|
||||
.from(services);
|
||||
|
||||
const availableServices = macro.category
|
||||
? await availableServicesQuery.where(
|
||||
and(eq(services.active, true), eq(services.category, macro.category))
|
||||
)
|
||||
: await availableServicesQuery.where(eq(services.active, true));
|
||||
.from(services)
|
||||
.where(eq(services.active, true));
|
||||
|
||||
// Tipo/Obiettivo tags for this macro.
|
||||
const tagRows = await db
|
||||
|
||||
Reference in New Issue
Block a user