feat: tier names + per-tier prices on offer cards, unified admin UI

- 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>
This commit is contained in:
2026-06-26 16:58:18 +02:00
parent 9abe1fe4bb
commit add2176a6b
12 changed files with 281 additions and 91 deletions
+60 -3
View File
@@ -105,16 +105,23 @@ export async function getMicroAssignedServiceIds(microId: string): Promise<strin
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 rows = await db
const macros = await db
.select({
id: offer_macros.id,
internal_name: offer_macros.internal_name,
@@ -124,7 +131,57 @@ export async function getOfferListCards(): Promise<OfferListCard[]> {
})
.from(offer_macros)
.orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at));
return rows;
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 = {