Compare commits
33 Commits
55a2da5faf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fc0650dcf | |||
| c1ff5a7e67 | |||
| a1d8d18902 | |||
| c1cc13a99a | |||
| a20a9de2d7 | |||
| 08aadc1d97 | |||
| e5fa07bba3 | |||
| c110689b6a | |||
| 11870e15d3 | |||
| 6d5e04bf9c | |||
| 26d752892a | |||
| 9eb2d45c67 | |||
| 86e1499e8f | |||
| 43cb7e7469 | |||
| add2176a6b | |||
| 9abe1fe4bb | |||
| ae355c33a6 | |||
| fc766ca1ee | |||
| 0b8a7b3809 | |||
| e10d1f70cb | |||
| 64030afef3 | |||
| 4b28f254ba | |||
| 186bb9ea19 | |||
| 988f6f425a | |||
| f98828f75e | |||
| 1824cb643f | |||
| f5f90cd643 | |||
| e1b3e8c3d5 | |||
| e80c95f838 | |||
| 320827e13a | |||
| ba3e824157 | |||
| 19a7ffb6a6 | |||
| bc9051c899 |
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__plugin_claude-mem_mcp-search__get_observations",
|
||||
"Bash(rtk tsc *)",
|
||||
"Bash(rtk git *)",
|
||||
"Bash(rtk grep *)",
|
||||
"Read(//Users/simonecavalli/.claude/get-shit-done/references/**)",
|
||||
"Skill(gsd-execute-phase)",
|
||||
"Skill(gsd-execute-phase:*)",
|
||||
"Skill(gsd-plan-phase)",
|
||||
"Skill(gsd-plan-phase:*)",
|
||||
"Skill(gsd-progress)",
|
||||
"Skill(gsd-progress:*)",
|
||||
"Skill(gsd-complete-milestone)",
|
||||
"Skill(gsd-complete-milestone:*)",
|
||||
"Read(//Users/simonecavalli/Downloads/**)"
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"impeccable@impeccable": true
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"impeccable": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "pbakaus/impeccable"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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*
|
||||
|
||||
+38
-30
@@ -1,47 +1,51 @@
|
||||
---
|
||||
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-22T09:00:00.000Z"
|
||||
last_activity: 2026-06-22 -- Lead→Cliente (A+B) in prod; cleanup tab progetto + offerta→fasi in corso
|
||||
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: Pre-23 fixes & flow wiring (fuori roadmap formale)
|
||||
Plan: `.claude/plans/te-li-scrivo-tutti-lucky-hearth.md`
|
||||
Status: In esecuzione — cleanup tab progetto + collegamento offerta→fasi/task
|
||||
Last activity: 2026-06-22 — Lead→Cliente consegnato in prod
|
||||
|
||||
Progress (v2.2): [████░░░░░░] 40% — 2/5 fasi complete
|
||||
### Lavoro recente (pre-fase-23, in prod)
|
||||
|
||||
### Fasi completate (v2.1, storico)
|
||||
- **Tassonomie**: gestione centralizzata categorie/tag in Impostazioni (modello Notion, pool persistenti, `src/lib/taxonomy.ts`).
|
||||
- **Lead → Cliente (A+B)**: campi `clients.email/phone` + `leads.archived` (migrazione 0011 applicata a prod); `convertLeadToClient` (riusa `createClientCore`, porta i transcript, archivia il lead mantenendo "won"); tasto Converti/Convertito; lead archiviati nascosti da lista/kanban.
|
||||
- **In corso**: rimozione tab Preventivo dal progetto, riordino sidebar, tab Offerte rifatta, import offerta→fasi/task per `services.fase`.
|
||||
|
||||
Phase 11 (catalogo), Phase 12 (offer editor), Phase 14 (CRM Attio) — consegnate e in prod. 55 servizi reali + tag offerta caricati.
|
||||
### Fasi completate (v2.2, 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.
|
||||
|
||||
## 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 +74,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 +98,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 +108,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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# UI Rules — ClientHub admin
|
||||
|
||||
Derived from the live codebase (`src/app/globals.css`, design system usage across admin pages).
|
||||
These are the rules to follow for any new or modified admin page.
|
||||
|
||||
---
|
||||
|
||||
## Brand palette
|
||||
|
||||
All colours must be written as **hex literals** — no Tailwind semantic tokens (`text-foreground`, `bg-muted`, etc.) in admin UI. Semantic tokens are defined in `globals.css` but mixing hex and tokens creates inconsistency.
|
||||
|
||||
| Role | Hex | Usage example |
|
||||
|-------------------|-------------|-----------------------------------------|
|
||||
| Primary | `#1A463C` | Primary buttons, active states, accents |
|
||||
| Primary hover | `#163a31` | Hover on primary buttons |
|
||||
| Accent | `#DEF168` | Brand highlights (use sparingly) |
|
||||
| Foreground | `#1a1a1a` | Body text, headings |
|
||||
| Muted text | `#71717a` | Secondary text, labels, meta |
|
||||
| Border | `#e5e7eb` | Card borders, table dividers, inputs |
|
||||
| Background muted | `#f9f9f9` | Table header rows, card hover bg |
|
||||
| White | `#ffffff` | Card / panel backgrounds |
|
||||
| Destructive | `#dc2626` | Delete/archive actions, error text |
|
||||
|
||||
---
|
||||
|
||||
## Typographic scale
|
||||
|
||||
| Level | Classes |
|
||||
|--------------------|----------------------------------------------|
|
||||
| Page title (h1) | `text-2xl font-bold text-[#1a1a1a]` |
|
||||
| Section heading | `text-base font-semibold text-[#1a1a1a]` |
|
||||
| Subsection (h3) | `text-sm font-bold text-[#71717a] uppercase tracking-wider` |
|
||||
| Body | `text-sm text-[#1a1a1a]` |
|
||||
| Secondary/label | `text-xs text-[#71717a]` |
|
||||
| Numeric values | always add `tabular-nums` |
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
- **Page wrapper:** `<div className="space-y-6">` — uniform vertical rhythm, full-width.
|
||||
- **Page header:** always use `<PageHeader>` from `src/components/admin/PageHeader.tsx` — never hand-roll the title + action row.
|
||||
- **No width constraints on list pages:** do not add `max-w-*` or `mx-auto` to the page root. Width is controlled by the sidebar layout (`src/app/admin/layout.tsx`).
|
||||
- **Detail/form pages** (e.g. OfferEditorClient) may keep their own `max-w-4xl mx-auto` — this rule applies to list/overview pages only.
|
||||
- **Cards:** `rounded-lg border border-[#e5e7eb] bg-white p-4`; hover: `hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]`.
|
||||
- **Tables:** `bg-white rounded-xl border border-[#e5e7eb] overflow-hidden`; thead `bg-[#f9f9f9] border-b border-[#e5e7eb]`; row divider `border-b border-[#e5e7eb]`.
|
||||
|
||||
---
|
||||
|
||||
## Interaction
|
||||
|
||||
- Clickable elements: `cursor-pointer`
|
||||
- Colour transitions: `transition-colors duration-150`
|
||||
- Focus ring: `focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1A463C]/30`
|
||||
- Minimum touch target on buttons: 44 × 44 px (use `py-2 px-4` minimum or `h-10`)
|
||||
- Primary CTA: `bg-[#1A463C] text-white hover:bg-[#163a31] transition-colors`
|
||||
- Secondary/ghost CTA: `border border-[#e5e7eb] text-[#1a1a1a] hover:bg-[#f9f9f9] transition-colors`
|
||||
- Destructive action: `text-[#dc2626] hover:bg-red-50 transition-colors`
|
||||
|
||||
---
|
||||
|
||||
## Anti-patterns (do not do these)
|
||||
|
||||
- **No emoji as UI icons** — use Lucide React icons instead.
|
||||
- **No mixed semantic tokens and hex** — pick hex throughout any given page/component.
|
||||
- **No per-page `max-w-*` on list pages** — layout width is the sidebar shell's responsibility.
|
||||
- **No hand-rolled page header divs** — always use `<PageHeader>` to keep title size/weight/colour uniform.
|
||||
- **No inline `style={{}}` for colours that have a Tailwind class** — reserve `style` for dynamic values only (e.g. progress bar width percentages).
|
||||
@@ -0,0 +1,433 @@
|
||||
# Phase 19: Pipeline CRM Kanban — Research
|
||||
|
||||
**Researched:** 2026-06-19
|
||||
**Domain:** @dnd-kit drag-drop, Next.js App Router client components, CRM leads view toggle
|
||||
**Confidence:** HIGH — all findings verified directly from codebase
|
||||
|
||||
---
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| PIPE-01 | I lead sono visualizzabili in una board Kanban stile Pipedrive con colonne per stage e drag-drop per cambiare stage | `leads.status` enum verified (6 stages); `@dnd-kit/core` v6.3.1 already installed; exact analog in `KanbanBoard.tsx` |
|
||||
| PIPE-02 | Spostare un lead nelle colonne "Vinto"/"Perso" è il cambio-stato manuale dell'esito | `won`/`lost` are existing LEAD_STAGES values; `updateLeadField(id, "status", value)` handles this today via the table dropdown |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 14 delivered a complete inline-edit table view of leads (`LeadTable.tsx`) backed by a solid data layer: `getLeadsWithTags()`, `updateLeadField()`, typed `LEAD_STAGES`, and a polymorphic tag system. Phase 19 adds a second view — Kanban — toggled from the same page, without replacing or changing any of that.
|
||||
|
||||
The project already ships a working `KanbanBoard.tsx` (for project tasks) that uses exactly the `@dnd-kit` primitives needed here. The new `LeadsKanbanBoard` is a direct structural analog: swap task status columns (`todo/in_progress/done`) for lead stage columns (`contacted/qualified/proposal_sent/negotiating/won/lost`), swap task cards for lead cards, swap `updateTaskStatus` for `updateLeadField(id, "status", newStage)`.
|
||||
|
||||
The view-toggle pattern is also ready in `PhasesViewToggle.tsx` — a client component that holds `useState<"list" | "kanban">` and renders either `listView` (a `ReactNode` passed as prop) or the kanban. The leads page only needs a `LeadsViewToggle` wrapper that receives the existing `LeadsSearch` as the `listView` slot and the new `LeadsKanbanBoard` as the kanban.
|
||||
|
||||
No schema changes. No new server actions. No new dependencies. This is a pure UI addition.
|
||||
|
||||
**Primary recommendation:** Copy the `KanbanBoard.tsx` structure exactly; adapt for 6 lead-stage columns; wire to `updateLeadField`; wrap with a `LeadsViewToggle` component in `LeadsSearch` or at the page level.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||
|------------|-------------|----------------|-----------|
|
||||
| Kanban board rendering + drag state | Browser / Client | — | Drag-drop is inherently client-side; `"use client"` required |
|
||||
| Lead status persistence on drop | API / Backend (Server Action) | — | `updateLeadField` is already a `"use server"` action |
|
||||
| Lead data fetching | Frontend Server (SSR) | — | `LeadsPage` is a server component; passes data down as props |
|
||||
| View toggle state (table / kanban) | Browser / Client | — | `useState` in a client wrapper component |
|
||||
| Column definitions (stage labels, colors) | Browser / Client | — | Derived from `LEAD_STAGES` constant, purely presentational |
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core (already installed — no new installs needed)
|
||||
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| @dnd-kit/core | ^6.3.1 [VERIFIED: package.json] | DndContext, useDraggable, useDroppable, sensors | Already used in KanbanBoard.tsx |
|
||||
| @dnd-kit/sortable | ^10.0.0 [VERIFIED: package.json] | Available but NOT used by existing KanbanBoard | Not needed; existing pattern uses useDraggable + useDroppable directly |
|
||||
| @dnd-kit/utilities | ^3.2.2 [VERIFIED: package.json] | CSS.Transform helper | Imported if transform style needed |
|
||||
| React (useTransition, useState) | via Next.js 16 | Optimistic state + async server action bridging | Project pattern |
|
||||
|
||||
**Installation:** None required. All dependencies already present.
|
||||
|
||||
### Existing Primitives Used by KanbanBoard.tsx [VERIFIED: src/components/admin/kanban/KanbanBoard.tsx]
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DndContext, // Root context — wraps the entire board
|
||||
DragEndEvent, // Event type for onDragEnd handler
|
||||
DragOverlay, // Ghost card rendered at cursor during drag
|
||||
PointerSensor, // Mouse/touch activation
|
||||
KeyboardSensor, // Accessibility
|
||||
useSensor,
|
||||
useSensors,
|
||||
useDroppable, // Applied to column containers
|
||||
useDraggable, // Applied to individual cards
|
||||
} from "@dnd-kit/core";
|
||||
```
|
||||
|
||||
Note: `@dnd-kit/sortable` / `SortableContext` / `useSortable` are NOT used. The existing pattern uses the lower-level `useDraggable` + `useDroppable` primitives, which is appropriate for cross-column drag (not intra-column reordering).
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### System Architecture Diagram
|
||||
|
||||
```
|
||||
LeadsPage (server component)
|
||||
├─ getLeadsWithTags() ──────────────────────────────► Postgres / leads + tags
|
||||
├─ getLeadFieldOptions() ───────────────────────────► Postgres / tags
|
||||
└─ renders LeadsViewToggle (client component)
|
||||
├─ [view="list"] → LeadsSearch → LeadTable (existing, unchanged)
|
||||
└─ [view="kanban"] → LeadsKanbanBoard (new)
|
||||
├─ DndContext (onDragEnd → updateLeadField server action)
|
||||
├─ DroppableColumn × 6 (one per LEAD_STAGES value)
|
||||
└─ DraggableLeadCard × N (one per lead)
|
||||
└─ useTransition + router.refresh() (after persist)
|
||||
```
|
||||
|
||||
### Recommended File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├─ components/admin/leads/
|
||||
│ ├─ LeadTable.tsx # EXISTING — unchanged
|
||||
│ └─ LeadsKanbanBoard.tsx # NEW — analogous to KanbanBoard.tsx
|
||||
├─ app/admin/leads/
|
||||
│ ├─ page.tsx # MODIFIED — wrap with LeadsViewToggle
|
||||
│ ├─ LeadsSearch.tsx # MODIFIED — receives view toggle or replaced by LeadsViewToggle
|
||||
│ └─ actions.ts # EXISTING — updateLeadField already handles status changes
|
||||
```
|
||||
|
||||
The view toggle can live either at the page level (simpler) or inside `LeadsSearch` (keeps search state alive across views). Recommended: extract a `LeadsViewToggle` client wrapper at the page level (same pattern as `PhasesViewToggle`), passing `<LeadsSearch leads={leads} options={options} />` as the `listView` ReactNode and `<LeadsKanbanBoard leads={leads} />` as the kanban.
|
||||
|
||||
### Pattern 1: Column definition for 6 lead stages
|
||||
|
||||
```typescript
|
||||
// Source: VERIFIED from src/lib/lead-validators.ts + src/components/admin/leads/LeadTable.tsx
|
||||
|
||||
type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost";
|
||||
|
||||
const LEAD_COLUMNS: {
|
||||
id: LeadStage;
|
||||
label: string;
|
||||
headerClass: string;
|
||||
dotClass: string;
|
||||
}[] = [
|
||||
{ id: "contacted", label: "Contattato", headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
|
||||
{ id: "qualified", label: "Qualificato", headerClass: "text-[#1A463C]", dotClass: "bg-purple-400" },
|
||||
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700", dotClass: "bg-amber-400" },
|
||||
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700", dotClass: "bg-orange-400" },
|
||||
{ id: "won", label: "Vinto", headerClass: "text-green-700", dotClass: "bg-green-500" },
|
||||
{ id: "lost", label: "Perso", headerClass: "text-red-700", dotClass: "bg-red-400" },
|
||||
];
|
||||
```
|
||||
|
||||
### Pattern 2: Lead Kanban Board (adapted from KanbanBoard.tsx)
|
||||
|
||||
```typescript
|
||||
// Source: VERIFIED structure from src/components/admin/kanban/KanbanBoard.tsx
|
||||
// Key adaptation: replace taskStatuses/updateTaskStatus with leadStatuses/updateLeadField
|
||||
|
||||
"use client";
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
DndContext, DragEndEvent, DragOverlay,
|
||||
PointerSensor, KeyboardSensor, useSensor, useSensors,
|
||||
useDroppable, useDraggable,
|
||||
} from "@dnd-kit/core";
|
||||
import { updateLeadField } from "@/app/admin/leads/actions";
|
||||
import type { LeadWithTags } from "@/lib/admin-queries";
|
||||
|
||||
export function LeadsKanbanBoard({ leads }: { leads: LeadWithTags[] }) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [leadStatuses, setLeadStatuses] = useState<Record<string, LeadStage>>(
|
||||
() => Object.fromEntries(leads.map((l) => [l.id, l.status as LeadStage]))
|
||||
);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor)
|
||||
);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
if (!over) return;
|
||||
const leadId = active.id as string;
|
||||
const newStage = over.id as LeadStage;
|
||||
if (newStage === leadStatuses[leadId]) return;
|
||||
// Optimistic update
|
||||
setLeadStatuses((prev) => ({ ...prev, [leadId]: newStage }));
|
||||
// Persist
|
||||
startTransition(async () => {
|
||||
await updateLeadField(leadId, "status", newStage);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
// ... render DndContext with LEAD_COLUMNS mapped to DroppableColumn
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: View toggle (adapted from PhasesViewToggle.tsx)
|
||||
|
||||
```typescript
|
||||
// Source: VERIFIED from src/components/admin/kanban/PhasesViewToggle.tsx
|
||||
"use client";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard";
|
||||
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
||||
|
||||
export function LeadsViewToggle({
|
||||
listView,
|
||||
leads,
|
||||
}: {
|
||||
listView: ReactNode;
|
||||
leads: LeadWithTags[];
|
||||
}) {
|
||||
const [view, setView] = useState<"list" | "kanban">("list");
|
||||
return (
|
||||
<div>
|
||||
{/* Toggle buttons — same pill pattern as PhasesViewToggle */}
|
||||
{view === "list" ? listView : <LeadsKanbanBoard leads={leads} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Lead card content
|
||||
|
||||
Each Kanban card should show: `name` (primary), `company` (secondary/optional), `next_action` (hint text, optional). Avoid showing `email`/`phone`/`tags` on the card to keep it compact — these are available in the table view.
|
||||
|
||||
```typescript
|
||||
// Fields available on LeadWithTags (VERIFIED: src/lib/admin-queries.ts line 890)
|
||||
// Lead & { tags: string[] }
|
||||
// Relevant for card: name, company, next_action, status
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Using `useSortable` / `SortableContext`:** The existing project pattern does NOT use these. They are for intra-column reordering. Use `useDraggable` + `useDroppable` to match the established `KanbanBoard.tsx` pattern.
|
||||
- **Calling `router.refresh()` before `await updateLeadField`:** Always await the server action first, then refresh. The existing KanbanBoard does this correctly inside `startTransition`.
|
||||
- **Dropping `react-hook-form` / Zod on drag-drop:** No form validation needed for a status change — `updateLeadField` already validates via `LEAD_STAGES.includes(value)` check.
|
||||
- **Removing `LeadsSearch` / `LeadTable`:** PIPE-01 requires the table to remain as an alternative view. Do not replace it.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Drag detection (distance threshold) | Custom mouse event tracking | `PointerSensor` with `activationConstraint: { distance: 5 }` | Already proven in KanbanBoard.tsx; prevents accidental drag on click |
|
||||
| Keyboard accessibility for drag | Custom key handlers | `KeyboardSensor` from @dnd-kit/core | a11y for free |
|
||||
| Drag ghost/overlay | CSS clone positioning | `DragOverlay` from @dnd-kit/core | Correct portal rendering, no z-index fights |
|
||||
| Optimistic UI update | Complex local state with rollback | `useState` + `useTransition` (React pattern) | Already used in KanbanBoard.tsx and LeadTable.tsx |
|
||||
| Status validation | Re-implementing LEAD_STAGES check | `updateLeadField` server action already validates status | DRY — the action throws on invalid stage |
|
||||
|
||||
**Key insight:** The entire drag-drop + persist pattern is already implemented and tested in `KanbanBoard.tsx`. This phase is a structural copy with domain adaptation, not a new implementation.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Columns wider than viewport on 6-stage board
|
||||
**What goes wrong:** 6 columns in `grid-cols-6` become too narrow on typical laptop screens (1280–1440px). The 3-column project kanban uses `grid-cols-3` with comfortable card width.
|
||||
**Why it happens:** 6 × min-width ≈ 720px+ is tight.
|
||||
**How to avoid:** Use `grid-cols-3 lg:grid-cols-6` or a horizontally scrollable container (`overflow-x-auto` on the grid wrapper). Alternatively, `min-w-[200px]` per column inside a scroll container.
|
||||
**Warning signs:** Cards truncate before the lead name is visible.
|
||||
|
||||
### Pitfall 2: Won/Lost columns need visual distinction
|
||||
**What goes wrong:** Dropping to "won" or "lost" looks identical to other columns — user may not notice the semantic weight of these terminal states.
|
||||
**Why it happens:** Uniform column styling.
|
||||
**How to avoid:** Use visually distinct `headerClass` (green for won, red for lost) and consider a stronger `isOver` highlight for these columns. The STAGE_COLOR map in `LeadTable.tsx` already defines these colors — reuse them.
|
||||
|
||||
### Pitfall 3: Leads not sorted consistently between views
|
||||
**What goes wrong:** Table shows leads ordered by `updated_at DESC`; kanban derived from the same array shows different visual order depending on column grouping.
|
||||
**Why it happens:** No explicit sort on the kanban card order within a column.
|
||||
**How to avoid:** Sort leads within each column by `updated_at DESC` (same as the existing query order). The `getLeadsWithTags` query already returns `orderBy(desc(leads.updated_at))` so inheriting that order is sufficient.
|
||||
|
||||
### Pitfall 4: `router.refresh()` causes full re-mount of kanban
|
||||
**What goes wrong:** After a drag-drop, `router.refresh()` rehydrates the server component, re-running `getLeadsWithTags()`. If the drag animation hasn't completed, it can cause a visual flicker.
|
||||
**Why it happens:** Next.js App Router refresh re-renders the whole tree.
|
||||
**How to avoid:** The existing `KanbanBoard.tsx` uses the same pattern without issue. The `setActiveId(null)` call in `handleDragEnd` clears the overlay before the refresh arrives, so the flicker is acceptable. This is the project's established pattern — do not deviate.
|
||||
|
||||
### Pitfall 5: Search/filter not available in kanban view
|
||||
**What goes wrong:** The search bar lives in `LeadsSearch.tsx` and only filters `LeadTable`. If the user switches to kanban, they lose the ability to filter.
|
||||
**Why it happens:** The view toggle renders either `LeadsSearch` (with its internal state) or the bare `LeadsKanbanBoard`.
|
||||
**How to avoid:** Two acceptable approaches: (a) wrap both views together inside `LeadsSearch` and pass filtered leads to both (preferred — search state persists across view switches); or (b) accept that kanban shows all leads unfiltered (simpler, acceptable for now given the single-user context). Document the choice in the plan.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Existing updateLeadField signature (server action)
|
||||
|
||||
```typescript
|
||||
// Source: VERIFIED from src/app/admin/leads/actions.ts line 174
|
||||
// EDITABLE_FIELDS includes "status" — drag-drop can call this directly
|
||||
export async function updateLeadField(
|
||||
leadId: string,
|
||||
fieldName: "name" | "email" | "phone" | "company" | "status" | "next_action",
|
||||
value: string
|
||||
): Promise<void>
|
||||
// Validates: status must be in LEAD_STAGES; throws on invalid value
|
||||
// Side effects: revalidatePath("/admin/leads") + revalidatePath(`/admin/leads/${leadId}`)
|
||||
```
|
||||
|
||||
### LEAD_STAGES canonical values
|
||||
|
||||
```typescript
|
||||
// Source: VERIFIED from src/lib/lead-validators.ts line 4
|
||||
export const LEAD_STAGES = [
|
||||
"contacted",
|
||||
"qualified",
|
||||
"proposal_sent",
|
||||
"negotiating",
|
||||
"won",
|
||||
"lost",
|
||||
] as const;
|
||||
```
|
||||
|
||||
### Existing STAGE_COLOR map (reuse for kanban column headers)
|
||||
|
||||
```typescript
|
||||
// Source: VERIFIED from src/components/admin/leads/LeadTable.tsx line 20
|
||||
const STAGE_COLOR: Record<string, string> = {
|
||||
contacted: "bg-blue-100 text-blue-800",
|
||||
qualified: "bg-purple-100 text-purple-800",
|
||||
proposal_sent: "bg-amber-100 text-amber-800",
|
||||
negotiating: "bg-orange-100 text-orange-800",
|
||||
won: "bg-green-100 text-green-800",
|
||||
lost: "bg-red-100 text-red-800",
|
||||
};
|
||||
// Move to a shared constant (e.g., src/lib/lead-constants.ts) if reused in both components
|
||||
```
|
||||
|
||||
### PhasesViewToggle pattern (exact analog)
|
||||
|
||||
```typescript
|
||||
// Source: VERIFIED from src/components/admin/kanban/PhasesViewToggle.tsx
|
||||
// State: useState<"list" | "kanban">("list")
|
||||
// Toggle: pill button group (bg-[#f4f4f5] rounded-lg p-1 w-fit)
|
||||
// Active: bg-white text-[#1A463C] shadow-sm
|
||||
// Inactive: text-[#71717a] hover:text-[#1a1a1a]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Lead status change via modal form | Inline dropdown in table cell (`StatusCell`) | Phase 14 | Drag-drop is the third mechanism; all write to same `updateLeadField` action |
|
||||
| Separate `/admin/analytics` route | Fused into `/admin` dashboard | Phase 18 | No impact on leads page |
|
||||
| `SendQuoteModal` with dead branch | Dead branch removed | Phase 18 | No impact |
|
||||
|
||||
---
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
| Directive | Impact on This Phase |
|
||||
|-----------|---------------------|
|
||||
| `clients.token` = rotatable, never PK | Not relevant (leads have no token) |
|
||||
| `quote_items` never exposed via client API | Not relevant (Kanban is admin-only) |
|
||||
| `deliverables.approved_at` immutable once set | Not relevant |
|
||||
| Auth: `/admin/*` → Auth.js session | Kanban lives at `/admin/leads` — already protected |
|
||||
| No file hosting v1 | Not relevant |
|
||||
| Migration safety: never drop/truncate rows | Phase 19 is UI-only — no schema changes, no migration needed |
|
||||
| Security: confirm before destructive commands | No destructive operations |
|
||||
| No package installs without showing name+version | No new packages needed |
|
||||
|
||||
---
|
||||
|
||||
## Environment Availability
|
||||
|
||||
Step 2.6: SKIPPED — Phase 19 is a pure UI addition. All required libraries (`@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`) are already installed. No external services, databases (beyond the existing Neon Postgres connection), or CLI tools are needed.
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
`nyquist_validation: false` in `.planning/config.json` — section omitted per config.
|
||||
|
||||
---
|
||||
|
||||
## Security Domain
|
||||
|
||||
Phase 19 adds a new interaction path to an existing admin-only route (`/admin/leads`). No new auth surface is introduced.
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (existing) | Auth.js session via `requireAdmin()` in server action |
|
||||
| V4 Access Control | yes (existing) | `requireAdmin()` guard in `updateLeadField` — drag-drop calls same action |
|
||||
| V5 Input Validation | yes | `updateLeadField` validates status via `LEAD_STAGES.includes(value)` — no new validation needed |
|
||||
|
||||
No new threat surface beyond what Phase 14 already addressed. The drag-drop `handleDragEnd` validates the `over.id` is a known stage before calling the server action — follow the same guard pattern as in `KanbanBoard.tsx` (line 190: `if (!(["todo", "in_progress", "done"] as string[]).includes(newStatus)) return;`).
|
||||
|
||||
---
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | The `won` and `lost` columns are terminal states with no special side-effects beyond setting `leads.status` (no auto-creation of client/project, no email trigger) | Architecture Patterns | If the plan later requires auto-provisioning on "won", a new server action will be needed — but PIPE-01/02 say nothing about this, and PROP-04 (auto-provisioning) is deferred to backlog post-R5 |
|
||||
| A2 | Card content (name, company, next_action) is sufficient for the kanban view; no additional fields are needed per card | Code Examples | If the user wants tags or email visible on cards, the `LeadWithTags` type already provides them — no data-layer change, only card template change |
|
||||
| A3 | The search filter covering only the table view (not the kanban) is acceptable for v1 of this feature | Common Pitfalls | If the user wants search in kanban too, the fix is to lift filtered state into the toggle wrapper — straightforward but adds scope |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Search/filter scope in kanban view**
|
||||
- What we know: `LeadsSearch` holds the search `useState` and passes `filtered` leads to `LeadTable`. The kanban would receive all leads from the page.
|
||||
- What's unclear: Does the user want the search bar to filter the kanban board too, or is it acceptable that kanban shows all leads?
|
||||
- Recommendation: Default to wrapping both views inside a new `LeadsViewToggle` that receives `leads` (unfiltered) and `options`, manages the view toggle, and passes `filtered` leads to both `LeadTable` and `LeadsKanbanBoard`. This is a clean pattern and handles it gracefully.
|
||||
|
||||
2. **Column layout: scroll vs. wrap on 6 columns**
|
||||
- What we know: The existing kanban uses `grid-cols-3`. Six columns need more space.
|
||||
- What's unclear: Target viewport is unknown (likely 1440px+ since this is a single-admin tool).
|
||||
- Recommendation: Use `min-w-[180px]` per column inside an `overflow-x-auto` wrapper. This makes it work on any viewport without content truncation.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `src/components/admin/kanban/KanbanBoard.tsx` — exact @dnd-kit usage pattern, drag primitives, sensors, DragOverlay, optimistic update + router.refresh()
|
||||
- `src/components/admin/kanban/PhasesViewToggle.tsx` — view toggle pattern (list/kanban state, pill button UI)
|
||||
- `src/components/admin/leads/LeadTable.tsx` — STAGE_COLOR map, LeadWithTags usage, StatusCell inline dropdown
|
||||
- `src/app/admin/leads/actions.ts` — `updateLeadField` signature, EDITABLE_FIELDS, `requireAdmin()` guard
|
||||
- `src/lib/lead-validators.ts` — canonical LEAD_STAGES array (6 values)
|
||||
- `src/lib/admin-queries.ts` lines 883–942 — `LeadWithTags` type, `getLeadsWithTags()` query (all fields), `LeadFieldOptions`
|
||||
- `src/db/schema.ts` lines 441–462 — `leads` table definition, `status` column with all 6 stage values documented
|
||||
- `src/app/admin/leads/LeadsSearch.tsx` — search filter pattern, `LeadWithTags` + `LeadFieldOptions` prop interface
|
||||
- `src/app/admin/leads/page.tsx` — server component structure, data fetching pattern, `revalidate = 0`
|
||||
- `src/components/admin/AdminSidebar.tsx` — `/admin/leads` is already in NAV_ITEMS, no sidebar change needed
|
||||
- `package.json` — @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- `.planning/config.json` — `nyquist_validation: false` confirmed
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — all packages verified in package.json; exact primitives verified in KanbanBoard.tsx
|
||||
- Architecture: HIGH — all patterns verified from existing codebase analogs
|
||||
- Pitfalls: HIGH — derived from direct code inspection and known Next.js App Router behaviors
|
||||
- Data layer: HIGH — schema, actions, and query functions all read directly
|
||||
|
||||
**Research date:** 2026-06-19
|
||||
**Valid until:** Stable indefinitely (no external dependencies; codebase-derived findings)
|
||||
@@ -20,9 +20,17 @@ Planning in `.planning/`. Use `/gsd-plan-phase N` → `/gsd-execute-phase N`. St
|
||||
- Before running any migration: verify it only adds columns/tables — never drops or truncates production data
|
||||
- Confirm explicitly before any schema change that removes a column or table used by these entities
|
||||
|
||||
## Deploy & DB Access (procedure)
|
||||
- Environments: local → Gitea (remote is named `gitea`, NOT `origin`) → Coolify (prod, auto-deploys on push to `main`)
|
||||
- Prod Postgres is NOT publicly exposed. Claude has working key-based SSH to `root@178.104.27.55` and applies migrations directly via docker exec — no SSH tunnel needed from the user:
|
||||
`cat src/db/migrations/NNNN.sql | ssh root@178.104.27.55 "docker exec -i xwkk0040w0kk0gsgcgog8owk psql -U clienthub -d clienthub -v ON_ERROR_STOP=1 --single-transaction"`
|
||||
(container = `xwkk0040w0kk0gsgcgog8owk`, db/user = `clienthub`; if the container hash changes, find it by scanning `docker ps` for the one whose db has the `payments` table). A tunnel `-L 54321:localhost:54321` is only needed to point local tooling at prod.
|
||||
- Migrations are hand-written SQL in `src/db/migrations/` (drizzle-kit generate is broken).
|
||||
- Ordering: apply an additive migration to prod BEFORE pushing the schema-dependent code, so the live portal never queries a missing column.
|
||||
|
||||
## Security
|
||||
- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE, infra changes)
|
||||
- Never read/expose .env or credentials without explicit request
|
||||
- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE / drop-column, truncate, infra changes)
|
||||
- Never print .env contents or credentials in plaintext output; using them internally to connect is fine
|
||||
- Don't install packages without showing name + registry + version first
|
||||
- Don't push to main or create PRs without explicit confirmation
|
||||
- Pushing to `main` is allowed automatically (standard local → Gitea → Coolify flow); never force-push to `main`
|
||||
- Any change to this section: propose full new version, get approval before applying
|
||||
@@ -0,0 +1,40 @@
|
||||
# ClientHub (IAMCAVALLI) — Status
|
||||
|
||||
_Ultimo aggiornamento: 2026-06-22_
|
||||
|
||||
## Stato attuale
|
||||
In prod su Coolify (Gitea→deploy). Milestone v2.3 "Email & Accesso" in planning (fase 23 non ancora avviata); ultime sessioni dedicate a fix/flow pre-fase-23. Build verde.
|
||||
|
||||
---
|
||||
|
||||
## Fatto (recente, cumulativo)
|
||||
- **Tassonomie centralizzate** in Impostazioni (modello Notion, pool persistenti `src/lib/taxonomy.ts`): Categoria/Ticket/Tipo/Obiettivo offerte + Fase/Offerta/Pacchetto catalogo. Add/remove/sync globale.
|
||||
- **Lead → Cliente**: `clients.email/phone` + `leads.archived` (migr. 0011, in prod). `convertLeadToClient` riusa `createClientCore`, porta i transcript, archivia il lead mantenendo "won". Tasto Converti/Convertito; lead archiviati nascosti da lista/kanban.
|
||||
- **Pagina progetto**: tab "Preventivo" rimossa; sidebar riordinata (Lead prima di Clienti).
|
||||
- **Offerta → Fasi/Task**: `importOfferIntoProject` crea fasi raggruppando i servizi del tier per `services.fase` (merge per titolo, dedup task). Trigger dalla tab Offerte (checkbox import).
|
||||
- **Offerte (modello + UI)**: nuovo campo `offer_macros.offer_type` ('una_tantum'|'retainer') + toggle "Modalità" nell'editor (migr. 0012). Tab Offerte ridisegnata: assegnazione a 2 step (Offerta→Tier con prezzo), badge tipo invece di "X mesi". Deck preventivo mostra il tipo.
|
||||
- **Cleanup dati**: rimossi 3 tier duplicati di "Web Domination" (bug pre-fix) + vincolo `UNIQUE(macro_id, tier_letter)`.
|
||||
- Fix precedenti: persistenza flag A/B/C offerte, sort+reorder colonne catalogo.
|
||||
|
||||
## Da fare
|
||||
- [ ] **Fasi/Task dall'offerta** funzionano solo se i servizi hanno il campo **Fase** valorizzato nel Catalogo (altrimenti finiscono in "Generale").
|
||||
- [ ] **Forecast** (`forecast-queries.ts`) usa ancora `duration_months`: i retainer andrebbero trattati come ricorrenti (follow-up).
|
||||
- [ ] Micro legacy "Mantenimento" senza tier: valutare se rimuoverlo/normalizzarlo.
|
||||
- [ ] Avviare **fase 23** (OTP gate portale cliente + invio link preventivo via email, integrazione Resend) — `/gsd-plan-phase 23`.
|
||||
|
||||
## Note tecniche
|
||||
- **DB prod = `.env.local` porta 54321**, raggiungibile SOLO via tunnel SSH `ssh -L 54321:localhost:54321 root@178.104.27.55` (cade per idle; usare `-o ServerAliveInterval=30`). `node`/`docker` non disponibili lato server → migrazioni si applicano da locale con script `postgres.js` su 127.0.0.1:54321.
|
||||
- **Ordine deploy con schema**: applicare la migrazione a prod PRIMA del push (il deploy gira subito il codice nuovo).
|
||||
- `drizzle-kit generate` è rotto → migrazioni SQL scritte a mano in `src/db/migrations/`.
|
||||
- `offer_micros` non ha `created_at` (no "tier più vecchio" affidabile).
|
||||
|
||||
## File chiave
|
||||
| File | Scopo |
|
||||
|---|---|
|
||||
| src/lib/taxonomy.ts | Pool tassonomie (Impostazioni) |
|
||||
| src/app/admin/leads/actions.ts | `convertLeadToClient` |
|
||||
| src/app/admin/clients/new/actions.ts | `createClientCore` (riuso) |
|
||||
| src/app/admin/projects/project-actions.ts | `importOfferIntoProject`, assegna offerta |
|
||||
| src/components/admin/tabs/OffersTab.tsx | Tab Offerte (2 step + badge tipo) |
|
||||
| src/lib/admin-queries.ts | `getProjectFullDetail` (offerte/dedup) |
|
||||
| src/db/migrations/ | 0011 (email/phone/archived), 0012 (offer_type/unique) |
|
||||
@@ -0,0 +1,259 @@
|
||||
# Design System: iamcavalli Admin & Client Portal (v1.0)
|
||||
|
||||
> "Minimalist Premium / Quiet Luxury" — the aesthetic that replaces the old
|
||||
> mixed token/hardcoded styling across ClientHub's admin area. First applied
|
||||
> to the Lead Pipeline page (Phase: Pipeline redesign); the tokens and
|
||||
> primitives documented here are the base for restyling every other section.
|
||||
|
||||
## Philosophy
|
||||
|
||||
- **Quiet, not loud.** Elevation is nearly invisible (shadow opacity ≤ 0.02),
|
||||
borders are hairline, color is used sparingly and only to carry meaning
|
||||
(status, semantics) — never for decoration.
|
||||
- **Density with air.** Generous padding (`py-4 px-6` in tables, `p-4` in
|
||||
kanban columns) paired with small type (`text-xs`, `text-[11px]`) reads as
|
||||
premium rather than cramped.
|
||||
- **Numbers are monospace.** Prices, phone numbers, dates, counts — anything
|
||||
tabular/numeric — use `font-mono` and right-alignment so columns of digits
|
||||
line up.
|
||||
- **Dual-theme by construction.** Every surface is built from semantic
|
||||
tokens (`bg-card`, `text-muted-foreground`, `border-border`, …), never raw
|
||||
Tailwind palette classes (`bg-white`, `text-slate-900`, …) or hex literals.
|
||||
This is what makes dark mode "just work" without a parallel dark stylesheet.
|
||||
- **Motion is functional.** Transitions exist to explain state change (sidebar
|
||||
collapse, hover, drag) — `duration-200`–`duration-350`,
|
||||
`ease-[cubic-bezier(0.4,0,0.2,1)]` for the sidebar specifically. No
|
||||
decorative animation.
|
||||
|
||||
## Typography
|
||||
|
||||
- **Sans**: Plus Jakarta Sans (weights 300–700), loaded via `next/font/google`
|
||||
as `--font-plus-jakarta-sans`, mapped to Tailwind's `--font-sans` in
|
||||
`@theme`. Used for all UI text.
|
||||
- **Mono**: Geist Mono, `--font-geist-mono` → `--font-mono`. Used for numeric
|
||||
data cells only (prices, phone numbers, counts, dates in tables).
|
||||
- **Scale conventions**:
|
||||
- Page title: `text-2xl font-semibold tracking-tight`
|
||||
- Page subtitle: `text-xs text-muted-foreground`
|
||||
- Table header cells: `text-[11px] font-semibold uppercase tracking-wider text-muted-foreground`
|
||||
- Table body: `text-sm`
|
||||
- Badge/pill label: `text-[10px]`–`text-xs font-semibold uppercase tracking-wide`
|
||||
|
||||
## Color Tokens
|
||||
|
||||
Raw values live in `:root` (light) / `.dark` (dark) in `src/app/globals.css`
|
||||
and are mapped to Tailwind utilities via `@theme inline`. Components must
|
||||
consume the mapped utility, never the raw hex or a Tailwind palette shade.
|
||||
|
||||
| Utility | Light | Dark | Usage |
|
||||
|---|---|---|---|
|
||||
| `bg-background` / `text-foreground` | `#ffffff` / `#1a1a1a` | `#0e1512` / `#f2f4f3` | Page canvas |
|
||||
| `bg-card` / `text-card-foreground` | `#ffffff` | `#131a16` | Cards, table, kanban cards, header bar |
|
||||
| `bg-muted` / `text-muted-foreground` | `#f9f9f9` / `#71717a` | `#171f1b` / `#9aa39e` | Subtle backgrounds, secondary text, kanban columns |
|
||||
| `bg-primary` / `text-primary-foreground` | `#1A463C` | `#3FA88C` | Primary actions, sidebar, active nav state |
|
||||
| `bg-accent` | `#DEF168` | `#DEF168` | Rare highlight accent (lime) |
|
||||
| `border-border` / `border-input` | `#e5e7eb` | `#26302b` | All hairline borders |
|
||||
| `ring-ring` | `#1A463C` | `#3FA88C` | Focus rings |
|
||||
| `bg-destructive` | `#ef4444` | `#f87171` | Destructive actions |
|
||||
|
||||
**Sidebar exception**: the sidebar stays brand green `bg-[#1A463C]` in both
|
||||
themes (it is not a themed surface — it's the constant brand anchor).
|
||||
|
||||
**Status/semantic colors** (lead stages, badges) use Tailwind's default
|
||||
palette (blue/purple/amber/orange/emerald/red) directly, each paired with an
|
||||
explicit `dark:` variant for legibility on dark surfaces — see `StatusBadge`.
|
||||
|
||||
## Elevation & Radius
|
||||
|
||||
- `--radius: 0.5rem` (base) is wired into `@theme` as `--radius-lg` (`=
|
||||
--radius`) and `--radius-xl` (`= --radius + 0.25rem`), so `rounded-lg` /
|
||||
`rounded-xl` utilities resolve consistently off one token.
|
||||
- `--shadow-card: 0 4px 20px rgba(0,0,0,0.01)` → `shadow-card` utility.
|
||||
Default resting elevation for cards, table containers, kanban cards.
|
||||
- `--shadow-card-hover: 0 8px 30px rgba(0,0,0,0.02)` → `shadow-card-hover`
|
||||
utility. Applied on hover for interactive cards.
|
||||
|
||||
## Layout Conventions
|
||||
|
||||
- **Table container**: `bg-card rounded-xl border border-border shadow-card
|
||||
overflow-hidden`.
|
||||
- **Table header row**: `bg-muted/50 border-b border-border text-[11px]
|
||||
uppercase tracking-wider text-muted-foreground`.
|
||||
- **Table body rows**: `py-4 px-6`, `divide-y divide-border`,
|
||||
`hover:bg-muted/40 transition-colors`.
|
||||
- **Kanban column**: `bg-muted/60 rounded-xl border border-border p-4`.
|
||||
- **Kanban card**: `bg-card rounded-lg border border-border shadow-sm
|
||||
hover:border-primary/30 hover:shadow-card-hover transition-all`.
|
||||
- **Page header**: title `text-2xl font-semibold tracking-tight
|
||||
text-foreground`, subtitle `text-xs text-muted-foreground mt-1`, primary
|
||||
action button `bg-primary text-primary-foreground hover:bg-primary/90`.
|
||||
|
||||
## Color/Class Migration Map (dual-theme principle)
|
||||
|
||||
When restyling any surface, replace hardcoded classes with token utilities:
|
||||
|
||||
| Old (hardcoded) | New (token) |
|
||||
|---|---|
|
||||
| `bg-white` | `bg-card` |
|
||||
| `bg-slate-50` / `bg-gray-50` | `bg-muted` |
|
||||
| `border-slate-100` / `border-slate-200` | `border-border` |
|
||||
| `text-slate-900` / `text-[#1a1a1a]` | `text-foreground` |
|
||||
| `text-slate-500` / `text-slate-400` / `text-[#71717a]` | `text-muted-foreground` |
|
||||
| `bg-brand-dark` / `text-[#1A463C]` (as action color) | `bg-primary` / `text-primary` |
|
||||
|
||||
## Motion
|
||||
|
||||
- **Sidebar collapse**: `transition-[width] duration-350
|
||||
ease-[cubic-bezier(0.4,0,0.2,1)]` on the `<aside>`; label/logo text use
|
||||
`whitespace-nowrap transition-opacity duration-200` and fade to
|
||||
`opacity-0` immediately on collapse, but only fade back in ~150ms after
|
||||
the width transition starts on expand (avoids text reflow/wrap during the
|
||||
animation).
|
||||
- **Hover states**: `transition-colors duration-150`–`duration-200` on rows,
|
||||
buttons, nav links.
|
||||
- **Kanban drag**: dragged card renders in a `DragOverlay` with
|
||||
`rotate-1 shadow-xl`; drop target column highlights via `border-primary
|
||||
bg-primary/5`.
|
||||
|
||||
## UX Rules
|
||||
|
||||
1. Numeric/tabular data is always monospace and right-aligned in tables.
|
||||
2. Every interactive control has a visible focus state (`focus:ring-1
|
||||
focus:ring-ring focus:border-primary` pattern) for keyboard accessibility.
|
||||
3. Status is always communicated redundantly — color + text label — never
|
||||
color alone.
|
||||
4. Destructive actions use `destructive` tokens, never ad-hoc red.
|
||||
5. Empty states are muted, centered, and brief ("Nessun lead trovato").
|
||||
6. Sidebar collapse state persists across page loads via `localStorage`
|
||||
(`iamcavalli:sidebar`) and is restored before paint where possible to
|
||||
avoid layout jump.
|
||||
|
||||
---
|
||||
|
||||
## Component Inventory
|
||||
|
||||
Primitives extracted from the Lead Pipeline redesign, intended for reuse
|
||||
across all future admin pages.
|
||||
|
||||
| Component | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `AdminShell` | `src/components/admin/AdminShell.tsx` | Client shell holding sidebar-collapsed state (persisted to `localStorage`), renders sidebar + top header + `<main>`. Wraps all `/admin/*` pages. |
|
||||
| `AdminSidebar` | `src/components/admin/AdminSidebar.tsx` | Collapsible brand sidebar (`w-64` ↔ `w-20`), controlled by `AdminShell`. Nav items, theme toggle, sign-out. |
|
||||
| `StatusBadge` | `src/components/ui/StatusBadge.tsx` | Rounded-full status pill with a color map per lead stage, each with a `dark:` variant. Replaces the old scattered `STAGE_COLOR` maps. |
|
||||
| `SearchInput` | `src/components/ui/SearchInput.tsx` | Search-icon input, `rounded-lg border-border focus:ring-primary`. |
|
||||
| `SegmentedToggle` | `src/components/ui/SegmentedToggle.tsx` | Generic Lista/Kanban-style segmented control (`bg-muted p-1 rounded-lg`, active option `bg-card shadow-sm`). |
|
||||
| `PageHeader` | `src/components/admin/PageHeader.tsx` | Page title (`text-2xl tracking-tight`) + subtitle + action slot, token-based. |
|
||||
| `LeadTable` | `src/components/admin/leads/LeadTable.tsx` | Luxury table restyle: `bg-card rounded-xl shadow-card`, uppercase muted headers, `hover:bg-muted/40` rows, uses `StatusBadge`. |
|
||||
| `LeadsKanbanBoard` | `src/components/admin/leads/LeadsKanbanBoard.tsx` | Kanban restyle: `bg-muted/60` columns with count pills, `bg-card` cards with `hover:border-primary/30`. Uses `@dnd-kit`, keeps all 6 lead stages. |
|
||||
| `LeadsViewToggle` | `src/components/admin/leads/LeadsViewToggle.tsx` | Wires `SearchInput` + `SegmentedToggle` together with the table/kanban views. |
|
||||
| `MilestoneStepper` | `src/components/client/MilestoneStepper.tsx` | Client-portal horizontal milestone stepper: one node per real phase, connecting line filled by `global_progress_pct`. Done = filled `bg-primary` ✓, active = `border-primary` outline, upcoming = `bg-muted`. |
|
||||
| `LeadDetail` | `src/components/admin/leads/LeadDetail.tsx` | Lead detail page (`/admin/pipeline/[id]`): bespoke header (name + inline `StatusBadge` + action bar), asymmetric `lg:grid-cols-3` layout (1/3 profile card w/ icon-chip rows + tags, 2/3 Note/Follow-up + unified timeline). |
|
||||
| `ClientProfitability` | `src/components/admin/dashboard/ClientProfitability.tsx` | Dashboard widget: valore orario reale per cliente (`getClientProfitability` → contrattualizzato ÷ ore tracciate) con badge margine (Ottimo `bg-primary/10 text-primary` / In Linea `bg-muted`). |
|
||||
| `YearSelector` | `src/components/admin/YearSelector.tsx` | Selettore anno restilizzato a pill (`rounded-full border-border bg-card shadow-card`), frecce ←/→, valore `font-mono`. Guida i dati year-scoped della dashboard via `?year=`. |
|
||||
| `ConversationsView` | `src/components/admin/conversazioni/ConversationsView.tsx` | Inbox messaggi clienti (`/admin/conversazioni`) a due pannelli: lista conversazioni filtrabile (search su nome/brand/ultimo messaggio, pallino unread) + thread attivo con bolle messaggio (admin a destra `bg-primary`, cliente a sinistra), badge entità per messaggio (Generale/Fase/Task/Deliverable) e form di risposta. Auto-mark-read all'apertura. |
|
||||
| `MessagesWidget` | `src/components/admin/dashboard/MessagesWidget.tsx` | Widget dashboard "Messaggi Clienti": chat clienti in attesa di risposta (`getConversations()` filtrato su `unread`), pill emerald "N Nuovi" + pallino pulsante, righe con anteprima e link `Rispondi →` deep-link a `/admin/conversazioni?c=<clientId>`. Empty state "Nessun messaggio in attesa ✓". |
|
||||
| `CopyLinkButton` | `src/components/admin/CopyLinkButton.tsx` | Icon button che copia un URL assoluto (`window.location.origin + path`) negli appunti; swap icona → check emerald per 1.5s con tooltip "Copiato!". Usato nella lista Clienti per il link profilo pubblico. |
|
||||
| `ClientRow` | `src/components/admin/ClientRow.tsx` | Riga tabella Clienti restilizzata Quiet Luxury: nome link → dettaglio + sottotitolo brand/progetti, colonne numeriche `font-mono` right-aligned (LTV, incassato come pill emerald, da saldare), cella "Link profilo" con pill mono troncata (apre portale in new tab) + `CopyLinkButton`. |
|
||||
|
||||
**Unified timeline pattern**: activities + transcripts merged into one
|
||||
reverse-chronological "Storico delle Interazioni". Vertical connector = absolute
|
||||
`w-px bg-border` at `left-[19px]` behind `w-10 h-10 rounded-full` nodes.
|
||||
Transcript node is highlighted (`bg-primary text-primary-foreground`) and
|
||||
expandable (`line-clamp-2` → "Mostra tutto/meno"); activity nodes are muted
|
||||
(`bg-muted border-border`). Dates are `font-mono` — activities relative
|
||||
(`formatDistanceToNow`), transcripts absolute (`d MMMM yyyy`).
|
||||
|
||||
### Admin Dashboard restyle notes
|
||||
|
||||
The `/admin` dashboard was migrated to "Quiet Luxury" (applied 2026-07-11),
|
||||
replicating `design-reference/pagina-dashboard`. It is a condensed single-screen
|
||||
layout: no in-`<main>` `PageHeader` (sidebar + shell header carry the chrome).
|
||||
|
||||
- **KPI strip**: 4 `bg-card` metric cards (Clienti Attivi · Contrattualizzato ·
|
||||
Incassato Reale · Ore Tracciate). Value `text-2xl font-bold font-mono`, label
|
||||
`text-[10px] uppercase text-muted-foreground`. Positive delta uses
|
||||
`text-emerald-600 dark:text-emerald-400`.
|
||||
- **Griglia `lg:grid-cols-3`**: 2/3 col = `ForecastChart` (istogramma flussi 12
|
||||
mesi, mese prossimo evidenziato `bg-primary`) + `ClientProfitability`; 1/3 col =
|
||||
`FollowUpWidget` (count pill amber) + `MessagesWidget` ("Messaggi Clienti", pill
|
||||
emerald "N Nuovi" + pallino pulsante, chat clienti in attesa di risposta) +
|
||||
`OffersSoldChart` ("Offerte Più Richieste").
|
||||
- **Year selector** interattivo (`YearSelector`) in una toolbar top-right; governa
|
||||
i KPI year-scoped + redditività. Il forecast è rolling-12-mesi (non year-scoped).
|
||||
- Widget rimossi dal legacy: chart incassi mensili, card "Da incassare"/"Clienti
|
||||
acquisiti", barre "Ore per cliente" (sostituite dalla redditività oraria).
|
||||
|
||||
### Conversazioni (inbox) notes
|
||||
|
||||
`/admin/conversazioni` è l'inbox unificata dei messaggi clienti (replica
|
||||
`design-reference/pagina-conversazioni`). Ogni conversazione è **per cliente**:
|
||||
raccoglie tutti i suoi commenti — `general` + quelli su fasi/task/deliverable —
|
||||
in un unico thread ordinato, con badge entità per messaggio.
|
||||
|
||||
- **Fonte unread**: `clients.admin_last_read_at` confrontato con l'ultimo
|
||||
messaggio autore `client`. Una conversazione è non letta se esiste un messaggio
|
||||
cliente dopo `admin_last_read_at` (o se è `NULL`). Query in
|
||||
`src/lib/conversations-queries.ts` (`getConversations`, `getConversationThread`,
|
||||
`getUnreadConversationsCount`).
|
||||
- **Badge sidebar**: voce "Conversazioni" (`AdminSidebar`) con pill emerald del
|
||||
numero di clienti con non letti (pallino verde se collapsed), alimentato da
|
||||
`getUnreadConversationsCount()` in `src/app/admin/layout.tsx`. Stessa fonte di
|
||||
verità del `MessagesWidget` in dashboard.
|
||||
- **Reply**: salvata come commento `general` sul cliente e aggiorna
|
||||
`admin_last_read_at` (mark-read). L'apertura di una conversazione fa
|
||||
auto-mark-read. Deep-link a un thread specifico via `?c=<clientId>`.
|
||||
|
||||
### Clienti (lista) notes
|
||||
|
||||
`/admin/clients` migrata a "Quiet Luxury" (replica `design-reference/pagina-clienti`).
|
||||
Tabella `bg-card rounded-xl border-border shadow-card` con header uppercase muted e
|
||||
righe `py-4 px-6` / `divide-y divide-border` / `hover:bg-muted/40`.
|
||||
|
||||
- **Colonne**: Cliente (nome link → `/admin/clients/[id]` + sottotitolo
|
||||
brand/progetti) a sinistra; LTV, Importo incassato, Importo da saldare, Link
|
||||
profilo tutte right-aligned. Valori monetari `font-mono`; "incassato" reso come
|
||||
pill emerald (`bg-emerald-50 text-emerald-700` + variante dark), zero → `—` muted.
|
||||
- **Link profilo**: pill mono troncata (`/client/<slug|token>…`) che apre il portale
|
||||
in new tab + `CopyLinkButton` (copia URL assoluto).
|
||||
- **Header actions**: link testuale "Mostra/Nascondi archiviati" (`?archived=1`) +
|
||||
Button primario "Nuovo cliente". Footer tabella "Mostrando N clienti attivi"
|
||||
(singolare/plurale gestiti).
|
||||
- Le righe archiviate restano `opacity-60` con pill "Archiviato".
|
||||
|
||||
### Offerte (lista) notes
|
||||
|
||||
`/admin/offers` migrata a "Quiet Luxury" (replica `design-reference/pagina-offerte`).
|
||||
Griglia responsive di card `bg-card rounded-xl border-border p-6 shadow-card`
|
||||
(`hover:shadow-card-hover`), `md:grid-cols-2 lg:grid-cols-3`.
|
||||
|
||||
- **Header**: `PageHeader` "Offerte" + sottotitolo, azione "+ Nuova Offerta"
|
||||
(`Button` primario) che espande un form inline (`Input` nome + "Crea").
|
||||
- **Filtri**: pill categoria rettangolari (`rounded-lg px-4 py-2`) — attiva
|
||||
`bg-primary text-primary-foreground`, inattiva `border-border bg-card
|
||||
text-muted-foreground hover:bg-muted`. "Tutti" + `categoryOptions` dinamiche.
|
||||
A destra checkbox "Mostra offerte archiviate".
|
||||
- **Card**: nome interno + descrizione muted + badge categoria colorato per tipo
|
||||
(Retainer→blue, Entry→purple, Signature→amber, tutti con variante dark) via
|
||||
helper `categoryBadgeClass`. Le archiviate sono `opacity-60` con pill "Archiviata".
|
||||
- **Tabella tier**: `border-border rounded-lg`, header uppercase muted
|
||||
(Tier · Servizi · Pubblico), righe `font-mono` `divide-y divide-border`; solo i
|
||||
tier con `servicesTotal > 0` o `public_price` valorizzato; "Pubblico" assente → `—`.
|
||||
|
||||
### Client Portal restyle notes
|
||||
|
||||
The `/client/[token]` portal was migrated to the same "Quiet Luxury" tokens
|
||||
(applied 2026-07-10). Key conventions specific to the client-facing surface:
|
||||
|
||||
- **Header**: `bg-card` sticky bar, three zones — `iamcavalli | Client Portal`
|
||||
(left), brand name centered, "Area Riservata Protetta" emerald pill (right).
|
||||
- **Status pills** (phases, payments) use soft tints, not solid fills:
|
||||
emerald-50/700 (done/saldato), amber-50/700 (in corso/da saldare),
|
||||
`bg-muted`/`text-muted-foreground` (upcoming) — each with a `dark:` variant.
|
||||
- **Phase cards** (`PhaseCard`): `rounded-xl border-border-light bg-card
|
||||
shadow-card`, progress bar colored per status (emerald/amber/`bg-border`).
|
||||
- Sidebar cards (`OffersSection`, `PaymentStatus`, `DocumentsSection`,
|
||||
`NotesSection`, `TranscriptsSection`) all follow `rounded-xl border-border-light
|
||||
bg-card shadow-card`.
|
||||
- `ClientDashboard` takes an `embedded` prop: when rendered inside the
|
||||
multi-project tabs wrapper (`page.tsx`), it drops its own header/footer/canvas
|
||||
so the page-level chrome isn't duplicated.
|
||||
@@ -0,0 +1,361 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Catalogo Servizi — Luxury Admin CRM</title>
|
||||
<!-- Google Fonts: Plus Jakarta Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
dark: '#1A463C', /* Verde scuro richiesto */
|
||||
darkHover: '#13342D', /* Variante scura per hover */
|
||||
active: 'rgba(255, 255, 255, 0.08)',
|
||||
bg: '#F8F9FA',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.sidebar-transition {
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
|
||||
<div class="overflow-hidden">
|
||||
<!-- Logo e Intestazione -->
|
||||
<div class="mb-10 px-2 flex items-center justify-between">
|
||||
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigazione Principale -->
|
||||
<nav class="space-y-1">
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
|
||||
</a>
|
||||
<!-- Stato Attivo su Catalogo -->
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Catalogo</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Area Inferiore Sidebar -->
|
||||
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
|
||||
<span class="sidebar-text transition-opacity duration-300">Tema</span>
|
||||
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span class="sidebar-text transition-opacity duration-300">Esci</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- SEZIONE PRINCIPALE -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
<!-- BARRA DI INTESTAZIONE SUPERIORE -->
|
||||
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Pulsante Apri/Chiudi Sidebar -->
|
||||
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
|
||||
</button>
|
||||
<span class="text-xs text-slate-400 font-medium">Area di Lavoro</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Demo Account</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- AREA DEL CONTENUTO -->
|
||||
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-6 overflow-y-auto">
|
||||
|
||||
<!-- INTESTAZIONE SEZIONE -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900 tracking-tight">Catalogo Servizi</h1>
|
||||
<p class="text-xs text-slate-400 mt-1">Configura e gestisci le singole voci di servizio offerte</p>
|
||||
</div>
|
||||
|
||||
<!-- BARRA DI RICERCA MINIMALE -->
|
||||
<div class="relative w-full">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3.5 pointer-events-none text-slate-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cerca per nome, categoria, fase, tag o pacchetto..."
|
||||
class="w-full pl-10 pr-4 py-3 bg-white text-sm text-slate-800 placeholder-slate-400 border border-slate-200/80 rounded-lg focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TABELLA CATALOGO -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse table-fixed min-w-[1000px]">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-100/80 bg-slate-50/50 text-slate-400 text-[11px] font-semibold uppercase tracking-wider">
|
||||
<th class="py-3 px-5 w-1/3">
|
||||
<span class="inline-flex items-center gap-1 cursor-pointer hover:text-slate-700">
|
||||
Nome
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M8 9l4-4 4 4m0 6l-4 4-4-4"/></svg>
|
||||
</span>
|
||||
</th>
|
||||
<th class="py-3 px-4 w-[110px]">
|
||||
<span class="inline-flex items-center gap-1 cursor-pointer hover:text-slate-700">
|
||||
Prezzo
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M8 9l4-4 4 4m0 6l-4 4-4-4"/></svg>
|
||||
</span>
|
||||
</th>
|
||||
<th class="py-3 px-4 w-[160px]">Offerta</th>
|
||||
<th class="py-3 px-4 w-[240px]">Fase</th>
|
||||
<th class="py-3 px-4 w-1/5">Descrizione</th>
|
||||
<th class="py-3 px-4 w-[100px] text-center">Pacchetto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100/60 text-[13px] text-slate-700">
|
||||
|
||||
<!-- Riga 1 -->
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-3 px-5 font-medium text-slate-900 truncate">Landing Page (Metodo o Differenziante)</td>
|
||||
<td class="py-3 px-4 font-mono text-xs text-slate-600">€1.000,00</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
Signature Offer
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium bg-amber-50 text-amber-700 border border-amber-100">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
|
||||
Fase 3 ➔ Esecuzione
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-slate-400 text-xs truncate">—</td>
|
||||
<td class="py-3 px-4 text-center">
|
||||
<button class="w-6 h-6 rounded-full border border-slate-200 text-slate-400 hover:text-brand-dark hover:border-brand-dark hover:bg-emerald-50/30 flex items-center justify-center mx-auto transition-all">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Riga 2 -->
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-3 px-5 font-medium text-slate-900 truncate">Analisi Competitor</td>
|
||||
<td class="py-3 px-4 font-mono text-xs text-slate-600">€400,00</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex flex-col gap-1 items-start">
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-purple-50 text-purple-600 border border-purple-100">
|
||||
Entry Offer
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
Signature Offer
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium bg-blue-50 text-blue-700 border border-blue-100">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-400"></span>
|
||||
Fase 2 ➔ Analisi / Strat.
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-slate-400 text-xs truncate">—</td>
|
||||
<td class="py-3 px-4 text-center">
|
||||
<button class="w-6 h-6 rounded-full border border-slate-200 text-slate-400 hover:text-brand-dark hover:border-brand-dark hover:bg-emerald-50/30 flex items-center justify-center mx-auto transition-all">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Riga 3 -->
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-3 px-5 font-medium text-slate-900 truncate">Art direction su direzione da prendere</td>
|
||||
<td class="py-3 px-4 font-mono text-xs text-slate-600">€2.000,00</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-blue-50 text-blue-600 border border-blue-100">
|
||||
Retainer Offer
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-slate-400 text-xs">—</td>
|
||||
<td class="py-3 px-4 text-slate-400 text-xs truncate">—</td>
|
||||
<td class="py-3 px-4 text-center">
|
||||
<button class="w-6 h-6 rounded-full border border-slate-200 text-slate-400 hover:text-brand-dark hover:border-brand-dark hover:bg-emerald-50/30 flex items-center justify-center mx-auto transition-all">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Riga 4 -->
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-3 px-5 font-medium text-slate-900 truncate">Audit iniziale (UX/UI, struttura, conversione)</td>
|
||||
<td class="py-3 px-4 font-mono text-xs text-slate-600">€500,00</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex flex-col gap-1 items-start">
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-purple-50 text-purple-600 border border-purple-100">
|
||||
Entry Offer
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
Signature Offer
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium bg-orange-50 text-orange-700 border border-orange-100">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-orange-400"></span>
|
||||
Fase 1 ➔ Onboarding
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-slate-400 text-xs truncate">—</td>
|
||||
<td class="py-3 px-4 text-center">
|
||||
<button class="w-6 h-6 rounded-full border border-slate-200 text-slate-400 hover:text-brand-dark hover:border-brand-dark hover:bg-emerald-50/30 flex items-center justify-center mx-auto transition-all">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Riga 5 -->
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-3 px-5 font-medium text-slate-900 truncate">Go-live / messa online & accessi</td>
|
||||
<td class="py-3 px-4 font-mono text-xs text-slate-600">€300,00</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded text-[10px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
Signature Offer
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium bg-teal-50 text-teal-700 border border-teal-100">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-teal-400"></span>
|
||||
Fase 5 ➔ Consegna
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-slate-400 text-xs truncate">—</td>
|
||||
<td class="py-3 px-4 text-center">
|
||||
<button class="w-6 h-6 rounded-full border border-slate-200 text-slate-400 hover:text-brand-dark hover:border-brand-dark hover:bg-emerald-50/30 flex items-center justify-center mx-auto transition-all">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- FORM INLINE PER NUOVA RIGA ("Aggiungi servizio") -->
|
||||
<tr class="bg-slate-50/40 border-t-2 border-slate-100">
|
||||
<td class="py-4 px-5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="+ Aggiungi servizio..."
|
||||
class="w-full bg-white border border-slate-200 rounded px-3 py-1.5 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="0,00"
|
||||
class="w-full bg-white border border-slate-200 rounded px-3 py-1.5 text-xs font-mono text-slate-800 placeholder-slate-400 focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-4 px-4 text-slate-300 text-xs select-none">Seleziona...</td>
|
||||
<td class="py-4 px-4">
|
||||
<select class="w-full bg-white border border-slate-200 rounded px-2 py-1.5 text-xs text-slate-600 focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all">
|
||||
<option value="">Fase...</option>
|
||||
<option value="1">Fase 1</option>
|
||||
<option value="2">Fase 2</option>
|
||||
<option value="3">Fase 3</option>
|
||||
<option value="4">Fase 4</option>
|
||||
<option value="5">Fase 5</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="py-4 px-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Descrizione..."
|
||||
class="w-full bg-white border border-slate-200 rounded px-3 py-1.5 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-4 px-4 text-center">
|
||||
<button class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-medium px-4 py-1.5 rounded transition-all">
|
||||
Invia
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER TABELLA -->
|
||||
<div class="border-t border-slate-100 px-6 py-4 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Visualizzazione di 5 servizi principali</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- SCRIPT DI LOGICA (Gestione Sidebar) -->
|
||||
<script>
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleButton = document.getElementById('sidebar-toggle');
|
||||
const logoText = document.getElementById('sidebar-logo-text');
|
||||
const sidebarTexts = document.querySelectorAll('.sidebar-text');
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const isCollapsed = sidebar.classList.contains('w-20');
|
||||
|
||||
if (isCollapsed) {
|
||||
sidebar.classList.remove('w-20');
|
||||
sidebar.classList.add('w-64');
|
||||
setTimeout(() => {
|
||||
logoText.classList.remove('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
|
||||
}, 150);
|
||||
} else {
|
||||
logoText.classList.add('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
|
||||
sidebar.classList.remove('w-64');
|
||||
sidebar.classList.add('w-20');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Clienti — Luxury Admin CRM</title>
|
||||
<!-- Google Fonts: Plus Jakarta Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
dark: '#1A463C', /* Verde scuro richiesto */
|
||||
darkHover: '#13342D', /* Variante scura per hover */
|
||||
active: 'rgba(255, 255, 255, 0.08)',
|
||||
bg: '#F8F9FA',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.sidebar-transition {
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
|
||||
<div class="overflow-hidden">
|
||||
<!-- Logo e Intestazione -->
|
||||
<div class="mb-10 px-2 flex items-center justify-between">
|
||||
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigazione Principale -->
|
||||
<nav class="space-y-1">
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
|
||||
</a>
|
||||
<!-- Stato Attivo su Clienti -->
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Area Inferiore Sidebar -->
|
||||
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
|
||||
<span class="sidebar-text transition-opacity duration-300">Tema</span>
|
||||
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span class="sidebar-text transition-opacity duration-300">Esci</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- SEZIONE PRINCIPALE -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
<!-- BARRA DI INTESTAZIONE SUPERIORE -->
|
||||
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Pulsante Apri/Chiudi Sidebar -->
|
||||
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
|
||||
</button>
|
||||
<span class="text-xs text-slate-400 font-medium">Area di Lavoro</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Demo Account</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- AREA DEL CONTENUTO DINAMICO -->
|
||||
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-8 overflow-y-auto">
|
||||
|
||||
<!-- INTESTAZIONE SEZIONE -->
|
||||
<div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900 tracking-tight">Clienti</h1>
|
||||
<p class="text-xs text-slate-400 mt-1">Monitora i rapporti finanziari e i dettagli dei clienti attivi</p>
|
||||
</div>
|
||||
|
||||
<!-- Pulsanti di azione -->
|
||||
<div class="flex items-center gap-4 self-start sm:self-auto">
|
||||
<button class="text-xs font-medium text-slate-500 hover:text-slate-900 transition-colors">
|
||||
Mostra archiviati
|
||||
</button>
|
||||
<button class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-medium tracking-wide px-5 py-3 rounded-lg flex items-center gap-2 transition-all duration-200 shadow-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Nuovo cliente
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TABELLA CLIENTI -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-100/80 bg-slate-50/50 text-slate-400 text-[11px] font-semibold uppercase tracking-wider">
|
||||
<th class="py-4 px-6 w-1/4">Cliente</th>
|
||||
<th class="py-4 px-6 text-right">LTV</th>
|
||||
<th class="py-4 px-6 text-right">Importo Incassato</th>
|
||||
<th class="py-4 px-6 text-right">Importo da Saldare</th>
|
||||
<th class="py-4 px-6 text-right">Link Profilo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100/60 text-sm">
|
||||
|
||||
<!-- Riga 1: George Vlad -->
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-4 px-6">
|
||||
<div class="font-semibold text-slate-950">George Vlad</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Protocollo Estetico | Incarichi Online</div>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-right font-medium text-slate-900">€7.000,00</td>
|
||||
<td class="py-4 px-6 text-right">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-emerald-50 text-emerald-700">
|
||||
€2.800,00
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-right font-medium text-slate-600">€4.200,00</td>
|
||||
<td class="py-4 px-6 text-right">
|
||||
<div class="inline-flex items-center gap-2 justify-end">
|
||||
<span class="text-xs font-mono text-slate-400 bg-slate-50 px-2 py-1 rounded border border-slate-100">/client/george-vlad-...</span>
|
||||
<button class="p-1 hover:bg-slate-100 rounded text-slate-400 hover:text-slate-700 transition-colors" title="Copia Link">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Riga 2: Gian Luca Caruso -->
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-4 px-6">
|
||||
<div class="font-semibold text-slate-950">Gian Luca Caruso</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Caruso Speaker</div>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-right font-medium text-slate-900">€5.000,00</td>
|
||||
<td class="py-4 px-6 text-right">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-semibold bg-emerald-50 text-emerald-700">
|
||||
€2.500,00
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-right font-medium text-slate-600">€2.500,00</td>
|
||||
<td class="py-4 px-6 text-right">
|
||||
<div class="inline-flex items-center gap-2 justify-end">
|
||||
<span class="text-xs font-mono text-slate-400 bg-slate-50 px-2 py-1 rounded border border-slate-100">/client/gian-luca-ca...</span>
|
||||
<button class="p-1 hover:bg-slate-100 rounded text-slate-400 hover:text-slate-700 transition-colors" title="Copia Link">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Riga 3: Gianfranco Barban -->
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-4 px-6">
|
||||
<div class="font-semibold text-slate-950">Gianfranco Barban</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Teckell</div>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-right font-medium text-slate-900">€200,00</td>
|
||||
<td class="py-4 px-6 text-right text-slate-300 font-medium">—</td>
|
||||
<td class="py-4 px-6 text-right text-slate-300 font-medium">—</td>
|
||||
<td class="py-4 px-6 text-right">
|
||||
<div class="inline-flex items-center gap-2 justify-end">
|
||||
<span class="text-xs font-mono text-slate-400 bg-slate-50 px-2 py-1 rounded border border-slate-100">/client/gianfranco-b...</span>
|
||||
<button class="p-1 hover:bg-slate-100 rounded text-slate-400 hover:text-slate-700 transition-colors" title="Copia Link">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Riga 4: Mario Rossi -->
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-4 px-6">
|
||||
<div class="font-semibold text-slate-950">Mario Rossi</div>
|
||||
<div class="text-[11px] text-slate-400 mt-0.5">Rossi Inc</div>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-right font-medium text-slate-900">€7.000,00</td>
|
||||
<td class="py-4 px-6 text-right text-slate-300 font-medium">—</td>
|
||||
<td class="py-4 px-6 text-right font-medium text-slate-600">€7.000,00</td>
|
||||
<td class="py-4 px-6 text-right">
|
||||
<div class="inline-flex items-center gap-2 justify-end">
|
||||
<span class="text-xs font-mono text-slate-400 bg-slate-50 px-2 py-1 rounded border border-slate-100">/client/mario-rossi-...</span>
|
||||
<button class="p-1 hover:bg-slate-100 rounded text-slate-400 hover:text-slate-700 transition-colors" title="Copia Link">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer Tabella -->
|
||||
<div class="border-t border-slate-100 px-6 py-4 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Mostrando 4 di 4 clienti attivi</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- SCRIPT DI LOGICA (Gestione Sidebar) -->
|
||||
<script>
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleButton = document.getElementById('sidebar-toggle');
|
||||
const logoText = document.getElementById('sidebar-logo-text');
|
||||
const sidebarTexts = document.querySelectorAll('.sidebar-text');
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const isCollapsed = sidebar.classList.contains('w-20');
|
||||
|
||||
if (isCollapsed) {
|
||||
sidebar.classList.remove('w-20');
|
||||
sidebar.classList.add('w-64');
|
||||
setTimeout(() => {
|
||||
logoText.classList.remove('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
|
||||
}, 150);
|
||||
} else {
|
||||
logoText.classList.add('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
|
||||
sidebar.classList.remove('w-64');
|
||||
sidebar.classList.add('w-20');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,484 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rossi Inc — Client Portal</title>
|
||||
<!-- Google Fonts: Plus Jakarta Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<!-- Canvas Confetti per micro-interazioni celebrative -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.6.0/dist/confetti.browser.min.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
dark: '#1A463C', /* Verde lusso */
|
||||
darkHover: '#13342D',
|
||||
lightBg: '#F8F9FA',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.smooth-transition {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
/* Stili per il Dragging del Kanban */
|
||||
.dragging {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.drag-over-zone {
|
||||
background-color: rgba(26, 70, 60, 0.03);
|
||||
border-color: rgba(26, 70, 60, 0.2);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-brand-lightBg font-sans text-slate-800 antialiased min-h-screen pb-16">
|
||||
|
||||
<!-- HEADER PORTALE -->
|
||||
<header class="bg-white border-b border-slate-100 px-8 py-5 sticky top-0 z-50 shadow-sm flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div class="flex items-center gap-3 w-full md:w-auto">
|
||||
<span class="text-xs font-bold uppercase tracking-widest text-slate-400">iamcavalli</span>
|
||||
<span class="text-slate-300">|</span>
|
||||
<span class="text-xs font-medium text-slate-500">Client Portal</span>
|
||||
</div>
|
||||
|
||||
<!-- Rossi Inc al Centro -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-xl font-bold text-slate-900 tracking-tight">Rossi Inc</h1>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-2 text-[11px] text-emerald-700 bg-emerald-50 px-3 py-1 rounded-full border border-emerald-100">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
Area Riservata Protetta
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- STEP PROGRESS TIMELINE UNIFICATA -->
|
||||
<div class="bg-white border-b border-slate-100 py-8 px-6">
|
||||
<div class="max-w-[1200px] mx-auto relative">
|
||||
<!-- Linea di connessione di fondo -->
|
||||
<div class="absolute top-[15px] left-[5%] right-[5%] h-1 bg-slate-100 rounded-full z-0">
|
||||
<!-- Riempimento dinamico fisso basato sul progresso reale del progetto -->
|
||||
<div class="bg-brand-dark h-full rounded-full smooth-transition" style="width: 35%;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Contenitore Nodi Milestone -->
|
||||
<div class="relative z-10 grid grid-cols-5 gap-4">
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-brand-dark text-white flex items-center justify-center text-xs font-bold shadow-md border-4 border-white smooth-transition">
|
||||
✓
|
||||
</div>
|
||||
<span class="text-[11px] font-bold text-slate-900 mt-2">Step 1</span>
|
||||
<span class="text-[10px] text-slate-400 font-medium">Onboarding</span>
|
||||
<span class="text-[9px] font-bold text-emerald-600 mt-1 uppercase">Completed</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-white text-brand-dark border-4 border-brand-dark flex items-center justify-center text-xs font-bold shadow-md smooth-transition">
|
||||
2
|
||||
</div>
|
||||
<span class="text-[11px] font-bold text-slate-900 mt-2">Step 2</span>
|
||||
<span class="text-[10px] text-slate-500 font-medium">Analisi</span>
|
||||
<span class="text-[9px] font-bold text-amber-600 mt-1 uppercase">In Progress</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-slate-100 text-slate-400 border-4 border-white flex items-center justify-center text-xs font-semibold smooth-transition">
|
||||
3
|
||||
</div>
|
||||
<span class="text-[11px] font-bold text-slate-500 mt-2">Step 3</span>
|
||||
<span class="text-[10px] text-slate-400 font-medium">Esecuzione</span>
|
||||
<span class="text-[9px] font-bold text-slate-400 mt-1 uppercase">Pending</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 4 -->
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-slate-100 text-slate-400 border-4 border-white flex items-center justify-center text-xs font-semibold smooth-transition">
|
||||
4
|
||||
</div>
|
||||
<span class="text-[11px] font-bold text-slate-500 mt-2">Step 4</span>
|
||||
<span class="text-[10px] text-slate-400 font-medium">Raffinamento</span>
|
||||
<span class="text-[9px] font-bold text-slate-400 mt-1 uppercase">Pending</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 5 -->
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="w-8 h-8 rounded-full bg-slate-100 text-slate-400 border-4 border-white flex items-center justify-center text-xs font-semibold smooth-transition">
|
||||
5
|
||||
</div>
|
||||
<span class="text-[11px] font-bold text-slate-500 mt-2">Step 5</span>
|
||||
<span class="text-[10px] text-slate-400 font-medium">Consegna</span>
|
||||
<span class="text-[9px] font-bold text-slate-400 mt-1 uppercase">Pending</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SEZIONE PRINCIPALE CONTENUTO -->
|
||||
<main class="max-w-[1400px] mx-auto px-6 mt-10 grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
|
||||
<!-- COLONNA DI SINISTRA (Informazioni statiche di riepilogo) -->
|
||||
<div class="lg:col-span-1 flex flex-col gap-6">
|
||||
|
||||
<!-- Offerte Attive -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 p-5 shadow-sm">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-4">Offerte Attive</h3>
|
||||
<div class="border-l-2 border-brand-dark pl-3">
|
||||
<h4 class="text-sm font-bold text-slate-900">Web Domination</h4>
|
||||
<div class="flex justify-between items-center text-xs mt-2 text-slate-500">
|
||||
<span>Prezzo finale</span>
|
||||
<span class="font-bold text-brand-dark">€7.000,00</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-slate-100">
|
||||
<button onclick="toggleAccordion('compreso-content')" class="flex justify-between items-center w-full text-xs font-semibold text-slate-600 hover:text-slate-950">
|
||||
<span>Cosa è compreso</span>
|
||||
<svg id="compreso-arrow" class="w-4 h-4 transform transition-transform" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg>
|
||||
</button>
|
||||
<div id="compreso-content" class="hidden mt-2 text-[11px] text-slate-500 space-y-1">
|
||||
<p>• Strategia e architettura Web</p>
|
||||
<p>• Sviluppo custom Webflow</p>
|
||||
<p>• Ottimizzazione SEO specialistica</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagamenti -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 p-5 shadow-sm">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-4">Pagamenti</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center p-3 rounded-lg border border-slate-100 text-xs">
|
||||
<span class="font-semibold text-slate-700">Saldo 50%</span>
|
||||
<span class="px-2 py-1 rounded text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 uppercase">Da Saldare</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-3 rounded-lg border border-slate-100 text-xs">
|
||||
<span class="font-semibold text-slate-700">Acconto 50%</span>
|
||||
<span class="px-2 py-1 rounded text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 uppercase">Saldato</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documenti -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 p-5 shadow-sm">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-4">Documenti & File</h3>
|
||||
<div class="space-y-2">
|
||||
<a href="#" class="flex items-center justify-between p-3 rounded-lg border border-slate-100 text-xs hover:border-slate-300 transition-colors">
|
||||
<span class="text-slate-700 font-medium">Brief Progetto</span>
|
||||
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- COLONNA DI DESTRA (Area di lavoro commutabile) -->
|
||||
<div class="lg:col-span-3 flex flex-col gap-6">
|
||||
|
||||
<!-- CONTROLLI DELLA VISTA -->
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 id="view-title" class="text-lg font-bold text-slate-900 tracking-tight">Fasi del Progetto</h2>
|
||||
<div class="bg-slate-200/50 p-1 rounded-lg flex gap-1">
|
||||
<button id="btn-timeline" class="px-5 py-1.5 bg-white rounded-md text-[11px] font-semibold text-slate-800 shadow-sm transition-all duration-200">
|
||||
Timeline
|
||||
</button>
|
||||
<button id="btn-kanban" class="px-5 py-1.5 rounded-md text-[11px] font-semibold text-slate-500 hover:text-slate-800 transition-all duration-200">
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= VISTA 1: TIMELINE (Read-only da backend) ================= -->
|
||||
<div id="view-timeline" class="space-y-6">
|
||||
|
||||
<!-- FASE 1: COMPLETATA -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 p-6 shadow-sm">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-bold text-slate-900">Fase 1 ➔ Onboarding / Setup</h3>
|
||||
<span class="px-2.5 py-0.5 rounded text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 uppercase">Completata</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden mb-4">
|
||||
<div class="bg-emerald-600 h-full" style="width: 100%;"></div>
|
||||
</div>
|
||||
<ul class="space-y-2.5 text-xs text-slate-600 pt-2 border-t border-slate-50">
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center text-[10px] font-bold">✓</span>
|
||||
<span class="line-through text-slate-400">Audit iniziale (UX/UI, struttura, conversione)</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center text-[10px] font-bold">✓</span>
|
||||
<span class="line-through text-slate-400">Raccolta e Mappatura Materiali</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 rounded-full bg-emerald-50 text-emerald-600 flex items-center justify-center text-[10px] font-bold">✓</span>
|
||||
<span class="line-through text-slate-400">Workshop 1° fase</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- FASE 2: IN CORSO -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 p-6 shadow-sm">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-bold text-slate-900">Fase 2 ➔ Analisi / Strategia</h3>
|
||||
<span class="px-2.5 py-0.5 rounded text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 uppercase">In corso</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden mb-4">
|
||||
<div class="bg-amber-500 h-full" style="width: 30%;"></div>
|
||||
</div>
|
||||
<ul class="space-y-2.5 text-xs text-slate-600 pt-2 border-t border-slate-50">
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
|
||||
<span>Analisi Competitor</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
|
||||
<span>Architettura informativa (Sitemap)</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
|
||||
<span>Brand Identity - Visual Identity</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- FASE 3: DA INIZIARE -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 p-6 shadow-sm">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-bold text-slate-900">Fase 3 ➔ Esecuzione / Core</h3>
|
||||
<span class="px-2.5 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-500 border border-slate-200 uppercase">Da iniziare</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden mb-4">
|
||||
<div class="bg-slate-300 h-full" style="width: 0%;"></div>
|
||||
</div>
|
||||
<ul class="space-y-2.5 text-xs text-slate-600 pt-2 border-t border-slate-50">
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
|
||||
<span>Homepage Figma & Webflow development</span>
|
||||
</li>
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
|
||||
<span>Configurazione CMS Blog e sezioni dinamiche</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- FASE 4: DA INIZIARE -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 p-6 shadow-sm">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-bold text-slate-900">Fase 4 ➔ Raffinamento / Extra</h3>
|
||||
<span class="px-2.5 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-500 border border-slate-200 uppercase">Da iniziare</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden mb-4">
|
||||
<div class="bg-slate-300 h-full" style="width: 0%;"></div>
|
||||
</div>
|
||||
<ul class="space-y-2.5 text-xs text-slate-600 pt-2 border-t border-slate-50">
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
|
||||
<span>Ottimizzazione Web Core Vitals</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- FASE 5: DA INIZIARE -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 p-6 shadow-sm">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-sm font-bold text-slate-900">Fase 5 ➔ Onboarding / Consegna</h3>
|
||||
<span class="px-2.5 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-500 border border-slate-200 uppercase">Da iniziare</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden mb-4">
|
||||
<div class="bg-slate-300 h-full" style="width: 0%;"></div>
|
||||
</div>
|
||||
<ul class="space-y-2.5 text-xs text-slate-600 pt-2 border-t border-slate-50">
|
||||
<li class="flex items-center gap-3">
|
||||
<span class="w-5 h-5 rounded-full border-2 border-slate-200 bg-white flex items-center justify-center"></span>
|
||||
<span>Rilascio e puntamento DNS live</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ================= VISTA 2: KANBAN ATTIVO & TRASCINABILE ================= -->
|
||||
<div id="view-kanban" class="hidden grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
|
||||
<!-- COLONNA: DA FARE -->
|
||||
<div class="kanban-column bg-slate-50/80 border border-slate-100 rounded-xl p-4 flex flex-col gap-4 min-h-[500px]" data-status="todo">
|
||||
<div class="flex justify-between items-center border-b border-slate-200/60 pb-2">
|
||||
<span class="text-xs font-bold text-slate-500 uppercase tracking-wider">Da Fare</span>
|
||||
<span class="count-pill bg-slate-200 text-slate-600 text-[10px] font-bold px-2 py-0.5 rounded-full">3</span>
|
||||
</div>
|
||||
<div class="cards-dropzone flex-1 flex flex-col gap-3">
|
||||
<!-- Card 1 -->
|
||||
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-1">
|
||||
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 2</span>
|
||||
<p class="text-xs font-medium text-slate-800">Analisi Competitor</p>
|
||||
</div>
|
||||
<!-- Card 2 -->
|
||||
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-2">
|
||||
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 2</span>
|
||||
<p class="text-xs font-medium text-slate-800">Architettura Informativa (Sitemap)</p>
|
||||
</div>
|
||||
<!-- Card 3 -->
|
||||
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-3">
|
||||
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 2</span>
|
||||
<p class="text-xs font-medium text-slate-800">Brand Identity - Visual Identity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COLONNA: IN CORSO -->
|
||||
<div class="kanban-column bg-slate-50/80 border border-slate-100 rounded-xl p-4 flex flex-col gap-4 min-h-[500px]" data-status="inprogress">
|
||||
<div class="flex justify-between items-center border-b border-slate-200/60 pb-2">
|
||||
<span class="text-xs font-bold text-slate-500 uppercase tracking-wider">In Corso</span>
|
||||
<span class="count-pill bg-slate-200 text-slate-600 text-[10px] font-bold px-2 py-0.5 rounded-full">1</span>
|
||||
</div>
|
||||
<div class="cards-dropzone flex-1 flex flex-col gap-3">
|
||||
<!-- Card 4 -->
|
||||
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-4">
|
||||
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 2</span>
|
||||
<p class="text-xs font-medium text-slate-800">Direzione Creativa & Moodboard</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COLONNA: FATTO -->
|
||||
<div class="kanban-column bg-slate-50/80 border border-slate-100 rounded-xl p-4 flex flex-col gap-4 min-h-[500px]" data-status="done">
|
||||
<div class="flex justify-between items-center border-b border-slate-200/60 pb-2">
|
||||
<span class="text-xs font-bold text-slate-500 uppercase tracking-wider">Fatto</span>
|
||||
<span class="count-pill bg-slate-200 text-slate-600 text-[10px] font-bold px-2 py-0.5 rounded-full">2</span>
|
||||
</div>
|
||||
<div class="cards-dropzone flex-1 flex flex-col gap-3">
|
||||
<!-- Card 5 -->
|
||||
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-5">
|
||||
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 1</span>
|
||||
<p class="text-xs font-medium text-slate-800">Kickoff strategico iniziale</p>
|
||||
</div>
|
||||
<!-- Card 6 -->
|
||||
<div class="kanban-card bg-white p-4 rounded-lg border border-slate-200/60 shadow-sm cursor-grab active:cursor-grabbing hover:shadow transition-all" draggable="true" id="task-6">
|
||||
<span class="text-[9px] font-semibold text-slate-400 block mb-1">FASE 1</span>
|
||||
<p class="text-xs font-medium text-slate-800">Setup Repository & Strumenti</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="text-center text-xs text-slate-400 py-10">
|
||||
Questa è la tua dashboard privata — non condividere il link.
|
||||
</footer>
|
||||
|
||||
<!-- SCRIPT DI LOGICA INTERATTIVA -->
|
||||
<script>
|
||||
// Accordion Control
|
||||
function toggleAccordion(id) {
|
||||
const el = document.getElementById(id);
|
||||
const arrow = document.getElementById('compreso-arrow');
|
||||
el.classList.toggle('hidden');
|
||||
arrow.classList.toggle('rotate-180');
|
||||
}
|
||||
|
||||
// Swapping delle viste (Timeline / Kanban)
|
||||
const btnTimeline = document.getElementById('btn-timeline');
|
||||
const btnKanban = document.getElementById('btn-kanban');
|
||||
const viewTimeline = document.getElementById('view-timeline');
|
||||
const viewKanban = document.getElementById('view-kanban');
|
||||
|
||||
btnTimeline.addEventListener('click', () => {
|
||||
viewKanban.classList.add('hidden');
|
||||
viewTimeline.classList.remove('hidden');
|
||||
btnTimeline.className = "px-5 py-1.5 bg-white rounded-md text-[11px] font-semibold text-slate-800 shadow-sm transition-all duration-200";
|
||||
btnKanban.className = "px-5 py-1.5 rounded-md text-[11px] font-semibold text-slate-500 hover:text-slate-800 transition-all duration-200";
|
||||
});
|
||||
|
||||
btnKanban.addEventListener('click', () => {
|
||||
viewTimeline.classList.add('hidden');
|
||||
viewKanban.classList.remove('hidden');
|
||||
btnKanban.className = "px-5 py-1.5 bg-white rounded-md text-[11px] font-semibold text-slate-800 shadow-sm transition-all duration-200";
|
||||
btnTimeline.className = "px-5 py-1.5 rounded-md text-[11px] font-semibold text-slate-500 hover:text-slate-800 transition-all duration-200";
|
||||
|
||||
// Inizializza Drag & Drop quando viene aperta la vista Kanban
|
||||
initializeKanbanDragAndDrop();
|
||||
});
|
||||
|
||||
// Logica Drag & Drop per il Kanban
|
||||
function initializeKanbanDragAndDrop() {
|
||||
const cards = document.querySelectorAll('.kanban-card');
|
||||
const columns = document.querySelectorAll('.kanban-column');
|
||||
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('dragstart', () => {
|
||||
card.classList.add('dragging');
|
||||
});
|
||||
|
||||
card.addEventListener('dragend', () => {
|
||||
card.classList.remove('dragging');
|
||||
updateCounts();
|
||||
});
|
||||
});
|
||||
|
||||
columns.forEach(column => {
|
||||
const dropzone = column.querySelector('.cards-dropzone');
|
||||
|
||||
column.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
column.classList.add('drag-over-zone');
|
||||
});
|
||||
|
||||
column.addEventListener('dragleave', () => {
|
||||
column.classList.remove('drag-over-zone');
|
||||
});
|
||||
|
||||
column.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
column.classList.remove('drag-over-zone');
|
||||
const draggingCard = document.querySelector('.dragging');
|
||||
if (draggingCard) {
|
||||
dropzone.appendChild(draggingCard);
|
||||
|
||||
// Se lanciato nella colonna "Fatto" (done), triggera una micro-celebrazione
|
||||
if (column.getAttribute('data-status') === 'done' && typeof confetti === 'function') {
|
||||
confetti({
|
||||
particleCount: 50,
|
||||
spread: 50,
|
||||
origin: { y: 0.8 }
|
||||
});
|
||||
}
|
||||
}
|
||||
updateCounts();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Aggiornamento dei contatori delle colonne del Kanban
|
||||
function updateCounts() {
|
||||
const columns = document.querySelectorAll('.kanban-column');
|
||||
columns.forEach(col => {
|
||||
const countPill = col.querySelector('.count-pill');
|
||||
const count = col.querySelector('.cards-dropzone').children.length;
|
||||
countPill.innerText = count;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,292 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Conversazioni — Luxury Admin CRM</title>
|
||||
<!-- Google Fonts: Plus Jakarta Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
dark: '#1A463C', /* Verde scuro lusso */
|
||||
darkHover: '#13342D',
|
||||
active: 'rgba(255, 255, 255, 0.08)',
|
||||
bg: '#F8F9FA',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.sidebar-transition {
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
|
||||
<div class="overflow-hidden">
|
||||
<!-- Logo e Intestazione -->
|
||||
<div class="mb-10 px-2 flex items-center justify-between">
|
||||
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigazione Principale -->
|
||||
<nav class="space-y-1">
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
|
||||
</a>
|
||||
|
||||
<!-- NUOVA VOCE: Conversazioni (Con Badge Notifica) -->
|
||||
<a href="#" class="flex items-center justify-between px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a.598.598 0 01-.73-.578 5.672 5.672 0 011.02-3.155A8.72 8.72 0 013 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Conversazioni</span>
|
||||
</div>
|
||||
<span class="bg-emerald-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full sidebar-text">2</span>
|
||||
</a>
|
||||
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Area Inferiore Sidebar -->
|
||||
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
|
||||
<span class="sidebar-text transition-opacity duration-300">Tema</span>
|
||||
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span class="sidebar-text transition-opacity duration-300">Esci</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- SEZIONE PRINCIPALE -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
<!-- BARRA SUPERIORE -->
|
||||
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
|
||||
</button>
|
||||
<span class="text-xs text-slate-400 font-medium">Centro Messaggi Clienti</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Admin Panel</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- AREA CHAT INBOX (LAYOUT A 2 COLONNE STILE WHATSAPP / INTERCOM) -->
|
||||
<main class="flex-1 p-8 max-w-[1400px] w-full mx-auto flex gap-6 h-[calc(100vh-4rem)] overflow-hidden">
|
||||
|
||||
<!-- COLONNA SINISTRA (1/3): Lista Progetti / Chat Clienti -->
|
||||
<div class="w-80 lg:w-96 bg-white border border-slate-100 rounded-xl flex flex-col shadow-[0_4px_20px_rgba(0,0,0,0.01)] shrink-0">
|
||||
|
||||
<!-- Header Filtro Lista -->
|
||||
<div class="p-4 border-b border-slate-100">
|
||||
<h2 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-3">Conversazioni Attive</h2>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cerca cliente o progetto..."
|
||||
class="w-full bg-slate-50 border border-slate-200 rounded-lg pl-8 pr-3 py-2 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-brand-dark transition-all"
|
||||
/>
|
||||
<svg class="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-2.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista Thread Clienti -->
|
||||
<div class="flex-1 overflow-y-auto divide-y divide-slate-50">
|
||||
|
||||
<!-- Thread 1 (Selezionato / Non letto) -->
|
||||
<div onclick="selectThread('Rossi Inc')" class="p-4 bg-emerald-50/30 border-l-4 border-brand-dark cursor-pointer transition-colors flex justify-between items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<h3 class="text-xs font-bold text-slate-900 truncate">Rossi Inc</h3>
|
||||
<span class="text-[10px] text-slate-400 font-mono">10:42</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-600 font-medium truncate mb-1">In merito all'analisi competitor della Fase 2...</p>
|
||||
<span class="text-[9px] font-semibold text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-100">Fase 2: Analisi</span>
|
||||
</div>
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 shrink-0 mt-1"></span>
|
||||
</div>
|
||||
|
||||
<!-- Thread 2 (Non letto) -->
|
||||
<div onclick="selectThread('Azienda Srl')" class="p-4 hover:bg-slate-50 cursor-pointer transition-colors flex justify-between items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<h3 class="text-xs font-bold text-slate-800 truncate">Azienda Srl</h3>
|
||||
<span class="text-[10px] text-slate-400 font-mono">Ieri</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-500 truncate mb-1">Abbiamo caricato i file di onboarding!</p>
|
||||
<span class="text-[9px] font-semibold text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-100">Fase 1: Onboarding</span>
|
||||
</div>
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 shrink-0 mt-1"></span>
|
||||
</div>
|
||||
|
||||
<!-- Thread 3 (Letto) -->
|
||||
<div onclick="selectThread('Caruso Speaker')" class="p-4 hover:bg-slate-50 cursor-pointer transition-colors flex justify-between items-start gap-3 opacity-60">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<h3 class="text-xs font-semibold text-slate-800 truncate">Caruso Speaker</h3>
|
||||
<span class="text-[10px] text-slate-400 font-mono">12 Lug</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-500 truncate mb-1">Perfetto, attendo aggiornamenti.</p>
|
||||
<span class="text-[9px] font-semibold text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">Generale</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COLONNA DESTRA (2/3): Area Chat con Thread Attivo -->
|
||||
<div class="flex-1 bg-white border border-slate-100 rounded-xl flex flex-col shadow-[0_4px_20px_rgba(0,0,0,0.01)] min-w-0">
|
||||
|
||||
<!-- Header Chat Selezionata -->
|
||||
<div class="p-4 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 id="active-chat-title" class="text-sm font-bold text-slate-900">Rossi Inc</h2>
|
||||
<p class="text-[10px] text-slate-400 mt-0.5">Progetto: Web Domination — Pagina Pubblica Client Portal</p>
|
||||
</div>
|
||||
<a href="#" class="text-xs font-medium text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-1">
|
||||
Vedi Portal Cliente ➔
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Thread Messaggi -->
|
||||
<div id="admin-chat-messages" class="flex-1 p-6 overflow-y-auto bg-slate-50/30 space-y-4">
|
||||
|
||||
<!-- Messaggio Cliente -->
|
||||
<div class="max-w-[75%] bg-white border border-slate-200/80 p-3.5 rounded-2xl rounded-tl-sm shadow-sm space-y-1">
|
||||
<div class="flex justify-between items-center gap-4 text-[9px] font-bold text-slate-400">
|
||||
<span>Cliente (Rossi Inc)</span>
|
||||
<span class="text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded">Riferito a: Fase 2 - Analisi</span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-700 leading-relaxed">
|
||||
Buongiorno, abbiamo revisionato l'analisi dei competitor. Vorremmo aggiungere un paio di riferimenti al report prima della meeting di martedì.
|
||||
</p>
|
||||
<span class="text-[9px] text-slate-300 font-mono block text-right">10:42</span>
|
||||
</div>
|
||||
|
||||
<!-- Risposta Admin -->
|
||||
<div class="max-w-[75%] ml-auto bg-brand-dark text-white p-3.5 rounded-2xl rounded-tr-sm shadow-sm space-y-1">
|
||||
<div class="flex justify-between items-center gap-4 text-[9px] font-bold text-emerald-200/80">
|
||||
<span>Tu (Admin)</span>
|
||||
<span>10:45</span>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed">
|
||||
Ricevuto! Potete caricare i documenti direttamente nella sezione Documenti della vostra area riservata, così li integriamo nella Fase 2.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Box Risposta Admin -->
|
||||
<div class="p-4 border-t border-slate-100 bg-white">
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
id="admin-chat-input"
|
||||
rows="2"
|
||||
placeholder="Scrivi una risposta per il cliente..."
|
||||
class="flex-1 bg-slate-50 border border-slate-200 rounded-lg p-3 text-xs text-slate-800 placeholder-slate-400 focus:outline-none focus:border-brand-dark focus:bg-white transition-all resize-none"
|
||||
></textarea>
|
||||
<button
|
||||
onclick="sendAdminReply()"
|
||||
class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-semibold px-5 py-2 rounded-lg transition-colors shrink-0 self-end h-10"
|
||||
>
|
||||
Invia Risposta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- SCRIPT INTERATTIVO CHAT -->
|
||||
<script>
|
||||
// Toggle Sidebar
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleButton = document.getElementById('sidebar-toggle');
|
||||
const logoText = document.getElementById('sidebar-logo-text');
|
||||
const sidebarTexts = document.querySelectorAll('.sidebar-text');
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const isCollapsed = sidebar.classList.contains('w-20');
|
||||
|
||||
if (isCollapsed) {
|
||||
sidebar.classList.remove('w-20');
|
||||
sidebar.classList.add('w-64');
|
||||
setTimeout(() => {
|
||||
logoText.classList.remove('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
|
||||
}, 150);
|
||||
} else {
|
||||
logoText.classList.add('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
|
||||
sidebar.classList.remove('w-64');
|
||||
sidebar.classList.add('w-20');
|
||||
}
|
||||
});
|
||||
|
||||
// Cambio Thread Cliente
|
||||
function selectThread(clientName) {
|
||||
document.getElementById('active-chat-title').innerText = clientName;
|
||||
}
|
||||
|
||||
// Invio Risposta
|
||||
function sendAdminReply() {
|
||||
const input = document.getElementById('admin-chat-input');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
const container = document.getElementById('admin-chat-messages');
|
||||
const replyBubble = document.createElement('div');
|
||||
replyBubble.className = "max-w-[75%] ml-auto bg-brand-dark text-white p-3.5 rounded-2xl rounded-tr-sm shadow-sm space-y-1";
|
||||
replyBubble.innerHTML = `
|
||||
<div class="flex justify-between items-center gap-4 text-[9px] font-bold text-emerald-200/80">
|
||||
<span>Tu (Admin)</span>
|
||||
<span>Adesso</span>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed">${text}</p>
|
||||
`;
|
||||
|
||||
container.appendChild(replyBubble);
|
||||
input.value = '';
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,387 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard — Luxury Admin CRM</title>
|
||||
<!-- Google Fonts: Plus Jakarta Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
dark: '#1A463C', /* Verde scuro lusso */
|
||||
darkHover: '#13342D',
|
||||
active: 'rgba(255, 255, 255, 0.08)',
|
||||
bg: '#F8F9FA',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.sidebar-transition {
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
|
||||
<div class="overflow-hidden">
|
||||
<!-- Logo e Intestazione -->
|
||||
<div class="mb-10 px-2 flex items-center justify-between">
|
||||
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigazione Principale -->
|
||||
<nav class="space-y-1">
|
||||
<!-- Stato Attivo su Dashboard -->
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
|
||||
</a>
|
||||
|
||||
<!-- Voce: Conversazioni (Connessa all'Inbox) -->
|
||||
<a href="#" class="flex items-center justify-between px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<div class="flex items-center gap-3">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a.598.598 0 01-.73-.578 5.672 5.672 0 011.02-3.155A8.72 8.72 0 013 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Conversazioni</span>
|
||||
</div>
|
||||
<span class="bg-emerald-500 text-white text-[10px] font-bold px-2 py-0.5 rounded-full sidebar-text">2</span>
|
||||
</a>
|
||||
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 animate-pulse text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Catalogo</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Impostazioni</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Area Inferiore Sidebar -->
|
||||
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
|
||||
<span class="sidebar-text transition-opacity duration-300">Tema</span>
|
||||
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span class="sidebar-text transition-opacity duration-300">Esci</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- SEZIONE PRINCIPALE -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
<!-- BARRA DI INTESTAZIONE SUPERIORE -->
|
||||
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Pulsante Apri/Chiudi Sidebar -->
|
||||
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
|
||||
</button>
|
||||
<span class="text-xs text-slate-400 font-medium">Panoramica Aziendale</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Admin Panel</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- AREA DEL CONTENUTO DASHBOARD -->
|
||||
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-8 overflow-y-auto">
|
||||
|
||||
<!-- STRISCIA METRICHE CHIAVE (KPI STRIP) -->
|
||||
<section class="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
|
||||
<!-- Metric 1 -->
|
||||
<div class="bg-white p-5 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)]">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Clienti Attivi</span>
|
||||
<div class="flex items-baseline gap-2 mt-1">
|
||||
<span class="text-2xl font-bold text-slate-900">4</span>
|
||||
<span class="text-xs text-emerald-600 font-medium">+1 questo mese</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metric 2 -->
|
||||
<div class="bg-white p-5 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)]">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Contrattualizzato</span>
|
||||
<div class="flex items-baseline gap-2 mt-1">
|
||||
<span class="text-2xl font-bold text-slate-900">19.000€</span>
|
||||
<span class="text-xs text-slate-400">Totale anno 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metric 3 -->
|
||||
<div class="bg-white p-5 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)]">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Incassato Reale</span>
|
||||
<div class="flex items-baseline gap-2 mt-1">
|
||||
<span class="text-2xl font-bold text-slate-900 text-emerald-800">5.300€</span>
|
||||
<span class="text-xs text-slate-400">28% del totale</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metric 4 -->
|
||||
<div class="bg-white p-5 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)]">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Ore Tracciate</span>
|
||||
<div class="flex items-baseline gap-2 mt-1">
|
||||
<span class="text-2xl font-bold text-slate-900">31.5h</span>
|
||||
<span class="text-xs text-slate-400">Su progetti attivi</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- SEZIONE CENTRALE: GRIGLIA CONTENUTI -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
<!-- COLONNA COMPLESSA (2/3): Grafico Cashflow & Analisi Profitto -->
|
||||
<div class="lg:col-span-2 flex flex-col gap-8">
|
||||
|
||||
<!-- Grafico Cashflow Previsionale (12 Mesi) -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400">Previsione Flussi di Cassa (12 Mesi)</h3>
|
||||
<p class="text-[11px] text-slate-400 mt-0.5">Stima basata su retainer attivi e saldi dilazionati</p>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-slate-800 bg-slate-50 px-2.5 py-1 rounded border border-slate-100 font-mono">2026</span>
|
||||
</div>
|
||||
|
||||
<!-- Istogramma Semplificato (SVG pulito) -->
|
||||
<div class="h-48 flex items-end justify-between gap-2 pt-4 border-b border-slate-100">
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Lug: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Lug</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-brand-dark rounded-t h-40 hover:bg-brand-dark transition-all" title="Ago: €1.200"></div>
|
||||
<span class="text-[10px] text-slate-900 font-bold">Ago</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-24 hover:bg-brand-dark transition-all" title="Set: €400"></div>
|
||||
<span class="text-[10px] text-slate-400">Set</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Ott: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Ott</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Nov: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Nov</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Dic: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Dic</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Gen: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Gen</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-24 hover:bg-brand-dark transition-all" title="Feb: €450"></div>
|
||||
<span class="text-[10px] text-slate-400">Feb</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Mar: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Mar</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Apr: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Apr</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analisi di Profitto Orario Effettivo -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] p-6">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-4">Analisi Oraria & Redditività Clienti</h3>
|
||||
<div class="space-y-4">
|
||||
<!-- Item 1 -->
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border border-slate-100 bg-slate-50/30">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-slate-800">Mario Rossi (Web Domination)</h4>
|
||||
<p class="text-[10px] text-slate-400">Tracciate: 30.0h — Valore Orario Target: €100/h</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-xs font-bold text-emerald-700 bg-emerald-50 px-2 py-0.5 rounded border border-emerald-100">€233,33 /h Reali</span>
|
||||
<p class="text-[9px] text-slate-400 mt-1">Margine Ottimo</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Item 2 -->
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border border-slate-100 bg-slate-50/30">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-slate-800">Gianfranco Barban (Consulenza)</h4>
|
||||
<p class="text-[10px] text-slate-400">Tracciate: 1.5h — Valore Orario Target: €100/h</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-xs font-bold text-slate-700 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">€133,33 /h Reali</span>
|
||||
<p class="text-[9px] text-slate-400 mt-1">In Linea</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- COLONNA COMPATTA (1/3): Follow-up, Messaggi Clienti, Offerte più vendute -->
|
||||
<div class="lg:col-span-1 flex flex-col gap-8">
|
||||
|
||||
<!-- Box Operativo 1: Follow-up -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] p-5">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400">Follow-up Attivi</h3>
|
||||
<span class="text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 px-1.5 py-0.5 rounded-full">2</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 border border-slate-100 rounded-lg hover:border-slate-200 transition-colors">
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-slate-800">Nome Cognome</h4>
|
||||
<p class="text-[10px] text-slate-400">Azienda Srl — Contattare</p>
|
||||
</div>
|
||||
<a href="#" class="text-[11px] font-bold text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-0.5">
|
||||
Apri ➔
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 border border-slate-100 rounded-lg hover:border-slate-200 transition-colors">
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-slate-800">Mario Rossi</h4>
|
||||
<p class="text-[10px] text-slate-400">Fissare Kickoff</p>
|
||||
</div>
|
||||
<a href="#" class="text-[11px] font-bold text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-0.5">
|
||||
Apri ➔
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NUOVO BOX OPERATIVO 2: NOTIFICHE MESSAGGI CLIENTI (Da conversazioni.html) -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] p-5">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400">Messaggi Clienti</h3>
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 px-2 py-0.5 rounded-full">2 Nuovi</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Notifica 1 -->
|
||||
<div class="p-3 bg-slate-50/60 rounded-lg border border-slate-100 flex justify-between items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="text-xs font-bold text-slate-800">Rossi Inc</h4>
|
||||
<p class="text-[10px] text-slate-400 truncate">In merito all'analisi competitor della Fase 2...</p>
|
||||
</div>
|
||||
<a href="conversazioni.html" class="text-[11px] font-bold text-brand-dark hover:text-brand-darkHover shrink-0">Rispondi ➔</a>
|
||||
</div>
|
||||
|
||||
<!-- Notifica 2 -->
|
||||
<div class="p-3 bg-slate-50/60 rounded-lg border border-slate-100 flex justify-between items-center gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="text-xs font-bold text-slate-800">Azienda Srl</h4>
|
||||
<p class="text-[10px] text-slate-400 truncate">Abbiamo caricato i file di onboarding!</p>
|
||||
</div>
|
||||
<a href="conversazioni.html" class="text-[11px] font-bold text-brand-dark hover:text-brand-darkHover shrink-0">Rispondi ➔</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Distribuzione Offerte Vendute -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] p-5">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-4">Offerte Più Richieste</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-slate-600 mb-1">
|
||||
<span class="font-semibold text-slate-800">Web Domination (Tier B)</span>
|
||||
<span class="font-mono">1</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div class="bg-brand-dark h-full" style="width: 50%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-slate-600 mb-1">
|
||||
<span class="font-semibold text-slate-800">Mantenimento (Tier A)</span>
|
||||
<span class="font-mono">1</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div class="bg-brand-dark h-full" style="width: 50%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- SCRIPT DI LOGICA (Gestione Sidebar) -->
|
||||
<script>
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleButton = document.getElementById('sidebar-toggle');
|
||||
const logoText = document.getElementById('sidebar-logo-text');
|
||||
const sidebarTexts = document.querySelectorAll('.sidebar-text');
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const isCollapsed = sidebar.classList.contains('w-20');
|
||||
|
||||
if (isCollapsed) {
|
||||
sidebar.classList.remove('w-20');
|
||||
sidebar.classList.add('w-64');
|
||||
setTimeout(() => {
|
||||
logoText.classList.remove('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
|
||||
}, 150);
|
||||
} else {
|
||||
logoText.classList.add('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
|
||||
sidebar.classList.remove('w-64');
|
||||
sidebar.classList.add('w-20');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,353 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dettagli Lead — Rossi Inc</title>
|
||||
<!-- Google Fonts: Plus Jakarta Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
dark: '#1A463C', /* Verde scuro richiesto */
|
||||
darkHover: '#13342D', /* Variante scura per hover */
|
||||
active: 'rgba(255, 255, 255, 0.08)',
|
||||
bg: '#F8F9FA',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.sidebar-transition {
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.timeline-line::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
left: 19px;
|
||||
width: 1.5px;
|
||||
background-color: #E2E8F0;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
|
||||
<div class="overflow-hidden">
|
||||
<!-- Logo e Intestazione -->
|
||||
<div class="mb-10 px-2 flex items-center justify-between">
|
||||
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigazione Principale -->
|
||||
<nav class="space-y-1">
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Area Inferiore Sidebar -->
|
||||
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
|
||||
<span class="sidebar-text transition-opacity duration-300">Tema</span>
|
||||
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span class="sidebar-text transition-opacity duration-300">Esci</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- SEZIONE PRINCIPALE -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
<!-- BARRA DI INTESTAZIONE SUPERIORE -->
|
||||
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Pulsante Apri/Chiudi Sidebar -->
|
||||
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
|
||||
</button>
|
||||
<span class="text-xs text-slate-400 font-medium">Area di Lavoro</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Demo Account</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- AREA DEL CONTENUTO DETTAGLIO -->
|
||||
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-8 overflow-y-auto">
|
||||
|
||||
<!-- INTESTAZIONE DETTAGLIO LEAD -->
|
||||
<div class="flex flex-col lg:flex-row justify-between lg:items-start gap-6 border-b border-slate-100 pb-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-3xl font-bold text-slate-900 tracking-tight">Nome Cognome</h1>
|
||||
<span class="px-2.5 py-1 rounded text-[10px] font-semibold bg-purple-50 text-purple-600 border border-purple-100 uppercase tracking-wide">Qualified</span>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-slate-500 mt-1">Azienda Srl</p>
|
||||
</div>
|
||||
|
||||
<!-- Pulsantiera Azioni Riorganizzata -->
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
<button class="px-4 py-2.5 border border-slate-200 text-slate-600 hover:text-slate-900 hover:bg-slate-50 text-xs font-semibold rounded-lg transition-colors flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
|
||||
Registra Attività
|
||||
</button>
|
||||
<button class="px-4 py-2.5 border border-slate-200 text-slate-600 hover:text-slate-900 hover:bg-slate-50 text-xs font-semibold rounded-lg transition-colors flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"/></svg>
|
||||
Aggiungi Transcript
|
||||
</button>
|
||||
<!-- Azione Chiave Evidenziata -->
|
||||
<button class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-medium tracking-wide px-5 py-2.5 rounded-lg flex items-center gap-2 transition-all duration-200 shadow-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9.813 15.904L9 21l5.096-.813a2 2 0 001.414-.586l5.086-5.086a2 2 0 000-2.828l-3.212-3.212a2 2 0 00-2.828 0l-5.086 5.086a2 2 0 00-.586 1.414zM16.5 7.5L19.5 10.5"/></svg>
|
||||
Genera preventivo
|
||||
</button>
|
||||
<!-- Azioni di Sistema Discrete -->
|
||||
<button class="p-2 border border-slate-200 text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg transition-colors" title="Modifica">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CORPO DEL DETTAGLIO: COLONNE ASIMMETRICHE -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
||||
|
||||
<!-- COLONNA DI SINISTRA (1/3): Scheda Anagrafica / Profilo -->
|
||||
<div class="lg:col-span-1 bg-white border border-slate-100 rounded-xl p-6 shadow-[0_4px_20px_rgba(0,0,0,0.01)] flex flex-col gap-6">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400">Profilo Lead</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Email -->
|
||||
<div class="flex items-center gap-3.5 text-xs">
|
||||
<div class="p-2 bg-slate-50 text-slate-400 rounded-lg">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] text-slate-400 font-medium">Email</p>
|
||||
<a href="mailto:test@gmail.com" class="font-semibold text-slate-800 hover:text-brand-dark">test@gmail.com</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Telefono -->
|
||||
<div class="flex items-center gap-3.5 text-xs">
|
||||
<div class="p-2 bg-slate-50 text-slate-400 rounded-lg">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-2.824-1.802-5.14-4.118-6.944-6.94l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] text-slate-400 font-medium">Telefono</p>
|
||||
<span class="font-semibold text-slate-800 font-mono">+39 444 3322111</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ultimo Contatto -->
|
||||
<div class="flex items-center gap-3.5 text-xs">
|
||||
<div class="p-2 bg-slate-50 text-slate-400 rounded-lg">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] text-slate-400 font-medium">Ultimo Contatto</p>
|
||||
<span class="font-semibold text-slate-800">23 giorni fa</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prossima Azione -->
|
||||
<div class="flex items-center gap-3.5 text-xs">
|
||||
<div class="p-2 bg-slate-50 text-slate-400 rounded-lg">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] text-slate-400 font-medium">Prossima Azione programmata</p>
|
||||
<span class="font-semibold text-slate-400">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gestione Tag -->
|
||||
<div class="border-t border-slate-100 pt-5">
|
||||
<p class="text-[10px] uppercase font-bold text-slate-400 tracking-wider mb-2.5">Tag Lead</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span class="px-2.5 py-1 bg-slate-100 text-slate-600 rounded-md text-[11px] font-medium">B2B</span>
|
||||
<span class="px-2.5 py-1 bg-slate-100 text-slate-600 rounded-md text-[11px] font-medium">Design</span>
|
||||
<button class="inline-flex items-center justify-center w-6 h-6 rounded-full border border-dashed border-slate-200 text-slate-400 hover:text-slate-600 hover:border-slate-400 transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COLONNA DI DESTRA (2/3): Timeline, Note, Prossimi Step -->
|
||||
<div class="lg:col-span-2 flex flex-col gap-6">
|
||||
|
||||
<!-- SEZIONE NOTA RAPIDA & PROSSIMI APPUNTAMENTI -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Box: Note -->
|
||||
<div class="bg-white border border-slate-100 rounded-xl p-5 shadow-[0_4px_20px_rgba(0,0,0,0.01)]">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-3">Note</h3>
|
||||
<div class="text-xs text-slate-600 leading-relaxed min-h-[80px] p-2.5 bg-slate-50/50 rounded-lg border border-slate-100">
|
||||
Brand di fashion design di lusso milanese interessato ad una ristrutturazione completa del canale ecommerce.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Box: Prossimo Reminder -->
|
||||
<div class="bg-white border border-slate-100 rounded-xl p-5 shadow-[0_4px_20px_rgba(0,0,0,0.01)]">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-3">Prossimo Follow-up</h3>
|
||||
<div class="flex flex-col items-center justify-center min-h-[80px] border border-dashed border-slate-200 rounded-lg text-slate-400 p-3">
|
||||
<svg class="w-5 h-5 mb-1.5 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
<span class="text-[11px] font-medium">Nessun reminder impostato</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- SEZIONE UNIFICATA: STORICO CRONOLOGICO (TIMELINE DELLE INTERAZIONI) -->
|
||||
<div class="bg-white border border-slate-100 rounded-xl p-6 shadow-[0_4px_20px_rgba(0,0,0,0.01)]">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-6">Storico delle Interazioni</h3>
|
||||
|
||||
<!-- Griglia Timeline -->
|
||||
<div class="relative timeline-line space-y-6">
|
||||
|
||||
<!-- Elemento Timeline 1: Transcript Chiamata AI (In Evidenza) -->
|
||||
<div class="relative z-10 pl-10 flex gap-4 items-start group">
|
||||
<div class="absolute left-0 w-10 h-10 rounded-full bg-brand-dark text-white flex items-center justify-center shadow-md smooth-transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1 bg-slate-50 border border-slate-100 rounded-xl p-4 hover:border-slate-200 transition-all">
|
||||
<div class="flex flex-wrap justify-between items-center gap-2 mb-2">
|
||||
<div>
|
||||
<span class="text-[10px] font-bold text-brand-dark uppercase tracking-wide">Transcript Chiamata AI</span>
|
||||
<h4 class="text-xs font-bold text-slate-800">Discovery Call - Progetto "SaaS Networker"</h4>
|
||||
</div>
|
||||
<span class="text-[10px] text-slate-400 font-mono">26 Giugno 2026</span>
|
||||
</div>
|
||||
<!-- Sotto-contenuto del transcript -->
|
||||
<div class="text-xs text-slate-600 leading-relaxed pt-2 border-t border-slate-200/60">
|
||||
<p class="font-semibold text-slate-800 mb-1">1. Apertura e Contesto Strategico</p>
|
||||
<p id="transcript-excerpt" class="line-clamp-2">"Tu: Alessandro, grazie per la disponibilità. Sappiamo che hai 3 mesi per lanciare questo SaaS dedicato al Network Marketing. Il tempo è poco, quindi dobbiamo essere precisi. L'integrazione con i sistemi di pagamento e la sitemap sono la nostra prima priorità per..."</p>
|
||||
<p id="transcript-full" class="hidden">"Tu: Alessandro, grazie per la disponibilità. Sappiamo che hai 3 mesi per lanciare questo SaaS dedicato al Network Marketing. Il tempo è poco, quindi dobbiamo essere precisi. L'integrazione con i sistemi di pagamento e la sitemap sono la nostra prima priorità per sbloccare la fase di programmazione. Serviranno grafiche ad hoc e un sistema di onboarding utente rapido."</p>
|
||||
<button onclick="toggleTranscript()" id="btn-transcript-toggle" class="text-[11px] font-bold text-brand-dark hover:text-brand-darkHover mt-2 inline-flex items-center gap-1">
|
||||
Mostra tutto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Elemento Timeline 2: Invio Email -->
|
||||
<div class="relative z-10 pl-10 flex gap-4 items-start group">
|
||||
<div class="absolute left-0 w-10 h-10 rounded-full bg-slate-100 text-slate-500 border border-slate-200 flex items-center justify-center smooth-transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/></svg>
|
||||
</div>
|
||||
<div class="flex-1 border border-slate-100 rounded-xl p-4 hover:border-slate-200 transition-all">
|
||||
<div class="flex justify-between items-center gap-2 mb-1.5">
|
||||
<span class="text-[10px] font-bold text-slate-500 uppercase tracking-wide">E-mail inviata</span>
|
||||
<span class="text-[10px] text-slate-400 font-mono">23 giorni fa</span>
|
||||
</div>
|
||||
<h4 class="text-xs font-bold text-slate-800 mb-1">Mail di onboarding</h4>
|
||||
<p class="text-xs text-slate-500 leading-relaxed">Inviato pacchetto di benvenuto e richiesta accessi per iniziare l'analisi competitiva del brand.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Elemento Timeline 3: Telefonata -->
|
||||
<div class="relative z-10 pl-10 flex gap-4 items-start group">
|
||||
<div class="absolute left-0 w-10 h-10 rounded-full bg-slate-100 text-slate-500 border border-slate-200 flex items-center justify-center smooth-transition">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 002.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-2.824-1.802-5.14-4.118-6.944-6.94l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 00-1.091-.852H4.5A2.25 2.25 0 002.25 4.5v2.25z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1 border border-slate-100 rounded-xl p-4 hover:border-slate-200 transition-all">
|
||||
<div class="flex justify-between items-center gap-2 mb-1.5">
|
||||
<span class="text-[10px] font-bold text-slate-500 uppercase tracking-wide">Chiamata effettuata</span>
|
||||
<span class="text-[10px] text-slate-400 font-mono">25 giorni fa</span>
|
||||
</div>
|
||||
<h4 class="text-xs font-bold text-slate-800 mb-1">Telefonata di follow-up</h4>
|
||||
<p class="text-xs text-slate-500 leading-relaxed">Chiamata conoscitiva di test per allineamento sulle tempistiche di consegna.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- SCRIPT DI LOGICA (Gestione Sidebar e Timeline) -->
|
||||
<script>
|
||||
// Toggle Sidebar
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleButton = document.getElementById('sidebar-toggle');
|
||||
const logoText = document.getElementById('sidebar-logo-text');
|
||||
const sidebarTexts = document.querySelectorAll('.sidebar-text');
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const isCollapsed = sidebar.classList.contains('w-20');
|
||||
|
||||
if (isCollapsed) {
|
||||
sidebar.classList.remove('w-20');
|
||||
sidebar.classList.add('w-64');
|
||||
setTimeout(() => {
|
||||
logoText.classList.remove('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
|
||||
}, 150);
|
||||
} else {
|
||||
logoText.classList.add('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
|
||||
sidebar.classList.remove('w-64');
|
||||
sidebar.classList.add('w-20');
|
||||
}
|
||||
});
|
||||
|
||||
// Funzione interattiva per mostrare / nascondere il transcript completo
|
||||
function toggleTranscript() {
|
||||
const excerpt = document.getElementById('transcript-excerpt');
|
||||
const full = document.getElementById('transcript-full');
|
||||
const btn = document.getElementById('btn-transcript-toggle');
|
||||
|
||||
if (full.classList.contains('hidden')) {
|
||||
full.classList.remove('hidden');
|
||||
excerpt.classList.add('hidden');
|
||||
btn.innerText = 'Mostra meno';
|
||||
} else {
|
||||
full.classList.add('hidden');
|
||||
excerpt.classList.remove('hidden');
|
||||
btn.innerText = 'Mostra tutto';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,405 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lead Pipeline — Luxury Redesign</title>
|
||||
<!-- Google Fonts: Plus Jakarta Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
dark: '#1A463C', /* Verde scuro richiesto */
|
||||
darkHover: '#13342D', /* Variante scura per hover */
|
||||
active: 'rgba(255, 255, 255, 0.08)',
|
||||
bg: '#F8F9FA',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
/* Transizione fluida per l'apertura/chiusura della sidebar */
|
||||
.sidebar-transition {
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
/* Stile per l'effetto di trascinamento nel Kanban */
|
||||
.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.drag-over {
|
||||
background-color: rgba(26, 70, 60, 0.03);
|
||||
border-color: rgba(26, 70, 60, 0.2);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
|
||||
<div class="overflow-hidden">
|
||||
<!-- Logo e Intestazione -->
|
||||
<div class="mb-10 px-2 flex items-center justify-between">
|
||||
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigazione Principale -->
|
||||
<nav class="space-y-1">
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Area Inferiore Sidebar -->
|
||||
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
|
||||
<span class="sidebar-text transition-opacity duration-300">Tema</span>
|
||||
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span class="sidebar-text transition-opacity duration-300">Esci</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- SEZIONE PRINCIPALE -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
<!-- BARRA DI INTESTAZIONE SUPERIORE (Contiene i controlli globali) -->
|
||||
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Pulsante Minimal per Aprire/Chiudere la Sidebar -->
|
||||
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
|
||||
</button>
|
||||
<span class="text-xs text-slate-400 font-medium">Area di Lavoro</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Demo Account</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- AREA DEL CONTENUTO DINAMICO -->
|
||||
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-8 overflow-y-auto">
|
||||
|
||||
<!-- INTESTAZIONE SEZIONE -->
|
||||
<div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900 tracking-tight">Lead Pipeline</h1>
|
||||
<p class="text-xs text-slate-400 mt-1">Gestisci e monitora i tuoi contatti commerciali</p>
|
||||
</div>
|
||||
|
||||
<!-- Pulsante Nuovo Lead -->
|
||||
<button class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-medium tracking-wide px-5 py-3 rounded-lg flex items-center gap-2 transition-all duration-200 shadow-sm self-start sm:self-auto">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Nuovo Lead
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- AZIONI: BARRA DI RICERCA & SELETTORE VISTA -->
|
||||
<div class="flex flex-col md:flex-row justify-between md:items-center gap-4">
|
||||
<!-- Barra di ricerca -->
|
||||
<div class="relative w-full md:w-80">
|
||||
<span class="absolute inset-y-0 left-0 flex items-center pl-3.5 pointer-events-none text-slate-400">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Cerca lead..."
|
||||
class="w-full pl-10 pr-4 py-2.5 bg-white text-sm text-slate-800 placeholder-slate-400 border border-slate-200/80 rounded-lg focus:outline-none focus:border-brand-dark focus:ring-1 focus:ring-brand-dark transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Selettore Vista (Lista vs Kanban) -->
|
||||
<div class="bg-slate-200/50 p-1 rounded-lg flex gap-1 self-start md:self-auto">
|
||||
<button id="btn-view-list" class="px-5 py-1.5 rounded-md text-xs font-medium bg-white text-slate-800 shadow-sm transition-all duration-200">
|
||||
Lista
|
||||
</button>
|
||||
<button id="btn-view-kanban" class="px-5 py-1.5 rounded-md text-xs font-medium text-slate-500 hover:text-slate-800 transition-all duration-200">
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VISTA 1: TABELLA (Default) -->
|
||||
<div id="view-list" class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden transition-opacity duration-300">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-100/80 bg-slate-50/50 text-slate-400 text-[11px] font-semibold uppercase tracking-wider">
|
||||
<th class="py-4 px-6">Nome</th>
|
||||
<th class="py-4 px-6">Email</th>
|
||||
<th class="py-4 px-6">Telefono</th>
|
||||
<th class="py-4 px-6">Azienda</th>
|
||||
<th class="py-4 px-6 text-center">Stato</th>
|
||||
<th class="py-4 px-6">Prossima Azione</th>
|
||||
<th class="py-4 px-6 text-center">Tag</th>
|
||||
<th class="py-4 px-6 text-right">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100/60 text-sm">
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-4 px-6 font-medium text-slate-900">Nome Cognome</td>
|
||||
<td class="py-4 px-6 text-slate-500 text-xs">test@gmail.com</td>
|
||||
<td class="py-4 px-6 text-slate-500 text-xs font-mono">+39 444 3322111</td>
|
||||
<td class="py-4 px-6 text-slate-600">Azienda Srl</td>
|
||||
<td class="py-4 px-6 text-center">
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide bg-purple-50 text-purple-600 border border-purple-100 uppercase">
|
||||
qualified
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-slate-400">—</td>
|
||||
<td class="py-4 px-6 text-center">
|
||||
<button class="inline-flex items-center justify-center w-6 h-6 rounded-full border border-dashed border-slate-200 text-slate-400 hover:text-slate-600 hover:border-slate-400 transition-all duration-200">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
</button>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-right">
|
||||
<a href="#" class="text-xs font-medium text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-1 group transition-colors">
|
||||
Dettagli
|
||||
<svg class="w-3 h-3 transform group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24"><path d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="border-t border-slate-100 px-6 py-4 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Mostrando 1 di 1 lead</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VISTA 2: KANBAN (Nascosta all'avvio) -->
|
||||
<div id="view-kanban" class="hidden grid grid-cols-1 md:grid-cols-4 gap-6 transition-opacity duration-300">
|
||||
|
||||
<!-- Colonna 1: Nuovo -->
|
||||
<div class="kanban-column bg-slate-50/75 rounded-xl border border-slate-100 p-4 flex flex-col gap-4 min-h-[450px]" data-status="new">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 pb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-500">Nuovo</span>
|
||||
<span class="text-xs font-medium bg-slate-200/60 text-slate-600 px-2 py-0.5 rounded-full count">0</span>
|
||||
</div>
|
||||
<div class="cards-container flex-1 flex flex-col gap-3">
|
||||
<!-- Esempio di lead spostabile -->
|
||||
<div id="lead-card-2" class="kanban-card bg-white p-4 rounded-lg border border-slate-200/80 shadow-sm cursor-grab active:cursor-grabbing hover:border-brand-dark/30 hover:shadow-md transition-all duration-200" draggable="true">
|
||||
<div class="flex justify-between items-start gap-2 mb-2">
|
||||
<h4 class="text-xs font-semibold text-slate-800">Marco Rossi</h4>
|
||||
<span class="text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100 px-1.5 py-0.5 rounded uppercase">New</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-400 mb-3">rossi@azienda.it</p>
|
||||
<div class="flex justify-between items-center text-[10px] text-slate-500">
|
||||
<span class="font-medium">Rossi Consulting</span>
|
||||
<span>Tag +</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colonna 2: Contattato -->
|
||||
<div class="kanban-column bg-slate-50/75 rounded-xl border border-slate-100 p-4 flex flex-col gap-4 min-h-[450px]" data-status="contacted">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 pb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-500">Contattato</span>
|
||||
<span class="text-xs font-medium bg-slate-200/60 text-slate-600 px-2 py-0.5 rounded-full count">0</span>
|
||||
</div>
|
||||
<div class="cards-container flex-1 flex flex-col gap-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Colonna 3: Qualificato -->
|
||||
<div class="kanban-column bg-slate-50/75 rounded-xl border border-slate-100 p-4 flex flex-col gap-4 min-h-[450px]" data-status="qualified">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 pb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-500">Qualificato</span>
|
||||
<span class="text-xs font-medium bg-slate-200/60 text-slate-600 px-2 py-0.5 rounded-full count">1</span>
|
||||
</div>
|
||||
<div class="cards-container flex-1 flex flex-col gap-3">
|
||||
|
||||
<!-- Lead proveniente dalla lista originale -->
|
||||
<div id="lead-card-1" class="kanban-card bg-white p-4 rounded-lg border border-slate-200/80 shadow-sm cursor-grab active:cursor-grabbing hover:border-brand-dark/30 hover:shadow-md transition-all duration-200" draggable="true">
|
||||
<div class="flex justify-between items-start gap-2 mb-2">
|
||||
<h4 class="text-xs font-semibold text-slate-800">Nome Cognome</h4>
|
||||
<span class="text-[9px] font-semibold bg-purple-50 text-purple-600 border border-purple-100 px-1.5 py-0.5 rounded uppercase">Qualified</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-slate-400 mb-3">test@gmail.com</p>
|
||||
<div class="flex justify-between items-center text-[10px] text-slate-500">
|
||||
<span class="font-medium">Azienda Srl</span>
|
||||
<span>Tag +</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colonna 4: In Trattativa -->
|
||||
<div class="kanban-column bg-slate-50/75 rounded-xl border border-slate-100 p-4 flex flex-col gap-4 min-h-[450px]" data-status="negotiation">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 pb-2">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider text-slate-500">Trattativa</span>
|
||||
<span class="text-xs font-medium bg-slate-200/60 text-slate-600 px-2 py-0.5 rounded-full count">0</span>
|
||||
</div>
|
||||
<div class="cards-container flex-1 flex flex-col gap-3"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- SCRIPT LOGICA (Gestione Sidebar e Kanban) -->
|
||||
<script>
|
||||
// --- GESTIONE SIDEBAR COLLASSABILE ---
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleButton = document.getElementById('sidebar-toggle');
|
||||
const logoText = document.getElementById('sidebar-logo-text');
|
||||
const sidebarTexts = document.querySelectorAll('.sidebar-text');
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
// Verifica lo stato attuale
|
||||
const isCollapsed = sidebar.classList.contains('w-20');
|
||||
|
||||
if (isCollapsed) {
|
||||
// Espandi
|
||||
sidebar.classList.remove('w-20');
|
||||
sidebar.classList.add('w-64');
|
||||
setTimeout(() => {
|
||||
logoText.classList.remove('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
|
||||
}, 150);
|
||||
} else {
|
||||
// Collassa
|
||||
logoText.classList.add('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
|
||||
sidebar.classList.remove('w-64');
|
||||
sidebar.classList.add('w-20');
|
||||
}
|
||||
});
|
||||
|
||||
// --- INTERRUTTORE DI VISTA (LISTA / KANBAN) ---
|
||||
const btnViewList = document.getElementById('btn-view-list');
|
||||
const btnViewKanban = document.getElementById('btn-view-kanban');
|
||||
const viewList = document.getElementById('view-list');
|
||||
const viewKanban = document.getElementById('view-kanban');
|
||||
|
||||
btnViewList.addEventListener('click', () => {
|
||||
// Attiva vista Lista
|
||||
viewKanban.classList.add('hidden');
|
||||
viewList.classList.remove('hidden');
|
||||
|
||||
// Regola stili pulsanti
|
||||
btnViewList.className = "px-5 py-1.5 rounded-md text-xs font-medium bg-white text-slate-800 shadow-sm transition-all duration-200";
|
||||
btnViewKanban.className = "px-5 py-1.5 rounded-md text-xs font-medium text-slate-500 hover:text-slate-800 transition-all duration-200";
|
||||
});
|
||||
|
||||
btnViewKanban.addEventListener('click', () => {
|
||||
// Attiva vista Kanban
|
||||
viewList.classList.add('hidden');
|
||||
viewKanban.classList.remove('hidden');
|
||||
|
||||
// Regola stili pulsanti
|
||||
btnViewKanban.className = "px-5 py-1.5 rounded-md text-xs font-medium bg-white text-slate-800 shadow-sm transition-all duration-200";
|
||||
btnViewList.className = "px-5 py-1.5 rounded-md text-xs font-medium text-slate-500 hover:text-slate-800 transition-all duration-200";
|
||||
});
|
||||
|
||||
// --- FUNZIONALITÀ DRAG AND DROP KANBAN ---
|
||||
const cards = document.querySelectorAll('.kanban-card');
|
||||
const columns = document.querySelectorAll('.kanban-column');
|
||||
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('dragstart', () => {
|
||||
card.classList.add('dragging');
|
||||
});
|
||||
|
||||
card.addEventListener('dragend', () => {
|
||||
card.classList.remove('dragging');
|
||||
updateColumnCounts();
|
||||
});
|
||||
});
|
||||
|
||||
columns.forEach(column => {
|
||||
const container = column.querySelector('.cards-container');
|
||||
|
||||
column.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
column.classList.add('drag-over');
|
||||
});
|
||||
|
||||
column.addEventListener('dragleave', () => {
|
||||
column.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
column.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
column.classList.remove('drag-over');
|
||||
|
||||
const draggingCard = document.querySelector('.dragging');
|
||||
if (draggingCard) {
|
||||
container.appendChild(draggingCard);
|
||||
|
||||
// Cambia visivamente il badge interno dello stato in base alla destinazione
|
||||
const badge = draggingCard.querySelector('span');
|
||||
const status = column.getAttribute('data-status');
|
||||
|
||||
if (status === 'new') {
|
||||
badge.innerText = 'New';
|
||||
badge.className = 'text-[9px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100 px-1.5 py-0.5 rounded uppercase';
|
||||
} else if (status === 'contacted') {
|
||||
badge.innerText = 'Contacted';
|
||||
badge.className = 'text-[9px] font-semibold bg-blue-50 text-blue-600 border border-blue-100 px-1.5 py-0.5 rounded uppercase';
|
||||
} else if (status === 'qualified') {
|
||||
badge.innerText = 'Qualified';
|
||||
badge.className = 'text-[9px] font-semibold bg-purple-50 text-purple-600 border border-purple-100 px-1.5 py-0.5 rounded uppercase';
|
||||
} else if (status === 'negotiation') {
|
||||
badge.innerText = 'In Trattativa';
|
||||
badge.className = 'text-[9px] font-semibold bg-amber-50 text-amber-600 border border-amber-100 px-1.5 py-0.5 rounded uppercase';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Funzione d'appoggio per aggiornare il conteggio numerico nelle colonne
|
||||
function updateColumnCounts() {
|
||||
columns.forEach(column => {
|
||||
const countBadge = column.querySelector('.count');
|
||||
const cardCount = column.querySelector('.cards-container').children.length;
|
||||
countBadge.innerText = cardCount;
|
||||
});
|
||||
}
|
||||
|
||||
// Esegui inizialmente per impostare i corretti conteggi
|
||||
updateColumnCounts();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,366 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Offerte — Luxury Admin CRM</title>
|
||||
<!-- Google Fonts: Plus Jakarta Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
dark: '#1A463C', /* Verde scuro richiesto */
|
||||
darkHover: '#13342D', /* Variante scura per hover */
|
||||
active: 'rgba(255, 255, 255, 0.08)',
|
||||
bg: '#F8F9FA',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.sidebar-transition {
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
|
||||
<div class="overflow-hidden">
|
||||
<!-- Logo e Intestazione -->
|
||||
<div class="mb-10 px-2 flex items-center justify-between">
|
||||
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigazione Principale -->
|
||||
<nav class="space-y-1">
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
|
||||
</a>
|
||||
<!-- Stato Attivo su Offerte -->
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Area Inferiore Sidebar -->
|
||||
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
|
||||
<span class="sidebar-text transition-opacity duration-300">Tema</span>
|
||||
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span class="sidebar-text transition-opacity duration-300">Esci</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- SEZIONE PRINCIPALE -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
<!-- BARRA DI INTESTAZIONE SUPERIORE -->
|
||||
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Pulsante Apri/Chiudi Sidebar -->
|
||||
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
|
||||
</button>
|
||||
<span class="text-xs text-slate-400 font-medium">Area di Lavoro</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Demo Account</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- AREA DEL CONTENUTO -->
|
||||
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-8 overflow-y-auto">
|
||||
|
||||
<!-- INTESTAZIONE SEZIONE -->
|
||||
<div class="flex flex-col sm:flex-row justify-between sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900 tracking-tight">Offerte</h1>
|
||||
<p class="text-xs text-slate-400 mt-1">Configura e gestisci i pacchetti di offerta commerciali</p>
|
||||
</div>
|
||||
|
||||
<!-- Bottone Nuova Offerta -->
|
||||
<button class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-medium tracking-wide px-5 py-3 rounded-lg flex items-center gap-2 transition-all duration-200 shadow-sm self-start sm:self-auto">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Nuova Offerta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- BARRA FILTRI -->
|
||||
<div class="flex flex-col lg:flex-row justify-between lg:items-center gap-4">
|
||||
<!-- Selettori Categorie di Offerta -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="px-4 py-2 text-xs font-medium bg-brand-dark text-white rounded-lg transition-colors">
|
||||
Tutti
|
||||
</button>
|
||||
<button class="px-4 py-2 text-xs font-medium bg-white text-slate-600 hover:text-slate-900 hover:bg-slate-100/70 border border-slate-200/60 rounded-lg transition-all duration-200">
|
||||
Entry Offer
|
||||
</button>
|
||||
<button class="px-4 py-2 text-xs font-medium bg-white text-slate-600 hover:text-slate-900 hover:bg-slate-100/70 border border-slate-200/60 rounded-lg transition-all duration-200">
|
||||
Retainer Offer
|
||||
</button>
|
||||
<button class="px-4 py-2 text-xs font-medium bg-white text-slate-600 hover:text-slate-900 hover:bg-slate-100/70 border border-slate-200/60 rounded-lg transition-all duration-200">
|
||||
Signature Offer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Checkbox Opzione Archiviati -->
|
||||
<label class="flex items-center gap-2.5 cursor-pointer group text-xs text-slate-500 hover:text-slate-800 transition-colors">
|
||||
<input type="checkbox" class="w-4 h-4 rounded text-brand-dark border-slate-300 focus:ring-brand-dark focus:ring-opacity-25 transition-all" />
|
||||
<span class="select-none font-medium">Mostra offerte archiviate</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- GRID CARDS DELLE OFFERTE -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Card 1: Mantenimento -->
|
||||
<div class="bg-white p-6 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] hover:shadow-[0_8px_30px_rgba(0,0,0,0.02)] transition-all duration-200 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex justify-between items-start gap-4 mb-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Mantenimento</h3>
|
||||
<p class="text-[11px] text-slate-400 mt-0.5">(nei clienti già attivi)</p>
|
||||
</div>
|
||||
<span class="px-2 py-1 rounded text-[9px] font-semibold bg-blue-50 text-blue-600 border border-blue-100 uppercase tracking-wider">
|
||||
Retainer Offer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Listino Tabellare Opzioni -->
|
||||
<div class="border border-slate-100 rounded-lg overflow-hidden my-6">
|
||||
<table class="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 border-b border-slate-100 text-slate-400 font-semibold uppercase">
|
||||
<th class="py-2 px-3 text-left">Tier</th>
|
||||
<th class="py-2 px-3 text-right">Servizi</th>
|
||||
<th class="py-2 px-3 text-right">Pubblico</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 text-slate-600 font-mono">
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">A</td>
|
||||
<td class="py-2 px-3 text-right">€200,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€200,00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">B</td>
|
||||
<td class="py-2 px-3 text-right">€300,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€300,00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">C</td>
|
||||
<td class="py-2 px-3 text-right">€400,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€400,00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 2: Sblocca Business -->
|
||||
<div class="bg-white p-6 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] hover:shadow-[0_8px_30px_rgba(0,0,0,0.02)] transition-all duration-200 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex justify-between items-start gap-4 mb-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Sblocca Business</h3>
|
||||
<p class="text-[11px] text-slate-400 mt-0.5">Avviamento strategico</p>
|
||||
</div>
|
||||
<span class="px-2 py-1 rounded text-[9px] font-semibold bg-purple-50 text-purple-600 border border-purple-100 uppercase tracking-wider">
|
||||
Entry Offer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Listino Tabellare Opzioni -->
|
||||
<div class="border border-slate-100 rounded-lg overflow-hidden my-6">
|
||||
<table class="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 border-b border-slate-100 text-slate-400 font-semibold uppercase">
|
||||
<th class="py-2 px-3 text-left">Tier</th>
|
||||
<th class="py-2 px-3 text-right">Servizi</th>
|
||||
<th class="py-2 px-3 text-right">Pubblico</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 text-slate-600 font-mono">
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">A</td>
|
||||
<td class="py-2 px-3 text-right">€2.600,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€400,00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">B</td>
|
||||
<td class="py-2 px-3 text-right">€3.300,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€600,00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">C</td>
|
||||
<td class="py-2 px-3 text-right">€3.900,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€1.200,00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 3: Web Domination -->
|
||||
<div class="bg-white p-6 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] hover:shadow-[0_8px_30px_rgba(0,0,0,0.02)] transition-all duration-200 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex justify-between items-start gap-4 mb-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Web Domination</h3>
|
||||
<p class="text-[11px] text-slate-400 mt-0.5">Soluzione enterprise omnicanale</p>
|
||||
</div>
|
||||
<span class="px-2 py-1 rounded text-[9px] font-semibold bg-amber-50 text-amber-700 border border-amber-100 uppercase tracking-wider">
|
||||
Signature Offer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Listino Tabellare Opzioni -->
|
||||
<div class="border border-slate-100 rounded-lg overflow-hidden my-6">
|
||||
<table class="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 border-b border-slate-100 text-slate-400 font-semibold uppercase">
|
||||
<th class="py-2 px-3 text-left">Tier</th>
|
||||
<th class="py-2 px-3 text-right">Servizi</th>
|
||||
<th class="py-2 px-3 text-right">Pubblico</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 text-slate-600 font-mono">
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">A</td>
|
||||
<td class="py-2 px-3 text-right">€9.800,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€6.000,00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">B</td>
|
||||
<td class="py-2 px-3 text-right">€20.250,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€9.000,00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">C</td>
|
||||
<td class="py-2 px-3 text-right">€25.250,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€12.000,00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card 4: Business in Banca -->
|
||||
<div class="bg-white p-6 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] hover:shadow-[0_8px_30px_rgba(0,0,0,0.02)] transition-all duration-200 flex flex-col justify-between">
|
||||
<div>
|
||||
<div class="flex justify-between items-start gap-4 mb-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-slate-900">Business in Banca</h3>
|
||||
<p class="text-[11px] text-slate-400 mt-0.5">Controllo tesoreria integrato</p>
|
||||
</div>
|
||||
<span class="px-2 py-1 rounded text-[9px] font-semibold bg-blue-50 text-blue-600 border border-blue-100 uppercase tracking-wider">
|
||||
Retainer Offer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Listino Tabellare Opzioni -->
|
||||
<div class="border border-slate-100 rounded-lg overflow-hidden my-6">
|
||||
<table class="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 border-b border-slate-100 text-slate-400 font-semibold uppercase">
|
||||
<th class="py-2 px-3 text-left">Tier</th>
|
||||
<th class="py-2 px-3 text-right">Servizi</th>
|
||||
<th class="py-2 px-3 text-right">Pubblico</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 text-slate-600 font-mono">
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">A</td>
|
||||
<td class="py-2 px-3 text-right">€200,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€200,00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">B</td>
|
||||
<td class="py-2 px-3 text-right">€400,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€300,00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 px-3 font-semibold text-slate-800">C</td>
|
||||
<td class="py-2 px-3 text-right">€900,00</td>
|
||||
<td class="py-2 px-3 text-right text-slate-400">€500,00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- SCRIPT DI LOGICA (Gestione Sidebar) -->
|
||||
<script>
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleButton = document.getElementById('sidebar-toggle');
|
||||
const logoText = document.getElementById('sidebar-logo-text');
|
||||
const sidebarTexts = document.querySelectorAll('.sidebar-text');
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const isCollapsed = sidebar.classList.contains('w-20');
|
||||
|
||||
if (isCollapsed) {
|
||||
sidebar.classList.remove('w-20');
|
||||
sidebar.classList.add('w-64');
|
||||
setTimeout(() => {
|
||||
logoText.classList.remove('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
|
||||
}, 150);
|
||||
} else {
|
||||
logoText.classList.add('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
|
||||
sidebar.classList.remove('w-64');
|
||||
sidebar.classList.add('w-20');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,238 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Preventivi — Luxury Admin CRM</title>
|
||||
<!-- Google Fonts: Plus Jakarta Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
dark: '#1A463C', /* Verde scuro richiesto */
|
||||
darkHover: '#13342D', /* Variante scura per hover */
|
||||
active: 'rgba(255, 255, 255, 0.08)',
|
||||
bg: '#F8F9FA',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.sidebar-transition {
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
|
||||
<div class="overflow-hidden">
|
||||
<!-- Logo e Intestazione -->
|
||||
<div class="mb-10 px-2 flex items-center justify-between">
|
||||
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigazione Principale -->
|
||||
<nav class="space-y-1">
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
|
||||
</a>
|
||||
<!-- Stato Attivo su Preventivi -->
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Area Inferiore Sidebar -->
|
||||
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
|
||||
<span class="sidebar-text transition-opacity duration-300">Tema</span>
|
||||
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span class="sidebar-text transition-opacity duration-300">Esci</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- SEZIONE PRINCIPALE -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
<!-- BARRA DI INTESTAZIONE SUPERIORE -->
|
||||
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Pulsante Apri/Chiudi Sidebar -->
|
||||
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
|
||||
</button>
|
||||
<span class="text-xs text-slate-400 font-medium">Area di Lavoro</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Demo Account</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- AREA DEL CONTENUTO -->
|
||||
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-8 overflow-y-auto">
|
||||
|
||||
<!-- INTESTAZIONE SEZIONE -->
|
||||
<div class="flex flex-col sm:flex-row justify-between sm:items-start gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900 tracking-tight">Preventivi</h1>
|
||||
<p class="text-xs text-slate-400 mt-1">Preventivi generati con AI e pubblicati ai clienti</p>
|
||||
</div>
|
||||
|
||||
<!-- Pulsante Genera -->
|
||||
<button class="bg-brand-dark hover:bg-brand-darkHover text-white text-xs font-medium tracking-wide px-5 py-3 rounded-lg flex items-center gap-2 transition-all duration-200 shadow-sm self-start sm:self-auto">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Genera preventivo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TABELLA PREVENTIVI -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-slate-100/80 bg-slate-50/50 text-slate-400 text-[11px] font-semibold uppercase tracking-wider">
|
||||
<th class="py-4 px-6 w-1/3">Titolo</th>
|
||||
<th class="py-4 px-6">Lead / Cliente</th>
|
||||
<th class="py-4 px-6">Offerta</th>
|
||||
<th class="py-4 px-6 text-center">Stato</th>
|
||||
<th class="py-4 px-6">Data</th>
|
||||
<th class="py-4 px-6 text-right">Azione</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100/60 text-sm">
|
||||
|
||||
<!-- Riga 1: Pubblicato -->
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-4 px-6 font-semibold text-slate-900">
|
||||
Rossi Inc × Entry Offer
|
||||
</td>
|
||||
<td class="py-4 px-6 text-slate-600">
|
||||
Mario Rossi
|
||||
</td>
|
||||
<td class="py-4 px-6 text-slate-500 font-medium">
|
||||
Entry Offer
|
||||
</td>
|
||||
<td class="py-4 px-6 text-center">
|
||||
<!-- Badge Stato: Pubblicato -->
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide bg-sky-50 text-sky-700 border border-sky-100 uppercase">
|
||||
Pubblicato
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-slate-500 text-xs font-mono">
|
||||
26/06/2026
|
||||
</td>
|
||||
<td class="py-4 px-6 text-right">
|
||||
<a href="#" class="text-xs font-medium text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-1.5 group transition-colors">
|
||||
Apri
|
||||
<svg class="w-3.5 h-3.5 transform group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24"><path d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/></svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Riga 2: Accettato -->
|
||||
<tr class="hover:bg-slate-50/30 transition-all duration-150">
|
||||
<td class="py-4 px-6 font-semibold text-slate-900">
|
||||
Rossi Inc × Entry Offer
|
||||
</td>
|
||||
<td class="py-4 px-6 text-slate-600">
|
||||
Mario Rossi
|
||||
</td>
|
||||
<td class="py-4 px-6 text-slate-500 font-medium">
|
||||
Entry Offer
|
||||
</td>
|
||||
<td class="py-4 px-6 text-center">
|
||||
<!-- Badge Stato: Accettato -->
|
||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide bg-emerald-50 text-emerald-700 border border-emerald-100 uppercase">
|
||||
Accettato
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-6 text-slate-500 text-xs font-mono">
|
||||
20/06/2026
|
||||
</td>
|
||||
<td class="py-4 px-6 text-right">
|
||||
<a href="#" class="text-xs font-medium text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-1.5 group transition-colors">
|
||||
Apri
|
||||
<svg class="w-3.5 h-3.5 transform group-hover:translate-x-0.5 transition-transform" fill="none" stroke="currentColor" stroke-width="2.2" viewBox="0 0 24 24"><path d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/></svg>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer Tabella -->
|
||||
<div class="border-t border-slate-100 px-6 py-4 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Mostrando 2 di 2 preventivi generati</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- SCRIPT DI LOGICA (Gestione Sidebar) -->
|
||||
<script>
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleButton = document.getElementById('sidebar-toggle');
|
||||
const logoText = document.getElementById('sidebar-logo-text');
|
||||
const sidebarTexts = document.querySelectorAll('.sidebar-text');
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const isCollapsed = sidebar.classList.contains('w-20');
|
||||
|
||||
if (isCollapsed) {
|
||||
sidebar.classList.remove('w-20');
|
||||
sidebar.classList.add('w-64');
|
||||
setTimeout(() => {
|
||||
logoText.classList.remove('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
|
||||
}, 150);
|
||||
} else {
|
||||
logoText.classList.add('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
|
||||
sidebar.classList.remove('w-64');
|
||||
sidebar.classList.add('w-20');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+10
-1
@@ -33,6 +33,7 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -9308,9 +9309,17 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
|
||||
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.0.0 || insiders"
|
||||
}
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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(() => {
|
||||
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();
|
||||
if (!q) return services;
|
||||
return services.filter((s) => {
|
||||
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, query]);
|
||||
})
|
||||
: 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { addPoolValue, renamePoolValue } from "@/lib/taxonomy";
|
||||
|
||||
const serviceSchema = z.object({
|
||||
name: z.string().min(1, "Nome richiesto"),
|
||||
@@ -95,6 +96,7 @@ export async function updateServiceField(
|
||||
} else if (fieldName === "fase") {
|
||||
const s = String(value).trim();
|
||||
await db.update(services).set({ fase: s || null }).where(eq(services.id, serviceId));
|
||||
if (s) await addPoolValue("service_fase", s);
|
||||
} else if (fieldName === "unit_price") {
|
||||
// Normalize locale-formatted input (WR-04): the cell displays it-IT (€1.234,50),
|
||||
// so an admin may type "1.234,50". When a comma is present, treat "." as thousands
|
||||
@@ -143,6 +145,9 @@ export async function addServiceOption(
|
||||
.values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed })
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Register into the persistent pool so it survives unassignment / shows in settings.
|
||||
await addPoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", trimmed);
|
||||
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
|
||||
@@ -187,10 +192,12 @@ export async function renameServiceOption(
|
||||
.update(tags)
|
||||
.set({ name: next })
|
||||
.where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue)));
|
||||
await renamePoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", oldValue, next);
|
||||
} else if (field === "categoria") {
|
||||
await db.update(services).set({ category: next }).where(eq(services.category, oldValue));
|
||||
} else if (field === "fase") {
|
||||
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
|
||||
await renamePoolValue("service_fase", oldValue, next);
|
||||
} else {
|
||||
throw new Error(`Campo non valido: ${field}`);
|
||||
}
|
||||
@@ -227,14 +234,16 @@ export async function quickAddService(payload: QuickAddPayload | string) {
|
||||
unit_price = num.toFixed(2);
|
||||
}
|
||||
|
||||
const fase = data.fase?.trim() || null;
|
||||
await db.insert(services).values({
|
||||
name,
|
||||
description: data.description?.trim() || null,
|
||||
category: data.category?.trim() || null,
|
||||
fase: data.fase?.trim() || null,
|
||||
fase,
|
||||
unit_price,
|
||||
active: true,
|
||||
});
|
||||
if (fase) await addPoolValue("service_fase", fase);
|
||||
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getAllServices, getCatalogFieldOptions } from "@/lib/admin-queries";
|
||||
import { CatalogSearch } from "./CatalogSearch";
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
@@ -10,11 +11,8 @@ export default async function CatalogPage() {
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Catalogo Servizi" />
|
||||
<CatalogSearch services={services} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,10 +50,17 @@ async function resolveEntity(id: string): Promise<{ projectId: string | null; pa
|
||||
|
||||
// ── CLIENT CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
const emptyToNull = (v: unknown) => {
|
||||
const s = typeof v === "string" ? v.trim() : "";
|
||||
return s === "" ? null : s;
|
||||
};
|
||||
|
||||
const clientSchema = z.object({
|
||||
name: z.string().min(1, "Nome richiesto"),
|
||||
brand_name: z.string().min(1, "Brand name richiesto"),
|
||||
brief: z.string(),
|
||||
email: z.string().optional().transform(emptyToNull),
|
||||
phone: z.string().optional().transform(emptyToNull),
|
||||
slug: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]{3,50}$/, "Slug non valido (es. mario-rossi, min 3 max 50 caratteri)")
|
||||
@@ -68,6 +75,8 @@ export async function updateClient(clientId: string, formData: FormData) {
|
||||
name: formData.get("name"),
|
||||
brand_name: formData.get("brand_name"),
|
||||
brief: formData.get("brief") ?? "",
|
||||
email: formData.get("email") ?? "",
|
||||
phone: formData.get("phone") ?? "",
|
||||
slug: formData.get("slug") ?? "",
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
@@ -133,6 +142,14 @@ export async function updatePhaseStatus(phaseId: string, id: string, status: str
|
||||
revalidatePath(path);
|
||||
}
|
||||
|
||||
export async function deletePhase(phaseId: string, id: string) {
|
||||
await requireAdmin();
|
||||
// FK cascade on tasks and deliverables handles child rows
|
||||
await db.delete(phases).where(eq(phases.id, phaseId));
|
||||
const { path } = await resolveEntity(id);
|
||||
revalidatePath(path);
|
||||
}
|
||||
|
||||
// ── TASKS ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function addTask(phaseId: string, id: string, formData: FormData) {
|
||||
@@ -153,15 +170,56 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
|
||||
sort_order: maxOrder + 1,
|
||||
status: "todo",
|
||||
});
|
||||
// Cascade: a new todo task keeps phase active/upcoming — recompute to be safe
|
||||
await recomputePhaseStatus(phaseId);
|
||||
const { path } = await resolveEntity(id);
|
||||
revalidatePath(path);
|
||||
}
|
||||
|
||||
// ── PHASE STATUS CASCADE ──────────────────────────────────────────────────────
|
||||
// Recomputes phase status from its tasks:
|
||||
// all done → done
|
||||
// any in_progress or done (but not all done) → active
|
||||
// all todo (or no tasks) → upcoming
|
||||
export async function recomputePhaseStatus(phaseId: string): Promise<void> {
|
||||
const phaseTasks = await db
|
||||
.select({ status: tasks.status })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.phase_id, phaseId));
|
||||
|
||||
let newStatus: "upcoming" | "active" | "done" = "upcoming";
|
||||
if (phaseTasks.length > 0) {
|
||||
const allDone = phaseTasks.every((t) => t.status === "done");
|
||||
const anyActive = phaseTasks.some(
|
||||
(t) => t.status === "in_progress" || t.status === "done"
|
||||
);
|
||||
if (allDone) newStatus = "done";
|
||||
else if (anyActive) newStatus = "active";
|
||||
}
|
||||
await db.update(phases).set({ status: newStatus }).where(eq(phases.id, phaseId));
|
||||
}
|
||||
|
||||
export async function updateTaskStatus(taskId: string, id: string, status: string) {
|
||||
await requireAdmin();
|
||||
const allowed = ["todo", "in_progress", "done"];
|
||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
||||
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
||||
|
||||
// Cascade: recompute parent phase status from all its tasks
|
||||
const taskRow = await db.select({ phase_id: tasks.phase_id }).from(tasks).where(eq(tasks.id, taskId)).limit(1);
|
||||
if (taskRow[0]) await recomputePhaseStatus(taskRow[0].phase_id);
|
||||
|
||||
const { path } = await resolveEntity(id);
|
||||
revalidatePath(path);
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId: string, id: string) {
|
||||
await requireAdmin();
|
||||
// Fetch phase_id before deletion so we can recompute phase status after
|
||||
const taskRow = await db.select({ phase_id: tasks.phase_id }).from(tasks).where(eq(tasks.id, taskId)).limit(1);
|
||||
const phaseId = taskRow[0]?.phase_id;
|
||||
await db.delete(tasks).where(eq(tasks.id, taskId));
|
||||
if (phaseId) await recomputePhaseStatus(phaseId);
|
||||
const { path } = await resolveEntity(id);
|
||||
revalidatePath(path);
|
||||
}
|
||||
@@ -231,6 +289,54 @@ export async function updatePaymentStatus(paymentId: string, id: string, status:
|
||||
revalidatePath(path);
|
||||
}
|
||||
|
||||
// Imposta il mese in cui un pagamento è stato incassato (formato "YYYY-MM").
|
||||
// Mappa al primo giorno del mese (mezzogiorno UTC per evitare drift di fuso) e
|
||||
// porta lo stato a "saldato" così l'incasso viene attribuito a quel mese nelle analytics.
|
||||
export async function setPaymentPaidAt(paymentId: string, id: string, monthStr: string) {
|
||||
await requireAdmin();
|
||||
const m = /^(\d{4})-(\d{2})$/.exec(monthStr);
|
||||
if (!m) throw new Error("Mese non valido");
|
||||
const year = parseInt(m[1], 10);
|
||||
const month = parseInt(m[2], 10);
|
||||
if (month < 1 || month > 12) throw new Error("Mese non valido");
|
||||
const paid_at = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0));
|
||||
await db
|
||||
.update(payments)
|
||||
.set({ paid_at, status: "saldato" })
|
||||
.where(eq(payments.id, paymentId));
|
||||
const { path } = await resolveEntity(id);
|
||||
revalidatePath(path);
|
||||
}
|
||||
|
||||
// Rescales payment amounts when the total changes.
|
||||
// If the payment has a `percent` field, use it (new plan rows).
|
||||
// Legacy rows (percent null) fall back to equal split across all rows.
|
||||
async function rescalePayments(projectId: string, newTotal: number): Promise<void> {
|
||||
const projectPayments = await db
|
||||
.select({ id: payments.id, percent: payments.percent })
|
||||
.from(payments)
|
||||
.where(eq(payments.project_id, projectId));
|
||||
|
||||
if (projectPayments.length === 0) return;
|
||||
|
||||
const hasPercent = projectPayments.some((p) => p.percent !== null);
|
||||
|
||||
if (hasPercent) {
|
||||
// New plan: rescale each row by its stored percent
|
||||
for (const p of projectPayments) {
|
||||
const pct = p.percent !== null ? parseFloat(String(p.percent)) : 0;
|
||||
const newAmount = ((newTotal * pct) / 100).toFixed(2);
|
||||
await db.update(payments).set({ amount: newAmount }).where(eq(payments.id, p.id));
|
||||
}
|
||||
} else {
|
||||
// Legacy: equal split across all rows (backward compat)
|
||||
const share = (newTotal / projectPayments.length).toFixed(2);
|
||||
for (const p of projectPayments) {
|
||||
await db.update(payments).set({ amount: share }).where(eq(payments.id, p.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAcceptedTotal(id: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const raw = (formData.get("accepted_total") as string)?.trim();
|
||||
@@ -244,28 +350,17 @@ export async function updateAcceptedTotal(id: string, formData: FormData) {
|
||||
.limit(1);
|
||||
|
||||
if (asProject[0]) {
|
||||
// Project context: update projects.accepted_total + this project's payment stubs
|
||||
// Project context: update projects.accepted_total + rescale this project's payments
|
||||
await db.update(projects).set({ accepted_total: val.toFixed(2) }).where(eq(projects.id, id));
|
||||
const half = (val / 2).toFixed(2);
|
||||
const projectPayments = await db.select({ id: payments.id })
|
||||
.from(payments).where(eq(payments.project_id, id));
|
||||
for (const p of projectPayments) {
|
||||
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
|
||||
}
|
||||
await rescalePayments(id, val);
|
||||
revalidatePath(`/admin/projects/${id}`);
|
||||
} else {
|
||||
// Client context: update clients.accepted_total + all project payment stubs
|
||||
// Client context: update clients.accepted_total + rescale all project payments
|
||||
await db.update(clients).set({ accepted_total: val.toFixed(2) }).where(eq(clients.id, id));
|
||||
const projectRows = await db.select({ id: projects.id })
|
||||
.from(projects).where(eq(projects.client_id, id));
|
||||
if (projectRows.length > 0) {
|
||||
const projectIds = projectRows.map((p) => p.id);
|
||||
const half = (val / 2).toFixed(2);
|
||||
const paymentsRows = await db.select()
|
||||
.from(payments).where(inArray(payments.project_id, projectIds));
|
||||
for (const p of paymentsRows) {
|
||||
await db.update(payments).set({ amount: half }).where(eq(payments.id, p.id));
|
||||
}
|
||||
for (const proj of projectRows) {
|
||||
await rescalePayments(proj.id, val);
|
||||
}
|
||||
revalidatePath(`/admin/clients/${id}`);
|
||||
}
|
||||
@@ -280,7 +375,7 @@ export async function postAdminComment(id: string, formData: FormData) {
|
||||
if (!body || !entity) throw new Error("Dati mancanti");
|
||||
const [entity_type, entity_id] = entity.split(":");
|
||||
if (!entity_type || !entity_id) throw new Error("Formato entity non valido");
|
||||
const allowedTypes = ["task", "deliverable"];
|
||||
const allowedTypes = ["task", "deliverable", "phase", "general"];
|
||||
if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
|
||||
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
|
||||
const { path } = await resolveEntity(id);
|
||||
|
||||
@@ -62,6 +62,29 @@ export default async function EditClientPage({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={client.email ?? ""}
|
||||
placeholder="marco@rossistudio.it"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="phone">Telefono</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
defaultValue={client.phone ?? ""}
|
||||
placeholder="+39 333 1234567"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="brief">Brief progetto</Label>
|
||||
<Textarea
|
||||
|
||||
@@ -33,6 +33,21 @@ export default async function ClientDetailPage({
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
|
||||
<p className="text-sm text-[#71717a]">{client.brand_name}</p>
|
||||
{(client.email || client.phone) && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-[#71717a]">
|
||||
{client.email && (
|
||||
<a href={`mailto:${client.email}`} className="hover:text-[#1A463C]">
|
||||
{client.email}
|
||||
</a>
|
||||
)}
|
||||
{client.email && client.phone && <span className="text-[#d4d4d8]">·</span>}
|
||||
{client.phone && (
|
||||
<a href={`tel:${client.phone}`} className="hover:text-[#1A463C]">
|
||||
{client.phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{client.archived && (
|
||||
<span className="inline-block mt-1 text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full">
|
||||
Archiviato
|
||||
@@ -115,19 +130,40 @@ export default async function ClientDetailPage({
|
||||
Offerte Attive ({activeOffers.length})
|
||||
</p>
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] divide-y divide-[#e5e7eb]">
|
||||
{activeOffers.map((offer) => (
|
||||
{activeOffers.map((offer) => {
|
||||
const categoryLabel =
|
||||
offer.category ??
|
||||
(offer.offer_type === "retainer"
|
||||
? "Retainer"
|
||||
: offer.offer_type === "una_tantum"
|
||||
? "Una tantum"
|
||||
: null);
|
||||
return (
|
||||
<div key={offer.offer_id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{offer.public_name}</p>
|
||||
<p className="text-xs text-[#71717a]">{offer.project_name}</p>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{offer.macro_name}</p>
|
||||
{categoryLabel && (
|
||||
<span className="inline-flex items-center rounded-full bg-[#1A463C]/10 px-2 py-0.5 text-[11px] font-semibold text-[#1A463C]">
|
||||
{categoryLabel}
|
||||
</span>
|
||||
)}
|
||||
{offer.tier_letter && (
|
||||
<span className="inline-flex items-center rounded-full border border-[#e5e7eb] px-2 py-0.5 text-[11px] font-semibold text-[#71717a]">
|
||||
Tier {offer.tier_letter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[#71717a] mt-0.5">{offer.project_name}</p>
|
||||
</div>
|
||||
{offer.accepted_total && (
|
||||
<span className="text-sm font-mono text-[#1a1a1a]">
|
||||
<span className="text-sm font-mono text-[#1a1a1a] shrink-0">
|
||||
€{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -44,62 +44,68 @@ const createClientSchema = z.object({
|
||||
name: z.string().min(1, "Nome richiesto"),
|
||||
brand_name: z.string().min(1, "Nome brand richiesto"),
|
||||
brief: z.string().min(1, "Brief richiesto"),
|
||||
email: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function createClient(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const raw = {
|
||||
name: formData.get("name") as string,
|
||||
brand_name: formData.get("brand_name") as string,
|
||||
brief: formData.get("brief") as string,
|
||||
};
|
||||
// Shared core used by both the manual create form and the lead→client
|
||||
// conversion. Creates the client (+ slug + token), a default project (optionally
|
||||
// linked to the originating lead), and the two 50/50 payment stubs. Returns the
|
||||
// new ids. Does NOT redirect — callers decide where to go next.
|
||||
export async function createClientCore(input: {
|
||||
name: string;
|
||||
brand_name: string;
|
||||
brief: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
created_from_lead_id?: string | null;
|
||||
}): Promise<{ clientId: string; projectId: string }> {
|
||||
const slug = await uniqueSlug(toSlug(input.name));
|
||||
|
||||
const parsed = createClientSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new Error(
|
||||
parsed.error.issues.map((i) => i.message).join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-generate slug from name (e.g. "Mario Rossi" → "mario-rossi")
|
||||
const slug = await uniqueSlug(toSlug(parsed.data.name));
|
||||
|
||||
// Insert client — token and id are auto-generated by $defaultFn(() => nanoid())
|
||||
const [newClient] = await db
|
||||
.insert(clients)
|
||||
.values({
|
||||
name: parsed.data.name,
|
||||
brand_name: parsed.data.brand_name,
|
||||
brief: parsed.data.brief,
|
||||
name: input.name,
|
||||
brand_name: input.brand_name,
|
||||
brief: input.brief,
|
||||
email: input.email?.trim() || null,
|
||||
phone: input.phone?.trim() || null,
|
||||
slug,
|
||||
})
|
||||
.returning({ id: clients.id, token: clients.token, brand_name: clients.brand_name });
|
||||
.returning({ id: clients.id, brand_name: clients.brand_name });
|
||||
|
||||
// Create a default project for the client — all work items are project-scoped
|
||||
const [newProject] = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
client_id: newClient.id,
|
||||
name: newClient.brand_name,
|
||||
created_from_lead_id: input.created_from_lead_id ?? null,
|
||||
})
|
||||
.returning({ id: projects.id });
|
||||
|
||||
// Always create two payment stubs per project — Acconto 50% and Saldo 50%
|
||||
await db.insert(payments).values([
|
||||
{
|
||||
project_id: newProject.id,
|
||||
label: "Acconto 50%",
|
||||
amount: "0",
|
||||
status: "da_saldare",
|
||||
},
|
||||
{
|
||||
project_id: newProject.id,
|
||||
label: "Saldo 50%",
|
||||
amount: "0",
|
||||
status: "da_saldare",
|
||||
},
|
||||
{ project_id: newProject.id, label: "Acconto 50%", amount: "0", status: "da_saldare" },
|
||||
{ project_id: newProject.id, label: "Saldo 50%", amount: "0", status: "da_saldare" },
|
||||
]);
|
||||
|
||||
revalidatePath("/admin");
|
||||
redirect(`/admin/clients/${newClient.id}`);
|
||||
return { clientId: newClient.id, projectId: newProject.id };
|
||||
}
|
||||
|
||||
export async function createClient(formData: FormData) {
|
||||
await requireAdmin();
|
||||
const parsed = createClientSchema.safeParse({
|
||||
name: formData.get("name") as string,
|
||||
brand_name: formData.get("brand_name") as string,
|
||||
brief: formData.get("brief") as string,
|
||||
email: (formData.get("email") as string) ?? "",
|
||||
phone: (formData.get("phone") as string) ?? "",
|
||||
});
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.issues.map((i) => i.message).join(", "));
|
||||
}
|
||||
|
||||
const { clientId } = await createClientCore(parsed.data);
|
||||
|
||||
revalidatePath("/admin");
|
||||
redirect(`/admin/clients/${clientId}`);
|
||||
}
|
||||
@@ -45,6 +45,26 @@ export default function NewClientPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="es. marco@rossistudio.it"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="phone">Telefono</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
placeholder="es. +39 333 1234567"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="brief">Brief del progetto</Label>
|
||||
<Textarea
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { getAllClientsWithPayments } from "@/lib/admin-queries";
|
||||
import { ClientRow } from "@/components/admin/ClientRow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
@@ -14,53 +15,65 @@ export default async function AdminClientsPage({
|
||||
const showArchived = archived === "1";
|
||||
const clients = await getAllClientsWithPayments(showArchived);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
|
||||
const clientiAction = (
|
||||
<div className="flex items-center gap-4 self-start sm:self-auto">
|
||||
<a
|
||||
href={showArchived ? "/admin/clients" : "/admin/clients?archived=1"}
|
||||
className="text-xs text-[#71717a] hover:text-[#1A463C] underline underline-offset-2"
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showArchived ? "Nascondi archiviati" : "Mostra archiviati"}
|
||||
</a>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Clienti"
|
||||
subtitle="Monitora i rapporti finanziari e i dettagli dei clienti attivi"
|
||||
action={clientiAction}
|
||||
/>
|
||||
|
||||
{clients.length === 0 ? (
|
||||
<div className="text-center py-20 text-[#71717a]">
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
<p>{showArchived ? "Nessun cliente archiviato." : "Nessun cliente ancora."}</p>
|
||||
{!showArchived && (
|
||||
<p className="mt-2">
|
||||
<Link href="/admin/clients/new" className="text-[#1A463C] hover:underline font-medium">
|
||||
<Link href="/admin/clients/new" className="text-primary hover:underline font-medium">
|
||||
Crea il primo cliente
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Cliente</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">LTV</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Importo incassato</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Importo da saldare</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Link</th>
|
||||
<div className="bg-card rounded-xl border border-border shadow-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50 text-muted-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||
<th className="py-4 px-6 text-left w-1/4">Cliente</th>
|
||||
<th className="py-4 px-6 text-right">LTV</th>
|
||||
<th className="py-4 px-6 text-right">Importo incassato</th>
|
||||
<th className="py-4 px-6 text-right">Importo da saldare</th>
|
||||
<th className="py-4 px-6 text-right">Link profilo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-border">
|
||||
{clients.map((client) => (
|
||||
<ClientRow key={client.id} client={client} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="border-t border-border px-6 py-4 text-xs text-muted-foreground">
|
||||
Mostrando {clients.length}{" "}
|
||||
{clients.length === 1 ? "cliente" : "clienti"}
|
||||
{showArchived ? "" : clients.length === 1 ? " attivo" : " attivi"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { db } from "@/db";
|
||||
import { clients, comments } from "@/db/schema";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin reply from the Conversazioni inbox. Per project decision, replies are
|
||||
* saved as a "general" comment on the client (entity_id = clientId), so they
|
||||
* surface in the client's general chat and in the client detail CommentsTab.
|
||||
*/
|
||||
export async function replyToConversation(clientId: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const body = (formData.get("body") as string)?.trim();
|
||||
if (!clientId || !body) throw new Error("Dati mancanti");
|
||||
|
||||
// Validate the client exists (entity_id integrity).
|
||||
const rows = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.limit(1);
|
||||
if (rows.length === 0) throw new Error("Cliente non trovato");
|
||||
|
||||
await db.insert(comments).values({
|
||||
entity_type: "general",
|
||||
entity_id: clientId,
|
||||
author: "admin",
|
||||
body,
|
||||
});
|
||||
|
||||
// Replying implies the admin has read the incoming messages.
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ admin_last_read_at: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
|
||||
revalidatePath("/admin/conversazioni");
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
/** Mark a client's conversation as read up to now (clears unread dot/badge). */
|
||||
export async function markConversationRead(clientId: string) {
|
||||
await requireAdmin();
|
||||
if (!clientId) return;
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ admin_last_read_at: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
revalidatePath("/admin/conversazioni");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
import { ConversationsView } from "@/components/admin/conversazioni/ConversationsView";
|
||||
import {
|
||||
getConversations,
|
||||
getConversationThread,
|
||||
} from "@/lib/conversations-queries";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ConversazioniPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ c?: string }>;
|
||||
}) {
|
||||
const { c } = await searchParams;
|
||||
|
||||
const conversations = await getConversations();
|
||||
// Default selection: the requested client, else the most recent conversation.
|
||||
const activeClientId =
|
||||
(c && conversations.some((conv) => conv.clientId === c) ? c : null) ??
|
||||
conversations[0]?.clientId ??
|
||||
null;
|
||||
|
||||
const activeThread = activeClientId
|
||||
? await getConversationThread(activeClientId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Conversazioni"
|
||||
subtitle="Centro messaggi clienti — tutte le richieste in un unico posto"
|
||||
/>
|
||||
<ConversationsView
|
||||
conversations={conversations}
|
||||
activeThread={activeThread}
|
||||
activeClientId={activeClientId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
addPoolValue,
|
||||
removePoolValue,
|
||||
TAXONOMY_FIELDS,
|
||||
type TaxonomyFieldId,
|
||||
} from "@/lib/taxonomy";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
function assertField(fieldId: string): asserts fieldId is TaxonomyFieldId {
|
||||
if (!(fieldId in TAXONOMY_FIELDS)) throw new Error(`Tassonomia non valida: ${fieldId}`);
|
||||
}
|
||||
|
||||
function revalidateAll() {
|
||||
revalidatePath("/admin/impostazioni");
|
||||
revalidatePath("/admin/offers");
|
||||
revalidatePath("/admin/catalog");
|
||||
}
|
||||
|
||||
export async function addTaxonomyValue(fieldId: string, value: string): Promise<void> {
|
||||
await requireAdmin();
|
||||
assertField(fieldId);
|
||||
await addPoolValue(fieldId, value);
|
||||
revalidateAll();
|
||||
}
|
||||
|
||||
// Global delete: removes the value from the pool AND cascade-strips it from every
|
||||
// offer/service using it.
|
||||
export async function removeTaxonomyValue(fieldId: string, value: string): Promise<void> {
|
||||
await requireAdmin();
|
||||
assertField(fieldId);
|
||||
await removePoolValue(fieldId, value);
|
||||
revalidateAll();
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
|
||||
import { getAllPools } from "@/lib/taxonomy";
|
||||
import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager";
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ImpostazioniPage() {
|
||||
const targetRate = await getTargetHourlyRate();
|
||||
const [targetRate, pools] = await Promise.all([getTargetHourlyRate(), getAllPools()]);
|
||||
|
||||
async function handleSave(fd: FormData) {
|
||||
"use server";
|
||||
@@ -14,9 +17,10 @@ export default async function ImpostazioniPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-6">Impostazioni</h1>
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Impostazioni" />
|
||||
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<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>
|
||||
|
||||
@@ -54,6 +58,9 @@ export default async function ImpostazioniPage() {
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<TaxonomyManager pools={pools} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AdminSidebar } from "@/components/admin/AdminSidebar";
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { getUnreadConversationsCount } from "@/lib/conversations-queries";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
@@ -8,12 +9,9 @@ export default async function AdminLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getServerSession(authOptions);
|
||||
return (
|
||||
<div className="flex min-h-screen bg-gray-50">
|
||||
{session && <AdminSidebar />}
|
||||
<main className="flex-1 px-8 py-8 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
if (!session) {
|
||||
return <div className="min-h-screen bg-background">{children}</div>;
|
||||
}
|
||||
const unreadConversations = await getUnreadConversationsCount();
|
||||
return <AdminShell unreadConversations={unreadConversations}>{children}</AdminShell>;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,12 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getActivityLog, getUpcomingReminders, getTranscripts } from "@/lib/lead-service";
|
||||
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
|
||||
import { LeadDetail } from "@/components/admin/leads/LeadDetail";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
// Legacy route — Lead was renamed to Pipeline. Kept as a redirect stub so
|
||||
// existing links/bookmarks to /admin/leads/<id> don't break.
|
||||
export default async function LeadDetailRedirectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
const [leads, options] = await Promise.all([
|
||||
getLeadsWithTags(),
|
||||
getLeadFieldOptions(),
|
||||
]);
|
||||
|
||||
const lead = leads.find((l) => l.id === id);
|
||||
|
||||
if (!lead) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [activities, reminders, transcripts] = await Promise.all([
|
||||
getActivityLog(id),
|
||||
getUpcomingReminders(id),
|
||||
getTranscripts(id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<LeadDetail
|
||||
lead={lead}
|
||||
activities={activities}
|
||||
reminders={reminders}
|
||||
tags={lead.tags}
|
||||
tagOptions={options.tags}
|
||||
transcripts={transcripts}
|
||||
/>
|
||||
);
|
||||
redirect(`/admin/pipeline/${id}`);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
|
||||
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
|
||||
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function LeadsPage() {
|
||||
const [leads, options] = await Promise.all([
|
||||
getLeadsWithTags(),
|
||||
getLeadFieldOptions(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Lead Pipeline</h1>
|
||||
<CreateLeadModal />
|
||||
</div>
|
||||
|
||||
<LeadsViewToggle leads={leads} options={options} />
|
||||
</div>
|
||||
);
|
||||
// Legacy route — Lead was renamed to Pipeline. Kept as a redirect stub so
|
||||
// existing links/bookmarks to /admin/leads don't break.
|
||||
export default function LeadsRedirectPage() {
|
||||
redirect("/admin/pipeline");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { eq, and, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { addPoolValue, renamePoolValue } from "@/lib/taxonomy";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
@@ -155,6 +156,7 @@ const saveOfferEditorSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
ticket: z.string().optional(),
|
||||
offer_type: z.enum(["una_tantum", "retainer"]).optional(),
|
||||
cliente_ideale: z.string().optional(),
|
||||
risultato: z.string().optional(),
|
||||
tempo: z.string().optional(),
|
||||
@@ -188,6 +190,7 @@ export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorP
|
||||
description: data.description || null,
|
||||
category: data.category || null,
|
||||
ticket: data.ticket || null,
|
||||
offer_type: data.offer_type ?? "una_tantum",
|
||||
cliente_ideale: data.cliente_ideale || null,
|
||||
risultato: data.risultato || null,
|
||||
tempo: data.tempo || null,
|
||||
@@ -259,6 +262,13 @@ export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorP
|
||||
await db.insert(tags).values(tagRows).onConflictDoNothing();
|
||||
}
|
||||
|
||||
// 4. Register any newly-typed values into the persistent taxonomy pools so they
|
||||
// survive unassignment and appear in settings + every dropdown.
|
||||
if (data.category) await addPoolValue("offer_categoria", data.category);
|
||||
if (data.ticket) await addPoolValue("offer_ticket", data.ticket);
|
||||
for (const name of data.tipoTags) await addPoolValue("offer_tipo", name);
|
||||
for (const name of data.obiettivoTags) await addPoolValue("offer_obiettivo", name);
|
||||
|
||||
revalidatePath("/admin/offers");
|
||||
revalidatePath(`/admin/offers/${macroId}/edit`);
|
||||
}
|
||||
@@ -290,6 +300,8 @@ export async function addOfferTag(dimension: OfferTagDimension, macroId: string,
|
||||
.values({ entity_type: OFFER_TAG_ENTITY[dimension], entity_id: macroId, name: trimmed })
|
||||
.onConflictDoNothing();
|
||||
|
||||
await addPoolValue(dimension === "tipo" ? "offer_tipo" : "offer_obiettivo", trimmed);
|
||||
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
|
||||
@@ -332,13 +344,16 @@ export async function renameOfferOption(
|
||||
|
||||
if (field === "categoria") {
|
||||
await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue));
|
||||
await renamePoolValue("offer_categoria", oldValue, next);
|
||||
} else if (field === "ticket") {
|
||||
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue));
|
||||
await renamePoolValue("offer_ticket", oldValue, next);
|
||||
} else if (field === "tipo" || field === "obiettivo") {
|
||||
await db
|
||||
.update(tags)
|
||||
.set({ name: next })
|
||||
.where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue)));
|
||||
await renamePoolValue(field === "tipo" ? "offer_tipo" : "offer_obiettivo", oldValue, next);
|
||||
} else {
|
||||
throw new Error(`Campo non valido: ${field}`);
|
||||
}
|
||||
@@ -371,6 +386,7 @@ export async function createOfferMacro(formData: FormData) {
|
||||
description: parsed.data.description || null,
|
||||
category: parsed.data.category || null,
|
||||
});
|
||||
if (parsed.data.category) await addPoolValue("offer_categoria", parsed.data.category);
|
||||
|
||||
revalidatePath("/admin/offers");
|
||||
}
|
||||
|
||||
+69
-229
@@ -1,114 +1,65 @@
|
||||
import { Suspense } from "react";
|
||||
import { getDashboardStats } from "@/lib/dashboard-queries";
|
||||
import { FollowUpWidget } from "@/components/admin/dashboard/FollowUpWidget";
|
||||
import { MessagesWidget } from "@/components/admin/dashboard/MessagesWidget";
|
||||
import {
|
||||
getAnalyticsByYear,
|
||||
getMonthlyCollected,
|
||||
getAvailableYears,
|
||||
getTimeByClient,
|
||||
getTotalTrackedHours,
|
||||
getClientProfitability,
|
||||
} from "@/lib/analytics-queries";
|
||||
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
|
||||
import { Users, FolderOpen, Euro, Clock } from "lucide-react";
|
||||
import { getRevenueForecast12Months, getOffersSoldBreakdown } from "@/lib/forecast-queries";
|
||||
import { YearSelector } from "@/components/admin/YearSelector";
|
||||
import { ForecastChart } from "@/components/admin/ForecastChart";
|
||||
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
|
||||
import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
function KpiCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 flex items-start gap-4">
|
||||
<div className={`p-2 rounded-lg ${color}`}>
|
||||
<Icon size={20} strokeWidth={1.8} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-medium uppercase tracking-wide">{label}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-0.5">{value}</p>
|
||||
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
label,
|
||||
value,
|
||||
valueAccent,
|
||||
delta,
|
||||
sub,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
valueAccent?: boolean;
|
||||
delta?: string;
|
||||
sub?: string;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border p-5 ${
|
||||
accent ? "bg-[#1A463C] border-[#1A463C] text-white" : "bg-white border-[#e5e7eb]"
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-xs font-bold uppercase tracking-wider mb-2 ${
|
||||
accent ? "text-white/60" : "text-[#71717a]"
|
||||
}`}
|
||||
>
|
||||
<div className="bg-card p-5 rounded-xl border border-border-light shadow-card">
|
||||
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`text-2xl font-bold tracking-tight ${
|
||||
accent ? "text-white" : "text-[#1a1a1a]"
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span
|
||||
className={`text-2xl font-bold font-mono ${
|
||||
valueAccent ? "text-emerald-700 dark:text-emerald-400" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{sub && (
|
||||
<p className={`text-xs mt-1 ${accent ? "text-white/60" : "text-[#71717a]"}`}>{sub}</p>
|
||||
)}
|
||||
</span>
|
||||
{delta ? (
|
||||
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{delta}</span>
|
||||
) : sub ? (
|
||||
<span className="text-xs text-muted-foreground">{sub}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTIVITY_ICONS: Record<string, string> = {
|
||||
nuovo_cliente: "👤",
|
||||
nuovo_progetto: "📁",
|
||||
deliverable_approvato: "✅",
|
||||
timer_stoppato: "⏱",
|
||||
};
|
||||
|
||||
function fmt(ts: Date) {
|
||||
return new Intl.DateTimeFormat("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(ts));
|
||||
}
|
||||
|
||||
function fmtEur(n: number) {
|
||||
function fmtEur0(n: number) {
|
||||
return n.toLocaleString("it-IT", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function fmtSeconds(s: number): string {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export default async function AdminDashboard({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -117,175 +68,64 @@ export default async function AdminDashboard({
|
||||
const { year: yearParam } = await searchParams;
|
||||
const year = parseInt(yearParam ?? "") || new Date().getFullYear();
|
||||
|
||||
const { kpi, activity } = await getDashboardStats();
|
||||
const { kpi } = await getDashboardStats();
|
||||
|
||||
const [data, monthly, availableYears, timeByClient, totalHours] = await Promise.all([
|
||||
const [data, totalHours, profitability, forecast, offersSold] = await Promise.all([
|
||||
getAnalyticsByYear(year),
|
||||
getMonthlyCollected(year),
|
||||
getAvailableYears(),
|
||||
getTimeByClient(year),
|
||||
getTotalTrackedHours(year),
|
||||
getClientProfitability(year),
|
||||
getRevenueForecast12Months(),
|
||||
getOffersSoldBreakdown(),
|
||||
]);
|
||||
|
||||
const collectedPct =
|
||||
data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
|
||||
|
||||
const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
|
||||
|
||||
{/* Follow-up Widget */}
|
||||
<div className="mb-8">
|
||||
<Suspense fallback={<div className="animate-pulse h-40 bg-gray-200 rounded-lg" />}>
|
||||
<FollowUpWidget />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<KpiCard
|
||||
label="Clienti attivi"
|
||||
value={kpi.clientiAttivi}
|
||||
icon={Users}
|
||||
color="bg-[#1A463C]"
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* KPI strip */}
|
||||
<section className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<MetricCard
|
||||
label="Clienti Attivi"
|
||||
value={String(kpi.clientiAttivi)}
|
||||
delta={kpi.clientiNuoviMese > 0 ? `+${kpi.clientiNuoviMese} questo mese` : undefined}
|
||||
sub="Totale corrente"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Revenue totale"
|
||||
value={fmtEur(parseFloat(kpi.revenueTotale))}
|
||||
sub="progetti non archiviati"
|
||||
icon={Euro}
|
||||
color="bg-emerald-600"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Progetti in corso"
|
||||
value={kpi.progettiInCorso}
|
||||
icon={FolderOpen}
|
||||
color="bg-blue-600"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Pagamenti in sospeso"
|
||||
value={fmtEur(parseFloat(kpi.pagamentiInSospeso))}
|
||||
sub="da_saldare + inviata"
|
||||
icon={Clock}
|
||||
color="bg-amber-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Activity feed */}
|
||||
<div className="bg-white rounded-xl border border-gray-200">
|
||||
<div className="px-5 py-4 border-b border-gray-100">
|
||||
<h2 className="text-sm font-semibold text-gray-700">Attività recente</h2>
|
||||
</div>
|
||||
{activity.length === 0 ? (
|
||||
<p className="px-5 py-8 text-sm text-gray-400 text-center">Nessuna attività recente</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-50">
|
||||
{activity.map((item, i) => (
|
||||
<li key={i} className="px-5 py-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-base">{ACTIVITY_ICONS[item.type]}</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">{item.label}</p>
|
||||
<p className="text-xs text-gray-500">{item.detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 shrink-0">{fmt(item.timestamp)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── SEZIONE STATISTICHE ANNUALI ── */}
|
||||
<div className="mt-10 space-y-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Statistiche</h2>
|
||||
<p className="text-sm text-gray-400 mt-0.5">Panoramica per anno</p>
|
||||
</div>
|
||||
<YearSelector currentYear={year} availableYears={availableYears} />
|
||||
</div>
|
||||
|
||||
{/* Sezione economica */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
label="Contrattualizzato"
|
||||
value={fmtEur(data.contracted)}
|
||||
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"}`}
|
||||
accent
|
||||
value={fmtEur0(data.contracted)}
|
||||
sub={`Totale anno ${year}`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Incassato"
|
||||
value={fmtEur(data.collected)}
|
||||
sub={`${collectedPct}% del contrattualizzato`}
|
||||
label="Incassato Reale"
|
||||
value={fmtEur0(data.collected)}
|
||||
valueAccent={data.collected > 0}
|
||||
sub={`${collectedPct}% del totale`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Da incassare"
|
||||
value={fmtEur(data.pending)}
|
||||
sub="Tutti gli anni"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Clienti acquisiti"
|
||||
value={String(data.clientsAcquired)}
|
||||
sub={`Anno ${year}`}
|
||||
/>
|
||||
</div>
|
||||
<MonthlyChart data={monthly} year={year} />
|
||||
{data.contracted === 0 && (
|
||||
<p className="text-sm text-[#71717a] italic text-center py-2">
|
||||
Nessun cliente registrato nel {year}.
|
||||
</p>
|
||||
)}
|
||||
<MetricCard label="Ore Tracciate" value={`${totalHours}h`} sub="Su progetti attivi" />
|
||||
</section>
|
||||
|
||||
{/* Griglia contenuti: 2/3 + 1/3 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Colonna 2/3 */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-8">
|
||||
<ForecastChart data={forecast} headerAction={<YearSelector currentYear={year} />} />
|
||||
<ClientProfitability data={profitability} />
|
||||
</div>
|
||||
|
||||
{/* Sezione time tracking */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
|
||||
Tempo tracciato
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard label="Ore totali" value={`${totalHours}h`} sub={`Anno ${year}`} accent />
|
||||
</div>
|
||||
|
||||
{timeByClient.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-8 text-center">
|
||||
<p className="text-sm text-[#71717a] italic">
|
||||
Nessuna sessione registrata nel {year}. Usa il timer ▶ nella lista clienti per
|
||||
iniziare.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-4">
|
||||
<h3 className="text-sm font-bold text-[#1a1a1a]">Ore per cliente — {year}</h3>
|
||||
<div className="space-y-3">
|
||||
{timeByClient.map((row) => {
|
||||
const pct = Math.round((row.totalSeconds / maxClientSeconds) * 100);
|
||||
return (
|
||||
<div key={row.clientId}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-[#1a1a1a]">
|
||||
{row.clientName}
|
||||
</span>
|
||||
<span className="text-sm tabular-nums text-[#71717a]">
|
||||
{fmtSeconds(row.totalSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[#f4f4f5] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#1A463C] transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Colonna 1/3 */}
|
||||
<div className="lg:col-span-1 flex flex-col gap-8">
|
||||
<Suspense
|
||||
fallback={<div className="animate-pulse h-40 bg-muted rounded-xl" />}
|
||||
>
|
||||
<FollowUpWidget />
|
||||
</Suspense>
|
||||
<Suspense
|
||||
fallback={<div className="animate-pulse h-40 bg-muted rounded-xl" />}
|
||||
>
|
||||
<MessagesWidget />
|
||||
</Suspense>
|
||||
<OffersSoldChart data={offersSold} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getActivityLog, getUpcomingReminders, getTranscripts } from "@/lib/lead-service";
|
||||
import {
|
||||
getLeadByIdWithTags,
|
||||
getLeadFieldOptions,
|
||||
getClientIdFromLead,
|
||||
} from "@/lib/admin-queries";
|
||||
import { LeadDetail } from "@/components/admin/leads/LeadDetail";
|
||||
|
||||
export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
||||
const [lead, options, convertedClientId] = await Promise.all([
|
||||
getLeadByIdWithTags(id),
|
||||
getLeadFieldOptions(),
|
||||
getClientIdFromLead(id),
|
||||
]);
|
||||
|
||||
if (!lead) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const [activities, reminders, transcripts] = await Promise.all([
|
||||
getActivityLog(id),
|
||||
getUpcomingReminders(id),
|
||||
getTranscripts(id),
|
||||
]);
|
||||
|
||||
return (
|
||||
<LeadDetail
|
||||
lead={lead}
|
||||
activities={activities}
|
||||
reminders={reminders}
|
||||
tags={lead.tags}
|
||||
tagOptions={options.tags}
|
||||
transcripts={transcripts}
|
||||
convertedClientId={convertedClientId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { leads, quotes, tags, clientTranscripts } from "@/db/schema";
|
||||
import { leads, quotes, tags, clientTranscripts, projects } from "@/db/schema";
|
||||
import { nanoid } from "nanoid";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
@@ -12,10 +12,61 @@ import {
|
||||
LEAD_STAGES,
|
||||
} from "@/lib/lead-validators";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { createActivity, updateLeadStage } from "@/lib/lead-service";
|
||||
import { createClientCore } from "@/app/admin/clients/new/actions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
async function requireAdminSession() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
// Converts a lead into a client (reusing createClientCore): creates the client
|
||||
// + default project (linked via created_from_lead_id) + payment stubs, carries
|
||||
// over the lead's call transcripts, then archives the lead while keeping its
|
||||
// "won" status. Idempotent: a lead already converted just redirects to its
|
||||
// client. Redirects to the new client page on success.
|
||||
export async function convertLeadToClient(leadId: string): Promise<void> {
|
||||
await requireAdminSession();
|
||||
|
||||
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||
if (!lead) throw new Error("Lead non trovato");
|
||||
|
||||
// Already converted? Redirect to the linked client instead of duplicating.
|
||||
const [existing] = await db
|
||||
.select({ client_id: projects.client_id })
|
||||
.from(projects)
|
||||
.where(eq(projects.created_from_lead_id, leadId))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
redirect(`/admin/clients/${existing.client_id}`);
|
||||
}
|
||||
|
||||
const { clientId } = await createClientCore({
|
||||
name: lead.name,
|
||||
brand_name: lead.company?.trim() || lead.name,
|
||||
brief: lead.notes?.trim() || `Lead convertito: ${lead.name}`,
|
||||
email: lead.email,
|
||||
phone: lead.phone,
|
||||
created_from_lead_id: lead.id,
|
||||
});
|
||||
|
||||
// Carry over the lead's transcripts to the new client.
|
||||
await db
|
||||
.update(clientTranscripts)
|
||||
.set({ client_id: clientId, lead_id: null })
|
||||
.where(eq(clientTranscripts.lead_id, leadId));
|
||||
|
||||
// Archive the lead but keep its "won" status (traceability without clutter).
|
||||
await db.update(leads).set({ archived: true }).where(eq(leads.id, leadId));
|
||||
|
||||
revalidatePath("/admin/pipeline");
|
||||
revalidatePath("/admin");
|
||||
redirect(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function createLead(data: z.infer<typeof createLeadSchema>) {
|
||||
const parsed = createLeadSchema.safeParse(data);
|
||||
|
||||
@@ -36,7 +87,7 @@ export async function createLead(data: z.infer<typeof createLeadSchema>) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
revalidatePath("/admin/leads");
|
||||
revalidatePath("/admin/pipeline");
|
||||
return { success: true, lead };
|
||||
} catch (error) {
|
||||
console.error("createLead error:", error);
|
||||
@@ -67,8 +118,8 @@ export async function updateLead(id: string, data: z.infer<typeof updateLeadSche
|
||||
.where(eq(leads.id, id))
|
||||
.returning();
|
||||
|
||||
revalidatePath("/admin/leads");
|
||||
revalidatePath(`/admin/leads/${id}`);
|
||||
revalidatePath("/admin/pipeline");
|
||||
revalidatePath(`/admin/pipeline/${id}`);
|
||||
return { success: true, lead: updated };
|
||||
} catch (error) {
|
||||
console.error("updateLead error:", error);
|
||||
@@ -79,7 +130,7 @@ export async function updateLead(id: string, data: z.infer<typeof updateLeadSche
|
||||
export async function deleteLead(id: string) {
|
||||
try {
|
||||
await db.delete(leads).where(eq(leads.id, id));
|
||||
revalidatePath("/admin/leads");
|
||||
revalidatePath("/admin/pipeline");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("deleteLead error:", error);
|
||||
@@ -104,7 +155,7 @@ export async function logActivity(data: z.infer<typeof createActivitySchema>) {
|
||||
notes: parsed.data.notes,
|
||||
});
|
||||
|
||||
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
|
||||
revalidatePath(`/admin/pipeline/${parsed.data.lead_id}`);
|
||||
return { success: true, activity };
|
||||
} catch (error) {
|
||||
console.error("logActivity error:", error);
|
||||
@@ -154,7 +205,7 @@ export async function assignQuoteToLead(data: z.infer<typeof assignQuoteSchema>)
|
||||
// Update lead status to "proposal_sent"
|
||||
await updateLeadStage(leadId, "proposal_sent");
|
||||
|
||||
revalidatePath(`/admin/leads/${leadId}`);
|
||||
revalidatePath(`/admin/pipeline/${leadId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("assignQuoteToLead error:", error);
|
||||
@@ -201,8 +252,8 @@ export async function updateLeadField(
|
||||
.where(eq(leads.id, leadId));
|
||||
}
|
||||
|
||||
revalidatePath("/admin/leads");
|
||||
revalidatePath(`/admin/leads/${leadId}`);
|
||||
revalidatePath("/admin/pipeline");
|
||||
revalidatePath(`/admin/pipeline/${leadId}`);
|
||||
}
|
||||
|
||||
// ── Lead tags (Phase 14, CRM-09) — polymorphic `tags` table, entity_type="leads" ─
|
||||
@@ -219,8 +270,8 @@ export async function addLeadTag(leadId: string, value: string) {
|
||||
.values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed })
|
||||
.onConflictDoNothing();
|
||||
|
||||
revalidatePath("/admin/leads");
|
||||
revalidatePath(`/admin/leads/${leadId}`);
|
||||
revalidatePath("/admin/pipeline");
|
||||
revalidatePath(`/admin/pipeline/${leadId}`);
|
||||
}
|
||||
|
||||
export async function removeLeadTag(leadId: string, value: string) {
|
||||
@@ -236,8 +287,8 @@ export async function removeLeadTag(leadId: string, value: string) {
|
||||
)
|
||||
);
|
||||
|
||||
revalidatePath("/admin/leads");
|
||||
revalidatePath(`/admin/leads/${leadId}`);
|
||||
revalidatePath("/admin/pipeline");
|
||||
revalidatePath(`/admin/pipeline/${leadId}`);
|
||||
}
|
||||
|
||||
export async function renameLeadTag(oldValue: string, newValue: string) {
|
||||
@@ -251,7 +302,7 @@ export async function renameLeadTag(oldValue: string, newValue: string) {
|
||||
.set({ name: next })
|
||||
.where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.name, oldValue)));
|
||||
|
||||
revalidatePath("/admin/leads");
|
||||
revalidatePath("/admin/pipeline");
|
||||
}
|
||||
|
||||
// ── Transcript actions (Phase 20 — KB-02) ────────────────────────────────────
|
||||
@@ -283,7 +334,7 @@ export async function addTranscript(data: z.infer<typeof addTranscriptSchema>) {
|
||||
})
|
||||
.returning();
|
||||
|
||||
revalidatePath(`/admin/leads/${parsed.data.lead_id}`);
|
||||
revalidatePath(`/admin/pipeline/${parsed.data.lead_id}`);
|
||||
return { success: true, transcript };
|
||||
} catch (error) {
|
||||
console.error("addTranscript error:", error);
|
||||
@@ -301,7 +352,7 @@ export async function deleteTranscript(transcriptId: string, leadId: string) {
|
||||
.delete(clientTranscripts)
|
||||
.where(eq(clientTranscripts.id, transcriptId));
|
||||
|
||||
revalidatePath(`/admin/leads/${leadId}`);
|
||||
revalidatePath(`/admin/pipeline/${leadId}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("deleteTranscript error:", error);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
|
||||
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
|
||||
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function LeadsPage() {
|
||||
const [leads, options] = await Promise.all([
|
||||
getLeadsWithTags(),
|
||||
getLeadFieldOptions(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Lead Pipeline"
|
||||
subtitle="Gestisci e monitora i tuoi contatti commerciali"
|
||||
action={<CreateLeadModal />}
|
||||
/>
|
||||
<LeadsViewToggle leads={leads} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,7 +62,17 @@ export async function generateProposalDraft(formData: FormData) {
|
||||
.orderBy(desc(clientTranscripts.call_date));
|
||||
}
|
||||
|
||||
const MODEL = "claude-opus-4-8";
|
||||
const MODEL_IDS = {
|
||||
opus: "claude-opus-4-8",
|
||||
sonnet: "claude-sonnet-4-6",
|
||||
} as const;
|
||||
|
||||
const modelKey = (() => {
|
||||
const raw = formData.get("model");
|
||||
if (raw === "opus" || raw === "sonnet") return raw;
|
||||
return "opus" as const;
|
||||
})();
|
||||
const resolvedModel = MODEL_IDS[modelKey];
|
||||
|
||||
// Chiama agente AI
|
||||
const { content: aiContent } = await generateProposalContent({
|
||||
@@ -70,6 +80,7 @@ export async function generateProposalDraft(formData: FormData) {
|
||||
client,
|
||||
transcripts,
|
||||
offer,
|
||||
model: resolvedModel,
|
||||
});
|
||||
|
||||
// Assembla contenuto completo
|
||||
@@ -78,7 +89,7 @@ export async function generateProposalDraft(formData: FormData) {
|
||||
client,
|
||||
offer,
|
||||
aiContent,
|
||||
model: MODEL,
|
||||
model: resolvedModel,
|
||||
});
|
||||
|
||||
// Salva nel DB
|
||||
@@ -94,7 +105,7 @@ export async function generateProposalDraft(formData: FormData) {
|
||||
offer_macro_id: offerMacroId,
|
||||
title,
|
||||
content: assembled as unknown as Record<string, unknown>,
|
||||
model: MODEL,
|
||||
model: resolvedModel,
|
||||
state: "draft",
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
|
||||
const [subjectType, setSubjectType] = useState<"lead" | "client">(
|
||||
preselectedLeadId ? "lead" : "lead"
|
||||
);
|
||||
const [model, setModel] = useState<"opus" | "sonnet">("opus");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -25,6 +26,8 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
|
||||
const form = e.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
|
||||
formData.append("model", model);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await action(formData);
|
||||
@@ -65,6 +68,35 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modello AI */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Modello AI</label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModel("opus")}
|
||||
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
|
||||
model === "opus"
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
Opus
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModel("sonnet")}
|
||||
className={`px-4 py-2 rounded-md text-sm border transition-colors ${
|
||||
model === "sonnet"
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
Sonnet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Select lead o cliente */}
|
||||
{subjectType === "lead" ? (
|
||||
<div className="space-y-1">
|
||||
@@ -135,7 +167,7 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
|
||||
|
||||
{/* Info generazione */}
|
||||
<div className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2">
|
||||
L'AI (Claude Opus) legge i transcript del soggetto selezionato e genera una bozza
|
||||
L'AI (Claude {model === "opus" ? "Opus" : "Sonnet"}) legge i transcript del soggetto selezionato e genera una bozza
|
||||
personalizzata. La generazione richiede 30–60 secondi.
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { listProposals } from "@/lib/proposal/queries";
|
||||
import { FileText, Plus } from "lucide-react";
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
@@ -14,47 +15,46 @@ const STATE_LABELS: Record<string, { label: string; className: string }> = {
|
||||
export default async function PreventiviPage() {
|
||||
const proposals = await listProposals();
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Preventivi</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Preventivi generati con AI e pubblicati ai clienti
|
||||
</p>
|
||||
</div>
|
||||
const preventiviAction = (
|
||||
<Link
|
||||
href="/admin/preventivi/genera"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[#1A463C] text-white rounded-md text-sm font-medium hover:bg-[#163a31] transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Genera preventivo
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Preventivi"
|
||||
subtitle="Preventivi generati con AI e pubblicati ai clienti"
|
||||
action={preventiviAction}
|
||||
/>
|
||||
|
||||
{/* Lista */}
|
||||
{proposals.length === 0 ? (
|
||||
<div className="text-center py-20 border border-dashed border-border rounded-lg">
|
||||
<FileText size={40} className="mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-sm">Nessun preventivo ancora.</p>
|
||||
<div className="text-center py-20 border border-dashed border-[#e5e7eb] rounded-lg">
|
||||
<FileText size={40} className="mx-auto text-[#71717a] mb-4" />
|
||||
<p className="text-[#71717a] text-sm">Nessun preventivo ancora.</p>
|
||||
<Link
|
||||
href="/admin/preventivi/genera"
|
||||
className="mt-4 inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
className="mt-4 inline-flex items-center gap-2 text-sm text-[#1A463C] hover:underline"
|
||||
>
|
||||
Genera il primo preventivo →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="border border-[#e5e7eb] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted border-b border-border">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Titolo</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Lead / Cliente</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Offerta</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Stato</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Data</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Titolo</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Lead / Cliente</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Offerta</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Stato</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Data</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -63,20 +63,20 @@ export default async function PreventiviPage() {
|
||||
const stateInfo = STATE_LABELS[p.state] ?? { label: p.state, className: "bg-gray-100 text-gray-700" };
|
||||
const subject = p.clientName ?? p.leadName ?? "—";
|
||||
return (
|
||||
<tr key={p.id} className="border-b border-border last:border-0 hover:bg-muted/50 transition-colors">
|
||||
<tr key={p.id} className="border-b border-[#e5e7eb] last:border-0 hover:bg-[#f9f9f9] transition-colors">
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<Link href={`/admin/preventivi/${p.id}`} className="hover:text-primary transition-colors">
|
||||
<Link href={`/admin/preventivi/${p.id}`} className="hover:text-[#1A463C] transition-colors">
|
||||
{p.title ?? "Senza titolo"}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{subject}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{p.offerName}</td>
|
||||
<td className="px-4 py-3 text-[#71717a]">{subject}</td>
|
||||
<td className="px-4 py-3 text-[#71717a]">{p.offerName}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${stateInfo.className}`}>
|
||||
{stateInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
<td className="px-4 py-3 text-[#71717a]">
|
||||
{p.createdAt.toLocaleDateString("it-IT")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
@@ -85,7 +85,7 @@ export default async function PreventiviPage() {
|
||||
href={`/preventivo/${p.slug}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline"
|
||||
className="text-xs text-[#1A463C] hover:underline"
|
||||
>
|
||||
Apri →
|
||||
</a>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getProjectFullDetail } from "@/lib/admin-queries";
|
||||
import { getProjectFullDetail, getRecentTimeEntries } from "@/lib/admin-queries";
|
||||
import { getTargetHourlyRate } from "@/lib/settings";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
||||
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
|
||||
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
||||
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
||||
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||
@@ -27,6 +26,8 @@ export default async function ProjectDetailPage({
|
||||
|
||||
if (!detail) notFound();
|
||||
|
||||
const recentEntries = await getRecentTimeEntries(id, 5);
|
||||
|
||||
const {
|
||||
project,
|
||||
phases,
|
||||
@@ -34,13 +35,13 @@ export default async function ProjectDetailPage({
|
||||
documents,
|
||||
notes,
|
||||
comments,
|
||||
quoteItems,
|
||||
activeServices,
|
||||
activeTimerEntryId,
|
||||
activeTimerStartedAt,
|
||||
totalTrackedSeconds,
|
||||
projectOffers,
|
||||
availableMicros,
|
||||
offersAcceptedTotal,
|
||||
transcripts,
|
||||
} = detail;
|
||||
|
||||
return (
|
||||
@@ -72,7 +73,6 @@ export default async function ProjectDetailPage({
|
||||
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||
<TabsTrigger value="notes">Note</TabsTrigger>
|
||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||
<TabsTrigger value="quote">Preventivo</TabsTrigger>
|
||||
<TabsTrigger value="timer">Timer</TabsTrigger>
|
||||
<TabsTrigger value="offers">Offerte</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -91,11 +91,12 @@ export default async function ProjectDetailPage({
|
||||
acceptedTotal={project.accepted_total ?? "0"}
|
||||
clientId={id}
|
||||
projectId={id}
|
||||
offersAcceptedTotal={offersAcceptedTotal}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documents">
|
||||
<DocumentsTab documents={documents} clientId={id} />
|
||||
<DocumentsTab documents={documents} clientId={id} transcripts={transcripts} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes">
|
||||
@@ -118,15 +119,6 @@ export default async function ProjectDetailPage({
|
||||
<CommentsTab comments={comments} phases={phases} clientId={id} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="quote">
|
||||
<QuoteTab
|
||||
clientId={id}
|
||||
items={quoteItems}
|
||||
activeServices={activeServices}
|
||||
acceptedTotal={project.accepted_total ?? "0"}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="timer">
|
||||
<TimerTab
|
||||
projectId={id}
|
||||
@@ -135,6 +127,7 @@ export default async function ProjectDetailPage({
|
||||
activeTimerStartedAt={activeTimerStartedAt}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
targetHourlyRate={targetHourlyRate}
|
||||
recentEntries={recentEntries}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { getAllProjectsWithPayments } from "@/lib/admin-queries";
|
||||
import { ProjectRow } from "@/components/admin/ProjectRow";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
const projects = await getAllProjectsWithPayments();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Progetti</h1>
|
||||
const projectiAction = (
|
||||
<Link
|
||||
href="/admin/projects/new"
|
||||
className="text-sm bg-[#1A463C] text-white px-4 py-2 rounded-lg hover:bg-[#1A463C]/90 transition-colors"
|
||||
>
|
||||
+ Nuovo Progetto
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Progetti" action={projectiAction} />
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-12 text-center">
|
||||
|
||||
@@ -4,8 +4,17 @@ import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { db } from "@/db";
|
||||
import { projects, clients, payments, project_offers } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
projects,
|
||||
clients,
|
||||
payments,
|
||||
project_offers,
|
||||
offer_tier_services,
|
||||
services,
|
||||
phases,
|
||||
tasks,
|
||||
} from "@/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
@@ -56,6 +65,7 @@ export async function updateProjectAcceptedTotal(projectId: string, acceptedTota
|
||||
}
|
||||
|
||||
// Creates Acconto 50% + Saldo 50% stubs for a project that has no payments yet.
|
||||
// Kept for backward compatibility; prefer setPaymentPlan for new code.
|
||||
export async function initProjectPayments(projectId: string): Promise<void> {
|
||||
await requireAdmin();
|
||||
const rows = await db
|
||||
@@ -65,11 +75,50 @@ export async function initProjectPayments(projectId: string): Promise<void> {
|
||||
.limit(1);
|
||||
if (!rows[0]) throw new Error("Progetto non trovato");
|
||||
const total = parseFloat(rows[0].accepted_total ?? "0");
|
||||
const half = (total / 2).toFixed(2);
|
||||
await db.insert(payments).values([
|
||||
{ project_id: projectId, label: "Acconto 50%", amount: half, status: "da_saldare" },
|
||||
{ project_id: projectId, label: "Saldo 50%", amount: half, status: "da_saldare" },
|
||||
]);
|
||||
await setPaymentPlan(projectId, "two", total);
|
||||
}
|
||||
|
||||
// Replaces all payments for a project with a fresh plan at a given total.
|
||||
// Modes:
|
||||
// single → 1 row 100% "Pagamento unico (100%)"
|
||||
// two → 2 rows 50%/50% "Acconto 50% (inizio lavori)" / "Saldo 50% (alla consegna)"
|
||||
// three → 3 rows 50%/30%/20%
|
||||
export async function setPaymentPlan(
|
||||
projectId: string,
|
||||
mode: "single" | "two" | "three",
|
||||
total: number
|
||||
): Promise<void> {
|
||||
await requireAdmin();
|
||||
|
||||
type PaymentDef = { label: string; percent: number };
|
||||
const plans: Record<string, PaymentDef[]> = {
|
||||
single: [{ label: "Pagamento unico (100%)", percent: 100 }],
|
||||
two: [
|
||||
{ label: "Acconto 50% (inizio lavori)", percent: 50 },
|
||||
{ label: "Saldo 50% (alla consegna)", percent: 50 },
|
||||
],
|
||||
three: [
|
||||
{ label: "Acconto 50% (inizio lavori)", percent: 50 },
|
||||
{ label: "30% (post revisioni)", percent: 30 },
|
||||
{ label: "Saldo 20% (alla consegna)", percent: 20 },
|
||||
],
|
||||
};
|
||||
|
||||
const plan = plans[mode];
|
||||
if (!plan) throw new Error("Modalità pagamento non valida");
|
||||
|
||||
// Delete existing payments for this project, then insert new ones.
|
||||
await db.delete(payments).where(eq(payments.project_id, projectId));
|
||||
await db.insert(payments).values(
|
||||
plan.map((p) => ({
|
||||
project_id: projectId,
|
||||
label: p.label,
|
||||
percent: p.percent.toFixed(2),
|
||||
amount: ((total * p.percent) / 100).toFixed(2),
|
||||
status: "da_saldare" as const,
|
||||
}))
|
||||
);
|
||||
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
}
|
||||
|
||||
@@ -110,12 +159,94 @@ export async function splitPayment(
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
}
|
||||
|
||||
// ── Offer → project phases/tasks import ───────────────────────────────────────
|
||||
// An offer/tier has no phases of its own; it carries a flat list of services,
|
||||
// and each service has a `fase` (catalog taxonomy). We materialize project
|
||||
// phases by grouping the tier's services by `services.fase` (one phase per
|
||||
// distinct fase, one task per service). Idempotent + mergeable: existing phases
|
||||
// are matched by title (case-insensitive) and reused, duplicate tasks skipped —
|
||||
// so assigning a 2nd offer with shared fasi adds tasks without duplicating.
|
||||
|
||||
const NO_FASE_LABEL = "Generale";
|
||||
|
||||
export async function importOfferIntoProject(projectId: string, microId: string): Promise<void> {
|
||||
await requireAdmin();
|
||||
|
||||
// Tier services with their catalog fase, ordered (null fase last, then name).
|
||||
const rows = await db
|
||||
.select({ name: services.name, fase: services.fase })
|
||||
.from(offer_tier_services)
|
||||
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
|
||||
.where(eq(offer_tier_services.tier_id, microId))
|
||||
.orderBy(asc(services.fase), asc(services.name));
|
||||
|
||||
if (rows.length === 0) return;
|
||||
|
||||
// Group service names by fase, preserving first-seen order.
|
||||
const groups: Array<{ fase: string; serviceNames: string[] }> = [];
|
||||
const groupIndex = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
const fase = r.fase?.trim() || NO_FASE_LABEL;
|
||||
let idx = groupIndex.get(fase.toLowerCase());
|
||||
if (idx === undefined) {
|
||||
idx = groups.length;
|
||||
groupIndex.set(fase.toLowerCase(), idx);
|
||||
groups.push({ fase, serviceNames: [] });
|
||||
}
|
||||
groups[idx].serviceNames.push(r.name);
|
||||
}
|
||||
|
||||
// Existing project phases (for merge-by-title) + current max sort_order.
|
||||
const existingPhases = await db
|
||||
.select({ id: phases.id, title: phases.title, sort_order: phases.sort_order })
|
||||
.from(phases)
|
||||
.where(eq(phases.project_id, projectId));
|
||||
const phaseByTitle = new Map(existingPhases.map((p) => [p.title.trim().toLowerCase(), p]));
|
||||
let maxPhaseOrder = existingPhases.reduce((m, p) => Math.max(m, p.sort_order), -1);
|
||||
|
||||
for (const group of groups) {
|
||||
let phaseId: string;
|
||||
const existing = phaseByTitle.get(group.fase.toLowerCase());
|
||||
if (existing) {
|
||||
phaseId = existing.id;
|
||||
} else {
|
||||
maxPhaseOrder += 1;
|
||||
const [inserted] = await db
|
||||
.insert(phases)
|
||||
.values({ project_id: projectId, title: group.fase, sort_order: maxPhaseOrder, status: "upcoming" })
|
||||
.returning({ id: phases.id });
|
||||
phaseId = inserted.id;
|
||||
phaseByTitle.set(group.fase.toLowerCase(), { id: phaseId, title: group.fase, sort_order: maxPhaseOrder });
|
||||
}
|
||||
|
||||
// Skip tasks whose title already exists in this phase.
|
||||
const existingTasks = await db
|
||||
.select({ title: tasks.title, sort_order: tasks.sort_order })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.phase_id, phaseId));
|
||||
const taskTitles = new Set(existingTasks.map((t) => t.title.trim().toLowerCase()));
|
||||
let maxTaskOrder = existingTasks.reduce((m, t) => Math.max(m, t.sort_order), -1);
|
||||
|
||||
const toInsert = group.serviceNames
|
||||
.filter((name) => !taskTitles.has(name.trim().toLowerCase()))
|
||||
.map((name) => {
|
||||
taskTitles.add(name.trim().toLowerCase());
|
||||
maxTaskOrder += 1;
|
||||
return { phase_id: phaseId, title: name, status: "todo", sort_order: maxTaskOrder };
|
||||
});
|
||||
if (toInsert.length > 0) await db.insert(tasks).values(toInsert);
|
||||
}
|
||||
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
}
|
||||
|
||||
// ── Offer assignment actions ──────────────────────────────────────────────────
|
||||
|
||||
const assignOfferSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
micro_id: z.string().min(1, "Seleziona una micro-offerta"),
|
||||
micro_id: z.string().min(1, "Seleziona un'offerta"),
|
||||
accepted_total: z.coerce.number().min(0).optional(),
|
||||
import_phases: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function assignOfferToProject(formData: FormData) {
|
||||
@@ -124,6 +255,7 @@ export async function assignOfferToProject(formData: FormData) {
|
||||
project_id: formData.get("project_id"),
|
||||
micro_id: formData.get("micro_id"),
|
||||
accepted_total: formData.get("accepted_total") || undefined,
|
||||
import_phases: formData.get("import_phases") === "on",
|
||||
});
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
await db.insert(project_offers).values({
|
||||
@@ -134,6 +266,10 @@ export async function assignOfferToProject(formData: FormData) {
|
||||
? parsed.data.accepted_total.toFixed(2)
|
||||
: null,
|
||||
});
|
||||
// Materialize project phases/tasks from the tier's services grouped by fase.
|
||||
if (parsed.data.import_phases) {
|
||||
await importOfferIntoProject(parsed.data.project_id, parsed.data.micro_id);
|
||||
}
|
||||
revalidatePath(`/admin/projects/${parsed.data.project_id}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function startTimerForClient(clientId: string): Promise<{ entryId:
|
||||
export async function stopTimer(entryId: string): Promise<void> {
|
||||
await requireAdmin();
|
||||
const rows = await db
|
||||
.select({ started_at: time_entries.started_at })
|
||||
.select({ started_at: time_entries.started_at, project_id: time_entries.project_id })
|
||||
.from(time_entries)
|
||||
.where(eq(time_entries.id, entryId))
|
||||
.limit(1);
|
||||
@@ -77,4 +77,56 @@ export async function stopTimer(entryId: string): Promise<void> {
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/admin/projects");
|
||||
revalidatePath(`/admin/projects/${rows[0].project_id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a manually entered time block (already closed).
|
||||
* duration_seconds = minutes * 60
|
||||
* ended_at = started_at + duration_seconds
|
||||
* @param projectId project to record time against
|
||||
* @param minutes duration in minutes (must be > 0)
|
||||
* @param startedAt optional ISO date string — defaults to start of current day
|
||||
*/
|
||||
export async function addManualTimeEntry(
|
||||
projectId: string,
|
||||
minutes: number,
|
||||
startedAt?: string
|
||||
): Promise<void> {
|
||||
await requireAdmin();
|
||||
if (!Number.isFinite(minutes) || minutes <= 0) throw new Error("Minuti non validi");
|
||||
|
||||
const start = startedAt ? new Date(startedAt) : new Date();
|
||||
if (isNaN(start.getTime())) throw new Error("Data non valida");
|
||||
|
||||
const durationSeconds = Math.round(minutes * 60);
|
||||
const end = new Date(start.getTime() + durationSeconds * 1000);
|
||||
|
||||
await db.insert(time_entries).values({
|
||||
id: nanoid(),
|
||||
project_id: projectId,
|
||||
started_at: start,
|
||||
ended_at: end,
|
||||
duration_seconds: durationSeconds,
|
||||
});
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/admin/projects");
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a time entry by id, scoped to the given project (guard against
|
||||
* cross-project deletions).
|
||||
*/
|
||||
export async function deleteTimeEntry(entryId: string, projectId: string): Promise<void> {
|
||||
await requireAdmin();
|
||||
await db
|
||||
.delete(time_entries)
|
||||
.where(eq(time_entries.id, entryId));
|
||||
|
||||
revalidatePath("/admin");
|
||||
revalidatePath("/admin/projects");
|
||||
revalidatePath(`/admin/projects/${projectId}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const commentSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
entity_type: z.enum(["task", "deliverable", "general"]),
|
||||
entity_type: z.enum(["task", "deliverable", "general", "phase"]),
|
||||
entity_id: z.string().min(1),
|
||||
body: z.string().min(1, "Il commento non può essere vuoto").max(2000),
|
||||
});
|
||||
@@ -76,7 +76,12 @@ export async function POST(request: NextRequest) {
|
||||
.from(tasks)
|
||||
.where(inArray(tasks.phase_id, phaseIds));
|
||||
|
||||
if (entity_type === "task") {
|
||||
if (entity_type === "phase") {
|
||||
// Phase: entity_id must be one of the client's phases
|
||||
if (!phasesForClient.find((p) => p.id === entity_id)) {
|
||||
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
||||
}
|
||||
} else if (entity_type === "task") {
|
||||
if (!taskRows.find((r) => r.id === entity_id)) {
|
||||
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -67,6 +67,13 @@ function projectViewToClientView(
|
||||
})),
|
||||
global_progress_pct: view.global_progress_pct,
|
||||
activeOffers: view.activeOffers,
|
||||
transcripts: view.transcripts.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
call_date: t.call_date,
|
||||
content: t.content,
|
||||
created_at: t.created_at instanceof Date ? t.created_at.toISOString() : String(t.created_at),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,10 +106,10 @@ export default async function ClientPage({
|
||||
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f9f9f9] flex items-center justify-center">
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-bold text-[#1a1a1a]">{client.name}</h1>
|
||||
<p className="text-sm text-[#71717a] mt-2">Nessun progetto disponibile al momento.</p>
|
||||
<h1 className="text-xl font-bold text-foreground">{client.name}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">Nessun progetto disponibile al momento.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -125,22 +132,23 @@ export default async function ClientPage({
|
||||
const projectViews = await Promise.all(projects.map((p) => getProjectView(p.id)));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-5">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0">
|
||||
iamcavalli
|
||||
</span>
|
||||
<h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight">
|
||||
{client.brand_name}
|
||||
</h1>
|
||||
<div className="w-28 shrink-0" />
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="sticky top-0 z-50 flex flex-col items-center gap-4 border-b border-border-light bg-card px-6 py-5 shadow-card md:flex-row md:justify-between md:px-8">
|
||||
<div className="flex w-full items-center gap-3 md:w-auto">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">iamcavalli</span>
|
||||
<span className="text-border">|</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">Client Portal</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-foreground">{client.brand_name}</h1>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-[11px] text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-400 md:flex">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
Area Riservata Protetta
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 py-8">
|
||||
<Tabs defaultValue={projects[0].id} className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
{projects.map((p) => (
|
||||
@@ -159,9 +167,10 @@ export default async function ClientPage({
|
||||
view={projectViewToClientView(client, view)}
|
||||
token={client.token}
|
||||
comments={view.comments as unknown as Comment[]}
|
||||
embedded
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-[#71717a]">Progetto non disponibile.</p>
|
||||
<p className="text-sm text-muted-foreground">Progetto non disponibile.</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
);
|
||||
@@ -169,12 +178,8 @@ export default async function ClientPage({
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-[#e5e5e5] bg-[#f9f9f9] mt-10">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
|
||||
<p className="text-xs text-[#999999] text-center">
|
||||
<footer className="mt-10 py-10 text-center text-xs text-muted-foreground">
|
||||
Questa è la tua dashboard privata — non condividere il link.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 28 KiB |
+174
-40
@@ -1,64 +1,164 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "tailwindcss-animate";
|
||||
@source not "../../.01_projects/**";
|
||||
@source not "../../.claude/**";
|
||||
@source not "../../scripts/**";
|
||||
@source not "../../.planning/**";
|
||||
|
||||
/* Class-based dark mode (Tailwind v4). The FOUC guard in layout.tsx toggles
|
||||
`.dark` on <html> before paint; the useTheme hook keeps it in sync. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
/* =========================================================
|
||||
Design tokens — iamcavalli brand palette (Tailwind v4 @theme)
|
||||
Design tokens — iamcavalli brand palette
|
||||
Raw values live in :root (light) / .dark (dark) so the whole
|
||||
app themes off a single class toggle; @theme inline below maps
|
||||
them to Tailwind color utilities (bg-background, text-primary…).
|
||||
========================================================= */
|
||||
:root {
|
||||
/* Base — sfondo generale app #F8F9FA (color-bg-light), testo slate-900 */
|
||||
--background: #F8F9FA;
|
||||
--foreground: #0F172A;
|
||||
|
||||
/* Brand primary — verde scuro iamcavalli */
|
||||
--primary: #1A463C;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
/* Brand accent — giallo lime */
|
||||
--accent: #DEF168;
|
||||
--accent-foreground: #1A463C;
|
||||
|
||||
/* Secondary — grigio neutro (slate-100) */
|
||||
--secondary: #f1f5f9;
|
||||
--secondary-foreground: #0F172A;
|
||||
|
||||
/* Muted — sfondi secondari (slate-50) / testo slate-500 */
|
||||
--muted: #f8fafc;
|
||||
--muted-foreground: #64748B;
|
||||
|
||||
/* Destructive */
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
/* Card — bianco puro (color-bg-card) */
|
||||
--card: #FFFFFF;
|
||||
--card-foreground: #0F172A;
|
||||
|
||||
/* Popover */
|
||||
--popover: #FFFFFF;
|
||||
--popover-foreground: #0F172A;
|
||||
|
||||
/* Border (slate-200 = border-card) / Input / Ring */
|
||||
--border: #e2e8f0;
|
||||
--input: #e2e8f0;
|
||||
--ring: #1A463C;
|
||||
|
||||
/* Semantic — stato task/pagamenti */
|
||||
--success: #16a34a;
|
||||
--warning: #ca8a04;
|
||||
--info: #2563eb;
|
||||
|
||||
/* Legacy — text-light slate-400, border-soft slate-100 */
|
||||
--tertiary: #94A3B8;
|
||||
--bg-subtle: #f8fafc;
|
||||
--border-light: #f1f5f9;
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Base — verde-nero, coerente col brand */
|
||||
--background: #0e1512;
|
||||
--foreground: #f2f4f3;
|
||||
|
||||
/* Brand primary — verde schiarito per contrasto su fondo scuro */
|
||||
--primary: #3FA88C;
|
||||
--primary-foreground: #06110d;
|
||||
|
||||
/* Brand accent — lime invariato, testo verde scuro */
|
||||
--accent: #DEF168;
|
||||
--accent-foreground: #14231d;
|
||||
|
||||
--secondary: #1b2420;
|
||||
--secondary-foreground: #f2f4f3;
|
||||
|
||||
--muted: #171f1b;
|
||||
--muted-foreground: #9aa39e;
|
||||
|
||||
--destructive: #f87171;
|
||||
--destructive-foreground: #1a0a0a;
|
||||
|
||||
--card: #131a16;
|
||||
--card-foreground: #f2f4f3;
|
||||
|
||||
--popover: #131a16;
|
||||
--popover-foreground: #f2f4f3;
|
||||
|
||||
--border: #26302b;
|
||||
--input: #26302b;
|
||||
--ring: #3FA88C;
|
||||
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--info: #3b82f6;
|
||||
|
||||
--tertiary: #8a938d;
|
||||
--bg-subtle: #171f1b;
|
||||
--border-light: #26302b;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Font */
|
||||
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont,
|
||||
--font-sans: var(--font-plus-jakarta-sans), system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
|
||||
/* Base */
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #1a1a1a;
|
||||
/* Colors — mapped from the raw tokens above */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
/* Brand primary — verde scuro iamcavalli */
|
||||
--color-primary: #1A463C;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
/* Brand accent — giallo lime */
|
||||
--color-accent: #DEF168;
|
||||
--color-accent-foreground: #1A463C;
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
/* Secondary — grigio neutro */
|
||||
--color-secondary: #f4f4f5;
|
||||
--color-secondary-foreground: #1a1a1a;
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
/* Muted — per testi e sfondi secondari */
|
||||
--color-muted: #f9f9f9;
|
||||
--color-muted-foreground: #71717a;
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
/* Destructive */
|
||||
--color-destructive: #ef4444;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
/* Card */
|
||||
--color-card: #ffffff;
|
||||
--color-card-foreground: #1a1a1a;
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
/* Popover */
|
||||
--color-popover: #ffffff;
|
||||
--color-popover-foreground: #1a1a1a;
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
/* Border / Input / Ring */
|
||||
--color-border: #e5e7eb;
|
||||
--color-input: #e5e7eb;
|
||||
--color-ring: #1A463C;
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Semantic — stato task/pagamenti */
|
||||
--color-success: #16a34a;
|
||||
--color-warning: #ca8a04;
|
||||
--color-info: #2563eb;
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-info: var(--info);
|
||||
|
||||
/* Legacy — usati inline nei componenti esistenti */
|
||||
--color-tertiary: #999999;
|
||||
--color-bg-subtle: #f9f9f9;
|
||||
--color-border-light: #e5e5e5;
|
||||
--color-tertiary: var(--tertiary);
|
||||
--color-bg-subtle: var(--bg-subtle);
|
||||
--color-border-light: var(--border-light);
|
||||
|
||||
/* Radius — wire the base --radius into Tailwind's radius scale so
|
||||
rounded-lg / rounded-xl resolve off a single token. */
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 0.25rem);
|
||||
|
||||
/* Luxury shadows — very low-opacity, wide-spread card elevation
|
||||
("Quiet Luxury" design system). */
|
||||
--shadow-card: 0 4px 20px rgba(0, 0, 0, 0.01);
|
||||
--shadow-card-hover: 0 8px 30px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
@@ -75,10 +175,44 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: #171717;
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Scrollbar utilities (ported from OMC design system)
|
||||
========================================================= */
|
||||
@layer utilities {
|
||||
/* Hide the native scrollbar without breaking scroll. */
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Very thin, muted scrollbar — discoverable but low-noise. */
|
||||
.thin-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
.thin-scrollbar::-webkit-scrollbar {
|
||||
height: 3px;
|
||||
width: 3px;
|
||||
}
|
||||
.thin-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.thin-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
+11
-5
@@ -1,10 +1,11 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Plus_Jakarta_Sans, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
const plusJakartaSans = Plus_Jakarta_Sans({
|
||||
variable: "--font-plus-jakarta-sans",
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
@@ -30,9 +31,14 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="it"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
className={`${plusJakartaSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col bg-white text-zinc-900">
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground">
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var k='iamcavalli:theme';var p=localStorage.getItem(k);var d=p==='dark'||((p==null||p==='system')&&window.matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.classList.toggle('dark',d);}catch(e){}})();`,
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Check, Monitor, Moon, Sun } from 'lucide-react'
|
||||
|
||||
import { useTheme, type ThemePref } from '@/lib/theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const OPTIONS: { value: ThemePref; label: string; icon: typeof Sun }[] = [
|
||||
{ value: 'system', label: 'Sistema', icon: Monitor },
|
||||
{ value: 'light', label: 'Chiaro', icon: Sun },
|
||||
{ value: 'dark', label: 'Scuro', icon: Moon },
|
||||
]
|
||||
|
||||
/** Header theme switcher — Sistema / Chiaro / Scuro. Defaults to Sistema
|
||||
* (follows the OS). Dropdown closes on outside-click + Esc. */
|
||||
export default function ThemeToggle() {
|
||||
const { pref, resolved, setTheme } = useTheme()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick)
|
||||
document.addEventListener('keydown', onEsc)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick)
|
||||
document.removeEventListener('keydown', onEsc)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const TriggerIcon = pref === 'system' ? Monitor : resolved === 'dark' ? Moon : Sun
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title="Tema"
|
||||
aria-label="Tema"
|
||||
className="flex items-center justify-center size-9 rounded-full border bg-card/70 hover:bg-card transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<TriggerIcon className="size-4" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full right-0 mt-2 w-40 bg-popover text-popover-foreground border rounded-xl shadow-xl overflow-hidden z-50 p-1">
|
||||
{OPTIONS.map((o) => {
|
||||
const Icon = o.icon
|
||||
const active = o.value === pref
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTheme(o.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-2.5 py-2 rounded-md text-sm transition-colors text-left',
|
||||
active ? 'bg-primary/10 text-primary' : 'hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{o.label}
|
||||
{active && <Check className="size-3.5 ml-auto" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Menu, User } from "lucide-react";
|
||||
import { AdminSidebar } from "@/components/admin/AdminSidebar";
|
||||
|
||||
const SIDEBAR_STORAGE_KEY = "iamcavalli:sidebar";
|
||||
|
||||
/**
|
||||
* Client shell for every /admin/* page: collapsible sidebar + top header +
|
||||
* <main>. Collapse state is persisted to localStorage and read on mount
|
||||
* (starts expanded on server/first paint to avoid a hydration mismatch,
|
||||
* then reconciles client-side — mirrors the pattern used by useTheme).
|
||||
*/
|
||||
export function AdminShell({
|
||||
children,
|
||||
unreadConversations = 0,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
unreadConversations?: number;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(SIDEBAR_STORAGE_KEY);
|
||||
if (stored === "collapsed") setCollapsed(true);
|
||||
}, []);
|
||||
|
||||
function toggleCollapsed() {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
window.localStorage.setItem(SIDEBAR_STORAGE_KEY, next ? "collapsed" : "expanded");
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<AdminSidebar collapsed={collapsed} unreadConversations={unreadConversations} />
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Top header bar */}
|
||||
<header className="h-16 shrink-0 bg-card border-b border-border px-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCollapsed}
|
||||
title="Espandi/Comprimi Sidebar"
|
||||
aria-label="Espandi/Comprimi Sidebar"
|
||||
className="p-2 -ml-2 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus:outline-none"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-muted-foreground">Area di Lavoro</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-semibold text-foreground">Admin</span>
|
||||
{/* TODO: gestione utenti/collaboratori — placeholder avatar until
|
||||
real user accounts + uploaded profile images are wired up. */}
|
||||
<div className="w-8 h-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center shrink-0">
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-[1400px] w-full mx-auto px-8 py-8 lg:px-10">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,12 +13,16 @@ import {
|
||||
LogOut,
|
||||
Zap,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||
{ href: "/admin/conversazioni", label: "Conversazioni", icon: MessageSquare },
|
||||
{ href: "/admin/pipeline", label: "Pipeline", icon: Zap },
|
||||
{ href: "/admin/clients", label: "Clienti", icon: Users },
|
||||
{ href: "/admin/leads", label: "Lead", icon: Zap },
|
||||
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
|
||||
{ href: "/admin/preventivi", label: "Preventivi", icon: FileText },
|
||||
{ href: "/admin/offers", label: "Offerte", icon: Tag },
|
||||
@@ -26,59 +30,108 @@ const NAV_ITEMS = [
|
||||
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
|
||||
];
|
||||
|
||||
export function AdminSidebar() {
|
||||
export function AdminSidebar({
|
||||
collapsed = false,
|
||||
unreadConversations = 0,
|
||||
}: {
|
||||
collapsed?: boolean;
|
||||
unreadConversations?: number;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (href: string, exact?: boolean) =>
|
||||
exact ? pathname === href : pathname === href || pathname.startsWith(href + "/");
|
||||
|
||||
return (
|
||||
<aside className="w-56 shrink-0 min-h-screen bg-[#1A463C] flex flex-col">
|
||||
{/* Logo */}
|
||||
<div className="px-5 py-5 border-b border-white/10">
|
||||
<span className="font-bold text-white tracking-tight text-sm">iamcavalli</span>
|
||||
</div>
|
||||
// Shared row layout: full width + gap + label when expanded, icon centered
|
||||
// (no gap, no padding) when collapsed so nothing pushes the icon off-center.
|
||||
const rowClass = collapsed ? "justify-center px-0" : "gap-3 px-3";
|
||||
|
||||
{/* CTA globale — genera preventivo */}
|
||||
<div className="px-3 py-3 border-b border-white/10">
|
||||
<Link
|
||||
href="/admin/preventivi/genera"
|
||||
className="flex items-center justify-center gap-2 w-full px-3 py-2 rounded-md text-xs font-semibold bg-[#DEF168] text-[#1A463C] hover:bg-[#d4e85e] transition-colors"
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"shrink-0 min-h-screen bg-[#1A463C] flex flex-col overflow-hidden",
|
||||
"transition-[width] duration-350 ease-[cubic-bezier(0.4,0,0.2,1)]",
|
||||
collapsed ? "w-16" : "w-[200px]"
|
||||
)}
|
||||
>
|
||||
<FileText size={13} />
|
||||
Genera preventivo
|
||||
</Link>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className={cn(
|
||||
"h-[57px] flex items-center border-b border-white/10",
|
||||
collapsed ? "justify-center px-0" : "px-5"
|
||||
)}
|
||||
>
|
||||
{!collapsed && (
|
||||
<span className="font-bold text-white tracking-tight text-sm whitespace-nowrap">
|
||||
iamcavalli
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="flex-1 px-3 py-4 flex flex-col gap-0.5">
|
||||
<nav className="flex-1 px-2 py-4 flex flex-col gap-0.5">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
|
||||
const active = isActive(href, exact);
|
||||
const badge =
|
||||
href === "/admin/conversazioni" && unreadConversations > 0
|
||||
? unreadConversations
|
||||
: 0;
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
title={collapsed ? label : undefined}
|
||||
className={cn(
|
||||
"flex items-center py-2.5 rounded-lg text-sm transition-all duration-200",
|
||||
rowClass,
|
||||
active
|
||||
? "bg-white/15 text-white font-medium"
|
||||
: "text-white/65 hover:text-white hover:bg-white/10"
|
||||
}`}
|
||||
)}
|
||||
>
|
||||
<Icon size={16} strokeWidth={1.8} />
|
||||
{label}
|
||||
<span className="relative shrink-0">
|
||||
<Icon size={16} strokeWidth={1.8} className="shrink-0" />
|
||||
{badge > 0 && collapsed && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-2 h-2 rounded-full bg-emerald-500 ring-2 ring-[#1A463C]" />
|
||||
)}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="flex-1 flex items-center justify-between gap-2 min-w-0">
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
{badge > 0 && (
|
||||
<span className="shrink-0 min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full bg-emerald-500 text-white text-[10px] font-bold">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="px-3 py-4 border-t border-white/10">
|
||||
{/* Theme + Logout */}
|
||||
<div className="px-2 py-4 border-t border-white/10 flex flex-col gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-1",
|
||||
collapsed ? "justify-center px-0" : "justify-between px-3"
|
||||
)}
|
||||
>
|
||||
{!collapsed && <span className="text-xs text-white/50">Tema</span>}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
||||
className="flex items-center gap-3 px-3 py-2 w-full rounded-md text-sm text-white/65 hover:text-white hover:bg-white/10 transition-colors"
|
||||
title={collapsed ? "Esci" : undefined}
|
||||
className={cn(
|
||||
"flex items-center py-2.5 w-full rounded-lg text-sm transition-all duration-200",
|
||||
"text-white/65 hover:text-white hover:bg-white/10",
|
||||
rowClass
|
||||
)}
|
||||
>
|
||||
<LogOut size={16} strokeWidth={1.8} />
|
||||
Esci
|
||||
<LogOut size={16} strokeWidth={1.8} className="shrink-0" />
|
||||
{!collapsed && <span className="whitespace-nowrap">Esci</span>}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import type { ClientWithPayments } from "@/lib/admin-queries";
|
||||
import { CopyLinkButton } from "@/components/admin/CopyLinkButton";
|
||||
|
||||
function fmt(amount: number) {
|
||||
return `€${amount.toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
|
||||
@@ -14,52 +15,59 @@ export function ClientRow({ client }: { client: ClientWithPayments }) {
|
||||
.filter((p) => p.status !== "saldato")
|
||||
.reduce((sum, p) => sum + parseFloat(p.amount || "0"), 0);
|
||||
|
||||
const slug = client.slug || client.token;
|
||||
const path = `/client/${slug}`;
|
||||
|
||||
return (
|
||||
<tr className={`border-b border-[#f4f4f5] hover:bg-[#f9f9f9] transition-colors ${client.archived ? "opacity-60" : ""}`}>
|
||||
<td className="py-3 px-4">
|
||||
<tr className={`hover:bg-muted/40 transition-colors ${client.archived ? "opacity-60" : ""}`}>
|
||||
<td className="py-4 px-6">
|
||||
<Link
|
||||
href={`/admin/clients/${client.id}`}
|
||||
className="font-medium text-[#1a1a1a] hover:text-[#1A463C] hover:underline"
|
||||
className="font-semibold text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{client.name}
|
||||
</Link>
|
||||
{client.projectBrands && client.projectBrands.length > 0 ? (
|
||||
<p className="text-xs text-[#71717a] mt-0.5">{client.projectBrands.join(" | ")}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">{client.projectBrands.join(" | ")}</p>
|
||||
) : (
|
||||
<p className="text-xs text-[#71717a]">{client.brand_name}</p>
|
||||
client.brand_name && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">{client.brand_name}</p>
|
||||
)
|
||||
)}
|
||||
{client.archived && (
|
||||
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-1.5 py-0.5 rounded-full">
|
||||
<span className="inline-block mt-1 text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded-full">
|
||||
Archiviato
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-[#1a1a1a]">
|
||||
<td className="py-4 px-6 text-right font-mono font-medium text-foreground">
|
||||
{fmt(parseFloat(client.ltv))}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-[#1a1a1a]">
|
||||
<td className="py-4 px-6 text-right">
|
||||
{incassato > 0 ? (
|
||||
<span className="text-[#16a34a] font-medium">{fmt(incassato)}</span>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded font-mono text-xs font-semibold bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400">
|
||||
{fmt(incassato)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[#71717a]">—</span>
|
||||
<span className="text-muted-foreground/50">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm">
|
||||
{daSaldare > 0 ? (
|
||||
<span className="text-[#1a1a1a]">{fmt(daSaldare)}</span>
|
||||
) : (
|
||||
<span className="text-[#71717a]">—</span>
|
||||
)}
|
||||
<td className="py-4 px-6 text-right font-mono font-medium text-muted-foreground">
|
||||
{daSaldare > 0 ? fmt(daSaldare) : <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<td className="py-4 px-6 text-right">
|
||||
<div className="inline-flex items-center gap-2 justify-end">
|
||||
<a
|
||||
href={`/client/${client.slug || client.token}`}
|
||||
href={path}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-[#1A463C] hover:underline font-mono"
|
||||
className="text-xs font-mono text-muted-foreground bg-muted px-2 py-1 rounded border border-border hover:text-foreground transition-colors"
|
||||
title={path}
|
||||
>
|
||||
/client/{(client.slug || client.token).slice(0, 12)}…
|
||||
/client/{slug.slice(0, 12)}…
|
||||
</a>
|
||||
<CopyLinkButton path={path} title="Copia link profilo" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function CopyLinkButton({ path, title = "Copia link" }: { path: string; title?: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function handleCopy() {
|
||||
const url = typeof window !== "undefined" ? `${window.location.origin}${path}` : path;
|
||||
navigator.clipboard?.writeText(url).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
title={copied ? "Copiato!" : title}
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<svg className="w-4 h-4 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={1.8} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { deletePhase, deleteTask } from "@/app/admin/clients/[id]/actions";
|
||||
|
||||
interface DeletePhaseButtonProps {
|
||||
type: "phase";
|
||||
phaseId: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
interface DeleteTaskButtonProps {
|
||||
type: "task";
|
||||
taskId: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
type Props = DeletePhaseButtonProps | DeleteTaskButtonProps;
|
||||
|
||||
export function DeletePhaseTaskButton(props: Props) {
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
function handleClick() {
|
||||
const label = props.type === "phase" ? "questa fase (e tutti i task/deliverable al suo interno)" : "questo task";
|
||||
if (!window.confirm(`Eliminare ${label}? L'azione non è reversibile.`)) return;
|
||||
|
||||
startTransition(async () => {
|
||||
if (props.type === "phase") {
|
||||
await deletePhase(props.phaseId, props.clientId);
|
||||
} else {
|
||||
await deleteTask(props.taskId, props.clientId);
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
title={props.type === "phase" ? "Elimina fase" : "Elimina task"}
|
||||
className="p-1 rounded text-[#999999] hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
aria-label={props.type === "phase" ? "Elimina fase" : "Elimina task"}
|
||||
>
|
||||
{/* Trash icon */}
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ForecastMonth } from "@/lib/forecast-queries";
|
||||
|
||||
export function ForecastChart({
|
||||
data,
|
||||
headerAction,
|
||||
}: {
|
||||
data: ForecastMonth[];
|
||||
headerAction?: ReactNode;
|
||||
}) {
|
||||
const max = Math.max(...data.map((d) => d.total), 1);
|
||||
// index 1 = mese prossimo (evidenziato)
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Previsione Flussi di Cassa (12 Mesi)
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
Stima basata su retainer attivi e saldi dilazionati
|
||||
</p>
|
||||
</div>
|
||||
{headerAction}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2 h-48 border-b border-border-light pt-4">
|
||||
{data.map((m, i) => {
|
||||
const pct = Math.round((m.total / max) * 100);
|
||||
const isNext = i === 1;
|
||||
return (
|
||||
<div key={`${m.year}-${m.month}`} className="flex-1 flex flex-col items-center gap-2">
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: "150px" }}>
|
||||
<div
|
||||
className={`w-full rounded-t transition-all ${
|
||||
isNext ? "bg-primary" : "bg-border hover:bg-primary/50"
|
||||
}`}
|
||||
style={{ height: `${pct}%`, minHeight: m.total > 0 ? "4px" : "0" }}
|
||||
title={`${m.label}: ${m.total.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })}`}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[10px] capitalize ${
|
||||
isNext ? "font-bold text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{m.label.split(" ")[0]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { addManualTimeEntry, deleteTimeEntry } from "@/app/admin/timer-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type TimeEntry = {
|
||||
id: string;
|
||||
started_at: Date;
|
||||
ended_at: Date | null;
|
||||
duration_seconds: number | null;
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (!seconds) return "0 min";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}min`;
|
||||
return `${m} min`;
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Date(date).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
recentEntries: TimeEntry[];
|
||||
};
|
||||
|
||||
export function ManualTimeEntry({ projectId, recentEntries }: Props) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [minutes, setMinutes] = useState("");
|
||||
const [date, setDate] = useState(() => new Date().toISOString().split("T")[0]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
async function handleQuickAdd(mins: number) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const startedAt = new Date(`${date}T09:00:00`).toISOString();
|
||||
await addManualTimeEntry(projectId, mins, startedAt);
|
||||
startTransition(() => router.refresh());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCustomAdd(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const mins = parseFloat(minutes);
|
||||
if (!mins || mins <= 0) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const startedAt = new Date(`${date}T09:00:00`).toISOString();
|
||||
await addManualTimeEntry(projectId, mins, startedAt);
|
||||
setMinutes("");
|
||||
startTransition(() => router.refresh());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(entryId: string) {
|
||||
setDeletingId(entryId);
|
||||
try {
|
||||
await deleteTimeEntry(entryId, projectId);
|
||||
startTransition(() => router.refresh());
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-4">
|
||||
<h3 className="font-medium text-[#1a1a1a]">Aggiungi tempo manuale</h3>
|
||||
|
||||
{/* Data */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="entry-date">Data</Label>
|
||||
<Input
|
||||
id="entry-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="max-w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick buttons */}
|
||||
<div>
|
||||
<p className="text-xs text-[#71717a] mb-2">Aggiunte rapide</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => handleQuickAdd(30)}
|
||||
>
|
||||
+30 min
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
onClick={() => handleQuickAdd(60)}
|
||||
>
|
||||
+60 min
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom minutes */}
|
||||
<form onSubmit={handleCustomAdd} className="flex items-end gap-2">
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="manual-minutes">Minuti personalizzati</Label>
|
||||
<Input
|
||||
id="manual-minutes"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
placeholder="es. 45"
|
||||
value={minutes}
|
||||
onChange={(e) => setMinutes(e.target.value)}
|
||||
className="max-w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm" disabled={loading || !minutes}>
|
||||
Aggiungi
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Recent entries */}
|
||||
{recentEntries.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-[#71717a] mb-2 font-medium">Ultime registrazioni</p>
|
||||
<ul className="space-y-1.5">
|
||||
{recentEntries.map((entry) => (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="flex items-center justify-between gap-2 bg-[#f9f9f9] rounded px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-[#1a1a1a]">
|
||||
{formatDuration(entry.duration_seconds)}
|
||||
</p>
|
||||
<p className="text-xs text-[#71717a]">{formatDate(entry.started_at)}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={deletingId === entry.id}
|
||||
onClick={() => handleDelete(entry.id)}
|
||||
className="text-[#999999] hover:text-red-500 transition-colors shrink-0 text-xs"
|
||||
aria-label="Elimina registrazione"
|
||||
>
|
||||
{deletingId === entry.id ? "..." : "✕"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { OffersSoldBreakdown } from "@/lib/forecast-queries";
|
||||
|
||||
export function OffersSoldChart({ data }: { data: OffersSoldBreakdown }) {
|
||||
const max = Math.max(...data.rows.map((r) => r.count), 1);
|
||||
|
||||
if (data.rows.length === 0) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground italic">Nessuna offerta venduta finora.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card p-5 space-y-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Offerte Più Richieste
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{data.rows.map((row, i) => {
|
||||
const pct = Math.round((row.count / max) * 100);
|
||||
return (
|
||||
<div key={`${row.offer_name}-${row.tier_letter ?? "—"}-${i}`}>
|
||||
<div className="flex items-center justify-between mb-1 gap-2">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{row.offer_name}
|
||||
{row.tier_letter && (
|
||||
<span className="ml-2 inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
|
||||
Tier {row.tier_letter}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm font-mono text-muted-foreground shrink-0">{row.count}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function PageHeader({ title, subtitle, action }: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type Transcript = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
call_date: string;
|
||||
content: string;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
export function TranscriptItem({ transcript }: { transcript: Transcript }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const date = new Date(transcript.call_date).toLocaleDateString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="border border-[#e5e7eb] rounded-lg bg-white overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-[#f9f9f9] transition-colors"
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[#1a1a1a] truncate">
|
||||
{transcript.title ?? "Transcript chiamata"}
|
||||
</p>
|
||||
<p className="text-xs text-[#71717a] mt-0.5">{date}</p>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-[#999999] shrink-0 ml-3 transition-transform ${expanded ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 pt-1 border-t border-[#e5e7eb]">
|
||||
<pre className="text-xs text-[#1a1a1a] whitespace-pre-wrap font-sans leading-relaxed max-h-96 overflow-y-auto">
|
||||
{transcript.content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const MONTHS = ["Gen", "Feb", "Mar", "Apr", "Mag", "Giu", "Lug", "Ago", "Set", "Ott", "Nov", "Dic"];
|
||||
|
||||
export function YearSelector({
|
||||
currentYear,
|
||||
availableYears,
|
||||
}: {
|
||||
currentYear: number;
|
||||
availableYears: number[];
|
||||
}) {
|
||||
export function YearSelector({ currentYear }: { currentYear: number }) {
|
||||
const router = useRouter();
|
||||
const thisYear = new Date().getFullYear();
|
||||
|
||||
@@ -19,21 +11,21 @@ export function YearSelector({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center gap-0.5 rounded-lg border border-border-light bg-muted/50 px-1 py-0.5">
|
||||
<button
|
||||
onClick={() => go(currentYear - 1)}
|
||||
className="w-8 h-8 rounded-lg border border-[#e5e7eb] flex items-center justify-center text-[#71717a] hover:border-[#1A463C] hover:text-[#1A463C] transition-colors"
|
||||
className="w-5 h-5 rounded flex items-center justify-center text-muted-foreground hover:text-primary transition-colors"
|
||||
aria-label="Anno precedente"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span className="text-lg font-bold text-[#1a1a1a] tabular-nums w-14 text-center">
|
||||
<span className="text-xs font-bold text-foreground font-mono w-10 text-center">
|
||||
{currentYear}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => go(currentYear + 1)}
|
||||
disabled={currentYear >= thisYear}
|
||||
className="w-8 h-8 rounded-lg border border-[#e5e7eb] flex items-center justify-center text-[#71717a] hover:border-[#1A463C] hover:text-[#1A463C] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="w-5 h-5 rounded flex items-center justify-center text-muted-foreground hover:text-primary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="Anno successivo"
|
||||
>
|
||||
→
|
||||
@@ -41,30 +33,3 @@ export function YearSelector({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonthlyChart({ data, year }: { data: number[]; year: number }) {
|
||||
const max = Math.max(...data, 1);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
|
||||
<h3 className="text-sm font-bold text-[#1a1a1a] mb-6">Incassato mese per mese — {year}</h3>
|
||||
<div className="flex items-end gap-2 h-40">
|
||||
{data.map((val, i) => {
|
||||
const pct = Math.round((val / max) * 100);
|
||||
return (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: "120px" }}>
|
||||
<div
|
||||
className="w-full rounded-t-md bg-[#1A463C] transition-all"
|
||||
style={{ height: `${pct}%`, minHeight: val > 0 ? "4px" : "0" }}
|
||||
title={`€ ${val.toLocaleString("it-IT", { minimumFractionDigits: 2 })}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#71717a]">{MONTHS[i]}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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,14 +69,11 @@ function ServiceRow({
|
||||
});
|
||||
}
|
||||
|
||||
function renderCell(key: ColumnKey) {
|
||||
switch (key) {
|
||||
case "nome":
|
||||
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]">
|
||||
<td key={key} className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
|
||||
<EditableCell
|
||||
value={service.name}
|
||||
type="text"
|
||||
@@ -69,7 +81,10 @@ function ServiceRow({
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "name", v))}
|
||||
/>
|
||||
</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
|
||||
value={service.description ?? ""}
|
||||
type="textarea"
|
||||
@@ -77,7 +92,10 @@ function ServiceRow({
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
|
||||
/>
|
||||
</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
|
||||
value={service.fase}
|
||||
options={options.fase}
|
||||
@@ -85,7 +103,10 @@ function ServiceRow({
|
||||
onRename={(o, n) => run(() => renameServiceOption("fase", o, n))}
|
||||
/>
|
||||
</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
|
||||
values={service.tags}
|
||||
options={options.tag}
|
||||
@@ -94,7 +115,10 @@ function ServiceRow({
|
||||
onRename={(o, n) => run(() => renameServiceOption("tag", o, n))}
|
||||
/>
|
||||
</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
|
||||
values={service.pacchetto}
|
||||
options={options.pacchetto}
|
||||
@@ -103,7 +127,10 @@ function ServiceRow({
|
||||
onRename={(o, n) => run(() => renameServiceOption("pacchetto", o, n))}
|
||||
/>
|
||||
</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
|
||||
value={service.unit_price}
|
||||
type="number"
|
||||
@@ -111,17 +138,32 @@ function ServiceRow({
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "unit_price", v))}
|
||||
/>
|
||||
</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
|
||||
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" : ""
|
||||
}`}
|
||||
>
|
||||
{colOrder.map((key) => renderCell(key))}
|
||||
</tr>
|
||||
{error && (
|
||||
<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}
|
||||
</td>
|
||||
</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 [description, setDescription] = useState("");
|
||||
const [fase, setFase] = useState("");
|
||||
@@ -172,10 +220,11 @@ 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 (
|
||||
<>
|
||||
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
|
||||
<td className="py-2 px-3">
|
||||
<td key={key} className="py-2 px-3">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
@@ -184,7 +233,10 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
);
|
||||
case "descrizione":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3">
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
@@ -193,7 +245,10 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
);
|
||||
case "fase":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3">
|
||||
<Input
|
||||
value={fase}
|
||||
onChange={(e) => setFase(e.target.value)}
|
||||
@@ -202,11 +257,16 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
||||
list="catalog-fase-pool"
|
||||
className={cellInput}
|
||||
/>
|
||||
<datalist id="catalog-fase-pool">
|
||||
{options.fase.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
</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">
|
||||
);
|
||||
case "prezzo":
|
||||
return (
|
||||
<td key={key} className="py-2 px-3">
|
||||
<Input
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
@@ -216,16 +276,26 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
||||
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>
|
||||
);
|
||||
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]">
|
||||
{colOrder.map((key) => renderCell(key))}
|
||||
</tr>
|
||||
{error && (
|
||||
<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}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -235,14 +305,35 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
||||
}
|
||||
|
||||
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) => (
|
||||
{colOrder.map((key) => {
|
||||
const def = COLUMN_DEFS[key];
|
||||
return (
|
||||
<th
|
||||
key={header}
|
||||
className="text-left py-2 px-3 font-semibold text-[#71717a]"
|
||||
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 }}
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</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,291 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
|
||||
import { SearchInput } from "@/components/ui/SearchInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
markConversationRead,
|
||||
replyToConversation,
|
||||
} from "@/app/admin/conversazioni/actions";
|
||||
import type {
|
||||
ConversationSummary,
|
||||
ConversationThread,
|
||||
} from "@/lib/conversations-queries";
|
||||
|
||||
type Props = {
|
||||
conversations: ConversationSummary[];
|
||||
activeThread: ConversationThread | null;
|
||||
activeClientId: string | null;
|
||||
};
|
||||
|
||||
export function ConversationsView({
|
||||
conversations,
|
||||
activeThread,
|
||||
activeClientId,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return conversations;
|
||||
return conversations.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.brand_name.toLowerCase().includes(q) ||
|
||||
c.lastMessage.toLowerCase().includes(q)
|
||||
);
|
||||
}, [conversations, query]);
|
||||
|
||||
const activeSummary = conversations.find((c) => c.clientId === activeClientId);
|
||||
|
||||
// Mark the active conversation as read when it is opened and still unread.
|
||||
// Guarded on `unread` so the refresh (which flips it to read) won't re-fire.
|
||||
useEffect(() => {
|
||||
if (!activeClientId || !activeSummary?.unread) return;
|
||||
(async () => {
|
||||
await markConversationRead(activeClientId);
|
||||
router.refresh();
|
||||
})();
|
||||
}, [activeClientId, activeSummary?.unread, router]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 h-[calc(100vh-15rem)] min-h-[520px]">
|
||||
{/* ── Left: conversation list ─────────────────────────────── */}
|
||||
<aside className="w-80 lg:w-96 shrink-0 flex flex-col bg-card border border-border rounded-xl shadow-card overflow-hidden">
|
||||
<div className="p-4 border-b border-border">
|
||||
<SearchInput
|
||||
placeholder="Cerca conversazione..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-border">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="p-6 text-sm text-muted-foreground text-center">
|
||||
Nessuna conversazione.
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((c) => (
|
||||
<ConversationListItem
|
||||
key={c.clientId}
|
||||
conv={c}
|
||||
active={c.clientId === activeClientId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Right: active thread ────────────────────────────────── */}
|
||||
<section className="flex-1 min-w-0 flex flex-col bg-card border border-border rounded-xl shadow-card overflow-hidden">
|
||||
{activeThread ? (
|
||||
<ActiveThread thread={activeThread} />
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 text-muted-foreground">
|
||||
<MessageSquare className="w-10 h-10 opacity-40" strokeWidth={1.5} />
|
||||
<p className="text-sm">Nessun messaggio dai clienti ancora.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationListItem({
|
||||
conv,
|
||||
active,
|
||||
}: {
|
||||
conv: ConversationSummary;
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/conversazioni?c=${conv.clientId}`}
|
||||
className={cn(
|
||||
"block p-4 transition-colors hover:bg-muted/40",
|
||||
active && "bg-muted/60 border-l-2 border-primary",
|
||||
!active && "border-l-2 border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm truncate",
|
||||
conv.unread ? "font-bold text-foreground" : "font-semibold text-foreground"
|
||||
)}
|
||||
>
|
||||
{conv.name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
|
||||
{formatListTime(conv.lastMessageAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||||
{conv.lastMessageAuthor === "admin" && (
|
||||
<span className="text-muted-foreground/70">Tu: </span>
|
||||
)}
|
||||
{conv.lastMessage}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<EntityBadge label={conv.lastEntityLabel} />
|
||||
{conv.unread && (
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{conv.unreadCount > 1 && (
|
||||
<span className="text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
{conv.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveThread({ thread }: { thread: ConversationThread }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const portalHref = `/client/${thread.slug ?? thread.token}`;
|
||||
|
||||
// Keep the thread pinned to the latest message.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [thread.clientId, thread.messages.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-bold text-foreground truncate">{thread.name}</h2>
|
||||
<p className="text-[11px] text-muted-foreground truncate">{thread.brand_name}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Link
|
||||
href={`/admin/clients/${thread.clientId}`}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<IdCard className="w-3.5 h-3.5" /> Scheda
|
||||
</Link>
|
||||
<Link
|
||||
href={portalHref}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" /> Portale
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
|
||||
{thread.messages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10">
|
||||
Nessun messaggio in questa conversazione.
|
||||
</p>
|
||||
) : (
|
||||
thread.messages.map((m) => <MessageBubble key={m.id} message={m} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply box */}
|
||||
<form
|
||||
action={replyToConversation.bind(null, thread.clientId)}
|
||||
className="p-4 border-t border-border bg-card flex items-end gap-3"
|
||||
>
|
||||
<Textarea
|
||||
name="body"
|
||||
rows={2}
|
||||
required
|
||||
placeholder="Scrivi una risposta..."
|
||||
className="flex-1 resize-none"
|
||||
/>
|
||||
<Button type="submit" size="sm" className="shrink-0">
|
||||
Invia
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
}: {
|
||||
message: ConversationThread["messages"][number];
|
||||
}) {
|
||||
const isAdmin = message.author === "admin";
|
||||
const showEntity = message.entityType !== "general";
|
||||
return (
|
||||
<div className={cn("flex", isAdmin && "justify-end")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] p-3.5 rounded-2xl shadow-sm space-y-1",
|
||||
isAdmin
|
||||
? "bg-primary text-primary-foreground rounded-tr-sm"
|
||||
: "bg-background border border-border text-foreground rounded-tl-sm"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-bold",
|
||||
isAdmin ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isAdmin ? "Tu (Admin)" : "Cliente"}
|
||||
</span>
|
||||
{showEntity && <EntityBadge label={message.entityLabel} />}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed whitespace-pre-wrap">{message.body}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[9px] font-mono",
|
||||
isAdmin ? "text-primary-foreground/50" : "text-muted-foreground/60"
|
||||
)}
|
||||
>
|
||||
{formatMessageTime(message.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-block text-[9px] font-semibold px-1.5 py-0.5 rounded border border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400 truncate max-w-[140px]">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function formatListTime(date: Date): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
if (sameDay) {
|
||||
return d.toLocaleTimeString("it-IT", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return "Ieri";
|
||||
return d.toLocaleDateString("it-IT", { day: "numeric", month: "short" });
|
||||
}
|
||||
|
||||
function formatMessageTime(date: Date): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleString("it-IT", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ClientProfitabilityRow } from "@/lib/analytics-queries";
|
||||
|
||||
function fmtRate(n: number) {
|
||||
return n.toLocaleString("it-IT", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const MARGIN_LABEL: Record<ClientProfitabilityRow["margin"], string> = {
|
||||
ottimo: "Margine Ottimo",
|
||||
in_linea: "In Linea",
|
||||
sotto: "Sotto Target",
|
||||
};
|
||||
|
||||
export function ClientProfitability({ data }: { data: ClientProfitabilityRow[] }) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card p-6">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-4">
|
||||
Analisi Oraria & Redditività Clienti
|
||||
</h3>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic text-center py-4">
|
||||
Nessuna sessione tracciata per calcolare la redditività.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.map((row) => {
|
||||
const isOttimo = row.margin === "ottimo";
|
||||
return (
|
||||
<div
|
||||
key={row.clientId}
|
||||
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border-light bg-muted/30"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-xs font-semibold text-foreground truncate">
|
||||
{row.clientName}
|
||||
</h4>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Tracciate:{" "}
|
||||
<span className="font-mono">{row.hours.toLocaleString("it-IT")}h</span> — Valore
|
||||
Orario Target:{" "}
|
||||
<span className="font-mono">{row.target}€/h</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span
|
||||
className={`text-xs font-bold font-mono px-2 py-0.5 rounded border ${
|
||||
isOttimo
|
||||
? "text-emerald-700 bg-emerald-50 border-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20"
|
||||
: "text-foreground bg-muted border-border"
|
||||
}`}
|
||||
>
|
||||
{fmtRate(row.realRate)} /h Reali
|
||||
</span>
|
||||
<p className="text-[9px] text-muted-foreground mt-1">{MARGIN_LABEL[row.margin]}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,76 @@
|
||||
import { getLeadsNeedingFollowUp } from "@/lib/lead-service";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export async function FollowUpWidget() {
|
||||
const leadsNeedingFollowUp = await getLeadsNeedingFollowUp(7);
|
||||
const MAX_ROWS = 4;
|
||||
|
||||
function relativeDays(date: Date | string | null | undefined): string | null {
|
||||
if (!date) return "Mai contattato";
|
||||
const d = date instanceof Date ? date : new Date(date);
|
||||
const diffMs = Date.now() - d.getTime();
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (days <= 0) return "Contattato oggi";
|
||||
if (days === 1) return "Contattato ieri";
|
||||
return `Contattato ${days} giorni fa`;
|
||||
}
|
||||
|
||||
export async function FollowUpWidget() {
|
||||
const leads = await getLeadsNeedingFollowUp(7);
|
||||
|
||||
if (leads.length === 0) {
|
||||
return (
|
||||
<Card className="border-orange-200 bg-orange-50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-orange-600" />
|
||||
Richiedi Follow-up
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{leadsNeedingFollowUp.length > 0 ? (
|
||||
<>
|
||||
<p className="text-lg font-bold text-orange-900">
|
||||
{leadsNeedingFollowUp.length} lead da contattare
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{leadsNeedingFollowUp.slice(0, 3).map((lead) => (
|
||||
<li key={lead.id} className="flex justify-between items-center">
|
||||
<span>{lead.name}</span>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/admin/leads/${lead.id}`}>Contatta</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{leadsNeedingFollowUp.length > 3 && (
|
||||
<Button variant="outline" className="w-full" asChild>
|
||||
<Link href="/admin/leads">Vedi Tutto ({leadsNeedingFollowUp.length})</Link>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-600 text-sm">Tutti i lead sono aggiornati!</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card p-5">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Follow-up Attivi
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-3">Tutti i lead sono aggiornati ✓</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visible = leads.slice(0, MAX_ROWS);
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Follow-up Attivi
|
||||
</h3>
|
||||
<span className="text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 px-1.5 py-0.5 rounded-full dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20">
|
||||
{leads.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{visible.map((lead) => {
|
||||
const subtitle = lead.next_action
|
||||
? lead.next_action
|
||||
: [lead.company, relativeDays(lead.last_contact_date)].filter(Boolean).join(" — ");
|
||||
return (
|
||||
<Link
|
||||
key={lead.id}
|
||||
href={`/admin/pipeline/${lead.id}`}
|
||||
className="flex items-center justify-between gap-3 p-3 border border-border-light rounded-lg hover:border-border transition-colors group"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-xs font-bold text-foreground truncate">{lead.name}</h4>
|
||||
<p className="text-[10px] text-muted-foreground truncate">{subtitle}</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-primary group-hover:opacity-70 transition-opacity shrink-0 inline-flex items-center gap-0.5">
|
||||
Apri →
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{leads.length > MAX_ROWS && (
|
||||
<Link
|
||||
href="/admin/pipeline"
|
||||
className="block mt-3 pt-3 text-center text-xs font-medium text-muted-foreground hover:text-primary border-t border-border-light transition-colors"
|
||||
>
|
||||
Vedi tutti i {leads.length} lead
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getConversations } from "@/lib/conversations-queries";
|
||||
import Link from "next/link";
|
||||
|
||||
const MAX_ROWS = 4;
|
||||
|
||||
export async function MessagesWidget() {
|
||||
const conversations = await getConversations();
|
||||
const unread = conversations.filter((c) => c.unread);
|
||||
|
||||
if (unread.length === 0) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card p-5">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Messaggi Clienti
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-3">Nessun messaggio in attesa ✓</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visible = unread.slice(0, MAX_ROWS);
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Messaggi Clienti
|
||||
</h3>
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold bg-emerald-50 text-emerald-700 border border-emerald-100 px-2 py-0.5 rounded-full dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20">
|
||||
{unread.length} {unread.length === 1 ? "Nuovo" : "Nuovi"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{visible.map((conv) => (
|
||||
<Link
|
||||
key={conv.clientId}
|
||||
href={`/admin/conversazioni?c=${conv.clientId}`}
|
||||
className="flex items-center justify-between gap-3 p-3 border border-border-light rounded-lg hover:border-border transition-colors group"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-xs font-bold text-foreground truncate">{conv.brand_name}</h4>
|
||||
<p className="text-[10px] text-muted-foreground truncate">{conv.lastMessage}</p>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-primary group-hover:opacity-70 transition-opacity shrink-0 inline-flex items-center gap-0.5">
|
||||
Rispondi →
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{unread.length > MAX_ROWS && (
|
||||
<Link
|
||||
href="/admin/conversazioni"
|
||||
className="block mt-3 pt-3 text-center text-xs font-medium text-muted-foreground hover:text-primary border-t border-border-light transition-colors"
|
||||
>
|
||||
Vedi tutti i messaggi
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { X, Plus } 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);
|
||||
|
||||
const canAdd = input.trim().length > 0;
|
||||
|
||||
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) {
|
||||
const ok = window.confirm(
|
||||
`Eliminare "${value}" da «${label}»?\n\nVerrà rimosso ovunque: sparirà dal menu e da tutte le offerte/servizi che lo usano.`
|
||||
);
|
||||
if (!ok) return;
|
||||
startTransition(() => onRemove(value));
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border border-[#ececee] bg-[#fafafa] p-3.5 transition-opacity ${
|
||||
isPending ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{label}</p>
|
||||
{pool.length > 0 && (
|
||||
<span className="text-[11px] text-[#a1a1aa] tabular-nums">{pool.length}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3 min-h-[26px]">
|
||||
{pool.length === 0 ? (
|
||||
<span className="text-xs text-[#a1a1aa] italic">Nessun valore</span>
|
||||
) : (
|
||||
pool.map((v) => (
|
||||
<span
|
||||
key={v}
|
||||
className="group inline-flex items-center gap-1 rounded-md border border-[#e5e7eb] bg-white pl-2.5 pr-1 py-1 text-xs text-[#27272a] shadow-[0_1px_1px_rgba(0,0,0,0.02)]"
|
||||
>
|
||||
{v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(v)}
|
||||
disabled={isPending}
|
||||
className="rounded p-0.5 text-[#c4c4c8] transition-colors hover:bg-red-50 hover:text-red-500 disabled:opacity-50"
|
||||
aria-label={`Elimina ${v}`}
|
||||
title={`Elimina "${v}" ovunque`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Aggiungi valore"
|
||||
disabled={isPending}
|
||||
aria-label={`Aggiungi valore a ${label}`}
|
||||
className="h-8 w-full rounded-md border border-[#e5e7eb] bg-white pl-3 pr-9 text-sm text-[#1a1a1a] placeholder:text-[#a1a1aa] transition-shadow focus:border-[#1A463C]/30 focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={isPending || !canAdd}
|
||||
aria-label={`Conferma aggiunta a ${label}`}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 grid h-6 w-6 place-items-center rounded text-[#1A463C] transition-colors hover:bg-[#1A463C]/10 disabled:text-[#c4c4c8] disabled:hover:bg-transparent"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="mt-1.5 text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { Tag, BookOpen } from "lucide-react";
|
||||
import { PoolManager } from "./PoolManager";
|
||||
import { addTaxonomyValue, removeTaxonomyValue } from "@/app/admin/impostazioni/actions";
|
||||
|
||||
type FieldDef = { id: string; label: string };
|
||||
|
||||
const OFFER_FIELDS: FieldDef[] = [
|
||||
{ id: "offer_categoria", label: "Categoria" },
|
||||
{ id: "offer_ticket", label: "Ticket" },
|
||||
{ id: "offer_tipo", label: "Tipo" },
|
||||
{ id: "offer_obiettivo", label: "Obiettivo" },
|
||||
];
|
||||
|
||||
const CATALOG_FIELDS: FieldDef[] = [
|
||||
{ id: "service_fase", label: "Fase" },
|
||||
{ id: "service_offerta", label: "Offerta" },
|
||||
{ id: "service_pacchetto", label: "Pacchetto" },
|
||||
];
|
||||
|
||||
function Section({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
fields,
|
||||
pools,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
fields: FieldDef[];
|
||||
pools: Record<string, string[]>;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
|
||||
<div className="flex items-start gap-3 mb-5">
|
||||
<div className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-[#1A463C]/8 text-[#1A463C]">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-[#1a1a1a] leading-tight">{title}</h2>
|
||||
<p className="text-xs text-[#71717a] mt-0.5">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{fields.map((f) => (
|
||||
<PoolManager
|
||||
key={f.id}
|
||||
label={f.label}
|
||||
pool={pools[f.id] ?? []}
|
||||
onAdd={(v) => addTaxonomyValue(f.id, v)}
|
||||
onRemove={(v) => removeTaxonomyValue(f.id, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaxonomyManager({ pools }: { pools: Record<string, string[]> }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Section
|
||||
icon={<Tag className="h-[18px] w-[18px]" />}
|
||||
title="Tassonomie Offerte"
|
||||
subtitle="Valori dei campi Categoria, Ticket, Tipo e Obiettivo dell'editor offerte."
|
||||
fields={OFFER_FIELDS}
|
||||
pools={pools}
|
||||
/>
|
||||
<Section
|
||||
icon={<BookOpen className="h-[18px] w-[18px]" />}
|
||||
title="Tassonomie Catalogo"
|
||||
subtitle="Valori dei campi Fase, Offerta e Pacchetto del catalogo servizi."
|
||||
fields={CATALOG_FIELDS}
|
||||
pools={pools}
|
||||
/>
|
||||
<p className="text-xs text-[#a1a1aa] leading-relaxed">
|
||||
I valori si sincronizzano ovunque: creandone uno dall'editor offerte o dal catalogo
|
||||
comparirà qui automaticamente. Eliminando un valore da qui viene rimosso ovunque sia usato.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,34 +3,44 @@
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Lead, Activity, Reminder, ClientTranscript } from "@/db/schema";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/ui/StatusBadge";
|
||||
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||
import { addLeadTag, removeLeadTag, renameLeadTag, deleteTranscript } from "@/app/admin/leads/actions";
|
||||
import {
|
||||
addLeadTag,
|
||||
removeLeadTag,
|
||||
renameLeadTag,
|
||||
deleteTranscript,
|
||||
convertLeadToClient,
|
||||
} from "@/app/admin/pipeline/actions";
|
||||
import { formatDistanceToNow, format } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Mail,
|
||||
Phone,
|
||||
Clock,
|
||||
CalendarDays,
|
||||
Calendar,
|
||||
StickyNote,
|
||||
Mic,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { LogActivityModal } from "./LogActivityModal";
|
||||
import { SendQuoteModal } from "./SendQuoteModal";
|
||||
import { EditLeadModal } from "./LeadForm";
|
||||
import { TranscriptModal } from "./TranscriptModal";
|
||||
import { EditLeadModal } from "./LeadForm";
|
||||
|
||||
const ACTIVITY_ICON: Record<string, string> = {
|
||||
call: "📞",
|
||||
email: "📧",
|
||||
meeting: "📅",
|
||||
note: "📝",
|
||||
const ACTIVITY_META: Record<string, { label: string; Icon: LucideIcon }> = {
|
||||
call: { label: "Chiamata effettuata", Icon: Phone },
|
||||
email: { label: "E-mail inviata", Icon: Mail },
|
||||
meeting: { label: "Meeting", Icon: Calendar },
|
||||
note: { label: "Nota", Icon: StickyNote },
|
||||
};
|
||||
|
||||
const STAGE_COLOR: Record<string, string> = {
|
||||
contacted: "bg-blue-100 text-blue-800",
|
||||
qualified: "bg-purple-100 text-purple-800",
|
||||
proposal_sent: "bg-amber-100 text-amber-800",
|
||||
negotiating: "bg-orange-100 text-orange-800",
|
||||
won: "bg-green-100 text-green-800",
|
||||
lost: "bg-red-100 text-red-800",
|
||||
};
|
||||
type TimelineItem =
|
||||
| { kind: "transcript"; date: Date; data: ClientTranscript }
|
||||
| { kind: "activity"; date: Date; data: Activity };
|
||||
|
||||
export function LeadDetail({
|
||||
lead,
|
||||
@@ -39,6 +49,7 @@ export function LeadDetail({
|
||||
tags,
|
||||
tagOptions,
|
||||
transcripts,
|
||||
convertedClientId,
|
||||
}: {
|
||||
lead: Lead;
|
||||
activities: Activity[];
|
||||
@@ -46,13 +57,23 @@ export function LeadDetail({
|
||||
tags: string[];
|
||||
tagOptions: string[];
|
||||
transcripts: ClientTranscript[];
|
||||
convertedClientId?: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [converting, startConvert] = useTransition();
|
||||
const [tagError, setTagError] = useState<string | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
function handleConvert() {
|
||||
const ok = window.confirm(
|
||||
`Convertire "${lead.name}" in cliente?\n\nVerrà creato un cliente (con email, telefono e transcript del lead) e il lead verrà archiviato mantenendo lo stato "vinto".`
|
||||
);
|
||||
if (!ok) return;
|
||||
startConvert(() => convertLeadToClient(lead.id));
|
||||
}
|
||||
|
||||
function run(fn: () => Promise<unknown>) {
|
||||
setTagError(null);
|
||||
startTransition(async () => {
|
||||
@@ -88,43 +109,130 @@ export function LeadDetail({
|
||||
});
|
||||
}
|
||||
|
||||
// Unified, reverse-chronological timeline of activities + transcripts.
|
||||
const timeline: TimelineItem[] = [
|
||||
...activities.map((a) => ({
|
||||
kind: "activity" as const,
|
||||
date: new Date(a.activity_date),
|
||||
data: a,
|
||||
})),
|
||||
...transcripts.map((t) => ({
|
||||
kind: "transcript" as const,
|
||||
date: new Date(t.call_date + "T00:00:00"),
|
||||
data: t,
|
||||
})),
|
||||
].sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Header + Actions */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex flex-col lg:flex-row justify-between lg:items-start gap-6 border-b border-border pb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{lead.name}</h1>
|
||||
<p className="text-gray-600">{lead.company || "Azienda non specificata"}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{lead.name}
|
||||
</h1>
|
||||
<StatusBadge status={lead.status} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<p className="text-sm font-medium text-muted-foreground mt-1">
|
||||
{lead.company || "Azienda non specificata"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<LogActivityModal leadId={lead.id} />
|
||||
<TranscriptModal leadId={lead.id} />
|
||||
<SendQuoteModal leadId={lead.id} />
|
||||
|
||||
{convertedClientId ? (
|
||||
<Link
|
||||
href={`/admin/clients/${convertedClientId}`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 border border-border text-primary hover:bg-muted text-xs font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
✓ Convertito → Apri cliente
|
||||
</Link>
|
||||
) : (
|
||||
lead.status === "won" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConvert}
|
||||
disabled={converting}
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 border border-border text-muted-foreground hover:text-foreground hover:bg-muted text-xs font-semibold rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{converting ? "Conversione..." : "Converti in cliente"}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Primary action */}
|
||||
<Link
|
||||
href={`/admin/preventivi/genera?lead_id=${lead.id}`}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#DEF168] text-[#1A463C] rounded-md text-sm font-semibold hover:bg-[#d4e85e] transition-colors"
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-primary text-primary-foreground hover:bg-primary/90 text-xs font-medium tracking-wide rounded-lg shadow-sm transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Genera preventivo
|
||||
</Link>
|
||||
|
||||
<EditLeadModal lead={lead} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Lead Profile Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profilo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Stato</label>
|
||||
<Badge className={STAGE_COLOR[lead.status as keyof typeof STAGE_COLOR] || ""}>
|
||||
{lead.status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
{/* Body: asymmetric columns */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
||||
{/* Left (1/3): Profile card */}
|
||||
<div className="lg:col-span-1 bg-card border border-border rounded-xl p-6 shadow-card flex flex-col gap-6">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Profilo Lead
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<ProfileRow Icon={Mail} label="Email">
|
||||
{lead.email ? (
|
||||
<a
|
||||
href={`mailto:${lead.email}`}
|
||||
className="font-semibold text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{lead.email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-semibold text-muted-foreground">—</span>
|
||||
)}
|
||||
</ProfileRow>
|
||||
|
||||
<ProfileRow Icon={Phone} label="Telefono">
|
||||
<span className="font-semibold text-foreground font-mono">
|
||||
{lead.phone || "—"}
|
||||
</span>
|
||||
</ProfileRow>
|
||||
|
||||
<ProfileRow Icon={Clock} label="Ultimo contatto">
|
||||
<span className="font-semibold text-foreground">
|
||||
{lead.last_contact_date
|
||||
? formatDistanceToNow(new Date(lead.last_contact_date), {
|
||||
addSuffix: true,
|
||||
locale: it,
|
||||
})
|
||||
: "—"}
|
||||
</span>
|
||||
</ProfileRow>
|
||||
|
||||
<ProfileRow Icon={CalendarDays} label="Prossima azione programmata">
|
||||
<span
|
||||
className={
|
||||
lead.next_action
|
||||
? "font-semibold text-foreground"
|
||||
: "font-semibold text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{lead.next_action || "—"}
|
||||
</span>
|
||||
</ProfileRow>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Tag</label>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="border-t border-border pt-5">
|
||||
<p className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider mb-2.5">
|
||||
Tag Lead
|
||||
</p>
|
||||
<OptionMultiSelect
|
||||
values={tags}
|
||||
options={tagOptions}
|
||||
@@ -132,171 +240,204 @@ export function LeadDetail({
|
||||
onRemove={(v) => run(() => removeLeadTag(lead.id, v))}
|
||||
onRename={(o, n) => run(() => renameLeadTag(o, n))}
|
||||
/>
|
||||
{tagError && <p className="text-xs text-red-600 mt-1">{tagError}</p>}
|
||||
{tagError && <p className="text-xs text-destructive mt-1">{tagError}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Email</label>
|
||||
<p>{lead.email || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Telefono</label>
|
||||
<p>{lead.phone || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Ultimo contatto</label>
|
||||
<p>
|
||||
{lead.last_contact_date
|
||||
? formatDistanceToNow(new Date(lead.last_contact_date), {
|
||||
addSuffix: true,
|
||||
locale: it,
|
||||
})
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Prossima azione</label>
|
||||
<p className="font-medium">{lead.next_action || "—"}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Upcoming Reminders Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Prossimi Follow-up</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Right (2/3): Note, Follow-up, Timeline */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Note */}
|
||||
<div className="bg-card border border-border rounded-xl p-5 shadow-card">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Note
|
||||
</h3>
|
||||
{lead.notes ? (
|
||||
<div className="text-xs text-foreground/80 leading-relaxed min-h-[80px] p-2.5 bg-muted/50 rounded-lg border border-border">
|
||||
{lead.notes}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center min-h-[80px] text-[11px] font-medium text-muted-foreground p-2.5 bg-muted/50 rounded-lg border border-border">
|
||||
Nessuna nota
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next follow-up */}
|
||||
<div className="bg-card border border-border rounded-xl p-5 shadow-card">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">
|
||||
Prossimo Follow-up
|
||||
</h3>
|
||||
{reminders.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-2.5">
|
||||
{reminders.map((r) => (
|
||||
<li key={r.id} className="text-sm border-l-2 border-blue-300 pl-2">
|
||||
<p className="font-medium">{r.title}</p>
|
||||
<p className="text-gray-600">
|
||||
<li
|
||||
key={r.id}
|
||||
className="border-l-2 border-primary/40 pl-2.5 text-xs"
|
||||
>
|
||||
<p className="font-semibold text-foreground">{r.title}</p>
|
||||
<p className="text-muted-foreground font-mono text-[11px] mt-0.5">
|
||||
{format(new Date(r.due_date), "dd MMM yyyy", { locale: it })}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Nessun reminder</p>
|
||||
<div className="flex flex-col items-center justify-center min-h-[80px] border border-dashed border-border rounded-lg text-muted-foreground p-3">
|
||||
<Clock className="w-5 h-5 mb-1.5 text-muted-foreground/60" />
|
||||
<span className="text-[11px] font-medium">
|
||||
Nessun reminder impostato
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Note</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm">{lead.notes || "Nessuna nota"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity Log */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Storico Attività</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activities.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="border-l-4 border-blue-300 pl-4 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">
|
||||
{ACTIVITY_ICON[activity.type as keyof typeof ACTIVITY_ICON] || "📝"}
|
||||
{/* Unified timeline */}
|
||||
<div className="bg-card border border-border rounded-xl p-6 shadow-card">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-6">
|
||||
Storico delle Interazioni
|
||||
</h3>
|
||||
|
||||
{timeline.length > 0 ? (
|
||||
<div className="relative space-y-6">
|
||||
{/* vertical connector line */}
|
||||
<div className="absolute top-2 bottom-2 left-[19px] w-px bg-border z-0" />
|
||||
|
||||
{timeline.map((item) => {
|
||||
if (item.kind === "transcript") {
|
||||
const t = item.data;
|
||||
const isExpanded = expandedIds.has(t.id);
|
||||
const isLong =
|
||||
t.content.length > 140 || t.content.split("\n").length > 2;
|
||||
return (
|
||||
<div
|
||||
key={`t-${t.id}`}
|
||||
className="relative z-10 pl-10 flex gap-4 items-start group"
|
||||
>
|
||||
<div className="absolute left-0 w-10 h-10 rounded-full bg-primary text-primary-foreground flex items-center justify-center shadow">
|
||||
<Mic className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 bg-muted border border-border rounded-xl p-4 hover:border-muted-foreground/30 transition-colors">
|
||||
<div className="flex flex-wrap justify-between items-start gap-2 mb-2">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-primary uppercase tracking-wide">
|
||||
Transcript Chiamata AI
|
||||
</span>
|
||||
<span className="font-medium capitalize">{activity.type}</span>
|
||||
<span className="text-gray-600 text-sm">
|
||||
{formatDistanceToNow(new Date(activity.activity_date), {
|
||||
addSuffix: true,
|
||||
{t.title && (
|
||||
<h4 className="text-xs font-bold text-foreground">
|
||||
{t.title}
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{format(new Date(t.call_date + "T00:00:00"), "d MMMM yyyy", {
|
||||
locale: it,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mt-2">{activity.notes}</p>
|
||||
{activity.duration_minutes && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Durata: {activity.duration_minutes} minuti
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Nessuna attività registrata</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transcript Call */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Transcript Call</CardTitle>
|
||||
<span className="text-sm text-gray-500">
|
||||
{transcripts.length} {transcripts.length === 1 ? "transcript" : "transcript"}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{transcripts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{transcripts.map((t) => {
|
||||
const isExpanded = expandedIds.has(t.id);
|
||||
const formattedDate = format(
|
||||
new Date(t.call_date + "T00:00:00"),
|
||||
"d MMMM yyyy",
|
||||
{ locale: it }
|
||||
);
|
||||
const lines = t.content.split("\n");
|
||||
const preview = lines.slice(0, 3).join("\n").slice(0, 200);
|
||||
const hasMore = t.content.length > preview.length || lines.length > 3;
|
||||
|
||||
return (
|
||||
<div key={t.id} className="border-l-4 border-purple-300 pl-4 pb-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">{formattedDate}</span>
|
||||
{t.title && (
|
||||
<span className="text-gray-600 text-sm">— {t.title}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-700 whitespace-pre-wrap">
|
||||
{isExpanded ? t.content : preview}
|
||||
{!isExpanded && hasMore && (
|
||||
<span className="text-gray-400">...</span>
|
||||
)}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteTranscript(t.id)}
|
||||
disabled={deletingId === t.id}
|
||||
aria-label="Elimina transcript"
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-all disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-foreground/80 leading-relaxed pt-2 border-t border-border/60">
|
||||
<p
|
||||
className={
|
||||
isExpanded ? "whitespace-pre-wrap" : "line-clamp-2"
|
||||
}
|
||||
>
|
||||
{t.content}
|
||||
</p>
|
||||
{isLong && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(t.id)}
|
||||
className="text-xs text-blue-600 hover:underline mt-1"
|
||||
className="text-[11px] font-bold text-primary hover:text-primary/80 mt-2 inline-flex items-center gap-1"
|
||||
>
|
||||
{isExpanded ? "Mostra meno" : "Mostra tutto"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-700 shrink-0"
|
||||
disabled={deletingId === t.id}
|
||||
onClick={() => handleDeleteTranscript(t.id)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const a = item.data;
|
||||
const meta = ACTIVITY_META[a.type] ?? ACTIVITY_META.note;
|
||||
const Icon = meta.Icon;
|
||||
return (
|
||||
<div
|
||||
key={`a-${a.id}`}
|
||||
className="relative z-10 pl-10 flex gap-4 items-start"
|
||||
>
|
||||
{deletingId === t.id ? "..." : "Elimina"}
|
||||
</Button>
|
||||
<div className="absolute left-0 w-10 h-10 rounded-full bg-muted text-muted-foreground border border-border flex items-center justify-center">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 border border-border rounded-xl p-4 hover:border-muted-foreground/30 transition-colors">
|
||||
<div className="flex justify-between items-center gap-2 mb-1.5">
|
||||
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-wide">
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{formatDistanceToNow(new Date(a.activity_date), {
|
||||
addSuffix: true,
|
||||
locale: it,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{a.notes && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{a.notes}
|
||||
</p>
|
||||
)}
|
||||
{a.duration_minutes && (
|
||||
<p className="text-[11px] text-muted-foreground/70 font-mono mt-1.5">
|
||||
Durata: {a.duration_minutes} min
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Nessun transcript registrato</p>
|
||||
<p className="text-sm text-muted-foreground text-center py-6">
|
||||
Nessuna interazione registrata
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRow({
|
||||
Icon,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
Icon: LucideIcon;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3.5 text-xs">
|
||||
<div className="p-2 bg-muted text-muted-foreground rounded-lg shrink-0">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[10px] text-muted-foreground font-medium">{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createLeadSchema, LEAD_STAGES, type CreateLeadInput } from "@/lib/lead-validators";
|
||||
import { Lead } from "@/db/schema";
|
||||
import { createLead, updateLead } from "@/app/admin/leads/actions";
|
||||
import { createLead, updateLead } from "@/app/admin/pipeline/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pencil } from "lucide-react";
|
||||
|
||||
export function CreateLeadModal() {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -228,9 +229,14 @@ export function EditLeadModal({ lead }: { lead: Lead }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Modifica
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Modifica lead"
|
||||
title="Modifica"
|
||||
className="inline-flex items-center justify-center p-2.5 border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -3,29 +3,19 @@
|
||||
import Link from "next/link";
|
||||
import { useState, useRef, useEffect, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ArrowRight } from "lucide-react";
|
||||
import { EditableCell } from "@/components/ui/editable-cell";
|
||||
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||
import { StatusBadge } from "@/components/ui/StatusBadge";
|
||||
import {
|
||||
updateLeadField,
|
||||
addLeadTag,
|
||||
removeLeadTag,
|
||||
renameLeadTag,
|
||||
} from "@/app/admin/leads/actions";
|
||||
} from "@/app/admin/pipeline/actions";
|
||||
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STAGE_COLOR: Record<string, string> = {
|
||||
contacted: "bg-blue-100 text-blue-800",
|
||||
qualified: "bg-purple-100 text-purple-800",
|
||||
proposal_sent: "bg-amber-100 text-amber-800",
|
||||
negotiating: "bg-orange-100 text-orange-800",
|
||||
won: "bg-green-100 text-green-800",
|
||||
lost: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
const COLUMN_HEADERS = [
|
||||
"Nome",
|
||||
"Email",
|
||||
@@ -37,6 +27,11 @@ const COLUMN_HEADERS = [
|
||||
"Azioni",
|
||||
];
|
||||
const COL_COUNT = COLUMN_HEADERS.length;
|
||||
const HEADER_ALIGN: Record<string, string> = {
|
||||
Stato: "text-center",
|
||||
Tag: "text-center",
|
||||
Azioni: "text-right",
|
||||
};
|
||||
|
||||
function StatusCell({
|
||||
lead,
|
||||
@@ -61,23 +56,15 @@ function StatusCell({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full min-w-[120px]">
|
||||
<div ref={containerRef} className="relative w-full min-w-[120px] flex justify-center">
|
||||
<div
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px] cursor-pointer hover:bg-[#f0f0f0]"
|
||||
className="flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px] cursor-pointer hover:bg-muted"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
STAGE_COLOR[lead.status] ?? "",
|
||||
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
|
||||
)}
|
||||
>
|
||||
{lead.status.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
<StatusBadge status={lead.status} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-1.5 z-20 min-w-[180px]">
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-1 bg-popover border border-border rounded-md shadow-card-hover p-1.5 z-20 min-w-[180px]">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{options.status.map((stage) => (
|
||||
<button
|
||||
@@ -87,17 +74,12 @@ function StatusCell({
|
||||
setIsOpen(false);
|
||||
run(() => updateLeadField(lead.id, "status", stage));
|
||||
}}
|
||||
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-[#f5f5f5] text-left"
|
||||
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-muted text-left"
|
||||
>
|
||||
<span className="w-3.5 flex-shrink-0">
|
||||
{lead.status === stage && <Check className="h-3.5 w-3.5 text-[#1A463C]" />}
|
||||
{lead.status === stage && <Check className="h-3.5 w-3.5 text-primary" />}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(STAGE_COLOR[stage] ?? "", "border-transparent text-xs px-2 py-0.5 font-medium truncate")}
|
||||
>
|
||||
{stage.replace(/_/g, " ")}
|
||||
</Badge>
|
||||
<StatusBadge status={stage} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -132,8 +114,8 @@ function LeadRow({
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150">
|
||||
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
|
||||
<tr className="hover:bg-muted/40 transition-colors duration-150">
|
||||
<td className="py-4 px-6 font-medium text-foreground min-w-[160px]">
|
||||
<EditableCell
|
||||
value={lead.name}
|
||||
type="text"
|
||||
@@ -141,7 +123,7 @@ function LeadRow({
|
||||
onSave={(v) => run(() => updateLeadField(lead.id, "name", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[160px]">
|
||||
<td className="py-4 px-6 min-w-[160px] text-muted-foreground text-xs">
|
||||
<EditableCell
|
||||
value={lead.email ?? ""}
|
||||
type="text"
|
||||
@@ -149,7 +131,7 @@ function LeadRow({
|
||||
onSave={(v) => run(() => updateLeadField(lead.id, "email", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[140px]">
|
||||
<td className="py-4 px-6 min-w-[140px] text-muted-foreground text-xs font-mono">
|
||||
<EditableCell
|
||||
value={lead.phone ?? ""}
|
||||
type="text"
|
||||
@@ -157,7 +139,7 @@ function LeadRow({
|
||||
onSave={(v) => run(() => updateLeadField(lead.id, "phone", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[140px]">
|
||||
<td className="py-4 px-6 min-w-[140px]">
|
||||
<EditableCell
|
||||
value={lead.company ?? ""}
|
||||
type="text"
|
||||
@@ -165,10 +147,10 @@ function LeadRow({
|
||||
onSave={(v) => run(() => updateLeadField(lead.id, "company", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[120px]">
|
||||
<td className="py-4 px-6 min-w-[120px] text-center">
|
||||
<StatusCell lead={lead} options={options} run={run} />
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[180px]">
|
||||
<td className="py-4 px-6 min-w-[180px]">
|
||||
<EditableCell
|
||||
value={lead.next_action ?? ""}
|
||||
type="text"
|
||||
@@ -176,7 +158,7 @@ function LeadRow({
|
||||
onSave={(v) => run(() => updateLeadField(lead.id, "next_action", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[180px]">
|
||||
<td className="py-4 px-6 min-w-[180px] text-center">
|
||||
<OptionMultiSelect
|
||||
values={lead.tags}
|
||||
options={options.tags}
|
||||
@@ -185,15 +167,19 @@ function LeadRow({
|
||||
onRename={(o, n) => run(() => renameLeadTag(o, n))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[80px]">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/admin/leads/${lead.id}`}>Dettagli</Link>
|
||||
</Button>
|
||||
<td className="py-4 px-6 min-w-[80px] text-right">
|
||||
<Link
|
||||
href={`/admin/pipeline/${lead.id}`}
|
||||
className="text-xs font-medium text-primary hover:text-primary/80 inline-flex items-center gap-1 group transition-colors"
|
||||
>
|
||||
Dettagli
|
||||
<ArrowRight className="w-3 h-3 transform group-hover:translate-x-0.5 transition-transform" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
{error && (
|
||||
<tr>
|
||||
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
||||
<td colSpan={COL_COUNT} className="py-1 px-6 text-xs text-destructive">
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -205,27 +191,29 @@ function LeadRow({
|
||||
export function LeadTable({
|
||||
leads,
|
||||
options,
|
||||
total,
|
||||
}: {
|
||||
leads: LeadWithTags[];
|
||||
options: LeadFieldOptions;
|
||||
total?: number;
|
||||
}) {
|
||||
const count = leads.length;
|
||||
const totalCount = total ?? leads.length;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb]">
|
||||
<div className="overflow-x-auto min-h-[300px]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
|
||||
<tr>
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border-light bg-muted/50 text-muted-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||
{COLUMN_HEADERS.map((header) => (
|
||||
<th
|
||||
key={header}
|
||||
className="text-left py-2 px-3 font-semibold text-[#71717a]"
|
||||
>
|
||||
<th key={header} className={cn("py-4 px-6", HEADER_ALIGN[header])}>
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-border-light text-sm">
|
||||
{leads.map((lead) => (
|
||||
<LeadRow key={lead.id} lead={lead} options={options} />
|
||||
))}
|
||||
@@ -234,8 +222,12 @@ export function LeadTable({
|
||||
</div>
|
||||
|
||||
{leads.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Nessun lead trovato</div>
|
||||
<div className="text-center py-8 text-muted-foreground">Nessun lead trovato</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border-light px-6 py-4 text-xs text-muted-foreground">
|
||||
Mostrando {count} di {totalCount} lead
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,34 +13,31 @@ import {
|
||||
useDroppable,
|
||||
useDraggable,
|
||||
} from "@dnd-kit/core";
|
||||
import { updateLeadField } from "@/app/admin/leads/actions";
|
||||
import { updateLeadField } from "@/app/admin/pipeline/actions";
|
||||
import type { LeadWithTags } from "@/lib/admin-queries";
|
||||
import { StatusBadge } from "@/components/ui/StatusBadge";
|
||||
|
||||
type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost";
|
||||
|
||||
const LEAD_COLUMNS: {
|
||||
id: LeadStage;
|
||||
label: string;
|
||||
headerClass: string;
|
||||
dotClass: string;
|
||||
}[] = [
|
||||
{ id: "contacted", label: "Contattato", headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
|
||||
{ id: "qualified", label: "Qualificato", headerClass: "text-purple-700", dotClass: "bg-purple-400" },
|
||||
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700", dotClass: "bg-amber-400" },
|
||||
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700", dotClass: "bg-orange-400" },
|
||||
{ id: "won", label: "Vinto", headerClass: "text-green-700", dotClass: "bg-green-500" },
|
||||
{ id: "lost", label: "Perso", headerClass: "text-red-700", dotClass: "bg-red-400" },
|
||||
{ id: "contacted", label: "Contattato" },
|
||||
{ id: "qualified", label: "Qualificato" },
|
||||
{ id: "proposal_sent", label: "Offerta inviata" },
|
||||
{ id: "negotiating", label: "Trattativa" },
|
||||
{ id: "won", label: "Vinto" },
|
||||
{ id: "lost", label: "Perso" },
|
||||
];
|
||||
|
||||
const VALID_STAGES = LEAD_COLUMNS.map((c) => c.id) as string[];
|
||||
|
||||
function DroppableColumn({
|
||||
id, label, headerClass, dotClass, leads, activeId,
|
||||
id, label, leads, activeId,
|
||||
}: {
|
||||
id: LeadStage;
|
||||
label: string;
|
||||
headerClass: string;
|
||||
dotClass: string;
|
||||
leads: LeadWithTags[];
|
||||
activeId: string | null;
|
||||
}) {
|
||||
@@ -49,26 +46,23 @@ function DroppableColumn({
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex flex-col rounded-xl border-2 transition-colors ${
|
||||
isOver ? "border-[#1A463C] bg-[#1A463C]/5" : "border-[#e5e7eb] bg-[#f9f9f9]"
|
||||
} min-h-[240px]`}
|
||||
className={`w-[280px] shrink-0 flex flex-col gap-4 rounded-xl border p-4 transition-colors min-h-[420px] ${
|
||||
isOver ? "border-primary bg-primary/5" : "border-border-light bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<div className={`px-4 py-3 flex items-center justify-between ${headerClass}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${dotClass}`} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">{label}</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold tabular-nums bg-white rounded-full px-2 py-0.5 border border-[#e5e7eb]">
|
||||
<div className="flex items-center justify-between border-b border-border-light pb-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-medium tabular-nums bg-secondary text-muted-foreground px-2 py-0.5 rounded-full">
|
||||
{leads.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-3 space-y-2">
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
{leads.map((lead) => (
|
||||
<DraggableLeadCard key={lead.id} lead={lead} isActive={activeId === lead.id} />
|
||||
<DraggableLeadCard key={lead.id} lead={lead} status={id} isActive={activeId === lead.id} />
|
||||
))}
|
||||
{leads.length === 0 && (
|
||||
<p className="text-xs text-[#d4d4d8] italic text-center py-10 select-none">
|
||||
<p className="text-xs text-muted-foreground italic text-center py-10 select-none">
|
||||
Nessun lead
|
||||
</p>
|
||||
)}
|
||||
@@ -77,7 +71,15 @@ function DroppableColumn({
|
||||
);
|
||||
}
|
||||
|
||||
function DraggableLeadCard({ lead, isActive }: { lead: LeadWithTags; isActive: boolean }) {
|
||||
function DraggableLeadCard({
|
||||
lead,
|
||||
status,
|
||||
isActive,
|
||||
}: {
|
||||
lead: LeadWithTags;
|
||||
status: LeadStage;
|
||||
isActive: boolean;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: lead.id });
|
||||
|
||||
const style = transform
|
||||
@@ -90,17 +92,21 @@ function DraggableLeadCard({ lead, isActive }: { lead: LeadWithTags; isActive: b
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`bg-white rounded-lg border border-[#e5e7eb] px-3 py-2.5 cursor-grab active:cursor-grabbing shadow-sm select-none transition-opacity ${
|
||||
isDragging ? "opacity-30" : "hover:border-[#1A463C]/40 hover:shadow"
|
||||
className={`bg-card p-4 rounded-lg border border-border shadow-sm cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${
|
||||
isDragging ? "opacity-30" : "hover:border-primary/30 hover:shadow-card-hover"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{lead.name}</p>
|
||||
{lead.company && (
|
||||
<p className="text-xs text-[#71717a]">{lead.company}</p>
|
||||
)}
|
||||
{lead.next_action && (
|
||||
<p className="text-xs text-[#71717a] mt-1 line-clamp-1">{lead.next_action}</p>
|
||||
<div className="flex justify-between items-start gap-2 mb-2">
|
||||
<p className="text-xs font-semibold text-foreground">{lead.name}</p>
|
||||
<StatusBadge status={status} className="rounded px-1.5 py-0.5 text-[9px]" />
|
||||
</div>
|
||||
{lead.email && (
|
||||
<p className="text-[11px] text-muted-foreground mb-3 line-clamp-1">{lead.email}</p>
|
||||
)}
|
||||
<div className="flex justify-between items-center text-[10px] text-muted-foreground">
|
||||
<span className="font-medium">{lead.company ?? ""}</span>
|
||||
<span>Tag +</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -156,7 +162,7 @@ export function LeadsKanbanBoard({ leads }: { leads: LeadWithTags[] }) {
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="grid grid-cols-6 gap-3 min-w-[1080px]">
|
||||
<div className="flex gap-4">
|
||||
{LEAD_COLUMNS.map((col) => (
|
||||
<DroppableColumn
|
||||
key={col.id}
|
||||
@@ -170,10 +176,10 @@ export function LeadsKanbanBoard({ leads }: { leads: LeadWithTags[] }) {
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeLead && (
|
||||
<div className="bg-white rounded-lg border-2 border-[#1A463C] px-3 py-2.5 shadow-xl rotate-1 pointer-events-none">
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{activeLead.name}</p>
|
||||
<div className="bg-card rounded-lg border-2 border-primary p-4 shadow-xl rotate-1 pointer-events-none">
|
||||
<p className="text-xs font-semibold text-foreground mb-2">{activeLead.name}</p>
|
||||
{activeLead.company && (
|
||||
<p className="text-xs text-[#71717a]">{activeLead.company}</p>
|
||||
<p className="text-[10px] text-muted-foreground font-medium">{activeLead.company}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { SearchInput } from "@/components/ui/SearchInput";
|
||||
import { SegmentedToggle } from "@/components/ui/SegmentedToggle";
|
||||
import { LeadTable } from "@/components/admin/leads/LeadTable";
|
||||
import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard";
|
||||
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
|
||||
@@ -32,43 +32,26 @@ export function LeadsViewToggle({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
|
||||
<Input
|
||||
type="text"
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
|
||||
<SearchInput
|
||||
placeholder="Cerca lead..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
containerClassName="w-full md:w-80"
|
||||
/>
|
||||
<SegmentedToggle
|
||||
options={[
|
||||
{ value: "list", label: "Lista" },
|
||||
{ value: "kanban", label: "Kanban" },
|
||||
]}
|
||||
value={view}
|
||||
onChange={setView}
|
||||
className="self-start md:self-auto flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-[#f4f4f5] rounded-lg p-1 w-fit flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setView("list")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
||||
view === "list"
|
||||
? "bg-white text-[#1A463C] shadow-sm"
|
||||
: "text-[#71717a] hover:text-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
Lista
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("kanban")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
||||
view === "kanban"
|
||||
? "bg-white text-[#1A463C] shadow-sm"
|
||||
: "text-[#71717a] hover:text-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
Kanban
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === "list" ? (
|
||||
<LeadTable leads={filtered} options={options} />
|
||||
<LeadTable leads={filtered} total={leads.length} options={options} />
|
||||
) : (
|
||||
<LeadsKanbanBoard leads={filtered} />
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { createActivitySchema, ACTIVITY_TYPES } from "@/lib/lead-validators";
|
||||
import { logActivity } from "@/app/admin/leads/actions";
|
||||
import { logActivity } from "@/app/admin/pipeline/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Activity as ActivityIcon } from "lucide-react";
|
||||
|
||||
type LogActivityInput = z.infer<typeof createActivitySchema>;
|
||||
|
||||
@@ -71,9 +72,13 @@ export function LogActivityModal({ leadId }: { leadId: string }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 border border-border text-muted-foreground hover:text-foreground hover:bg-muted text-xs font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
<ActivityIcon className="w-4 h-4 text-muted-foreground/70" />
|
||||
Registra Attività
|
||||
</Button>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { assignQuoteToLead } from "@/app/admin/leads/actions";
|
||||
import { assignQuoteToLead } from "@/app/admin/pipeline/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { addTranscript } from "@/app/admin/leads/actions";
|
||||
import { addTranscript } from "@/app/admin/pipeline/actions";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Mic } from "lucide-react";
|
||||
|
||||
const transcriptSchema = z.object({
|
||||
lead_id: z.string().min(1),
|
||||
@@ -72,9 +73,13 @@ export function TranscriptModal({ leadId }: { leadId: string }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 px-4 py-2.5 border border-border text-muted-foreground hover:text-foreground hover:bg-muted text-xs font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
<Mic className="w-4 h-4 text-muted-foreground/70" />
|
||||
Aggiungi Transcript
|
||||
</Button>
|
||||
</button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -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 {
|
||||
@@ -45,12 +45,15 @@ function formatUnitPrice(raw: string): string {
|
||||
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
type OfferType = "una_tantum" | "retainer";
|
||||
|
||||
type MacroFields = {
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
description: string;
|
||||
category: string | null;
|
||||
ticket: string | null;
|
||||
offer_type: OfferType;
|
||||
cliente_ideale: string;
|
||||
risultato: string;
|
||||
tempo: string;
|
||||
@@ -75,6 +78,7 @@ export function OfferEditorClient({
|
||||
description: data.macro.description ?? "",
|
||||
category: data.macro.category,
|
||||
ticket: data.macro.ticket,
|
||||
offer_type: (data.macro.offer_type === "retainer" ? "retainer" : "una_tantum") as OfferType,
|
||||
cliente_ideale: data.macro.cliente_ideale ?? "",
|
||||
risultato: data.macro.risultato ?? "",
|
||||
tempo: data.macro.tempo ?? "",
|
||||
@@ -86,6 +90,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);
|
||||
@@ -151,6 +162,12 @@ export function OfferEditorClient({
|
||||
);
|
||||
}
|
||||
|
||||
function updateTierName(tierIdx: number, value: string) {
|
||||
setTiers((prev) =>
|
||||
prev.map((tier, idx) => (idx === tierIdx ? { ...tier, public_name: value } : tier))
|
||||
);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setSaveError(null);
|
||||
const payload: SaveOfferEditorPayload = {
|
||||
@@ -159,20 +176,24 @@ export function OfferEditorClient({
|
||||
description: macro.description,
|
||||
category: macro.category ?? undefined,
|
||||
ticket: macro.ticket ?? undefined,
|
||||
offer_type: macro.offer_type,
|
||||
cliente_ideale: macro.cliente_ideale,
|
||||
risultato: macro.risultato,
|
||||
tempo: macro.tempo,
|
||||
pain: macro.pain,
|
||||
metodo: macro.metodo,
|
||||
tiers: tiers.map((t) => ({
|
||||
tiers: tiers.map((t) => {
|
||||
const tierName = t.public_name?.trim();
|
||||
return {
|
||||
id: t.id || undefined,
|
||||
tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C",
|
||||
internal_name: t.internal_name || `Tier ${t.tier_letter}`,
|
||||
public_name: t.public_name || t.tier_letter || "Tier",
|
||||
internal_name: tierName || `Tier ${t.tier_letter}`,
|
||||
public_name: tierName || t.tier_letter || "Tier",
|
||||
duration_months: t.duration_months || 1,
|
||||
public_price: t.public_price ? Number(t.public_price) : null,
|
||||
assignedServiceIds: t.assignedServiceIds,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
tipoTags,
|
||||
obiettivoTags,
|
||||
};
|
||||
@@ -284,6 +305,42 @@ export function OfferEditorClient({
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Modalità toggle — directly under title */}
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0">Modalità</span>
|
||||
<div className="inline-flex rounded-lg border border-[#e5e7eb] bg-[#fafafa] p-0.5">
|
||||
{([
|
||||
{ value: "una_tantum", label: "Una tantum" },
|
||||
{ value: "retainer", label: "Retainer" },
|
||||
] as const).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => updateMacro("offer_type", opt.value)}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
macro.offer_type === opt.value
|
||||
? "bg-[#1A463C] text-white font-medium"
|
||||
: "text-[#71717a] hover:text-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descrizione — nota interna, non visibile al cliente */}
|
||||
<div className="mt-3 flex items-start gap-3">
|
||||
<span className="text-xs text-[#71717a] w-24 shrink-0 pt-2">Descrizione</span>
|
||||
<textarea
|
||||
value={macro.description}
|
||||
onChange={(e) => updateMacro("description", e.target.value)}
|
||||
placeholder="Descrizione interna dell'offerta (non visibile al cliente)..."
|
||||
rows={3}
|
||||
className="flex-1 text-sm border border-[#e5e7eb] rounded-lg px-3 py-2 bg-white resize-none focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30 text-[#1a1a1a] placeholder:text-[#aaaaaa]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags block */}
|
||||
@@ -426,6 +483,22 @@ export function OfferEditorClient({
|
||||
)}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-[#e5e7eb]">
|
||||
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
|
||||
Nome Tier
|
||||
</td>
|
||||
{tiers.map((tier, tierIdx) => (
|
||||
<td key={tier.tier_letter} className="py-2 px-3 text-center">
|
||||
<input
|
||||
type="text"
|
||||
value={tier.public_name ?? ""}
|
||||
onChange={(e) => updateTierName(tierIdx, e.target.value)}
|
||||
placeholder="Nome…"
|
||||
className="w-20 text-center text-sm border border-[#e5e7eb] rounded px-1 py-1 focus-visible:ring-1 focus-visible:ring-primary focus:outline-none"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
<tr className="border-t border-[#e5e7eb]">
|
||||
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
|
||||
Totale Servizi
|
||||
|
||||
@@ -6,8 +6,25 @@ import Link from "next/link";
|
||||
import { createOfferMacro } from "@/app/admin/offers/actions";
|
||||
import type { OfferListCard } from "@/lib/offer-queries";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatEuro(value: number): string {
|
||||
return `€${value.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
/** Category → pill colors, light/dark. Falls back to a neutral badge. */
|
||||
function categoryBadgeClass(category: string): string {
|
||||
const key = category.toLowerCase();
|
||||
if (key.includes("retainer"))
|
||||
return "bg-blue-50 text-blue-600 border-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-900";
|
||||
if (key.includes("entry"))
|
||||
return "bg-purple-50 text-purple-600 border-purple-100 dark:bg-purple-950/40 dark:text-purple-300 dark:border-purple-900";
|
||||
if (key.includes("signature"))
|
||||
return "bg-amber-50 text-amber-700 border-amber-100 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900";
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
|
||||
export function OfferListClient({
|
||||
cards,
|
||||
@@ -41,18 +58,14 @@ export function OfferListClient({
|
||||
}
|
||||
|
||||
const createCta = (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-[#1A463C] text-white hover:bg-[#163a31]"
|
||||
onClick={() => setShowCreateForm((v) => !v)}
|
||||
>
|
||||
<div className="flex flex-col items-end gap-2 self-start sm:self-auto">
|
||||
<Button type="button" onClick={() => setShowCreateForm((v) => !v)}>
|
||||
+ Nuova Offerta
|
||||
</Button>
|
||||
{showCreateForm && (
|
||||
<form
|
||||
action={handleCreateSubmit}
|
||||
className="flex items-center gap-2 bg-white border border-[#e5e7eb] rounded-lg p-2"
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-card p-2 shadow-card"
|
||||
>
|
||||
<Input
|
||||
name="internal_name"
|
||||
@@ -62,12 +75,7 @@ export function OfferListClient({
|
||||
onChange={(e) => setNewOfferName(e.target.value)}
|
||||
className="h-9 w-48"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
className="bg-[#1A463C] text-white hover:bg-[#163a31]"
|
||||
>
|
||||
<Button type="submit" size="sm" disabled={isPending}>
|
||||
Crea
|
||||
</Button>
|
||||
</form>
|
||||
@@ -76,24 +84,25 @@ export function OfferListClient({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-[20px] font-semibold text-[#1a1a1a]">Offerte</h2>
|
||||
{createCta}
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Offerte"
|
||||
subtitle="Configura e gestisci i pacchetti di offerta commerciali"
|
||||
action={createCta}
|
||||
/>
|
||||
|
||||
{/* Filter row */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(null)}
|
||||
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200",
|
||||
activeCategory === null
|
||||
? "border-[#1A463C] text-[#1A463C]"
|
||||
: "border-[#e5e7eb] text-[#71717a]"
|
||||
}`}
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
Tutti
|
||||
</button>
|
||||
@@ -102,55 +111,110 @@ export function OfferListClient({
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(category)}
|
||||
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-xs font-medium transition-all duration-200",
|
||||
activeCategory === category
|
||||
? "border-[#1A463C] text-[#1A463C]"
|
||||
: "border-[#e5e7eb] text-[#71717a]"
|
||||
}`}
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-[#71717a]">
|
||||
<label className="group flex cursor-pointer items-center gap-2.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showArchived}
|
||||
onChange={(e) => setShowArchived(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-[#e5e7eb]"
|
||||
className="h-4 w-4 rounded border-border text-primary focus:ring-primary/25"
|
||||
/>
|
||||
Mostra offerte archiviate
|
||||
<span className="select-none">Mostra offerte archiviate</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Card grid / empty state */}
|
||||
{filteredCards.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-lg border border-[#e5e7eb] bg-white py-16 text-center">
|
||||
<h3 className="text-[16px] font-semibold text-[#1a1a1a]">Nessuna offerta</h3>
|
||||
<p className="text-sm text-[#71717a]">Inizia creando la tua prima offerta</p>
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-xl border border-border bg-card py-16 text-center shadow-card">
|
||||
<h3 className="text-base font-semibold text-foreground">Nessuna offerta</h3>
|
||||
<p className="text-sm text-muted-foreground">Inizia creando la tua prima offerta</p>
|
||||
<div className="mt-2">{createCta}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredCards.map((card) => (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredCards.map((card) => {
|
||||
const visibleTiers = card.tiers.filter(
|
||||
(t) => Number(t.servicesTotal) > 0 || t.public_price !== null
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
key={card.id}
|
||||
href={`/admin/offers/${card.id}/edit`}
|
||||
className="cursor-pointer rounded-lg border border-[#e5e7eb] bg-white p-4 transition-shadow hover:shadow-[0_4px_12px_rgba(0,0,0,0.08)]"
|
||||
>
|
||||
<h3 className="text-[16px] font-semibold text-[#1a1a1a]">{card.internal_name}</h3>
|
||||
{card.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-[#71717a]">{card.description}</p>
|
||||
className={cn(
|
||||
"flex flex-col rounded-xl border border-border bg-card p-6 shadow-card transition-all duration-200 hover:shadow-card-hover",
|
||||
card.is_archived && "opacity-60"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground">{card.internal_name}</h3>
|
||||
{card.description && (
|
||||
<p className="mt-0.5 line-clamp-2 text-[11px] text-muted-foreground">
|
||||
{card.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{card.category && <Badge variant="secondary">{card.category}</Badge>}
|
||||
{card.is_archived && (
|
||||
<span className="text-xs font-semibold text-[#dc2626]">Archiviata</span>
|
||||
<span className="mt-1 inline-block rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
Archiviata
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
{card.category && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded border px-2 py-1 text-[9px] font-semibold uppercase tracking-wider",
|
||||
categoryBadgeClass(card.category)
|
||||
)}
|
||||
>
|
||||
{card.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tier table */}
|
||||
{visibleTiers.length > 0 && (
|
||||
<div className="my-2 overflow-hidden rounded-lg border border-border">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted uppercase text-muted-foreground">
|
||||
<th className="px-3 py-2 text-left font-semibold">Tier</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Servizi</th>
|
||||
<th className="px-3 py-2 text-right font-semibold">Pubblico</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border font-mono text-muted-foreground">
|
||||
{visibleTiers.map((t) => (
|
||||
<tr key={t.tier_letter ?? t.public_name}>
|
||||
<td className="px-3 py-2 font-semibold text-foreground">
|
||||
{t.tier_letter || t.public_name}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{formatEuro(Number(t.servicesTotal))}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground/70">
|
||||
{t.public_price !== null ? formatEuro(Number(t.public_price)) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,12 @@ type Props = {
|
||||
};
|
||||
|
||||
export async function CommentsTab({ comments, phases, clientId }: Props) {
|
||||
// Build entity label map for display
|
||||
const entityLabels: Record<string, string> = {};
|
||||
// Build entity label map for display (phases, tasks, deliverables, and general)
|
||||
const entityLabels: Record<string, string> = {
|
||||
[clientId]: "Messaggio generale",
|
||||
};
|
||||
for (const phase of phases) {
|
||||
entityLabels[phase.id] = `Fase: ${phase.title}`;
|
||||
for (const task of phase.tasks) {
|
||||
entityLabels[task.id] = `Task: ${task.title}`;
|
||||
for (const d of task.deliverables) {
|
||||
@@ -23,8 +26,11 @@ export async function CommentsTab({ comments, phases, clientId }: Props) {
|
||||
}
|
||||
|
||||
// Build list of entities the admin can reply on
|
||||
const entities: Array<{ id: string; type: string; label: string }> = [];
|
||||
const entities: Array<{ id: string; type: string; label: string }> = [
|
||||
{ id: clientId, type: "general", label: "Messaggio generale" },
|
||||
];
|
||||
for (const phase of phases) {
|
||||
entities.push({ id: phase.id, type: "phase", label: `Fase: ${phase.title}` });
|
||||
for (const task of phase.tasks) {
|
||||
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
|
||||
for (const d of task.deliverables) {
|
||||
@@ -67,7 +73,6 @@ export async function CommentsTab({ comments, phases, clientId }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Admin reply form */}
|
||||
{entities.length > 0 && (
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
@@ -99,7 +104,6 @@ export async function CommentsTab({ comments, phases, clientId }: Props) {
|
||||
Invia risposta
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,29 @@
|
||||
import { addDocument } from "@/app/admin/clients/[id]/actions";
|
||||
import { DocumentRow } from "@/components/admin/DocumentRow";
|
||||
import { TranscriptItem } from "@/components/admin/TranscriptItem";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Document } from "@/db/schema";
|
||||
|
||||
type Props = { documents: Document[]; clientId: string };
|
||||
type Transcript = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
call_date: string;
|
||||
content: string;
|
||||
created_at: Date;
|
||||
};
|
||||
|
||||
export async function DocumentsTab({ documents, clientId }: Props) {
|
||||
type Props = {
|
||||
documents: Document[];
|
||||
clientId: string;
|
||||
transcripts?: Transcript[];
|
||||
};
|
||||
|
||||
export async function DocumentsTab({ documents, clientId, transcripts = [] }: Props) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-lg">
|
||||
<div className="space-y-8 max-w-lg">
|
||||
{/* Add document form */}
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
@@ -42,6 +56,8 @@ export async function DocumentsTab({ documents, clientId }: Props) {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Documents list */}
|
||||
<div>
|
||||
{documents.length === 0 && (
|
||||
<p className="text-sm text-[#71717a]">Nessun documento ancora.</p>
|
||||
)}
|
||||
@@ -51,5 +67,18 @@ export async function DocumentsTab({ documents, clientId }: Props) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcripts section (read-only, expandable via client component) */}
|
||||
{transcripts.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-medium text-[#1a1a1a] mb-3">Transcript chiamate</h3>
|
||||
<div className="space-y-2">
|
||||
{transcripts.map((t) => (
|
||||
<TranscriptItem key={t.id} transcript={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition, useRef } from "react";
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, Trash2 } from "lucide-react";
|
||||
import {
|
||||
assignOfferToProject,
|
||||
removeProjectOffer,
|
||||
@@ -14,6 +15,12 @@ type AvailableMicro = {
|
||||
internal_name: string;
|
||||
public_name: string;
|
||||
duration_months: number;
|
||||
tier_letter: string | null;
|
||||
public_price: string | null;
|
||||
macro_id: string;
|
||||
macro_internal_name: string;
|
||||
macro_category: string | null;
|
||||
macro_offer_type: string;
|
||||
};
|
||||
|
||||
interface OffersTabProps {
|
||||
@@ -22,20 +29,97 @@ interface OffersTabProps {
|
||||
availableMicros: AvailableMicro[];
|
||||
}
|
||||
|
||||
function typeLabel(offerType: string): string {
|
||||
return offerType === "retainer" ? "Ricorrente" : "Una tantum";
|
||||
}
|
||||
|
||||
function formatEuro(raw: string | null): string | null {
|
||||
if (raw == null || raw === "") return null;
|
||||
const n = parseFloat(raw);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
return `€${n.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
// public_name is only meaningful when it differs from the tier letter (it
|
||||
// defaults to the letter when unset).
|
||||
function meaningfulPublicName(m: { public_name: string; tier_letter: string | null }): string | null {
|
||||
const p = m.public_name?.trim();
|
||||
if (!p) return null;
|
||||
if (m.tier_letter && p.toUpperCase() === m.tier_letter.toUpperCase()) return null;
|
||||
return p;
|
||||
}
|
||||
|
||||
function TypeBadge({ offerType }: { offerType: string }) {
|
||||
const retainer = offerType === "retainer";
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded text-[11px] font-medium px-1.5 py-0.5 ${
|
||||
retainer ? "bg-blue-50 text-blue-700" : "bg-[#f4f4f5] text-[#52525b]"
|
||||
}`}
|
||||
>
|
||||
{typeLabel(offerType)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
function handleAssign(formData: FormData) {
|
||||
const [macroId, setMacroId] = useState<string>("");
|
||||
const [tierId, setTierId] = useState<string>("");
|
||||
const [acceptedTotal, setAcceptedTotal] = useState<string>("");
|
||||
const [importPhases, setImportPhases] = useState(true);
|
||||
|
||||
// Group tiers by offer (macro), preserving query order.
|
||||
const offers = useMemo(() => {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ macro_id: string; name: string; category: string | null; offer_type: string; tiers: AvailableMicro[] }
|
||||
>();
|
||||
for (const m of availableMicros) {
|
||||
let entry = map.get(m.macro_id);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
macro_id: m.macro_id,
|
||||
name: m.macro_internal_name,
|
||||
category: m.macro_category,
|
||||
offer_type: m.macro_offer_type,
|
||||
tiers: [],
|
||||
};
|
||||
map.set(m.macro_id, entry);
|
||||
}
|
||||
entry.tiers.push(m);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}, [availableMicros]);
|
||||
|
||||
const selectedOffer = offers.find((o) => o.macro_id === macroId) ?? null;
|
||||
|
||||
function selectMacro(id: string) {
|
||||
setMacroId(id);
|
||||
setTierId("");
|
||||
}
|
||||
|
||||
function handleAssign() {
|
||||
if (!tierId) return;
|
||||
const fd = new FormData();
|
||||
fd.set("project_id", projectId);
|
||||
fd.set("micro_id", tierId);
|
||||
if (acceptedTotal.trim()) fd.set("accepted_total", acceptedTotal.trim());
|
||||
if (importPhases) fd.set("import_phases", "on");
|
||||
startTransition(async () => {
|
||||
await assignOfferToProject(formData);
|
||||
formRef.current?.reset();
|
||||
await assignOfferToProject(fd);
|
||||
setMacroId("");
|
||||
setTierId("");
|
||||
setAcceptedTotal("");
|
||||
setImportPhases(true);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemove(offerId: string) {
|
||||
if (!window.confirm("Rimuovere questa offerta dal progetto?")) return;
|
||||
startTransition(async () => {
|
||||
await removeProjectOffer(offerId, projectId);
|
||||
router.refresh();
|
||||
@@ -50,7 +134,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
{/* Active assignments */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
|
||||
@@ -58,23 +142,29 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{projectOffers.map((offer) => (
|
||||
{projectOffers.map((offer) => {
|
||||
const pub = meaningfulPublicName({
|
||||
public_name: offer.micro_public_name,
|
||||
tier_letter: offer.tier_letter,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={offer.id}
|
||||
className="bg-white rounded-lg border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
|
||||
className="bg-white rounded-xl border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{offer.micro_internal_name}</p>
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Pubblico: {offer.micro_public_name} · {offer.micro_duration_months}{" "}
|
||||
{offer.micro_duration_months === 1 ? "mese" : "mesi"}
|
||||
</p>
|
||||
<p className="text-xs text-[#71717a] mt-1">
|
||||
Inizio: {new Date(offer.start_date).toLocaleDateString("it-IT")}
|
||||
</p>
|
||||
{/* Accepted total inline edit */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{offer.macro_internal_name}</p>
|
||||
{offer.tier_letter && (
|
||||
<span className="inline-block rounded bg-[#1A463C]/10 text-[#1A463C] text-[11px] font-semibold px-1.5 py-0.5">
|
||||
Tier {offer.tier_letter}
|
||||
</span>
|
||||
)}
|
||||
<TypeBadge offerType={offer.macro_offer_type} />
|
||||
</div>
|
||||
{pub && <p className="text-xs text-[#71717a] mt-1">Pubblico: {offer.micro_public_name}</p>}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<label className="text-xs text-[#71717a]">Totale accettato €:</label>
|
||||
<label className="text-xs text-[#71717a]">Totale accettato €</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
@@ -83,11 +173,9 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
placeholder="0.00"
|
||||
onBlur={(e) => {
|
||||
const val = e.currentTarget.value.trim();
|
||||
if (val !== (offer.accepted_total ?? "")) {
|
||||
handleTotalUpdate(offer.id, val);
|
||||
}
|
||||
if (val !== (offer.accepted_total ?? "")) handleTotalUpdate(offer.id, val);
|
||||
}}
|
||||
className="w-24 border rounded px-2 py-0.5 text-xs"
|
||||
className="w-28 border border-[#e5e7eb] rounded-md px-2 py-1 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,70 +183,125 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
|
||||
type="button"
|
||||
onClick={() => handleRemove(offer.id)}
|
||||
disabled={isPending}
|
||||
className="text-xs text-red-600 hover:underline shrink-0"
|
||||
aria-label="Rimuovi offerta"
|
||||
className="shrink-0 rounded-md p-1.5 text-[#a1a1aa] hover:bg-red-50 hover:text-red-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Rimuovi
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assignment form */}
|
||||
{/* Assignment — step 1: offer, step 2: tier */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Micro-Offerta</h3>
|
||||
<form
|
||||
ref={formRef}
|
||||
action={handleAssign}
|
||||
className="bg-white rounded-lg border border-[#e5e7eb] p-4 space-y-3"
|
||||
>
|
||||
<input type="hidden" name="project_id" value={projectId} />
|
||||
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Offerta</h3>
|
||||
|
||||
{offers.length === 0 ? (
|
||||
<p className="text-sm text-[#71717a]">
|
||||
Nessuna offerta disponibile. Crea prima un'offerta in{" "}
|
||||
<a href="/admin/offers" className="text-[#1A463C] underline">Offerte</a>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-4 space-y-4">
|
||||
{/* Step 1 — choose offer */}
|
||||
<div>
|
||||
<label className="text-xs text-[#71717a] block mb-1">Micro-offerta</label>
|
||||
<label className="text-xs text-[#71717a] block mb-1">Offerta</label>
|
||||
<select
|
||||
name="micro_id"
|
||||
required
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
value={macroId}
|
||||
onChange={(e) => selectMacro(e.target.value)}
|
||||
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15"
|
||||
>
|
||||
<option value="">Seleziona micro-offerta...</option>
|
||||
{availableMicros.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.internal_name} ({m.duration_months}{" "}
|
||||
{m.duration_months === 1 ? "mese" : "mesi"})
|
||||
<option value="">Seleziona offerta...</option>
|
||||
{offers.map((o) => (
|
||||
<option key={o.macro_id} value={o.macro_id}>
|
||||
{o.name}
|
||||
{o.category ? ` · ${o.category}` : ""} · {typeLabel(o.offer_type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Step 2 — choose tier */}
|
||||
{selectedOffer && (
|
||||
<div>
|
||||
<label className="text-xs text-[#71717a] block mb-1.5">Tier</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
{selectedOffer.tiers.map((t) => {
|
||||
const active = tierId === t.id;
|
||||
const price = formatEuro(t.public_price);
|
||||
const pub = meaningfulPublicName(t);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTierId(t.id)}
|
||||
className={`text-left rounded-lg border p-3 transition-colors ${
|
||||
active
|
||||
? "border-[#1A463C] bg-[#1A463C]/5 ring-1 ring-[#1A463C]/20"
|
||||
: "border-[#e5e7eb] hover:border-[#1A463C]/30 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-[#1a1a1a]">
|
||||
{t.tier_letter ? `Tier ${t.tier_letter}` : t.public_name || "Tier"}
|
||||
</span>
|
||||
{active && <Check className="h-4 w-4 text-[#1A463C]" />}
|
||||
</div>
|
||||
{pub && <p className="text-xs text-[#71717a] mt-0.5 truncate">{pub}</p>}
|
||||
<p className="text-sm text-[#1a1a1a] mt-1 tabular-nums">{price ?? "—"}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options + submit */}
|
||||
{selectedOffer && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<div>
|
||||
<label className="text-xs text-[#71717a] block mb-1">Totale accettato € (opzionale)</label>
|
||||
<input
|
||||
name="accepted_total"
|
||||
value={acceptedTotal}
|
||||
onChange={(e) => setAcceptedTotal(e.target.value)}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
className="w-full border rounded px-3 py-1.5 text-sm"
|
||||
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || availableMicros.length === 0}
|
||||
className="bg-[#1A463C] text-white text-sm px-4 py-1.5 rounded hover:bg-[#163a31] disabled:opacity-50"
|
||||
>
|
||||
{isPending ? "Salvataggio..." : "Assegna Offerta"}
|
||||
</button>
|
||||
<label className="flex items-start gap-2 text-xs text-[#1a1a1a] cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importPhases}
|
||||
onChange={(e) => setImportPhases(e.target.checked)}
|
||||
className="mt-0.5 h-3.5 w-3.5 accent-[#1A463C]"
|
||||
/>
|
||||
<span>
|
||||
Importa fasi e task dall'offerta
|
||||
<span className="block text-[#71717a]">
|
||||
Crea le fasi raggruppando i servizi per «Fase»; ogni servizio diventa un task.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{availableMicros.length === 0 && (
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Nessuna micro-offerta disponibile. Crea prima una micro-offerta in{" "}
|
||||
<a href="/admin/offers" className="underline">Offerte</a>.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssign}
|
||||
disabled={isPending || !tierId}
|
||||
className="bg-[#1A463C] text-white text-sm font-medium px-4 py-2 rounded-md hover:bg-[#1A463C]/90 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{isPending ? "Assegnazione..." : "Assegna Offerta"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
updatePaymentStatus,
|
||||
updateAcceptedTotal,
|
||||
setPaymentPaidAt,
|
||||
} from "@/app/admin/clients/[id]/actions";
|
||||
import { initProjectPayments } from "@/app/admin/projects/project-actions";
|
||||
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -14,6 +19,7 @@ type Props = {
|
||||
acceptedTotal: string;
|
||||
clientId: string;
|
||||
projectId?: string; // set only from project detail page — enables init & split
|
||||
offersAcceptedTotal?: number; // sum of accepted_total from active offers
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
@@ -22,28 +28,132 @@ const statusLabels: Record<string, string> = {
|
||||
saldato: "Saldato",
|
||||
};
|
||||
|
||||
export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId }: Props) {
|
||||
// paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per <input type="month">
|
||||
function toMonthValue(paidAt: Date | string | null | undefined): string {
|
||||
if (!paidAt) {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
const d = paidAt instanceof Date ? paidAt : new Date(paidAt);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
type PlanMode = "single" | "two" | "three";
|
||||
|
||||
const planOptions: { mode: PlanMode; label: string; description: string }[] = [
|
||||
{ mode: "single", label: "Pagamento unico", description: "100% in un'unica soluzione" },
|
||||
{ mode: "two", label: "2 step", description: "Acconto 50% + Saldo 50%" },
|
||||
{ mode: "three", label: "3 step", description: "50% + 30% + 20%" },
|
||||
];
|
||||
|
||||
export function PaymentsTab({
|
||||
payments,
|
||||
acceptedTotal,
|
||||
clientId,
|
||||
projectId,
|
||||
offersAcceptedTotal = 0,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [overrideValue, setOverrideValue] = useState(acceptedTotal);
|
||||
const [planLoading, setPlanLoading] = useState<PlanMode | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState<string | null>(null);
|
||||
|
||||
const currentTotal = parseFloat(acceptedTotal) || 0;
|
||||
const offersTotal = offersAcceptedTotal;
|
||||
const hasOffers = offersTotal > 0;
|
||||
|
||||
async function handleUseOffersTotal() {
|
||||
if (!projectId) return;
|
||||
setOverrideValue(offersTotal.toFixed(2));
|
||||
const fd = new FormData();
|
||||
fd.set("accepted_total", offersTotal.toFixed(2));
|
||||
await updateAcceptedTotal(projectId, fd);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleSaveTotal(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData();
|
||||
fd.set("accepted_total", overrideValue);
|
||||
await updateAcceptedTotal(clientId, fd);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function handleSetPlan(mode: PlanMode) {
|
||||
if (!projectId) return;
|
||||
setPlanLoading(mode);
|
||||
try {
|
||||
const total = parseFloat(overrideValue) || currentTotal;
|
||||
await setPaymentPlan(projectId, mode, total);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setPlanLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusUpdate(paymentId: string, status: string) {
|
||||
setStatusLoading(paymentId);
|
||||
try {
|
||||
await updatePaymentStatus(paymentId, clientId, status);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setStatusLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePaidMonthUpdate(paymentId: string, monthStr: string) {
|
||||
if (!monthStr) return;
|
||||
setStatusLoading(paymentId);
|
||||
try {
|
||||
await setPaymentPaidAt(paymentId, clientId, monthStr);
|
||||
router.refresh();
|
||||
} finally {
|
||||
setStatusLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-md">
|
||||
{/* Accepted total */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updateAcceptedTotal(clientId, fd);
|
||||
}}
|
||||
className="flex items-end gap-3"
|
||||
{/* Totale preventivo */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<h3 className="font-medium text-gray-900">Totale preventivo</h3>
|
||||
|
||||
{/* Totale ereditato dalle offerte */}
|
||||
{hasOffers && (
|
||||
<div className="flex items-center justify-between bg-[#f9f9f9] border border-[#e5e7eb] rounded-md px-3 py-2 gap-3">
|
||||
<span className="text-sm text-[#71717a]">
|
||||
Ereditato dalle offerte attive:{" "}
|
||||
<span className="font-semibold text-[#1a1a1a]">
|
||||
€{" "}
|
||||
{offersTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</span>
|
||||
{projectId && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleUseOffersTotal}
|
||||
className="shrink-0 text-xs"
|
||||
>
|
||||
Usa questo totale
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Override manuale */}
|
||||
<form onSubmit={handleSaveTotal} className="flex items-end gap-3">
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="accepted_total">Importo (€)</Label>
|
||||
<Label htmlFor="accepted_total">Importo override (€)</Label>
|
||||
<Input
|
||||
id="accepted_total"
|
||||
name="accepted_total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={acceptedTotal}
|
||||
value={overrideValue}
|
||||
onChange={(e) => setOverrideValue(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -51,40 +161,71 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
||||
Salva
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
|
||||
</div>
|
||||
|
||||
{/* Selettore schema rate */}
|
||||
{projectId && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<h3 className="font-medium text-gray-900">Schema di pagamento</h3>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{planOptions.map(({ mode, label, description }) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => handleSetPlan(mode)}
|
||||
disabled={planLoading !== null}
|
||||
className={`flex-1 min-w-[120px] border rounded-lg px-3 py-2.5 text-left transition-colors ${
|
||||
planLoading === mode
|
||||
? "border-[#1A463C] bg-[#1A463C]/5 opacity-70"
|
||||
: "border-[#e5e7eb] hover:border-[#1A463C] hover:bg-[#1A463C]/5"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-[#1a1a1a]">{label}</p>
|
||||
<p className="text-xs text-[#71717a] mt-0.5">{description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Selezionare uno schema cancella le rate esistenti e crea le nuove in base al totale corrente.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state — project has no payments yet */}
|
||||
{projectId && payments.length === 0 && (
|
||||
<div className="bg-white border border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<p className="text-sm text-[#71717a] mb-4">
|
||||
Nessun pagamento configurato. Crea le rate standard Acconto e Saldo.
|
||||
Nessun pagamento configurato. Scegli uno schema sopra o crea le rate standard.
|
||||
</p>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await initProjectPayments(projectId);
|
||||
}}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => handleSetPlan("two")}
|
||||
disabled={planLoading !== null}
|
||||
>
|
||||
<Button type="submit" size="sm">
|
||||
Crea Acconto & Saldo
|
||||
Crea Acconto & Saldo (50/50)
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment rows */}
|
||||
{payments.map((p) => {
|
||||
const amount = parseFloat(p.amount);
|
||||
const pct = p.percent !== null && p.percent !== undefined
|
||||
? parseFloat(String(p.percent))
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
||||
{pct !== null && (
|
||||
<p className="text-xs text-[#71717a]">{pct}% del totale</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
€{" "}
|
||||
@@ -101,16 +242,14 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={p.status}
|
||||
disabled={statusLoading === p.id}
|
||||
onChange={async (e) => {
|
||||
await handleStatusUpdate(p.id, e.target.value);
|
||||
}}
|
||||
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
|
||||
>
|
||||
{Object.entries(statusLabels).map(([val, label]) => (
|
||||
@@ -119,10 +258,25 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
Aggiorna
|
||||
</Button>
|
||||
</form>
|
||||
{statusLoading === p.id && (
|
||||
<span className="text-xs text-[#71717a]">...</span>
|
||||
)}
|
||||
</div>
|
||||
{p.status === "saldato" && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Label htmlFor={`paid-${p.id}`} className="text-xs text-[#71717a] shrink-0">
|
||||
Incassato nel mese
|
||||
</Label>
|
||||
<input
|
||||
id={`paid-${p.id}`}
|
||||
type="month"
|
||||
defaultValue={toMonthValue(p.paid_at)}
|
||||
disabled={statusLoading === p.id}
|
||||
onChange={(e) => handlePaidMonthUpdate(p.id, e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded px-2 py-1 bg-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/app/admin/clients/[id]/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
|
||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||
|
||||
type Props = {
|
||||
@@ -20,8 +21,8 @@ const taskStatusOptions = [
|
||||
];
|
||||
|
||||
const phaseStatusOptions = [
|
||||
{ value: "upcoming", label: "In arrivo" },
|
||||
{ value: "active", label: "Attiva" },
|
||||
{ value: "upcoming", label: "Da iniziare" },
|
||||
{ value: "active", label: "In corso" },
|
||||
{ value: "done", label: "Completata" },
|
||||
];
|
||||
|
||||
@@ -58,6 +59,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900">{phase.title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
@@ -84,6 +86,8 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
Salva
|
||||
</Button>
|
||||
</form>
|
||||
<DeletePhaseTaskButton type="phase" phaseId={phase.id} clientId={clientId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
@@ -94,6 +98,7 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
className="flex items-center justify-between pl-3 border-l-2 border-gray-100"
|
||||
>
|
||||
<span className="text-sm text-gray-800">{task.title}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<form
|
||||
action={async (fd: FormData) => {
|
||||
"use server";
|
||||
@@ -125,6 +130,8 @@ export async function PhasesTab({ phases, clientId }: Props) {
|
||||
✓
|
||||
</Button>
|
||||
</form>
|
||||
<DeletePhaseTaskButton type="task" taskId={task.id} clientId={clientId} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
addQuoteItem,
|
||||
removeQuoteItem,
|
||||
updateAcceptedTotal,
|
||||
} from "@/app/admin/clients/[id]/quote-actions";
|
||||
import type { QuoteItemWithLabel } from "@/lib/admin-queries";
|
||||
import type { Service } from "@/db/schema";
|
||||
|
||||
type Props = {
|
||||
clientId: string;
|
||||
items: QuoteItemWithLabel[];
|
||||
activeServices: Service[];
|
||||
acceptedTotal: string;
|
||||
};
|
||||
|
||||
export function QuoteTab({ clientId, items, activeServices, acceptedTotal }: Props) {
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [totalError, setTotalError] = useState<string | null>(null);
|
||||
// For catalog mode: pre-fill unit_price when service is selected
|
||||
const [selectedServicePrice, setSelectedServicePrice] = useState<string>("");
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
const calculatedTotal = items.reduce(
|
||||
(sum, item) => sum + parseFloat(item.subtotal),
|
||||
0
|
||||
);
|
||||
|
||||
function handleAddItem(fd: FormData) {
|
||||
setAddError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await addQuoteItem(clientId, fd);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setAddError(e instanceof Error ? e.message : "Errore nell'aggiunta");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemove(quoteItemId: string) {
|
||||
startTransition(async () => {
|
||||
await removeQuoteItem(quoteItemId, clientId);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function handleSaveTotal(fd: FormData) {
|
||||
setTotalError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateAcceptedTotal(clientId, fd);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setTotalError(
|
||||
e instanceof Error ? e.message : "Errore nel salvataggio"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
|
||||
{/* Section 1: Add items */}
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
|
||||
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">
|
||||
Aggiungi voci
|
||||
</h3>
|
||||
|
||||
{!showCustom ? (
|
||||
/* Catalog mode */
|
||||
<form action={handleAddItem} className="space-y-3">
|
||||
<input type="hidden" name="custom_label" value="" />
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[180px] space-y-1">
|
||||
<Label htmlFor="service_id">Seleziona dal catalogo</Label>
|
||||
<select
|
||||
name="service_id"
|
||||
id="service_id"
|
||||
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
||||
onChange={(e) => {
|
||||
const svc = activeServices.find((s) => s.id === e.target.value);
|
||||
setSelectedServicePrice(
|
||||
svc ? parseFloat(svc.unit_price).toFixed(2) : ""
|
||||
);
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="">— Scegli servizio —</option>
|
||||
{activeServices.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} (€
|
||||
{parseFloat(s.unit_price).toLocaleString("it-IT", {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-28 space-y-1">
|
||||
<Label htmlFor="unit_price_catalog">Prezzo unit.</Label>
|
||||
<Input
|
||||
id="unit_price_catalog"
|
||||
name="unit_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
value={selectedServicePrice}
|
||||
onChange={(e) => setSelectedServicePrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20 space-y-1">
|
||||
<Label htmlFor="quantity_catalog">Qty</Label>
|
||||
<Input
|
||||
id="quantity_catalog"
|
||||
name="quantity"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
defaultValue="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
||||
>
|
||||
Aggiungi
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCustom(true);
|
||||
setAddError(null);
|
||||
}}
|
||||
className="text-xs text-[#71717a] hover:text-[#1a1a1a] underline"
|
||||
>
|
||||
Oppure aggiungi voce libera →
|
||||
</button>
|
||||
{addError && <p className="text-xs text-red-600">{addError}</p>}
|
||||
</form>
|
||||
) : (
|
||||
/* Freeform mode */
|
||||
<form action={handleAddItem} className="space-y-3">
|
||||
<input type="hidden" name="service_id" value="" />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="custom_label">Nome voce</Label>
|
||||
<Input
|
||||
id="custom_label"
|
||||
name="custom_label"
|
||||
placeholder="es. Consulenza extra, Spese viaggi"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[120px] space-y-1">
|
||||
<Label htmlFor="unit_price_custom">Prezzo unitario (€)</Label>
|
||||
<Input
|
||||
id="unit_price_custom"
|
||||
name="unit_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20 space-y-1">
|
||||
<Label htmlFor="quantity_custom">Qty</Label>
|
||||
<Input
|
||||
id="quantity_custom"
|
||||
name="quantity"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
defaultValue="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{addError && <p className="text-xs text-red-600">{addError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
||||
>
|
||||
Aggiungi voce libera
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCustom(false);
|
||||
setAddError(null);
|
||||
}}
|
||||
>
|
||||
Torna al catalogo
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 2: Quote items table + calculated total */}
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4">
|
||||
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">
|
||||
Voci preventivo
|
||||
</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-[#71717a] py-4 text-center">
|
||||
Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-[#e5e7eb]">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-2 font-medium text-[#71717a]">
|
||||
Voce
|
||||
</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
|
||||
Qty
|
||||
</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
|
||||
Prezzo unit.
|
||||
</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-[#71717a]">
|
||||
Subtotale
|
||||
</th>
|
||||
<th className="py-2 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]"
|
||||
>
|
||||
<td className="py-2 px-2 text-[#1a1a1a]">{item.label}</td>
|
||||
<td className="py-2 px-2 text-right tabular-nums">
|
||||
{parseFloat(item.quantity).toLocaleString("it-IT", {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right tabular-nums font-mono">
|
||||
€
|
||||
{parseFloat(item.unit_price).toLocaleString("it-IT", {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right tabular-nums font-mono font-medium">
|
||||
€
|
||||
{parseFloat(item.subtotal).toLocaleString("it-IT", {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(item.id)}
|
||||
className="text-xs text-[#71717a] hover:text-red-600 transition-colors"
|
||||
>
|
||||
Rimuovi
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-[#e5e7eb] flex justify-end">
|
||||
<p className="font-bold text-[#1a1a1a] tabular-nums">
|
||||
Totale calcolato: €
|
||||
{calculatedTotal.toLocaleString("it-IT", {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 3: Accepted total */}
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-3">
|
||||
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">
|
||||
Totale accettato dal cliente
|
||||
</h3>
|
||||
<form action={handleSaveTotal} className="flex items-end gap-3">
|
||||
<div className="flex-1 max-w-[200px] space-y-1">
|
||||
<Label htmlFor="accepted_total">Importo (€)</Label>
|
||||
<Input
|
||||
id="accepted_total"
|
||||
name="accepted_total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={parseFloat(acceptedTotal).toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
|
||||
>
|
||||
Salva
|
||||
</Button>
|
||||
</form>
|
||||
{totalError && <p className="text-xs text-red-600">{totalError}</p>}
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Il cliente vede solo questo importo, non le singole voci del preventivo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
import { TimerCell } from "@/components/admin/TimerCell";
|
||||
import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard";
|
||||
import { ManualTimeEntry } from "@/components/admin/ManualTimeEntry";
|
||||
|
||||
type TimeEntry = {
|
||||
id: string;
|
||||
started_at: Date;
|
||||
ended_at: Date | null;
|
||||
duration_seconds: number | null;
|
||||
};
|
||||
|
||||
type TimerTabProps = {
|
||||
projectId: string;
|
||||
@@ -10,6 +18,7 @@ type TimerTabProps = {
|
||||
activeTimerStartedAt: Date | null;
|
||||
totalTrackedSeconds: number;
|
||||
targetHourlyRate: number;
|
||||
recentEntries: TimeEntry[];
|
||||
};
|
||||
|
||||
export function TimerTab({
|
||||
@@ -19,6 +28,7 @@ export function TimerTab({
|
||||
activeTimerStartedAt,
|
||||
totalTrackedSeconds,
|
||||
targetHourlyRate,
|
||||
recentEntries,
|
||||
}: TimerTabProps) {
|
||||
return (
|
||||
<div className="space-y-6 max-w-sm">
|
||||
@@ -33,6 +43,8 @@ export function TimerTab({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ManualTimeEntry projectId={projectId} recentEntries={recentEntries} />
|
||||
|
||||
<ProfitabilityCard
|
||||
acceptedTotal={acceptedTotal}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
|
||||
@@ -1,109 +1,157 @@
|
||||
import type { ClientView } from '@/lib/client-view';
|
||||
import type { Comment } from '@/db/schema';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { PhaseTimeline } from './phase-timeline';
|
||||
import { PaymentStatus } from './payment-status';
|
||||
import { DocumentsSection } from './documents-section';
|
||||
import { NotesSection } from './notes-section';
|
||||
import { TranscriptsSection } from './transcripts-section';
|
||||
import { PhaseViewToggle } from './client/kanban/PhaseViewToggle';
|
||||
import { ChatSection } from './client/ChatSection';
|
||||
import { OffersSection } from './client/OffersSection';
|
||||
import { MilestoneStepper } from './client/MilestoneStepper';
|
||||
import { ChatProvider } from './client/ChatProvider';
|
||||
import { ChatPanel } from './client/ChatPanel';
|
||||
|
||||
interface ClientDashboardProps {
|
||||
view: ClientView;
|
||||
token: string;
|
||||
comments: Comment[];
|
||||
/** When rendered inside the multi-project tabs wrapper, the page already
|
||||
* provides the portal header + footer — skip them here to avoid duplicates. */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function ClientDashboard({ view, token, comments }: ClientDashboardProps) {
|
||||
export function ClientDashboard({ view, token, comments, embedded = false }: ClientDashboardProps) {
|
||||
// Determine payment display mode based on active offers
|
||||
const retainerOffer = view.activeOffers?.find((o) => o.offer_type === "retainer");
|
||||
const hasRetainer = !!retainerOffer;
|
||||
|
||||
const paymentLabel = hasRetainer ? "Totale Pagamento Mensile" : "Totale Investimento";
|
||||
const paymentOverrideAmount = hasRetainer && retainerOffer?.accepted_total
|
||||
? retainerOffer.accepted_total
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-5">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0">
|
||||
<ChatProvider phases={view.phases} clientId={view.client.id}>
|
||||
<div className={embedded ? "" : "min-h-screen bg-background"}>
|
||||
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
|
||||
{!embedded && (
|
||||
<header className="sticky top-0 z-50 flex flex-col items-center gap-4 border-b border-border-light bg-card px-6 py-5 shadow-card md:flex-row md:justify-between md:px-8">
|
||||
<div className="flex w-full items-center gap-3 md:w-auto">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
||||
iamcavalli
|
||||
</span>
|
||||
<h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight">
|
||||
<span className="text-border">|</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">Client Portal</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-foreground">
|
||||
{view.client.brand_name}
|
||||
</h1>
|
||||
<div className="w-28 shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-[11px] text-emerald-700 dark:border-emerald-500/20 dark:bg-emerald-500/10 dark:text-emerald-400 md:flex">
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
Area Riservata Protetta
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* Barra progresso globale */}
|
||||
<section className="bg-[#f9f9f9] border-b border-[#e5e5e5]">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-sm font-semibold text-[#1a1a1a]">Avanzamento Progetto</p>
|
||||
<p className="text-sm font-bold text-[#1a1a1a]">{view.global_progress_pct}%</p>
|
||||
</div>
|
||||
<Progress value={view.global_progress_pct} className="h-2" />
|
||||
</div>
|
||||
</section>
|
||||
{/* Milestone stepper — nascosto per retainer (fasi non tracciate) */}
|
||||
{!hasRetainer && (
|
||||
<MilestoneStepper phases={view.phases} globalProgress={view.global_progress_pct} />
|
||||
)}
|
||||
|
||||
{/* Layout principale */}
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 py-8 lg:py-10">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
|
||||
{/* ── Sidebar sinistra ── */}
|
||||
<aside className="w-full lg:w-72 shrink-0 order-2 lg:order-1">
|
||||
<div className="lg:sticky lg:top-[89px] space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Pagamenti</h2>
|
||||
<PaymentStatus accepted_total={view.client.accepted_total} payments={view.payments} />
|
||||
</div>
|
||||
|
||||
{/* 1° Offerte Attive */}
|
||||
{view.activeOffers && view.activeOffers.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Offerte Attive</h2>
|
||||
<h2 className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-3">Offerte Attive</h2>
|
||||
<OffersSection offers={view.activeOffers} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 2° Pagamenti */}
|
||||
<div>
|
||||
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Documenti & File</h2>
|
||||
<h2 className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-3">Pagamenti</h2>
|
||||
<PaymentStatus
|
||||
accepted_total={view.client.accepted_total}
|
||||
payments={view.payments}
|
||||
totalLabel={paymentLabel}
|
||||
overrideAmount={paymentOverrideAmount}
|
||||
hideRows={hasRetainer}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3° Documenti & File */}
|
||||
<div>
|
||||
<h2 className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-3">Documenti & File</h2>
|
||||
<DocumentsSection documents={view.documents} />
|
||||
</div>
|
||||
|
||||
{view.notes.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Note & Decisioni</h2>
|
||||
<h2 className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-3">Note & Decisioni</h2>
|
||||
<NotesSection notes={view.notes} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.transcripts.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-bold text-muted-foreground uppercase tracking-wider mb-3">Transcript Chiamate</h2>
|
||||
<TranscriptsSection transcripts={view.transcripts} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Contenuto principale ── */}
|
||||
<main className="flex-1 min-w-0 order-1 lg:order-2 space-y-12">
|
||||
{hasRetainer ? (
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-foreground tracking-tight mb-4">
|
||||
Fasi del Progetto
|
||||
</h2>
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-border bg-muted px-8 py-12 text-center shadow-card">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-card border border-border-light">
|
||||
<RefreshCw className="h-5 w-5 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-semibold text-foreground">Servizio in abbonamento ricorrente</p>
|
||||
<p className="text-xs text-muted-foreground max-w-xs leading-relaxed">
|
||||
Non ci sono fasi di progetto da tracciare. Le fasi compaiono qui quando viene avviata un'offerta a progetto.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<PhaseViewToggle
|
||||
timelineView={<PhaseTimeline phases={view.phases} token={token} />}
|
||||
phases={view.phases}
|
||||
token={token}
|
||||
/>
|
||||
|
||||
{/* Chat revisioni */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-[#1a1a1a] mb-4">Messaggi & Revisioni</h2>
|
||||
<ChatSection
|
||||
clientId={view.client.id}
|
||||
phases={view.phases}
|
||||
token={token}
|
||||
comments={comments}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Chat "Messaggi & Revisioni" moved to slide-in panel (FAB bottom-right) */}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-[#e5e5e5] bg-[#f9f9f9] mt-10">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
|
||||
<p className="text-xs text-[#999999] text-center">
|
||||
<footer className="mt-10 py-10 text-center text-xs text-muted-foreground">
|
||||
Questa è la tua dashboard privata — non condividere il link.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Floating chat panel — FAB + slide-in panel */}
|
||||
<ChatPanel token={token} comments={comments} />
|
||||
</div>
|
||||
</ChatProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition, useRef, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Comment } from "@/db/schema";
|
||||
import { useChatContext } from "./ChatProvider";
|
||||
|
||||
// TODO: Email-on-tag (admin→client notification when admin posts on a phase/task) is OUT OF SCOPE
|
||||
// Hook point: after db.insert(comments) in /src/app/api/client/comment/route.ts and
|
||||
// postAdminComment in /src/app/admin/clients/[id]/actions.ts — send Resend email here.
|
||||
|
||||
function formatTime(ts: Date | string): string {
|
||||
const d = typeof ts === "string" ? new Date(ts) : ts;
|
||||
return d.toLocaleString("it-IT", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
interface ChatPanelProps {
|
||||
token: string;
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
export function ChatPanel({ token, comments }: ChatPanelProps) {
|
||||
const { isOpen, selectedPhaseId, phases, clientId, openChat, closeChat } = useChatContext();
|
||||
const [body, setBody] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Build entity list: Generale + phases
|
||||
type EntityOption = { id: string; type: "general" | "phase"; label: string };
|
||||
const entityOptions: EntityOption[] = [
|
||||
{ id: clientId, type: "general", label: "Generale" },
|
||||
...phases.map((p) => ({ id: p.id, type: "phase" as const, label: p.title })),
|
||||
];
|
||||
|
||||
// Build label map for displaying tags on existing messages
|
||||
const labelMap = new Map<string, string>();
|
||||
labelMap.set(clientId, "Generale");
|
||||
for (const p of phases) {
|
||||
labelMap.set(p.id, p.title);
|
||||
for (const t of p.tasks) {
|
||||
labelMap.set(t.id, `${p.title} — ${t.title}`);
|
||||
for (const d of t.deliverables) {
|
||||
labelMap.set(d.id, `${t.title} — ${d.title}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Current selection: sync with selectedPhaseId from context
|
||||
const [currentEntityId, setCurrentEntityId] = useState<string>(clientId);
|
||||
useEffect(() => {
|
||||
setCurrentEntityId(selectedPhaseId ?? clientId);
|
||||
}, [selectedPhaseId, clientId]);
|
||||
|
||||
// Sort all comments chronologically
|
||||
const sorted = [...comments].sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [isOpen, comments.length]);
|
||||
|
||||
function handleSend(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const selectedEntity = entityOptions.find((en) => en.id === currentEntityId);
|
||||
if (!selectedEntity) return;
|
||||
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/client/comment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
entity_type: selectedEntity.type,
|
||||
entity_id: selectedEntity.id,
|
||||
body: trimmed,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
setError(data.error ?? "Errore nell'invio");
|
||||
return;
|
||||
}
|
||||
setBody("");
|
||||
router.refresh();
|
||||
} catch {
|
||||
setError("Errore di rete");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* FAB — floating action button bottom-right */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (isOpen ? closeChat() : openChat())}
|
||||
aria-label="Apri messaggi"
|
||||
className="fixed bottom-6 right-6 z-40 w-14 h-14 rounded-full bg-[#1A463C] text-white shadow-lg hover:bg-[#163a31] transition-colors flex items-center justify-center"
|
||||
>
|
||||
{isOpen ? (
|
||||
/* X close icon */
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
/* Chat bubble + plus SVG */
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth={1.8} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.83L3 20l1.09-3.27C3.39 15.56 3 13.83 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Overlay — subtle backdrop on mobile */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/10 lg:hidden"
|
||||
onClick={closeChat}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Slide-in panel — z-[60] so it sits above the sticky page header (z-50) */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full z-[60] w-full sm:w-[420px] bg-white shadow-2xl flex flex-col transition-transform duration-300 ease-in-out ${
|
||||
isOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
aria-label="Messaggi & Revisioni"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{/* Vertical "Chiudi" tab — notebook-divider style, protrudes from the left edge */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeChat}
|
||||
aria-label="Chiudi"
|
||||
className="absolute left-0 top-1/2 flex -translate-x-full -translate-y-1/2 flex-col items-center gap-2 rounded-l-xl bg-[#1A463C] px-2 py-4 text-white shadow-lg transition-colors hover:bg-[#163a31]"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2.2} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] [writing-mode:vertical-rl]">
|
||||
Chiudi
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Panel header */}
|
||||
<div className="flex items-center px-5 py-4 border-b border-[#e5e7eb] shrink-0">
|
||||
<h2 className="text-base font-bold text-[#1a1a1a]">Messaggi & Revisioni</h2>
|
||||
</div>
|
||||
|
||||
{/* Chat feed */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{sorted.length === 0 && (
|
||||
<p className="text-sm text-[#71717a] italic text-center py-10">
|
||||
Nessun messaggio ancora. Scrivi qui sotto per iniziare.
|
||||
</p>
|
||||
)}
|
||||
{sorted.map((c) => {
|
||||
const isClient = c.author === "client";
|
||||
const entityLabel = labelMap.get(c.entity_id);
|
||||
const showTag = c.entity_id !== clientId && entityLabel;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`flex flex-col gap-1 ${isClient ? "items-end" : "items-start"}`}
|
||||
>
|
||||
{/* Author + tag */}
|
||||
<div className={`flex items-center gap-2 ${isClient ? "flex-row-reverse" : ""}`}>
|
||||
<span
|
||||
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full ${
|
||||
isClient
|
||||
? "bg-[#DEF168] text-[#1A463C]"
|
||||
: "bg-[#1A463C] text-white"
|
||||
}`}
|
||||
>
|
||||
{isClient ? "Tu" : "iamcavalli"}
|
||||
</span>
|
||||
{showTag && (
|
||||
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-2 py-0.5 rounded-full truncate max-w-[160px]">
|
||||
{entityLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
className={`max-w-[80%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed ${
|
||||
isClient
|
||||
? "bg-[#1A463C] text-white rounded-tr-sm"
|
||||
: "bg-[#f4f4f5] text-[#1a1a1a] rounded-tl-sm"
|
||||
}`}
|
||||
>
|
||||
{c.body}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="text-[10px] text-[#71717a]">{formatTime(c.created_at)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-[#e5e7eb]" />
|
||||
|
||||
{/* Composer */}
|
||||
<form onSubmit={handleSend} className="p-3 space-y-2 bg-[#fafafa] shrink-0">
|
||||
{/* Tag selector: Generale + phases */}
|
||||
<select
|
||||
value={currentEntityId}
|
||||
onChange={(e) => setCurrentEntityId(e.target.value)}
|
||||
className="w-full text-xs border border-[#e5e7eb] rounded-lg px-3 py-1.5 bg-white text-[#1a1a1a] focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
||||
>
|
||||
{entityOptions.map((en) => (
|
||||
<option key={en.id} value={en.id}>
|
||||
{en.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend(e as unknown as React.FormEvent);
|
||||
}
|
||||
}}
|
||||
placeholder="Scrivi un messaggio… (Invio per inviare)"
|
||||
rows={2}
|
||||
className="flex-1 text-sm border border-[#e5e7eb] rounded-lg px-3 py-2 bg-white resize-none focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!body.trim()}
|
||||
className="self-end px-4 py-2 rounded-lg bg-[#1A463C] text-white text-sm font-semibold disabled:opacity-40 hover:bg-[#1A463C]/90 transition-colors"
|
||||
>
|
||||
Invia
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback } from "react";
|
||||
import type { ClientView } from "@/lib/client-view";
|
||||
|
||||
interface ChatContextValue {
|
||||
isOpen: boolean;
|
||||
selectedPhaseId: string | null; // null = "Generale"
|
||||
phases: ClientView["phases"];
|
||||
clientId: string;
|
||||
openChat: (phaseId?: string) => void;
|
||||
closeChat: () => void;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextValue | null>(null);
|
||||
|
||||
export function useChatContext() {
|
||||
const ctx = useContext(ChatContext);
|
||||
if (!ctx) throw new Error("useChatContext must be used inside ChatProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
children,
|
||||
phases,
|
||||
clientId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
phases: ClientView["phases"];
|
||||
clientId: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
|
||||
|
||||
const openChat = useCallback((phaseId?: string) => {
|
||||
setSelectedPhaseId(phaseId ?? null);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeChat = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={{ isOpen, selectedPhaseId, phases, clientId, openChat, closeChat }}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { ClientView } from "@/lib/client-view";
|
||||
|
||||
type Phase = ClientView["phases"][number];
|
||||
|
||||
const statusWord: Record<"upcoming" | "active" | "done", string> = {
|
||||
done: "Completata",
|
||||
active: "In corso",
|
||||
upcoming: "Da iniziare",
|
||||
};
|
||||
|
||||
const statusWordClass: Record<"upcoming" | "active" | "done", string> = {
|
||||
done: "text-emerald-600 dark:text-emerald-400",
|
||||
active: "text-amber-600 dark:text-amber-400",
|
||||
upcoming: "text-muted-foreground",
|
||||
};
|
||||
|
||||
/**
|
||||
* Horizontal milestone stepper — replica del "STEP PROGRESS TIMELINE" del mock.
|
||||
* Un nodo per fase reale (non 5 fissi); la linea di fondo si riempie in base a
|
||||
* global_progress_pct.
|
||||
*/
|
||||
export function MilestoneStepper({
|
||||
phases,
|
||||
globalProgress,
|
||||
}: {
|
||||
phases: Phase[];
|
||||
globalProgress: number;
|
||||
}) {
|
||||
if (phases.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border-b border-border-light py-8 px-6">
|
||||
<div className="relative mx-auto max-w-[1200px]">
|
||||
{/* Linea di connessione + riempimento dinamico */}
|
||||
<div className="absolute left-[5%] right-[5%] top-[15px] z-0 h-1 rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-500 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
||||
style={{ width: `${globalProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nodi milestone */}
|
||||
<div
|
||||
className="relative z-10 grid gap-4"
|
||||
style={{ gridTemplateColumns: `repeat(${phases.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{phases.map((phase, i) => {
|
||||
const s = phase.status;
|
||||
return (
|
||||
<div key={phase.id} className="flex flex-col items-center text-center">
|
||||
<div
|
||||
className={
|
||||
"flex h-8 w-8 items-center justify-center rounded-full border-4 border-card text-xs font-bold shadow-md transition-all " +
|
||||
(s === "done"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: s === "active"
|
||||
? "border-primary bg-card text-primary"
|
||||
: "bg-muted text-muted-foreground")
|
||||
}
|
||||
>
|
||||
{s === "done" ? "✓" : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
"mt-2 text-[11px] font-bold " +
|
||||
(s === "upcoming" ? "text-muted-foreground" : "text-foreground")
|
||||
}
|
||||
>
|
||||
Step {i + 1}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground line-clamp-1">
|
||||
{phase.title}
|
||||
</span>
|
||||
<span className={`mt-1 text-[9px] font-bold uppercase ${statusWordClass[s]}`}>
|
||||
{statusWord[s]}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
interface IncludedService {
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface ActiveOffer {
|
||||
id: string;
|
||||
public_name: string; // micro offer public name only (security: see T-05-10)
|
||||
public_name: string; // micro offer public name — NOT shown to client (T-05-10)
|
||||
offer_name: string; // macro public_name — shown as heading
|
||||
offer_type: string; // una_tantum | retainer
|
||||
cumulative_price: string; // sum of service prices
|
||||
accepted_total: string | null;
|
||||
services: IncludedService[];
|
||||
}
|
||||
|
||||
interface OffersSectionProps {
|
||||
offers: ActiveOffer[];
|
||||
}
|
||||
|
||||
function OfferCard({ offer }: { offer: ActiveOffer }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const price = parseFloat(offer.cumulative_price);
|
||||
const hasPrice = price > 0;
|
||||
const hasServices = offer.services.length > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border-light shadow-card overflow-hidden">
|
||||
{/* Main info rows — accento a barra sinistra (mock) */}
|
||||
<div className="p-5">
|
||||
<div className="border-l-2 border-primary pl-3">
|
||||
{/* Heading: macro public name */}
|
||||
<p className="text-sm font-bold text-foreground">{offer.offer_name}</p>
|
||||
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{/* "Valore incluso" — hidden when 0 */}
|
||||
{hasPrice && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Valore incluso</span>
|
||||
<span className="font-mono text-foreground">€{price.toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* "Prezzo finale" */}
|
||||
{offer.accepted_total && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold text-foreground">Prezzo finale</span>
|
||||
<span className="text-sm font-bold text-primary">
|
||||
€{parseFloat(offer.accepted_total).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Accordion "Cosa è compreso" — only when there are services */}
|
||||
{hasServices && (
|
||||
<div className="border-t border-border-light">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between px-5 py-2.5 text-xs font-semibold text-foreground hover:bg-muted transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span>Cosa è compreso</span>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 text-muted-foreground transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul className="px-5 pb-3 space-y-2">
|
||||
{offer.services.map((svc, i) => (
|
||||
<li key={i} className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium text-foreground">{svc.name}</span>
|
||||
{svc.description && (
|
||||
<span className="text-[11px] text-muted-foreground leading-snug">{svc.description}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OffersSection({ offers }: OffersSectionProps) {
|
||||
if (offers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{offers.map((offer) => (
|
||||
<div key={offer.id} className="bg-white rounded-lg border border-[#e5e5e5] p-4">
|
||||
<p className="text-sm font-semibold text-[#1a1a1a]">{offer.public_name}</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[#71717a]">Valore incluso</span>
|
||||
<span className="text-xs font-mono text-[#1a1a1a]">
|
||||
€{parseFloat(offer.cumulative_price).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
{offer.accepted_total && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-[#1a1a1a]">Prezzo finale</span>
|
||||
<span className="text-sm font-bold text-[#1A463C]">
|
||||
€{parseFloat(offer.accepted_total).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<OfferCard key={offer.id} offer={offer} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ApproveButton } from "@/components/client/ApproveButton";
|
||||
import { useChatContext } from "@/components/client/ChatProvider";
|
||||
import type { ClientView } from "@/lib/client-view";
|
||||
|
||||
type Phase = ClientView["phases"][number];
|
||||
|
||||
const phaseStatusLabel: Record<"upcoming" | "active" | "done", string> = {
|
||||
upcoming: "Da iniziare",
|
||||
active: "In corso",
|
||||
done: "Completata",
|
||||
};
|
||||
|
||||
// Soft-tint status pill — replica del mock ("Quiet Luxury")
|
||||
const phaseStatusStyle: Record<"upcoming" | "active" | "done", string> = {
|
||||
upcoming: "bg-muted text-muted-foreground border border-border",
|
||||
active:
|
||||
"bg-amber-50 text-amber-700 border border-amber-100 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20",
|
||||
done: "bg-emerald-50 text-emerald-700 border border-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20",
|
||||
};
|
||||
|
||||
// Colore della barra di avanzamento per stato fase
|
||||
const phaseBarColor: Record<"upcoming" | "active" | "done", string> = {
|
||||
upcoming: "bg-border",
|
||||
active: "bg-amber-500",
|
||||
done: "bg-emerald-600",
|
||||
};
|
||||
|
||||
function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) {
|
||||
if (status === "done") {
|
||||
return (
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-50 text-[10px] font-bold text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">
|
||||
✓
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "in_progress") {
|
||||
return (
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-amber-400 bg-card">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-border bg-card" />
|
||||
);
|
||||
}
|
||||
|
||||
export function PhaseCard({
|
||||
phase,
|
||||
token,
|
||||
defaultOpen,
|
||||
}: {
|
||||
phase: Phase;
|
||||
token: string;
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const { openChat } = useChatContext();
|
||||
const doneCount = phase.tasks.filter((t) => t.status === "done").length;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border-light bg-card p-6 shadow-card">
|
||||
{/* Header — always visible, click to toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<h3 className="text-sm font-bold text-foreground leading-snug min-w-0">{phase.title}</h3>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className={`rounded px-2.5 py-0.5 text-[10px] font-bold uppercase ${phaseStatusStyle[phase.status]}`}
|
||||
>
|
||||
{phaseStatusLabel[phase.status]}
|
||||
</span>
|
||||
{/* Chat bubble — opens panel pre-tagged with this phase */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // don't toggle accordion
|
||||
openChat(phase.id);
|
||||
}}
|
||||
aria-label="Apri chat per questa fase"
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/5 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={1.8} viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.77 9.77 0 01-4-.83L3 20l1.09-3.27C3.39 15.56 3 13.83 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
className={`w-4 h-4 text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar — always visible, colore per stato */}
|
||||
<div className="mb-1">
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<p className="text-xs text-muted-foreground font-medium">{doneCount} di {phase.tasks.length} task</p>
|
||||
<p className="text-xs font-semibold text-foreground">{phase.progress_pct}%</p>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${phaseBarColor[phase.status]}`}
|
||||
style={{ width: `${phase.progress_pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Collapsible task list */}
|
||||
{open && (
|
||||
<div className="mt-4 border-t border-border-light pt-4">
|
||||
{phase.tasks.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">Nessun task ancora configurato.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{phase.tasks.map((task) => (
|
||||
<li key={task.id} className="flex items-start gap-3">
|
||||
<TaskStatusIcon status={task.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-xs leading-snug ${
|
||||
task.status === "done"
|
||||
? "line-through text-muted-foreground"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
{task.description && (
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
{task.deliverables.length > 0 && (
|
||||
<ul className="mt-1.5 space-y-2">
|
||||
{task.deliverables.map((d) => (
|
||||
<li
|
||||
key={d.id}
|
||||
className="bg-muted rounded-lg px-3 py-2 flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground truncate font-medium">
|
||||
{d.title}
|
||||
</span>
|
||||
{(d.status === "pending" ||
|
||||
d.status === "submitted" ||
|
||||
d.approved_at !== null) && (
|
||||
<ApproveButton
|
||||
deliverableId={d.id}
|
||||
token={token}
|
||||
approvedAt={d.approved_at}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,29 +7,29 @@ type Task = ClientView["phases"][number]["tasks"][number] & {
|
||||
phaseTitle: string;
|
||||
};
|
||||
|
||||
const COLUMNS: { id: "todo" | "in_progress" | "done"; label: string; dotClass: string; headerClass: string }[] = [
|
||||
{ id: "todo", label: "Da fare", dotClass: "bg-[#d4d4d8]", headerClass: "text-[#71717a]" },
|
||||
{ id: "in_progress", label: "In corso", dotClass: "bg-[#DEF168]", headerClass: "text-[#1A463C]" },
|
||||
{ id: "done", label: "Fatto", dotClass: "bg-[#1A463C]", headerClass: "text-[#1A463C]" },
|
||||
const COLUMNS: { id: "todo" | "in_progress" | "done"; label: string }[] = [
|
||||
{ id: "todo", label: "Da fare" },
|
||||
{ id: "in_progress", label: "In corso" },
|
||||
{ id: "done", label: "Fatto" },
|
||||
];
|
||||
|
||||
function TaskCard({ task, token }: { task: Task; token: string }) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-[#e5e7eb] px-3 py-2.5 shadow-sm">
|
||||
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
|
||||
<div className="bg-card rounded-lg border border-border-light px-4 py-4 shadow-sm hover:border-primary/30 hover:shadow-card-hover transition-all">
|
||||
<p className="text-[9px] font-semibold text-muted-foreground uppercase tracking-wide mb-1 truncate">
|
||||
{task.phaseTitle}
|
||||
</p>
|
||||
<p className={`text-sm font-medium leading-snug mb-2 ${task.status === "done" ? "line-through text-[#71717a]" : "text-[#1a1a1a]"}`}>
|
||||
<p className={`text-xs font-medium leading-snug ${task.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
{task.description && (
|
||||
<p className="text-xs text-[#71717a] mb-2 leading-snug">{task.description}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-2 leading-snug">{task.description}</p>
|
||||
)}
|
||||
{task.deliverables.length > 0 && (
|
||||
<ul className="space-y-1.5 mt-2 pt-2 border-t border-[#f4f4f5]">
|
||||
<ul className="space-y-1.5 mt-2 pt-2 border-t border-border-light">
|
||||
{task.deliverables.map((d) => (
|
||||
<li key={d.id} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-[#666666] truncate">{d.title}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{d.title}</span>
|
||||
{(d.status === "pending" || d.status === "submitted" || d.approved_at !== null) && (
|
||||
<ApproveButton deliverableId={d.id} token={token} approvedAt={d.approved_at} />
|
||||
)}
|
||||
@@ -53,24 +53,21 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
{COLUMNS.map((col) => (
|
||||
<div key={col.id} className="flex flex-col rounded-xl border border-[#e5e7eb] bg-[#f9f9f9] min-h-[200px]">
|
||||
<div className={`px-4 py-3 flex items-center justify-between ${col.headerClass}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${col.dotClass}`} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">{col.label}</span>
|
||||
</div>
|
||||
<span className="text-xs font-semibold tabular-nums bg-white rounded-full px-2 py-0.5 border border-[#e5e7eb]">
|
||||
<div key={col.id} className="flex flex-col gap-4 rounded-xl border border-border-light bg-muted/60 p-4 min-h-[300px]">
|
||||
<div className="flex items-center justify-between border-b border-border-light pb-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">{col.label}</span>
|
||||
<span className="text-[10px] font-bold tabular-nums bg-secondary text-secondary-foreground rounded-full px-2 py-0.5">
|
||||
{tasksByStatus[col.id].length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 p-3 space-y-2">
|
||||
<div className="flex-1 flex flex-col gap-3">
|
||||
{tasksByStatus[col.id].map((task) => (
|
||||
<TaskCard key={task.id} task={task} token={token} />
|
||||
))}
|
||||
{tasksByStatus[col.id].length === 0 && (
|
||||
<p className="text-xs text-[#d4d4d8] italic text-center py-10">Nessun task</p>
|
||||
<p className="text-xs text-muted-foreground italic text-center py-10">Nessun task</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,20 +18,20 @@ export function PhaseViewToggle({
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-[#1a1a1a]">Fasi del Progetto</h2>
|
||||
<div className="flex items-center gap-1 bg-[#f4f4f5] rounded-lg p-1">
|
||||
<h2 className="text-lg font-bold text-foreground tracking-tight">Fasi del Progetto</h2>
|
||||
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setView("timeline")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
||||
view === "timeline" ? "bg-white text-[#1A463C] shadow-sm" : "text-[#71717a] hover:text-[#1a1a1a]"
|
||||
className={`px-5 py-1.5 rounded-md text-[11px] font-semibold transition-all ${
|
||||
view === "timeline" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Timeline
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView("kanban")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
|
||||
view === "kanban" ? "bg-white text-[#1A463C] shadow-sm" : "text-[#71717a] hover:text-[#1a1a1a]"
|
||||
className={`px-5 py-1.5 rounded-md text-[11px] font-semibold transition-all ${
|
||||
view === "kanban" ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Kanban
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user