3fcb10dac6
Dalle impostazioni si poteva solo aggiungere o eliminare un valore: per cambiargli nome bisognava cancellarlo — strappandolo via da ogni servizio che lo usava — e ricrearlo a mano. La matita nel chip fa il rename in un passo. renamePoolValue esisteva gia' e propagava ovunque, tranne in un punto: importOfferIntoProject copia services.fase dentro phases.title e poi ritrova la fase confrontando i titoli (phases.offer_phase_id non viene mai popolata, quindi il titolo e' l'unico legame). Un rename fermo al catalogo lasciava le fasi dei progetti col vecchio nome e al re-import ne nasceva una duplicata. Ora propaga anche li', con lo stesso match trim+lowercase del merge. E' l'unico rename di tassonomia che scrive fuori dal dominio catalogo/offerte, quindi e' l'unico che chiede conferma, dicendo quante fasi e quanti progetti sta per toccare. Tolte anche le UPDATE manuali in renameServiceOption/renameOfferOption: erano la stessa scrittura che renamePoolValue faceva subito dopo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
8.8 KiB
TypeScript
248 lines
8.8 KiB
TypeScript
"use server";
|
|
|
|
import { db } from "@/db";
|
|
import { services, tags } from "@/db/schema";
|
|
import { revalidatePath } from "next/cache";
|
|
import { eq, and } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import { getServerSession } from "next-auth";
|
|
import { authOptions } from "@/lib/auth";
|
|
import { addPoolValue, renamePoolValue } from "@/lib/taxonomy";
|
|
|
|
const serviceSchema = z.object({
|
|
name: z.string().min(1, "Nome richiesto"),
|
|
description: z.string().optional(),
|
|
unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"),
|
|
category: z.string().optional(),
|
|
});
|
|
|
|
async function requireAdmin() {
|
|
const session = await getServerSession(authOptions);
|
|
if (!session) throw new Error("Non autorizzato");
|
|
}
|
|
|
|
export async function createService(formData: FormData) {
|
|
await requireAdmin();
|
|
const parsed = serviceSchema.safeParse({
|
|
name: formData.get("name"),
|
|
description: formData.get("description") ?? "",
|
|
unit_price: formData.get("unit_price"),
|
|
category: formData.get("category") ?? "",
|
|
});
|
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
|
// New rows created from /admin/catalog are NOT migrated — migrated_from/migrated_id stay null
|
|
await db.insert(services).values({
|
|
name: parsed.data.name,
|
|
description: parsed.data.description ?? null,
|
|
unit_price: parsed.data.unit_price.toFixed(2),
|
|
category: parsed.data.category || null,
|
|
});
|
|
revalidatePath("/admin/catalog");
|
|
}
|
|
|
|
export async function updateService(serviceId: string, formData: FormData) {
|
|
await requireAdmin();
|
|
const parsed = serviceSchema.safeParse({
|
|
name: formData.get("name"),
|
|
description: formData.get("description") ?? "",
|
|
unit_price: formData.get("unit_price"),
|
|
category: formData.get("category") ?? "",
|
|
});
|
|
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
|
await db
|
|
.update(services)
|
|
.set({
|
|
name: parsed.data.name,
|
|
description: parsed.data.description ?? null,
|
|
unit_price: parsed.data.unit_price.toFixed(2),
|
|
category: parsed.data.category || null,
|
|
})
|
|
.where(eq(services.id, serviceId));
|
|
revalidatePath("/admin/catalog");
|
|
}
|
|
|
|
export async function toggleServiceActive(serviceId: string, active: boolean) {
|
|
await requireAdmin();
|
|
await db.update(services).set({ active }).where(eq(services.id, serviceId));
|
|
revalidatePath("/admin/catalog");
|
|
}
|
|
|
|
// ── Inline-edit, tag, and quick-add actions (Phase 11 database-view) ────────
|
|
|
|
const EDITABLE_FIELDS = ["name", "description", "category", "fase", "unit_price", "active"] as const;
|
|
type EditableField = (typeof EDITABLE_FIELDS)[number];
|
|
|
|
export async function updateServiceField(
|
|
serviceId: string,
|
|
fieldName: EditableField,
|
|
value: string | boolean
|
|
) {
|
|
await requireAdmin();
|
|
|
|
if (!EDITABLE_FIELDS.includes(fieldName)) {
|
|
throw new Error(`Campo non editabile: ${fieldName}`);
|
|
}
|
|
|
|
if (fieldName === "name") {
|
|
const s = String(value).trim();
|
|
if (s.length === 0) throw new Error("Nome richiesto");
|
|
await db.update(services).set({ name: s }).where(eq(services.id, serviceId));
|
|
} else if (fieldName === "description") {
|
|
const s = String(value).trim();
|
|
await db.update(services).set({ description: s || null }).where(eq(services.id, serviceId));
|
|
} else if (fieldName === "category") {
|
|
const s = String(value).trim();
|
|
await db.update(services).set({ category: s || null }).where(eq(services.id, serviceId));
|
|
} else if (fieldName === "fase") {
|
|
const s = String(value).trim();
|
|
await db.update(services).set({ fase: s || null }).where(eq(services.id, serviceId));
|
|
if (s) await addPoolValue("service_fase", s);
|
|
} else if (fieldName === "unit_price") {
|
|
// Normalize locale-formatted input (WR-04): the cell displays it-IT (€1.234,50),
|
|
// so an admin may type "1.234,50". When a comma is present, treat "." as thousands
|
|
// separators and "," as the decimal mark; otherwise parse "." as the decimal mark.
|
|
// Number() (not parseFloat) rejects trailing garbage like "12abc".
|
|
const rawInput = String(value).trim();
|
|
const normalized = rawInput.includes(",")
|
|
? rawInput.replace(/\./g, "").replace(",", ".")
|
|
: rawInput;
|
|
const num = Number(normalized);
|
|
if (!Number.isFinite(num) || num < 0) throw new Error("Prezzo invalido");
|
|
await db.update(services).set({ unit_price: num.toFixed(2) }).where(eq(services.id, serviceId));
|
|
} else if (fieldName === "active") {
|
|
const boolValue = typeof value === "boolean" ? value : value === "true";
|
|
await db.update(services).set({ active: boolValue }).where(eq(services.id, serviceId));
|
|
}
|
|
|
|
revalidatePath("/admin/catalog");
|
|
}
|
|
|
|
// ── Multi-select option fields (Notion-style shared pools) ──────────────────
|
|
// "tag" and "pacchetto" are stored in the polymorphic `tags` table, scoped by
|
|
// entity_type so the two pools stay separate.
|
|
|
|
const MULTI_ENTITY: Record<MultiSelectField, string> = {
|
|
tag: "services",
|
|
pacchetto: "services.pacchetto",
|
|
};
|
|
const MULTI_FIELDS = ["tag", "pacchetto"] as const;
|
|
export type MultiSelectField = (typeof MULTI_FIELDS)[number];
|
|
|
|
type SingleSelectField = "categoria" | "fase";
|
|
|
|
export async function addServiceOption(
|
|
field: MultiSelectField,
|
|
serviceId: string,
|
|
value: string
|
|
) {
|
|
await requireAdmin();
|
|
if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`);
|
|
const trimmed = value.trim();
|
|
if (trimmed.length === 0) throw new Error("Valore richiesto");
|
|
|
|
await db
|
|
.insert(tags)
|
|
.values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed })
|
|
.onConflictDoNothing();
|
|
|
|
// Register into the persistent pool so it survives unassignment / shows in settings.
|
|
await addPoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", trimmed);
|
|
|
|
revalidatePath("/admin/catalog");
|
|
}
|
|
|
|
export async function removeServiceOption(
|
|
field: MultiSelectField,
|
|
serviceId: string,
|
|
value: string
|
|
) {
|
|
await requireAdmin();
|
|
if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`);
|
|
|
|
await db
|
|
.delete(tags)
|
|
.where(
|
|
and(
|
|
eq(tags.entity_type, MULTI_ENTITY[field]),
|
|
eq(tags.entity_id, serviceId),
|
|
eq(tags.name, value)
|
|
)
|
|
);
|
|
|
|
revalidatePath("/admin/catalog");
|
|
}
|
|
|
|
// ── Rename an option everywhere it is used (propagates across all services) ───
|
|
// Works for both multi-select pools (tag/pacchetto) and single-select columns
|
|
// (categoria/fase). This is what makes the dropdown options feel persistent &
|
|
// editable like Notion select properties.
|
|
|
|
export async function renameServiceOption(
|
|
field: MultiSelectField | SingleSelectField,
|
|
oldValue: string,
|
|
newValue: string
|
|
) {
|
|
await requireAdmin();
|
|
const next = newValue.trim();
|
|
if (next.length === 0) throw new Error("Nuovo nome richiesto");
|
|
if (next === oldValue) return;
|
|
|
|
// renamePoolValue owns the propagation (tags / services.fase / project phases);
|
|
// duplicating the UPDATE here would just run it twice.
|
|
if (field === "tag" || field === "pacchetto") {
|
|
await renamePoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", oldValue, next);
|
|
} else if (field === "categoria") {
|
|
// No taxonomy pool backs services.category — this one propagates by hand.
|
|
await db.update(services).set({ category: next }).where(eq(services.category, oldValue));
|
|
} else if (field === "fase") {
|
|
await renamePoolValue("service_fase", oldValue, next);
|
|
} else {
|
|
throw new Error(`Campo non valido: ${field}`);
|
|
}
|
|
|
|
revalidatePath("/admin/catalog");
|
|
}
|
|
|
|
// ── Quick-add: create a service from the bottom row with all scalar fields set
|
|
// and active=true in one Enter. Tags/pacchetto are assigned afterwards (they
|
|
// need the new row's id). Only `name` is required.
|
|
|
|
export type QuickAddPayload = {
|
|
name: string;
|
|
description?: string;
|
|
category?: string;
|
|
fase?: string;
|
|
unit_price?: string;
|
|
};
|
|
|
|
export async function quickAddService(payload: QuickAddPayload | string) {
|
|
await requireAdmin();
|
|
// Back-compat: accept a bare name string.
|
|
const data: QuickAddPayload = typeof payload === "string" ? { name: payload } : payload;
|
|
|
|
const name = data.name.trim();
|
|
if (name.length === 0) throw new Error("Nome richiesto");
|
|
|
|
let unit_price = "0.00";
|
|
if (data.unit_price && data.unit_price.trim().length > 0) {
|
|
const raw = data.unit_price.trim();
|
|
const normalized = raw.includes(",") ? raw.replace(/\./g, "").replace(",", ".") : raw;
|
|
const num = Number(normalized);
|
|
if (!Number.isFinite(num) || num < 0) throw new Error("Prezzo invalido");
|
|
unit_price = num.toFixed(2);
|
|
}
|
|
|
|
const fase = data.fase?.trim() || null;
|
|
await db.insert(services).values({
|
|
name,
|
|
description: data.description?.trim() || null,
|
|
category: data.category?.trim() || null,
|
|
fase,
|
|
unit_price,
|
|
active: true,
|
|
});
|
|
if (fase) await addPoolValue("service_fase", fase);
|
|
|
|
revalidatePath("/admin/catalog");
|
|
}
|