feat(portale): barra full-width, card offerta senza accordion, valore override

Barra di avanzamento a tutta larghezza (via il max-w-[1200px]).

Card offerta: rimosso l'accordion "Cosa è compreso". La lista servizi non
arriva più al client — client-view.ts legge dai servizi i soli prezzi — e
OffersSection perde il suo unico useState, quindi anche il "use client".

"Valore incluso" diventa "Valore dell'offerta" e accetta un override manuale
(migration 0020, project_offers.offer_value_override). Prima era sempre la
somma dei prezzi di catalogo del tier: in produzione mostrava €20.250 su
offerte vendute a 7.000 e 5.500. NULL torna al calcolo, 0 nasconde la riga.
Si gestisce dal tab Offerte del progetto, che affianca la cifra calcolata
per far vedere cosa si sta sostituendo.

La somma calcolata vive ora in src/lib/offer-value.ts, letta sia dal portale
sia dall'admin: duplicarla avrebbe fatto divergere le due viste.

"Prezzo finale" diventa "Investimento finale" (il ricorrente resta "Canone
mensile").

Migration 0020 applicata a prod prima del push. Override tutti NULL, quindi
il comportamento resta invariato finché non se ne imposta uno.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 07:54:55 +02:00
parent 7e58031528
commit 44be190631
12 changed files with 270 additions and 133 deletions
+7 -7
View File
@@ -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/<id>`.
- **`.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/<id>` → 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
+28 -1
View File
@@ -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
+4
View File
@@ -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.
+37
View File
@@ -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 <input type="number">, 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({
+44
View File
@@ -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<string | null>(null);
const [lifecycleError, setLifecycleError] = useState<string | null>(null);
const [overrideError, setOverrideError] = useState<string | null>(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 && (
<p className="mb-3 text-xs text-destructive">{lifecycleError}</p>
)}
{overrideError && (
<p className="mb-3 text-xs text-destructive">{overrideError}</p>
)}
{projectOffers.length === 0 ? (
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
) : (
@@ -243,6 +260,33 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
/>
</div>
{/* 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. */}
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1">
<label className="text-xs text-[#71717a]">Valore dell&apos;offerta </label>
<input
type="number"
step="0.01"
min="0"
defaultValue={offer.offer_value_override ?? ""}
placeholder={offer.computed_offer_value}
disabled={isPending}
onBlur={(e) => {
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"
/>
<span className="text-[11px] text-[#71717a]">
{offer.offer_value_override == null
? `calcolato dai servizi (€${offer.computed_offer_value})`
: `sostituisce €${offer.computed_offer_value} · svuota per tornare al calcolo`}
</span>
</div>
{/* Ciclo di vita — solo per i ricorrenti: per una una tantum
"sospendere" o "cessare" non significa nulla, la durata è
già data da duration_months. */}
+3 -1
View File
@@ -34,7 +34,9 @@ export function MilestoneStepper({
return (
<div className="bg-card border-b border-border-light px-6 py-3">
<div className="relative mx-auto max-w-[1200px]">
{/* Nessun max-width: la barra occupa tutta la larghezza della viewport,
a differenza del resto della dashboard che è incolonnato a 1400px. */}
<div className="relative w-full">
{/* Linea di connessione + riempimento dinamico (centrata sui cerchi) */}
<div className="absolute left-[5%] right-[5%] top-[14px] z-0 h-1 rounded-full bg-muted">
<div
+9 -51
View File
@@ -1,24 +1,13 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
interface IncludedService {
name: string;
description: string | null;
}
interface ActiveOffer {
id: string;
public_name: string; // micro offer public name — NOT shown to client (T-05-10)
offer_name: string; // macro public_name — shown as heading
offer_type: string; // una_tantum | retainer
cumulative_price: string; // sum of service prices
offer_value: string; // override admin, o somma calcolata dei servizi
accepted_total: string | null;
status: string; // attivo | sospeso — le cessate non arrivano qui
start_date: string; // ISO
end_date: string | null; // ISO — null = continuativo
services: IncludedService[];
}
/** "15 giugno 2026" — le date nel portale sono in italiano esteso. */
@@ -35,10 +24,8 @@ interface OffersSectionProps {
}
function OfferCard({ offer }: { offer: ActiveOffer }) {
const [open, setOpen] = useState(false);
const price = parseFloat(offer.cumulative_price);
const hasPrice = price > 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 }) {
)}
<div className="mt-2 space-y-1.5">
{/* "Valore incluso" — hidden when 0 */}
{hasPrice && (
{/* "Valore dell'offerta" — override admin o somma dei servizi.
Zero significa "non mostrarlo". */}
{hasValue && (
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Valore incluso</span>
<span className="font-mono text-foreground">{price.toFixed(2)}</span>
<span className="text-muted-foreground">Valore dell&apos;offerta</span>
<span className="font-mono text-foreground">{value.toFixed(2)}</span>
</div>
)}
@@ -79,7 +67,7 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
{offer.accepted_total && (
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-foreground">
{isRetainer ? "Canone mensile" : "Prezzo finale"}
{isRetainer ? "Canone mensile" : "Investimento finale"}
</span>
<span className="text-sm font-bold text-primary">
{parseFloat(offer.accepted_total).toFixed(2)}
@@ -89,36 +77,6 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
</div>
</div>
</div>
{/* Accordion "Cosa è compreso" — only when there are services */}
{hasServices && (
<div className="border-t border-border-light">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-5 py-2.5 text-xs font-semibold text-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
aria-expanded={open}
>
<span>Cosa è compreso</span>
<ChevronDown
className={`w-3.5 h-3.5 text-muted-foreground transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<ul className="px-5 pb-3 space-y-2">
{offer.services.map((svc, i) => (
<li key={i} className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">{svc.name}</span>
{svc.description && (
<span className="text-[11px] text-muted-foreground leading-snug">{svc.description}</span>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
@@ -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);
+4
View File
@@ -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(),
});
+19 -2
View File
@@ -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<ProjectFullDetai
accepted_total: project_offers.accepted_total,
status: project_offers.status,
end_date: project_offers.end_date,
offer_value_override: project_offers.offer_value_override,
created_at: project_offers.created_at,
})
.from(project_offers)
@@ -789,9 +795,20 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
return true;
});
// "Valore dell'offerta" calcolato per ogni tier assegnato: è la cifra che il
// portale mostra quando offer_value_override è NULL, quindi l'admin deve
// vederla per sapere cosa sta sostituendo scrivendo nel campo override.
const computedOfferValues = await getComputedOfferValues(
projectOffersRows.map((o) => 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<ProjectFullDetai
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
taskSeconds,
phaseSeconds,
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
projectOffers,
offersAcceptedTotal,
availableMicros: dedupedMicros,
transcripts: transcriptsRows,
+21 -71
View File
@@ -1,7 +1,8 @@
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_macros, clientTranscripts } from "@/db/schema";
import type { TaskStatus } from "@/lib/task-status";
import { getComputedOfferValues } from "@/lib/offer-value";
/**
* ClientView: Legacy shape used by ClientDashboard component.
@@ -58,12 +59,11 @@ export interface ClientView {
public_name: string;
offer_name: string; // offer_macros.public_name — macro-level name shown to client
offer_type: string; // una_tantum | retainer
cumulative_price: string;
offer_value: string; // override admin, o somma calcolata dei servizi
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 }>;
}>;
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<ProjectView | n
status: project_offers.status, // attivo | sospeso
start_date: project_offers.start_date,
end_date: project_offers.end_date,
offer_value_override: project_offers.offer_value_override,
micro_id: project_offers.micro_id,
})
.from(project_offers)
@@ -365,84 +368,31 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
)
);
// Cumulative price + service list per micro.
// Primary: offer_tier_services → services (Phase 12 catalog). Fallback: legacy offer_micro_services → offer_services.
const microIds = projectOfferRows.map((o) => 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<string, Array<{ name: string; description: string | null; unit_price: string }>>();
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<string, Array<{ name: string; description: string | null; price: string }>>();
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,
};
});
+78
View File
@@ -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 , 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<Map<string, string>> {
const out = new Map<string, string>();
if (microIds.length === 0) return out;
const sum = (prices: Array<string | null>) =>
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<string, Array<string | null>>();
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<string, Array<string | null>>();
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;
}