add2176a6b
- offer editor: per-tier name input (mirrors public_name/internal_name) - offer list cards: show 3-tier services total + manual public price - shared PageHeader component, full-width layout across all admin pages - UI-RULES.md design conventions doc Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
344 lines
11 KiB
TypeScript
344 lines
11 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 { getPool } from "@/lib/taxonomy";
|
|
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 OfferListTier = {
|
|
tier_letter: string | null;
|
|
public_name: string;
|
|
public_price: string | null;
|
|
servicesTotal: string;
|
|
};
|
|
|
|
export type OfferListCard = Pick<
|
|
OfferMacro,
|
|
"id" | "internal_name" | "description" | "category" | "is_archived"
|
|
> & { tiers: OfferListTier[] };
|
|
|
|
// 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 macros = 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));
|
|
|
|
if (macros.length === 0) return [];
|
|
|
|
const macroIds = macros.map((m) => m.id);
|
|
|
|
// Fetch all tiers for these macros, ordered A -> B -> C
|
|
const tierOrder = sql`CASE ${offer_micros.tier_letter} WHEN 'A' THEN 1 WHEN 'B' THEN 2 WHEN 'C' THEN 3 ELSE 4 END`;
|
|
const tierRows = await db
|
|
.select({
|
|
id: offer_micros.id,
|
|
macro_id: offer_micros.macro_id,
|
|
tier_letter: offer_micros.tier_letter,
|
|
public_name: offer_micros.public_name,
|
|
public_price: offer_micros.public_price,
|
|
})
|
|
.from(offer_micros)
|
|
.where(inArray(offer_micros.macro_id, macroIds))
|
|
.orderBy(asc(tierOrder));
|
|
|
|
const allTierIds = tierRows.map((t) => t.id);
|
|
|
|
// Compute servicesTotal per tier in batch
|
|
let totalsByTierId = new Map<string, string>();
|
|
if (allTierIds.length > 0) {
|
|
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, allTierIds))
|
|
.groupBy(offer_tier_services.tier_id);
|
|
|
|
totalsByTierId = new Map(totalRows.map((r) => [r.tier_id, r.servicesTotal]));
|
|
}
|
|
|
|
// Group tiers under each macro
|
|
const tiersByMacroId = new Map<string, OfferListTier[]>();
|
|
for (const t of tierRows) {
|
|
const list = tiersByMacroId.get(t.macro_id) ?? [];
|
|
list.push({
|
|
tier_letter: t.tier_letter,
|
|
public_name: t.public_name,
|
|
public_price: t.public_price,
|
|
servicesTotal: totalsByTierId.get(t.id) ?? "0",
|
|
});
|
|
tiersByMacroId.set(t.macro_id, list);
|
|
}
|
|
|
|
return macros.map((m) => ({ ...m, tiers: tiersByMacroId.get(m.id) ?? [] }));
|
|
}
|
|
|
|
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;
|
|
offerTags: string[];
|
|
}>;
|
|
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 with offer membership tags (entity_type = "services").
|
|
const allServices = await db
|
|
.select({
|
|
id: services.id,
|
|
name: services.name,
|
|
unit_price: services.unit_price,
|
|
category: services.category,
|
|
})
|
|
.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 })
|
|
.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). All four read from the centralized, persistent taxonomy pools
|
|
// (src/lib/taxonomy.ts), seeded from existing values on first access.
|
|
export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
|
|
const [categoria, ticket, tipo, obiettivo] = await Promise.all([
|
|
getPool("offer_categoria"),
|
|
getPool("offer_ticket"),
|
|
getPool("offer_tipo"),
|
|
getPool("offer_obiettivo"),
|
|
]);
|
|
return { categoria, ticket, tipo, obiettivo };
|
|
}
|