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:
+7
-7
@@ -4,8 +4,8 @@ milestone: v2.5
|
|||||||
milestone_name: Audit
|
milestone_name: Audit
|
||||||
status: executing
|
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."
|
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_updated: "2026-08-21T00:20:00.000Z"
|
||||||
last_activity: 2026-08-20 -- tab pagamenti (ordine, rinomina, override) e riordino task (1fa8e1a)
|
last_activity: 2026-08-21 -- portale cliente: stepper compatto/full-width, card offerta con override (0020)
|
||||||
progress:
|
progress:
|
||||||
total_phases: 4
|
total_phases: 4
|
||||||
completed_phases: 0
|
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 |
|
| C2 — TidyCal | ⛔ **[BLOCCANTE]** vedi sotto |
|
||||||
| C3 — Alleggerire l'hub | ⏸️ senza perimetro |
|
| C3 — Alleggerire l'hub | ⏸️ senza perimetro |
|
||||||
| Rifiniture — tassonomie, "In revisione", tab pagamenti, riordino task | ✅ in prod 2026-08-20 (migration `0019`) |
|
| 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 |
|
| D — Whop → audit | ⏸️ dipende dal motore v2.5 |
|
||||||
|
|
||||||
Progress: [███░░░░░░░] 25% (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 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.
|
- **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>`.
|
- **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.
|
- **`.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**.
|
||||||
- **Ogni fase con schema**: migration applicata a prod **prima** del push del codice.
|
|
||||||
- **Debito design (DEBT-01)** — ~40 file, ~450 occorrenze. Dettaglio in `STATUS.md`.
|
- **Debito design (DEBT-01)** — ~40 file, ~450 occorrenze. Dettaglio in `STATUS.md`.
|
||||||
|
|
||||||
## Deferred Items — vedi `REQUIREMENTS.md` § Backlog e § Rinviati da v2.5.
|
## Deferred Items — vedi `REQUIREMENTS.md` § Backlog e § Rinviati da v2.5.
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-08-19
|
Last session: 2026-08-21
|
||||||
Stopped at: modifiche hub, blocchi A + B + C1 in produzione (commit `19ed377`, migration `0018` applicata prima del push). C2 fermo sulle credenziali TidyCal.
|
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) 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/`.
|
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
|
Resume file: None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ClientHub (IAMCAVALLI) — Status
|
# 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,
|
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
|
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
|
di proposito: **l'unico DB raggiungibile in locale è la produzione**, e una prova di
|
||||||
rinomina riscriverebbe titoli di fasi veri.
|
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é:
|
Cosa manca, e perché:
|
||||||
|
|
||||||
- **[BLOCCANTE] TidyCal.** Non ha webhook — è scritto nella loro FAQ, la strada
|
- **[BLOCCANTE] TidyCal.** Non ha webhook — è scritto nella loro FAQ, la strada
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ The `/client/[token]` portal was migrated to the same "Quiet Luxury" tokens
|
|||||||
- Sidebar cards (`OffersSection`, `PaymentStatus`, `DocumentsSection`,
|
- Sidebar cards (`OffersSection`, `PaymentStatus`, `DocumentsSection`,
|
||||||
`NotesSection`, `TranscriptsSection`) all follow `rounded-xl border-border-light
|
`NotesSection`, `TranscriptsSection`) all follow `rounded-xl border-border-light
|
||||||
bg-card shadow-card`.
|
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
|
- `ClientDashboard` takes an `embedded` prop: when rendered inside the
|
||||||
multi-project tabs wrapper (`page.tsx`), it drops its own header/footer/canvas
|
multi-project tabs wrapper (`page.tsx`), it drops its own header/footer/canvas
|
||||||
so the page-level chrome isn't duplicated.
|
so the page-level chrome isn't duplicated.
|
||||||
|
|||||||
@@ -311,6 +311,43 @@ export async function updateProjectOfferTotal(
|
|||||||
revalidatePath(`/admin/projects/${projectId}`);
|
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) ─────────────────────
|
// ── CICLO DI VITA DELL'OFFERTA ASSEGNATA (v2.4 Phase 13) ─────────────────────
|
||||||
|
|
||||||
const lifecycleSchema = z.object({
|
const lifecycleSchema = z.object({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
assignOfferToProject,
|
assignOfferToProject,
|
||||||
removeProjectOffer,
|
removeProjectOffer,
|
||||||
updateProjectOfferTotal,
|
updateProjectOfferTotal,
|
||||||
|
updateOfferValueOverride,
|
||||||
setProjectOfferLifecycle,
|
setProjectOfferLifecycle,
|
||||||
} from "@/app/admin/projects/project-actions";
|
} from "@/app/admin/projects/project-actions";
|
||||||
import type { ProjectOfferWithMicro } from "@/lib/admin-queries";
|
import type { ProjectOfferWithMicro } from "@/lib/admin-queries";
|
||||||
@@ -111,6 +112,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
|||||||
// l'eliminazione in ClientActions).
|
// l'eliminazione in ClientActions).
|
||||||
const [ceaseArmed, setCeaseArmed] = useState<string | null>(null);
|
const [ceaseArmed, setCeaseArmed] = useState<string | null>(null);
|
||||||
const [lifecycleError, setLifecycleError] = 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.
|
// Group tiers by offer (macro), preserving query order.
|
||||||
const offers = useMemo(() => {
|
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(
|
function handleLifecycle(
|
||||||
offerId: string,
|
offerId: string,
|
||||||
input: { status: string; end_date?: string | null }
|
input: { status: string; end_date?: string | null }
|
||||||
@@ -198,6 +212,9 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
|||||||
{lifecycleError && (
|
{lifecycleError && (
|
||||||
<p className="mb-3 text-xs text-destructive">{lifecycleError}</p>
|
<p className="mb-3 text-xs text-destructive">{lifecycleError}</p>
|
||||||
)}
|
)}
|
||||||
|
{overrideError && (
|
||||||
|
<p className="mb-3 text-xs text-destructive">{overrideError}</p>
|
||||||
|
)}
|
||||||
{projectOffers.length === 0 ? (
|
{projectOffers.length === 0 ? (
|
||||||
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
|
<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>
|
</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'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
|
{/* Ciclo di vita — solo per i ricorrenti: per una una tantum
|
||||||
"sospendere" o "cessare" non significa nulla, la durata è
|
"sospendere" o "cessare" non significa nulla, la durata è
|
||||||
già data da duration_months. */}
|
già data da duration_months. */}
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ export function MilestoneStepper({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-card border-b border-border-light px-6 py-3">
|
<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) */}
|
{/* 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 className="absolute left-[5%] right-[5%] top-[14px] z-0 h-1 rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ChevronDown } from "lucide-react";
|
|
||||||
|
|
||||||
interface IncludedService {
|
|
||||||
name: string;
|
|
||||||
description: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActiveOffer {
|
interface ActiveOffer {
|
||||||
id: string;
|
id: string;
|
||||||
public_name: string; // micro offer public name — NOT shown to client (T-05-10)
|
public_name: string; // micro offer public name — NOT shown to client (T-05-10)
|
||||||
offer_name: string; // macro public_name — shown as heading
|
offer_name: string; // macro public_name — shown as heading
|
||||||
offer_type: string; // una_tantum | retainer
|
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;
|
accepted_total: string | null;
|
||||||
status: string; // attivo | sospeso — le cessate non arrivano qui
|
status: string; // attivo | sospeso — le cessate non arrivano qui
|
||||||
start_date: string; // ISO
|
start_date: string; // ISO
|
||||||
end_date: string | null; // ISO — null = continuativo
|
end_date: string | null; // ISO — null = continuativo
|
||||||
services: IncludedService[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "15 giugno 2026" — le date nel portale sono in italiano esteso. */
|
/** "15 giugno 2026" — le date nel portale sono in italiano esteso. */
|
||||||
@@ -35,10 +24,8 @@ interface OffersSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OfferCard({ offer }: { offer: ActiveOffer }) {
|
function OfferCard({ offer }: { offer: ActiveOffer }) {
|
||||||
const [open, setOpen] = useState(false);
|
const value = parseFloat(offer.offer_value);
|
||||||
const price = parseFloat(offer.cumulative_price);
|
const hasValue = value > 0;
|
||||||
const hasPrice = price > 0;
|
|
||||||
const hasServices = offer.services.length > 0;
|
|
||||||
const isRetainer = offer.offer_type === "retainer";
|
const isRetainer = offer.offer_type === "retainer";
|
||||||
const isSuspended = offer.status === "sospeso";
|
const isSuspended = offer.status === "sospeso";
|
||||||
|
|
||||||
@@ -67,11 +54,12 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-2 space-y-1.5">
|
<div className="mt-2 space-y-1.5">
|
||||||
{/* "Valore incluso" — hidden when 0 */}
|
{/* "Valore dell'offerta" — override admin o somma dei servizi.
|
||||||
{hasPrice && (
|
Zero significa "non mostrarlo". */}
|
||||||
|
{hasValue && (
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="text-muted-foreground">Valore incluso</span>
|
<span className="text-muted-foreground">Valore dell'offerta</span>
|
||||||
<span className="font-mono text-foreground">€{price.toFixed(2)}</span>
|
<span className="font-mono text-foreground">€{value.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -79,7 +67,7 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
|
|||||||
{offer.accepted_total && (
|
{offer.accepted_total && (
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{isRetainer ? "Canone mensile" : "Prezzo finale"}
|
{isRetainer ? "Canone mensile" : "Investimento finale"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-bold text-primary">
|
<span className="text-sm font-bold text-primary">
|
||||||
€{parseFloat(offer.accepted_total).toFixed(2)}
|
€{parseFloat(offer.accepted_total).toFixed(2)}
|
||||||
@@ -89,36 +77,6 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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);
|
||||||
@@ -461,6 +461,10 @@ export const project_offers = pgTable("project_offers", {
|
|||||||
status: text("status").notNull().default("attivo"),
|
status: text("status").notNull().default("attivo"),
|
||||||
// NULL = continuativo (semantica implicita prima della 0016).
|
// NULL = continuativo (semantica implicita prima della 0016).
|
||||||
end_date: timestamp("end_date", { withTimezone: true }),
|
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(),
|
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { eq, ne, inArray, asc, desc, isNull, isNotNull, sql, and } from "drizzle-orm";
|
import { eq, ne, inArray, asc, desc, isNull, isNotNull, sql, and } from "drizzle-orm";
|
||||||
import { getPool } from "@/lib/taxonomy";
|
import { getPool } from "@/lib/taxonomy";
|
||||||
import { LEAD_STAGES } from "@/lib/lead-validators";
|
import { LEAD_STAGES } from "@/lib/lead-validators";
|
||||||
|
import { getComputedOfferValues } from "@/lib/offer-value";
|
||||||
import type {
|
import type {
|
||||||
Client,
|
Client,
|
||||||
Project,
|
Project,
|
||||||
@@ -245,6 +246,10 @@ export type ProjectOfferWithMicro = {
|
|||||||
// Ciclo di vita (v2.4 Phase 13): attivo | sospeso | cessato.
|
// Ciclo di vita (v2.4 Phase 13): attivo | sospeso | cessato.
|
||||||
status: string;
|
status: string;
|
||||||
end_date: Date | null;
|
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;
|
created_at: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -715,6 +720,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
accepted_total: project_offers.accepted_total,
|
accepted_total: project_offers.accepted_total,
|
||||||
status: project_offers.status,
|
status: project_offers.status,
|
||||||
end_date: project_offers.end_date,
|
end_date: project_offers.end_date,
|
||||||
|
offer_value_override: project_offers.offer_value_override,
|
||||||
created_at: project_offers.created_at,
|
created_at: project_offers.created_at,
|
||||||
})
|
})
|
||||||
.from(project_offers)
|
.from(project_offers)
|
||||||
@@ -789,9 +795,20 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
return true;
|
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
|
// Le offerte cessate non entrano nel totale: serve come default del piano
|
||||||
// pagamenti, e un abbonamento chiuso non è denaro da incassare.
|
// pagamenti, e un abbonamento chiuso non è denaro da incassare.
|
||||||
const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce(
|
const offersAcceptedTotal = projectOffers.reduce(
|
||||||
(sum, o) =>
|
(sum, o) =>
|
||||||
o.status === "cessato"
|
o.status === "cessato"
|
||||||
? sum
|
? sum
|
||||||
@@ -814,7 +831,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
|
||||||
taskSeconds,
|
taskSeconds,
|
||||||
phaseSeconds,
|
phaseSeconds,
|
||||||
projectOffers: projectOffersRows as ProjectOfferWithMicro[],
|
projectOffers,
|
||||||
offersAcceptedTotal,
|
offersAcceptedTotal,
|
||||||
availableMicros: dedupedMicros,
|
availableMicros: dedupedMicros,
|
||||||
transcripts: transcriptsRows,
|
transcripts: transcriptsRows,
|
||||||
|
|||||||
+21
-71
@@ -1,7 +1,8 @@
|
|||||||
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
|
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
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 type { TaskStatus } from "@/lib/task-status";
|
||||||
|
import { getComputedOfferValues } from "@/lib/offer-value";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ClientView: Legacy shape used by ClientDashboard component.
|
* ClientView: Legacy shape used by ClientDashboard component.
|
||||||
@@ -58,12 +59,11 @@ export interface ClientView {
|
|||||||
public_name: string;
|
public_name: string;
|
||||||
offer_name: string; // offer_macros.public_name — macro-level name shown to client
|
offer_name: string; // offer_macros.public_name — macro-level name shown to client
|
||||||
offer_type: string; // una_tantum | retainer
|
offer_type: string; // una_tantum | retainer
|
||||||
cumulative_price: string;
|
offer_value: string; // override admin, o somma calcolata dei servizi
|
||||||
accepted_total: string | null;
|
accepted_total: string | null;
|
||||||
status: string; // attivo | sospeso — le cessate non arrivano al cliente
|
status: string; // attivo | sospeso — le cessate non arrivano al cliente
|
||||||
start_date: string; // ISO
|
start_date: string; // ISO
|
||||||
end_date: string | null; // ISO — null = continuativo
|
end_date: string | null; // ISO — null = continuativo
|
||||||
services: Array<{ name: string; description: string | null }>;
|
|
||||||
}>;
|
}>;
|
||||||
transcripts: Array<{
|
transcripts: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -134,12 +134,14 @@ export interface ProjectView {
|
|||||||
public_name: string; // offer_micros.public_name — NEVER internal_name
|
public_name: string; // offer_micros.public_name — NEVER internal_name
|
||||||
offer_name: string; // offer_macros.public_name — macro-level name shown to client
|
offer_name: string; // offer_macros.public_name — macro-level name shown to client
|
||||||
offer_type: string; // una_tantum | retainer
|
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;
|
accepted_total: string | null;
|
||||||
status: string; // attivo | sospeso — le cessate non arrivano al cliente
|
status: string; // attivo | sospeso — le cessate non arrivano al cliente
|
||||||
start_date: string; // ISO
|
start_date: string; // ISO
|
||||||
end_date: string | null; // ISO — null = continuativo
|
end_date: string | null; // ISO — null = continuativo
|
||||||
services: Array<{ name: string; description: string | null }>; // included services list
|
|
||||||
}>;
|
}>;
|
||||||
transcripts: Array<{
|
transcripts: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -353,6 +355,7 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
status: project_offers.status, // attivo | sospeso
|
status: project_offers.status, // attivo | sospeso
|
||||||
start_date: project_offers.start_date,
|
start_date: project_offers.start_date,
|
||||||
end_date: project_offers.end_date,
|
end_date: project_offers.end_date,
|
||||||
|
offer_value_override: project_offers.offer_value_override,
|
||||||
micro_id: project_offers.micro_id,
|
micro_id: project_offers.micro_id,
|
||||||
})
|
})
|
||||||
.from(project_offers)
|
.from(project_offers)
|
||||||
@@ -365,84 +368,31 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cumulative price + service list per micro.
|
// Somma dei prezzi dei servizi per micro — è il fallback del "Valore
|
||||||
// Primary: offer_tier_services → services (Phase 12 catalog). Fallback: legacy offer_micro_services → offer_services.
|
// dell'offerta" quando l'admin non ha messo un override. Stesso helper che
|
||||||
const microIds = projectOfferRows.map((o) => o.micro_id);
|
// usa l'admin per mostrare quale cifra sta sostituendo.
|
||||||
|
const computedValues = await getComputedOfferValues(
|
||||||
// New-style: services from offer_tier_services joined to unified services catalog
|
projectOfferRows.map((o) => o.micro_id)
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeOffers = projectOfferRows.map((o) => {
|
const activeOffers = projectOfferRows.map((o) => {
|
||||||
const newStyleServices = tierServicesMap.get(o.micro_id);
|
// L'override dell'admin vince sulla somma calcolata. Lo zero è un valore
|
||||||
const legacyServices = legacyServicesMap.get(o.micro_id);
|
// vero — nasconde la riga — quindi si testa la presenza, non la verità.
|
||||||
|
const offer_value =
|
||||||
let cumulative_price: string;
|
o.offer_value_override != null
|
||||||
let servicesList: Array<{ name: string; description: string | null }>;
|
? parseFloat(String(o.offer_value_override)).toFixed(2)
|
||||||
|
: computedValues.get(o.micro_id) ?? "0";
|
||||||
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 = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: o.id,
|
id: o.id,
|
||||||
public_name: o.public_name,
|
public_name: o.public_name,
|
||||||
offer_name: o.offer_name,
|
offer_name: o.offer_name,
|
||||||
offer_type: o.offer_type,
|
offer_type: o.offer_type,
|
||||||
cumulative_price,
|
offer_value,
|
||||||
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
|
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
|
||||||
status: o.status,
|
status: o.status,
|
||||||
start_date: o.start_date.toISOString(),
|
start_date: o.start_date.toISOString(),
|
||||||
end_date: o.end_date ? o.end_date.toISOString() : null,
|
end_date: o.end_date ? o.end_date.toISOString() : null,
|
||||||
services: servicesList,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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<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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user