diff --git a/.planning/STATE.md b/.planning/STATE.md index 02fb926..f12db35 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,8 +4,8 @@ milestone: v2.5 milestone_name: Audit status: executing stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1 e le due rifiniture del 2026-08-20 in prod; C2 (TidyCal) bloccato sulle credenziali API." -last_updated: "2026-08-20T22:45:00.000Z" -last_activity: 2026-08-20 -- tab pagamenti (ordine, rinomina, override) e riordino task (1fa8e1a) +last_updated: "2026-08-21T00:20:00.000Z" +last_activity: 2026-08-21 -- portale cliente: stepper compatto/full-width, card offerta con override (0020) progress: total_phases: 4 completed_phases: 0 @@ -38,6 +38,7 @@ Phase 27 resta a metà — schema e fonti in prod, resto da scrivere. | C2 — TidyCal | ⛔ **[BLOCCANTE]** vedi sotto | | C3 — Alleggerire l'hub | ⏸️ senza perimetro | | Rifiniture — tassonomie, "In revisione", tab pagamenti, riordino task | ✅ in prod 2026-08-20 (migration `0019`) | +| Portale cliente — stepper compatto/full-width, card offerta | ✅ in prod 2026-08-21 (migration `0020`) | | D — Whop → audit | ⏸️ dipende dal motore v2.5 | Progress: [███░░░░░░░] 25% (v2.5) @@ -86,15 +87,14 @@ Log completo in `PROJECT.md`. Vive per il lavoro corrente: - **Il 100% dell'incassato è "Senza offerta"** — Caruso Speaker e Protocollo Estetico: 5.300 € senza offerte assegnate. Si sistema assegnandole dai rispettivi progetti. Il payload Elementor, intanto, non è ancora verificato sul campo: gestito in modo difensivo, serve un invio vero. - **Il copy del template v1 non ha fonte nel repo** — il prototipo Giojello non c'è: testi e gerarchia dei blocchi da recuperare prima di Phase 30. - **Audit, da vedere sul campo:** il caso "zero dati CrUX" (test 5) e quanto del 52% non verificabile da HTML statico recuperi Lighthouse (test 3). **Whitelist portale vuota per 3 clienti su 4** — si popola da `/admin/clients/`. -- **`.env.local` punta al DB di PRODUZIONE** (non allineato a Coolify per `ADMIN_PASSWORD`/`NEXTAUTH_SECRET`), e la porta 54321 non è pubblica: per provare in locale serve il tunnel SSH. -- **Ogni fase con schema**: migration applicata a prod **prima** del push del codice. +- **`.env.local` NON è allineato a Coolify**: `ADMIN_PASSWORD`, `NEXTAUTH_SECRET` **e la password del DB** sono stale, e l'host che scrive (`178.104.27.55:5432`) è chiuso — il DB vero è su `127.0.0.1:54321` dietro tunnel SSH. Rendere in locale contro i dati veri **oggi non si può** senza rigenerare quelle credenziali (2026-08-21). Resta valido il resto della procedura: **ogni fase con schema applica la migration a prod prima del push del codice**. - **Debito design (DEBT-01)** — ~40 file, ~450 occorrenze. Dettaglio in `STATUS.md`. ## Deferred Items — vedi `REQUIREMENTS.md` § Backlog e § Rinviati da v2.5. ## Session Continuity -Last session: 2026-08-19 -Stopped at: modifiche hub, blocchi A + B + C1 in produzione (commit `19ed377`, migration `0018` applicata prima del push). C2 fermo sulle credenziali TidyCal. -Next: (1) sbloccare TidyCal con token o documentazione; (2) confermare il payload Elementor con un invio vero; (3) impostare `LEAD_WEBHOOK_SECRET` su Coolify — finché manca la route risponde 403 a tutti; (4) poi riprendere v2.5 da `src/lib/audit/schema.ts` + `agents/`. +Last session: 2026-08-21 +Stopped at: portale cliente — stepper compatto e a tutta larghezza, card offerta senza accordion, "Valore dell'offerta" con override admin (migration `0020` applicata prima del push). **Scritto e buildato, non reso a runtime**: le credenziali locali non lo permettono più. +Next: (1) guardare il portale in prod e cliccare l'override in `/admin/projects/` → Offerte; (2) sbloccare TidyCal con token o documentazione; (3) `LEAD_WEBHOOK_SECRET` su Coolify — finché manca la route risponde 403 a tutti; (4) poi v2.5 da `src/lib/audit/schema.ts` + `agents/`. Resume file: None diff --git a/STATUS.md b/STATUS.md index b482f6c..029d55b 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,6 @@ # ClientHub (IAMCAVALLI) — Status -_Ultimo aggiornamento: 2026-08-20_ +_Ultimo aggiornamento: 2026-08-21_ Questo è **l'unico documento narrativo** del progetto: a che punto siamo, cosa manca, cosa abbiamo imparato. `.planning/STATE.md` è il digest che leggono i comandi @@ -94,6 +94,33 @@ interazioni: la matita di rinomina con il dialog di conferma, e il drag di un ta di proposito: **l'unico DB raggiungibile in locale è la produzione**, e una prova di rinomina riscriverebbe titoli di fasi veri. +**In produzione dal 2026-08-21** (migration `0020`) — portale cliente: + +- **Barra di avanzamento compatta e a tutta larghezza.** Rubava ~147px di altezza sopra + la piega per dire una cosa sola. Ora "Step N" e lo stato stanno sulla stessa riga, il + titolo della fase sotto — due righe invece di tre — e il padding scende da `py-8` a + `py-3`: ~88px. Il mock sta in `design-reference/Client-Portal-Progress-Bar`, scritto in + `slate-*` grezzi come tutti i mock: tradotto a token, non copiato. +- **Card offerta senza accordion.** Via "Cosa è compreso": la lista dei servizi non serve + al cliente e non viene più nemmeno **proiettata** — `client-view.ts` ora legge dai + servizi i soli prezzi. `OffersSection` ha perso il suo unico `useState` e con esso il + `"use client"`. +- **"Valore incluso" → "Valore dell'offerta", con override.** Era sempre la somma dei + prezzi di catalogo dei servizi del tier: un artefatto che si muove quando si muove il + listino. In produzione produceva **€20.250 su offerte vendute a 7.000 e 5.500** — al + cliente sembrava uno sconto del 70%. Ora `project_offers.offer_value_override` vince + sulla somma; NULL torna al calcolo, `0` nasconde la riga. Si gestisce dal tab Offerte + del progetto, che mostra accanto la cifra calcolata per far vedere cosa si sta + sostituendo. La somma vive in `src/lib/offer-value.ts`, letta **sia** dal portale sia + dall'admin: duplicarla avrebbe fatto divergere le due viste. +- **"Prezzo finale" → "Investimento finale"** (solo una tantum; il ricorrente resta + "Canone mensile"). + +⚠️ **Scritto e buildato, non reso a runtime.** Build verde, migration `0020` applicata e +verificata in prod, override tutti NULL quindi comportamento invariato finché non si +tocca. La prova a runtime **non è stata possibile**: la password del DB in `.env.local` è +ormai stale e l'estrazione di quella viva è stata bloccata. Va guardato in prod. + Cosa manca, e perché: - **[BLOCCANTE] TidyCal.** Non ha webhook — è scritto nella loro FAQ, la strada diff --git a/design-reference/DESIGN-SYSTEM.md b/design-reference/DESIGN-SYSTEM.md index c411dee..0de56a8 100644 --- a/design-reference/DESIGN-SYSTEM.md +++ b/design-reference/DESIGN-SYSTEM.md @@ -343,6 +343,10 @@ The `/client/[token]` portal was migrated to the same "Quiet Luxury" tokens - Sidebar cards (`OffersSection`, `PaymentStatus`, `DocumentsSection`, `NotesSection`, `TranscriptsSection`) all follow `rounded-xl border-border-light bg-card shadow-card`. +- **Offer card** (`OffersSection`): due sole righe di importo — «Valore + dell'offerta» (override admin o somma dei servizi; nascosta a 0) e «Canone + mensile» / «Investimento finale». Niente accordion «Cosa è compreso»: la lista + servizi non arriva più al client, e il componente non è più `"use client"`. - `ClientDashboard` takes an `embedded` prop: when rendered inside the multi-project tabs wrapper (`page.tsx`), it drops its own header/footer/canvas so the page-level chrome isn't duplicated. diff --git a/src/app/admin/projects/project-actions.ts b/src/app/admin/projects/project-actions.ts index 1cbb6bc..0d0b1d7 100644 --- a/src/app/admin/projects/project-actions.ts +++ b/src/app/admin/projects/project-actions.ts @@ -311,6 +311,43 @@ export async function updateProjectOfferTotal( revalidatePath(`/admin/projects/${projectId}`); } +/** + * Override manuale del "Valore dell'offerta" mostrato nel portale cliente + * (migration 0020). + * + * Campo vuoto → NULL → il portale torna alla somma calcolata dai prezzi dei + * servizi del tier. Zero è un valore legittimo, non un "vuoto": nasconde la + * riga al cliente, esattamente come fa già una somma calcolata pari a 0. + */ +export async function updateOfferValueOverride( + projectOfferId: string, + projectId: string, + rawValue: string +): Promise<{ ok: true } | { ok: false; error: string }> { + await requireAdmin(); + + const trimmed = rawValue.trim(); + let override: string | null = null; + + if (trimmed !== "") { + // L'input admin è un , quindi punto decimale; la + // virgola è comunque tollerata per chi incolla un importo in formato it-IT. + const amount = parseFloat(trimmed.replace(",", ".")); + if (!Number.isFinite(amount) || amount < 0) { + return { ok: false, error: "Importo non valido" }; + } + override = amount.toFixed(2); + } + + await db + .update(project_offers) + .set({ offer_value_override: override }) + .where(eq(project_offers.id, projectOfferId)); + + revalidatePath(`/admin/projects/${projectId}`); + return { ok: true }; +} + // ── CICLO DI VITA DELL'OFFERTA ASSEGNATA (v2.4 Phase 13) ───────────────────── const lifecycleSchema = z.object({ diff --git a/src/components/admin/tabs/OffersTab.tsx b/src/components/admin/tabs/OffersTab.tsx index ea7786f..5f36da1 100644 --- a/src/components/admin/tabs/OffersTab.tsx +++ b/src/components/admin/tabs/OffersTab.tsx @@ -7,6 +7,7 @@ import { assignOfferToProject, removeProjectOffer, updateProjectOfferTotal, + updateOfferValueOverride, setProjectOfferLifecycle, } from "@/app/admin/projects/project-actions"; import type { ProjectOfferWithMicro } from "@/lib/admin-queries"; @@ -111,6 +112,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT // l'eliminazione in ClientActions). const [ceaseArmed, setCeaseArmed] = useState(null); const [lifecycleError, setLifecycleError] = useState(null); + const [overrideError, setOverrideError] = useState(null); // Group tiers by offer (macro), preserving query order. const offers = useMemo(() => { @@ -174,6 +176,18 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT }); } + function handleValueOverride(offerId: string, value: string) { + setOverrideError(null); + startTransition(async () => { + const res = await updateOfferValueOverride(offerId, projectId, value); + if (!res.ok) { + setOverrideError(res.error); + return; + } + router.refresh(); + }); + } + function handleLifecycle( offerId: string, input: { status: string; end_date?: string | null } @@ -198,6 +212,9 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT {lifecycleError && (

