Compare commits

...

19 Commits

Author SHA1 Message Date
simone 26d752892a style: align light palette to Quiet Luxury reference
- Page background now #F8F9FA (was white); tokens matched to reference:
  foreground slate-900, muted slate-50/slate-500, border slate-200,
  card pure white, tertiary slate-400, border-light slate-100
- Sidebar colors matched to mock: emerald-200/70 inactive, white/8 active
  with emerald-400 active icon, emerald-50 logo, red-300 logout, right border
- Content wrapped in max-w-[1400px] centered container (px-8 lg:px-10)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:33:00 +02:00
simone 9eb2d45c67 fix: sidebar width/centering + kanban card badge & table details
- Sidebar: expanded 256->200px, collapsed 80->64px; collapsed state now
  renders icon-only (label removed from flow) so icons are centered, not
  pushed left by an invisible text span
- Kanban cards: add StatusBadge + email line to match design reference
- Lead table: add "Mostrando N lead" footer and center/right-align
  Stato/Tag/Azioni headers per reference

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:28:43 +02:00
simone 86e1499e8f feat: Quiet Luxury design system + Pipeline (ex-Lead) redesign
- Design system foundations: Plus Jakarta Sans font, shadow-card/radius
  tokens in @theme, design-reference/DESIGN-SYSTEM.md with component inventory
- Collapsible admin shell (AdminShell): smooth w-64<->w-20 sidebar, new top
  header with "Admin" + avatar placeholder, localStorage-persisted state
- Rename route /admin/leads -> /admin/pipeline (redirect stubs preserved),
  nav label Lead -> Pipeline; DB table unchanged
