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.
|
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:
|
**Target features:**
|
||||||
- **PUB-03** — Invio link preventivo via email Resend (primo candidato, piccola effort)
|
- AUTH-OTP-01 — OTP gate portale cliente (whitelist email + sessione 30gg + admin UI)
|
||||||
- **PROP-03/04** — Stripe Payment Link + auto-provisioning al "Vinto"
|
- PUB-03 — Invio link `/preventivo/[slug]` via email Resend dall'admin
|
||||||
- **AUTH-OTP-01** — Accesso cliente via OTP email (design già pronto)
|
|
||||||
- **Phase 13** — Servizi attivi/ricorrenti post-vendita (congelata, ripescabile)
|
**Backlog v2.4+:** PROP-03 (Stripe Payment Link), PROP-04 (auto-provisioning al "Vinto"), Phase 13 (servizi ricorrenti)
|
||||||
|
|
||||||
## Requirements
|
## 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)
|
- ✓ 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)
|
- ✓ 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)
|
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email: whitelist `client_emails`, gate `/client/[token]/*`, sessione 30gg, admin whitelist UI
|
||||||
- [ ] PROP-03 — Stripe Payment Link su offerta pubblica
|
- [ ] PUB-03 — Invia link `/preventivo/[slug]` via email Resend dall'admin
|
||||||
- [ ] PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto"
|
|
||||||
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email (design pronto)
|
|
||||||
|
|
||||||
### Out of Scope
|
### Out of Scope
|
||||||
|
|
||||||
@@ -70,7 +68,6 @@ Validated in v2.2 Sales Loop (shipped 2026-06-20):
|
|||||||
- App mobile nativa — solo web responsive
|
- App mobile nativa — solo web responsive
|
||||||
- Multi-utente con team — solo tu come admin per ora
|
- Multi-utente con team — solo tu come admin per ora
|
||||||
- Prezzi singoli visibili al cliente — vede solo il totale accettato
|
- 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)
|
- File hosting — documenti solo come URL esterni (v1 constraint, ancora valido)
|
||||||
- Sezioni analitiche stile Notion (psicologia, rating, performance) — fuori v2.1, eventuale milestone futura
|
- 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
|
- 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
|
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.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.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.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
|
## Phases
|
||||||
|
|
||||||
@@ -34,15 +34,49 @@ Archivio completo: [milestones/v2.2-ROADMAP.md](milestones/v2.2-ROADMAP.md)
|
|||||||
|
|
||||||
</details>
|
</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
|
## Phase Details
|
||||||
- [ ] PROP-03 — Stripe Payment Link su offerta pubblica
|
|
||||||
- [ ] PROP-04 — Auto-provisioning cliente/progetto/fasi al "Vinto"
|
### Phase 23: Resend Setup + Invio Preventivo
|
||||||
- [ ] AUTH-OTP-01 — Accesso cliente via OTP email (design pronto)
|
**Goal**: Admin può inviare il link `/preventivo/[slug]` via email con un click dall'admin UI
|
||||||
- [ ] Phase 13 — Servizi attivi/ricorrenti post-vendita (congelata, ripescabile)
|
**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
|
## 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 |
|
| 20. Knowledge Base Cliente | v2.2 | 3/3 | ✅ Done | 2026-06-20 |
|
||||||
| 21. Agente AI Preventivo | v2.2 | 1/1 | ✅ 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 |
|
| 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
|
gsd_state_version: 1.0
|
||||||
milestone: v2.2
|
milestone: v2.3
|
||||||
milestone_name: — Sales Loop
|
milestone_name: Email & Accesso
|
||||||
status: complete
|
status: planning
|
||||||
stopped_at: "v2.2 completa — tutto in prod. Backlog: PUB-03 email Resend, PROP-03 Stripe, PROP-04 auto-provisioning"
|
stopped_at: ""
|
||||||
last_updated: "2026-06-20T16:30:00.000Z"
|
last_updated: "2026-06-21T11:05:00.000Z"
|
||||||
last_activity: 2026-06-20 -- v2.2 chiusa — 5/5 fasi complete, REQUIREMENTS aggiornati, SUMMARY.md scritti per 21+22
|
last_activity: 2026-06-21 -- Roadmap v2.3 created (Phases 23-25)
|
||||||
progress:
|
progress:
|
||||||
total_phases: 5
|
total_phases: 3
|
||||||
completed_phases: 5
|
completed_phases: 0
|
||||||
total_plans: 7
|
total_plans: 0
|
||||||
completed_plans: 7
|
completed_plans: 0
|
||||||
percent: 100
|
percent: 0
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
## Project Reference
|
## 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.
|
**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
|
## Current Position
|
||||||
|
|
||||||
Phase: 20 (R3) Knowledge Base Cliente — ready to execute
|
Phase: Not started
|
||||||
Plan: 3 piani (20-01 migration, 20-02 data layer, 20-03 UI)
|
Plan: —
|
||||||
Status: Ready to execute
|
Status: Roadmap created, ready to plan Phase 23
|
||||||
Last activity: 2026-06-19 -- Phase 20 planned (3 piani, verification passed — KB-01/KB-02)
|
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 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.
|
||||||
|
|
||||||
Phase 11 (catalogo), Phase 12 (offer editor), Phase 14 (CRM Attio) — consegnate e in prod. 55 servizi reali + tag offerta caricati.
|
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
**Velocity:**
|
**Velocity:**
|
||||||
|
|
||||||
- Total plans completed: 7 (v2.1)
|
- Total plans completed: 7 (v2.1) + 9 (v2.2) = 16 totali
|
||||||
- Average duration: —
|
- Average duration: —
|
||||||
- Total execution time: —
|
- 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.
|
Decisions are logged in PROJECT.md Key Decisions table.
|
||||||
Recent decisions affecting current work:
|
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`
|
- **[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
|
- [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`
|
- 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
|
### 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.
|
- **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.
|
||||||
- **Phase 21 (AI)**: nessuna integrazione AI ancora nel codice. Servirà chiave Anthropic API in env (Coolify) + decisioni modello/prompt in fase di planning.
|
- **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`. Script di validazione consolidamento Phase 11 (migrate/validate) mai eseguiti ma OFFER-13 è di fatto soddisfatto (catalogo `services` in uso, 55 servizi reali caricati).
|
- **Debito tecnico (non bloccante)**: tabelle legacy `service_catalog`/`offer_services`/`offer_micro_services` restano come deadweight; `createService`/`serviceSchema` dead code in `catalog/actions.ts`.
|
||||||
- **[RISOLTO]** ~~Phase 15 bloccata su mockup~~ → fase abbandonata dal reset 2026-06-19.
|
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
@@ -102,12 +102,14 @@ Items acknowledged and carried forward from previous milestone close:
|
|||||||
|
|
||||||
| Category | Item | Status | Deferred At |
|
| Category | Item | Status | Deferred At |
|
||||||
|----------|------|--------|-------------|
|
|----------|------|--------|-------------|
|
||||||
| v2 | OFFER-14 — Sezioni analitiche stile Notion (psicologia/rating/performance) | Backlog | v2.1 kickoff |
|
| v2.4 | PROP-03 — Stripe Payment Link su deck pubblico | Backlog | v2.3 kickoff |
|
||||||
| v2 | AUTH-OTP-01 — Accesso dashboard cliente via OTP email | Design ready, deferred | v2.1 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 |
|
| v2 | ARCH-01 — Split modulo "compartimento stagno" in deploy separato | Backlog (only if module grows) | v2.1 kickoff |
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-06-19T18:05:00.000Z
|
Last session: 2026-06-21T11: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
|
Stopped at: Roadmap v2.3 created — Phases 23–25, 9/9 requirements mapped. Next: `/gsd-plan-phase 23`
|
||||||
Resume file: .planning/phases/19-pipeline-crm-kanban/19-01-SUMMARY.md
|
Resume file: .planning/ROADMAP.md
|
||||||
|
|||||||
@@ -1,11 +1,59 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useRef } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search } from "lucide-react";
|
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";
|
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({
|
export function CatalogSearch({
|
||||||
services,
|
services,
|
||||||
options,
|
options,
|
||||||
@@ -14,19 +62,65 @@ export function CatalogSearch({
|
|||||||
options: CatalogFieldOptions;
|
options: CatalogFieldOptions;
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
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(() => {
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortFn = useMemo(() => buildSortFn(sortKey, sortDir), [sortKey, sortDir]);
|
||||||
|
|
||||||
|
const { activeServices, inactiveServices } = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return services;
|
const filtered = q
|
||||||
return services.filter((s) => {
|
? services.filter((s) => {
|
||||||
if (s.name.toLowerCase().includes(q)) return true;
|
if (s.name.toLowerCase().includes(q)) return true;
|
||||||
if (s.category?.toLowerCase().includes(q)) return true;
|
if (s.category?.toLowerCase().includes(q)) return true;
|
||||||
if (s.fase?.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.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||||
if (s.pacchetto.some((p) => p.toLowerCase().includes(q))) return true;
|
if (s.pacchetto.some((p) => p.toLowerCase().includes(q))) return true;
|
||||||
return false;
|
return false;
|
||||||
});
|
})
|
||||||
}, [services, query]);
|
: services;
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeServices: [...filtered.filter((s) => s.active)].sort(sortFn),
|
||||||
|
inactiveServices: [...filtered.filter((s) => !s.active)].sort(sortFn),
|
||||||
|
};
|
||||||
|
}, [services, query, sortFn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -40,7 +134,18 @@ export function CatalogSearch({
|
|||||||
className="pl-9 h-9"
|
className="pl-9 h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</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 const revalidate = 0;
|
||||||
|
|
||||||
export default async function ImpostazioniPage() {
|
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) {
|
async function handleSave(fd: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -17,7 +23,8 @@ export default async function ImpostazioniPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-6">Impostazioni</h1>
|
<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">
|
<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>
|
<h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2>
|
||||||
|
|
||||||
<form action={handleSave} className="space-y-4">
|
<form action={handleSave} className="space-y-4">
|
||||||
@@ -54,6 +61,13 @@ export default async function ImpostazioniPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OfferPoolsSection
|
||||||
|
tipoPool={tipoPool}
|
||||||
|
obiettivoPool={obiettivoPool}
|
||||||
|
categoriaPool={categoriaPool}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ArrowUp, ArrowDown, ArrowUpDown, GripVertical } from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { EditableCell } from "@/components/ui/editable-cell";
|
import { EditableCell } from "@/components/ui/editable-cell";
|
||||||
import { OptionSelect } from "@/components/ui/option-select";
|
import { OptionSelect } from "@/components/ui/option-select";
|
||||||
@@ -15,28 +16,42 @@ import {
|
|||||||
} from "@/app/admin/catalog/actions";
|
} from "@/app/admin/catalog/actions";
|
||||||
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
|
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 {
|
function formatPrice(raw: string): string {
|
||||||
const num = parseFloat(raw);
|
const num = parseFloat(raw);
|
||||||
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
|
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({
|
function ServiceRow({
|
||||||
service,
|
service,
|
||||||
options,
|
options,
|
||||||
|
colOrder,
|
||||||
}: {
|
}: {
|
||||||
service: ServiceWithTags;
|
service: ServiceWithTags;
|
||||||
options: CatalogFieldOptions;
|
options: CatalogFieldOptions;
|
||||||
|
colOrder: ColumnKey[];
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
@@ -54,14 +69,11 @@ function ServiceRow({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCell(key: ColumnKey) {
|
||||||
|
switch (key) {
|
||||||
|
case "nome":
|
||||||
return (
|
return (
|
||||||
<>
|
<td key={key} className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
|
||||||
<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
|
<EditableCell
|
||||||
value={service.name}
|
value={service.name}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -69,7 +81,10 @@ function ServiceRow({
|
|||||||
onSave={(v) => run(() => updateServiceField(service.id, "name", v))}
|
onSave={(v) => run(() => updateServiceField(service.id, "name", v))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-[#71717a] min-w-[200px]">
|
);
|
||||||
|
case "descrizione":
|
||||||
|
return (
|
||||||
|
<td key={key} className="py-2 px-3 text-[#71717a] min-w-[200px]">
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value={service.description ?? ""}
|
value={service.description ?? ""}
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@@ -77,7 +92,10 @@ function ServiceRow({
|
|||||||
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
|
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 min-w-[140px]">
|
);
|
||||||
|
case "fase":
|
||||||
|
return (
|
||||||
|
<td key={key} className="py-2 px-3 min-w-[140px]">
|
||||||
<OptionSelect
|
<OptionSelect
|
||||||
value={service.fase}
|
value={service.fase}
|
||||||
options={options.fase}
|
options={options.fase}
|
||||||
@@ -85,7 +103,10 @@ function ServiceRow({
|
|||||||
onRename={(o, n) => run(() => renameServiceOption("fase", o, n))}
|
onRename={(o, n) => run(() => renameServiceOption("fase", o, n))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 min-w-[180px]">
|
);
|
||||||
|
case "offerta":
|
||||||
|
return (
|
||||||
|
<td key={key} className="py-2 px-3 min-w-[180px]">
|
||||||
<OptionMultiSelect
|
<OptionMultiSelect
|
||||||
values={service.tags}
|
values={service.tags}
|
||||||
options={options.tag}
|
options={options.tag}
|
||||||
@@ -94,7 +115,10 @@ function ServiceRow({
|
|||||||
onRename={(o, n) => run(() => renameServiceOption("tag", o, n))}
|
onRename={(o, n) => run(() => renameServiceOption("tag", o, n))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 min-w-[180px]">
|
);
|
||||||
|
case "pacchetto":
|
||||||
|
return (
|
||||||
|
<td key={key} className="py-2 px-3 min-w-[180px]">
|
||||||
<OptionMultiSelect
|
<OptionMultiSelect
|
||||||
values={service.pacchetto}
|
values={service.pacchetto}
|
||||||
options={options.pacchetto}
|
options={options.pacchetto}
|
||||||
@@ -103,7 +127,10 @@ function ServiceRow({
|
|||||||
onRename={(o, n) => run(() => renameServiceOption("pacchetto", o, n))}
|
onRename={(o, n) => run(() => renameServiceOption("pacchetto", o, n))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 tabular-nums min-w-[100px]">
|
);
|
||||||
|
case "prezzo":
|
||||||
|
return (
|
||||||
|
<td key={key} className="py-2 px-3 tabular-nums min-w-[100px]">
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value={service.unit_price}
|
value={service.unit_price}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -111,17 +138,32 @@ function ServiceRow({
|
|||||||
onSave={(v) => run(() => updateServiceField(service.id, "unit_price", v))}
|
onSave={(v) => run(() => updateServiceField(service.id, "unit_price", v))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 min-w-[100px]">
|
);
|
||||||
|
case "stato":
|
||||||
|
return (
|
||||||
|
<td key={key} className="py-2 px-3 min-w-[100px]">
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value={service.active ? "true" : "false"}
|
value={service.active ? "true" : "false"}
|
||||||
type="toggle"
|
type="toggle"
|
||||||
onSave={(v) => run(() => updateServiceField(service.id, "active", v))}
|
onSave={(v) => run(() => updateServiceField(service.id, "active", v))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${
|
||||||
|
!service.active ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{colOrder.map((key) => renderCell(key))}
|
||||||
</tr>
|
</tr>
|
||||||
{error && (
|
{error && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
<td colSpan={colOrder.length} className="py-1 px-3 text-xs text-red-600">
|
||||||
{error}
|
{error}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -130,7 +172,13 @@ function ServiceRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
function QuickAddRow({
|
||||||
|
options,
|
||||||
|
colOrder,
|
||||||
|
}: {
|
||||||
|
options: CatalogFieldOptions;
|
||||||
|
colOrder: ColumnKey[];
|
||||||
|
}) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [fase, setFase] = useState("");
|
const [fase, setFase] = useState("");
|
||||||
@@ -172,10 +220,11 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
|||||||
const cellInput =
|
const cellInput =
|
||||||
"border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary";
|
"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 (
|
return (
|
||||||
<>
|
<td key={key} className="py-2 px-3">
|
||||||
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
|
|
||||||
<td className="py-2 px-3">
|
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
@@ -184,7 +233,10 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
|||||||
className={cellInput}
|
className={cellInput}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3">
|
);
|
||||||
|
case "descrizione":
|
||||||
|
return (
|
||||||
|
<td key={key} className="py-2 px-3">
|
||||||
<Input
|
<Input
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
@@ -193,7 +245,10 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
|||||||
className={cellInput}
|
className={cellInput}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3">
|
);
|
||||||
|
case "fase":
|
||||||
|
return (
|
||||||
|
<td key={key} className="py-2 px-3">
|
||||||
<Input
|
<Input
|
||||||
value={fase}
|
value={fase}
|
||||||
onChange={(e) => setFase(e.target.value)}
|
onChange={(e) => setFase(e.target.value)}
|
||||||
@@ -202,11 +257,16 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
|||||||
list="catalog-fase-pool"
|
list="catalog-fase-pool"
|
||||||
className={cellInput}
|
className={cellInput}
|
||||||
/>
|
/>
|
||||||
|
<datalist id="catalog-fase-pool">
|
||||||
|
{options.fase.map((f) => (
|
||||||
|
<option key={f} value={f} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-xs text-[#a1a1aa]" colSpan={2}>
|
);
|
||||||
Offerta e pacchetto dopo la creazione
|
case "prezzo":
|
||||||
</td>
|
return (
|
||||||
<td className="py-2 px-3">
|
<td key={key} className="py-2 px-3">
|
||||||
<Input
|
<Input
|
||||||
value={price}
|
value={price}
|
||||||
onChange={(e) => setPrice(e.target.value)}
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
@@ -216,16 +276,26 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
|||||||
className={`${cellInput} tabular-nums`}
|
className={`${cellInput} tabular-nums`}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-xs text-[#a1a1aa]">Invio ↵</td>
|
);
|
||||||
<datalist id="catalog-fase-pool">
|
case "stato":
|
||||||
{options.fase.map((f) => (
|
return (
|
||||||
<option key={f} value={f} />
|
<td key={key} className="py-2 px-3 text-xs text-[#a1a1aa]">
|
||||||
))}
|
Invio ↵
|
||||||
</datalist>
|
</td>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <td key={key} className="py-2 px-3 text-xs text-[#a1a1aa]" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
|
||||||
|
{colOrder.map((key) => renderCell(key))}
|
||||||
</tr>
|
</tr>
|
||||||
{error && (
|
{error && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
<td colSpan={colOrder.length} className="py-1 px-3 text-xs text-red-600">
|
||||||
{error}
|
{error}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -235,14 +305,35 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceTable({
|
export function ServiceTable({
|
||||||
services,
|
activeServices,
|
||||||
|
inactiveServices,
|
||||||
options,
|
options,
|
||||||
|
colOrder,
|
||||||
|
sortKey,
|
||||||
|
sortDir,
|
||||||
|
onSortClick,
|
||||||
|
onColDragStart,
|
||||||
|
onColDragOver,
|
||||||
|
onColDrop,
|
||||||
}: {
|
}: {
|
||||||
services: ServiceWithTags[];
|
activeServices: ServiceWithTags[];
|
||||||
|
inactiveServices: ServiceWithTags[];
|
||||||
options: CatalogFieldOptions;
|
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);
|
function SortIcon({ colKey }: { colKey: ColumnKey }) {
|
||||||
const inactiveServices = services.filter((s) => !s.active);
|
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 (
|
return (
|
||||||
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
||||||
@@ -250,33 +341,48 @@ export function ServiceTable({
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
|
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
|
||||||
<tr>
|
<tr>
|
||||||
{COLUMN_HEADERS.map((header) => (
|
{colOrder.map((key) => {
|
||||||
|
const def = COLUMN_DEFS[key];
|
||||||
|
return (
|
||||||
<th
|
<th
|
||||||
key={header}
|
key={key}
|
||||||
className="text-left py-2 px-3 font-semibold text-[#71717a]"
|
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 }}
|
||||||
>
|
>
|
||||||
{header}
|
<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>
|
</th>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{activeServices.map((service) => (
|
{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 && (
|
{inactiveServices.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<tr className="bg-[#fafafa]">
|
<tr className="bg-[#fafafa]">
|
||||||
<td
|
<td
|
||||||
colSpan={COL_COUNT}
|
colSpan={colOrder.length}
|
||||||
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-[#a1a1aa]"
|
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-[#a1a1aa]"
|
||||||
>
|
>
|
||||||
Servizi disattivati
|
Servizi disattivati
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{inactiveServices.map((service) => (
|
{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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo, useTransition } from "react";
|
import { useState, useMemo, useTransition, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
@@ -86,6 +86,13 @@ export function OfferEditorClient({
|
|||||||
const [tipoTags, setTipoTags] = useState<string[]>(data.tipoTags);
|
const [tipoTags, setTipoTags] = useState<string[]>(data.tipoTags);
|
||||||
const [obiettivoTags, setObiettivoTags] = useState<string[]>(data.obiettivoTags);
|
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 [categoriaOptions, setCategoriaOptions] = useState<string[]>(fieldOptions.categoria);
|
||||||
const [ticketOptions, setTicketOptions] = useState<string[]>(fieldOptions.ticket);
|
const [ticketOptions, setTicketOptions] = useState<string[]>(fieldOptions.ticket);
|
||||||
const [tipoOptions, setTipoOptions] = useState<string[]>(fieldOptions.tipo);
|
const [tipoOptions, setTipoOptions] = useState<string[]>(fieldOptions.tipo);
|
||||||
|
|||||||
+25
-11
@@ -8,6 +8,7 @@ import {
|
|||||||
services,
|
services,
|
||||||
tags,
|
tags,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
|
import { getJsonPool, SETTINGS_KEYS } from "@/lib/settings";
|
||||||
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
|
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
|
||||||
import { eq, and, asc, inArray, sql } from "drizzle-orm";
|
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
|
// 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> {
|
export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
|
||||||
const categoriaRows = await db.selectDistinct({ value: offer_macros.category }).from(offer_macros);
|
const [categoriaRows, ticketRows, tagRows, tipoPool, obiettivoPool, categoriaPool] =
|
||||||
const ticketRows = await db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros);
|
await Promise.all([
|
||||||
|
db.selectDistinct({ value: offer_macros.category }).from(offer_macros),
|
||||||
const tagRows = await db
|
db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros),
|
||||||
|
db
|
||||||
.selectDistinct({ name: tags.name, type: tags.entity_type })
|
.selectDistinct({ name: tags.name, type: tags.entity_type })
|
||||||
.from(tags)
|
.from(tags)
|
||||||
.where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY]));
|
.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)[]) =>
|
const sortUnique = (arr: (string | null)[]) =>
|
||||||
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
|
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 {
|
return {
|
||||||
categoria: sortUnique(categoriaRows.map((r) => r.value)),
|
categoria: sortUnique([
|
||||||
|
...categoriaPool,
|
||||||
|
...categoriaRows.map((r) => r.value),
|
||||||
|
]),
|
||||||
ticket: sortUnique(ticketRows.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)),
|
tipo: sortUnique([
|
||||||
obiettivo: sortUnique(
|
...tipoPool,
|
||||||
tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name)
|
...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 = {
|
export const SETTINGS_KEYS = {
|
||||||
TARGET_HOURLY_RATE: "target_hourly_rate",
|
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;
|
} as const;
|
||||||
|
|
||||||
export async function getSetting(key: string): Promise<string | null> {
|
export async function getSetting(key: string): Promise<string | null> {
|
||||||
@@ -33,3 +36,21 @@ export async function getTargetHourlyRate(): Promise<number> {
|
|||||||
const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
|
const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
|
||||||
return value ? parseFloat(value) : 50;
|
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