feat(offers): ciclo di vita dei servizi ricorrenti (v2.4 Phase 13)
Un retainer, una volta assegnato, non si poteva fermare: project_offers aveva solo start_date e il forecast sommava il canone a ogni mese dell'orizzonte da li in poi, per sempre. Un cliente che disdiceva continuava a gonfiare il forecast a 12 mesi e a vedersi l'abbonamento attivo nel portale. - migration 0016 (gia applicata a prod): project_offers.status (attivo|sospeso|cessato, CHECK) + end_date. Additiva pura, default 'attivo' cosi le righe esistenti conservano il comportamento di prima - forecast: i retainer si fermano a end_date, sospesi e cessati escono. getOffersSoldBreakdown NON filtra per stato: e uno storico di vendita, escludere le cessate riscriverebbe il passato - offersAcceptedTotal esclude le cessate (default del piano pagamenti) - admin: comandi Sospendi/Riattiva/Cessa + data fine nella tab Offerte, solo per i ricorrenti. setProjectOfferLifecycle valida con Zod e filtra anche per project_id, cosi un id arbitrario non tocca altri progetti - portale: "Attivo dal", "fino al", badge In pausa, "Canone mensile" invece di "Prezzo finale"; le cessate non arrivano al client - fix: un retainer sospeso continuava a intestare i pagamenti "Totale Pagamento Mensile" e a sovrascrivere l'importo Igiene nello stesso giro: - STATUS.md riscritto: era fermo al 22 giugno e diceva che node/docker non sono disponibili sul server e che le migrazioni si applicano da locale con uno script postgres.js — il contrario della procedura reale - rimossi ChatSection/CommentList/CommentForm, senza importatori (308 righe) - overrides postcss>=8.5.18 e sharp>=0.35.0: 3 CVE high transitive di Next senza fix upstream. npm audit ora pulito, build verde Verifica: forecast controllato sui dati veri in 5 scenari (baseline invariata, end_date, sospeso, cessato, ripristino); portale verificato nei 4 stati; tab admin verificata con Playwright sul build di produzione (i comandi non compaiono sulle una tantum). Dati di test ripuliti, tabelle protette invariate 4/5/11/10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+12
-4
@@ -6,7 +6,8 @@
|
||||
- ✅ **v2.0 Business Operations Suite** — Phases 7–10 (shipped 2026-06-13) — [archive](milestones/v2.0-ROADMAP.md)
|
||||
- ✅ **v2.1 Offer Studio + CRM** — Phases 11–14 parziale (chiuso 2026-06-19, reset → v2.2)
|
||||
- ✅ **v2.2 Sales Loop** — Phases 18–22 (shipped 2026-06-20) — [archive](milestones/v2.2-ROADMAP.md)
|
||||
- 🔨 **v2.3 Email & Accesso** — Phases 23–25 (in corso)
|
||||
- ✅ **v2.3 Email & Accesso** — Phases 23–25 (shipped 2026-07-29)
|
||||
- 🔨 **v2.4 Post-vendita** — Phase 13 (in corso)
|
||||
|
||||
## Phases
|
||||
|
||||
@@ -40,7 +41,13 @@ Archivio completo: [milestones/v2.2-ROADMAP.md](milestones/v2.2-ROADMAP.md)
|
||||
- [x] **Phase 24: Schema + Whitelist Admin** — Tabelle `client_emails` e `otp_codes`, admin UI gestione whitelist + revoca sessioni
|
||||
- [x] **Phase 25: OTP Gate + Sessione** — Gate OTP completo, sessione **90gg**, rate limiting, no enumeration
|
||||
|
||||
> ⚠️ **Codice completo e testato, NON ancora in produzione** (2026-07-28). Il deploy è bloccato da: `RESEND_API_KEY` assente su Coolify e whitelist vuota per 3 clienti su 4. Dettagli e checklist in `STATE.md`.
|
||||
✅ **Shipped 2026-07-29** (commit `27da969`), verificata end-to-end su `hub.iamcavalli.net`. SEND-01/02 rinviati a backlog.
|
||||
|
||||
### 🔨 v2.4 — Post-vendita (Phase 13)
|
||||
|
||||
- [ ] **Phase 13: Ciclo di vita dei servizi ricorrenti** — `project_offers.status` + `end_date` (migr. 0016), forecast che si ferma davvero, comandi Sospendi/Riattiva/Cessa nella tab Offerte, stato dell'abbonamento visibile al cliente.
|
||||
|
||||
Fuori scope di questo giro, rimandato: tracciamento dei canoni mese per mese (serve una tabella nuova — `payments` è protetta e la sua riscalatura è pensata per i piani una tantum).
|
||||
|
||||
## Phase Details
|
||||
|
||||
@@ -99,7 +106,8 @@ Archivio completo: [milestones/v2.2-ROADMAP.md](milestones/v2.2-ROADMAP.md)
|
||||
| 22. Pagina Pubblica + Deck | v2.2 | 1/1 | ✅ Done | 2026-06-20 |
|
||||
| 23. Resend Setup | v2.3 | 1/1 | ✅ Done | 2026-07-28 |
|
||||
| 24. Schema + Whitelist Admin | v2.3 | 1/1 | ✅ Done | 2026-07-28 |
|
||||
| 25. OTP Gate + Sessione | v2.3 | 1/1 | ✅ Done (non deployato) | 2026-07-28 |
|
||||
| 25. OTP Gate + Sessione | v2.3 | 1/1 | ✅ Done | 2026-07-29 |
|
||||
| 13. Ciclo di vita servizi ricorrenti | v2.4 | 1/1 | 🔨 In corso | — |
|
||||
|
||||
---
|
||||
|
||||
@@ -142,7 +150,7 @@ Phase 24 before Phase 25: le tabelle `client_emails` e `otp_codes` devono essere
|
||||
|
||||
**Resend shared infra:** La stessa istanza Resend client e le stesse variabili d'ambiente (`RESEND_API_KEY`, `RESEND_FROM`) servono sia Phase 23 (email preventivo) sia Phase 25 (email OTP). Configurare una volta in Phase 23, riusare in Phase 25.
|
||||
|
||||
**Security invariants (Phase 25):** Rate limiting su entrambi gli endpoint OTP; risposta identica per email in whitelist e non; OTP 6 cifre, scade 15 minuti, monouso (`consumed_at` impostato al primo uso); cookie HttpOnly, SameSite=Lax, MaxAge 30 giorni.
|
||||
**Security invariants (Phase 25, come consegnata):** rate limiting su entrambi gli endpoint OTP; risposta identica per email in whitelist e non; OTP 6 cifre, scade 15 minuti, monouso (`consumed_at` al primo uso), max 5 tentativi; cookie HttpOnly + Secure + SameSite=Lax, **MaxAge 90 giorni** (non 30: modificato il 2026-07-28, compensato dalla revoca admin), per-cliente. Il gate sta in cima alla `page`, non nel layout — vedi la lezione in `STATE.md`.
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-06-21 — v2.3 Email & Accesso*
|
||||
|
||||
@@ -1,40 +1,56 @@
|
||||
# ClientHub (IAMCAVALLI) — Status
|
||||
|
||||
_Ultimo aggiornamento: 2026-06-22_
|
||||
_Ultimo aggiornamento: 2026-08-01_
|
||||
|
||||
## Stato attuale
|
||||
In prod su Coolify (Gitea→deploy). Milestone v2.3 "Email & Accesso" in planning (fase 23 non ancora avviata); ultime sessioni dedicate a fix/flow pre-fase-23. Build verde.
|
||||
|
||||
In prod su Coolify (Gitea → deploy automatico su push a `main`). Build verde, `npm audit` pulito.
|
||||
|
||||
Milestone **v2.3 "Email & Accesso" chiusa e in produzione** dal 2026-07-29: il portale cliente non è più apribile col solo link. In corso **v2.4 Phase 13** — ciclo di vita dei servizi ricorrenti.
|
||||
|
||||
---
|
||||
|
||||
## Fatto (recente, cumulativo)
|
||||
- **Tassonomie centralizzate** in Impostazioni (modello Notion, pool persistenti `src/lib/taxonomy.ts`): Categoria/Ticket/Tipo/Obiettivo offerte + Fase/Offerta/Pacchetto catalogo. Add/remove/sync globale.
|
||||
- **Lead → Cliente**: `clients.email/phone` + `leads.archived` (migr. 0011, in prod). `convertLeadToClient` riusa `createClientCore`, porta i transcript, archivia il lead mantenendo "won". Tasto Converti/Convertito; lead archiviati nascosti da lista/kanban.
|
||||
- **Pagina progetto**: tab "Preventivo" rimossa; sidebar riordinata (Lead prima di Clienti).
|
||||
- **Offerta → Fasi/Task**: `importOfferIntoProject` crea fasi raggruppando i servizi del tier per `services.fase` (merge per titolo, dedup task). Trigger dalla tab Offerte (checkbox import).
|
||||
- **Offerte (modello + UI)**: nuovo campo `offer_macros.offer_type` ('una_tantum'|'retainer') + toggle "Modalità" nell'editor (migr. 0012). Tab Offerte ridisegnata: assegnazione a 2 step (Offerta→Tier con prezzo), badge tipo invece di "X mesi". Deck preventivo mostra il tipo.
|
||||
- **Cleanup dati**: rimossi 3 tier duplicati di "Web Domination" (bug pre-fix) + vincolo `UNIQUE(macro_id, tier_letter)`.
|
||||
- Fix precedenti: persistenza flag A/B/C offerte, sort+reorder colonne catalogo.
|
||||
|
||||
- **[v2.4, in corso] Ciclo di vita dei servizi ricorrenti**: `project_offers.status` (attivo/sospeso/cessato) + `end_date` (migr. 0016, in prod). Prima un retainer non poteva finire e il forecast lo sommava a ogni mese in eterno. Comandi Sospendi/Riattiva/Cessa nella tab Offerte del progetto; il cliente vede stato, "attivo dal" e canone mensile.
|
||||
- **[v2.3] Gate OTP sul portale cliente**: whitelist email per cliente (`client_emails`), codice a 6 cifre via Resend, sessione firmata 90 giorni, revoca in blocco dall'admin (`clients.sessions_valid_from`). Migr. 0015. Verificato E2E in produzione.
|
||||
- **[2026-07] Audit di sicurezza chiuso**: 4 vulnerabilità risolte e deployate, slug clienti ruotati a 12 char CSPRNG, `INTERNAL_SECRET` e `ADMIN_PASSWORD` configurati su Coolify. Report in `.planning/SECURITY-*.md`.
|
||||
- **Tassonomie centralizzate** in Impostazioni (modello Notion, pool persistenti `src/lib/taxonomy.ts`).
|
||||
- **Lead → Cliente**: `clients.email/phone` + `leads.archived` (migr. 0011). `convertLeadToClient` riusa `createClientCore`, porta i transcript, archivia il lead mantenendo "won".
|
||||
- **Offerta → Fasi/Task**: `importOfferIntoProject` crea fasi raggruppando i servizi del tier per `services.fase`.
|
||||
- **Offerte (modello + UI)**: `offer_macros.offer_type` ('una_tantum'|'retainer') + toggle "Modalità" nell'editor (migr. 0012). Tab Offerte a 2 step (Offerta → Tier con prezzo).
|
||||
|
||||
## Da fare
|
||||
|
||||
- [ ] **Whitelist portale**: dei 4 clienti solo alcuni hanno email autorizzate. Chi ha la whitelist vuota non entra nel proprio portale — si popola da `/admin/clients/<id>` → "Accessi al portale".
|
||||
- [ ] **Fasi/Task dall'offerta** funzionano solo se i servizi hanno il campo **Fase** valorizzato nel Catalogo (altrimenti finiscono in "Generale").
|
||||
- [ ] **Forecast** (`forecast-queries.ts`) usa ancora `duration_months`: i retainer andrebbero trattati come ricorrenti (follow-up).
|
||||
- [ ] Micro legacy "Mantenimento" senza tier: valutare se rimuoverlo/normalizzarlo.
|
||||
- [ ] Avviare **fase 23** (OTP gate portale cliente + invio link preventivo via email, integrazione Resend) — `/gsd-plan-phase 23`.
|
||||
- [ ] **Debito design**: 11 pagine ancora a palette raw invece che a token semantici. Le più pesanti: `/admin/projects/[id]` (~140 occorrenze fra i suoi tab), `/admin/offers/[id]/edit`, e tutto `/quote/[token]` (~40, ed è rivolto al cliente). Anche `ui/dialog.tsx`, che propaga il look vecchio a ogni modale.
|
||||
- [ ] **Backlog v2.5**: canoni mensili tracciabili (agosto pagato / settembre no) — serve una tabella nuova, `payments` è protetta e la sua riscalatura è pensata per i piani una tantum. Più PROP-03 (Stripe sul deck), PROP-04 (auto-provisioning al "Vinto"), SEND-01/02 (invio preventivo via email — il mailer è già pronto).
|
||||
|
||||
## Note tecniche
|
||||
- **DB prod = `.env.local` porta 54321**, raggiungibile SOLO via tunnel SSH `ssh -L 54321:localhost:54321 root@178.104.27.55` (cade per idle; usare `-o ServerAliveInterval=30`). `node`/`docker` non disponibili lato server → migrazioni si applicano da locale con script `postgres.js` su 127.0.0.1:54321.
|
||||
- **Ordine deploy con schema**: applicare la migrazione a prod PRIMA del push (il deploy gira subito il codice nuovo).
|
||||
- `drizzle-kit generate` è rotto → migrazioni SQL scritte a mano in `src/db/migrations/`.
|
||||
|
||||
- **Il DB di `.env.local` È la produzione** (178.104.27.55). Non esiste un database di sviluppo separato: qualunque cosa si esegua in locale scrive su dati reali. Verificare i conteggi delle tabelle protette prima e dopo ogni prova.
|
||||
- **Migrazioni**: SQL scritto a mano in `src/db/migrations/` (`drizzle-kit generate` è rotto — vanno tenuti in sync `schema.ts` e l'SQL). Si applicano **da locale via SSH + docker exec**, senza tunnel:
|
||||
`cat src/db/migrations/NNNN.sql | ssh root@178.104.27.55 "docker exec -i xwkk0040w0kk0gsgcgog8owk psql -U clienthub -d clienthub -v ON_ERROR_STOP=1 --single-transaction"`
|
||||
Il tunnel `ssh -f -N -L 54321:localhost:54321` serve solo per puntare il tooling locale (es. `npx tsx`) al DB di prod, riscrivendo l'host di `DATABASE_URL` a `127.0.0.1:54321`.
|
||||
- **Ordine di deploy con schema**: applicare la migrazione a prod **prima** del push (il deploy fa girare subito il codice nuovo).
|
||||
- **`NEXTAUTH_SECRET` locale ≠ produzione**: per costruire firme o hash validi in prod va letto da Coolify, non da `.env.local`.
|
||||
- **Coolify API**: credenziali in `~/.coolify.env` (formato `export VAR=…`, va sorgentato). App uuid `xsksow44g4kcoo8wocsgkscc`. Il POST su `/api/v1/applications/<uuid>/envs` rifiuta il campo `is_build_time` con 422.
|
||||
- **`overrides` in package.json** forzano `postcss >= 8.5.18` e `sharp >= 0.35.0`: le versioni che Next si porta dietro hanno CVE high e non c'è fix upstream. Se un aggiornamento di Next rompe qualcosa, è il primo posto dove guardare.
|
||||
- `offer_micros` non ha `created_at` (no "tier più vecchio" affidabile).
|
||||
|
||||
## File chiave
|
||||
|
||||
| File | Scopo |
|
||||
|---|---|
|
||||
| src/proxy.ts | Middleware (Next 16 lo chiama `proxy`, non `middleware`) |
|
||||
| src/lib/client-gate.ts | Gate OTP — va chiamato in cima a ogni page sotto `/client/[token]/` |
|
||||
| src/lib/otp.ts, client-session.ts | Codici OTP e cookie di sessione 90gg |
|
||||
| src/lib/mailer.ts | Unico punto di invio email (Resend) |
|
||||
| src/lib/forecast-queries.ts | Forecast 12 mesi — rispetta stato e `end_date` dei retainer |
|
||||
| src/lib/taxonomy.ts | Pool tassonomie (Impostazioni) |
|
||||
| src/app/admin/leads/actions.ts | `convertLeadToClient` |
|
||||
| src/app/admin/clients/new/actions.ts | `createClientCore` (riuso) |
|
||||
| src/app/admin/projects/project-actions.ts | `importOfferIntoProject`, assegna offerta |
|
||||
| src/components/admin/tabs/OffersTab.tsx | Tab Offerte (2 step + badge tipo) |
|
||||
| src/lib/admin-queries.ts | `getProjectFullDetail` (offerte/dedup) |
|
||||
| src/db/migrations/ | 0011 (email/phone/archived), 0012 (offer_type/unique) |
|
||||
| src/app/admin/projects/project-actions.ts | `importOfferIntoProject`, `setProjectOfferLifecycle`, piani pagamento |
|
||||
| src/components/admin/tabs/OffersTab.tsx | Tab Offerte + comandi ciclo di vita |
|
||||
| src/lib/admin-queries.ts / client-view.ts | I due layer separati: admin vs proiezioni client-safe |
|
||||
| src/db/migrations/ | 0011 (email/phone), 0012 (offer_type), 0015 (OTP), 0016 (ciclo di vita) |
|
||||
|
||||
Generated
+184
-192
@@ -405,9 +405,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1574,9 +1574,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1586,19 +1586,19 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-darwin-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1608,19 +1608,38 @@
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1634,9 +1653,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1650,9 +1669,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
|
||||
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1669,9 +1688,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1688,9 +1707,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
|
||||
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1707,9 +1726,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
|
||||
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1726,9 +1745,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
|
||||
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1745,9 +1764,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1764,9 +1783,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
|
||||
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1783,9 +1802,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
|
||||
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1802,9 +1821,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
|
||||
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1817,19 +1836,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1842,19 +1861,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-ppc64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
|
||||
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1867,19 +1886,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-riscv64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
|
||||
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1892,19 +1911,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-s390x": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
|
||||
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1917,19 +1936,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linux-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1942,19 +1961,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1967,19 +1986,19 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1992,38 +2011,54 @@
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-wasm32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.7.0"
|
||||
"@emnapi/runtime": "^1.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
|
||||
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/sharp-wasm32": "0.35.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-arm64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
|
||||
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2033,16 +2068,16 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-ia32": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
|
||||
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2052,16 +2087,16 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": "^20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
}
|
||||
},
|
||||
"node_modules/@img/sharp-win32-x64": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
|
||||
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2071,7 +2106,7 @@
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
@@ -8006,52 +8041,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/node-exports-info": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
||||
@@ -8402,10 +8391,9 @@
|
||||
"license": "MIT-0"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dev": true,
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -8422,7 +8410,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -8431,10 +8419,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8921,54 +8908,59 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||
"hasInstallScript": true,
|
||||
"version": "0.35.3",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
|
||||
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@img/colour": "^1.0.0",
|
||||
"@img/colour": "^1.1.0",
|
||||
"detect-libc": "^2.1.2",
|
||||
"semver": "^7.7.3"
|
||||
"semver": "^7.8.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/libvips"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-arm64": "0.34.5",
|
||||
"@img/sharp-darwin-x64": "0.34.5",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||
"@img/sharp-linux-arm": "0.34.5",
|
||||
"@img/sharp-linux-arm64": "0.34.5",
|
||||
"@img/sharp-linux-ppc64": "0.34.5",
|
||||
"@img/sharp-linux-riscv64": "0.34.5",
|
||||
"@img/sharp-linux-s390x": "0.34.5",
|
||||
"@img/sharp-linux-x64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||
"@img/sharp-wasm32": "0.34.5",
|
||||
"@img/sharp-win32-arm64": "0.34.5",
|
||||
"@img/sharp-win32-ia32": "0.34.5",
|
||||
"@img/sharp-win32-x64": "0.34.5"
|
||||
"@img/sharp-darwin-arm64": "0.35.3",
|
||||
"@img/sharp-darwin-x64": "0.35.3",
|
||||
"@img/sharp-freebsd-wasm32": "0.35.3",
|
||||
"@img/sharp-libvips-darwin-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-darwin-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm": "1.3.2",
|
||||
"@img/sharp-libvips-linux-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-ppc64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-riscv64": "1.3.2",
|
||||
"@img/sharp-libvips-linux-s390x": "1.3.2",
|
||||
"@img/sharp-libvips-linux-x64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
|
||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
|
||||
"@img/sharp-linux-arm": "0.35.3",
|
||||
"@img/sharp-linux-arm64": "0.35.3",
|
||||
"@img/sharp-linux-ppc64": "0.35.3",
|
||||
"@img/sharp-linux-riscv64": "0.35.3",
|
||||
"@img/sharp-linux-s390x": "0.35.3",
|
||||
"@img/sharp-linux-x64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.3",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.3",
|
||||
"@img/sharp-webcontainers-wasm32": "0.35.3",
|
||||
"@img/sharp-win32-arm64": "0.35.3",
|
||||
"@img/sharp-win32-ia32": "0.35.3",
|
||||
"@img/sharp-win32-x64": "0.35.3"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
|
||||
@@ -48,5 +48,9 @@
|
||||
"eslint-config-next": "16.2.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": ">=8.5.18",
|
||||
"sharp": ">=0.35.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
services,
|
||||
phases,
|
||||
tasks,
|
||||
PROJECT_OFFER_STATUSES,
|
||||
} from "@/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { eq, asc, and } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
@@ -292,4 +293,62 @@ export async function updateProjectOfferTotal(
|
||||
.set({ accepted_total: amount.toFixed(2) })
|
||||
.where(eq(project_offers.id, projectOfferId));
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
}
|
||||
|
||||
// ── CICLO DI VITA DELL'OFFERTA ASSEGNATA (v2.4 Phase 13) ─────────────────────
|
||||
|
||||
const lifecycleSchema = z.object({
|
||||
status: z.enum(PROJECT_OFFER_STATUSES),
|
||||
// stringa "YYYY-MM-DD" dall'input date, oppure null per "continuativo"
|
||||
end_date: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, "Data non valida")
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type LifecycleResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Cambia stato e/o data di fine di un'offerta assegnata.
|
||||
*
|
||||
* Conta soprattutto per i retainer: prima della 0016 un abbonamento non poteva
|
||||
* finire, e il forecast lo sommava a ogni mese in eterno (vedi
|
||||
* getRevenueForecast12Months in src/lib/forecast-queries.ts).
|
||||
*/
|
||||
export async function setProjectOfferLifecycle(
|
||||
projectOfferId: string,
|
||||
projectId: string,
|
||||
input: { status: string; end_date?: string | null }
|
||||
): Promise<LifecycleResult> {
|
||||
await requireAdmin();
|
||||
|
||||
const parsed = lifecycleSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: parsed.error.issues[0].message };
|
||||
|
||||
const { status } = parsed.data;
|
||||
let end_date: Date | null =
|
||||
parsed.data.end_date ? new Date(`${parsed.data.end_date}T12:00:00Z`) : null;
|
||||
|
||||
// Un'offerta cessata ha per definizione una fine: senza end_date resterebbe
|
||||
// "chiusa" ma priva della data che serve a delimitarla nei conteggi futuri.
|
||||
if (status === "cessato" && !end_date) end_date = new Date();
|
||||
|
||||
// Riattivare azzera la data di fine, altrimenti l'offerta tornerebbe attiva
|
||||
// ma già scaduta — stato incoerente e invisibile nel forecast.
|
||||
if (status === "attivo" && parsed.data.end_date === undefined) end_date = null;
|
||||
|
||||
// project_id nella WHERE: un projectOfferId arbitrario non può toccare la
|
||||
// riga di un altro progetto.
|
||||
const updated = await db
|
||||
.update(project_offers)
|
||||
.set({ status, end_date })
|
||||
.where(and(eq(project_offers.id, projectOfferId), eq(project_offers.project_id, projectId)))
|
||||
.returning({ id: project_offers.id });
|
||||
|
||||
if (updated.length === 0) return { ok: false, error: "Offerta non trovata" };
|
||||
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
revalidatePath("/admin");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
assignOfferToProject,
|
||||
removeProjectOffer,
|
||||
updateProjectOfferTotal,
|
||||
setProjectOfferLifecycle,
|
||||
} from "@/app/admin/projects/project-actions";
|
||||
import type { ProjectOfferWithMicro } from "@/lib/admin-queries";
|
||||
|
||||
@@ -49,6 +50,42 @@ function meaningfulPublicName(m: { public_name: string; tier_letter: string | nu
|
||||
return p;
|
||||
}
|
||||
|
||||
// Ciclo di vita (v2.4 Phase 13). Scritto a token semantici + coppie dark:
|
||||
// secondo il design system corrente, anche se la pagina attorno è ancora a
|
||||
// palette raw: al redesign di /admin/projects/[id] questa parte non si tocca.
|
||||
const LIFECYCLE_STYLES: Record<string, string> = {
|
||||
attivo:
|
||||
"bg-emerald-50 text-emerald-600 border-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900",
|
||||
sospeso:
|
||||
"bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900",
|
||||
cessato:
|
||||
"bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
const LIFECYCLE_LABELS: Record<string, string> = {
|
||||
attivo: "Attivo",
|
||||
sospeso: "Sospeso",
|
||||
cessato: "Cessato",
|
||||
};
|
||||
|
||||
function LifecycleBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full border px-2 py-0.5 text-[11px] font-medium ${
|
||||
LIFECYCLE_STYLES[status] ?? LIFECYCLE_STYLES.cessato
|
||||
}`}
|
||||
>
|
||||
{LIFECYCLE_LABELS[status] ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Date → "YYYY-MM-DD" per il value di un <input type="date">. */
|
||||
function toDateInput(d: Date | null): string {
|
||||
if (!d) return "";
|
||||
return new Date(d).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function TypeBadge({ offerType }: { offerType: string }) {
|
||||
const retainer = offerType === "retainer";
|
||||
return (
|
||||
@@ -70,6 +107,10 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
const [tierId, setTierId] = useState<string>("");
|
||||
const [acceptedTotal, setAcceptedTotal] = useState<string>("");
|
||||
const [importPhases, setImportPhases] = useState(true);
|
||||
// id dell'offerta per cui "Cessa" attende conferma (due passaggi, come
|
||||
// l'eliminazione in ClientActions).
|
||||
const [ceaseArmed, setCeaseArmed] = useState<string | null>(null);
|
||||
const [lifecycleError, setLifecycleError] = useState<string | null>(null);
|
||||
|
||||
// Group tiers by offer (macro), preserving query order.
|
||||
const offers = useMemo(() => {
|
||||
@@ -133,11 +174,30 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
});
|
||||
}
|
||||
|
||||
function handleLifecycle(
|
||||
offerId: string,
|
||||
input: { status: string; end_date?: string | null }
|
||||
) {
|
||||
setLifecycleError(null);
|
||||
startTransition(async () => {
|
||||
const res = await setProjectOfferLifecycle(offerId, projectId, input);
|
||||
if (!res.ok) {
|
||||
setLifecycleError(res.error);
|
||||
return;
|
||||
}
|
||||
setCeaseArmed(null);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
{/* Active assignments */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
|
||||
{lifecycleError && (
|
||||
<p className="mb-3 text-xs text-destructive">{lifecycleError}</p>
|
||||
)}
|
||||
{projectOffers.length === 0 ? (
|
||||
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
|
||||
) : (
|
||||
@@ -147,6 +207,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
public_name: offer.micro_public_name,
|
||||
tier_letter: offer.tier_letter,
|
||||
});
|
||||
const isRetainer = offer.macro_offer_type === "retainer";
|
||||
return (
|
||||
<div
|
||||
key={offer.id}
|
||||
@@ -161,10 +222,13 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
</span>
|
||||
)}
|
||||
<TypeBadge offerType={offer.macro_offer_type} />
|
||||
{isRetainer && <LifecycleBadge status={offer.status} />}
|
||||
</div>
|
||||
{pub && <p className="text-xs text-[#71717a] mt-1">Pubblico: {offer.micro_public_name}</p>}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<label className="text-xs text-[#71717a]">Totale accettato €</label>
|
||||
<label className="text-xs text-[#71717a]">
|
||||
{isRetainer ? "Canone mensile €" : "Totale accettato €"}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
@@ -178,6 +242,97 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
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"
|
||||
/>
|
||||
</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. */}
|
||||
{isRetainer && (
|
||||
<div className="mt-3 rounded-lg border border-border bg-card p-3">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-muted-foreground">Attivo fino al</label>
|
||||
<input
|
||||
type="date"
|
||||
defaultValue={toDateInput(offer.end_date)}
|
||||
disabled={isPending}
|
||||
onChange={(e) => {
|
||||
const val = e.currentTarget.value;
|
||||
handleLifecycle(offer.id, {
|
||||
status: offer.status,
|
||||
end_date: val === "" ? null : val,
|
||||
});
|
||||
}}
|
||||
className="rounded-md border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-ring focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
{!offer.end_date && (
|
||||
<span className="text-[11px] text-muted-foreground">continuativo</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{offer.status === "attivo" ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => handleLifecycle(offer.id, { status: "sospeso" })}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
|
||||
>
|
||||
Sospendi
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
handleLifecycle(offer.id, { status: "attivo", end_date: null })
|
||||
}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
|
||||
>
|
||||
Riattiva
|
||||
</button>
|
||||
)}
|
||||
|
||||
{offer.status !== "cessato" &&
|
||||
(ceaseArmed === offer.id ? (
|
||||
<>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Chiudere l'abbonamento?
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => handleLifecycle(offer.id, { status: "cessato" })}
|
||||
className="text-xs font-semibold text-destructive underline-offset-2 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Sì, cessa
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCeaseArmed(null)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => setCeaseArmed(offer.id)}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:text-destructive hover:underline disabled:opacity-50"
|
||||
>
|
||||
Cessa
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||
Sospeso e cessato escono dal forecast dei prossimi 12 mesi. Lo storico
|
||||
delle offerte vendute non cambia.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -22,8 +22,14 @@ interface ClientDashboardProps {
|
||||
}
|
||||
|
||||
export function ClientDashboard({ view, token, comments, embedded = false }: ClientDashboardProps) {
|
||||
// Determine payment display mode based on active offers
|
||||
const retainerOffer = view.activeOffers?.find((o) => o.offer_type === "retainer");
|
||||
// Determine payment display mode based on active offers.
|
||||
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
||||
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
||||
// sovrascrivere l'importo con un canone che al momento non si paga.
|
||||
// (Le offerte cessate sono già escluse a monte, in client-view.ts.)
|
||||
const retainerOffer = view.activeOffers?.find(
|
||||
(o) => o.offer_type === "retainer" && o.status === "attivo"
|
||||
);
|
||||
const hasRetainer = !!retainerOffer;
|
||||
|
||||
const paymentLabel = hasRetainer ? "Totale Pagamento Mensile" : "Totale Investimento";
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ClientView } from "@/lib/client-view";
|
||||
import type { Comment } from "@/db/schema";
|
||||
|
||||
type Entity = { id: string; type: "general" | "task" | "deliverable"; label: string };
|
||||
|
||||
function buildEntityList(clientId: string, phases: ClientView["phases"]): Entity[] {
|
||||
const entities: Entity[] = [
|
||||
{ id: clientId, type: "general", label: "Messaggio generale" },
|
||||
];
|
||||
for (const phase of phases) {
|
||||
for (const task of phase.tasks) {
|
||||
entities.push({
|
||||
id: task.id,
|
||||
type: "task",
|
||||
label: `${phase.title} — ${task.title}`,
|
||||
});
|
||||
for (const d of task.deliverables) {
|
||||
entities.push({
|
||||
id: d.id,
|
||||
type: "deliverable",
|
||||
label: `${task.title} — ${d.title}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
function buildLabelMap(entities: Entity[]): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const e of entities) map.set(e.id, e.label);
|
||||
return map;
|
||||
}
|
||||
|
||||
function formatTime(ts: Date | string): string {
|
||||
const d = typeof ts === "string" ? new Date(ts) : ts;
|
||||
return d.toLocaleString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function ChatSection({
|
||||
clientId,
|
||||
phases,
|
||||
token,
|
||||
comments,
|
||||
}: {
|
||||
clientId: string;
|
||||
phases: ClientView["phases"];
|
||||
token: string;
|
||||
comments: Comment[];
|
||||
}) {
|
||||
const entities = buildEntityList(clientId, phases);
|
||||
const labelMap = buildLabelMap(entities);
|
||||
|
||||
const [body, setBody] = useState("");
|
||||
const [selectedEntityId, setSelectedEntityId] = useState(clientId);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sort all comments chronologically
|
||||
const sorted = [...comments].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [comments.length]);
|
||||
|
||||
function handleSend(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const selectedEntity = entities.find((en) => en.id === selectedEntityId);
|
||||
if (!selectedEntity) return;
|
||||
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/client/comment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
entity_type: selectedEntity.type,
|
||||
entity_id: selectedEntity.id,
|
||||
body: trimmed,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? "Errore nell'invio");
|
||||
return;
|
||||
}
|
||||
setBody("");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Errore di rete");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-[#e5e7eb] overflow-hidden bg-white">
|
||||
{/* Chat feed */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 max-h-[480px] min-h-[200px]">
|
||||
{sorted.length === 0 && (
|
||||
<p className="text-sm text-[#71717a] italic text-center py-10">
|
||||
Nessun messaggio ancora. Scrivi qui sotto per iniziare.
|
||||
</p>
|
||||
)}
|
||||
{sorted.map((c) => {
|
||||
const isClient = c.author === "client";
|
||||
const entityLabel = labelMap.get(c.entity_id);
|
||||
const showTag = c.entity_id !== clientId && entityLabel;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`flex flex-col gap-1 ${isClient ? "items-end" : "items-start"}`}
|
||||
>
|
||||
{/* Author + tag */}
|
||||
<div className={`flex items-center gap-2 ${isClient ? "flex-row-reverse" : ""}`}>
|
||||
<span
|
||||
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full ${
|
||||
isClient
|
||||
? "bg-[#DEF168] text-[#1A463C]"
|
||||
: "bg-[#1A463C] text-white"
|
||||
}`}
|
||||
>
|
||||
{isClient ? "Tu" : "iamcavalli"}
|
||||
</span>
|
||||
{showTag && (
|
||||
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-2 py-0.5 rounded-full truncate max-w-[180px]">
|
||||
{entityLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
className={`max-w-[75%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
|
||||
isClient
|
||||
? "bg-[#1A463C] text-white rounded-tr-sm"
|
||||
: "bg-[#f4f4f5] text-[#1a1a1a] rounded-tl-sm"
|
||||
}`}
|
||||
>
|
||||
{c.body}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="text-[10px] text-[#71717a]">{formatTime(c.created_at)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-[#e5e7eb]" />
|
||||
|
||||
{/* Input area */}
|
||||
<form onSubmit={handleSend} className="p-3 space-y-2 bg-[#fafafa]">
|
||||
{/* Entity selector */}
|
||||
<select
|
||||
value={selectedEntityId}
|
||||
onChange={(e) => setSelectedEntityId(e.target.value)}
|
||||
className="w-full text-xs border border-[#e5e7eb] rounded-lg px-3 py-1.5 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
||||
>
|
||||
{entities.map((en) => (
|
||||
<option key={en.id} value={en.id}>
|
||||
{en.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend(e as unknown as React.FormEvent);
|
||||
}
|
||||
}}
|
||||
placeholder="Scrivi un messaggio… (Invio per inviare)"
|
||||
rows={2}
|
||||
className="flex-1 text-sm border border-[#e5e7eb] rounded-lg px-3 py-2 bg-white resize-none focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!body.trim()}
|
||||
className="self-end px-4 py-2 rounded-lg bg-[#1A463C] text-white text-sm font-semibold disabled:opacity-40 hover:bg-[#1A463C]/90 transition-colors"
|
||||
>
|
||||
Invia
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
entityType: "task" | "deliverable";
|
||||
entityId: string;
|
||||
};
|
||||
|
||||
export function CommentForm({ token, entityType, entityId }: Props) {
|
||||
const router = useRouter();
|
||||
const [body, setBody] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!body.trim()) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/client/comment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, entity_type: entityType, entity_id: entityId, body }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? "Errore durante l'invio");
|
||||
return;
|
||||
}
|
||||
setBody("");
|
||||
router.refresh(); // Re-fetch Server Component to show new comment
|
||||
} catch {
|
||||
setError("Errore di rete");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-3 flex gap-2 items-end">
|
||||
<Textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="Lascia un commento..."
|
||||
rows={2}
|
||||
className="text-sm resize-none flex-1"
|
||||
/>
|
||||
<div className="flex flex-col justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={loading || !body.trim()}
|
||||
className="text-xs"
|
||||
>
|
||||
{loading ? "Invio..." : "Invia"}
|
||||
</Button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Comment } from "@/db/schema";
|
||||
|
||||
type Props = { comments: Comment[] };
|
||||
|
||||
export function CommentList({ comments }: Props) {
|
||||
if (comments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
{comments.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`flex gap-2 ${c.author === "admin" ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg px-3 py-2 text-xs max-w-xs ${
|
||||
c.author === "admin"
|
||||
? "bg-gray-900 text-white"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium mb-0.5 opacity-60">
|
||||
{c.author === "admin" ? "iamcavalli" : "Tu"}
|
||||
</p>
|
||||
<p>{c.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,21 @@ interface ActiveOffer {
|
||||
offer_type: string; // una_tantum | retainer
|
||||
cumulative_price: string; // sum of service prices
|
||||
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. */
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("it-IT", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
interface OffersSectionProps {
|
||||
offers: ActiveOffer[];
|
||||
}
|
||||
@@ -27,6 +39,8 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
|
||||
const price = parseFloat(offer.cumulative_price);
|
||||
const hasPrice = price > 0;
|
||||
const hasServices = offer.services.length > 0;
|
||||
const isRetainer = offer.offer_type === "retainer";
|
||||
const isSuspended = offer.status === "sospeso";
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card overflow-hidden">
|
||||
@@ -34,7 +48,23 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
|
||||
<div className="p-5">
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
{/* Heading: macro public name */}
|
||||
<p className="text-sm font-bold text-foreground">{offer.offer_name}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-bold text-foreground">{offer.offer_name}</p>
|
||||
{isSuspended && (
|
||||
<span className="inline-block rounded-full border border-amber-100 bg-amber-50 px-2 py-0.5 text-[11px] font-medium text-amber-600 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300">
|
||||
In pausa
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Periodo di attività — solo per i servizi ricorrenti: per una una
|
||||
tantum "attivo dal" non aggiunge nulla. */}
|
||||
{isRetainer && (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
Attivo dal {formatDate(offer.start_date)}
|
||||
{offer.end_date && ` · fino al ${formatDate(offer.end_date)}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{/* "Valore incluso" — hidden when 0 */}
|
||||
@@ -45,10 +75,12 @@ function OfferCard({ offer }: { offer: ActiveOffer }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* "Prezzo finale" */}
|
||||
{/* Prezzo: per un ricorrente è il canone, non un totale una tantum */}
|
||||
{offer.accepted_total && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-foreground">Prezzo finale</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{isRetainer ? "Canone mensile" : "Prezzo finale"}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-primary">
|
||||
€{parseFloat(offer.accepted_total).toFixed(2)}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Additive: ciclo di vita delle offerte assegnate a un progetto (v2.4 Phase 13).
|
||||
--
|
||||
-- project_offers aveva solo start_date. Per i retainer il forecast somma
|
||||
-- accepted_total a OGNI mese da start_date in poi, senza limite (vedi il ramo
|
||||
-- retainer in src/lib/forecast-queries.ts): un abbonamento disdetto continuava
|
||||
-- quindi a gonfiare il forecast a 12 mesi in eterno, e il portale continuava a
|
||||
-- mostrarlo attivo al cliente.
|
||||
--
|
||||
-- status → attivo | sospeso | cessato. Default 'attivo' così TUTTE le righe
|
||||
-- esistenti conservano esattamente il comportamento di oggi.
|
||||
-- end_date → nullable. NULL = continuativo, che è la semantica implicita attuale.
|
||||
--
|
||||
-- Nessun DROP, nessun TRUNCATE, nessuna colonna rimossa o modificata.
|
||||
-- Applicare a prod via SSH+docker exec PRIMA di pushare il codice dipendente.
|
||||
-- Idempotente: safe to re-run.
|
||||
|
||||
ALTER TABLE project_offers
|
||||
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'attivo';
|
||||
|
||||
ALTER TABLE project_offers
|
||||
ADD COLUMN IF NOT EXISTS end_date timestamptz;
|
||||
|
||||
-- Vincolo sui valori ammessi. NOT VALID: non riverifica le righe esistenti
|
||||
-- (tutte 'attivo' per il default, quindi già conformi) ed evita un lock lungo.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'project_offers_status_check'
|
||||
) THEN
|
||||
ALTER TABLE project_offers
|
||||
ADD CONSTRAINT project_offers_status_check
|
||||
CHECK (status IN ('attivo', 'sospeso', 'cessato')) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Il forecast filtra per stato su ogni riga non archiviata: indice utile appena
|
||||
-- le righe crescono.
|
||||
CREATE INDEX IF NOT EXISTS project_offers_status_idx ON project_offers (status);
|
||||
@@ -431,9 +431,19 @@ export const project_offers = pgTable("project_offers", {
|
||||
start_date: timestamp("start_date", { withTimezone: true }).notNull().defaultNow(),
|
||||
// Offer-level accepted total — separate from projects.accepted_total (quote builder total)
|
||||
accepted_total: numeric("accepted_total", { precision: 10, scale: 2 }),
|
||||
// Ciclo di vita (v2.4 Phase 13): attivo | sospeso | cessato.
|
||||
// Rilevante soprattutto per i retainer, che senza uno stato restavano nel
|
||||
// forecast per sempre. CHECK sui valori in 0016_retainer_lifecycle.sql.
|
||||
status: text("status").notNull().default("attivo"),
|
||||
// NULL = continuativo (semantica implicita prima della 0016).
|
||||
end_date: timestamp("end_date", { withTimezone: true }),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/** Stati del ciclo di vita di un'offerta assegnata a un progetto. */
|
||||
export const PROJECT_OFFER_STATUSES = ["attivo", "sospeso", "cessato"] as const;
|
||||
export type ProjectOfferStatus = (typeof PROJECT_OFFER_STATUSES)[number];
|
||||
|
||||
// ============ OFFER PHASES (hierarchical breakdown of offer micros) ============
|
||||
export const offer_phases = pgTable("offer_phases", {
|
||||
id: text("id")
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
clientTranscripts,
|
||||
client_emails,
|
||||
} from "@/db/schema";
|
||||
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
||||
import { eq, ne, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
||||
import { getPool } from "@/lib/taxonomy";
|
||||
import { LEAD_STAGES } from "@/lib/lead-validators";
|
||||
import type {
|
||||
@@ -238,6 +238,9 @@ export type ProjectOfferWithMicro = {
|
||||
macro_offer_type: string;
|
||||
start_date: Date;
|
||||
accepted_total: string | null;
|
||||
// Ciclo di vita (v2.4 Phase 13): attivo | sospeso | cessato.
|
||||
status: string;
|
||||
end_date: Date | null;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
@@ -684,6 +687,8 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
macro_offer_type: offer_macros.offer_type,
|
||||
start_date: project_offers.start_date,
|
||||
accepted_total: project_offers.accepted_total,
|
||||
status: project_offers.status,
|
||||
end_date: project_offers.end_date,
|
||||
created_at: project_offers.created_at,
|
||||
})
|
||||
.from(project_offers)
|
||||
@@ -759,8 +764,13 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
||||
return true;
|
||||
});
|
||||
|
||||
// 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(
|
||||
(sum, o) => sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
|
||||
(sum, o) =>
|
||||
o.status === "cessato"
|
||||
? sum
|
||||
: sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
|
||||
0
|
||||
);
|
||||
|
||||
@@ -863,6 +873,7 @@ export type ClientActiveOfferSummary = {
|
||||
category: string | null; // Entry | Signature | Retainer (offer_macros.category)
|
||||
offer_type: string; // una_tantum | retainer (fallback for category)
|
||||
accepted_total: string | null;
|
||||
status: string; // attivo | sospeso — le cessate non compaiono qui
|
||||
};
|
||||
|
||||
export async function getClientActiveOffers(clientId: string): Promise<ClientActiveOfferSummary[]> {
|
||||
@@ -887,11 +898,18 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
|
||||
category: offer_macros.category,
|
||||
offer_type: offer_macros.offer_type,
|
||||
accepted_total: project_offers.accepted_total,
|
||||
status: project_offers.status,
|
||||
})
|
||||
.from(project_offers)
|
||||
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
||||
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
||||
.where(inArray(project_offers.project_id, projectIds))
|
||||
// La sezione si chiama "Offerte Attive": le cessate non ci vanno.
|
||||
.where(
|
||||
and(
|
||||
inArray(project_offers.project_id, projectIds),
|
||||
ne(project_offers.status, "cessato")
|
||||
)
|
||||
)
|
||||
.orderBy(asc(project_offers.created_at));
|
||||
|
||||
return rows.map((r) => ({
|
||||
@@ -904,6 +922,7 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
|
||||
category: r.category,
|
||||
offer_type: r.offer_type,
|
||||
accepted_total: r.accepted_total ? String(r.accepted_total) : null,
|
||||
status: r.status,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -1,4 +1,4 @@
|
||||
import { eq, inArray, asc, desc, sql } from "drizzle-orm";
|
||||
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";
|
||||
|
||||
@@ -59,6 +59,9 @@ export interface ClientView {
|
||||
offer_type: string; // una_tantum | retainer
|
||||
cumulative_price: 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 }>;
|
||||
}>;
|
||||
transcripts: Array<{
|
||||
@@ -132,6 +135,9 @@ export interface ProjectView {
|
||||
offer_type: string; // una_tantum | retainer
|
||||
cumulative_price: string; // sum of assigned services.unit_price (new) or offer_services.price (legacy)
|
||||
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<{
|
||||
@@ -331,7 +337,10 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
||||
.where(eq(notes.project_id, projectId))
|
||||
.orderBy(asc(notes.created_at));
|
||||
|
||||
// Fetch active offers for this project (client-safe fields only — never internal_name)
|
||||
// Fetch active offers for this project (client-safe fields only — never internal_name).
|
||||
// Le offerte cessate sono escluse: un abbonamento chiuso non è un servizio
|
||||
// attivo e il cliente non deve vederselo ancora addosso. Le sospese restano,
|
||||
// marcate come tali — sapere che è in pausa è diverso dal vederlo sparire.
|
||||
const projectOfferRows = await db
|
||||
.select({
|
||||
id: project_offers.id,
|
||||
@@ -339,12 +348,20 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
||||
offer_name: offer_macros.public_name, // macro-level public name — NEVER internal_name
|
||||
offer_type: offer_macros.offer_type, // una_tantum | retainer
|
||||
accepted_total: project_offers.accepted_total,
|
||||
status: project_offers.status, // attivo | sospeso
|
||||
start_date: project_offers.start_date,
|
||||
end_date: project_offers.end_date,
|
||||
micro_id: project_offers.micro_id,
|
||||
})
|
||||
.from(project_offers)
|
||||
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
||||
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
||||
.where(eq(project_offers.project_id, projectId));
|
||||
.where(
|
||||
and(
|
||||
eq(project_offers.project_id, projectId),
|
||||
ne(project_offers.status, "cessato")
|
||||
)
|
||||
);
|
||||
|
||||
// Cumulative price + service list per micro.
|
||||
// Primary: offer_tier_services → services (Phase 12 catalog). Fallback: legacy offer_micro_services → offer_services.
|
||||
@@ -420,6 +437,9 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
||||
offer_type: o.offer_type,
|
||||
cumulative_price,
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from "@/db";
|
||||
import { project_offers, offer_micros, offer_macros, projects } from "@/db/schema";
|
||||
import { eq, sql, desc } from "drizzle-orm";
|
||||
import { eq, ne, and, sql, desc } from "drizzle-orm";
|
||||
|
||||
export type ForecastMonth = {
|
||||
year: number;
|
||||
@@ -21,6 +21,11 @@ export type OffersSoldBreakdown = {
|
||||
};
|
||||
|
||||
// Quante offerte sono state vendute (assegnate a un progetto), per offerta + tier.
|
||||
//
|
||||
// NON filtrare per project_offers.status: questo è un dato storico di vendita.
|
||||
// Escludere le offerte cessate riscriverebbe il passato ogni volta che un
|
||||
// cliente disdice, e "quante ne ho vendute" smetterebbe di essere una risposta
|
||||
// stabile. Il ciclo di vita riguarda il forecast, non lo storico.
|
||||
export async function getOffersSoldBreakdown(): Promise<OffersSoldBreakdown> {
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -49,9 +54,15 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
||||
// Active offers only (non-archived projects). offer_type drives the math:
|
||||
// - retainer: accepted_total è il CANONE MENSILE → ogni mese coperto riceve l'intero importo
|
||||
// - una_tantum: accepted_total è il prezzo TOTALE → spalmato su duration_months
|
||||
//
|
||||
// Le offerte cessate escono dalla query: un abbonamento chiuso non è ricavo
|
||||
// futuro. Prima della 0016 non esisteva modo di chiuderne uno, e un retainer
|
||||
// disdetto restava nel forecast per sempre.
|
||||
const offers = await db
|
||||
.select({
|
||||
start_date: project_offers.start_date,
|
||||
end_date: project_offers.end_date,
|
||||
status: project_offers.status,
|
||||
duration_months: offer_micros.duration_months,
|
||||
accepted_total: project_offers.accepted_total,
|
||||
offer_type: offer_macros.offer_type,
|
||||
@@ -60,7 +71,7 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
||||
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
||||
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
|
||||
.innerJoin(projects, eq(project_offers.project_id, projects.id))
|
||||
.where(eq(projects.archived, false));
|
||||
.where(and(eq(projects.archived, false), ne(project_offers.status, "cessato")));
|
||||
|
||||
// Build 12-month bucket array starting from current month
|
||||
const now = new Date();
|
||||
@@ -83,11 +94,23 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
||||
const startMonthKey = start.getFullYear() * 12 + start.getMonth();
|
||||
|
||||
if (offer.offer_type === "retainer") {
|
||||
// Canone ricorrente: contribuisce ad OGNI mese dell'orizzonte da start in poi,
|
||||
// senza limite di duration_months (un retainer è continuativo).
|
||||
// Un retainer sospeso non produce ricavo prevedibile: senza una data di
|
||||
// ripresa non si può sapere quando ricomincia, quindi ai fini del forecast
|
||||
// vale come chiuso. Scelta di prodotto, non conseguenza tecnica.
|
||||
if (offer.status !== "attivo") continue;
|
||||
|
||||
// Canone ricorrente: contribuisce ad OGNI mese dell'orizzonte da start in
|
||||
// poi, senza limite di duration_months (un retainer è continuativo) —
|
||||
// ma ora si ferma a end_date se valorizzata. NULL = continuativo.
|
||||
const endMonthKey = offer.end_date
|
||||
? offer.end_date.getFullYear() * 12 + offer.end_date.getMonth()
|
||||
: null;
|
||||
|
||||
for (const b of buckets) {
|
||||
const bucketMonthKey = b.year * 12 + (b.month - 1);
|
||||
if (bucketMonthKey >= startMonthKey) b.total += total;
|
||||
if (bucketMonthKey < startMonthKey) continue;
|
||||
if (endMonthKey !== null && bucketMonthKey > endMonthKey) continue;
|
||||
b.total += total;
|
||||
}
|
||||
} else {
|
||||
// Una tantum: prezzo totale ripartito su duration_months a partire da start.
|
||||
|
||||
Reference in New Issue
Block a user