{lifecycleError}

)} + {overrideError && ( +

{overrideError}

+ )} {projectOffers.length === 0 ? (

Nessuna offerta assegnata a questo progetto.

) : ( @@ -243,6 +260,33 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT /> + {/* Override del "Valore dell'offerta" mostrato nel portale + (0020). Vuoto = il portale calcola la somma dei prezzi + dei servizi del tier; 0 = riga nascosta al cliente. */} +
+ + { + const val = e.currentTarget.value.trim(); + if (val !== (offer.offer_value_override ?? "")) { + handleValueOverride(offer.id, val); + } + }} + className="w-28 border border-[#e5e7eb] rounded-md px-2 py-1 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15 disabled:opacity-50" + /> + + {offer.offer_value_override == null + ? `calcolato dai servizi (€${offer.computed_offer_value})` + : `sostituisce €${offer.computed_offer_value} · svuota per tornare al calcolo`} + +
+ {/* Ciclo di vita — solo per i ricorrenti: per una una tantum "sospendere" o "cessare" non significa nulla, la durata è già data da duration_months. */} diff --git a/src/components/client/MilestoneStepper.tsx b/src/components/client/MilestoneStepper.tsx index 1c34c5d..2454c3f 100644 --- a/src/components/client/MilestoneStepper.tsx +++ b/src/components/client/MilestoneStepper.tsx @@ -34,7 +34,9 @@ export function MilestoneStepper({ return (
-
+ {/* Nessun max-width: la barra occupa tutta la larghezza della viewport, + a differenza del resto della dashboard che è incolonnato a 1400px. */} +
{/* Linea di connessione + riempimento dinamico (centrata sui cerchi) */}
0; - const hasServices = offer.services.length > 0; + const value = parseFloat(offer.offer_value); + const hasValue = value > 0; const isRetainer = offer.offer_type === "retainer"; const isSuspended = offer.status === "sospeso"; @@ -67,11 +54,12 @@ function OfferCard({ offer }: { offer: ActiveOffer }) { )}
- {/* "Valore incluso" — hidden when 0 */} - {hasPrice && ( + {/* "Valore dell'offerta" — override admin o somma dei servizi. + Zero significa "non mostrarlo". */} + {hasValue && (
- Valore incluso - €{price.toFixed(2)} + Valore dell'offerta + €{value.toFixed(2)}
)} @@ -79,7 +67,7 @@ function OfferCard({ offer }: { offer: ActiveOffer }) { {offer.accepted_total && (
- {isRetainer ? "Canone mensile" : "Prezzo finale"} + {isRetainer ? "Canone mensile" : "Investimento finale"} €{parseFloat(offer.accepted_total).toFixed(2)} @@ -89,36 +77,6 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
- - {/* Accordion "Cosa è compreso" — only when there are services */} - {hasServices && ( -
- - - {open && ( -
    - {offer.services.map((svc, i) => ( -
  • - {svc.name} - {svc.description && ( - {svc.description} - )} -
  • - ))} -
- )} -
- )}
); } diff --git a/src/db/migrations/0020_offer_value_override.sql b/src/db/migrations/0020_offer_value_override.sql new file mode 100644 index 0000000..be22834 --- /dev/null +++ b/src/db/migrations/0020_offer_value_override.sql @@ -0,0 +1,16 @@ +-- Additive: let the admin override the "Valore dell'offerta" shown in the client portal. +-- +-- Why: the figure on the portal's offer card was always computed — the sum of the +-- unit_price of every service attached to the tier (or the legacy offer_services +-- fallback). That sum is a catalog artifact: it moves whenever a price in the +-- catalog moves, and for offers assembled out of odd service mixes it lands on a +-- number that means nothing to the client. It had no manual escape hatch. +-- +-- NULL keeps the computed sum (current behaviour for every existing row). +-- A value wins over it. Zero is meaningful: it hides the row entirely, the same +-- way a computed sum of 0 already does. +-- +-- No drops, no truncates, no deletes. + +ALTER TABLE project_offers + ADD COLUMN IF NOT EXISTS offer_value_override numeric(10, 2); diff --git a/src/db/schema.ts b/src/db/schema.ts index 35c5f85..9a3ac4b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -461,6 +461,10 @@ export const project_offers = pgTable("project_offers", { status: text("status").notNull().default("attivo"), // NULL = continuativo (semantica implicita prima della 0016). end_date: timestamp("end_date", { withTimezone: true }), + // Override manuale del "Valore dell'offerta" mostrato nel portale cliente + // (migration 0020). NULL = si usa la somma calcolata dai prezzi dei servizi; + // 0 = riga nascosta al cliente. + offer_value_override: numeric("offer_value_override", { precision: 10, scale: 2 }), created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index ccdb9e7..70b70ff 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -28,6 +28,7 @@ import { import { eq, ne, inArray, asc, desc, isNull, isNotNull, sql, and } from "drizzle-orm"; import { getPool } from "@/lib/taxonomy"; import { LEAD_STAGES } from "@/lib/lead-validators"; +import { getComputedOfferValues } from "@/lib/offer-value"; import type { Client, Project, @@ -245,6 +246,10 @@ export type ProjectOfferWithMicro = { // Ciclo di vita (v2.4 Phase 13): attivo | sospeso | cessato. status: string; end_date: Date | null; + /** Override del "Valore dell'offerta" nel portale (0020). null = somma calcolata. */ + offer_value_override: string | null; + /** Somma dei prezzi dei servizi del tier — la cifra che l'override sostituisce. */ + computed_offer_value: string; created_at: Date; }; @@ -715,6 +720,7 @@ export async function getProjectFullDetail(id: string): Promise o.micro_id) + ); + const projectOffers: ProjectOfferWithMicro[] = projectOffersRows.map((o) => ({ + ...o, + computed_offer_value: computedOfferValues.get(o.micro_id) ?? "0", + })); + // Le offerte cessate non entrano nel totale: serve come default del piano // pagamenti, e un abbonamento chiuso non è denaro da incassare. - const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce( + const offersAcceptedTotal = projectOffers.reduce( (sum, o) => o.status === "cessato" ? sum @@ -814,7 +831,7 @@ export async function getProjectFullDetail(id: string): Promise; }>; transcripts: Array<{ id: string; @@ -134,12 +134,14 @@ export interface ProjectView { public_name: string; // offer_micros.public_name — NEVER internal_name offer_name: string; // offer_macros.public_name — macro-level name shown to client offer_type: string; // una_tantum | retainer - cumulative_price: string; // sum of assigned services.unit_price (new) or offer_services.price (legacy) + // "Valore dell'offerta": project_offers.offer_value_override quando è + // valorizzato, altrimenti la somma di services.unit_price (nuovo catalogo) + // o offer_services.price (legacy). "0" = da non mostrare. + offer_value: string; accepted_total: string | null; status: string; // attivo | sospeso — le cessate non arrivano al cliente start_date: string; // ISO end_date: string | null; // ISO — null = continuativo - services: Array<{ name: string; description: string | null }>; // included services list }>; transcripts: Array<{ id: string; @@ -353,6 +355,7 @@ export async function getProjectView(projectId: string): Promise o.micro_id); - - // New-style: services from offer_tier_services joined to unified services catalog - const tierServiceRows = microIds.length === 0 ? [] : await db - .select({ - tier_id: offer_tier_services.tier_id, - name: services.name, - description: services.description, - unit_price: services.unit_price, - }) - .from(offer_tier_services) - .innerJoin(services, eq(offer_tier_services.service_id, services.id)) - .where(inArray(offer_tier_services.tier_id, microIds)); - - // Group by tier_id for quick lookup - const tierServicesMap = new Map>(); - for (const row of tierServiceRows) { - const existing = tierServicesMap.get(row.tier_id) ?? []; - existing.push({ name: row.name, description: row.description, unit_price: row.unit_price }); - tierServicesMap.set(row.tier_id, existing); - } - - // Determine which micros have NO entries in the new table → need legacy fallback - const microsNeedingLegacy = microIds.filter((id) => !tierServicesMap.has(id)); - - // Legacy fallback: offer_micro_services → offer_services (for old offers with no tier_services rows) - const legacyPriceRows = microsNeedingLegacy.length === 0 ? [] : await db - .select({ - micro_id: offer_micro_services.micro_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, microsNeedingLegacy)); - - const legacyServicesMap = new Map>(); - for (const row of legacyPriceRows) { - const existing = legacyServicesMap.get(row.micro_id) ?? []; - existing.push({ name: row.name, description: null, price: row.price }); - legacyServicesMap.set(row.micro_id, existing); - } + // Somma dei prezzi dei servizi per micro — è il fallback del "Valore + // dell'offerta" quando l'admin non ha messo un override. Stesso helper che + // usa l'admin per mostrare quale cifra sta sostituendo. + const computedValues = await getComputedOfferValues( + projectOfferRows.map((o) => o.micro_id) + ); const activeOffers = projectOfferRows.map((o) => { - const newStyleServices = tierServicesMap.get(o.micro_id); - const legacyServices = legacyServicesMap.get(o.micro_id); - - let cumulative_price: string; - let servicesList: Array<{ name: string; description: string | null }>; - - if (newStyleServices && newStyleServices.length > 0) { - // Use new catalog prices - const total = newStyleServices.reduce((sum, s) => sum + parseFloat(s.unit_price ?? "0"), 0); - cumulative_price = total.toFixed(2); - servicesList = newStyleServices.map((s) => ({ name: s.name, description: s.description })); - } else if (legacyServices && legacyServices.length > 0) { - // Legacy fallback - const total = legacyServices.reduce((sum, s) => sum + parseFloat(s.price ?? "0"), 0); - cumulative_price = total.toFixed(2); - servicesList = legacyServices.map((s) => ({ name: s.name, description: null })); - } else { - cumulative_price = "0"; - servicesList = []; - } + // L'override dell'admin vince sulla somma calcolata. Lo zero è un valore + // vero — nasconde la riga — quindi si testa la presenza, non la verità. + const offer_value = + o.offer_value_override != null + ? parseFloat(String(o.offer_value_override)).toFixed(2) + : computedValues.get(o.micro_id) ?? "0"; return { id: o.id, public_name: o.public_name, offer_name: o.offer_name, offer_type: o.offer_type, - cumulative_price, + offer_value, accepted_total: o.accepted_total ? String(o.accepted_total) : null, status: o.status, start_date: o.start_date.toISOString(), end_date: o.end_date ? o.end_date.toISOString() : null, - services: servicesList, }; }); diff --git a/src/lib/offer-value.ts b/src/lib/offer-value.ts new file mode 100644 index 0000000..638b640 --- /dev/null +++ b/src/lib/offer-value.ts @@ -0,0 +1,78 @@ +import { inArray, eq } from "drizzle-orm"; +import { db } from "@/db"; +import { + offer_tier_services, + services, + offer_micro_services, + offer_services, +} from "@/db/schema"; + +/** + * "Valore dell'offerta" calcolato: la somma dei prezzi dei servizi collegati a + * un tier (micro). + * + * Due sorgenti, nell'ordine: `offer_tier_services` → `services` (catalogo + * Phase 12) e, per i tier che non hanno righe lì, il legacy + * `offer_micro_services` → `offer_services`. + * + * Vive in un modulo neutro perché lo leggono **due** lati: il portale cliente + * (`client-view.ts`, dove è il fallback quando l'admin non ha messo un + * override) e l'area admin (`admin-queries.ts`, dove serve a mostrare quale + * cifra si sta sostituendo). Duplicarlo significherebbe farli divergere. + * + * Legge solo prezzi: nomi e descrizioni dei servizi non servono a nessuno dei + * due chiamanti, e client-view non deve proiettare più di quanto renderizza. + * + * @returns Map micro_id → totale con 2 decimali. I micro senza servizi non + * compaiono nella mappa (il chiamante decide se è "0" o "assente"). + */ +export async function getComputedOfferValues( + microIds: string[] +): Promise> { + const out = new Map(); + if (microIds.length === 0) return out; + + const sum = (prices: Array) => + prices.reduce((acc, p) => acc + (parseFloat(p ?? "0") || 0), 0).toFixed(2); + + // Sorgente primaria: catalogo unificato. + const tierRows = await db + .select({ + tier_id: offer_tier_services.tier_id, + unit_price: services.unit_price, + }) + .from(offer_tier_services) + .innerJoin(services, eq(offer_tier_services.service_id, services.id)) + .where(inArray(offer_tier_services.tier_id, microIds)); + + const byTier = new Map>(); + for (const row of tierRows) { + const existing = byTier.get(row.tier_id) ?? []; + existing.push(row.unit_price); + byTier.set(row.tier_id, existing); + } + for (const [tierId, prices] of byTier) out.set(tierId, sum(prices)); + + // Fallback legacy solo per i micro rimasti scoperti. + const needLegacy = microIds.filter((id) => !out.has(id)); + if (needLegacy.length === 0) return out; + + const legacyRows = await db + .select({ + micro_id: offer_micro_services.micro_id, + 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, needLegacy)); + + const byMicro = new Map>(); + for (const row of legacyRows) { + const existing = byMicro.get(row.micro_id) ?? []; + existing.push(row.price); + byMicro.set(row.micro_id, existing); + } + for (const [microId, prices] of byMicro) out.set(microId, sum(prices)); + + return out; +}