feat: service offer tags, catalog cleanup, offer editor filter

- Import 58 offer membership tags for all 55 services (entity_type="services")
  via scripts/import-service-offer-tags.ts (already run on prod)
- ServiceTable: remove Categoria column, rename Tag → Offerta; QuickAddRow
  no longer has a category field (offer tags set post-creation)
- offer-queries: getOfferEditorData fetches offerTags per service (join on
  tags WHERE entity_type="services") and exposes them in OfferEditorData
- OfferEditorClient: filter chips above "Servizi Inclusi" — Tutti / Entry
  Offer / Signature Offer / Retainer Offer, derived from actual tag data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 21:58:34 +02:00
parent 696a95950c
commit 43238341c1
4 changed files with 187 additions and 33 deletions
+2 -29
View File
@@ -23,9 +23,8 @@ function formatPrice(raw: string): string {
const COLUMN_HEADERS = [
"Nome",
"Descrizione",
"Categoria",
"Fase",
"Tag",
"Offerta",
"Pacchetto",
"Prezzo",
"Stato",
@@ -78,14 +77,6 @@ function ServiceRow({
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
/>
</td>
<td className="py-2 px-3 min-w-[140px]">
<OptionSelect
value={service.category}
options={options.categoria}
onChange={(v) => run(() => updateServiceField(service.id, "category", v ?? ""))}
onRename={(o, n) => run(() => renameServiceOption("categoria", o, n))}
/>
</td>
<td className="py-2 px-3 min-w-[140px]">
<OptionSelect
value={service.fase}
@@ -142,7 +133,6 @@ function ServiceRow({
function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [category, setCategory] = useState("");
const [fase, setFase] = useState("");
const [price, setPrice] = useState("");
const [, startTransition] = useTransition();
@@ -158,13 +148,11 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
await quickAddService({
name: trimmed,
description: description || undefined,
category: category || undefined,
fase: fase || undefined,
unit_price: price || undefined,
});
setName("");
setDescription("");
setCategory("");
setFase("");
setPrice("");
router.refresh();
@@ -205,16 +193,6 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
className={cellInput}
/>
</td>
<td className="py-2 px-3">
<Input
value={category}
onChange={(e) => setCategory(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Categoria..."
list="catalog-categoria-pool"
className={cellInput}
/>
</td>
<td className="py-2 px-3">
<Input
value={fase}
@@ -226,7 +204,7 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
/>
</td>
<td className="py-2 px-3 text-xs text-[#a1a1aa]" colSpan={2}>
Tag e pacchetto dopo la creazione
Offerta e pacchetto dopo la creazione
</td>
<td className="py-2 px-3">
<Input
@@ -239,11 +217,6 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
/>
</td>
<td className="py-2 px-3 text-xs text-[#a1a1aa]">Invio </td>
<datalist id="catalog-categoria-pool">
{options.categoria.map((c) => (
<option key={c} value={c} />
))}
</datalist>
<datalist id="catalog-fase-pool">
{options.fase.map((f) => (
<option key={f} value={f} />
@@ -93,8 +93,21 @@ export function OfferEditorClient({
const [initialArchived] = useState<boolean>(data.macro.is_archived);
const [savedDraft, setSavedDraft] = useState(false);
const [offerFilter, setOfferFilter] = useState<string | null>(null);
const filteredServices = data.availableServices;
const offerFilterOptions = useMemo(() => {
const all = new Set<string>();
for (const s of data.availableServices) s.offerTags.forEach((t) => all.add(t));
return Array.from(all).sort();
}, [data.availableServices]);
const filteredServices = useMemo(
() =>
offerFilter
? data.availableServices.filter((s) => s.offerTags.includes(offerFilter))
: data.availableServices,
[data.availableServices, offerFilter]
);
// Totals use the full catalog (not filtered) so changing category doesn't zero them.
const serviceById = useMemo(
@@ -334,6 +347,35 @@ export function OfferEditorClient({
<section className="space-y-3">
<h3 className="text-base font-semibold text-[#1a1a1a]">Servizi Inclusi</h3>
{/* Offer filter chips */}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setOfferFilter(null)}
className={`text-xs px-3 py-1 rounded-full border transition-colors duration-150 ${
offerFilter === null
? "bg-[#1A463C] text-white border-[#1A463C]"
: "border-[#e5e7eb] text-[#71717a] hover:border-[#1A463C] hover:text-[#1A463C]"
}`}
>
Tutti
</button>
{offerFilterOptions.map((opt) => (
<button
key={opt}
type="button"
onClick={() => setOfferFilter(offerFilter === opt ? null : opt)}
className={`text-xs px-3 py-1 rounded-full border transition-colors duration-150 ${
offerFilter === opt
? "bg-[#1A463C] text-white border-[#1A463C]"
: "border-[#e5e7eb] text-[#71717a] hover:border-[#1A463C] hover:text-[#1A463C]"
}`}
>
{opt}
</button>
))}
</div>
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
+24 -3
View File
@@ -145,6 +145,7 @@ export type OfferEditorData = {
name: string;
unit_price: string;
category: string | null;
offerTags: string[];
}>;
tipoTags: string[];
obiettivoTags: string[];
@@ -206,9 +207,8 @@ export async function getOfferEditorData(macroId: string): Promise<OfferEditorDa
servicesTotal: totalsMap.get(tier.id) ?? "0",
}));
// 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
// Full service catalog with offer membership tags (entity_type = "services").
const allServices = await db
.select({
id: services.id,
name: services.name,
@@ -218,6 +218,27 @@ export async function getOfferEditorData(macroId: string): Promise<OfferEditorDa
.from(services)
.where(eq(services.active, true));
const serviceIds = allServices.map((s) => s.id);
const offerTagRows =
serviceIds.length > 0
? await db
.select({ entity_id: tags.entity_id, name: tags.name })
.from(tags)
.where(and(eq(tags.entity_type, "services"), inArray(tags.entity_id, serviceIds)))
: [];
const tagsByServiceId = new Map<string, string[]>();
for (const row of offerTagRows) {
const list = tagsByServiceId.get(row.entity_id) ?? [];
list.push(row.name);
tagsByServiceId.set(row.entity_id, list);
}
const availableServices = allServices.map((s) => ({
...s,
offerTags: tagsByServiceId.get(s.id) ?? [],
}));
// Tipo/Obiettivo tags for this macro.
const tagRows = await db
.select({ name: tags.name, type: tags.entity_type })