Compare commits
4 Commits
v2.2
..
320827e13a
| Author | SHA1 | Date | |
|---|---|---|---|
| 320827e13a | |||
| ba3e824157 | |||
| 19a7ffb6a6 | |||
| bc9051c899 |
+11
-14
@@ -8,15 +8,15 @@ Suite operativa per un consulente di personal branding, live su hub.iamcavalli.n
|
||||
|
||||
Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
|
||||
|
||||
## Current State: v2.2 Sales Loop ✅ SHIPPED 2026-06-20
|
||||
## Current Milestone: v2.3 Email & Accesso
|
||||
|
||||
Il loop di vendita end-to-end è live in produzione: lead in pipeline Kanban → transcript datati → agente AI (Claude Opus 4.8) genera preventivo personalizzato → deck pubblico 20+ slide a `/preventivo/[slug]` → cliente sceglie tier A/B/C e accetta → vinto/perso nel CRM.
|
||||
**Goal:** Aggiungere uno strato email all'app — OTP gate per il portale cliente e invio link preventivo dall'admin — con un'unica integrazione Resend condivisa.
|
||||
|
||||
**Prossima milestone:** `/gsd-new-milestone` — candidati backlog:
|
||||
- **PUB-03** — Invio link preventivo via email Resend (primo candidato, piccola effort)
|
||||
- **PROP-03/04** — Stripe Payment Link + auto-provisioning al "Vinto"
|
||||
- **AUTH-OTP-01** — Accesso cliente via OTP email (design già pronto)
|
||||
- **Phase 13** — Servizi attivi/ricorrenti post-vendita (congelata, ripescabile)
|
||||
**Target features:**
|
||||
- AUTH-OTP-01 — OTP gate portale cliente (whitelist email + sessione 30gg + admin UI)
|
||||
- PUB-03 — Invio link `/preventivo/[slug]` via email Resend dall'admin
|
||||
|
||||
**Backlog v2.4+:** PROP-03 (Stripe Payment Link), PROP-04 (auto-provisioning al "Vinto"), Phase 13 (servizi ricorrenti)
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -57,12 +57,10 @@ Validated in v2.2 Sales Loop (shipped 2026-06-20):
|
||||
- ✓ Agente AI: Claude Opus 4.8, Zod schema 20+ sezioni, snapshot JSONB proposals, form admin — Phase 21 (AI-01, AI-02)
|
||||
- ✓ Deck pubblico `/preventivo/[slug]`: 20+ slide 100vh, keyboard nav; accept/reject `accepted_at` immutabile — Phase 22 (PUB-01, PUB-02)
|
||||
|
||||
### Active — v2.3 (prossima milestone)
|
||||
### Active — v2.3
|
||||
|
||||
- [ ] PUB-03 — Invio link preventivo via email Resend (primo candidato)
|
||||
- [ ] PROP-03 — Stripe Payment Link su offerta pubblica
|
||||
- [ ] PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto"
|
||||
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email (design pronto)
|
||||
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email: whitelist `client_emails`, gate `/client/[token]/*`, sessione 30gg, admin whitelist UI
|
||||
- [ ] PUB-03 — Invia link `/preventivo/[slug]` via email Resend dall'admin
|
||||
|
||||
### Out of Scope
|
||||
|
||||
@@ -70,7 +68,6 @@ Validated in v2.2 Sales Loop (shipped 2026-06-20):
|
||||
- App mobile nativa — solo web responsive
|
||||
- Multi-utente con team — solo tu come admin per ora
|
||||
- Prezzi singoli visibili al cliente — vede solo il totale accettato
|
||||
- Email OTP per accesso cliente — design pronto ma deferito a batch successivo su richiesta utente
|
||||
- File hosting — documenti solo come URL esterni (v1 constraint, ancora valido)
|
||||
- Sezioni analitiche stile Notion (psicologia, rating, performance) — fuori v2.1, eventuale milestone futura
|
||||
- Deploy separati per modulo (architettura OMC multi-app) — non finché un modulo non cresce abbastanza da giustificarlo
|
||||
@@ -133,4 +130,4 @@ This document evolves at phase transitions and milestone boundaries.
|
||||
4. Update Context with current state
|
||||
|
||||
---
|
||||
*Last updated: 2026-06-20 — v2.2 milestone complete: Sales Loop end-to-end shipped (Phases 18–22)*
|
||||
*Last updated: 2026-06-21 — v2.3 milestone started: Email & Accesso (AUTH-OTP-01 + PUB-03)*
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Requirements: ClientHub v2.3 Email & Accesso
|
||||
|
||||
**Defined:** 2026-06-21
|
||||
**Core Value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
|
||||
|
||||
## v2.3 Requirements
|
||||
|
||||
### Email OTP Gate (AUTH-OTP-01)
|
||||
|
||||
Portale cliente blindato da email OTP. Nuovo `client_emails` table (whitelist) + `otp_codes` table (codice, email, expires_at, consumed). Resend come provider email. Sessione 30gg con cookie dopo verifica.
|
||||
|
||||
- [ ] **OTP-01**: Admin può aggiungere e rimuovere email dalla whitelist di ogni cliente nell'admin UI
|
||||
- [ ] **OTP-02**: Cliente senza sessione OTP vede una schermata "inserisci email" invece della dashboard
|
||||
- [ ] **OTP-03**: Sistema invia OTP via Resend solo se l'email inserita è nella whitelist di quel cliente
|
||||
- [ ] **OTP-04**: Cliente inserisce il codice OTP ricevuto e ottiene sessione autenticata (cookie 30 giorni)
|
||||
- [ ] **OTP-05**: Codici OTP scadono dopo 15 minuti dall'invio
|
||||
- [ ] **OTP-06**: Endpoint OTP è rate-limited per prevenire brute force
|
||||
- [ ] **OTP-07**: Messaggi di errore OTP non rivelano se l'email è in whitelist o no (no enumeration)
|
||||
|
||||
### Invio Link Preventivo via Email (PUB-03)
|
||||
|
||||
Admin invia link deck pubblico al lead direttamente dall'admin UI. Usa stessa infrastruttura Resend del OTP gate.
|
||||
|
||||
- [ ] **SEND-01**: Admin può inviare link `/preventivo/[slug]` via email al lead con un'azione dall'admin UI
|
||||
- [ ] **SEND-02**: Email inviata via Resend include link deck + nome cliente, template minimale in italiano
|
||||
|
||||
## v2.4+ Backlog
|
||||
|
||||
### Conversione Commerciale
|
||||
|
||||
- **PROP-03**: Stripe Payment Link su deck pubblico `/preventivo/[slug]`
|
||||
- **PROP-04**: Auto-provisioning cliente/progetto/fasi al "Vinto" nel CRM
|
||||
|
||||
### Post-Vendita
|
||||
|
||||
- **Phase 13**: Gestione servizi attivi/ricorrenti post-vendita nel portale cliente (congelata da v2.1)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Self-registration cliente | Solo whitelist admin-gestita — nessun accesso senza approvazione esplicita |
|
||||
| Magic link senza OTP | OTP è più sicuro e già deciso come design; magic link = scope creep |
|
||||
| Email marketing / newsletter | Non pertinente al portale |
|
||||
| Multi-admin | Ancora single admin per ora |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| OTP-01 | Phase 24 | Pending |
|
||||
| OTP-02 | Phase 25 | Pending |
|
||||
| OTP-03 | Phase 25 | Pending |
|
||||
| OTP-04 | Phase 25 | Pending |
|
||||
| OTP-05 | Phase 25 | Pending |
|
||||
| OTP-06 | Phase 25 | Pending |
|
||||
| OTP-07 | Phase 25 | Pending |
|
||||
| SEND-01 | Phase 23 | Pending |
|
||||
| SEND-02 | Phase 23 | Pending |
|
||||
|
||||
**Coverage:**
|
||||
- v2.3 requirements: 9 total
|
||||
- Mapped to phases: 9
|
||||
- Unmapped: 0 ✓
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-06-21*
|
||||
*Last updated: 2026-06-21 — traceability filled after roadmap creation*
|
||||
+91
-9
@@ -6,7 +6,7 @@
|
||||
- ✅ **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** — prossima milestone (in definizione)
|
||||
- 🔨 **v2.3 Email & Accesso** — Phases 23–25 (in corso)
|
||||
|
||||
## Phases
|
||||
|
||||
@@ -34,15 +34,49 @@ Archivio completo: [milestones/v2.2-ROADMAP.md](milestones/v2.2-ROADMAP.md)
|
||||
|
||||
</details>
|
||||
|
||||
### 📋 v2.3 — Prossima Milestone (in definizione)
|
||||
### 🔨 v2.3 — Email & Accesso (Phases 23–25)
|
||||
|
||||
Candidati backlog (da formalizzare con `/gsd-new-milestone`):
|
||||
- [ ] **Phase 23: Resend Setup + Invio Preventivo** — Integrazione Resend e invio link deck dall'admin
|
||||
- [ ] **Phase 24: Schema + Whitelist Admin** — Tabelle `client_emails` e `otp_codes`, admin UI gestione whitelist
|
||||
- [ ] **Phase 25: OTP Gate + Sessione** — Gate OTP completo, sessione 30gg, rate limiting, no enumeration
|
||||
|
||||
- [ ] PUB-03 — Invio link preventivo via email Resend
|
||||
- [ ] PROP-03 — Stripe Payment Link su offerta pubblica
|
||||
- [ ] PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto"
|
||||
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email (design pronto)
|
||||
- [ ] Phase 13 — Servizi attivi/ricorrenti post-vendita (congelata, ripescabile)
|
||||
## Phase Details
|
||||
|
||||
### Phase 23: Resend Setup + Invio Preventivo
|
||||
**Goal**: Admin può inviare il link `/preventivo/[slug]` via email con un click dall'admin UI
|
||||
**Depends on**: Nothing — primo uso di Resend, nessuna dipendenza DB
|
||||
**Requirements**: SEND-01, SEND-02
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Admin fa click su "Invia preventivo" nel dettaglio lead/preventivo e l'email parte senza uscire dall'app
|
||||
2. Il destinatario riceve un'email in italiano con nome cliente e link cliccabile al deck pubblico
|
||||
3. L'invio usa Resend con le variabili d'ambiente configurate su Coolify (`RESEND_API_KEY`, `RESEND_FROM`)
|
||||
4. In caso di errore Resend, l'admin vede un messaggio di errore chiaro nell'UI (non un crash silenzioso)
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 24: Schema + Whitelist Admin
|
||||
**Goal**: Admin può gestire la whitelist email di ogni cliente, con le tabelle DB pronte per l'OTP gate
|
||||
**Depends on**: Phase 23 (Resend SDK già installato e variabili d'ambiente configurate)
|
||||
**Requirements**: OTP-01
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Admin può aggiungere una o più email alla whitelist di un cliente dalla pagina dettaglio cliente
|
||||
2. Admin può rimuovere un'email dalla whitelist di un cliente
|
||||
3. Le tabelle `client_emails` e `otp_codes` esistono in produzione (migration additive applicata via SSH prima del codice)
|
||||
4. La migration non tocca nessuna delle tabelle protette (`clients`, `projects`, `payments`, `phases`)
|
||||
**Plans**: TBD
|
||||
**UI hint**: yes
|
||||
|
||||
### Phase 25: OTP Gate + Sessione
|
||||
**Goal**: Il portale `/client/[token]/*` richiede verifica OTP email prima di mostrare la dashboard
|
||||
**Depends on**: Phase 24 (tabelle `client_emails` e `otp_codes` in prod)
|
||||
**Requirements**: OTP-02, OTP-03, OTP-04, OTP-05, OTP-06, OTP-07
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Cliente senza sessione OTP valida vede una schermata "inserisci la tua email" al posto della dashboard
|
||||
2. Inserita un'email in whitelist, il cliente riceve il codice OTP via Resend; inserendo il codice corretto ottiene accesso con cookie valido 30 giorni
|
||||
3. Un'email non in whitelist non riceve OTP — il messaggio d'errore mostrato è identico a quello per email valide (no enumeration)
|
||||
4. Un codice OTP non utilizzato entro 15 minuti viene rifiutato; il cliente deve richiederne uno nuovo
|
||||
5. Tentativi ripetuti sugli endpoint OTP vengono bloccati dal rate limiter (no brute force)
|
||||
**Plans**: TBD
|
||||
|
||||
## Progress
|
||||
|
||||
@@ -61,4 +95,52 @@ Candidati backlog (da formalizzare con `/gsd-new-milestone`):
|
||||
| 20. Knowledge Base Cliente | v2.2 | 3/3 | ✅ Done | 2026-06-20 |
|
||||
| 21. Agente AI Preventivo | v2.2 | 1/1 | ✅ Done | 2026-06-20 |
|
||||
| 22. Pagina Pubblica + Deck | v2.2 | 1/1 | ✅ Done | 2026-06-20 |
|
||||
| 23+. v2.3 TBD | v2.3 | TBD | 📋 Planned | — |
|
||||
| 23. Resend Setup + Invio Preventivo | v2.3 | 0/? | Not started | — |
|
||||
| 24. Schema + Whitelist Admin | v2.3 | 0/? | Not started | — |
|
||||
| 25. OTP Gate + Sessione | v2.3 | 0/? | Not started | — |
|
||||
|
||||
---
|
||||
|
||||
## Requirement Coverage (v2.3)
|
||||
|
||||
| Requirement | Phase |
|
||||
|-------------|-------|
|
||||
| SEND-01 | Phase 23 |
|
||||
| SEND-02 | Phase 23 |
|
||||
| OTP-01 | Phase 24 |
|
||||
| OTP-02 | Phase 25 |
|
||||
| OTP-03 | Phase 25 |
|
||||
| OTP-04 | Phase 25 |
|
||||
| OTP-05 | Phase 25 |
|
||||
| OTP-06 | Phase 25 |
|
||||
| OTP-07 | Phase 25 |
|
||||
|
||||
**Mapped: 9/9. No orphans.**
|
||||
|
||||
---
|
||||
|
||||
## Dependency Chain (v2.3)
|
||||
|
||||
```
|
||||
Phase 23 (Resend config + SDK)
|
||||
└── Phase 24 (schema DB additive: client_emails + otp_codes)
|
||||
└── Phase 25 (OTP gate + sessione cookie 30gg)
|
||||
```
|
||||
|
||||
Phase 23 first: Resend SDK e variabili d'ambiente sono infrastruttura condivisa con Phase 25 (email OTP).
|
||||
Phase 24 before Phase 25: le tabelle `client_emails` e `otp_codes` devono essere in prod (via SSH migration) prima del gate.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes (v2.3)
|
||||
|
||||
**Migration constraint:** `client_emails` e `otp_codes` sono nuove tabelle — migration additiva pura. SQL a mano (drizzle-kit generate rotto da Phase 8). Applicare via SSH tunnel PRIMA di pushare il codice dipendente.
|
||||
|
||||
**OTP middleware layer:** Il token middleware esistente (proxy.ts → `/api/internal/validate-token`) rimane invariato. Il gate OTP è uno strato aggiuntivo dopo la validazione del token, non un suo rimpiazzo.
|
||||
|
||||
**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.
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-06-21 — v2.3 Email & Accesso*
|
||||
|
||||
+33
-31
@@ -1,47 +1,45 @@
|
||||
---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v2.2
|
||||
milestone_name: — Sales Loop
|
||||
status: complete
|
||||
stopped_at: "v2.2 completa — tutto in prod. Backlog: PUB-03 email Resend, PROP-03 Stripe, PROP-04 auto-provisioning"
|
||||
last_updated: "2026-06-20T16:30:00.000Z"
|
||||
last_activity: 2026-06-20 -- v2.2 chiusa — 5/5 fasi complete, REQUIREMENTS aggiornati, SUMMARY.md scritti per 21+22
|
||||
milestone: v2.3
|
||||
milestone_name: Email & Accesso
|
||||
status: planning
|
||||
stopped_at: ""
|
||||
last_updated: "2026-06-21T11:05:00.000Z"
|
||||
last_activity: 2026-06-21 -- Roadmap v2.3 created (Phases 23-25)
|
||||
progress:
|
||||
total_phases: 5
|
||||
completed_phases: 5
|
||||
total_plans: 7
|
||||
completed_plans: 7
|
||||
percent: 100
|
||||
total_phases: 3
|
||||
completed_phases: 0
|
||||
total_plans: 0
|
||||
completed_plans: 0
|
||||
percent: 0
|
||||
---
|
||||
|
||||
# Project State
|
||||
|
||||
## Project Reference
|
||||
|
||||
See: .planning/PROJECT.md (updated 2026-06-13)
|
||||
See: .planning/PROJECT.md (updated 2026-06-21)
|
||||
|
||||
**Core value:** Il cliente apre il link e vede esattamente a che punto è il suo progetto, cosa deve ancora succedere e cosa ha già approvato — senza dover scrivere email per chiedere aggiornamenti.
|
||||
|
||||
**Current focus:** Milestone **v2.2 "Sales Loop"** (reset 2026-06-19). North-star: lead in pipeline Kanban → transcript call → agente AI genera preventivo → pagina pubblica `/preventivo/[slug]` → vinto/perso. Piano: `.claude/plans/glittery-sprouting-pudding.md`. Prossimo passo: eseguire Phase 19 (R2) Pipeline CRM Kanban.
|
||||
**Current focus:** Milestone **v2.3 "Email & Accesso"** (started 2026-06-21). North-star: OTP gate per il portale cliente + invio link preventivo via email — un'unica integrazione Resend condivisa. Roadmap: `.planning/ROADMAP.md` (Phases 23–25). Prossimo passo: `/gsd-plan-phase 23`.
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 20 (R3) Knowledge Base Cliente — ready to execute
|
||||
Plan: 3 piani (20-01 migration, 20-02 data layer, 20-03 UI)
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-06-19 -- Phase 20 planned (3 piani, verification passed — KB-01/KB-02)
|
||||
Phase: Not started
|
||||
Plan: —
|
||||
Status: Roadmap created, ready to plan Phase 23
|
||||
Last activity: 2026-06-21 — Roadmap v2.3 created (Phases 23–25, 9 requirements mapped)
|
||||
|
||||
Progress (v2.2): [████░░░░░░] 40% — 2/5 fasi complete
|
||||
### Fasi completate (v2.2, storico)
|
||||
|
||||
### Fasi completate (v2.1, storico)
|
||||
|
||||
Phase 11 (catalogo), Phase 12 (offer editor), Phase 14 (CRM Attio) — consegnate e in prod. 55 servizi reali + tag offerta caricati.
|
||||
Phase 18 (cleanup), Phase 19 (Kanban CRM), Phase 20 (transcript KB), Phase 21 (AI agent), Phase 22 (deck pubblico) — consegnate e in prod 2026-06-20.
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
|
||||
- Total plans completed: 7 (v2.1)
|
||||
- Total plans completed: 7 (v2.1) + 9 (v2.2) = 16 totali
|
||||
- Average duration: —
|
||||
- Total execution time: —
|
||||
|
||||
@@ -70,6 +68,9 @@ Phase 11 (catalogo), Phase 12 (offer editor), Phase 14 (CRM Attio) — consegnat
|
||||
Decisions are logged in PROJECT.md Key Decisions table.
|
||||
Recent decisions affecting current work:
|
||||
|
||||
- **[v2.3 2026-06-21] Resend come provider email unico** — PUB-03 (invio preventivo) e AUTH-OTP-01 (OTP gate) condividono la stessa integrazione Resend. Phase 23 configura SDK + env vars, Phase 25 li riusa.
|
||||
- **[v2.3 2026-06-21] OTP gate è strato aggiuntivo, non rimpiazzo del token middleware** — proxy.ts e `/api/internal/validate-token` rimangono invariati. Il gate OTP interviene dopo la validazione del token, nel rendering della route `/client/[token]/*`.
|
||||
- **[v2.3 2026-06-21] Migration Phase 24 è additiva pura** — `client_emails` e `otp_codes` sono nuove tabelle. Nessun drop/truncate. SQL a mano (drizzle-kit generate rotto). Applicare via SSH prima del codice dipendente.
|
||||
- **[RESET 2026-06-19] Milestone v2.2 "Sales Loop"** sostituisce le fasi residue v2.1. Decisioni bloccate: (1) URL preventivo = `/preventivo/[slug]` pubblico; (2) tagliare Forecast + quote builder manuale + Phase 15, fondere `/admin/analytics` nella dashboard; (3) portale post-vendita resta core, non si tocca (Phase 13 congelata); (4) agente AI = "io scelgo l'offerta, l'AI personalizza" leggendo i transcript, provider Claude. Piano: `.claude/plans/glittery-sprouting-pudding.md`
|
||||
- [SUPERSEDED dal reset] v2.1 roadmap: Offer Studio (Phases 11-15) sequenced before Proposal AI (Phases 16-17) — clean/fast data UX before the AI builder
|
||||
- Phase 11 bundles catalog database-view UX (OFFER-07..10) with legacy consolidation (OFFER-13) since the new UX should be built on a single unified `services` table, not on top of legacy `service_catalog`/`offer_services`
|
||||
@@ -91,10 +92,9 @@ None yet.
|
||||
|
||||
### Blockers/Concerns
|
||||
|
||||
- **Migrations (sempre valido)**: ogni fase con schema (es. Phase 20 transcript) DEVE avere la migration applicata a prod via tunnel SSH (`ssh -L 54321:localhost:54321 root@178.104.27.55`, `DATABASE_URL` riscritto a `127.0.0.1:54321`) PRIMA di pushare il codice dipendente. `drizzle-kit generate` rotto → SQL a mano.
|
||||
- **Phase 21 (AI)**: nessuna integrazione AI ancora nel codice. Servirà chiave Anthropic API in env (Coolify) + decisioni modello/prompt in fase di planning.
|
||||
- **Debito tecnico (non bloccante)**: tabelle legacy `service_catalog`/`offer_services`/`offer_micro_services` restano come deadweight; `createService`/`serviceSchema` dead code in `catalog/actions.ts`. Script di validazione consolidamento Phase 11 (migrate/validate) mai eseguiti ma OFFER-13 è di fatto soddisfatto (catalogo `services` in uso, 55 servizi reali caricati).
|
||||
- **[RISOLTO]** ~~Phase 15 bloccata su mockup~~ → fase abbandonata dal reset 2026-06-19.
|
||||
- **Migrations (sempre valido)**: ogni fase con schema (Phase 24: `client_emails` + `otp_codes`) DEVE avere la migration applicata a prod via tunnel SSH (`ssh -L 54321:localhost:54321 root@178.104.27.55`, `DATABASE_URL` riscritto a `127.0.0.1:54321`) PRIMA di pushare il codice dipendente. `drizzle-kit generate` rotto → SQL a mano.
|
||||
- **Resend env vars**: `RESEND_API_KEY` e `RESEND_FROM` devono essere aggiunti a Coolify prima di testare Phase 23 in prod. Stessa procedura di `ANTHROPIC_API_KEY` (2026-06-20).
|
||||
- **Debito tecnico (non bloccante)**: tabelle legacy `service_catalog`/`offer_services`/`offer_micro_services` restano come deadweight; `createService`/`serviceSchema` dead code in `catalog/actions.ts`.
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -102,12 +102,14 @@ Items acknowledged and carried forward from previous milestone close:
|
||||
|
||||
| Category | Item | Status | Deferred At |
|
||||
|----------|------|--------|-------------|
|
||||
| v2 | OFFER-14 — Sezioni analitiche stile Notion (psicologia/rating/performance) | Backlog | v2.1 kickoff |
|
||||
| v2 | AUTH-OTP-01 — Accesso dashboard cliente via OTP email | Design ready, deferred | v2.1 kickoff |
|
||||
| v2.4 | PROP-03 — Stripe Payment Link su deck pubblico | Backlog | v2.3 kickoff |
|
||||
| v2.4 | PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto" | Backlog | v2.3 kickoff |
|
||||
| v2+ | Phase 13 — Servizi attivi/ricorrenti post-vendita | Congelata | v2.1 kickoff |
|
||||
| v2 | OFFER-14 — Sezioni analitiche stile Notion | Backlog | v2.1 kickoff |
|
||||
| v2 | ARCH-01 — Split modulo "compartimento stagno" in deploy separato | Backlog (only if module grows) | v2.1 kickoff |
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-06-19T18:05:00.000Z
|
||||
Stopped at: Phase 19 complete — 19-01-SUMMARY.md scritto, PIPE-01/PIPE-02 verificati, checkpoint umano approvato. Next: /gsd-plan-phase 20
|
||||
Resume file: .planning/phases/19-pipeline-crm-kanban/19-01-SUMMARY.md
|
||||
Last session: 2026-06-21T11:05:00.000Z
|
||||
Stopped at: Roadmap v2.3 created — Phases 23–25, 9/9 requirements mapped. Next: `/gsd-plan-phase 23`
|
||||
Resume file: .planning/ROADMAP.md
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
|
||||
import { ServiceTable, DEFAULT_COL_ORDER } from "@/components/admin/catalog/ServiceTable";
|
||||
import type { ColumnKey } from "@/components/admin/catalog/ServiceTable";
|
||||
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
|
||||
|
||||
function loadColOrder(): ColumnKey[] {
|
||||
if (typeof window === "undefined") return DEFAULT_COL_ORDER;
|
||||
try {
|
||||
const saved = localStorage.getItem("catalog_col_order");
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as ColumnKey[];
|
||||
// Validate: must contain exactly the same keys
|
||||
if (
|
||||
parsed.length === DEFAULT_COL_ORDER.length &&
|
||||
DEFAULT_COL_ORDER.every((k) => parsed.includes(k))
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return DEFAULT_COL_ORDER;
|
||||
}
|
||||
|
||||
function buildSortFn(
|
||||
sortKey: ColumnKey | null,
|
||||
sortDir: "asc" | "desc"
|
||||
): (a: ServiceWithTags, b: ServiceWithTags) => number {
|
||||
if (!sortKey) return () => 0;
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
return (a, b) => {
|
||||
switch (sortKey) {
|
||||
case "nome":
|
||||
return dir * a.name.localeCompare(b.name, "it");
|
||||
case "descrizione":
|
||||
return dir * (a.description ?? "").localeCompare(b.description ?? "", "it");
|
||||
case "fase":
|
||||
return dir * (a.fase ?? "").localeCompare(b.fase ?? "", "it");
|
||||
case "offerta":
|
||||
return dir * a.tags.join(",").localeCompare(b.tags.join(","), "it");
|
||||
case "pacchetto":
|
||||
return dir * a.pacchetto.join(",").localeCompare(b.pacchetto.join(","), "it");
|
||||
case "prezzo": {
|
||||
const na = parseFloat(a.unit_price ?? "0");
|
||||
const nb = parseFloat(b.unit_price ?? "0");
|
||||
return dir * (na - nb);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function CatalogSearch({
|
||||
services,
|
||||
options,
|
||||
@@ -14,19 +62,65 @@ export function CatalogSearch({
|
||||
options: CatalogFieldOptions;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [colOrder, setColOrder] = useState<ColumnKey[]>(() => loadColOrder());
|
||||
const [sortKey, setSortKey] = useState<ColumnKey | null>(null);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
const dragCol = useRef<ColumnKey | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return services;
|
||||
return services.filter((s) => {
|
||||
if (s.name.toLowerCase().includes(q)) return true;
|
||||
if (s.category?.toLowerCase().includes(q)) return true;
|
||||
if (s.fase?.toLowerCase().includes(q)) return true;
|
||||
if (s.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||
if (s.pacchetto.some((p) => p.toLowerCase().includes(q))) return true;
|
||||
return false;
|
||||
function handleSortClick(key: ColumnKey) {
|
||||
if (sortKey === key) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir("asc");
|
||||
}
|
||||
}
|
||||
|
||||
function handleColDragStart(key: ColumnKey) {
|
||||
dragCol.current = key;
|
||||
}
|
||||
|
||||
function handleColDragOver(e: React.DragEvent, key: ColumnKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleColDrop(targetKey: ColumnKey) {
|
||||
const from = dragCol.current;
|
||||
dragCol.current = null;
|
||||
if (!from || from === targetKey) return;
|
||||
setColOrder((prev) => {
|
||||
const next = [...prev];
|
||||
const fromIdx = next.indexOf(from);
|
||||
const toIdx = next.indexOf(targetKey);
|
||||
next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, from);
|
||||
try {
|
||||
localStorage.setItem("catalog_col_order", JSON.stringify(next));
|
||||
} catch {}
|
||||
return next;
|
||||
});
|
||||
}, [services, query]);
|
||||
}
|
||||
|
||||
const sortFn = useMemo(() => buildSortFn(sortKey, sortDir), [sortKey, sortDir]);
|
||||
|
||||
const { activeServices, inactiveServices } = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? services.filter((s) => {
|
||||
if (s.name.toLowerCase().includes(q)) return true;
|
||||
if (s.category?.toLowerCase().includes(q)) return true;
|
||||
if (s.fase?.toLowerCase().includes(q)) return true;
|
||||
if (s.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||
if (s.pacchetto.some((p) => p.toLowerCase().includes(q))) return true;
|
||||
return false;
|
||||
})
|
||||
: services;
|
||||
|
||||
return {
|
||||
activeServices: [...filtered.filter((s) => s.active)].sort(sortFn),
|
||||
inactiveServices: [...filtered.filter((s) => !s.active)].sort(sortFn),
|
||||
};
|
||||
}, [services, query, sortFn]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -40,7 +134,18 @@ export function CatalogSearch({
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<ServiceTable services={filtered} options={options} />
|
||||
<ServiceTable
|
||||
activeServices={activeServices}
|
||||
inactiveServices={inactiveServices}
|
||||
options={options}
|
||||
colOrder={colOrder}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSortClick={handleSortClick}
|
||||
onColDragStart={handleColDragStart}
|
||||
onColDragOver={handleColDragOver}
|
||||
onColDrop={handleColDrop}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use server";
|
||||
|
||||
import { getJsonPool, updateJsonPool, SETTINGS_KEYS } from "@/lib/settings";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type PoolName = "tipo" | "obiettivo" | "categoria";
|
||||
|
||||
function poolKey(pool: PoolName): string {
|
||||
switch (pool) {
|
||||
case "tipo": return SETTINGS_KEYS.OFFER_TIPO_POOL;
|
||||
case "obiettivo": return SETTINGS_KEYS.OFFER_OBIETTIVO_POOL;
|
||||
case "categoria": return SETTINGS_KEYS.OFFER_CATEGORIA_POOL;
|
||||
}
|
||||
}
|
||||
|
||||
export async function addPoolValue(pool: PoolName, value: string): Promise<void> {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
const key = poolKey(pool);
|
||||
const current = await getJsonPool(key);
|
||||
if (current.includes(trimmed)) return;
|
||||
await updateJsonPool(key, [...current, trimmed]);
|
||||
revalidatePath("/admin/impostazioni");
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
|
||||
export async function removePoolValue(pool: PoolName, value: string): Promise<void> {
|
||||
const key = poolKey(pool);
|
||||
const current = await getJsonPool(key);
|
||||
await updateJsonPool(key, current.filter((v) => v !== value));
|
||||
revalidatePath("/admin/impostazioni");
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
|
||||
import { getTargetHourlyRate, getJsonPool, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
|
||||
import { OfferPoolsSection } from "@/components/admin/impostazioni/OfferPoolsSection";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ImpostazioniPage() {
|
||||
const targetRate = await getTargetHourlyRate();
|
||||
const [targetRate, tipoPool, obiettivoPool, categoriaPool] = await Promise.all([
|
||||
getTargetHourlyRate(),
|
||||
getJsonPool(SETTINGS_KEYS.OFFER_TIPO_POOL),
|
||||
getJsonPool(SETTINGS_KEYS.OFFER_OBIETTIVO_POOL),
|
||||
getJsonPool(SETTINGS_KEYS.OFFER_CATEGORIA_POOL),
|
||||
]);
|
||||
|
||||
async function handleSave(fd: FormData) {
|
||||
"use server";
|
||||
@@ -17,43 +23,51 @@ export default async function ImpostazioniPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-6">Impostazioni</h1>
|
||||
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 max-w-md">
|
||||
<h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2>
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
|
||||
<h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2>
|
||||
|
||||
<form action={handleSave} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="target_hourly_rate"
|
||||
className="block text-sm font-medium text-[#1a1a1a] mb-1"
|
||||
>
|
||||
Tariffa oraria target (€/h)
|
||||
</label>
|
||||
<p className="text-xs text-[#71717a] mb-2">
|
||||
Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[#71717a]">€</span>
|
||||
<input
|
||||
id="target_hourly_rate"
|
||||
name="target_hourly_rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={targetRate.toFixed(2)}
|
||||
className="border border-[#e5e7eb] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 w-32"
|
||||
/>
|
||||
<span className="text-sm text-[#71717a]">/h</span>
|
||||
<form action={handleSave} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="target_hourly_rate"
|
||||
className="block text-sm font-medium text-[#1a1a1a] mb-1"
|
||||
>
|
||||
Tariffa oraria target (€/h)
|
||||
</label>
|
||||
<p className="text-xs text-[#71717a] mb-2">
|
||||
Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[#71717a]">€</span>
|
||||
<input
|
||||
id="target_hourly_rate"
|
||||
name="target_hourly_rate"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={targetRate.toFixed(2)}
|
||||
className="border border-[#e5e7eb] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 w-32"
|
||||
/>
|
||||
<span className="text-sm text-[#71717a]">/h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#1A463C] text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors"
|
||||
>
|
||||
Salva
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-[#1A463C] text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors"
|
||||
>
|
||||
Salva
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<OfferPoolsSection
|
||||
tipoPool={tipoPool}
|
||||
obiettivoPool={obiettivoPool}
|
||||
categoriaPool={categoriaPool}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown, GripVertical } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EditableCell } from "@/components/ui/editable-cell";
|
||||
import { OptionSelect } from "@/components/ui/option-select";
|
||||
@@ -15,28 +16,42 @@ import {
|
||||
} from "@/app/admin/catalog/actions";
|
||||
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
|
||||
|
||||
export type ColumnKey =
|
||||
| "nome"
|
||||
| "descrizione"
|
||||
| "fase"
|
||||
| "offerta"
|
||||
| "pacchetto"
|
||||
| "prezzo"
|
||||
| "stato";
|
||||
|
||||
export const COLUMN_DEFS: Record<ColumnKey, { header: string; sortable: boolean; minWidth: string }> = {
|
||||
nome: { header: "Nome", sortable: true, minWidth: "160px" },
|
||||
descrizione: { header: "Descrizione", sortable: false, minWidth: "200px" },
|
||||
fase: { header: "Fase", sortable: true, minWidth: "140px" },
|
||||
offerta: { header: "Offerta", sortable: true, minWidth: "180px" },
|
||||
pacchetto: { header: "Pacchetto", sortable: true, minWidth: "180px" },
|
||||
prezzo: { header: "Prezzo", sortable: true, minWidth: "100px" },
|
||||
stato: { header: "Stato", sortable: false, minWidth: "100px" },
|
||||
};
|
||||
|
||||
export const DEFAULT_COL_ORDER: ColumnKey[] = [
|
||||
"nome", "descrizione", "fase", "offerta", "pacchetto", "prezzo", "stato",
|
||||
];
|
||||
|
||||
function formatPrice(raw: string): string {
|
||||
const num = parseFloat(raw);
|
||||
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
const COLUMN_HEADERS = [
|
||||
"Nome",
|
||||
"Descrizione",
|
||||
"Fase",
|
||||
"Offerta",
|
||||
"Pacchetto",
|
||||
"Prezzo",
|
||||
"Stato",
|
||||
];
|
||||
const COL_COUNT = COLUMN_HEADERS.length;
|
||||
|
||||
function ServiceRow({
|
||||
service,
|
||||
options,
|
||||
colOrder,
|
||||
}: {
|
||||
service: ServiceWithTags;
|
||||
options: CatalogFieldOptions;
|
||||
colOrder: ColumnKey[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
@@ -54,83 +69,116 @@ function ServiceRow({
|
||||
});
|
||||
}
|
||||
|
||||
function renderCell(key: ColumnKey) {
|
||||
switch (key) {
|
||||
case "nome":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
|
||||
<EditableCell
|
||||
value={service.name}
|
||||
type="text"
|
||||
required
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "name", v))}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "descrizione":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3 text-[#71717a] min-w-[200px]">
|
||||
<EditableCell
|
||||
value={service.description ?? ""}
|
||||
type="textarea"
|
||||
placeholder="Descrizione..."
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "fase":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3 min-w-[140px]">
|
||||
<OptionSelect
|
||||
value={service.fase}
|
||||
options={options.fase}
|
||||
onChange={(v) => run(() => updateServiceField(service.id, "fase", v ?? ""))}
|
||||
onRename={(o, n) => run(() => renameServiceOption("fase", o, n))}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "offerta":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3 min-w-[180px]">
|
||||
<OptionMultiSelect
|
||||
values={service.tags}
|
||||
options={options.tag}
|
||||
onAdd={(v) => run(() => addServiceOption("tag", service.id, v))}
|
||||
onRemove={(v) => run(() => removeServiceOption("tag", service.id, v))}
|
||||
onRename={(o, n) => run(() => renameServiceOption("tag", o, n))}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "pacchetto":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3 min-w-[180px]">
|
||||
<OptionMultiSelect
|
||||
values={service.pacchetto}
|
||||
options={options.pacchetto}
|
||||
onAdd={(v) => run(() => addServiceOption("pacchetto", service.id, v))}
|
||||
onRemove={(v) => run(() => removeServiceOption("pacchetto", service.id, v))}
|
||||
onRename={(o, n) => run(() => renameServiceOption("pacchetto", o, n))}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "prezzo":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3 tabular-nums min-w-[100px]">
|
||||
<EditableCell
|
||||
value={service.unit_price}
|
||||
type="number"
|
||||
formatDisplay={formatPrice}
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "unit_price", v))}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "stato":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3 min-w-[100px]">
|
||||
<EditableCell
|
||||
value={service.active ? "true" : "false"}
|
||||
type="toggle"
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "active", v))}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${
|
||||
!service.active ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
|
||||
<EditableCell
|
||||
value={service.name}
|
||||
type="text"
|
||||
required
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "name", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-[#71717a] min-w-[200px]">
|
||||
<EditableCell
|
||||
value={service.description ?? ""}
|
||||
type="textarea"
|
||||
placeholder="Descrizione..."
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[140px]">
|
||||
<OptionSelect
|
||||
value={service.fase}
|
||||
options={options.fase}
|
||||
onChange={(v) => run(() => updateServiceField(service.id, "fase", v ?? ""))}
|
||||
onRename={(o, n) => run(() => renameServiceOption("fase", o, n))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[180px]">
|
||||
<OptionMultiSelect
|
||||
values={service.tags}
|
||||
options={options.tag}
|
||||
onAdd={(v) => run(() => addServiceOption("tag", service.id, v))}
|
||||
onRemove={(v) => run(() => removeServiceOption("tag", service.id, v))}
|
||||
onRename={(o, n) => run(() => renameServiceOption("tag", o, n))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[180px]">
|
||||
<OptionMultiSelect
|
||||
values={service.pacchetto}
|
||||
options={options.pacchetto}
|
||||
onAdd={(v) => run(() => addServiceOption("pacchetto", service.id, v))}
|
||||
onRemove={(v) => run(() => removeServiceOption("pacchetto", service.id, v))}
|
||||
onRename={(o, n) => run(() => renameServiceOption("pacchetto", o, n))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 tabular-nums min-w-[100px]">
|
||||
<EditableCell
|
||||
value={service.unit_price}
|
||||
type="number"
|
||||
formatDisplay={formatPrice}
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "unit_price", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[100px]">
|
||||
<EditableCell
|
||||
value={service.active ? "true" : "false"}
|
||||
type="toggle"
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "active", v))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{error && (
|
||||
<tr>
|
||||
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
||||
{error}
|
||||
</td>
|
||||
<tr
|
||||
className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${
|
||||
!service.active ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{colOrder.map((key) => renderCell(key))}
|
||||
</tr>
|
||||
)}
|
||||
{error && (
|
||||
<tr>
|
||||
<td colSpan={colOrder.length} className="py-1 px-3 text-xs text-red-600">
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
||||
function QuickAddRow({
|
||||
options,
|
||||
colOrder,
|
||||
}: {
|
||||
options: CatalogFieldOptions;
|
||||
colOrder: ColumnKey[];
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [fase, setFase] = useState("");
|
||||
@@ -172,77 +220,120 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
||||
const cellInput =
|
||||
"border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary";
|
||||
|
||||
function renderCell(key: ColumnKey) {
|
||||
switch (key) {
|
||||
case "nome":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="+ Aggiungi servizio"
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "descrizione":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3">
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Descrizione..."
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "fase":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3">
|
||||
<Input
|
||||
value={fase}
|
||||
onChange={(e) => setFase(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Fase..."
|
||||
list="catalog-fase-pool"
|
||||
className={cellInput}
|
||||
/>
|
||||
<datalist id="catalog-fase-pool">
|
||||
{options.fase.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
</td>
|
||||
);
|
||||
case "prezzo":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3">
|
||||
<Input
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="0,00"
|
||||
inputMode="decimal"
|
||||
className={`${cellInput} tabular-nums`}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
case "stato":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3 text-xs text-[#a1a1aa]">
|
||||
Invio ↵
|
||||
</td>
|
||||
);
|
||||
default:
|
||||
return <td key={key} className="py-2 px-3 text-xs text-[#a1a1aa]" />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
|
||||
<td className="py-2 px-3">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="+ Aggiungi servizio"
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Descrizione..."
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<Input
|
||||
value={fase}
|
||||
onChange={(e) => setFase(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Fase..."
|
||||
list="catalog-fase-pool"
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-xs text-[#a1a1aa]" colSpan={2}>
|
||||
Offerta e pacchetto dopo la creazione
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<Input
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="0,00"
|
||||
inputMode="decimal"
|
||||
className={`${cellInput} tabular-nums`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-xs text-[#a1a1aa]">Invio ↵</td>
|
||||
<datalist id="catalog-fase-pool">
|
||||
{options.fase.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
</tr>
|
||||
{error && (
|
||||
<tr>
|
||||
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
||||
{error}
|
||||
</td>
|
||||
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
|
||||
{colOrder.map((key) => renderCell(key))}
|
||||
</tr>
|
||||
)}
|
||||
{error && (
|
||||
<tr>
|
||||
<td colSpan={colOrder.length} className="py-1 px-3 text-xs text-red-600">
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceTable({
|
||||
services,
|
||||
activeServices,
|
||||
inactiveServices,
|
||||
options,
|
||||
colOrder,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSortClick,
|
||||
onColDragStart,
|
||||
onColDragOver,
|
||||
onColDrop,
|
||||
}: {
|
||||
services: ServiceWithTags[];
|
||||
activeServices: ServiceWithTags[];
|
||||
inactiveServices: ServiceWithTags[];
|
||||
options: CatalogFieldOptions;
|
||||
colOrder: ColumnKey[];
|
||||
sortKey: ColumnKey | null;
|
||||
sortDir: "asc" | "desc";
|
||||
onSortClick: (key: ColumnKey) => void;
|
||||
onColDragStart: (key: ColumnKey) => void;
|
||||
onColDragOver: (e: React.DragEvent, key: ColumnKey) => void;
|
||||
onColDrop: (key: ColumnKey) => void;
|
||||
}) {
|
||||
const activeServices = services.filter((s) => s.active);
|
||||
const inactiveServices = services.filter((s) => !s.active);
|
||||
function SortIcon({ colKey }: { colKey: ColumnKey }) {
|
||||
if (!COLUMN_DEFS[colKey].sortable) return null;
|
||||
if (sortKey !== colKey) return <ArrowUpDown className="inline ml-1 h-3 w-3 opacity-40" />;
|
||||
return sortDir === "asc"
|
||||
? <ArrowUp className="inline ml-1 h-3 w-3 text-[#1A463C]" />
|
||||
: <ArrowDown className="inline ml-1 h-3 w-3 text-[#1A463C]" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
||||
@@ -250,33 +341,48 @@ export function ServiceTable({
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
|
||||
<tr>
|
||||
{COLUMN_HEADERS.map((header) => (
|
||||
<th
|
||||
key={header}
|
||||
className="text-left py-2 px-3 font-semibold text-[#71717a]"
|
||||
>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
{colOrder.map((key) => {
|
||||
const def = COLUMN_DEFS[key];
|
||||
return (
|
||||
<th
|
||||
key={key}
|
||||
draggable
|
||||
onDragStart={() => onColDragStart(key)}
|
||||
onDragOver={(e) => onColDragOver(e, key)}
|
||||
onDrop={() => onColDrop(key)}
|
||||
onClick={() => def.sortable && onSortClick(key)}
|
||||
className={`text-left py-2 px-3 font-semibold text-[#71717a] select-none whitespace-nowrap group ${
|
||||
def.sortable ? "cursor-pointer hover:text-[#1a1a1a]" : "cursor-grab"
|
||||
}`}
|
||||
style={{ minWidth: def.minWidth }}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<GripVertical className="h-3 w-3 opacity-0 group-hover:opacity-30 transition-opacity shrink-0" />
|
||||
{def.header}
|
||||
<SortIcon colKey={key} />
|
||||
</span>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeServices.map((service) => (
|
||||
<ServiceRow key={service.id} service={service} options={options} />
|
||||
<ServiceRow key={service.id} service={service} options={options} colOrder={colOrder} />
|
||||
))}
|
||||
<QuickAddRow options={options} />
|
||||
<QuickAddRow options={options} colOrder={colOrder} />
|
||||
{inactiveServices.length > 0 && (
|
||||
<>
|
||||
<tr className="bg-[#fafafa]">
|
||||
<td
|
||||
colSpan={COL_COUNT}
|
||||
colSpan={colOrder.length}
|
||||
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-[#a1a1aa]"
|
||||
>
|
||||
Servizi disattivati
|
||||
</td>
|
||||
</tr>
|
||||
{inactiveServices.map((service) => (
|
||||
<ServiceRow key={service.id} service={service} options={options} />
|
||||
<ServiceRow key={service.id} service={service} options={options} colOrder={colOrder} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { PoolManager } from "./PoolManager";
|
||||
import { addPoolValue, removePoolValue } from "@/app/admin/impostazioni/actions";
|
||||
|
||||
export function OfferPoolsSection({
|
||||
tipoPool,
|
||||
obiettivoPool,
|
||||
categoriaPool,
|
||||
}: {
|
||||
tipoPool: string[];
|
||||
obiettivoPool: string[];
|
||||
categoriaPool: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
|
||||
<h2 className="text-base font-semibold text-[#1a1a1a] mb-1">Categorie Offerta</h2>
|
||||
<p className="text-xs text-[#71717a] mb-5">
|
||||
Valori disponibili nei campi Tipo, Obiettivo e Categoria dell'editor offerte.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<PoolManager
|
||||
label="Tipo offerta"
|
||||
pool={tipoPool}
|
||||
onAdd={(v) => addPoolValue("tipo", v)}
|
||||
onRemove={(v) => removePoolValue("tipo", v)}
|
||||
/>
|
||||
<PoolManager
|
||||
label="Obiettivo"
|
||||
pool={obiettivoPool}
|
||||
onAdd={(v) => addPoolValue("obiettivo", v)}
|
||||
onRemove={(v) => removePoolValue("obiettivo", v)}
|
||||
/>
|
||||
<PoolManager
|
||||
label="Categoria"
|
||||
pool={categoriaPool}
|
||||
onAdd={(v) => addPoolValue("categoria", v)}
|
||||
onRemove={(v) => removePoolValue("categoria", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
export function PoolManager({
|
||||
label,
|
||||
pool,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: {
|
||||
label: string;
|
||||
pool: string[];
|
||||
onAdd: (value: string) => Promise<void>;
|
||||
onRemove: (value: string) => Promise<void>;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleAdd() {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return;
|
||||
if (pool.includes(trimmed)) {
|
||||
setError("Valore già presente");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
await onAdd(trimmed);
|
||||
setInput("");
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemove(value: string) {
|
||||
startTransition(() => onRemove(value));
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-[180px]">
|
||||
<p className="text-sm font-medium text-[#1a1a1a] mb-2">{label}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3 min-h-[28px]">
|
||||
{pool.length === 0 && (
|
||||
<span className="text-xs text-[#a1a1aa]">Nessun valore</span>
|
||||
)}
|
||||
{pool.map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className="inline-flex items-center gap-1 bg-[#f4f4f5] text-[#1a1a1a] text-xs px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(v)}
|
||||
disabled={isPending}
|
||||
className="text-[#71717a] hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
aria-label={`Rimuovi ${v}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setError(null); }}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Aggiungi..."
|
||||
disabled={isPending}
|
||||
className="border border-[#e5e7eb] rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 flex-1 min-w-0 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={isPending || !input.trim()}
|
||||
className="bg-[#1A463C] text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors disabled:opacity-40"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useTransition } from "react";
|
||||
import { useState, useMemo, useTransition, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
@@ -86,6 +86,13 @@ export function OfferEditorClient({
|
||||
const [tipoTags, setTipoTags] = useState<string[]>(data.tipoTags);
|
||||
const [obiettivoTags, setObiettivoTags] = useState<string[]>(data.obiettivoTags);
|
||||
|
||||
// After first draft save router.refresh() assigns real DB IDs to new tiers.
|
||||
// Re-sync tiers state when those IDs arrive so subsequent saves UPDATE instead of INSERT.
|
||||
const tierIdKey = data.tiers.map((t) => t.id).sort().join(",");
|
||||
useEffect(() => {
|
||||
setTiers(padTiers(data.tiers));
|
||||
}, [tierIdKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const [categoriaOptions, setCategoriaOptions] = useState<string[]>(fieldOptions.categoria);
|
||||
const [ticketOptions, setTicketOptions] = useState<string[]>(fieldOptions.ticket);
|
||||
const [tipoOptions, setTipoOptions] = useState<string[]>(fieldOptions.tipo);
|
||||
|
||||
+27
-13
@@ -8,6 +8,7 @@ import {
|
||||
services,
|
||||
tags,
|
||||
} from "@/db/schema";
|
||||
import { getJsonPool, SETTINGS_KEYS } from "@/lib/settings";
|
||||
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
|
||||
import { eq, and, asc, inArray, sql } from "drizzle-orm";
|
||||
|
||||
@@ -272,15 +273,21 @@ export type OfferFieldOptions = {
|
||||
};
|
||||
|
||||
// Shared option pools for the offer editor's select fields (Notion-style
|
||||
// dropdowns), mirroring getCatalogFieldOptions from admin-queries.ts.
|
||||
// dropdowns). Tipo, Obiettivo, and Categoria are merged from the managed
|
||||
// settings pools + any values already in use (retrocompatibility).
|
||||
export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
|
||||
const categoriaRows = await db.selectDistinct({ value: offer_macros.category }).from(offer_macros);
|
||||
const ticketRows = await db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros);
|
||||
|
||||
const tagRows = await db
|
||||
.selectDistinct({ name: tags.name, type: tags.entity_type })
|
||||
.from(tags)
|
||||
.where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY]));
|
||||
const [categoriaRows, ticketRows, tagRows, tipoPool, obiettivoPool, categoriaPool] =
|
||||
await Promise.all([
|
||||
db.selectDistinct({ value: offer_macros.category }).from(offer_macros),
|
||||
db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros),
|
||||
db
|
||||
.selectDistinct({ name: tags.name, type: tags.entity_type })
|
||||
.from(tags)
|
||||
.where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])),
|
||||
getJsonPool(SETTINGS_KEYS.OFFER_TIPO_POOL),
|
||||
getJsonPool(SETTINGS_KEYS.OFFER_OBIETTIVO_POOL),
|
||||
getJsonPool(SETTINGS_KEYS.OFFER_CATEGORIA_POOL),
|
||||
]);
|
||||
|
||||
const sortUnique = (arr: (string | null)[]) =>
|
||||
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
|
||||
@@ -288,11 +295,18 @@ export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
|
||||
);
|
||||
|
||||
return {
|
||||
categoria: sortUnique(categoriaRows.map((r) => r.value)),
|
||||
categoria: sortUnique([
|
||||
...categoriaPool,
|
||||
...categoriaRows.map((r) => r.value),
|
||||
]),
|
||||
ticket: sortUnique(ticketRows.map((r) => r.value)),
|
||||
tipo: sortUnique(tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name)),
|
||||
obiettivo: sortUnique(
|
||||
tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name)
|
||||
),
|
||||
tipo: sortUnique([
|
||||
...tipoPool,
|
||||
...tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name),
|
||||
]),
|
||||
obiettivo: sortUnique([
|
||||
...obiettivoPool,
|
||||
...tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name),
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import { revalidatePath } from "next/cache";
|
||||
|
||||
export const SETTINGS_KEYS = {
|
||||
TARGET_HOURLY_RATE: "target_hourly_rate",
|
||||
OFFER_TIPO_POOL: "offer_tipo_pool",
|
||||
OFFER_OBIETTIVO_POOL: "offer_obiettivo_pool",
|
||||
OFFER_CATEGORIA_POOL: "offer_categoria_pool",
|
||||
} as const;
|
||||
|
||||
export async function getSetting(key: string): Promise<string | null> {
|
||||
@@ -32,4 +35,22 @@ export async function updateSetting(key: string, value: string): Promise<void> {
|
||||
export async function getTargetHourlyRate(): Promise<number> {
|
||||
const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
|
||||
return value ? parseFloat(value) : 50;
|
||||
}
|
||||
|
||||
export async function getJsonPool(key: string): Promise<string[]> {
|
||||
const raw = await getSetting(key);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateJsonPool(key: string, values: string[]): Promise<void> {
|
||||
const sorted = [...new Set(values.filter((v) => v.trim()))].sort((a, b) =>
|
||||
a.localeCompare(b, "it")
|
||||
);
|
||||
await updateSetting(key, JSON.stringify(sorted));
|
||||
}
|
||||
Reference in New Issue
Block a user