- Reusable primitives: StatusBadge, SearchInput, SegmentedToggle (dual-theme)
- Luxury restyle of lead table, kanban (6 stages + @dnd-kit intact), PageHeader
- Tokenize editable-cell / option-multi-select for dark-mode legibility

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:16:44 +02:00
simone 43cb7e7469 feat: dark/light theming system + design tokens (OMC design system port)
- Restructure design tokens into :root/.dark raw vars mapped via @theme inline
- Add light/dark/system theming: useTheme hook + FOUC guard + ThemeToggle
- Keep iamcavalli palette (primary #1A463C, accent #DEF168) and Geist fonts
- Derive brand-consistent dark palette (verde-nero bg, lightened primary)
- Add scrollbar utilities (.no-scrollbar/.thin-scrollbar)
- Install tailwindcss-animate, register via @plugin (Tailwind v4)
- Mount ThemeToggle in admin sidebar footer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 08:08:56 +01:00
simone add2176a6b feat: tier names + per-tier prices on offer cards, unified admin UI
- offer editor: per-tier name input (mirrors public_name/internal_name)
- offer list cards: show 3-tier services total + manual public price
- shared PageHeader component, full-width layout across all admin pages
- UI-RULES.md design conventions doc

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:58:18 +02:00
simone 9abe1fe4bb fix: offer name in client detail, retainer forecast, LTV with offers, offers-sold chart
- Client detail offers: show offer macro name ("Mantenimento") instead of tier letter
- Forecast: retainers project the monthly fee across every month from start_date
  (no longer capped by duration_months); una_tantum unchanged
- Clients list LTV: per project max(accepted_total, sum of assigned offers) so a
  retainer with unset quote still counts
- Dashboard: new "Offerte vendute" chart (count by offer + tier)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:14:13 +02:00
simone ae355c33a6 feat: offer badges, unified dashboard, income forecast, Pipedrive-style follow-up
- Client detail: category + tier badges on active offers
- Dashboard: remove top KPI cards + recent activity; fold current KPIs into
  bottom MetricCard style; add 12-month income forecast chart
- Forecast query: branch by offer_type (retainer = monthly fee, una_tantum =
  spread over duration), filter archived projects
- Payments: set the month a payment was collected (paid_at) from PaymentsTab
- Redesign FollowUpWidget in clean Pipedrive style (brand green, no orange)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:28:12 +02:00
simone fc766ca1ee feat: add iamcavalli logo as favicon
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 21:51:05 +02:00
simone 0b8a7b3809 feat: add Opus/Sonnet model selector to "Genera preventivo" page
Adds a "Modello AI" toggle (Opus default, Sonnet option) styled like the
existing Soggetto toggle. The server action validates the keyword and maps
it to the real model id; the agent receives the resolved id and uses it in
the Anthropic messages.create call. The proposals DB record stores the
actual model used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:34:04 +02:00
simone e10d1f70cb fix: cumulative price from offer_tier_services, offers accordion, retainer phase placeholder
- client-view.ts: read cumulative_price + services list from offer_tier_services→services
  (Phase 12 catalog); legacy fallback to offer_micro_services→offer_services when no
  new-style rows exist. Extend activeOffers type with services[] in both ProjectView and ClientView.
- OffersSection.tsx: extract OfferCard client component with useState accordion;
  hide "Valore incluso" row when price is 0; add "Cosa è compreso" chevron toggle
  listing included services (name + description when present).
- client-dashboard.tsx: hide progress bar and PhaseViewToggle when hasRetainer;
  render polished empty-state placeholder with RefreshCw icon instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:53:25 +02:00
simone 64030afef3 feat: sidebar CTA removal, delete phase/task, public dashboard UX, chat panel
1. AdminSidebar: removed yellow "Genera preventivo" CTA (duplicated Preventivi tab nav item)
2. Delete phase/task: new deletePhase/deleteTask server actions with FK cascade and phase-status recompute; DeletePhaseTaskButton client component with window.confirm; trash icons in PhasesTab per phase and per task
3. Public dashboard: extend activeOffers query with offer_macros join (offer_name, offer_type); reorder sidebar 1°Offerte 2°Pagamenti 3°Documenti; OffersSection shows macro public_name as heading (never tier letter/internal_name); PaymentStatus gains totalLabel/overrideAmount/hideRows props — retainer=monthly label + no Acconto/Saldo rows
4. Offer editor: Descrizione textarea (nota interna) and Modalità toggle moved directly under title; Tags section now contains only Categoria/Ticket/Tipo/Obiettivo
5. Basecamp chat: API route supports entity_type="phase" with authorization scoping; comments scope in client-view broadened to include phaseIds and client_id (general); CommentsTab updated with phase/general entities; ChatProvider React context; ChatPanel fixed slide-in panel with FAB (bottom-right) and composer tag selector (Generale + phases); PhaseCard gets chat-bubble icon pre-tagging that phase; inline ChatSection block removed from dashboard main column

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 14:11:55 +02:00
simone 4b28f254ba docs: correct DB-access procedure (direct SSH+docker exec, remote is gitea)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:39:45 +02:00
simone 186bb9ea19 feat: payments plans, phase auto-cascade, transcripts, manual timer
- Pagamenti: totale ereditato dalla somma accepted_total delle offerte
  attive (con override) + selettore schema rate 1/2/3 step; nuova colonna
  payments.percent per rescalare gli importi preservando label/stato
- Fasi & Task: recomputePhaseStatus auto-cascade (task -> fase) su
  updateTaskStatus/addTask, fix UI stale, etichette Da iniziare/In corso/Completata
- Pagina pubblica: colori fasi (grigio/blu/#1A463C) + fasi collassabili
- Transcript del cliente visibili in tab Documenti admin e dashboard pubblica
- Timer: inserimento manuale (+30/+60, minuti liberi, data) + elimina entry
- CLAUDE.md: procedura Deploy & DB Access; push su main automatico

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 10:38:56 +02:00
simone 988f6f425a docs: add STATUS.md session handoff 2026-06-22 14:34:17 +02:00
simone f98828f75e feat: offer_type (una tantum/retainer), dedup tiers, redesigned Offerte tab
- offer_macros.offer_type ('una_tantum'|'retainer') + editor "Modalità" toggle
  (migration 0012, applied to prod)
- migration 0012 also adds UNIQUE(macro_id, tier_letter) — prevents duplicate
  tiers; ran after a one-time dedup of Web Domination's duplicate A/B/C tiers
- OffersTab redesigned: two-step assign (Offer → Tier cards with price), type
  badge "Una tantum/Ricorrente" instead of misleading "X mesi", no redundant
  "· A" when public_name == tier letter, cleaner active-offers cards
- getProjectFullDetail: availableMicros grouped by macro + defensive dedup;
  projectOffers/availableMicros carry offer_type/category/price
- proposal deck PricingSection shows offer type (fallback to duration for
  pre-existing proposals)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:32:59 +02:00
simone 1824cb643f feat: wire offer→project phases/tasks, redo Offerte tab, cleanup project tabs
- importOfferIntoProject(): materializes project phases/tasks from the assigned
  tier's services grouped by services.fase (merge-by-title, dedup tasks)
- assignOfferToProject: optional import_phases flag triggers the import
- getProjectFullDetail: projectOffers/availableMicros now include macro name +
  tier_letter (dropdown shows "Offerta — Tier X"); availableMicros filters
  non-archived macros
- OffersTab redone: shows macro + tier badge, "Importa fasi e task" checkbox
- removed project "Preventivo" tab + deleted QuoteTab.tsx (legacy quote_items)
- sidebar: Lead before Clienti
- no DB migration (reuses existing tables)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:08:28 +02:00
simone f5f90cd643 feat: lead → client conversion + client contact fields (email/phone)
Blocco A+B (milestone v2.3). Migration 0011 is additive (ADD COLUMN only):
clients.email, clients.phone, leads.archived.

- createClientCore() extracted from createClient and reused by conversion
- clients.email/phone added to create + edit forms and shown on client header
  (never exposed on the public /client/[token] page)
- convertLeadToClient(): reuses core, carries over lead transcripts
  (client_transcripts.client_id), links project.created_from_lead_id,
  archives the lead while keeping its "won" status; idempotent
- "Converti in cliente" button on won leads; "Convertito → Apri cliente"
  once converted (clientId resolved via created_from_lead_id)
- archived leads hidden from list/kanban; detail page fetches by id incl. archived

Migration must be applied to the prod DB BEFORE this is deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:18:41 +02:00
simone e1b3e8c3d5 style: refine settings taxonomy UI (field cells, icon headers, 2-col grid)
- PoolManager: defined field cells with value count, refined chips,
  inline ghost + button, focus ring, pending/empty states
- TaxonomyManager: icon-badged section headers, ordered 2-col grid,
  sync explainer note
- Settings page: wider container (max-w-4xl) for the grid to breathe

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:46:13 +02:00
simone e80c95f838 feat: centralized Notion-style taxonomy management in settings
Single persistent option pool per taxonomy (7 fields: offer
categoria/ticket/tipo/obiettivo + catalog fase/offerta/pacchetto),
stored as JSON in the settings table (no DB migration).

- src/lib/taxonomy.ts: pools with lazy seed from in-use values,
  add/remove(cascade)/rename helpers
- Inline creation anywhere registers into the pool (save offer,
  addServiceOption, updateServiceField fase, quickAdd, create macro)
- Deselecting on a row never touches the pool; global delete only
  from settings (cascade-strips tags / nulls columns)
- getOfferFieldOptions + getCatalogFieldOptions read from pools
- Settings: TaxonomyManager (offer + catalog groups), confirm on delete

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:24:47 +02:00
106 changed files with 7403 additions and 1782 deletions
+12 -6
View File
@@ -4,8 +4,8 @@ milestone: v2.3
milestone_name: Email & Accesso milestone_name: Email & Accesso
status: planning status: planning
stopped_at: "" stopped_at: ""
last_updated: "2026-06-21T11:05:00.000Z" last_updated: "2026-06-22T09:00:00.000Z"
last_activity: 2026-06-21 -- Roadmap v2.3 created (Phases 23-25) last_activity: 2026-06-22 -- Lead→Cliente (A+B) in prod; cleanup tab progetto + offerta→fasi in corso
progress: progress:
total_phases: 3 total_phases: 3
completed_phases: 0 completed_phases: 0
@@ -26,10 +26,16 @@ See: .planning/PROJECT.md (updated 2026-06-21)
## Current Position ## Current Position
Phase: Not started Phase: Pre-23 fixes & flow wiring (fuori roadmap formale)
Plan: Plan: `.claude/plans/te-li-scrivo-tutti-lucky-hearth.md`
Status: Roadmap created, ready to plan Phase 23 Status: In esecuzione — cleanup tab progetto + collegamento offerta→fasi/task
Last activity: 2026-06-21Roadmap v2.3 created (Phases 2325, 9 requirements mapped) Last activity: 2026-06-22Lead→Cliente consegnato in prod
### Lavoro recente (pre-fase-23, in prod)
- **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`.
### Fasi completate (v2.2, storico) ### Fasi completate (v2.2, storico)
+68
View File
@@ -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 (12801440px). 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 883942 — `LeadWithTags` type, `getLeadsWithTags()` query (all fields), `LeadFieldOptions`
- `src/db/schema.ts` lines 441462 — `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)
+11 -3
View File
@@ -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 - 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 - 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 ## Security
- Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE, infra changes) - Confirm before any destructive command (rm -rf, reset --hard, force push, DROP TABLE / drop-column, truncate, infra changes)
- Never read/expose .env or credentials without explicit request - 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 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 - Any change to this section: propose full new version, get approval before applying
+40
View File
@@ -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) |
+148
View File
@@ -0,0 +1,148 @@
# 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 300700), 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. |
+361
View File
@@ -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>
+270
View File
@@ -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>
+484
View File
@@ -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>
+405
View File
@@ -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>
+366
View File
@@ -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>
+238
View File
@@ -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>
+10 -1
View File
@@ -33,6 +33,7 @@
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.75.0", "react-hook-form": "^7.75.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
@@ -9308,9 +9309,17 @@
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"dev": true,
"license": "MIT" "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": { "node_modules/tapable": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+1
View File
@@ -34,6 +34,7 @@
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-hook-form": "^7.75.0", "react-hook-form": "^7.75.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
+10 -1
View File
@@ -7,6 +7,7 @@ import { eq, and } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { addPoolValue, renamePoolValue } from "@/lib/taxonomy";
const serviceSchema = z.object({ const serviceSchema = z.object({
name: z.string().min(1, "Nome richiesto"), name: z.string().min(1, "Nome richiesto"),
@@ -95,6 +96,7 @@ export async function updateServiceField(
} else if (fieldName === "fase") { } else if (fieldName === "fase") {
const s = String(value).trim(); const s = String(value).trim();
await db.update(services).set({ fase: s || null }).where(eq(services.id, serviceId)); 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") { } else if (fieldName === "unit_price") {
// Normalize locale-formatted input (WR-04): the cell displays it-IT (€1.234,50), // 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 // 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 }) .values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed })
.onConflictDoNothing(); .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"); revalidatePath("/admin/catalog");
} }
@@ -187,10 +192,12 @@ export async function renameServiceOption(
.update(tags) .update(tags)
.set({ name: next }) .set({ name: next })
.where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue))); .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") { } else if (field === "categoria") {
await db.update(services).set({ category: next }).where(eq(services.category, oldValue)); await db.update(services).set({ category: next }).where(eq(services.category, oldValue));
} else if (field === "fase") { } else if (field === "fase") {
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue)); await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
await renamePoolValue("service_fase", oldValue, next);
} else { } else {
throw new Error(`Campo non valido: ${field}`); throw new Error(`Campo non valido: ${field}`);
} }
@@ -227,14 +234,16 @@ export async function quickAddService(payload: QuickAddPayload | string) {
unit_price = num.toFixed(2); unit_price = num.toFixed(2);
} }
const fase = data.fase?.trim() || null;
await db.insert(services).values({ await db.insert(services).values({
name, name,
description: data.description?.trim() || null, description: data.description?.trim() || null,
category: data.category?.trim() || null, category: data.category?.trim() || null,
fase: data.fase?.trim() || null, fase,
unit_price, unit_price,
active: true, active: true,
}); });
if (fase) await addPoolValue("service_fase", fase);
revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog");
} }
+3 -5
View File
@@ -1,5 +1,6 @@
import { getAllServices, getCatalogFieldOptions } from "@/lib/admin-queries"; import { getAllServices, getCatalogFieldOptions } from "@/lib/admin-queries";
import { CatalogSearch } from "./CatalogSearch"; import { CatalogSearch } from "./CatalogSearch";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0; export const revalidate = 0;
@@ -10,11 +11,8 @@ export default async function CatalogPage() {
]); ]);
return ( return (
<div> <div className="space-y-6">
<div className="flex items-center justify-between mb-6"> <PageHeader title="Catalogo Servizi" />
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
</div>
<CatalogSearch services={services} options={options} /> <CatalogSearch services={services} options={options} />
</div> </div>
); );
+112 -17
View File
@@ -50,10 +50,17 @@ async function resolveEntity(id: string): Promise<{ projectId: string | null; pa
// ── CLIENT CRUD ─────────────────────────────────────────────────────────────── // ── CLIENT CRUD ───────────────────────────────────────────────────────────────
const emptyToNull = (v: unknown) => {
const s = typeof v === "string" ? v.trim() : "";
return s === "" ? null : s;
};
const clientSchema = z.object({ const clientSchema = z.object({
name: z.string().min(1, "Nome richiesto"), name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Brand name richiesto"), brand_name: z.string().min(1, "Brand name richiesto"),
brief: z.string(), brief: z.string(),
email: z.string().optional().transform(emptyToNull),
phone: z.string().optional().transform(emptyToNull),
slug: z slug: z
.string() .string()
.regex(/^[a-z0-9-]{3,50}$/, "Slug non valido (es. mario-rossi, min 3 max 50 caratteri)") .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"), name: formData.get("name"),
brand_name: formData.get("brand_name"), brand_name: formData.get("brand_name"),
brief: formData.get("brief") ?? "", brief: formData.get("brief") ?? "",
email: formData.get("email") ?? "",
phone: formData.get("phone") ?? "",
slug: formData.get("slug") ?? "", slug: formData.get("slug") ?? "",
}); });
if (!parsed.success) throw new Error(parsed.error.issues[0].message); 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); 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 ───────────────────────────────────────────────────────────────────── // ── TASKS ─────────────────────────────────────────────────────────────────────
export async function addTask(phaseId: string, id: string, formData: FormData) { 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, sort_order: maxOrder + 1,
status: "todo", status: "todo",
}); });
// Cascade: a new todo task keeps phase active/upcoming — recompute to be safe
await recomputePhaseStatus(phaseId);
const { path } = await resolveEntity(id); const { path } = await resolveEntity(id);
revalidatePath(path); 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) { export async function updateTaskStatus(taskId: string, id: string, status: string) {
await requireAdmin(); await requireAdmin();
const allowed = ["todo", "in_progress", "done"]; const allowed = ["todo", "in_progress", "done"];
if (!allowed.includes(status)) throw new Error("Stato non valido"); if (!allowed.includes(status)) throw new Error("Stato non valido");
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId)); 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); const { path } = await resolveEntity(id);
revalidatePath(path); revalidatePath(path);
} }
@@ -231,6 +289,54 @@ export async function updatePaymentStatus(paymentId: string, id: string, status:
revalidatePath(path); 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) { export async function updateAcceptedTotal(id: string, formData: FormData) {
await requireAdmin(); await requireAdmin();
const raw = (formData.get("accepted_total") as string)?.trim(); const raw = (formData.get("accepted_total") as string)?.trim();
@@ -244,28 +350,17 @@ export async function updateAcceptedTotal(id: string, formData: FormData) {
.limit(1); .limit(1);
if (asProject[0]) { 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)); await db.update(projects).set({ accepted_total: val.toFixed(2) }).where(eq(projects.id, id));
const half = (val / 2).toFixed(2); await rescalePayments(id, val);
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));
}
revalidatePath(`/admin/projects/${id}`); revalidatePath(`/admin/projects/${id}`);
} else { } 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)); await db.update(clients).set({ accepted_total: val.toFixed(2) }).where(eq(clients.id, id));
const projectRows = await db.select({ id: projects.id }) const projectRows = await db.select({ id: projects.id })
.from(projects).where(eq(projects.client_id, id)); .from(projects).where(eq(projects.client_id, id));
if (projectRows.length > 0) { for (const proj of projectRows) {
const projectIds = projectRows.map((p) => p.id); await rescalePayments(proj.id, val);
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));
}
} }
revalidatePath(`/admin/clients/${id}`); revalidatePath(`/admin/clients/${id}`);
} }
@@ -280,7 +375,7 @@ export async function postAdminComment(id: string, formData: FormData) {
if (!body || !entity) throw new Error("Dati mancanti"); if (!body || !entity) throw new Error("Dati mancanti");
const [entity_type, entity_id] = entity.split(":"); const [entity_type, entity_id] = entity.split(":");
if (!entity_type || !entity_id) throw new Error("Formato entity non valido"); 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"); if (!allowedTypes.includes(entity_type)) throw new Error("entity_type non valido");
await db.insert(comments).values({ entity_type, entity_id, author: "admin", body }); await db.insert(comments).values({ entity_type, entity_id, author: "admin", body });
const { path } = await resolveEntity(id); const { path } = await resolveEntity(id);
+23
View File
@@ -62,6 +62,29 @@ export default async function EditClientPage({
/> />
</div> </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"> <div className="space-y-1.5">
<Label htmlFor="brief">Brief progetto</Label> <Label htmlFor="brief">Brief progetto</Label>
<Textarea <Textarea
+48 -12
View File
@@ -33,6 +33,21 @@ export default async function ClientDetailPage({
<div> <div>
<h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1> <h1 className="text-2xl font-bold text-[#1a1a1a]">{client.name}</h1>
<p className="text-sm text-[#71717a]">{client.brand_name}</p> <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 && ( {client.archived && (
<span className="inline-block mt-1 text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full"> <span className="inline-block mt-1 text-xs font-medium bg-[#f4f4f5] text-[#71717a] px-2 py-0.5 rounded-full">
Archiviato Archiviato
@@ -115,19 +130,40 @@ export default async function ClientDetailPage({
Offerte Attive ({activeOffers.length}) Offerte Attive ({activeOffers.length})
</p> </p>
<div className="bg-white rounded-xl border border-[#e5e7eb] divide-y divide-[#e5e7eb]"> <div className="bg-white rounded-xl border border-[#e5e7eb] divide-y divide-[#e5e7eb]">
{activeOffers.map((offer) => ( {activeOffers.map((offer) => {
<div key={offer.offer_id} className="flex items-center justify-between px-4 py-3"> const categoryLabel =
<div> offer.category ??
<p className="text-sm font-medium text-[#1a1a1a]">{offer.public_name}</p> (offer.offer_type === "retainer"
<p className="text-xs text-[#71717a]">{offer.project_name}</p> ? "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 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] shrink-0">
&euro;{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</span>
)}
</div> </div>
{offer.accepted_total && ( );
<span className="text-sm font-mono text-[#1a1a1a]"> })}
&euro;{parseFloat(offer.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
</span>
)}
</div>
))}
</div> </div>
</div> </div>
)} )}
+43 -37
View File
@@ -44,62 +44,68 @@ const createClientSchema = z.object({
name: z.string().min(1, "Nome richiesto"), name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Nome brand richiesto"), brand_name: z.string().min(1, "Nome brand richiesto"),
brief: z.string().min(1, "Brief richiesto"), brief: z.string().min(1, "Brief richiesto"),
email: z.string().optional(),
phone: z.string().optional(),
}); });
export async function createClient(formData: FormData) { // Shared core used by both the manual create form and the lead→client
await requireAdmin(); // conversion. Creates the client (+ slug + token), a default project (optionally
const raw = { // linked to the originating lead), and the two 50/50 payment stubs. Returns the
name: formData.get("name") as string, // new ids. Does NOT redirect — callers decide where to go next.
brand_name: formData.get("brand_name") as string, export async function createClientCore(input: {
brief: formData.get("brief") as string, 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 const [newClient] = await db
.insert(clients) .insert(clients)
.values({ .values({
name: parsed.data.name, name: input.name,
brand_name: parsed.data.brand_name, brand_name: input.brand_name,
brief: parsed.data.brief, brief: input.brief,
email: input.email?.trim() || null,
phone: input.phone?.trim() || null,
slug, 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 const [newProject] = await db
.insert(projects) .insert(projects)
.values({ .values({
client_id: newClient.id, client_id: newClient.id,
name: newClient.brand_name, name: newClient.brand_name,
created_from_lead_id: input.created_from_lead_id ?? null,
}) })
.returning({ id: projects.id }); .returning({ id: projects.id });
// Always create two payment stubs per project — Acconto 50% and Saldo 50%
await db.insert(payments).values([ await db.insert(payments).values([
{ { project_id: newProject.id, label: "Acconto 50%", amount: "0", status: "da_saldare" },
project_id: newProject.id, { project_id: newProject.id, label: "Saldo 50%", amount: "0", status: "da_saldare" },
label: "Acconto 50%",
amount: "0",
status: "da_saldare",
},
{
project_id: newProject.id,
label: "Saldo 50%",
amount: "0",
status: "da_saldare",
},
]); ]);
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"); revalidatePath("/admin");
redirect(`/admin/clients/${newClient.id}`); redirect(`/admin/clients/${clientId}`);
} }
+20
View File
@@ -45,6 +45,26 @@ export default function NewClientPage() {
required required
/> />
</div> </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"> <div className="space-y-1">
<Label htmlFor="brief">Brief del progetto</Label> <Label htmlFor="brief">Brief del progetto</Label>
<Textarea <Textarea
+17 -15
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { getAllClientsWithPayments } from "@/lib/admin-queries"; import { getAllClientsWithPayments } from "@/lib/admin-queries";
import { ClientRow } from "@/components/admin/ClientRow"; import { ClientRow } from "@/components/admin/ClientRow";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0; export const revalidate = 0;
@@ -14,22 +15,23 @@ export default async function AdminClientsPage({
const showArchived = archived === "1"; const showArchived = archived === "1";
const clients = await getAllClientsWithPayments(showArchived); const clients = await getAllClientsWithPayments(showArchived);
const clientiAction = (
<div className="flex items-center gap-3">
<a
href={showArchived ? "/admin/clients" : "/admin/clients?archived=1"}
className="text-xs text-[#71717a] hover:text-[#1A463C] underline underline-offset-2"
>
{showArchived ? "Nascondi archiviati" : "Mostra archiviati"}
</a>
<Button asChild>
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
</Button>
</div>
);
return ( return (
<div> <div className="space-y-6">
<div className="flex items-center justify-between mb-6"> <PageHeader title="Clienti" action={clientiAction} />
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
<a
href={showArchived ? "/admin/clients" : "/admin/clients?archived=1"}
className="text-xs text-[#71717a] hover:text-[#1A463C] underline underline-offset-2"
>
{showArchived ? "Nascondi archiviati" : "Mostra archiviati"}
</a>
</div>
<Button asChild>
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
</Button>
</div>
{clients.length === 0 ? ( {clients.length === 0 ? (
<div className="text-center py-20 text-[#71717a]"> <div className="text-center py-20 text-[#71717a]">
+31 -22
View File
@@ -1,33 +1,42 @@
"use server"; "use server";
import { getJsonPool, updateJsonPool, SETTINGS_KEYS } from "@/lib/settings"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import {
addPoolValue,
removePoolValue,
TAXONOMY_FIELDS,
type TaxonomyFieldId,
} from "@/lib/taxonomy";
type PoolName = "tipo" | "obiettivo" | "categoria"; async function requireAdmin() {
const session = await getServerSession(authOptions);
function poolKey(pool: PoolName): string { if (!session) throw new Error("Non autorizzato");
switch (pool) {
case "tipo": return SETTINGS_KEYS.OFFER_TIPO_POOL;
case "obiettivo": return SETTINGS_KEYS.OFFER_OBIETTIVO_POOL;
case "categoria": return SETTINGS_KEYS.OFFER_CATEGORIA_POOL;
}
} }
export async function addPoolValue(pool: PoolName, value: string): Promise<void> { function assertField(fieldId: string): asserts fieldId is TaxonomyFieldId {
const trimmed = value.trim(); if (!(fieldId in TAXONOMY_FIELDS)) throw new Error(`Tassonomia non valida: ${fieldId}`);
if (!trimmed) return; }
const key = poolKey(pool);
const current = await getJsonPool(key); function revalidateAll() {
if (current.includes(trimmed)) return;
await updateJsonPool(key, [...current, trimmed]);
revalidatePath("/admin/impostazioni"); revalidatePath("/admin/impostazioni");
revalidatePath("/admin/offers"); revalidatePath("/admin/offers");
revalidatePath("/admin/catalog");
} }
export async function removePoolValue(pool: PoolName, value: string): Promise<void> { export async function addTaxonomyValue(fieldId: string, value: string): Promise<void> {
const key = poolKey(pool); await requireAdmin();
const current = await getJsonPool(key); assertField(fieldId);
await updateJsonPool(key, current.filter((v) => v !== value)); await addPoolValue(fieldId, value);
revalidatePath("/admin/impostazioni"); revalidateAll();
revalidatePath("/admin/offers"); }
// 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();
} }
+10 -17
View File
@@ -1,15 +1,12 @@
import { getTargetHourlyRate, getJsonPool, updateSetting, SETTINGS_KEYS } from "@/lib/settings"; import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
import { OfferPoolsSection } from "@/components/admin/impostazioni/OfferPoolsSection"; import { getAllPools } from "@/lib/taxonomy";
import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0; export const revalidate = 0;
export default async function ImpostazioniPage() { export default async function ImpostazioniPage() {
const [targetRate, tipoPool, obiettivoPool, categoriaPool] = await Promise.all([ const [targetRate, pools] = await Promise.all([getTargetHourlyRate(), getAllPools()]);
getTargetHourlyRate(),
getJsonPool(SETTINGS_KEYS.OFFER_TIPO_POOL),
getJsonPool(SETTINGS_KEYS.OFFER_OBIETTIVO_POOL),
getJsonPool(SETTINGS_KEYS.OFFER_CATEGORIA_POOL),
]);
async function handleSave(fd: FormData) { async function handleSave(fd: FormData) {
"use server"; "use server";
@@ -20,11 +17,11 @@ export default async function ImpostazioniPage() {
} }
return ( return (
<div> <div className="space-y-6">
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-6">Impostazioni</h1> <PageHeader title="Impostazioni" />
<div className="space-y-6 max-w-2xl"> <div className="space-y-6 max-w-4xl">
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6"> <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> <h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2>
<form action={handleSave} className="space-y-4"> <form action={handleSave} className="space-y-4">
@@ -62,11 +59,7 @@ export default async function ImpostazioniPage() {
</form> </form>
</div> </div>
<OfferPoolsSection <TaxonomyManager pools={pools} />
tipoPool={tipoPool}
obiettivoPool={obiettivoPool}
categoriaPool={categoriaPool}
/>
</div> </div>
</div> </div>
); );
+5 -9
View File
@@ -1,4 +1,4 @@
import { AdminSidebar } from "@/components/admin/AdminSidebar"; import { AdminShell } from "@/components/admin/AdminShell";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
@@ -8,12 +8,8 @@ export default async function AdminLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
return ( if (!session) {
<div className="flex min-h-screen bg-gray-50"> return <div className="min-h-screen bg-background">{children}</div>;
{session && <AdminSidebar />} }
<main className="flex-1 px-8 py-8 overflow-y-auto"> return <AdminShell>{children}</AdminShell>;
{children}
</main>
</div>
);
} }
+9 -33
View File
@@ -1,36 +1,12 @@
import { notFound } from "next/navigation"; import { redirect } 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";
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 { id } = await params;
redirect(`/admin/pipeline/${id}`);
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}
/>
);
} }
+5 -21
View File
@@ -1,23 +1,7 @@
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries"; import { redirect } from "next/navigation";
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
export const revalidate = 0; // Legacy route — Lead was renamed to Pipeline. Kept as a redirect stub so
// existing links/bookmarks to /admin/leads don't break.
export default async function LeadsPage() { export default function LeadsRedirectPage() {
const [leads, options] = await Promise.all([ redirect("/admin/pipeline");
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>
);
} }
+16
View File
@@ -14,6 +14,7 @@ import { eq, and, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { addPoolValue, renamePoolValue } from "@/lib/taxonomy";
async function requireAdmin() { async function requireAdmin() {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -155,6 +156,7 @@ const saveOfferEditorSchema = z.object({
description: z.string().optional(), description: z.string().optional(),
category: z.string().optional(), category: z.string().optional(),
ticket: z.string().optional(), ticket: z.string().optional(),
offer_type: z.enum(["una_tantum", "retainer"]).optional(),
cliente_ideale: z.string().optional(), cliente_ideale: z.string().optional(),
risultato: z.string().optional(), risultato: z.string().optional(),
tempo: z.string().optional(), tempo: z.string().optional(),
@@ -188,6 +190,7 @@ export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorP
description: data.description || null, description: data.description || null,
category: data.category || null, category: data.category || null,
ticket: data.ticket || null, ticket: data.ticket || null,
offer_type: data.offer_type ?? "una_tantum",
cliente_ideale: data.cliente_ideale || null, cliente_ideale: data.cliente_ideale || null,
risultato: data.risultato || null, risultato: data.risultato || null,
tempo: data.tempo || null, tempo: data.tempo || null,
@@ -259,6 +262,13 @@ export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorP
await db.insert(tags).values(tagRows).onConflictDoNothing(); 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");
revalidatePath(`/admin/offers/${macroId}/edit`); 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 }) .values({ entity_type: OFFER_TAG_ENTITY[dimension], entity_id: macroId, name: trimmed })
.onConflictDoNothing(); .onConflictDoNothing();
await addPoolValue(dimension === "tipo" ? "offer_tipo" : "offer_obiettivo", trimmed);
revalidatePath("/admin/offers"); revalidatePath("/admin/offers");
} }
@@ -332,13 +344,16 @@ export async function renameOfferOption(
if (field === "categoria") { if (field === "categoria") {
await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue)); await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue));
await renamePoolValue("offer_categoria", oldValue, next);
} else if (field === "ticket") { } else if (field === "ticket") {
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue)); 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") { } else if (field === "tipo" || field === "obiettivo") {
await db await db
.update(tags) .update(tags)
.set({ name: next }) .set({ name: next })
.where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue))); .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 { } else {
throw new Error(`Campo non valido: ${field}`); throw new Error(`Campo non valido: ${field}`);
} }
@@ -371,6 +386,7 @@ export async function createOfferMacro(formData: FormData) {
description: parsed.data.description || null, description: parsed.data.description || null,
category: parsed.data.category || null, category: parsed.data.category || null,
}); });
if (parsed.data.category) await addPoolValue("offer_categoria", parsed.data.category);
revalidatePath("/admin/offers"); revalidatePath("/admin/offers");
} }
+52 -111
View File
@@ -8,38 +8,14 @@ import {
getTimeByClient, getTimeByClient,
getTotalTrackedHours, getTotalTrackedHours,
} from "@/lib/analytics-queries"; } from "@/lib/analytics-queries";
import { getRevenueForecast12Months, getOffersSoldBreakdown } from "@/lib/forecast-queries";
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector"; import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
import { Users, FolderOpen, Euro, Clock } from "lucide-react"; import { ForecastChart } from "@/components/admin/ForecastChart";
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0; 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({ function MetricCard({
label, label,
value, value,
@@ -78,22 +54,6 @@ function MetricCard({
); );
} }
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 fmtEur(n: number) {
return n.toLocaleString("it-IT", { return n.toLocaleString("it-IT", {
style: "currency", style: "currency",
@@ -117,15 +77,18 @@ export default async function AdminDashboard({
const { year: yearParam } = await searchParams; const { year: yearParam } = await searchParams;
const year = parseInt(yearParam ?? "") || new Date().getFullYear(); 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, monthly, availableYears, timeByClient, totalHours, forecast, offersSold] =
getAnalyticsByYear(year), await Promise.all([
getMonthlyCollected(year), getAnalyticsByYear(year),
getAvailableYears(), getMonthlyCollected(year),
getTimeByClient(year), getAvailableYears(),
getTotalTrackedHours(year), getTimeByClient(year),
]); getTotalTrackedHours(year),
getRevenueForecast12Months(),
getOffersSoldBreakdown(),
]);
const collectedPct = const collectedPct =
data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0; data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
@@ -133,8 +96,8 @@ export default async function AdminDashboard({
const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1; const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1;
return ( return (
<div className="max-w-5xl"> <div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1> <PageHeader title="Dashboard" />
{/* Follow-up Widget */} {/* Follow-up Widget */}
<div className="mb-8"> <div className="mb-8">
@@ -143,63 +106,8 @@ export default async function AdminDashboard({
</Suspense> </Suspense>
</div> </div>
{/* KPI cards */} {/* ── SEZIONE STATISTICHE ── */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <div className="space-y-10">
<KpiCard
label="Clienti attivi"
value={kpi.clientiAttivi}
icon={Users}
color="bg-[#1A463C]"
/>
<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 className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-bold text-gray-900">Statistiche</h2> <h2 className="text-xl font-bold text-gray-900">Statistiche</h2>
@@ -208,6 +116,39 @@ export default async function AdminDashboard({
<YearSelector currentYear={year} availableYears={availableYears} /> <YearSelector currentYear={year} availableYears={availableYears} />
</div> </div>
{/* Panoramica corrente (non per-anno) */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Panoramica</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard label="Clienti attivi" value={String(kpi.clientiAttivi)} sub="Totale corrente" />
<MetricCard
label="Progetti in corso"
value={String(kpi.progettiInCorso)}
sub="Non archiviati"
/>
</div>
</div>
{/* Previsione incassi */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
Previsione incassi (12 mesi)
</h3>
<ForecastChart data={forecast} />
<p className="text-xs text-[#a1a1aa] italic">
Stima dalle offerte attive: i retainer contribuiscono il canone mensile, le una-tantum
ripartite sulla durata.
</p>
</div>
{/* Offerte vendute */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
Offerte vendute
</h3>
<OffersSoldChart data={offersSold} />
</div>
{/* Sezione economica */} {/* Sezione economica */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3> <h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
+40
View File
@@ -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 { z } from "zod";
import { db } from "@/db"; 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 { nanoid } from "nanoid";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { import {
@@ -12,10 +12,61 @@ import {
LEAD_STAGES, LEAD_STAGES,
} from "@/lib/lead-validators"; } from "@/lib/lead-validators";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createActivity, updateLeadStage } from "@/lib/lead-service"; import { createActivity, updateLeadStage } from "@/lib/lead-service";
import { createClientCore } from "@/app/admin/clients/new/actions";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/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>) { export async function createLead(data: z.infer<typeof createLeadSchema>) {
const parsed = createLeadSchema.safeParse(data); const parsed = createLeadSchema.safeParse(data);
@@ -36,7 +87,7 @@ export async function createLead(data: z.infer<typeof createLeadSchema>) {
}) })
.returning(); .returning();
revalidatePath("/admin/leads"); revalidatePath("/admin/pipeline");
return { success: true, lead }; return { success: true, lead };
} catch (error) { } catch (error) {
console.error("createLead error:", 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)) .where(eq(leads.id, id))
.returning(); .returning();
revalidatePath("/admin/leads"); revalidatePath("/admin/pipeline");
revalidatePath(`/admin/leads/${id}`); revalidatePath(`/admin/pipeline/${id}`);
return { success: true, lead: updated }; return { success: true, lead: updated };
} catch (error) { } catch (error) {
console.error("updateLead error:", 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) { export async function deleteLead(id: string) {
try { try {
await db.delete(leads).where(eq(leads.id, id)); await db.delete(leads).where(eq(leads.id, id));
revalidatePath("/admin/leads"); revalidatePath("/admin/pipeline");
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("deleteLead error:", error); console.error("deleteLead error:", error);
@@ -104,7 +155,7 @@ export async function logActivity(data: z.infer<typeof createActivitySchema>) {
notes: parsed.data.notes, notes: parsed.data.notes,
}); });
revalidatePath(`/admin/leads/${parsed.data.lead_id}`); revalidatePath(`/admin/pipeline/${parsed.data.lead_id}`);
return { success: true, activity }; return { success: true, activity };
} catch (error) { } catch (error) {
console.error("logActivity error:", 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" // Update lead status to "proposal_sent"
await updateLeadStage(leadId, "proposal_sent"); await updateLeadStage(leadId, "proposal_sent");
revalidatePath(`/admin/leads/${leadId}`); revalidatePath(`/admin/pipeline/${leadId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("assignQuoteToLead error:", error); console.error("assignQuoteToLead error:", error);
@@ -201,8 +252,8 @@ export async function updateLeadField(
.where(eq(leads.id, leadId)); .where(eq(leads.id, leadId));
} }
revalidatePath("/admin/leads"); revalidatePath("/admin/pipeline");
revalidatePath(`/admin/leads/${leadId}`); revalidatePath(`/admin/pipeline/${leadId}`);
} }
// ── Lead tags (Phase 14, CRM-09) — polymorphic `tags` table, entity_type="leads" ─ // ── 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 }) .values({ entity_type: LEADS_TAG_ENTITY, entity_id: leadId, name: trimmed })
.onConflictDoNothing(); .onConflictDoNothing();
revalidatePath("/admin/leads"); revalidatePath("/admin/pipeline");
revalidatePath(`/admin/leads/${leadId}`); revalidatePath(`/admin/pipeline/${leadId}`);
} }
export async function removeLeadTag(leadId: string, value: string) { 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/pipeline");
revalidatePath(`/admin/leads/${leadId}`); revalidatePath(`/admin/pipeline/${leadId}`);
} }
export async function renameLeadTag(oldValue: string, newValue: string) { export async function renameLeadTag(oldValue: string, newValue: string) {
@@ -251,7 +302,7 @@ export async function renameLeadTag(oldValue: string, newValue: string) {
.set({ name: next }) .set({ name: next })
.where(and(eq(tags.entity_type, LEADS_TAG_ENTITY), eq(tags.name, oldValue))); .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) ──────────────────────────────────── // ── Transcript actions (Phase 20 — KB-02) ────────────────────────────────────
@@ -283,7 +334,7 @@ export async function addTranscript(data: z.infer<typeof addTranscriptSchema>) {
}) })
.returning(); .returning();
revalidatePath(`/admin/leads/${parsed.data.lead_id}`); revalidatePath(`/admin/pipeline/${parsed.data.lead_id}`);
return { success: true, transcript }; return { success: true, transcript };
} catch (error) { } catch (error) {
console.error("addTranscript error:", error); console.error("addTranscript error:", error);
@@ -301,7 +352,7 @@ export async function deleteTranscript(transcriptId: string, leadId: string) {
.delete(clientTranscripts) .delete(clientTranscripts)
.where(eq(clientTranscripts.id, transcriptId)); .where(eq(clientTranscripts.id, transcriptId));
revalidatePath(`/admin/leads/${leadId}`); revalidatePath(`/admin/pipeline/${leadId}`);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error("deleteTranscript error:", error); console.error("deleteTranscript error:", error);
+24
View File
@@ -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>
);
}
+14 -3
View File
@@ -62,7 +62,17 @@ export async function generateProposalDraft(formData: FormData) {
.orderBy(desc(clientTranscripts.call_date)); .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 // Chiama agente AI
const { content: aiContent } = await generateProposalContent({ const { content: aiContent } = await generateProposalContent({
@@ -70,6 +80,7 @@ export async function generateProposalDraft(formData: FormData) {
client, client,
transcripts, transcripts,
offer, offer,
model: resolvedModel,
}); });
// Assembla contenuto completo // Assembla contenuto completo
@@ -78,7 +89,7 @@ export async function generateProposalDraft(formData: FormData) {
client, client,
offer, offer,
aiContent, aiContent,
model: MODEL, model: resolvedModel,
}); });
// Salva nel DB // Salva nel DB
@@ -94,7 +105,7 @@ export async function generateProposalDraft(formData: FormData) {
offer_macro_id: offerMacroId, offer_macro_id: offerMacroId,
title, title,
content: assembled as unknown as Record<string, unknown>, content: assembled as unknown as Record<string, unknown>,
model: MODEL, model: resolvedModel,
state: "draft", state: "draft",
}); });
@@ -16,6 +16,7 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
const [subjectType, setSubjectType] = useState<"lead" | "client">( const [subjectType, setSubjectType] = useState<"lead" | "client">(
preselectedLeadId ? "lead" : "lead" preselectedLeadId ? "lead" : "lead"
); );
const [model, setModel] = useState<"opus" | "sonnet">("opus");
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -25,6 +26,8 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
const form = e.currentTarget; const form = e.currentTarget;
const formData = new FormData(form); const formData = new FormData(form);
formData.append("model", model);
startTransition(async () => { startTransition(async () => {
try { try {
await action(formData); await action(formData);
@@ -65,6 +68,35 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
</div> </div>
</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 */} {/* Select lead o cliente */}
{subjectType === "lead" ? ( {subjectType === "lead" ? (
<div className="space-y-1"> <div className="space-y-1">
@@ -135,7 +167,7 @@ export function GeneraProposalForm({ leads, clients, offers, action, preselected
{/* Info generazione */} {/* Info generazione */}
<div className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2"> <div className="text-xs text-muted-foreground bg-muted rounded-md px-3 py-2">
L&apos;AI (Claude Opus) legge i transcript del soggetto selezionato e genera una bozza L&apos;AI (Claude {model === "opus" ? "Opus" : "Sonnet"}) legge i transcript del soggetto selezionato e genera una bozza
personalizzata. La generazione richiede 3060 secondi. personalizzata. La generazione richiede 3060 secondi.
</div> </div>
+34 -34
View File
@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { listProposals } from "@/lib/proposal/queries"; import { listProposals } from "@/lib/proposal/queries";
import { FileText, Plus } from "lucide-react"; import { FileText, Plus } from "lucide-react";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0; export const revalidate = 0;
@@ -14,47 +15,46 @@ const STATE_LABELS: Record<string, { label: string; className: string }> = {
export default async function PreventiviPage() { export default async function PreventiviPage() {
const proposals = await listProposals(); const proposals = await listProposals();
const preventiviAction = (
<Link
href="/admin/preventivi/genera"
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>
);
return ( return (
<div className="max-w-5xl mx-auto space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader
<div className="flex items-center justify-between"> title="Preventivi"
<div> subtitle="Preventivi generati con AI e pubblicati ai clienti"
<h1 className="text-2xl font-semibold text-foreground">Preventivi</h1> action={preventiviAction}
<p className="text-sm text-muted-foreground mt-1"> />
Preventivi generati con AI e pubblicati ai clienti
</p>
</div>
<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"
>
<Plus size={16} />
Genera preventivo
</Link>
</div>
{/* Lista */} {/* Lista */}
{proposals.length === 0 ? ( {proposals.length === 0 ? (
<div className="text-center py-20 border border-dashed border-border rounded-lg"> <div className="text-center py-20 border border-dashed border-[#e5e7eb] rounded-lg">
<FileText size={40} className="mx-auto text-muted-foreground mb-4" /> <FileText size={40} className="mx-auto text-[#71717a] mb-4" />
<p className="text-muted-foreground text-sm">Nessun preventivo ancora.</p> <p className="text-[#71717a] text-sm">Nessun preventivo ancora.</p>
<Link <Link
href="/admin/preventivi/genera" 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 Genera il primo preventivo
</Link> </Link>
</div> </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"> <table className="w-full text-sm">
<thead className="bg-muted border-b border-border"> <thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr> <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-[#71717a]">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-[#71717a]">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-[#71717a]">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-[#71717a]">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]">Data</th>
<th className="px-4 py-3" /> <th className="px-4 py-3" />
</tr> </tr>
</thead> </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 stateInfo = STATE_LABELS[p.state] ?? { label: p.state, className: "bg-gray-100 text-gray-700" };
const subject = p.clientName ?? p.leadName ?? "—"; const subject = p.clientName ?? p.leadName ?? "—";
return ( 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"> <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"} {p.title ?? "Senza titolo"}
</Link> </Link>
</td> </td>
<td className="px-4 py-3 text-muted-foreground">{subject}</td> <td className="px-4 py-3 text-[#71717a]">{subject}</td>
<td className="px-4 py-3 text-muted-foreground">{p.offerName}</td> <td className="px-4 py-3 text-[#71717a]">{p.offerName}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${stateInfo.className}`}> <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${stateInfo.className}`}>
{stateInfo.label} {stateInfo.label}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-muted-foreground"> <td className="px-4 py-3 text-[#71717a]">
{p.createdAt.toLocaleDateString("it-IT")} {p.createdAt.toLocaleDateString("it-IT")}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
@@ -85,7 +85,7 @@ export default async function PreventiviPage() {
href={`/preventivo/${p.slug}`} href={`/preventivo/${p.slug}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-primary hover:underline" className="text-xs text-[#1A463C] hover:underline"
> >
Apri Apri
</a> </a>
+8 -15
View File
@@ -1,12 +1,11 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getProjectFullDetail } from "@/lib/admin-queries"; import { getProjectFullDetail, getRecentTimeEntries } from "@/lib/admin-queries";
import { getTargetHourlyRate } from "@/lib/settings"; import { getTargetHourlyRate } from "@/lib/settings";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PhasesTab } from "@/components/admin/tabs/PhasesTab"; import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab"; import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab"; import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
import { CommentsTab } from "@/components/admin/tabs/CommentsTab"; import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
import { TimerTab } from "@/components/admin/tabs/TimerTab"; import { TimerTab } from "@/components/admin/tabs/TimerTab";
import { OffersTab } from "@/components/admin/tabs/OffersTab"; import { OffersTab } from "@/components/admin/tabs/OffersTab";
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle"; import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
@@ -27,6 +26,8 @@ export default async function ProjectDetailPage({
if (!detail) notFound(); if (!detail) notFound();
const recentEntries = await getRecentTimeEntries(id, 5);
const { const {
project, project,
phases, phases,
@@ -34,13 +35,13 @@ export default async function ProjectDetailPage({
documents, documents,
notes, notes,
comments, comments,
quoteItems,
activeServices,
activeTimerEntryId, activeTimerEntryId,
activeTimerStartedAt, activeTimerStartedAt,
totalTrackedSeconds, totalTrackedSeconds,
projectOffers, projectOffers,
availableMicros, availableMicros,
offersAcceptedTotal,
transcripts,
} = detail; } = detail;
return ( return (
@@ -72,7 +73,6 @@ export default async function ProjectDetailPage({
<TabsTrigger value="documents">Documenti</TabsTrigger> <TabsTrigger value="documents">Documenti</TabsTrigger>
<TabsTrigger value="notes">Note</TabsTrigger> <TabsTrigger value="notes">Note</TabsTrigger>
<TabsTrigger value="comments">Commenti</TabsTrigger> <TabsTrigger value="comments">Commenti</TabsTrigger>
<TabsTrigger value="quote">Preventivo</TabsTrigger>
<TabsTrigger value="timer">Timer</TabsTrigger> <TabsTrigger value="timer">Timer</TabsTrigger>
<TabsTrigger value="offers">Offerte</TabsTrigger> <TabsTrigger value="offers">Offerte</TabsTrigger>
</TabsList> </TabsList>
@@ -91,11 +91,12 @@ export default async function ProjectDetailPage({
acceptedTotal={project.accepted_total ?? "0"} acceptedTotal={project.accepted_total ?? "0"}
clientId={id} clientId={id}
projectId={id} projectId={id}
offersAcceptedTotal={offersAcceptedTotal}
/> />
</TabsContent> </TabsContent>
<TabsContent value="documents"> <TabsContent value="documents">
<DocumentsTab documents={documents} clientId={id} /> <DocumentsTab documents={documents} clientId={id} transcripts={transcripts} />
</TabsContent> </TabsContent>
<TabsContent value="notes"> <TabsContent value="notes">
@@ -118,15 +119,6 @@ export default async function ProjectDetailPage({
<CommentsTab comments={comments} phases={phases} clientId={id} /> <CommentsTab comments={comments} phases={phases} clientId={id} />
</TabsContent> </TabsContent>
<TabsContent value="quote">
<QuoteTab
clientId={id}
items={quoteItems}
activeServices={activeServices}
acceptedTotal={project.accepted_total ?? "0"}
/>
</TabsContent>
<TabsContent value="timer"> <TabsContent value="timer">
<TimerTab <TimerTab
projectId={id} projectId={id}
@@ -135,6 +127,7 @@ export default async function ProjectDetailPage({
activeTimerStartedAt={activeTimerStartedAt} activeTimerStartedAt={activeTimerStartedAt}
totalTrackedSeconds={totalTrackedSeconds} totalTrackedSeconds={totalTrackedSeconds}
targetHourlyRate={targetHourlyRate} targetHourlyRate={targetHourlyRate}
recentEntries={recentEntries}
/> />
</TabsContent> </TabsContent>
+12 -10
View File
@@ -1,23 +1,25 @@
import { getAllProjectsWithPayments } from "@/lib/admin-queries"; import { getAllProjectsWithPayments } from "@/lib/admin-queries";
import { ProjectRow } from "@/components/admin/ProjectRow"; import { ProjectRow } from "@/components/admin/ProjectRow";
import Link from "next/link"; import Link from "next/link";
import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0; export const revalidate = 0;
export default async function ProjectsPage() { export default async function ProjectsPage() {
const projects = await getAllProjectsWithPayments(); const projects = await getAllProjectsWithPayments();
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>
);
return ( return (
<div> <div className="space-y-6">
<div className="mb-6 flex items-center justify-between"> <PageHeader title="Progetti" action={projectiAction} />
<h1 className="text-2xl font-bold text-[#1a1a1a]">Progetti</h1>
<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>
{projects.length === 0 ? ( {projects.length === 0 ? (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-12 text-center"> <div className="bg-white rounded-xl border border-[#e5e7eb] p-12 text-center">
+144 -8
View File
@@ -4,8 +4,17 @@ import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { db } from "@/db"; import { db } from "@/db";
import { projects, clients, payments, project_offers } from "@/db/schema"; import {
import { eq } from "drizzle-orm"; 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 { z } from "zod";
import { nanoid } from "nanoid"; 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. // 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> { export async function initProjectPayments(projectId: string): Promise<void> {
await requireAdmin(); await requireAdmin();
const rows = await db const rows = await db
@@ -65,11 +75,50 @@ export async function initProjectPayments(projectId: string): Promise<void> {
.limit(1); .limit(1);
if (!rows[0]) throw new Error("Progetto non trovato"); if (!rows[0]) throw new Error("Progetto non trovato");
const total = parseFloat(rows[0].accepted_total ?? "0"); const total = parseFloat(rows[0].accepted_total ?? "0");
const half = (total / 2).toFixed(2); await setPaymentPlan(projectId, "two", total);
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" }, // 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}`); revalidatePath(`/admin/projects/${projectId}`);
} }
@@ -110,12 +159,94 @@ export async function splitPayment(
revalidatePath(`/admin/projects/${projectId}`); 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 ────────────────────────────────────────────────── // ── Offer assignment actions ──────────────────────────────────────────────────
const assignOfferSchema = z.object({ const assignOfferSchema = z.object({
project_id: z.string().min(1), 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(), accepted_total: z.coerce.number().min(0).optional(),
import_phases: z.boolean().optional(),
}); });
export async function assignOfferToProject(formData: FormData) { export async function assignOfferToProject(formData: FormData) {
@@ -124,6 +255,7 @@ export async function assignOfferToProject(formData: FormData) {
project_id: formData.get("project_id"), project_id: formData.get("project_id"),
micro_id: formData.get("micro_id"), micro_id: formData.get("micro_id"),
accepted_total: formData.get("accepted_total") || undefined, 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); if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.insert(project_offers).values({ await db.insert(project_offers).values({
@@ -134,6 +266,10 @@ export async function assignOfferToProject(formData: FormData) {
? parsed.data.accepted_total.toFixed(2) ? parsed.data.accepted_total.toFixed(2)
: null, : 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}`); revalidatePath(`/admin/projects/${parsed.data.project_id}`);
} }
+54 -2
View File
@@ -61,7 +61,7 @@ export async function startTimerForClient(clientId: string): Promise<{ entryId:
export async function stopTimer(entryId: string): Promise<void> { export async function stopTimer(entryId: string): Promise<void> {
await requireAdmin(); await requireAdmin();
const rows = await db 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) .from(time_entries)
.where(eq(time_entries.id, entryId)) .where(eq(time_entries.id, entryId))
.limit(1); .limit(1);
@@ -77,4 +77,56 @@ export async function stopTimer(entryId: string): Promise<void> {
revalidatePath("/admin"); revalidatePath("/admin");
revalidatePath("/admin/projects"); 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 -2
View File
@@ -7,7 +7,7 @@ import { rateLimit } from "@/lib/rate-limit";
const commentSchema = z.object({ const commentSchema = z.object({
token: z.string().min(1), 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), entity_id: z.string().min(1),
body: z.string().min(1, "Il commento non può essere vuoto").max(2000), 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) .from(tasks)
.where(inArray(tasks.phase_id, phaseIds)); .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)) { if (!taskRows.find((r) => r.id === entity_id)) {
return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
} }
+7
View File
@@ -67,6 +67,13 @@ function projectViewToClientView(
})), })),
global_progress_pct: view.global_progress_pct, global_progress_pct: view.global_progress_pct,
activeOffers: view.activeOffers, 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),
})),
}; };
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+175 -41
View File
@@ -1,64 +1,164 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "tailwindcss-animate";
@source not "../../.01_projects/**"; @source not "../../.01_projects/**";
@source not "../../.claude/**"; @source not "../../.claude/**";
@source not "../../scripts/**"; @source not "../../scripts/**";
@source not "../../.planning/**"; @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 { @theme inline {
/* Font */ /* 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; "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
/* Base */ /* Colors — mapped from the raw tokens above */
--color-background: #ffffff; --color-background: var(--background);
--color-foreground: #1a1a1a; --color-foreground: var(--foreground);
/* Brand primary — verde scuro iamcavalli */ --color-primary: var(--primary);
--color-primary: #1A463C; --color-primary-foreground: var(--primary-foreground);
--color-primary-foreground: #ffffff;
/* Brand accent — giallo lime */ --color-accent: var(--accent);
--color-accent: #DEF168; --color-accent-foreground: var(--accent-foreground);
--color-accent-foreground: #1A463C;
/* Secondary — grigio neutro */ --color-secondary: var(--secondary);
--color-secondary: #f4f4f5; --color-secondary-foreground: var(--secondary-foreground);
--color-secondary-foreground: #1a1a1a;
/* Muted — per testi e sfondi secondari */ --color-muted: var(--muted);
--color-muted: #f9f9f9; --color-muted-foreground: var(--muted-foreground);
--color-muted-foreground: #71717a;
/* Destructive */ --color-destructive: var(--destructive);
--color-destructive: #ef4444; --color-destructive-foreground: var(--destructive-foreground);
--color-destructive-foreground: #ffffff;
/* Card */ --color-card: var(--card);
--color-card: #ffffff; --color-card-foreground: var(--card-foreground);
--color-card-foreground: #1a1a1a;
/* Popover */ --color-popover: var(--popover);
--color-popover: #ffffff; --color-popover-foreground: var(--popover-foreground);
--color-popover-foreground: #1a1a1a;
/* Border / Input / Ring */ --color-border: var(--border);
--color-border: #e5e7eb; --color-input: var(--input);
--color-input: #e5e7eb; --color-ring: var(--ring);
--color-ring: #1A463C;
/* Semantic — stato task/pagamenti */ --color-success: var(--success);
--color-success: #16a34a; --color-warning: var(--warning);
--color-warning: #ca8a04; --color-info: var(--info);
--color-info: #2563eb;
/* Legacy — usati inline nei componenti esistenti */ --color-tertiary: var(--tertiary);
--color-tertiary: #999999; --color-bg-subtle: var(--bg-subtle);
--color-bg-subtle: #f9f9f9; --color-border-light: var(--border-light);
--color-border-light: #e5e5e5;
/* 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 { body {
background-color: #ffffff; background-color: var(--background);
color: #171717; color: var(--foreground);
font-family: var(--font-sans); font-family: var(--font-sans);
line-height: 1.6; line-height: 1.6;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -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);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

+11 -5
View File
@@ -1,10 +1,11 @@
import type { Metadata, Viewport } from "next"; 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"; import "./globals.css";
const geistSans = Geist({ const plusJakartaSans = Plus_Jakarta_Sans({
variable: "--font-geist-sans", variable: "--font-plus-jakarta-sans",
subsets: ["latin"], subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
}); });
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
@@ -30,9 +31,14 @@ export default function RootLayout({
return ( return (
<html <html
lang="it" 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} {children}
</body> </body>
</html> </html>
+80
View File
@@ -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>
)
}
+67
View File
@@ -0,0 +1,67 @@
"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 }: { children: React.ReactNode }) {
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} />
<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>
);
}
+60 -29
View File
@@ -14,11 +14,13 @@ import {
Zap, Zap,
FileText, FileText,
} from "lucide-react"; } from "lucide-react";
import ThemeToggle from "@/components/ThemeToggle";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true }, { href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
{ href: "/admin/pipeline", label: "Pipeline", icon: Zap },
{ href: "/admin/clients", label: "Clienti", icon: Users }, { href: "/admin/clients", label: "Clienti", icon: Users },
{ href: "/admin/leads", label: "Lead", icon: Zap },
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen }, { href: "/admin/projects", label: "Progetti", icon: FolderOpen },
{ href: "/admin/preventivi", label: "Preventivi", icon: FileText }, { href: "/admin/preventivi", label: "Preventivi", icon: FileText },
{ href: "/admin/offers", label: "Offerte", icon: Tag }, { href: "/admin/offers", label: "Offerte", icon: Tag },
@@ -26,59 +28,88 @@ const NAV_ITEMS = [
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings }, { href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
]; ];
export function AdminSidebar() { export function AdminSidebar({ collapsed = false }: { collapsed?: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
const isActive = (href: string, exact?: boolean) => const isActive = (href: string, exact?: boolean) =>
exact ? pathname === href : pathname === href || pathname.startsWith(href + "/"); exact ? pathname === href : pathname === href || pathname.startsWith(href + "/");
return ( // Shared row layout: full width + gap + label when expanded, icon centered
<aside className="w-56 shrink-0 min-h-screen bg-[#1A463C] flex flex-col"> // (no gap, no padding) when collapsed so nothing pushes the icon off-center.
{/* Logo */} const rowClass = collapsed ? "justify-center px-0" : "gap-3 px-3";
<div className="px-5 py-5 border-b border-white/10">
<span className="font-bold text-white tracking-tight text-sm">iamcavalli</span>
</div>
{/* CTA globale — genera preventivo */} return (
<div className="px-3 py-3 border-b border-white/10"> <aside
<Link className={cn(
href="/admin/preventivi/genera" "shrink-0 min-h-screen bg-[#1A463C] border-r border-emerald-950/20 flex flex-col overflow-hidden",
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" "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-emerald-50 tracking-tight text-sm whitespace-nowrap">
iamcavalli
</span>
)}
</div> </div>
{/* Nav links */} {/* 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 }) => { {NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
const active = isActive(href, exact); const active = isActive(href, exact);
return ( return (
<Link <Link
key={href} key={href}
href={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 active
? "bg-white/15 text-white font-medium" ? "bg-white/[0.08] text-white font-medium shadow-sm"
: "text-white/65 hover:text-white hover:bg-white/10" : "text-emerald-200/70 hover:text-white hover:bg-white/5"
}`} )}
> >
<Icon size={16} strokeWidth={1.8} /> <Icon
{label} size={16}
strokeWidth={1.8}
className={cn("shrink-0", active && "text-emerald-400")}
/>
{!collapsed && <span className="whitespace-nowrap">{label}</span>}
</Link> </Link>
); );
})} })}
</nav> </nav>
{/* Logout */} {/* Theme + Logout */}
<div className="px-3 py-4 border-t border-white/10"> <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-emerald-200/50">Tema</span>}
<ThemeToggle />
</div>
<button <button
onClick={() => signOut({ callbackUrl: "/admin/login" })} 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-red-300/80 hover:text-red-200 hover:bg-red-950/20",
rowClass
)}
> >
<LogOut size={16} strokeWidth={1.8} /> <LogOut size={16} strokeWidth={1.8} className="shrink-0" />
Esci {!collapsed && <span className="whitespace-nowrap">Esci</span>}
</button> </button>
</div> </div>
</aside> </aside>
@@ -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>
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { ForecastMonth } from "@/lib/forecast-queries";
function fmtEur(n: number) {
return n.toLocaleString("it-IT", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
export function ForecastChart({ data }: { data: ForecastMonth[] }) {
const max = Math.max(...data.map((d) => d.total), 1);
// index 0 = mese corrente, index 1 = mese prossimo (evidenziato)
const nextMonth = data[1];
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-5">
{nextMonth && (
<div className="flex items-baseline justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-wider text-[#71717a]">
Previsto il mese prossimo
</p>
<p className="text-xs text-[#a1a1aa] mt-0.5 capitalize">{nextMonth.label}</p>
</div>
<p className="text-2xl font-bold tracking-tight text-[#1A463C]">
{fmtEur(nextMonth.total)}
</p>
</div>
)}
<div className="flex items-end gap-2 h-44 border-t border-[#f4f4f5] 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-1">
<div className="w-full flex flex-col justify-end" style={{ height: "120px" }}>
<div
className={`w-full rounded-t-md transition-all ${
isNext ? "bg-[#1A463C]" : "bg-[#1A463C]/35"
}`}
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-[#1A463C]" : "text-[#71717a]"
}`}
>
{m.label.split(" ")[0]}
</span>
</div>
);
})}
</div>
</div>
);
}
+177
View File
@@ -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>
);
}
+50
View File
@@ -0,0 +1,50 @@
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-white rounded-xl border border-[#e5e7eb] p-8 text-center">
<p className="text-sm text-[#71717a] italic">Nessuna offerta venduta finora.</p>
</div>
);
}
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-[#1a1a1a]">Offerte vendute</h3>
<span className="inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full bg-[#1A463C] text-white text-xs font-bold">
{data.total}
</span>
</div>
<div className="space-y-3">
{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-[#1a1a1a] truncate">
{row.offer_name}
{row.tier_letter && (
<span className="ml-2 inline-flex items-center rounded-full bg-[#1A463C]/10 px-2 py-0.5 text-[11px] font-semibold text-[#1A463C]">
Tier {row.tier_letter}
</span>
)}
</span>
<span className="text-sm tabular-nums text-[#71717a] shrink-0">{row.count}</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>
);
}
+17
View File
@@ -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>
);
}
+55
View File
@@ -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>
);
}
@@ -1,46 +1,96 @@
import { getLeadsNeedingFollowUp } from "@/lib/lead-service"; 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"; import Link from "next/link";
const MAX_ROWS = 4;
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
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() { export async function FollowUpWidget() {
const leadsNeedingFollowUp = await getLeadsNeedingFollowUp(7); const leads = await getLeadsNeedingFollowUp(7);
if (leads.length === 0) {
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] px-5 py-4">
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-[#1a1a1a]">Follow-up</h2>
</div>
<p className="text-sm text-[#71717a] mt-2">Tutti i lead sono aggiornati </p>
</div>
);
}
const visible = leads.slice(0, MAX_ROWS);
return ( return (
<Card className="border-orange-200 bg-orange-50"> <div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
<CardHeader> {/* Header */}
<CardTitle className="flex items-center gap-2"> <div className="flex items-center justify-between px-5 py-3.5 border-b border-[#f4f4f5]">
<AlertCircle className="h-5 w-5 text-orange-600" /> <div className="flex items-center gap-2.5">
Richiedi Follow-up <h2 className="text-sm font-bold text-[#1a1a1a]">Follow-up</h2>
</CardTitle> <span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-[#1A463C] text-white text-[11px] font-bold">
</CardHeader> {leads.length}
<CardContent className="space-y-4"> </span>
{leadsNeedingFollowUp.length > 0 ? ( </div>
<> <Link
<p className="text-lg font-bold text-orange-900"> href="/admin/pipeline"
{leadsNeedingFollowUp.length} lead da contattare className="text-xs font-medium text-[#71717a] hover:text-[#1A463C] transition-colors"
</p> >
<ul className="space-y-2 text-sm"> Vedi tutti
{leadsNeedingFollowUp.slice(0, 3).map((lead) => ( </Link>
<li key={lead.id} className="flex justify-between items-center"> </div>
<span>{lead.name}</span>
<Button variant="ghost" size="sm" asChild> {/* Lead rows */}
<Link href={`/admin/leads/${lead.id}`}>Contatta</Link> <ul className="divide-y divide-[#f4f4f5]">
</Button> {visible.map((lead) => (
</li> <li key={lead.id}>
))} <Link
</ul> href={`/admin/pipeline/${lead.id}`}
{leadsNeedingFollowUp.length > 3 && ( className="flex items-center gap-3 px-5 py-3 hover:bg-[#f9f9f9] transition-colors group"
<Button variant="outline" className="w-full" asChild> >
<Link href="/admin/leads">Vedi Tutto ({leadsNeedingFollowUp.length})</Link> <span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#1A463C]/10 text-[#1A463C] text-xs font-bold">
</Button> {initials(lead.name)}
)} </span>
</> <div className="min-w-0 flex-1">
) : ( <p className="text-sm font-semibold text-[#1a1a1a] truncate">{lead.name}</p>
<p className="text-gray-600 text-sm">Tutti i lead sono aggiornati!</p> <p className="text-xs text-[#71717a] truncate">
)} {lead.next_action
</CardContent> ? lead.next_action
</Card> : [lead.company, relativeDays(lead.last_contact_date)]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<span className="text-xs font-medium text-[#71717a] group-hover:text-[#1A463C] transition-colors shrink-0">
Contatta
</span>
</Link>
</li>
))}
</ul>
{leads.length > MAX_ROWS && (
<Link
href="/admin/pipeline"
className="block px-5 py-2.5 text-center text-xs font-medium text-[#71717a] hover:text-[#1A463C] border-t border-[#f4f4f5] transition-colors"
>
Vedi tutti i {leads.length} lead
</Link>
)}
</div>
); );
} }
@@ -1,43 +0,0 @@
"use client";
import { PoolManager } from "./PoolManager";
import { addPoolValue, removePoolValue } from "@/app/admin/impostazioni/actions";
export function OfferPoolsSection({
tipoPool,
obiettivoPool,
categoriaPool,
}: {
tipoPool: string[];
obiettivoPool: string[];
categoriaPool: string[];
}) {
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
<h2 className="text-base font-semibold text-[#1a1a1a] mb-1">Categorie Offerta</h2>
<p className="text-xs text-[#71717a] mb-5">
Valori disponibili nei campi Tipo, Obiettivo e Categoria dell&apos;editor offerte.
</p>
<div className="flex flex-wrap gap-6">
<PoolManager
label="Tipo offerta"
pool={tipoPool}
onAdd={(v) => addPoolValue("tipo", v)}
onRemove={(v) => removePoolValue("tipo", v)}
/>
<PoolManager
label="Obiettivo"
pool={obiettivoPool}
onAdd={(v) => addPoolValue("obiettivo", v)}
onRemove={(v) => removePoolValue("obiettivo", v)}
/>
<PoolManager
label="Categoria"
pool={categoriaPool}
onAdd={(v) => addPoolValue("categoria", v)}
onRemove={(v) => removePoolValue("categoria", v)}
/>
</div>
</div>
);
}
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { X } from "lucide-react"; import { X, Plus } from "lucide-react";
export function PoolManager({ export function PoolManager({
label, label,
@@ -18,6 +18,8 @@ export function PoolManager({
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const canAdd = input.trim().length > 0;
function handleAdd() { function handleAdd() {
const trimmed = input.trim(); const trimmed = input.trim();
if (!trimmed) return; if (!trimmed) return;
@@ -33,6 +35,10 @@ export function PoolManager({
} }
function handleRemove(value: string) { 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)); startTransition(() => onRemove(value));
} }
@@ -44,52 +50,68 @@ export function PoolManager({
} }
return ( return (
<div className="flex-1 min-w-[180px]"> <div
<p className="text-sm font-medium text-[#1a1a1a] mb-2">{label}</p> className={`rounded-lg border border-[#ececee] bg-[#fafafa] p-3.5 transition-opacity ${
isPending ? "opacity-60" : ""
<div className="flex flex-wrap gap-1.5 mb-3 min-h-[28px]"> }`}
{pool.length === 0 && ( >
<span className="text-xs text-[#a1a1aa]">Nessun valore</span> <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>
)} )}
{pool.map((v) => (
<span
key={v}
className="inline-flex items-center gap-1 bg-[#f4f4f5] text-[#1a1a1a] text-xs px-2 py-0.5 rounded-full"
>
{v}
<button
type="button"
onClick={() => handleRemove(v)}
disabled={isPending}
className="text-[#71717a] hover:text-red-500 transition-colors disabled:opacity-50"
aria-label={`Rimuovi ${v}`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div> </div>
<div className="flex gap-1.5"> <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 <input
type="text" type="text"
value={input} value={input}
onChange={(e) => { setInput(e.target.value); setError(null); }} onChange={(e) => {
setInput(e.target.value);
setError(null);
}}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Aggiungi..." placeholder="Aggiungi valore"
disabled={isPending} disabled={isPending}
className="border border-[#e5e7eb] rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 flex-1 min-w-0 disabled:opacity-50" 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 <button
type="button" type="button"
onClick={handleAdd} onClick={handleAdd}
disabled={isPending || !input.trim()} disabled={isPending || !canAdd}
className="bg-[#1A463C] text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors disabled:opacity-40" 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> </button>
</div> </div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>} {error && <p className="mt-1.5 text-xs text-red-500">{error}</p>}
</div> </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&apos;editor offerte o dal catalogo
comparirà qui automaticamente. Eliminando un valore da qui viene rimosso ovunque sia usato.
</p>
</div>
);
}
+37 -1
View File
@@ -7,7 +7,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { OptionMultiSelect } from "@/components/ui/option-multi-select"; 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 { formatDistanceToNow, format } from "date-fns";
import { it } from "date-fns/locale"; import { it } from "date-fns/locale";
import Link from "next/link"; import Link from "next/link";
@@ -39,6 +45,7 @@ export function LeadDetail({
tags, tags,
tagOptions, tagOptions,
transcripts, transcripts,
convertedClientId,
}: { }: {
lead: Lead; lead: Lead;
activities: Activity[]; activities: Activity[];
@@ -46,13 +53,23 @@ export function LeadDetail({
tags: string[]; tags: string[];
tagOptions: string[]; tagOptions: string[];
transcripts: ClientTranscript[]; transcripts: ClientTranscript[];
convertedClientId?: string | null;
}) { }) {
const router = useRouter(); const router = useRouter();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const [converting, startConvert] = useTransition();
const [tagError, setTagError] = useState<string | null>(null); const [tagError, setTagError] = useState<string | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [deletingId, setDeletingId] = useState<string | null>(null); 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>) { function run(fn: () => Promise<unknown>) {
setTagError(null); setTagError(null);
startTransition(async () => { startTransition(async () => {
@@ -106,6 +123,25 @@ export function LeadDetail({
> >
Genera preventivo Genera preventivo
</Link> </Link>
{convertedClientId ? (
<Link
href={`/admin/clients/${convertedClientId}`}
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#1A463C]/10 text-[#1A463C] rounded-md text-sm font-semibold hover:bg-[#1A463C]/15 transition-colors"
>
Convertito Apri cliente
</Link>
) : (
lead.status === "won" && (
<button
type="button"
onClick={handleConvert}
disabled={converting}
className="inline-flex items-center gap-1.5 px-3 py-2 bg-[#1A463C] text-white rounded-md text-sm font-semibold hover:bg-[#1A463C]/90 transition-colors disabled:opacity-50"
>
{converting ? "Conversione..." : "Converti in cliente"}
</button>
)
)}
<EditLeadModal lead={lead} /> <EditLeadModal lead={lead} />
</div> </div>
</div> </div>
+1 -1
View File
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { createLeadSchema, LEAD_STAGES, type CreateLeadInput } from "@/lib/lead-validators"; import { createLeadSchema, LEAD_STAGES, type CreateLeadInput } from "@/lib/lead-validators";
import { Lead } from "@/db/schema"; import { Lead } from "@/db/schema";
import { createLead, updateLead } from "@/app/admin/leads/actions"; import { createLead, updateLead } from "@/app/admin/pipeline/actions";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
+43 -56
View File
@@ -3,29 +3,19 @@
import Link from "next/link"; import Link from "next/link";
import { useState, useRef, useEffect, useTransition } from "react"; import { useState, useRef, useEffect, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Check } from "lucide-react"; import { Check, ArrowRight } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EditableCell } from "@/components/ui/editable-cell"; import { EditableCell } from "@/components/ui/editable-cell";
import { OptionMultiSelect } from "@/components/ui/option-multi-select"; import { OptionMultiSelect } from "@/components/ui/option-multi-select";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { import {
updateLeadField, updateLeadField,
addLeadTag, addLeadTag,
removeLeadTag, removeLeadTag,
renameLeadTag, renameLeadTag,
} from "@/app/admin/leads/actions"; } from "@/app/admin/pipeline/actions";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries"; import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
import { cn } from "@/lib/utils"; 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 = [ const COLUMN_HEADERS = [
"Nome", "Nome",
"Email", "Email",
@@ -37,6 +27,11 @@ const COLUMN_HEADERS = [
"Azioni", "Azioni",
]; ];
const COL_COUNT = COLUMN_HEADERS.length; const COL_COUNT = COLUMN_HEADERS.length;
const HEADER_ALIGN: Record<string, string> = {
Stato: "text-center",
Tag: "text-center",
Azioni: "text-right",
};
function StatusCell({ function StatusCell({
lead, lead,
@@ -61,23 +56,15 @@ function StatusCell({
}, []); }, []);
return ( 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 <div
onClick={() => setIsOpen((v) => !v)} 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 <StatusBadge status={lead.status} />
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>
</div> </div>
{isOpen && ( {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"> <div className="flex flex-col gap-0.5">
{options.status.map((stage) => ( {options.status.map((stage) => (
<button <button
@@ -87,17 +74,12 @@ function StatusCell({
setIsOpen(false); setIsOpen(false);
run(() => updateLeadField(lead.id, "status", stage)); 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"> <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> </span>
<Badge <StatusBadge status={stage} />
variant="outline"
className={cn(STAGE_COLOR[stage] ?? "", "border-transparent text-xs px-2 py-0.5 font-medium truncate")}
>
{stage.replace(/_/g, " ")}
</Badge>
</button> </button>
))} ))}
</div> </div>
@@ -132,8 +114,8 @@ function LeadRow({
return ( return (
<> <>
<tr className="border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150"> <tr className="hover:bg-muted/40 transition-colors duration-150">
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]"> <td className="py-4 px-6 font-medium text-foreground min-w-[160px]">
<EditableCell <EditableCell
value={lead.name} value={lead.name}
type="text" type="text"
@@ -141,7 +123,7 @@ function LeadRow({
onSave={(v) => run(() => updateLeadField(lead.id, "name", v))} onSave={(v) => run(() => updateLeadField(lead.id, "name", v))}
/> />
</td> </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 <EditableCell
value={lead.email ?? ""} value={lead.email ?? ""}
type="text" type="text"
@@ -149,7 +131,7 @@ function LeadRow({
onSave={(v) => run(() => updateLeadField(lead.id, "email", v))} onSave={(v) => run(() => updateLeadField(lead.id, "email", v))}
/> />
</td> </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 <EditableCell
value={lead.phone ?? ""} value={lead.phone ?? ""}
type="text" type="text"
@@ -157,7 +139,7 @@ function LeadRow({
onSave={(v) => run(() => updateLeadField(lead.id, "phone", v))} onSave={(v) => run(() => updateLeadField(lead.id, "phone", v))}
/> />
</td> </td>
<td className="py-2 px-3 min-w-[140px]"> <td className="py-4 px-6 min-w-[140px]">
<EditableCell <EditableCell
value={lead.company ?? ""} value={lead.company ?? ""}
type="text" type="text"
@@ -165,10 +147,10 @@ function LeadRow({
onSave={(v) => run(() => updateLeadField(lead.id, "company", v))} onSave={(v) => run(() => updateLeadField(lead.id, "company", v))}
/> />
</td> </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} /> <StatusCell lead={lead} options={options} run={run} />
</td> </td>
<td className="py-2 px-3 min-w-[180px]"> <td className="py-4 px-6 min-w-[180px]">
<EditableCell <EditableCell
value={lead.next_action ?? ""} value={lead.next_action ?? ""}
type="text" type="text"
@@ -176,7 +158,7 @@ function LeadRow({
onSave={(v) => run(() => updateLeadField(lead.id, "next_action", v))} onSave={(v) => run(() => updateLeadField(lead.id, "next_action", v))}
/> />
</td> </td>
<td className="py-2 px-3 min-w-[180px]"> <td className="py-4 px-6 min-w-[180px] text-center">
<OptionMultiSelect <OptionMultiSelect
values={lead.tags} values={lead.tags}
options={options.tags} options={options.tags}
@@ -185,15 +167,19 @@ function LeadRow({
onRename={(o, n) => run(() => renameLeadTag(o, n))} onRename={(o, n) => run(() => renameLeadTag(o, n))}
/> />
</td> </td>
<td className="py-2 px-3 min-w-[80px]"> <td className="py-4 px-6 min-w-[80px] text-right">
<Button variant="ghost" size="sm" asChild> <Link
<Link href={`/admin/leads/${lead.id}`}>Dettagli</Link> href={`/admin/pipeline/${lead.id}`}
</Button> 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> </td>
</tr> </tr>
{error && ( {error && (
<tr> <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} {error}
</td> </td>
</tr> </tr>
@@ -210,22 +196,19 @@ export function LeadTable({
options: LeadFieldOptions; options: LeadFieldOptions;
}) { }) {
return ( return (
<div className="bg-white rounded-xl border border-[#e5e7eb]"> <div className="bg-card rounded-xl border border-border shadow-card overflow-hidden">
<div className="overflow-x-auto min-h-[300px]"> <div className="overflow-x-auto min-h-[300px]">
<table className="w-full text-sm"> <table className="w-full text-left border-collapse">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]"> <thead>
<tr> <tr className="border-b border-border bg-muted/50 text-muted-foreground text-[11px] font-semibold uppercase tracking-wider">
{COLUMN_HEADERS.map((header) => ( {COLUMN_HEADERS.map((header) => (
<th <th key={header} className={cn("py-4 px-6", HEADER_ALIGN[header])}>
key={header}
className="text-left py-2 px-3 font-semibold text-[#71717a]"
>
{header} {header}
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-border text-sm">
{leads.map((lead) => ( {leads.map((lead) => (
<LeadRow key={lead.id} lead={lead} options={options} /> <LeadRow key={lead.id} lead={lead} options={options} />
))} ))}
@@ -234,8 +217,12 @@ export function LeadTable({
</div> </div>
{leads.length === 0 && ( {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 px-6 py-4 text-xs text-muted-foreground">
Mostrando {leads.length} lead
</div>
</div> </div>
); );
} }
+38 -23
View File
@@ -13,8 +13,9 @@ import {
useDroppable, useDroppable,
useDraggable, useDraggable,
} from "@dnd-kit/core"; } 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 type { LeadWithTags } from "@/lib/admin-queries";
import { StatusBadge } from "@/components/ui/StatusBadge";
type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost"; type LeadStage = "contacted" | "qualified" | "proposal_sent" | "negotiating" | "won" | "lost";
@@ -24,12 +25,12 @@ const LEAD_COLUMNS: {
headerClass: string; headerClass: string;
dotClass: string; dotClass: string;
}[] = [ }[] = [
{ id: "contacted", label: "Contattato", headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" }, { id: "contacted", label: "Contattato", headerClass: "text-muted-foreground", dotClass: "bg-blue-400 dark:bg-blue-500" },
{ id: "qualified", label: "Qualificato", headerClass: "text-purple-700", dotClass: "bg-purple-400" }, { id: "qualified", label: "Qualificato", headerClass: "text-purple-700 dark:text-purple-400", dotClass: "bg-purple-400 dark:bg-purple-500" },
{ id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700", dotClass: "bg-amber-400" }, { id: "proposal_sent", label: "Offerta inviata", headerClass: "text-amber-700 dark:text-amber-400", dotClass: "bg-amber-400 dark:bg-amber-500" },
{ id: "negotiating", label: "Trattativa", headerClass: "text-orange-700", dotClass: "bg-orange-400" }, { id: "negotiating", label: "Trattativa", headerClass: "text-orange-700 dark:text-orange-400", dotClass: "bg-orange-400 dark:bg-orange-500" },
{ id: "won", label: "Vinto", headerClass: "text-green-700", dotClass: "bg-green-500" }, { id: "won", label: "Vinto", headerClass: "text-emerald-700 dark:text-emerald-400", dotClass: "bg-emerald-500" },
{ id: "lost", label: "Perso", headerClass: "text-red-700", dotClass: "bg-red-400" }, { id: "lost", label: "Perso", headerClass: "text-red-700 dark:text-red-400", dotClass: "bg-red-400 dark:bg-red-500" },
]; ];
const VALID_STAGES = LEAD_COLUMNS.map((c) => c.id) as string[]; const VALID_STAGES = LEAD_COLUMNS.map((c) => c.id) as string[];
@@ -49,26 +50,26 @@ function DroppableColumn({
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
className={`flex flex-col rounded-xl border-2 transition-colors ${ className={`flex flex-col rounded-xl border p-4 transition-colors ${
isOver ? "border-[#1A463C] bg-[#1A463C]/5" : "border-[#e5e7eb] bg-[#f9f9f9]" isOver ? "border-primary bg-primary/5" : "border-border bg-muted/60"
} min-h-[240px]`} } min-h-[240px]`}
> >
<div className={`px-4 py-3 flex items-center justify-between ${headerClass}`}> <div className={`flex items-center justify-between pb-2 mb-2 border-b border-border ${headerClass}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${dotClass}`} /> <span className={`w-2 h-2 rounded-full ${dotClass}`} />
<span className="text-xs font-bold uppercase tracking-wider">{label}</span> <span className="text-xs font-bold uppercase tracking-wider">{label}</span>
</div> </div>
<span className="text-xs font-semibold tabular-nums bg-white rounded-full px-2 py-0.5 border border-[#e5e7eb]"> <span className="text-xs font-semibold tabular-nums bg-card rounded-full px-2 py-0.5 border border-border text-muted-foreground">
{leads.length} {leads.length}
</span> </span>
</div> </div>
<div className="flex-1 p-3 space-y-2"> <div className="flex-1 space-y-2">
{leads.map((lead) => ( {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 && ( {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 Nessun lead
</p> </p>
)} )}
@@ -77,7 +78,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 { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: lead.id });
const style = transform const style = transform
@@ -90,16 +99,22 @@ function DraggableLeadCard({ lead, isActive }: { lead: LeadWithTags; isActive: b
style={style} style={style}
{...listeners} {...listeners}
{...attributes} {...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 ${ className={`bg-card rounded-lg border border-border px-3 py-2.5 cursor-grab active:cursor-grabbing shadow-sm select-none transition-all duration-200 ${
isDragging ? "opacity-30" : "hover:border-[#1A463C]/40 hover:shadow" isDragging ? "opacity-30" : "hover:border-primary/30 hover:shadow-card-hover"
}`} }`}
> >
<p className="text-sm font-medium text-[#1a1a1a]">{lead.name}</p> <div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-foreground">{lead.name}</p>
<StatusBadge status={status} className="rounded px-1.5 py-0.5 text-[9px]" />
</div>
{lead.email && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{lead.email}</p>
)}
{lead.company && ( {lead.company && (
<p className="text-xs text-[#71717a]">{lead.company}</p> <p className="text-xs text-muted-foreground mt-1">{lead.company}</p>
)} )}
{lead.next_action && ( {lead.next_action && (
<p className="text-xs text-[#71717a] mt-1 line-clamp-1">{lead.next_action}</p> <p className="text-xs text-muted-foreground mt-1 line-clamp-1">{lead.next_action}</p>
)} )}
</div> </div>
); );
@@ -170,10 +185,10 @@ export function LeadsKanbanBoard({ leads }: { leads: LeadWithTags[] }) {
<DragOverlay dropAnimation={null}> <DragOverlay dropAnimation={null}>
{activeLead && ( {activeLead && (
<div className="bg-white rounded-lg border-2 border-[#1A463C] px-3 py-2.5 shadow-xl rotate-1 pointer-events-none"> <div className="bg-card rounded-lg border-2 border-primary px-3 py-2.5 shadow-xl rotate-1 pointer-events-none">
<p className="text-sm font-medium text-[#1a1a1a]">{activeLead.name}</p> <p className="text-sm font-medium text-foreground">{activeLead.name}</p>
{activeLead.company && ( {activeLead.company && (
<p className="text-xs text-[#71717a]">{activeLead.company}</p> <p className="text-xs text-muted-foreground">{activeLead.company}</p>
)} )}
</div> </div>
)} )}
+18 -35
View File
@@ -1,8 +1,8 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { Input } from "@/components/ui/input"; import { SearchInput } from "@/components/ui/SearchInput";
import { Search } from "lucide-react"; import { SegmentedToggle } from "@/components/ui/SegmentedToggle";
import { LeadTable } from "@/components/admin/leads/LeadTable"; import { LeadTable } from "@/components/admin/leads/LeadTable";
import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard"; import { LeadsKanbanBoard } from "@/components/admin/leads/LeadsKanbanBoard";
import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries"; import type { LeadWithTags, LeadFieldOptions } from "@/lib/admin-queries";
@@ -32,39 +32,22 @@ export function LeadsViewToggle({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="relative max-w-sm flex-1"> <SearchInput
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" /> placeholder="Cerca lead..."
<Input value={query}
type="text" onChange={(e) => setQuery(e.target.value)}
placeholder="Cerca lead..." containerClassName="w-full md:w-80"
value={query} />
onChange={(e) => setQuery(e.target.value)} <SegmentedToggle
className="pl-9 h-9" options={[
/> { value: "list", label: "Lista" },
</div> { value: "kanban", label: "Kanban" },
<div className="flex items-center gap-1 bg-[#f4f4f5] rounded-lg p-1 w-fit flex-shrink-0"> ]}
<button value={view}
onClick={() => setView("list")} onChange={setView}
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${ className="self-start md:self-auto flex-shrink-0"
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> </div>
{view === "list" ? ( {view === "list" ? (
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { createActivitySchema, ACTIVITY_TYPES } from "@/lib/lead-validators"; import { createActivitySchema, ACTIVITY_TYPES } from "@/lib/lead-validators";
import { logActivity } from "@/app/admin/leads/actions"; import { logActivity } from "@/app/admin/pipeline/actions";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { assignQuoteToLead } from "@/app/admin/leads/actions"; import { assignQuoteToLead } from "@/app/admin/pipeline/actions";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { addTranscript } from "@/app/admin/leads/actions"; import { addTranscript } from "@/app/admin/pipeline/actions";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -45,12 +45,15 @@ function formatUnitPrice(raw: string): string {
return `${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; return `${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
} }
type OfferType = "una_tantum" | "retainer";
type MacroFields = { type MacroFields = {
internal_name: string; internal_name: string;
public_name: string; public_name: string;
description: string; description: string;
category: string | null; category: string | null;
ticket: string | null; ticket: string | null;
offer_type: OfferType;
cliente_ideale: string; cliente_ideale: string;
risultato: string; risultato: string;
tempo: string; tempo: string;
@@ -75,6 +78,7 @@ export function OfferEditorClient({
description: data.macro.description ?? "", description: data.macro.description ?? "",
category: data.macro.category, category: data.macro.category,
ticket: data.macro.ticket, ticket: data.macro.ticket,
offer_type: (data.macro.offer_type === "retainer" ? "retainer" : "una_tantum") as OfferType,
cliente_ideale: data.macro.cliente_ideale ?? "", cliente_ideale: data.macro.cliente_ideale ?? "",
risultato: data.macro.risultato ?? "", risultato: data.macro.risultato ?? "",
tempo: data.macro.tempo ?? "", tempo: data.macro.tempo ?? "",
@@ -158,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() { function handleSave() {
setSaveError(null); setSaveError(null);
const payload: SaveOfferEditorPayload = { const payload: SaveOfferEditorPayload = {
@@ -166,20 +176,24 @@ export function OfferEditorClient({
description: macro.description, description: macro.description,
category: macro.category ?? undefined, category: macro.category ?? undefined,
ticket: macro.ticket ?? undefined, ticket: macro.ticket ?? undefined,
offer_type: macro.offer_type,
cliente_ideale: macro.cliente_ideale, cliente_ideale: macro.cliente_ideale,
risultato: macro.risultato, risultato: macro.risultato,
tempo: macro.tempo, tempo: macro.tempo,
pain: macro.pain, pain: macro.pain,
metodo: macro.metodo, metodo: macro.metodo,
tiers: tiers.map((t) => ({ tiers: tiers.map((t) => {
id: t.id || undefined, const tierName = t.public_name?.trim();
tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C", return {
internal_name: t.internal_name || `Tier ${t.tier_letter}`, id: t.id || undefined,
public_name: t.public_name || t.tier_letter || "Tier", tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C",
duration_months: t.duration_months || 1, internal_name: tierName || `Tier ${t.tier_letter}`,
public_price: t.public_price ? Number(t.public_price) : null, public_name: tierName || t.tier_letter || "Tier",
assignedServiceIds: t.assignedServiceIds, duration_months: t.duration_months || 1,
})), public_price: t.public_price ? Number(t.public_price) : null,
assignedServiceIds: t.assignedServiceIds,
};
}),
tipoTags, tipoTags,
obiettivoTags, obiettivoTags,
}; };
@@ -291,6 +305,42 @@ export function OfferEditorClient({
/> />
</h3> </h3>
</div> </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> </div>
{/* Tags block */} {/* Tags block */}
@@ -433,6 +483,22 @@ export function OfferEditorClient({
)} )}
</tbody> </tbody>
<tfoot> <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]"> <tr className="border-t border-[#e5e7eb]">
<td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}> <td className="py-2 px-3 font-semibold text-[#1a1a1a]" colSpan={2}>
Totale Servizi Totale Servizi
@@ -4,10 +4,15 @@ import { useState, useMemo, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { createOfferMacro } from "@/app/admin/offers/actions"; import { createOfferMacro } from "@/app/admin/offers/actions";
import type { OfferListCard } from "@/lib/offer-queries"; import type { OfferListCard, OfferListTier } from "@/lib/offer-queries";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { PageHeader } from "@/components/admin/PageHeader";
function formatEuro(value: number): string {
return `${value.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function OfferListClient({ export function OfferListClient({
cards, cards,
@@ -77,11 +82,7 @@ export function OfferListClient({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Page header */} <PageHeader title="Offerte" action={createCta} />
<div className="flex items-center justify-between">
<h2 className="text-[20px] font-semibold text-[#1a1a1a]">Offerte</h2>
{createCta}
</div>
{/* Filter row */} {/* Filter row */}
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
@@ -149,6 +150,27 @@ export function OfferListClient({
<span className="text-xs font-semibold text-[#dc2626]">Archiviata</span> <span className="text-xs font-semibold text-[#dc2626]">Archiviata</span>
)} )}
</div> </div>
{card.tiers.filter(
(t) => Number(t.servicesTotal) > 0 || t.public_price !== null
).length > 0 && (
<div className="mt-3 pt-3 border-t border-[#e5e7eb] space-y-1">
{card.tiers
.filter((t) => Number(t.servicesTotal) > 0 || t.public_price !== null)
.map((t) => (
<div key={t.tier_letter} className="flex items-baseline gap-2 text-xs">
<span className="font-medium text-[#1a1a1a] w-16 shrink-0 truncate">
{t.public_name || `Tier ${t.tier_letter}`}
</span>
<span className="tabular-nums text-[#71717a]">
Servizi: {formatEuro(Number(t.servicesTotal))}
</span>
<span className="tabular-nums text-[#71717a]">
Pubblico: {t.public_price !== null ? formatEuro(Number(t.public_price)) : "—"}
</span>
</div>
))}
</div>
)}
</Link> </Link>
))} ))}
</div> </div>
+40 -36
View File
@@ -11,9 +11,12 @@ type Props = {
}; };
export async function CommentsTab({ comments, phases, clientId }: Props) { export async function CommentsTab({ comments, phases, clientId }: Props) {
// Build entity label map for display // Build entity label map for display (phases, tasks, deliverables, and general)
const entityLabels: Record<string, string> = {}; const entityLabels: Record<string, string> = {
[clientId]: "Messaggio generale",
};
for (const phase of phases) { for (const phase of phases) {
entityLabels[phase.id] = `Fase: ${phase.title}`;
for (const task of phase.tasks) { for (const task of phase.tasks) {
entityLabels[task.id] = `Task: ${task.title}`; entityLabels[task.id] = `Task: ${task.title}`;
for (const d of task.deliverables) { 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 // 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) { for (const phase of phases) {
entities.push({ id: phase.id, type: "phase", label: `Fase: ${phase.title}` });
for (const task of phase.tasks) { for (const task of phase.tasks) {
entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` }); entities.push({ id: task.id, type: "task", label: `Task: ${task.title}` });
for (const d of task.deliverables) { for (const d of task.deliverables) {
@@ -67,39 +73,37 @@ export async function CommentsTab({ comments, phases, clientId }: Props) {
</div> </div>
{/* Admin reply form */} {/* Admin reply form */}
{entities.length > 0 && ( <form
<form action={async (fd: FormData) => {
action={async (fd: FormData) => { "use server";
"use server"; await postAdminComment(clientId, fd);
await postAdminComment(clientId, fd); }}
}} className="bg-white border border-gray-200 rounded-lg p-4 space-y-3"
className="bg-white border border-gray-200 rounded-lg p-4 space-y-3" >
<h3 className="font-medium text-gray-900 text-sm">
Rispondi come admin
</h3>
<select
name="entity"
className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white"
required
> >
<h3 className="font-medium text-gray-900 text-sm"> {entities.map((e) => (
Rispondi come admin <option key={e.id} value={`${e.type}:${e.id}`}>
</h3> {e.label}
<select </option>
name="entity" ))}
className="w-full text-sm border border-gray-200 rounded px-2 py-1.5 bg-white" </select>
required <Textarea
> name="body"
{entities.map((e) => ( placeholder="Scrivi un commento..."
<option key={e.id} value={`${e.type}:${e.id}`}> rows={3}
{e.label} required
</option> />
))} <Button type="submit" size="sm">
</select> Invia risposta
<Textarea </Button>
name="body" </form>
placeholder="Scrivi un commento..."
rows={3}
required
/>
<Button type="submit" size="sm">
Invia risposta
</Button>
</form>
)}
</div> </div>
); );
} }
+40 -11
View File
@@ -1,15 +1,29 @@
import { addDocument } from "@/app/admin/clients/[id]/actions"; import { addDocument } from "@/app/admin/clients/[id]/actions";
import { DocumentRow } from "@/components/admin/DocumentRow"; import { DocumentRow } from "@/components/admin/DocumentRow";
import { TranscriptItem } from "@/components/admin/TranscriptItem";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import type { Document } from "@/db/schema"; 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 ( return (
<div className="space-y-6 max-w-lg"> <div className="space-y-8 max-w-lg">
{/* Add document form */}
<form <form
action={async (fd: FormData) => { action={async (fd: FormData) => {
"use server"; "use server";
@@ -42,14 +56,29 @@ export async function DocumentsTab({ documents, clientId }: Props) {
</Button> </Button>
</form> </form>
{documents.length === 0 && ( {/* Documents list */}
<p className="text-sm text-[#71717a]">Nessun documento ancora.</p> <div>
)} {documents.length === 0 && (
<div className="space-y-2"> <p className="text-sm text-[#71717a]">Nessun documento ancora.</p>
{documents.map((doc) => ( )}
<DocumentRow key={doc.id} doc={doc} clientId={clientId} /> <div className="space-y-2">
))} {documents.map((doc) => (
<DocumentRow key={doc.id} doc={doc} clientId={clientId} />
))}
</div>
</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> </div>
); );
} }
+242 -99
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useTransition, useRef } from "react"; import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Check, Trash2 } from "lucide-react";
import { import {
assignOfferToProject, assignOfferToProject,
removeProjectOffer, removeProjectOffer,
@@ -14,6 +15,12 @@ type AvailableMicro = {
internal_name: string; internal_name: string;
public_name: string; public_name: string;
duration_months: number; 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 { interface OffersTabProps {
@@ -22,20 +29,97 @@ interface OffersTabProps {
availableMicros: AvailableMicro[]; 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) { export function OffersTab({ projectId, projectOffers, availableMicros }: OffersTabProps) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const router = useRouter(); 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 () => { startTransition(async () => {
await assignOfferToProject(formData); await assignOfferToProject(fd);
formRef.current?.reset(); setMacroId("");
setTierId("");
setAcceptedTotal("");
setImportPhases(true);
router.refresh(); router.refresh();
}); });
} }
function handleRemove(offerId: string) { function handleRemove(offerId: string) {
if (!window.confirm("Rimuovere questa offerta dal progetto?")) return;
startTransition(async () => { startTransition(async () => {
await removeProjectOffer(offerId, projectId); await removeProjectOffer(offerId, projectId);
router.refresh(); router.refresh();
@@ -50,7 +134,7 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
} }
return ( return (
<div className="space-y-6 max-w-2xl"> <div className="space-y-8 max-w-2xl">
{/* Active assignments */} {/* Active assignments */}
<div> <div>
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3> <h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Offerte Attive</h3>
@@ -58,107 +142,166 @@ export function OffersTab({ projectId, projectOffers, availableMicros }: OffersT
<p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p> <p className="text-sm text-[#71717a]">Nessuna offerta assegnata a questo progetto.</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{projectOffers.map((offer) => ( {projectOffers.map((offer) => {
<div const pub = meaningfulPublicName({
key={offer.id} public_name: offer.micro_public_name,
className="bg-white rounded-lg border border-[#e5e7eb] p-4 flex items-start justify-between gap-4" tier_letter: offer.tier_letter,
> });
<div className="flex-1"> return (
<p className="text-sm font-medium text-[#1a1a1a]">{offer.micro_internal_name}</p> <div
<p className="text-xs text-[#71717a]"> key={offer.id}
Pubblico: {offer.micro_public_name} &middot; {offer.micro_duration_months}{" "} className="bg-white rounded-xl border border-[#e5e7eb] p-4 flex items-start justify-between gap-4"
{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 items-center gap-2 mt-2">
<label className="text-xs text-[#71717a]">Totale accettato :</label>
<input
type="number"
step="0.01"
min="0"
defaultValue={offer.accepted_total ?? ""}
placeholder="0.00"
onBlur={(e) => {
const val = e.currentTarget.value.trim();
if (val !== (offer.accepted_total ?? "")) {
handleTotalUpdate(offer.id, val);
}
}}
className="w-24 border rounded px-2 py-0.5 text-xs"
/>
</div>
</div>
<button
type="button"
onClick={() => handleRemove(offer.id)}
disabled={isPending}
className="text-xs text-red-600 hover:underline shrink-0"
> >
Rimuovi <div className="flex-1 min-w-0">
</button> <div className="flex flex-wrap items-center gap-2">
</div> <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>
<input
type="number"
step="0.01"
min="0"
defaultValue={offer.accepted_total ?? ""}
placeholder="0.00"
onBlur={(e) => {
const val = e.currentTarget.value.trim();
if (val !== (offer.accepted_total ?? "")) handleTotalUpdate(offer.id, val);
}}
className="w-28 border border-[#e5e7eb] rounded-md px-2 py-1 text-xs tabular-nums focus:outline-none focus:ring-2 focus:ring-[#1A463C]/15"
/>
</div>
</div>
<button
type="button"
onClick={() => handleRemove(offer.id)}
disabled={isPending}
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"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
})}
</div> </div>
)} )}
</div> </div>
{/* Assignment form */} {/* Assignment — step 1: offer, step 2: tier */}
<div> <div>
<h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna Micro-Offerta</h3> <h3 className="text-sm font-semibold text-[#1a1a1a] mb-3">Assegna 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} />
<div> {offers.length === 0 ? (
<label className="text-xs text-[#71717a] block mb-1">Micro-offerta</label> <p className="text-sm text-[#71717a]">
<select Nessuna offerta disponibile. Crea prima un&apos;offerta in{" "}
name="micro_id" <a href="/admin/offers" className="text-[#1A463C] underline">Offerte</a>.
required </p>
className="w-full border rounded px-3 py-1.5 text-sm" ) : (
> <div className="bg-white rounded-xl border border-[#e5e7eb] p-4 space-y-4">
<option value="">Seleziona micro-offerta...</option> {/* Step 1 — choose offer */}
{availableMicros.map((m) => ( <div>
<option key={m.id} value={m.id}> <label className="text-xs text-[#71717a] block mb-1">Offerta</label>
{m.internal_name} ({m.duration_months}{" "} <select
{m.duration_months === 1 ? "mese" : "mesi"}) value={macroId}
</option> 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"
</select> >
<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
value={acceptedTotal}
onChange={(e) => setAcceptedTotal(e.target.value)}
type="number"
step="0.01"
min="0"
placeholder="0.00"
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>
<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&apos;offerta
<span className="block text-[#71717a]">
Crea le fasi raggruppando i servizi per &laquo;Fase&raquo;; ogni servizio diventa un task.
</span>
</span>
</label>
<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> </div>
)}
<div>
<label className="text-xs text-[#71717a] block mb-1">Totale accettato (opzionale)</label>
<input
name="accepted_total"
type="number"
step="0.01"
min="0"
placeholder="0.00"
className="w-full border rounded px-3 py-1.5 text-sm"
/>
</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>
{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>
)}
</form>
</div> </div>
</div> </div>
); );
+194 -40
View File
@@ -1,8 +1,13 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { import {
updatePaymentStatus, updatePaymentStatus,
updateAcceptedTotal, updateAcceptedTotal,
setPaymentPaidAt,
} from "@/app/admin/clients/[id]/actions"; } 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -14,6 +19,7 @@ type Props = {
acceptedTotal: string; acceptedTotal: string;
clientId: string; clientId: string;
projectId?: string; // set only from project detail page — enables init & split 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> = { const statusLabels: Record<string, string> = {
@@ -22,28 +28,132 @@ const statusLabels: Record<string, string> = {
saldato: "Saldato", 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 ( return (
<div className="space-y-6 max-w-md"> <div className="space-y-6 max-w-md">
{/* Accepted total */} {/* Totale preventivo */}
<div className="bg-white border border-gray-200 rounded-lg p-4"> <div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3> <h3 className="font-medium text-gray-900">Totale preventivo</h3>
<form
action={async (fd: FormData) => { {/* Totale ereditato dalle offerte */}
"use server"; {hasOffers && (
await updateAcceptedTotal(clientId, fd); <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]">
className="flex items-end gap-3" 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"> <div className="space-y-1 flex-1">
<Label htmlFor="accepted_total">Importo ()</Label> <Label htmlFor="accepted_total">Importo override ()</Label>
<Input <Input
id="accepted_total" id="accepted_total"
name="accepted_total" name="accepted_total"
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"
defaultValue={acceptedTotal} value={overrideValue}
onChange={(e) => setOverrideValue(e.target.value)}
className="max-w-xs" className="max-w-xs"
/> />
</div> </div>
@@ -51,40 +161,71 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
Salva Salva
</Button> </Button>
</form> </form>
<p className="text-xs text-gray-400 mt-2">
Le rate Acconto e Saldo vengono aggiornate automaticamente al 50% ciascuna.
</p>
</div> </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 */} {/* Empty state — project has no payments yet */}
{projectId && payments.length === 0 && ( {projectId && payments.length === 0 && (
<div className="bg-white border border-dashed border-gray-300 rounded-lg p-6 text-center"> <div className="bg-white border border-dashed border-gray-300 rounded-lg p-6 text-center">
<p className="text-sm text-[#71717a] mb-4"> <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> </p>
<form <Button
action={async () => { type="button"
"use server"; size="sm"
await initProjectPayments(projectId); onClick={() => handleSetPlan("two")}
}} disabled={planLoading !== null}
> >
<Button type="submit" size="sm"> Crea Acconto &amp; Saldo (50/50)
Crea Acconto &amp; Saldo </Button>
</Button>
</form>
</div> </div>
)} )}
{/* Payment rows */} {/* Payment rows */}
{payments.map((p) => { {payments.map((p) => {
const amount = parseFloat(p.amount); const amount = parseFloat(p.amount);
const pct = p.percent !== null && p.percent !== undefined
? parseFloat(String(p.percent))
: null;
return ( return (
<div <div
key={p.id} key={p.id}
className="bg-white border border-gray-200 rounded-lg p-4" className="bg-white border border-gray-200 rounded-lg p-4"
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="font-medium text-gray-900">{p.label}</h3> <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"> <div className="flex items-center gap-2">
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{" "} {" "}
@@ -101,16 +242,14 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
)} )}
</div> </div>
</div> </div>
<form <div className="flex items-center gap-2">
action={async (fd: FormData) => {
"use server";
await updatePaymentStatus(p.id, clientId, fd.get("status") as string);
}}
className="flex items-center gap-2"
>
<select <select
name="status" name="status"
defaultValue={p.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" className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
> >
{Object.entries(statusLabels).map(([val, label]) => ( {Object.entries(statusLabels).map(([val, label]) => (
@@ -119,13 +258,28 @@ export async function PaymentsTab({ payments, acceptedTotal, clientId, projectId
</option> </option>
))} ))}
</select> </select>
<Button type="submit" size="sm" variant="outline"> {statusLoading === p.id && (
Aggiorna <span className="text-xs text-[#71717a]">...</span>
</Button> )}
</form> </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> </div>
); );
})} })}
</div> </div>
); );
} }
+64 -57
View File
@@ -6,6 +6,7 @@ import {
} from "@/app/admin/clients/[id]/actions"; } from "@/app/admin/clients/[id]/actions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
import type { ClientFullDetail } from "@/lib/admin-queries"; import type { ClientFullDetail } from "@/lib/admin-queries";
type Props = { type Props = {
@@ -20,8 +21,8 @@ const taskStatusOptions = [
]; ];
const phaseStatusOptions = [ const phaseStatusOptions = [
{ value: "upcoming", label: "In arrivo" }, { value: "upcoming", label: "Da iniziare" },
{ value: "active", label: "Attiva" }, { value: "active", label: "In corso" },
{ value: "done", label: "Completata" }, { value: "done", label: "Completata" },
]; ];
@@ -58,32 +59,35 @@ export async function PhasesTab({ phases, clientId }: Props) {
> >
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-gray-900">{phase.title}</h3> <h3 className="font-semibold text-gray-900">{phase.title}</h3>
<form <div className="flex items-center gap-2">
action={async (fd: FormData) => { <form
"use server"; action={async (fd: FormData) => {
await updatePhaseStatus( "use server";
phase.id, await updatePhaseStatus(
clientId, phase.id,
fd.get("status") as string clientId,
); fd.get("status") as string
}} );
className="flex items-center gap-2" }}
> className="flex items-center gap-2"
<select
name="status"
defaultValue={phase.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
> >
{phaseStatusOptions.map((o) => ( <select
<option key={o.value} value={o.value}> name="status"
{o.label} defaultValue={phase.status}
</option> className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
))} >
</select> {phaseStatusOptions.map((o) => (
<Button type="submit" variant="ghost" size="sm" className="text-xs"> <option key={o.value} value={o.value}>
Salva {o.label}
</Button> </option>
</form> ))}
</select>
<Button type="submit" variant="ghost" size="sm" className="text-xs">
Salva
</Button>
</form>
<DeletePhaseTaskButton type="phase" phaseId={phase.id} clientId={clientId} />
</div>
</div> </div>
{/* Tasks */} {/* Tasks */}
@@ -94,37 +98,40 @@ export async function PhasesTab({ phases, clientId }: Props) {
className="flex items-center justify-between pl-3 border-l-2 border-gray-100" className="flex items-center justify-between pl-3 border-l-2 border-gray-100"
> >
<span className="text-sm text-gray-800">{task.title}</span> <span className="text-sm text-gray-800">{task.title}</span>
<form <div className="flex items-center gap-1">
action={async (fd: FormData) => { <form
"use server"; action={async (fd: FormData) => {
await updateTaskStatus( "use server";
task.id, await updateTaskStatus(
clientId, task.id,
fd.get("status") as string clientId,
); fd.get("status") as string
}} );
className="flex items-center gap-1" }}
> className="flex items-center gap-1"
<select
name="status"
defaultValue={task.status}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
> >
{taskStatusOptions.map((o) => ( <select
<option key={o.value} value={o.value}> name="status"
{o.label} defaultValue={task.status}
</option> className="text-xs border border-gray-200 rounded px-2 py-1 bg-white"
))} >
</select> {taskStatusOptions.map((o) => (
<Button <option key={o.value} value={o.value}>
type="submit" {o.label}
variant="ghost" </option>
size="sm" ))}
className="text-xs px-1" </select>
> <Button
type="submit"
</Button> variant="ghost"
</form> size="sm"
className="text-xs px-1"
>
</Button>
</form>
<DeletePhaseTaskButton type="task" taskId={task.id} clientId={clientId} />
</div>
</div> </div>
))} ))}
</div> </div>
-333
View File
@@ -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>
);
}
+13 -1
View File
@@ -2,6 +2,14 @@
import { TimerCell } from "@/components/admin/TimerCell"; import { TimerCell } from "@/components/admin/TimerCell";
import { ProfitabilityCard } from "@/components/admin/ProfitabilityCard"; 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 = { type TimerTabProps = {
projectId: string; projectId: string;
@@ -10,6 +18,7 @@ type TimerTabProps = {
activeTimerStartedAt: Date | null; activeTimerStartedAt: Date | null;
totalTrackedSeconds: number; totalTrackedSeconds: number;
targetHourlyRate: number; targetHourlyRate: number;
recentEntries: TimeEntry[];
}; };
export function TimerTab({ export function TimerTab({
@@ -19,6 +28,7 @@ export function TimerTab({
activeTimerStartedAt, activeTimerStartedAt,
totalTrackedSeconds, totalTrackedSeconds,
targetHourlyRate, targetHourlyRate,
recentEntries,
}: TimerTabProps) { }: TimerTabProps) {
return ( return (
<div className="space-y-6 max-w-sm"> <div className="space-y-6 max-w-sm">
@@ -33,6 +43,8 @@ export function TimerTab({
/> />
</div> </div>
<ManualTimeEntry projectId={projectId} recentEntries={recentEntries} />
<ProfitabilityCard <ProfitabilityCard
acceptedTotal={acceptedTotal} acceptedTotal={acceptedTotal}
totalTrackedSeconds={totalTrackedSeconds} totalTrackedSeconds={totalTrackedSeconds}
@@ -40,4 +52,4 @@ export function TimerTab({
/> />
</div> </div>
); );
} }
+132 -84
View File
@@ -1,13 +1,16 @@
import type { ClientView } from '@/lib/client-view'; import type { ClientView } from '@/lib/client-view';
import type { Comment } from '@/db/schema'; import type { Comment } from '@/db/schema';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { RefreshCw } from 'lucide-react';
import { PhaseTimeline } from './phase-timeline'; import { PhaseTimeline } from './phase-timeline';
import { PaymentStatus } from './payment-status'; import { PaymentStatus } from './payment-status';
import { DocumentsSection } from './documents-section'; import { DocumentsSection } from './documents-section';
import { NotesSection } from './notes-section'; import { NotesSection } from './notes-section';
import { TranscriptsSection } from './transcripts-section';
import { PhaseViewToggle } from './client/kanban/PhaseViewToggle'; import { PhaseViewToggle } from './client/kanban/PhaseViewToggle';
import { ChatSection } from './client/ChatSection';
import { OffersSection } from './client/OffersSection'; import { OffersSection } from './client/OffersSection';
import { ChatProvider } from './client/ChatProvider';
import { ChatPanel } from './client/ChatPanel';
interface ClientDashboardProps { interface ClientDashboardProps {
view: ClientView; view: ClientView;
@@ -16,94 +19,139 @@ interface ClientDashboardProps {
} }
export function ClientDashboard({ view, token, comments }: ClientDashboardProps) { export function ClientDashboard({ view, token, comments }: 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 ( return (
<div className="min-h-screen bg-white"> <ChatProvider phases={view.phases} clientId={view.client.id}>
{/* Header */} <div className="min-h-screen bg-white">
<header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10"> {/* Header */}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-5"> <header className="bg-white border-b border-[#e5e5e5] sticky top-0 z-10">
<div className="flex items-center"> <div className="max-w-6xl mx-auto px-4 sm:px-6 py-5">
<span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0"> <div className="flex items-center">
iamcavalli <span className="text-xs font-semibold tracking-widest text-[#999999] uppercase w-28 shrink-0">
</span> iamcavalli
<h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight"> </span>
{view.client.brand_name} <h1 className="flex-1 text-center text-2xl sm:text-3xl font-bold text-[#1a1a1a] tracking-tight">
</h1> {view.client.brand_name}
<div className="w-28 shrink-0" /> </h1>
<div className="w-28 shrink-0" />
</div>
</div>
</header>
{/* Barra progresso globale — nascosta per retainer (fasi non tracciate) */}
{!hasRetainer && (
<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>
)}
{/* Layout principale */}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
<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">
{/* 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>
<OffersSection offers={view.activeOffers} />
</div>
)}
{/* 2° Pagamenti */}
<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}
totalLabel={paymentLabel}
overrideAmount={paymentOverrideAmount}
hideRows={hasRetainer}
/>
</div>
{/* 3° Documenti & File */}
<div>
<h2 className="text-xs font-bold text-[#71717a] 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>
<NotesSection notes={view.notes} />
</div>
)}
{view.transcripts.length > 0 && (
<div>
<h2 className="text-xs font-bold text-[#71717a] 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-xs font-bold text-[#71717a] uppercase tracking-wider mb-4">
Fasi del Progetto
</h2>
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-[#d4d4d4] bg-[#fafafa] px-8 py-12 text-center">
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-[#f0f0f0]">
<RefreshCw className="h-5 w-5 text-[#71717a]" />
</span>
<div className="space-y-1.5">
<p className="text-sm font-semibold text-[#1a1a1a]">Servizio in abbonamento ricorrente</p>
<p className="text-xs text-[#71717a] max-w-xs leading-relaxed">
Non ci sono fasi di progetto da tracciare. Le fasi compaiono qui quando viene avviata un&apos;offerta a progetto.
</p>
</div>
</div>
</div>
) : (
<PhaseViewToggle
timelineView={<PhaseTimeline phases={view.phases} token={token} />}
phases={view.phases}
token={token}
/>
)}
{/* Chat "Messaggi & Revisioni" moved to slide-in panel (FAB bottom-right) */}
</main>
</div> </div>
</div> </div>
</header>
{/* Barra progresso globale */} <footer className="border-t border-[#e5e5e5] bg-[#f9f9f9] mt-10">
<section className="bg-[#f9f9f9] border-b border-[#e5e5e5]"> <div className="max-w-6xl mx-auto px-4 sm:px-6 py-6">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-4"> <p className="text-xs text-[#999999] text-center">
<div className="flex items-center justify-between mb-2"> Questa è la tua dashboard privata non condividere il link.
<p className="text-sm font-semibold text-[#1a1a1a]">Avanzamento Progetto</p> </p>
<p className="text-sm font-bold text-[#1a1a1a]">{view.global_progress_pct}%</p>
</div> </div>
<Progress value={view.global_progress_pct} className="h-2" /> </footer>
</div>
</section>
{/* Layout principale */} {/* Floating chat panel — FAB + slide-in panel */}
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8"> <ChatPanel token={token} comments={comments} />
<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>
{view.activeOffers && view.activeOffers.length > 0 && (
<div>
<h2 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Offerte Attive</h2>
<OffersSection offers={view.activeOffers} />
</div>
)}
<div>
<h2 className="text-xs font-bold text-[#71717a] 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>
<NotesSection notes={view.notes} />
</div>
)}
</div>
</aside>
{/* ── Contenuto principale ── */}
<main className="flex-1 min-w-0 order-1 lg:order-2 space-y-12">
<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>
</main>
</div>
</div> </div>
</ChatProvider>
<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">
Questa è la tua dashboard privata non condividere il link.
</p>
</div>
</footer>
</div>
); );
} }
+260
View File
@@ -0,0 +1,260 @@
"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-30 bg-black/10 lg:hidden"
onClick={closeChat}
aria-hidden="true"
/>
)}
{/* Slide-in panel */}
<div
className={`fixed top-0 right-0 h-full z-40 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"
>
{/* Panel header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-[#e5e7eb] shrink-0">
<h2 className="text-base font-bold text-[#1a1a1a]">Messaggi & Revisioni</h2>
<button
type="button"
onClick={closeChat}
aria-label="Chiudi"
className="p-1.5 rounded-md text-[#71717a] hover:text-[#1a1a1a] hover:bg-[#f4f4f5] transition-colors"
>
<svg className="w-5 h-5" 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>
</button>
</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>
</>
);
}
+49
View File
@@ -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>
);
}
+84 -20
View File
@@ -1,39 +1,103 @@
"use client";
import { useState } from "react";
import { ChevronDown } from "lucide-react";
interface IncludedService {
name: string;
description: string | null;
}
interface ActiveOffer { interface ActiveOffer {
id: string; id: string;
public_name: string; // micro offer public name 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 cumulative_price: string; // sum of service prices
accepted_total: string | null; accepted_total: string | null;
services: IncludedService[];
} }
interface OffersSectionProps { interface OffersSectionProps {
offers: ActiveOffer[]; 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-white rounded-lg border border-[#e5e5e5] overflow-hidden">
{/* Main info rows */}
<div className="p-4 space-y-2">
{/* Heading: macro public name */}
<p className="text-sm font-semibold text-[#1a1a1a]">{offer.offer_name}</p>
<div className="space-y-1">
{/* "Valore incluso" — hidden when 0 */}
{hasPrice && (
<div className="flex items-center justify-between">
<span className="text-xs text-[#71717a]">Valore incluso</span>
<span className="text-xs font-mono text-[#1a1a1a]">
{price.toFixed(2)}
</span>
</div>
)}
{/* "Prezzo finale" */}
{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>
{/* Accordion "Cosa è compreso" — only when there are services */}
{hasServices && (
<div className="border-t border-[#e5e5e5]">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 text-xs font-semibold text-[#1a1a1a] hover:bg-[#f9f9f9] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1A463C] focus-visible:ring-inset"
aria-expanded={open}
>
<span>Cosa è compreso</span>
<ChevronDown
className={`w-3.5 h-3.5 text-[#71717a] transition-transform duration-200 ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<ul className="px-4 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-[#1a1a1a]">{svc.name}</span>
{svc.description && (
<span className="text-[11px] text-[#71717a] leading-snug">{svc.description}</span>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
export function OffersSection({ offers }: OffersSectionProps) { export function OffersSection({ offers }: OffersSectionProps) {
if (offers.length === 0) return null; if (offers.length === 0) return null;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{offers.map((offer) => ( {offers.map((offer) => (
<div key={offer.id} className="bg-white rounded-lg border border-[#e5e5e5] p-4"> <OfferCard key={offer.id} offer={offer} />
<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>
))} ))}
</div> </div>
); );
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { useState } from "react";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
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",
};
const phaseStatusStyle: Record<"upcoming" | "active" | "done", string> = {
upcoming: "border-transparent bg-[#999999] text-white",
active: "border-transparent bg-[#3b82f6] text-white",
done: "border-transparent bg-[#1A463C] text-white",
};
function PhaseStatusIcon({ status }: { status: "upcoming" | "active" | "done" }) {
if (status === "done") {
return (
<svg className="w-5 h-5 text-[#1A463C]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
);
}
if (status === "active") {
return (
<svg className="w-5 h-5 text-[#3b82f6]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-5 h-5 text-[#999999]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<circle cx={12} cy={12} r={9} />
</svg>
);
}
function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) {
if (status === "done") {
return (
<svg className="w-4 h-4 text-[#1A463C] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
);
}
if (status === "in_progress") {
return (
<svg className="w-4 h-4 text-[#3b82f6] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-4 h-4 text-[#999999] shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<circle cx={12} cy={12} r={9} />
</svg>
);
}
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 (
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-5">
{/* 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">
<div className="flex items-center gap-2.5 min-w-0">
<PhaseStatusIcon status={phase.status} />
<h3 className="text-base font-bold text-[#1a1a1a] leading-snug">{phase.title}</h3>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge className={`text-xs ${phaseStatusStyle[phase.status]}`}>
{phaseStatusLabel[phase.status]}
</Badge>
{/* 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-[#999999] hover:text-[#1A463C] hover:bg-[#f0f7f4] 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-[#999999] 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 */}
<div className="mb-1">
<div className="flex justify-between items-center mb-1.5">
<p className="text-xs text-[#666666] font-medium">{doneCount} di {phase.tasks.length} task</p>
<p className="text-xs font-semibold text-[#1a1a1a]">{phase.progress_pct}%</p>
</div>
<Progress value={phase.progress_pct} className="h-1.5" />
</div>
</button>
{/* Collapsible task list */}
{open && (
<div className="mt-4">
{phase.tasks.length === 0 ? (
<p className="text-xs text-[#999999] italic">Nessun task ancora configurato.</p>
) : (
<ul className="space-y-3">
{phase.tasks.map((task) => (
<li key={task.id} className="flex items-start gap-2.5">
<TaskStatusIcon status={task.status} />
<div className="flex-1 min-w-0">
<p
className={`text-sm leading-snug ${
task.status === "done"
? "line-through text-[#999999]"
: "text-[#1a1a1a]"
}`}
>
{task.title}
</p>
{task.description && (
<p className="text-xs text-[#999999] 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-[#f9f9f9] rounded px-3 py-2 flex items-center justify-between gap-2"
>
<span className="text-xs text-[#666666] 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>
)}
</Card>
);
}
+54 -39
View File
@@ -5,6 +5,12 @@ import { Badge } from '@/components/ui/badge';
interface PaymentStatusProps { interface PaymentStatusProps {
accepted_total: string; accepted_total: string;
payments: ClientView['payments']; payments: ClientView['payments'];
/** Label for the total row. Defaults to "Totale Investimento" */
totalLabel?: string;
/** Override the displayed amount (retainer: show offer accepted_total, not project total) */
overrideAmount?: string;
/** When true, hide individual payment rows (Acconto/Saldo) — used for retainer offers */
hideRows?: boolean;
} }
type PaymentStatusValue = 'da_saldare' | 'inviata' | 'saldato'; type PaymentStatusValue = 'da_saldare' | 'inviata' | 'saldato';
@@ -30,8 +36,15 @@ const statusConfig: Record<
}, },
}; };
export function PaymentStatus({ accepted_total, payments }: PaymentStatusProps) { export function PaymentStatus({
const totalFormatted = parseFloat(accepted_total || '0').toLocaleString('it-IT', { accepted_total,
payments,
totalLabel = 'Totale Investimento',
overrideAmount,
hideRows = false,
}: PaymentStatusProps) {
const displayAmount = overrideAmount ?? accepted_total;
const totalFormatted = parseFloat(displayAmount || '0').toLocaleString('it-IT', {
style: 'currency', style: 'currency',
currency: 'EUR', currency: 'EUR',
minimumFractionDigits: 2, minimumFractionDigits: 2,
@@ -40,52 +53,54 @@ export function PaymentStatus({ accepted_total, payments }: PaymentStatusProps)
return ( return (
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none overflow-hidden"> <Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none overflow-hidden">
{/* Totale accettato — unico importo visibile al cliente (LOCKED) */} {/* Totale — unico importo visibile al cliente (LOCKED) */}
<div className="px-6 py-5 border-b border-[#e5e5e5] bg-[#f9f9f9]"> <div className="px-6 py-5 border-b border-[#e5e5e5] bg-[#f9f9f9]">
<p className="text-xs font-semibold text-[#666666] uppercase tracking-wider mb-1"> <p className="text-xs font-semibold text-[#666666] uppercase tracking-wider mb-1">
Totale Preventivo Accettato {totalLabel}
</p> </p>
<p className="text-3xl font-bold text-[#1a1a1a] tracking-tight"> <p className="text-3xl font-bold text-[#1a1a1a] tracking-tight">
{totalFormatted} {totalFormatted}
</p> </p>
</div> </div>
{/* Righe pagamento: solo etichetta + stato, MAI importo singolo */} {/* Righe pagamento: solo etichetta + stato, MAI importo singolo — omesse se hideRows */}
<div className="px-6 py-4 space-y-3"> {!hideRows && (
{payments.length === 0 ? ( <div className="px-6 py-4 space-y-3">
<p className="text-sm text-[#999999] italic py-2"> {payments.length === 0 ? (
Nessun piano di pagamento configurato. <p className="text-sm text-[#999999] italic py-2">
</p> Nessun piano di pagamento configurato.
) : ( </p>
payments.map((payment) => { ) : (
const status = payment.status as PaymentStatusValue; payments.map((payment) => {
const config = statusConfig[status] ?? statusConfig.da_saldare; const status = payment.status as PaymentStatusValue;
const config = statusConfig[status] ?? statusConfig.da_saldare;
return ( return (
<div <div
key={payment.id} key={payment.id}
className="flex items-center justify-between gap-4 py-2" className="flex items-center justify-between gap-4 py-2"
> >
{/* Indicatore dot + etichetta */} {/* Indicatore dot + etichetta */}
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<span <span
className={`w-2 h-2 rounded-full shrink-0 ${config.dotClass}`} className={`w-2 h-2 rounded-full shrink-0 ${config.dotClass}`}
aria-hidden="true" aria-hidden="true"
/> />
<p className="text-sm font-medium text-[#1a1a1a]"> <p className="text-sm font-medium text-[#1a1a1a]">
{payment.label} {payment.label}
</p> </p>
</div>
{/* Badge stato — nessun importo */}
<Badge className={`text-xs shrink-0 ${config.badgeClass}`}>
{config.label}
</Badge>
</div> </div>
);
{/* Badge stato — nessun importo */} })
<Badge className={`text-xs shrink-0 ${config.badgeClass}`}> )}
{config.label} </div>
</Badge> )}
</div>
);
})
)}
</div>
{/* Nota informativa */} {/* Nota informativa */}
<div className="px-6 py-3 border-t border-[#e5e5e5] bg-[#f9f9f9]"> <div className="px-6 py-3 border-t border-[#e5e5e5] bg-[#f9f9f9]">
@@ -95,4 +110,4 @@ export function PaymentStatus({ accepted_total, payments }: PaymentStatusProps)
</div> </div>
</Card> </Card>
); );
} }
+18 -111
View File
@@ -1,70 +1,11 @@
import type { ClientView } from '@/lib/client-view'; import type { ClientView } from '@/lib/client-view';
import { Progress } from '@/components/ui/progress'; import { PhaseCard } from './client/PhaseCard';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ApproveButton } from './client/ApproveButton';
interface PhaseTimelineProps { interface PhaseTimelineProps {
phases: ClientView['phases']; phases: ClientView['phases'];
token: string; token: string;
} }
function PhaseStatusIcon({ status }: { status: 'upcoming' | 'active' | 'done' }) {
if (status === 'done') {
return (
<svg className="w-5 h-5 text-[#16a34a]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
);
}
if (status === 'active') {
return (
<svg className="w-5 h-5 text-[#1A463C]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-5 h-5 text-[#999999]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<circle cx={12} cy={12} r={9} />
</svg>
);
}
function TaskStatusIcon({ status }: { status: 'todo' | 'in_progress' | 'done' }) {
if (status === 'done') {
return (
<svg className="w-4 h-4 text-[#16a34a] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
);
}
if (status === 'in_progress') {
return (
<svg className="w-4 h-4 text-[#DEF168] shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-4 h-4 text-[#999999] shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<circle cx={12} cy={12} r={9} />
</svg>
);
}
const phaseStatusLabel: Record<'upcoming' | 'active' | 'done', string> = {
upcoming: 'In arrivo',
active: 'In corso',
done: 'Completata',
};
const phaseStatusStyle: Record<'upcoming' | 'active' | 'done', string> = {
upcoming: 'border-transparent bg-[#999999] text-white',
active: 'border-transparent bg-[#1A463C] text-white',
done: 'border-transparent bg-[#16a34a] text-white',
};
export function PhaseTimeline({ phases, token }: PhaseTimelineProps) { export function PhaseTimeline({ phases, token }: PhaseTimelineProps) {
if (phases.length === 0) { if (phases.length === 0) {
return <p className="text-sm text-[#999999] italic">Nessuna fase ancora configurata.</p>; return <p className="text-sm text-[#999999] italic">Nessuna fase ancora configurata.</p>;
@@ -73,71 +14,37 @@ export function PhaseTimeline({ phases, token }: PhaseTimelineProps) {
return ( return (
<div className="space-y-0"> <div className="space-y-0">
{phases.map((phase, index) => { {phases.map((phase, index) => {
const doneCount = phase.tasks.filter((t) => t.status === 'done').length;
const isLast = index === phases.length - 1; const isLast = index === phases.length - 1;
// done phases start collapsed; active and upcoming start expanded
const defaultOpen = phase.status !== 'done';
return ( return (
<div key={phase.id} className="flex gap-5"> <div key={phase.id} className="flex gap-5">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="w-10 h-10 rounded-full bg-white border-2 border-[#e5e5e5] flex items-center justify-center shrink-0 z-10"> <div className="w-10 h-10 rounded-full bg-white border-2 border-[#e5e5e5] flex items-center justify-center shrink-0 z-10">
<PhaseStatusIcon status={phase.status} /> {phase.status === 'done' ? (
<svg className="w-5 h-5 text-[#1A463C]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clipRule="evenodd" />
</svg>
) : phase.status === 'active' ? (
<svg className="w-5 h-5 text-[#3b82f6]" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-5 h-5 text-[#999999]" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24" aria-hidden="true">
<circle cx={12} cy={12} r={9} />
</svg>
)}
</div> </div>
{!isLast && <div className="flex-1 w-px bg-[#e5e5e5] my-2" style={{ minHeight: '2rem' }} />} {!isLast && <div className="flex-1 w-px bg-[#e5e5e5] my-2" style={{ minHeight: '2rem' }} />}
</div> </div>
<div className={`flex-1 ${isLast ? 'pb-0' : 'pb-6'}`}> <div className={`flex-1 ${isLast ? 'pb-0' : 'pb-6'}`}>
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-5"> <PhaseCard phase={phase} token={token} defaultOpen={defaultOpen} />
<div className="flex items-start justify-between gap-3 mb-4">
<h3 className="text-base font-bold text-[#1a1a1a] leading-snug">{phase.title}</h3>
<Badge className={`text-xs shrink-0 ${phaseStatusStyle[phase.status]}`}>
{phaseStatusLabel[phase.status]}
</Badge>
</div>
<div className="mb-5">
<div className="flex justify-between items-center mb-1.5">
<p className="text-xs text-[#666666] font-medium">{doneCount} di {phase.tasks.length} task</p>
<p className="text-xs font-semibold text-[#1a1a1a]">{phase.progress_pct}%</p>
</div>
<Progress value={phase.progress_pct} className="h-1.5" />
</div>
{phase.tasks.length === 0 ? (
<p className="text-xs text-[#999999] italic">Nessun task ancora configurato.</p>
) : (
<ul className="space-y-3">
{phase.tasks.map((task) => (
<li key={task.id} className="flex items-start gap-2.5">
<TaskStatusIcon status={task.status} />
<div className="flex-1 min-w-0">
<p className={`text-sm leading-snug ${task.status === 'done' ? 'line-through text-[#999999]' : 'text-[#1a1a1a]'}`}>
{task.title}
</p>
{task.description && (
<p className="text-xs text-[#999999] 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-[#f9f9f9] rounded px-3 py-2 flex items-center justify-between gap-2">
<span className="text-xs text-[#666666] 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>
)}
</Card>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
); );
} }
@@ -87,7 +87,7 @@ export function ProposalDeck({ proposal, onAccept, onReject }: Props) {
{ id: "timeline", component: <TimelineSection timeline={ai.timeline} /> }, { id: "timeline", component: <TimelineSection timeline={ai.timeline} /> },
// Cap.05 — Le opzioni // Cap.05 — Le opzioni
{ id: "ch05", component: <ChapterDivider number="05" title="Le opzioni." subtitle="Il valore di mercato delle attività, le tre opzioni progressive con il loro pricing, e il processo per partire." /> }, { id: "ch05", component: <ChapterDivider number="05" title="Le opzioni." subtitle="Il valore di mercato delle attività, le tre opzioni progressive con il loro pricing, e il processo per partire." /> },
{ id: "pricing", component: <PricingSection tiers={offer.tiers} /> }, { id: "pricing", component: <PricingSection tiers={offer.tiers} offerType={offer.offerType} /> },
// Stages recap + comparison // Stages recap + comparison
{ id: "stages", component: <StagesRecapSection stages={ai.stagesRecap.stages} /> }, { id: "stages", component: <StagesRecapSection stages={ai.stagesRecap.stages} /> },
{ id: "comparison", component: <ComparisonMatrixSection matrix={ai.comparisonMatrix} tiers={offer.tiers} /> }, { id: "comparison", component: <ComparisonMatrixSection matrix={ai.comparisonMatrix} tiers={offer.tiers} /> },
@@ -1,6 +1,9 @@
import type { AssembledProposal } from "@/lib/proposal/assemble"; import type { AssembledProposal } from "@/lib/proposal/assemble";
type Props = { tiers: AssembledProposal["offer"]["tiers"] }; type Props = {
tiers: AssembledProposal["offer"]["tiers"];
offerType?: string; // una_tantum | retainer — undefined for proposals generated before this field
};
function formatEur(val: string | null | undefined): string { function formatEur(val: string | null | undefined): string {
if (!val) return "—"; if (!val) return "—";
@@ -8,7 +11,8 @@ function formatEur(val: string | null | undefined): string {
return `${n.toLocaleString("it-IT")}`; return `${n.toLocaleString("it-IT")}`;
} }
export function PricingSection({ tiers }: Props) { export function PricingSection({ tiers, offerType }: Props) {
const isRetainer = offerType === "retainer";
return ( return (
<div className="h-full flex flex-col justify-center px-16 py-24 space-y-10"> <div className="h-full flex flex-col justify-center px-16 py-24 space-y-10">
<div> <div>
@@ -23,6 +27,15 @@ export function PricingSection({ tiers }: Props) {
const isHighlighted = i === 1; // tier B highlighted di default const isHighlighted = i === 1; // tier B highlighted di default
const price = tier.publicPrice ?? tier.servicesTotal; const price = tier.publicPrice ?? tier.servicesTotal;
const durationLabel = `${tier.durationMonths} ${tier.durationMonths === 1 ? "mese" : "mesi"}`; const durationLabel = `${tier.durationMonths} ${tier.durationMonths === 1 ? "mese" : "mesi"}`;
// Prefer the offer type label; fall back to duration for legacy proposals.
const eyebrowMeta = offerType ? (isRetainer ? "Ricorrente" : "Una tantum") : durationLabel;
const priceNote = offerType
? isRetainer
? "Ricorrente · rinnovo 12 mesi"
: "pagamento unico"
: tier.durationMonths > 1
? `${tier.durationMonths} mesi`
: "pagamento unico";
return ( return (
<div <div
@@ -36,7 +49,7 @@ export function PricingSection({ tiers }: Props) {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<p className="text-xs font-mono tracking-widest text-muted-foreground uppercase"> <p className="text-xs font-mono tracking-widest text-muted-foreground uppercase">
Opzione {tier.tierLetter} / {durationLabel} Opzione {tier.tierLetter} / {eyebrowMeta}
</p> </p>
<h3 className="text-xl font-medium text-foreground mt-1">{tier.publicName}</h3> <h3 className="text-xl font-medium text-foreground mt-1">{tier.publicName}</h3>
</div> </div>
@@ -47,9 +60,7 @@ export function PricingSection({ tiers }: Props) {
<div className="space-y-1"> <div className="space-y-1">
<p className="text-3xl font-light text-primary">{formatEur(price)}</p> <p className="text-3xl font-light text-primary">{formatEur(price)}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">{priceNote}</p>
{tier.durationMonths > 1 ? `${tier.durationMonths} mesi` : "pagamento unico"}
</p>
</div> </div>
{/* Servizi inclusi */} {/* Servizi inclusi */}
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
type Transcript = {
id: string;
title: string | null;
call_date: string;
content: string;
created_at: string | Date;
};
function TranscriptCard({ 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 (
<Card className="rounded-lg border border-[#e5e5e5] bg-white shadow-none p-0 overflow-hidden">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-[#f9f9f9] transition-colors group"
aria-expanded={expanded}
>
<div className="flex items-center gap-3 min-w-0">
{/* Icona microfono */}
<svg
className="w-4 h-4 text-[#999999] shrink-0"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
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 className="min-w-0">
<p className="text-sm font-medium text-[#1a1a1a] truncate group-hover:text-[#1A463C] transition-colors">
{transcript.title ?? "Transcript chiamata"}
</p>
<p className="text-xs text-[#999999] mt-0.5">{date}</p>
</div>
</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-5 pb-5 pt-1 border-t border-[#e5e5e5]">
<pre className="text-xs text-[#444444] whitespace-pre-wrap font-sans leading-relaxed max-h-80 overflow-y-auto">
{transcript.content}
</pre>
</div>
)}
</Card>
);
}
interface TranscriptsSectionProps {
transcripts: Transcript[];
}
export function TranscriptsSection({ transcripts }: TranscriptsSectionProps) {
if (transcripts.length === 0) {
return (
<p className="text-sm text-[#999999] italic">
Nessun transcript disponibile.
</p>
);
}
return (
<div className="space-y-2">
{transcripts.map((t) => (
<TranscriptCard key={t.id} transcript={t} />
))}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Search-icon input, extracted from the pattern originally inlined in
* LeadsViewToggle. Token-based so it themes correctly in dark mode.
*/
export function SearchInput({
className,
containerClassName,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & { containerClassName?: string }) {
return (
<div className={cn("relative", containerClassName)}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<input
type="text"
className={cn(
"w-full h-9 pl-9 pr-3 rounded-lg border border-border bg-card text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary transition-all duration-200",
className
)}
{...props}
/>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
export type SegmentedToggleOption<T extends string> = {
value: T;
label: string;
};
/**
* Generic segmented control (e.g. Lista/Kanban). Active option gets
* bg-card + shadow-sm to read as a "raised" tab against the bg-muted track.
*/
export function SegmentedToggle<T extends string>({
options,
value,
onChange,
className,
}: {
options: SegmentedToggleOption<T>[];
value: T;
onChange: (value: T) => void;
className?: string;
}) {
return (
<div className={cn("flex items-center gap-1 bg-muted rounded-lg p-1 w-fit", className)}>
{options.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
onClick={() => onChange(option.value)}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-semibold transition-all duration-200",
active
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{option.label}
</button>
);
})}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { cn } from "@/lib/utils";
/**
* Lead pipeline stages. Kept in sync with LEAD_STAGES
* (src/lib/lead-validators.ts) all 6 stages stay, per the CRM Kanban.
*/
export type LeadStage =
| "contacted"
| "qualified"
| "proposal_sent"
| "negotiating"
| "won"
| "lost";
const STAGE_STYLES: Record<LeadStage, string> = {
contacted:
"bg-blue-50 text-blue-600 border-blue-100 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-900",
qualified:
"bg-purple-50 text-purple-600 border-purple-100 dark:bg-purple-950/40 dark:text-purple-300 dark:border-purple-900",
proposal_sent:
"bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900",
negotiating:
"bg-orange-50 text-orange-600 border-orange-100 dark:bg-orange-950/40 dark:text-orange-300 dark:border-orange-900",
won: "bg-emerald-50 text-emerald-600 border-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900",
lost: "bg-red-50 text-red-600 border-red-100 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900",
};
/**
* Rounded-full status pill for lead stages. Replaces the old scattered
* STAGE_COLOR maps in LeadTable/LeadsKanbanBoard one source of truth for
* stage color, with explicit dark: variants for legibility.
*/
export function StatusBadge({
status,
className,
}: {
status: string;
className?: string;
}) {
const styles = STAGE_STYLES[status as LeadStage] ?? "bg-muted text-muted-foreground border-border";
return (
<span
className={cn(
"inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide uppercase border whitespace-nowrap",
styles,
className
)}
>
{status.replace(/_/g, " ")}
</span>
);
}
+1 -1
View File
@@ -110,7 +110,7 @@ export function EditableCell({
"px-2 py-1 rounded transition-colors duration-150 text-sm", "px-2 py-1 rounded transition-colors duration-150 text-sm",
disabled disabled
? "cursor-not-allowed opacity-50" ? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:bg-[#f0f0f0]", : "cursor-pointer hover:bg-muted",
type === "textarea" && "line-clamp-2 max-w-md" type === "textarea" && "line-clamp-2 max-w-md"
)} )}
> >
+10 -10
View File
@@ -81,11 +81,11 @@ export function OptionMultiSelect({
onClick={() => !disabled && setIsOpen((v) => !v)} onClick={() => !disabled && setIsOpen((v) => !v)}
className={cn( className={cn(
"flex flex-wrap gap-1 items-center px-2 py-1 rounded transition-colors duration-150 min-h-[28px]", "flex flex-wrap gap-1 items-center px-2 py-1 rounded transition-colors duration-150 min-h-[28px]",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-[#f0f0f0]" disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-muted"
)} )}
> >
{values.length === 0 ? ( {values.length === 0 ? (
<span className="text-xs text-[#71717a]"></span> <span className="text-xs text-muted-foreground"></span>
) : ( ) : (
values.map((v) => ( values.map((v) => (
<Badge <Badge
@@ -111,11 +111,11 @@ export function OptionMultiSelect({
</Badge> </Badge>
)) ))
)} )}
<Plus className="h-3.5 w-3.5 text-[#71717a]" /> <Plus className="h-3.5 w-3.5 text-muted-foreground" />
</div> </div>
{isOpen && ( {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-[220px] max-h-[280px] overflow-y-auto"> <div className="absolute top-full left-0 mt-1 bg-popover border border-border rounded-md shadow-md p-1.5 z-20 min-w-[220px] max-h-[280px] overflow-y-auto">
<Input <Input
ref={inputRef} ref={inputRef}
type="text" type="text"
@@ -160,7 +160,7 @@ export function OptionMultiSelect({
) : ( ) : (
<div <div
key={opt} key={opt}
className="flex items-center justify-between gap-1 rounded px-1.5 py-1 hover:bg-[#f5f5f5] group" className="flex items-center justify-between gap-1 rounded px-1.5 py-1 hover:bg-muted group"
> >
<button <button
type="button" type="button"
@@ -168,7 +168,7 @@ export function OptionMultiSelect({
className="flex items-center gap-2 flex-1 min-w-0 text-left" className="flex items-center gap-2 flex-1 min-w-0 text-left"
> >
<span className="w-3.5 flex-shrink-0"> <span className="w-3.5 flex-shrink-0">
{selected.has(opt) && <Check className="h-3.5 w-3.5 text-[#1A463C]" />} {selected.has(opt) && <Check className="h-3.5 w-3.5 text-primary" />}
</span> </span>
<Badge <Badge
variant="outline" variant="outline"
@@ -187,7 +187,7 @@ export function OptionMultiSelect({
setRenameValue(opt); setRenameValue(opt);
setRenaming(opt); setRenaming(opt);
}} }}
className="opacity-0 group-hover:opacity-100 text-[#71717a] hover:text-[#1a1a1a] p-0.5" className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-0.5"
aria-label={`Rinomina ${opt}`} aria-label={`Rinomina ${opt}`}
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
@@ -201,15 +201,15 @@ export function OptionMultiSelect({
<button <button
type="button" type="button"
onClick={createFromQuery} onClick={createFromQuery}
className="flex items-center gap-2 rounded px-1.5 py-1.5 hover:bg-[#f5f5f5] text-left text-sm" className="flex items-center gap-2 rounded px-1.5 py-1.5 hover:bg-muted text-left text-sm"
> >
<Plus className="h-3.5 w-3.5 text-[#1A463C]" /> <Plus className="h-3.5 w-3.5 text-primary" />
Crea <span className="font-medium">«{query.trim()}»</span> Crea <span className="font-medium">«{query.trim()}»</span>
</button> </button>
)} )}
{filtered.length === 0 && !q && ( {filtered.length === 0 && !q && (
<p className="text-xs text-[#71717a] px-1.5 py-1">Nessuna opzione ancora</p> <p className="text-xs text-muted-foreground px-1.5 py-1">Nessuna opzione ancora</p>
)} )}
</div> </div>
</div> </div>
@@ -0,0 +1,8 @@
-- Additive: client contact fields (email/phone) + lead archive flag.
-- Reuse: lead.email/phone carry over to the client on conversion; email also
-- feeds the v2.3 Email & Accesso (OTP) milestone. leads.archived hides a
-- converted lead from the pipeline while keeping its "won" status.
-- No drops/truncates. Safe to re-run.
ALTER TABLE clients ADD COLUMN IF NOT EXISTS email text;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS phone text;
ALTER TABLE leads ADD COLUMN IF NOT EXISTS archived boolean NOT NULL DEFAULT false;
@@ -0,0 +1,16 @@
-- Additive: offer_type on offer_macros (una_tantum | retainer) + dedup guard.
-- The UNIQUE constraint must be added AFTER the one-time dedup of duplicate
-- offer_micros (same macro_id + tier_letter), otherwise it fails on existing dupes.
-- NULLS NOT DISTINCT treats two NULL tier_letter rows under the same macro as a
-- conflict too; if that is undesired for legacy non-tier micros, switch to the
-- partial-index variant below. No drops/truncates.
ALTER TABLE offer_macros ADD COLUMN IF NOT EXISTS offer_type text NOT NULL DEFAULT 'una_tantum';
-- Run ONLY after dedup. Prevents future duplicate tiers within an offer.
ALTER TABLE offer_micros
ADD CONSTRAINT offer_micros_macro_tier_unique UNIQUE NULLS NOT DISTINCT (macro_id, tier_letter);
-- Alternative (allows multiple NULL-tier micros per macro):
-- CREATE UNIQUE INDEX offer_micros_macro_tier_unique
-- ON offer_micros (macro_id, tier_letter) WHERE tier_letter IS NOT NULL;
@@ -0,0 +1,6 @@
-- Additive: add percent column to payments for rescaling by payment plan.
-- nullable — existing rows keep percent NULL (legacy fallback in updateAcceptedTotal).
-- Apply to prod via SSH tunnel BEFORE pushing schema-dependent code.
-- No drops, no truncates, no data loss.
ALTER TABLE payments ADD COLUMN IF NOT EXISTS percent numeric(5,2);
+9
View File
@@ -21,6 +21,10 @@ export const clients = pgTable("clients", {
name: text("name").notNull(), name: text("name").notNull(),
brand_name: text("brand_name").notNull(), brand_name: text("brand_name").notNull(),
brief: text("brief").notNull(), brief: text("brief").notNull(),
// Contact fields (Phase v2.3) — carried over from a converted lead and used
// by the Email & Accesso (OTP) milestone. Admin-only, never exposed publicly.
email: text("email"),
phone: text("phone"),
// token is SEPARATE from id — rotatable secret for client link access // token is SEPARATE from id — rotatable secret for client link access
token: text("token") token: text("token")
.notNull() .notNull()
@@ -150,6 +154,7 @@ export const payments = pgTable("payments", {
.references(() => projects.id, { onDelete: "cascade" }), .references(() => projects.id, { onDelete: "cascade" }),
label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%" label: text("label").notNull(), // "Acconto 50%" | "Saldo 50%"
amount: numeric("amount", { precision: 10, scale: 2 }).notNull(), amount: numeric("amount", { precision: 10, scale: 2 }).notNull(),
percent: numeric("percent", { precision: 5, scale: 2 }), // nullable — % of total (for rescaling); null = legacy row
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
paid_at: timestamp("paid_at", { withTimezone: true }), paid_at: timestamp("paid_at", { withTimezone: true }),
}); });
@@ -275,6 +280,7 @@ export const offer_macros = pgTable("offer_macros", {
description: text("description"), // short description shown on offer cards description: text("description"), // short description shown on offer cards
category: text("category"), // single-select "Categoria" (Entry/Signature/Retainer Offer) — OFFER-15/18 category: text("category"), // single-select "Categoria" (Entry/Signature/Retainer Offer) — OFFER-15/18
ticket: text("ticket"), // single-select "Ticket" (Low/Mid/High Ticket) — OFFER-15 ticket: text("ticket"), // single-select "Ticket" (Low/Mid/High Ticket) — OFFER-15
offer_type: text("offer_type").notNull().default("una_tantum"), // una_tantum | retainer (v2.3)
is_archived: boolean("is_archived").notNull().default(false), // OFFER-18 archive flag is_archived: boolean("is_archived").notNull().default(false), // OFFER-18 archive flag
cliente_ideale: text("cliente_ideale"), // "Aiuto: [Cliente Ideale]" cliente_ideale: text("cliente_ideale"), // "Aiuto: [Cliente Ideale]"
risultato: text("risultato"), // "A ottenere: [Risultato]" risultato: text("risultato"), // "A ottenere: [Risultato]"
@@ -454,6 +460,9 @@ export const leads = pgTable("leads", {
next_action: text("next_action"), next_action: text("next_action"),
next_action_date: timestamp("next_action_date", { withTimezone: true }), next_action_date: timestamp("next_action_date", { withTimezone: true }),
notes: text("notes"), notes: text("notes"),
// Archive flag (v2.3) — a converted lead is hidden from the pipeline while
// keeping its "won" status, so the board doesn't fill up over time.
archived: boolean("archived").notNull().default(false),
created_at: timestamp("created_at", { withTimezone: true }) created_at: timestamp("created_at", { withTimezone: true })
.notNull() .notNull()
.defaultNow(), .defaultNow(),
+205 -32
View File
@@ -22,8 +22,10 @@ import {
quotes, quotes,
leads, leads,
tags, tags,
clientTranscripts,
} from "@/db/schema"; } from "@/db/schema";
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm"; import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
import { getPool } from "@/lib/taxonomy";
import { LEAD_STAGES } from "@/lib/lead-validators"; import { LEAD_STAGES } from "@/lib/lead-validators";
import type { import type {
Client, Client,
@@ -116,7 +118,7 @@ export async function getAllClientsWithPayments(
})); }));
} }
const [allPayments, activeEntries, totals] = await Promise.all([ const [allPayments, activeEntries, totals, offerTotals] = await Promise.all([
db.select().from(payments).where(inArray(payments.project_id, projectIds)), db.select().from(payments).where(inArray(payments.project_id, projectIds)),
db db
@@ -136,8 +138,23 @@ export async function getAllClientsWithPayments(
.from(time_entries) .from(time_entries)
.where(inArray(time_entries.project_id, projectIds)) .where(inArray(time_entries.project_id, projectIds))
.groupBy(time_entries.project_id), .groupBy(time_entries.project_id),
db
.select({
project_id: project_offers.project_id,
total: sql<string>`coalesce(sum(${project_offers.accepted_total}), 0)`,
})
.from(project_offers)
.where(inArray(project_offers.project_id, projectIds))
.groupBy(project_offers.project_id),
]); ]);
// Somma offerte accettate per progetto (per arricchire l'LTV)
const projectOffersTotalMap = new Map<string, number>();
for (const row of offerTotals) {
projectOffersTotalMap.set(row.project_id, parseFloat(row.total ?? "0") || 0);
}
// Aggregate project-scoped data back to client level // Aggregate project-scoped data back to client level
const clientPaymentsMap = new Map<string, typeof allPayments>(); const clientPaymentsMap = new Map<string, typeof allPayments>();
for (const payment of allPayments) { for (const payment of allPayments) {
@@ -168,8 +185,14 @@ export async function getAllClientsWithPayments(
const projectBrands = clientProjectsList const projectBrands = clientProjectsList
.filter((p) => !p.archived) .filter((p) => !p.archived)
.map((p) => p.name); .map((p) => p.name);
// LTV per progetto = max(totale preventivo impostato, somma offerte assegnate).
// Copre il caso "preventivo non impostato + offerta attiva" senza doppi conteggi.
const ltv = clientProjectsList const ltv = clientProjectsList
.reduce((sum, p) => sum + parseFloat(p.accepted_total ?? "0"), 0) .reduce((sum, p) => {
const acceptedTotal = parseFloat(p.accepted_total ?? "0") || 0;
const offersTotal = projectOffersTotalMap.get(p.id) ?? 0;
return sum + Math.max(acceptedTotal, offersTotal);
}, 0)
.toFixed(2); .toFixed(2);
return { return {
id: c.id, id: c.id,
@@ -209,6 +232,9 @@ export type ProjectOfferWithMicro = {
micro_internal_name: string; micro_internal_name: string;
micro_public_name: string; micro_public_name: string;
micro_duration_months: number; micro_duration_months: number;
tier_letter: string | null;
macro_internal_name: string;
macro_offer_type: string;
start_date: Date; start_date: Date;
accepted_total: string | null; accepted_total: string | null;
created_at: Date; created_at: Date;
@@ -420,32 +446,21 @@ export type CatalogFieldOptions = {
}; };
export async function getCatalogFieldOptions(): Promise<CatalogFieldOptions> { export async function getCatalogFieldOptions(): Promise<CatalogFieldOptions> {
const tagRows = await db // fase / Offerta (tag) / Pacchetto read from the centralized, persistent
.selectDistinct({ name: tags.name, type: tags.entity_type }) // taxonomy pools (src/lib/taxonomy.ts). `categoria` (services.category) is
.from(tags) // hidden in the catalog UI and stays derived from in-use values.
.where(inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY])); const [tag, pacchetto, fase, catRows] = await Promise.all([
getPool("service_offerta"),
getPool("service_pacchetto"),
getPool("service_fase"),
db.selectDistinct({ value: services.category }).from(services),
]);
// Distinct category / fase values (single-select pools); nulls filtered below. const categoria = Array.from(
const catRows = await db new Set(catRows.map((r) => r.value).filter((v): v is string => !!v && v.trim().length > 0))
.selectDistinct({ value: services.category }) ).sort((a, b) => a.localeCompare(b, "it"));
.from(services);
const faseRows = await db
.selectDistinct({ value: services.fase })
.from(services);
const sortUnique = (arr: (string | null)[]) => return { tag, pacchetto, categoria, fase };
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
(a, b) => a.localeCompare(b, "it")
);
return {
tag: sortUnique(tagRows.filter((r) => r.type === TAG_ENTITY).map((r) => r.name)),
pacchetto: sortUnique(
tagRows.filter((r) => r.type === PACCHETTO_ENTITY).map((r) => r.name)
),
categoria: sortUnique(catRows.map((r) => r.value)),
fase: sortUnique(faseRows.map((r) => r.value)),
};
} }
// ── ProjectWithPayments — used by /admin/projects list ─────────────────────── // ── ProjectWithPayments — used by /admin/projects list ───────────────────────
@@ -558,7 +573,27 @@ export type ProjectFullDetail = {
activeTimerStartedAt: Date | null; activeTimerStartedAt: Date | null;
totalTrackedSeconds: number; totalTrackedSeconds: number;
projectOffers: ProjectOfferWithMicro[]; projectOffers: ProjectOfferWithMicro[];
availableMicros: Array<{ id: string; internal_name: string; public_name: string; duration_months: number }>; /** Sum of accepted_total across all active project offers — used as default for payment plan */
offersAcceptedTotal: number;
availableMicros: Array<{
id: string;
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;
}>;
transcripts: Array<{
id: string;
title: string | null;
call_date: string;
content: string;
created_at: Date;
}>;
}; };
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> { export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
@@ -605,7 +640,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.from(deliverables) .from(deliverables)
.where(inArray(deliverables.task_id, taskIds)); .where(inArray(deliverables.task_id, taskIds));
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows] = const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows, transcriptsRows] =
await Promise.all([ await Promise.all([
db.select().from(payments).where(eq(payments.project_id, id)), db.select().from(payments).where(eq(payments.project_id, id)),
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)), db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
@@ -634,7 +669,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
.select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` }) .select({ total: sql<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
.from(time_entries) .from(time_entries)
.where(eq(time_entries.project_id, id)), .where(eq(time_entries.project_id, id)),
// Query A: project offers for this project joined with micro info // Query A: project offers for this project joined with micro + macro info
db db
.select({ .select({
id: project_offers.id, id: project_offers.id,
@@ -643,24 +678,54 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
micro_internal_name: offer_micros.internal_name, micro_internal_name: offer_micros.internal_name,
micro_public_name: offer_micros.public_name, micro_public_name: offer_micros.public_name,
micro_duration_months: offer_micros.duration_months, micro_duration_months: offer_micros.duration_months,
tier_letter: offer_micros.tier_letter,
macro_internal_name: offer_macros.internal_name,
macro_offer_type: offer_macros.offer_type,
start_date: project_offers.start_date, start_date: project_offers.start_date,
accepted_total: project_offers.accepted_total, accepted_total: project_offers.accepted_total,
created_at: project_offers.created_at, created_at: project_offers.created_at,
}) })
.from(project_offers) .from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)) .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(eq(project_offers.project_id, id)) .where(eq(project_offers.project_id, id))
.orderBy(asc(project_offers.created_at)), .orderBy(asc(project_offers.created_at)),
// Query B: all active micro-offers (for the assignment dropdown) // Query B: all tiers of non-archived offers (for the assignment dropdown).
// ordered oldest-first per (macro, tier) so client-side dedup keeps the
// oldest until the DB cleanup removes genuine duplicates.
db db
.select({ .select({
id: offer_micros.id, id: offer_micros.id,
internal_name: offer_micros.internal_name, internal_name: offer_micros.internal_name,
public_name: offer_micros.public_name, public_name: offer_micros.public_name,
duration_months: offer_micros.duration_months, duration_months: offer_micros.duration_months,
tier_letter: offer_micros.tier_letter,
public_price: offer_micros.public_price,
macro_id: offer_micros.macro_id,
macro_internal_name: offer_macros.internal_name,
macro_category: offer_macros.category,
macro_offer_type: offer_macros.offer_type,
}) })
.from(offer_micros) .from(offer_micros)
.orderBy(asc(offer_micros.internal_name)), .innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(eq(offer_macros.is_archived, false))
.orderBy(
asc(offer_macros.internal_name),
asc(offer_micros.tier_letter),
asc(offer_micros.id)
),
// Query C: transcripts linked to the project's client
db
.select({
id: clientTranscripts.id,
title: clientTranscripts.title,
call_date: clientTranscripts.call_date,
content: clientTranscripts.content,
created_at: clientTranscripts.created_at,
})
.from(clientTranscripts)
.where(eq(clientTranscripts.client_id, project.client_id))
.orderBy(desc(clientTranscripts.call_date)),
]); ]);
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)]; const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
@@ -683,6 +748,21 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
})), })),
})); }));
// Defensive dedup: until the DB cleanup removes genuine duplicate tiers,
// keep one micro per (macro_id, tier_letter) for the assignment dropdown.
const seenTier = new Set<string>();
const dedupedMicros = availableMicrosRows.filter((m) => {
const key = `${m.macro_id}::${m.tier_letter ?? `_null_${m.id}`}`;
if (seenTier.has(key)) return false;
seenTier.add(key);
return true;
});
const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce(
(sum, o) => sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0),
0
);
return { return {
project: { ...project, client } as ProjectFullDetail["project"], project: { ...project, client } as ProjectFullDetail["project"],
phases: phasesWithTasks, phases: phasesWithTasks,
@@ -696,10 +776,40 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null, activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null,
totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0, totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0,
projectOffers: projectOffersRows as ProjectOfferWithMicro[], projectOffers: projectOffersRows as ProjectOfferWithMicro[],
availableMicros: availableMicrosRows, offersAcceptedTotal,
availableMicros: dedupedMicros,
transcripts: transcriptsRows,
}; };
} }
// ── Recent time entries — used by TimerTab for the manual-entry recent list ───
export type RecentTimeEntry = {
id: string;
started_at: Date;
ended_at: Date | null;
duration_seconds: number | null;
};
export async function getRecentTimeEntries(
projectId: string,
limit = 5
): Promise<RecentTimeEntry[]> {
// sql<boolean>`ended_at IS NOT NULL` used to filter closed entries
// (Drizzle's isNotNull is available in v0.28+ but we use sql for safety)
return db
.select({
id: time_entries.id,
started_at: time_entries.started_at,
ended_at: time_entries.ended_at,
duration_seconds: time_entries.duration_seconds,
})
.from(time_entries)
.where(and(eq(time_entries.project_id, projectId), sql`${time_entries.ended_at} IS NOT NULL`))
.orderBy(desc(time_entries.started_at))
.limit(limit);
}
// ── ClientWithProjects — used by client detail showing project cards ────────── // ── ClientWithProjects — used by client detail showing project cards ──────────
export type ClientWithProjects = Client & { export type ClientWithProjects = Client & {
@@ -747,6 +857,10 @@ export type ClientActiveOfferSummary = {
project_id: string; project_id: string;
project_name: string; project_name: string;
public_name: string; // offer_micros.public_name — NEVER internal_name public_name: string; // offer_micros.public_name — NEVER internal_name
macro_name: string; // offer_macros.internal_name — admin-only offer name ("Mantenimento")
tier_letter: string | null; // A | B | C
category: string | null; // Entry | Signature | Retainer (offer_macros.category)
offer_type: string; // una_tantum | retainer (fallback for category)
accepted_total: string | null; accepted_total: string | null;
}; };
@@ -767,10 +881,15 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
offer_id: project_offers.id, offer_id: project_offers.id,
project_id: project_offers.project_id, project_id: project_offers.project_id,
public_name: offer_micros.public_name, public_name: offer_micros.public_name,
macro_name: offer_macros.internal_name,
tier_letter: offer_micros.tier_letter,
category: offer_macros.category,
offer_type: offer_macros.offer_type,
accepted_total: project_offers.accepted_total, accepted_total: project_offers.accepted_total,
}) })
.from(project_offers) .from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)) .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(inArray(project_offers.project_id, projectIds)) .where(inArray(project_offers.project_id, projectIds))
.orderBy(asc(project_offers.created_at)); .orderBy(asc(project_offers.created_at));
@@ -779,6 +898,10 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
project_id: r.project_id, project_id: r.project_id,
project_name: projectNameMap.get(r.project_id) ?? "—", project_name: projectNameMap.get(r.project_id) ?? "—",
public_name: r.public_name, public_name: r.public_name,
macro_name: r.macro_name,
tier_letter: r.tier_letter,
category: r.category,
offer_type: r.offer_type,
accepted_total: r.accepted_total ? String(r.accepted_total) : null, accepted_total: r.accepted_total ? String(r.accepted_total) : null,
})); }));
} }
@@ -902,6 +1025,7 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
next_action: leads.next_action, next_action: leads.next_action,
next_action_date: leads.next_action_date, next_action_date: leads.next_action_date,
notes: leads.notes, notes: leads.notes,
archived: leads.archived,
created_at: leads.created_at, created_at: leads.created_at,
updated_at: leads.updated_at, updated_at: leads.updated_at,
tag_name: tags.name, tag_name: tags.name,
@@ -914,6 +1038,7 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
eq(tags.entity_type, LEADS_TAG_ENTITY) eq(tags.entity_type, LEADS_TAG_ENTITY)
) )
) )
.where(eq(leads.archived, false)) // converted leads are archived → hidden from pipeline
.orderBy(desc(leads.updated_at), asc(tags.name)); .orderBy(desc(leads.updated_at), asc(tags.name));
const leadMap = new Map<string, LeadWithTags>(); const leadMap = new Map<string, LeadWithTags>();
@@ -925,6 +1050,54 @@ export async function getLeadsWithTags(): Promise<LeadWithTags[]> {
return Array.from(leadMap.values()); return Array.from(leadMap.values());
} }
// Single lead by id WITH tags — does NOT filter archived (the detail page must
// still show a converted/archived lead).
export async function getLeadByIdWithTags(leadId: string): Promise<LeadWithTags | null> {
const rows = await db
.select({
id: leads.id,
name: leads.name,
email: leads.email,
phone: leads.phone,
company: leads.company,
status: leads.status,
last_contact_date: leads.last_contact_date,
next_action: leads.next_action,
next_action_date: leads.next_action_date,
notes: leads.notes,
archived: leads.archived,
created_at: leads.created_at,
updated_at: leads.updated_at,
tag_name: tags.name,
})
.from(leads)
.leftJoin(
tags,
and(eq(tags.entity_id, leads.id), eq(tags.entity_type, LEADS_TAG_ENTITY))
)
.where(eq(leads.id, leadId));
if (rows.length === 0) return null;
let result: LeadWithTags | null = null;
for (const row of rows) {
const { tag_name, ...leadFields } = row;
if (!result) result = { ...leadFields, tags: [] };
if (tag_name) result.tags.push(tag_name);
}
return result;
}
// If this lead was converted, returns the client id of the project created from
// it (projects.created_from_lead_id), else null.
export async function getClientIdFromLead(leadId: string): Promise<string | null> {
const [row] = await db
.select({ client_id: projects.client_id })
.from(projects)
.where(eq(projects.created_from_lead_id, leadId))
.limit(1);
return row?.client_id ?? null;
}
export type LeadFieldOptions = { status: string[]; tags: string[] }; export type LeadFieldOptions = { status: string[]; tags: string[] };
export async function getLeadFieldOptions(): Promise<LeadFieldOptions> { export async function getLeadFieldOptions(): Promise<LeadFieldOptions> {
+117 -18
View File
@@ -1,6 +1,6 @@
import { eq, inArray, asc, sql } from "drizzle-orm"; import { eq, inArray, asc, desc, sql } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services } from "@/db/schema"; import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
/** /**
* ClientView: Legacy shape used by ClientDashboard component. * ClientView: Legacy shape used by ClientDashboard component.
@@ -55,8 +55,18 @@ export interface ClientView {
activeOffers?: Array<{ activeOffers?: Array<{
id: string; id: string;
public_name: string; public_name: string;
offer_name: string; // offer_macros.public_name — macro-level name shown to client
offer_type: string; // una_tantum | retainer
cumulative_price: string; cumulative_price: string;
accepted_total: string | null; accepted_total: string | null;
services: Array<{ name: string; description: string | null }>;
}>;
transcripts: Array<{
id: string;
title: string | null;
call_date: string;
content: string;
created_at: string;
}>; }>;
} }
@@ -118,8 +128,18 @@ export interface ProjectView {
activeOffers?: Array<{ activeOffers?: Array<{
id: string; id: string;
public_name: string; // offer_micros.public_name — NEVER internal_name public_name: string; // offer_micros.public_name — NEVER internal_name
cumulative_price: string; // sum of assigned offer_services.price offer_name: string; // offer_macros.public_name — macro-level name shown to client
offer_type: string; // una_tantum | retainer
cumulative_price: string; // sum of assigned services.unit_price (new) or offer_services.price (legacy)
accepted_total: string | null; accepted_total: string | null;
services: Array<{ name: string; description: string | null }>; // included services list
}>;
transcripts: Array<{
id: string;
title: string | null;
call_date: string;
content: string;
created_at: Date;
}>; }>;
} }
@@ -279,37 +299,115 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
const projectOfferRows = await db const projectOfferRows = await db
.select({ .select({
id: project_offers.id, id: project_offers.id,
public_name: offer_micros.public_name, // EXPLICITLY public_name, never internal_name public_name: offer_micros.public_name, // EXPLICITLY public_name, never internal_name
offer_name: offer_macros.public_name, // macro-level public name — NEVER internal_name
offer_type: offer_macros.offer_type, // una_tantum | retainer
accepted_total: project_offers.accepted_total, accepted_total: project_offers.accepted_total,
micro_id: project_offers.micro_id, micro_id: project_offers.micro_id,
}) })
.from(project_offers) .from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)) .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.where(eq(project_offers.project_id, projectId)); .where(eq(project_offers.project_id, projectId));
// Cumulative price per micro (sum of assigned services) // Cumulative price + service list per micro.
// Primary: offer_tier_services → services (Phase 12 catalog). Fallback: legacy offer_micro_services → offer_services.
const microIds = projectOfferRows.map((o) => o.micro_id); const microIds = projectOfferRows.map((o) => o.micro_id);
const cumulativePriceRows = microIds.length === 0 ? [] : await db
// New-style: services from offer_tier_services joined to unified services catalog
const tierServiceRows = microIds.length === 0 ? [] : await db
.select({
tier_id: offer_tier_services.tier_id,
name: services.name,
description: services.description,
unit_price: services.unit_price,
})
.from(offer_tier_services)
.innerJoin(services, eq(offer_tier_services.service_id, services.id))
.where(inArray(offer_tier_services.tier_id, microIds));
// Group by tier_id for quick lookup
const tierServicesMap = new Map<string, Array<{ name: string; description: string | null; unit_price: string }>>();
for (const row of tierServiceRows) {
const existing = tierServicesMap.get(row.tier_id) ?? [];
existing.push({ name: row.name, description: row.description, unit_price: row.unit_price });
tierServicesMap.set(row.tier_id, existing);
}
// Determine which micros have NO entries in the new table → need legacy fallback
const microsNeedingLegacy = microIds.filter((id) => !tierServicesMap.has(id));
// Legacy fallback: offer_micro_services → offer_services (for old offers with no tier_services rows)
const legacyPriceRows = microsNeedingLegacy.length === 0 ? [] : await db
.select({ .select({
micro_id: offer_micro_services.micro_id, micro_id: offer_micro_services.micro_id,
cumulative_price: sql<string>`coalesce(sum(${offer_services.price}::numeric), 0)`, name: offer_services.name,
price: offer_services.price,
}) })
.from(offer_micro_services) .from(offer_micro_services)
.innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id)) .innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id))
.where(inArray(offer_micro_services.micro_id, microIds)) .where(inArray(offer_micro_services.micro_id, microsNeedingLegacy));
.groupBy(offer_micro_services.micro_id);
const cumulMap = new Map(cumulativePriceRows.map((r) => [r.micro_id, r.cumulative_price])); const legacyServicesMap = new Map<string, Array<{ name: string; description: string | null; price: string }>>();
for (const row of legacyPriceRows) {
const existing = legacyServicesMap.get(row.micro_id) ?? [];
existing.push({ name: row.name, description: null, price: row.price });
legacyServicesMap.set(row.micro_id, existing);
}
const activeOffers = projectOfferRows.map((o) => ({ const activeOffers = projectOfferRows.map((o) => {
id: o.id, const newStyleServices = tierServicesMap.get(o.micro_id);
public_name: o.public_name, const legacyServices = legacyServicesMap.get(o.micro_id);
cumulative_price: cumulMap.get(o.micro_id) ?? "0",
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
}));
// Comments scoped to tasks and deliverables of this project let cumulative_price: string;
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)]; let servicesList: Array<{ name: string; description: string | null }>;
if (newStyleServices && newStyleServices.length > 0) {
// Use new catalog prices
const total = newStyleServices.reduce((sum, s) => sum + parseFloat(s.unit_price ?? "0"), 0);
cumulative_price = total.toFixed(2);
servicesList = newStyleServices.map((s) => ({ name: s.name, description: s.description }));
} else if (legacyServices && legacyServices.length > 0) {
// Legacy fallback
const total = legacyServices.reduce((sum, s) => sum + parseFloat(s.price ?? "0"), 0);
cumulative_price = total.toFixed(2);
servicesList = legacyServices.map((s) => ({ name: s.name, description: null }));
} else {
cumulative_price = "0";
servicesList = [];
}
return {
id: o.id,
public_name: o.public_name,
offer_name: o.offer_name,
offer_type: o.offer_type,
cumulative_price,
accepted_total: o.accepted_total ? String(o.accepted_total) : null,
services: servicesList,
};
});
// Transcripts linked to the project's client (post lead→client conversion)
const transcriptsRows = await db
.select({
id: clientTranscripts.id,
title: clientTranscripts.title,
call_date: clientTranscripts.call_date,
content: clientTranscripts.content,
created_at: clientTranscripts.created_at,
})
.from(clientTranscripts)
.where(eq(clientTranscripts.client_id, project.client_id))
.orderBy(desc(clientTranscripts.call_date));
// Comments scoped to: general (client_id), phases, tasks, and deliverables of this project
const allEntityIds = [
project.client_id, // general messages entity_id = client_id
...phaseIds, // phase-tagged messages
...taskIds,
...deliverablesRows.map((d) => d.id),
];
const commentsRows = const commentsRows =
allEntityIds.length === 0 allEntityIds.length === 0
? [] ? []
@@ -352,5 +450,6 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
comments: commentsRows, comments: commentsRows,
global_progress_pct, global_progress_pct,
activeOffers: activeOffers.length > 0 ? activeOffers : undefined, activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
transcripts: transcriptsRows,
}; };
} }
+3 -86
View File
@@ -1,6 +1,6 @@
import { db } from "@/db"; import { db } from "@/db";
import { clients, projects, payments, deliverables, time_entries } from "@/db/schema"; import { clients, projects, payments } from "@/db/schema";
import { eq, and, isNotNull, isNull, desc, sum, count } from "drizzle-orm"; import { eq, sum, count } from "drizzle-orm";
export type KpiStats = { export type KpiStats = {
clientiAttivi: number; clientiAttivi: number;
@@ -9,16 +9,8 @@ export type KpiStats = {
progettiInCorso: number; // projects not archived progettiInCorso: number; // projects not archived
}; };
export type ActivityItem = {
type: "nuovo_cliente" | "nuovo_progetto" | "deliverable_approvato" | "timer_stoppato";
label: string;
detail: string;
timestamp: Date;
};
export type DashboardStats = { export type DashboardStats = {
kpi: KpiStats; kpi: KpiStats;
activity: ActivityItem[];
}; };
export async function getDashboardStats(): Promise<DashboardStats> { export async function getDashboardStats(): Promise<DashboardStats> {
@@ -59,80 +51,5 @@ export async function getDashboardStats(): Promise<DashboardStats> {
progettiInCorso: progettiRow?.count ?? 0, progettiInCorso: progettiRow?.count ?? 0,
}; };
// ── Activity feed ───────────────────────────────────────────────────────────── return { kpi };
// Fetch recent events from multiple tables, merge and sort
const [recentClients, recentProjects, recentApprovals, recentTimers] = await Promise.all([
db
.select({ id: clients.id, name: clients.name, created_at: clients.created_at })
.from(clients)
.orderBy(desc(clients.created_at))
.limit(5),
db
.select({ id: projects.id, name: projects.name, created_at: projects.created_at })
.from(projects)
.orderBy(desc(projects.created_at))
.limit(5),
db
.select({ id: deliverables.id, title: deliverables.title, approved_at: deliverables.approved_at })
.from(deliverables)
.where(isNotNull(deliverables.approved_at))
.orderBy(desc(deliverables.approved_at))
.limit(5),
db
.select({
id: time_entries.id,
ended_at: time_entries.ended_at,
duration_seconds: time_entries.duration_seconds,
})
.from(time_entries)
.where(
and(
isNotNull(time_entries.ended_at),
isNotNull(time_entries.duration_seconds)
)
)
.orderBy(desc(time_entries.ended_at))
.limit(5),
]);
const activity: ActivityItem[] = [
...recentClients.map((c) => ({
type: "nuovo_cliente" as const,
label: "Nuovo cliente",
detail: c.name,
timestamp: c.created_at,
})),
...recentProjects.map((p) => ({
type: "nuovo_progetto" as const,
label: "Nuovo progetto",
detail: p.name,
timestamp: p.created_at,
})),
...recentApprovals
.filter((d): d is typeof d & { approved_at: Date } => d.approved_at !== null)
.map((d) => ({
type: "deliverable_approvato" as const,
label: "Deliverable approvato",
detail: d.title,
timestamp: d.approved_at,
})),
...recentTimers
.filter(
(t): t is typeof t & { ended_at: Date; duration_seconds: number } =>
t.ended_at !== null && t.duration_seconds !== null
)
.map((t) => ({
type: "timer_stoppato" as const,
label: "Timer stoppato",
detail: `${Math.round(t.duration_seconds / 60)} min`,
timestamp: t.ended_at,
})),
]
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.slice(0, 10);
return { kpi, activity };
} }
+65 -12
View File
@@ -1,6 +1,6 @@
import { db } from "@/db"; import { db } from "@/db";
import { project_offers, offer_micros } from "@/db/schema"; import { project_offers, offer_micros, offer_macros, projects } from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq, sql, desc } from "drizzle-orm";
export type ForecastMonth = { export type ForecastMonth = {
year: number; year: number;
@@ -9,16 +9,58 @@ export type ForecastMonth = {
total: number; total: number;
}; };
export type OffersSoldRow = {
offer_name: string; // offer_macros.internal_name
tier_letter: string | null;
count: number;
};
export type OffersSoldBreakdown = {
rows: OffersSoldRow[];
total: number;
};
// Quante offerte sono state vendute (assegnate a un progetto), per offerta + tier.
export async function getOffersSoldBreakdown(): Promise<OffersSoldBreakdown> {
const rows = await db
.select({
offer_name: offer_macros.internal_name,
tier_letter: offer_micros.tier_letter,
count: sql<number>`count(*)::int`,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.groupBy(offer_macros.internal_name, offer_micros.tier_letter)
.orderBy(desc(sql`count(*)`));
const total = rows.reduce((sum, r) => sum + Number(r.count), 0);
return {
rows: rows.map((r) => ({
offer_name: r.offer_name,
tier_letter: r.tier_letter,
count: Number(r.count),
})),
total,
};
}
export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> { export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
// Load all project offers with micro duration info // Active offers only (non-archived projects). offer_type drives the math:
// - retainer: accepted_total è il CANONE MENSILE → ogni mese coperto riceve l'intero importo
// - una_tantum: accepted_total è il prezzo TOTALE → spalmato su duration_months
const offers = await db const offers = await db
.select({ .select({
start_date: project_offers.start_date, start_date: project_offers.start_date,
duration_months: offer_micros.duration_months, duration_months: offer_micros.duration_months,
accepted_total: project_offers.accepted_total, accepted_total: project_offers.accepted_total,
offer_type: offer_macros.offer_type,
}) })
.from(project_offers) .from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)); .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id))
.innerJoin(projects, eq(project_offers.project_id, projects.id))
.where(eq(projects.archived, false));
// Build 12-month bucket array starting from current month // Build 12-month bucket array starting from current month
const now = new Date(); const now = new Date();
@@ -33,20 +75,31 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
}); });
for (const offer of offers) { for (const offer of offers) {
// Skip offers with no accepted_total — they contribute 0 to forecast
if (!offer.accepted_total) continue; if (!offer.accepted_total) continue;
const total = parseFloat(String(offer.accepted_total)); const total = parseFloat(String(offer.accepted_total));
if (isNaN(total) || total <= 0) continue; if (isNaN(total) || total <= 0) continue;
const perMonth = total / offer.duration_months;
const start = new Date(offer.start_date); const start = new Date(offer.start_date);
const startMonthKey = start.getFullYear() * 12 + start.getMonth();
for (let m = 0; m < offer.duration_months; m++) { if (offer.offer_type === "retainer") {
const offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1); // Canone ricorrente: contribuisce ad OGNI mese dell'orizzonte da start in poi,
const bucket = buckets.find( // senza limite di duration_months (un retainer è continuativo).
(b) => b.year === offerMonth.getFullYear() && b.month === offerMonth.getMonth() + 1 for (const b of buckets) {
); const bucketMonthKey = b.year * 12 + (b.month - 1);
if (bucket) bucket.total += perMonth; if (bucketMonthKey >= startMonthKey) b.total += total;
}
} else {
// Una tantum: prezzo totale ripartito su duration_months a partire da start.
const months = offer.duration_months > 0 ? offer.duration_months : 1;
const perMonth = total / months;
for (let m = 0; m < months; m++) {
const offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1);
const bucket = buckets.find(
(b) => b.year === offerMonth.getFullYear() && b.month === offerMonth.getMonth() + 1
);
if (bucket) bucket.total += perMonth;
}
} }
} }
+10
View File
@@ -219,3 +219,13 @@ export async function getTranscripts(leadId: string) {
.where(eq(clientTranscripts.lead_id, leadId)) .where(eq(clientTranscripts.lead_id, leadId))
.orderBy(desc(clientTranscripts.call_date)); .orderBy(desc(clientTranscripts.call_date));
} }
// Get transcripts linked to a client (post lead→client conversion).
// Returns all fields — used by admin DocumentsTab and public TranscriptsSection.
export async function getTranscriptsByClientId(clientId: string) {
return await db
.select()
.from(clientTranscripts)
.where(eq(clientTranscripts.client_id, clientId))
.orderBy(desc(clientTranscripts.call_date));
}

Some files were not shown because too many files have changed in this diff Show More