Files
clienthub/src/lib/offer-queries.ts
T
simone 4f7e0033c4 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>
2026-06-18 18:25:50 +02:00

278 lines
9.1 KiB
TypeScript

import { db } from "@/db";
import {
offer_macros,
offer_micros,
offer_services,
offer_micro_services,
offer_tier_services,
services,
tags,
} from "@/db/schema";
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
import { eq, and, asc, inArray, sql } from "drizzle-orm";
export type MicroWithServices = OfferMicro & {
services: Array<{ id: string; name: string; price: string }>;
cumulative_price: string;
};
export type MacroWithMicros = OfferMacro & {
micros: MicroWithServices[];
};
export async function getCatalogWithMicros(): Promise<MacroWithMicros[]> {
const macros = await db
.select()
.from(offer_macros)
.orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at));
if (macros.length === 0) return [];
const macroIds = macros.map((m) => m.id);
const micros = await db
.select()
.from(offer_micros)
.where(inArray(offer_micros.macro_id, macroIds))
.orderBy(asc(offer_micros.sort_order));
const microIds = micros.map((m) => m.id);
if (microIds.length === 0) {
return macros.map((m) => ({ ...m, micros: [] }));
}
// Fetch junction rows + service data in one query
const assignments = await db
.select({
micro_id: offer_micro_services.micro_id,
service_id: offer_services.id,
name: offer_services.name,
price: offer_services.price,
})
.from(offer_micro_services)
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
.where(inArray(offer_micro_services.micro_id, microIds));
// Cumulative price per micro
const cumulRows = await db
.select({
micro_id: offer_micro_services.micro_id,
cumulative_price: sql<string>`coalesce(sum(${offer_services.price}::numeric), 0)`,
})
.from(offer_micro_services)
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
.where(inArray(offer_micro_services.micro_id, microIds))
.groupBy(offer_micro_services.micro_id);
const cumulMap = new Map(cumulRows.map((r) => [r.micro_id, r.cumulative_price]));
const microsWithServices: MicroWithServices[] = micros.map((micro) => ({
...micro,
services: assignments
.filter((a) => a.micro_id === micro.id)
.map((a) => ({ id: a.service_id, name: a.name, price: String(a.price) })),
cumulative_price: cumulMap.get(micro.id) ?? "0",
}));
return macros.map((macro) => ({
...macro,
micros: microsWithServices.filter((m) => m.macro_id === macro.id),
}));
}
export async function getAllOfferServices(): Promise<OfferService[]> {
return db
.select()
.from(offer_services)
.where(eq(offer_services.active, true))
.orderBy(asc(offer_services.name));
}
// Returns assigned service IDs for a given micro-offer (used by ServiceCheckboxList)
export async function getMicroAssignedServiceIds(microId: string): Promise<string[]> {
const rows = await db
.select({ service_id: offer_micro_services.service_id })
.from(offer_micro_services)
.where(eq(offer_micro_services.micro_id, microId));
return rows.map((r) => r.service_id);
}
// ── Phase 12: Offer Editor query layer ──────────────────────────────────────
// entity_type values for the polymorphic `tags` table, scoped to offer_macros'
// Tipo/Obiettivo dimensions (D-06 pattern — separate pools from "services"/"leads").
const OFFER_TIPO_ENTITY = "offer_macros.tipo";
const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo";
export type OfferListCard = Pick<
OfferMacro,
"id" | "internal_name" | "description" | "category" | "is_archived"
>;
// List-page cards (Plan 04): one row per offer_macros, with category + archive
// status for client-side filtering. Archived offers ARE included — the
// "Mostra offerte archiviate" toggle filters client-side per UI-SPEC.
export async function getOfferListCards(): Promise<OfferListCard[]> {
const rows = await db
.select({
id: offer_macros.id,
internal_name: offer_macros.internal_name,
description: offer_macros.description,
category: offer_macros.category,
is_archived: offer_macros.is_archived,
})
.from(offer_macros)
.orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at));
return rows;
}
export type OfferTierData = {
id: string;
tier_letter: string | null;
internal_name: string;
public_name: string;
duration_months: number;
public_price: string | null;
assignedServiceIds: string[];
servicesTotal: string;
};
export type OfferEditorData = {
macro: OfferMacro;
tiers: OfferTierData[];
availableServices: Array<{
id: string;
name: string;
unit_price: string;
category: string | null;
}>;
tipoTags: string[];
obiettivoTags: string[];
};
// Full editor data for a single offer (Plan 05): macro fields (incl.
// category/ticket/is_archived/transformation-promise), its A/B/C tiers with
// assigned service IDs + computed services total, the category-filtered
// service catalog for the composition matrix, and Tipo/Obiettivo tags.
export async function getOfferEditorData(macroId: string): Promise<OfferEditorData | null> {
const [macro] = await db.select().from(offer_macros).where(eq(offer_macros.id, macroId));
if (!macro) return null;
// Tiers ordered A -> B -> C, with null/unrecognized tier_letter sorted last.
const tierOrder = sql`CASE ${offer_micros.tier_letter} WHEN 'A' THEN 1 WHEN 'B' THEN 2 WHEN 'C' THEN 3 ELSE 4 END`;
const tiers = await db
.select()
.from(offer_micros)
.where(eq(offer_micros.macro_id, macroId))
.orderBy(asc(tierOrder));
const tierIds = tiers.map((t) => t.id);
let assignedRows: Array<{ tier_id: string; service_id: string }> = [];
let totalsMap = new Map<string, string>();
if (tierIds.length > 0) {
assignedRows = await db
.select({
tier_id: offer_tier_services.tier_id,
service_id: offer_tier_services.service_id,
})
.from(offer_tier_services)
.where(inArray(offer_tier_services.tier_id, tierIds));
const totalRows = await db
.select({
tier_id: offer_tier_services.tier_id,
servicesTotal: sql<string>`coalesce(sum(${services.unit_price}::numeric), 0)`,
})
.from(offer_tier_services)
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
.where(inArray(offer_tier_services.tier_id, tierIds))
.groupBy(offer_tier_services.tier_id);
totalsMap = new Map(totalRows.map((r) => [r.tier_id, r.servicesTotal]));
}
const tiersData: OfferTierData[] = tiers.map((tier) => ({
id: tier.id,
tier_letter: tier.tier_letter,
internal_name: tier.internal_name,
public_name: tier.public_name,
duration_months: tier.duration_months,
public_price: tier.public_price,
assignedServiceIds: assignedRows
.filter((a) => a.tier_id === tier.id)
.map((a) => a.service_id),
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
.select({
id: services.id,
name: services.name,
unit_price: services.unit_price,
category: services.category,
})
.from(services)
.where(eq(services.active, true));
// Tipo/Obiettivo tags for this macro.
const tagRows = await db
.select({ name: tags.name, type: tags.entity_type })
.from(tags)
.where(
and(
eq(tags.entity_id, macroId),
inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])
)
);
const tipoTags = tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name);
const obiettivoTags = tagRows
.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY)
.map((r) => r.name);
return {
macro,
tiers: tiersData,
availableServices,
tipoTags,
obiettivoTags,
};
}
export type OfferFieldOptions = {
categoria: string[];
ticket: string[];
tipo: string[];
obiettivo: string[];
};
// Shared option pools for the offer editor's select fields (Notion-style
// dropdowns), mirroring getCatalogFieldOptions from admin-queries.ts.
export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
const categoriaRows = await db.selectDistinct({ value: offer_macros.category }).from(offer_macros);
const ticketRows = await db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros);
const tagRows = await db
.selectDistinct({ name: tags.name, type: tags.entity_type })
.from(tags)
.where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY]));
const sortUnique = (arr: (string | null)[]) =>
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
(a, b) => a.localeCompare(b, "it")
);
return {
categoria: sortUnique(categoriaRows.map((r) => r.value)),
ticket: sortUnique(ticketRows.map((r) => r.value)),
tipo: sortUnique(tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name)),
obiettivo: sortUnique(
tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name)
),
};
}