From add2176a6be857b03e971bda30b348a3ba90694b Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Fri, 26 Jun 2026 16:58:18 +0200 Subject: [PATCH] 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 --- .planning/UI-RULES.md | 68 +++++++++++++++++++ src/app/admin/catalog/page.tsx | 8 +-- src/app/admin/clients/page.tsx | 32 +++++---- src/app/admin/impostazioni/page.tsx | 5 +- src/app/admin/leads/page.tsx | 7 +- src/app/admin/page.tsx | 5 +- src/app/admin/preventivi/page.tsx | 68 +++++++++---------- src/app/admin/projects/page.tsx | 22 +++--- src/components/admin/PageHeader.tsx | 17 +++++ .../admin/offers/OfferEditorClient.tsx | 43 +++++++++--- .../admin/offers/OfferListClient.tsx | 34 ++++++++-- src/lib/offer-queries.ts | 63 ++++++++++++++++- 12 files changed, 281 insertions(+), 91 deletions(-) create mode 100644 .planning/UI-RULES.md create mode 100644 src/components/admin/PageHeader.tsx diff --git a/.planning/UI-RULES.md b/.planning/UI-RULES.md new file mode 100644 index 0000000..46f1354 --- /dev/null +++ b/.planning/UI-RULES.md @@ -0,0 +1,68 @@ +# UI Rules — ClientHub admin + +Derived from the live codebase (`src/app/globals.css`, design system usage across admin pages). +These are the rules to follow for any new or modified admin page. + +--- + +## Brand palette + +All colours must be written as **hex literals** — no Tailwind semantic tokens (`text-foreground`, `bg-muted`, etc.) in admin UI. Semantic tokens are defined in `globals.css` but mixing hex and tokens creates inconsistency. + +| Role | Hex | Usage example | +|-------------------|-------------|-----------------------------------------| +| Primary | `#1A463C` | Primary buttons, active states, accents | +| Primary hover | `#163a31` | Hover on primary buttons | +| Accent | `#DEF168` | Brand highlights (use sparingly) | +| Foreground | `#1a1a1a` | Body text, headings | +| Muted text | `#71717a` | Secondary text, labels, meta | +| Border | `#e5e7eb` | Card borders, table dividers, inputs | +| Background muted | `#f9f9f9` | Table header rows, card hover bg | +| White | `#ffffff` | Card / panel backgrounds | +| Destructive | `#dc2626` | Delete/archive actions, error text | + +--- + +## Typographic scale + +| Level | Classes | +|--------------------|----------------------------------------------| +| Page title (h1) | `text-2xl font-bold text-[#1a1a1a]` | +| Section heading | `text-base font-semibold text-[#1a1a1a]` | +| Subsection (h3) | `text-sm font-bold text-[#71717a] uppercase tracking-wider` | +| Body | `text-sm text-[#1a1a1a]` | +| Secondary/label | `text-xs text-[#71717a]` | +| Numeric values | always add `tabular-nums` | + +--- + +## Layout + +- **Page wrapper:** `
` — uniform vertical rhythm, full-width. +- **Page header:** always use `` 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 `` 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). diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx index a64548d..56e61d9 100644 --- a/src/app/admin/catalog/page.tsx +++ b/src/app/admin/catalog/page.tsx @@ -1,5 +1,6 @@ import { getAllServices, getCatalogFieldOptions } from "@/lib/admin-queries"; import { CatalogSearch } from "./CatalogSearch"; +import { PageHeader } from "@/components/admin/PageHeader"; export const revalidate = 0; @@ -10,11 +11,8 @@ export default async function CatalogPage() { ]); return ( -
-
-

Catalogo Servizi

-
- +
+
); diff --git a/src/app/admin/clients/page.tsx b/src/app/admin/clients/page.tsx index 460f15a..fce98f0 100644 --- a/src/app/admin/clients/page.tsx +++ b/src/app/admin/clients/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { getAllClientsWithPayments } from "@/lib/admin-queries"; import { ClientRow } from "@/components/admin/ClientRow"; import { Button } from "@/components/ui/button"; +import { PageHeader } from "@/components/admin/PageHeader"; export const revalidate = 0; @@ -14,22 +15,23 @@ export default async function AdminClientsPage({ const showArchived = archived === "1"; const clients = await getAllClientsWithPayments(showArchived); + const clientiAction = ( + + ); + return ( -
- +
+ {clients.length === 0 ? (
diff --git a/src/app/admin/impostazioni/page.tsx b/src/app/admin/impostazioni/page.tsx index 70d98af..44b5ee4 100644 --- a/src/app/admin/impostazioni/page.tsx +++ b/src/app/admin/impostazioni/page.tsx @@ -1,6 +1,7 @@ import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings"; import { getAllPools } from "@/lib/taxonomy"; import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager"; +import { PageHeader } from "@/components/admin/PageHeader"; export const revalidate = 0; @@ -16,8 +17,8 @@ export default async function ImpostazioniPage() { } return ( -
-

Impostazioni

+
+
diff --git a/src/app/admin/leads/page.tsx b/src/app/admin/leads/page.tsx index 236c99f..967f621 100644 --- a/src/app/admin/leads/page.tsx +++ b/src/app/admin/leads/page.tsx @@ -1,6 +1,7 @@ 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; @@ -12,11 +13,7 @@ export default async function LeadsPage() { return (
-
-

Lead Pipeline

- -
- + } />
); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index dc86ca3..7d12564 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -12,6 +12,7 @@ import { getRevenueForecast12Months, getOffersSoldBreakdown } from "@/lib/foreca import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector"; import { ForecastChart } from "@/components/admin/ForecastChart"; import { OffersSoldChart } from "@/components/admin/OffersSoldChart"; +import { PageHeader } from "@/components/admin/PageHeader"; export const revalidate = 0; @@ -95,8 +96,8 @@ export default async function AdminDashboard({ const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1; return ( -
-

Dashboard

+
+ {/* Follow-up Widget */}
diff --git a/src/app/admin/preventivi/page.tsx b/src/app/admin/preventivi/page.tsx index 3b8d8f9..07507b8 100644 --- a/src/app/admin/preventivi/page.tsx +++ b/src/app/admin/preventivi/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { listProposals } from "@/lib/proposal/queries"; import { FileText, Plus } from "lucide-react"; +import { PageHeader } from "@/components/admin/PageHeader"; export const revalidate = 0; @@ -14,47 +15,46 @@ const STATE_LABELS: Record = { export default async function PreventiviPage() { const proposals = await listProposals(); + const preventiviAction = ( + + + Genera preventivo + + ); + return ( -
- {/* Header */} -
-
-

Preventivi

-

- Preventivi generati con AI e pubblicati ai clienti -

-
- - - Genera preventivo - -
+
+ {/* Lista */} {proposals.length === 0 ? ( -
- -

Nessun preventivo ancora.

+
+ +

Nessun preventivo ancora.

Genera il primo preventivo →
) : ( -
+
- + - - - - - + + + + + @@ -63,20 +63,20 @@ export default async function PreventiviPage() { const stateInfo = STATE_LABELS[p.state] ?? { label: p.state, className: "bg-gray-100 text-gray-700" }; const subject = p.clientName ?? p.leadName ?? "—"; return ( - + - - + + - + + + {tiers.map((tier, tierIdx) => ( + + ))} +
TitoloLead / ClienteOffertaStatoDataTitoloLead / ClienteOffertaStatoData
- + {p.title ?? "Senza titolo"} {subject}{p.offerName}{subject}{p.offerName} {stateInfo.label} + {p.createdAt.toLocaleDateString("it-IT")} @@ -85,7 +85,7 @@ export default async function PreventiviPage() { href={`/preventivo/${p.slug}`} target="_blank" rel="noopener noreferrer" - className="text-xs text-primary hover:underline" + className="text-xs text-[#1A463C] hover:underline" > Apri → diff --git a/src/app/admin/projects/page.tsx b/src/app/admin/projects/page.tsx index b79b9f0..1537364 100644 --- a/src/app/admin/projects/page.tsx +++ b/src/app/admin/projects/page.tsx @@ -1,23 +1,25 @@ import { getAllProjectsWithPayments } from "@/lib/admin-queries"; import { ProjectRow } from "@/components/admin/ProjectRow"; import Link from "next/link"; +import { PageHeader } from "@/components/admin/PageHeader"; export const revalidate = 0; export default async function ProjectsPage() { const projects = await getAllProjectsWithPayments(); + const projectiAction = ( + + + Nuovo Progetto + + ); + return ( -
-
-

Progetti

- - + Nuovo Progetto - -
+
+ {projects.length === 0 ? (
diff --git a/src/components/admin/PageHeader.tsx b/src/components/admin/PageHeader.tsx new file mode 100644 index 0000000..c90ff3e --- /dev/null +++ b/src/components/admin/PageHeader.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; + +export function PageHeader({ title, subtitle, action }: { + title: string; + subtitle?: string; + action?: ReactNode; +}) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {action} +
+ ); +} diff --git a/src/components/admin/offers/OfferEditorClient.tsx b/src/components/admin/offers/OfferEditorClient.tsx index f8496a0..30636b1 100644 --- a/src/components/admin/offers/OfferEditorClient.tsx +++ b/src/components/admin/offers/OfferEditorClient.tsx @@ -162,6 +162,12 @@ export function OfferEditorClient({ ); } + function updateTierName(tierIdx: number, value: string) { + setTiers((prev) => + prev.map((tier, idx) => (idx === tierIdx ? { ...tier, public_name: value } : tier)) + ); + } + function handleSave() { setSaveError(null); const payload: SaveOfferEditorPayload = { @@ -176,15 +182,18 @@ export function OfferEditorClient({ tempo: macro.tempo, pain: macro.pain, metodo: macro.metodo, - tiers: tiers.map((t) => ({ - id: t.id || undefined, - tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C", - internal_name: t.internal_name || `Tier ${t.tier_letter}`, - public_name: t.public_name || t.tier_letter || "Tier", - duration_months: t.duration_months || 1, - public_price: t.public_price ? Number(t.public_price) : null, - assignedServiceIds: t.assignedServiceIds, - })), + tiers: tiers.map((t) => { + const tierName = t.public_name?.trim(); + return { + id: t.id || undefined, + tier_letter: (t.tier_letter ?? "A") as "A" | "B" | "C", + internal_name: tierName || `Tier ${t.tier_letter}`, + public_name: tierName || t.tier_letter || "Tier", + duration_months: t.duration_months || 1, + public_price: t.public_price ? Number(t.public_price) : null, + assignedServiceIds: t.assignedServiceIds, + }; + }), tipoTags, obiettivoTags, }; @@ -474,6 +483,22 @@ export function OfferEditorClient({ )}
+ Nome Tier + + 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" + /> +
Totale Servizi diff --git a/src/components/admin/offers/OfferListClient.tsx b/src/components/admin/offers/OfferListClient.tsx index df9e623..636c0f0 100644 --- a/src/components/admin/offers/OfferListClient.tsx +++ b/src/components/admin/offers/OfferListClient.tsx @@ -4,10 +4,15 @@ import { useState, useMemo, useTransition } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; 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 { Badge } from "@/components/ui/badge"; 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({ cards, @@ -77,11 +82,7 @@ export function OfferListClient({ return (
- {/* Page header */} -
-

Offerte

- {createCta} -
+ {/* Filter row */}
@@ -149,6 +150,27 @@ export function OfferListClient({ Archiviata )}
+ {card.tiers.filter( + (t) => Number(t.servicesTotal) > 0 || t.public_price !== null + ).length > 0 && ( +
+ {card.tiers + .filter((t) => Number(t.servicesTotal) > 0 || t.public_price !== null) + .map((t) => ( +
+ + {t.public_name || `Tier ${t.tier_letter}`} + + + Servizi: {formatEuro(Number(t.servicesTotal))} + + + Pubblico: {t.public_price !== null ? formatEuro(Number(t.public_price)) : "—"} + +
+ ))} +
+ )} ))}
diff --git a/src/lib/offer-queries.ts b/src/lib/offer-queries.ts index dbfa8e3..7924046 100644 --- a/src/lib/offer-queries.ts +++ b/src/lib/offer-queries.ts @@ -105,16 +105,23 @@ export async function getMicroAssignedServiceIds(microId: string): Promise; +> & { tiers: OfferListTier[] }; // List-page cards (Plan 04): one row per offer_macros, with category + archive // status for client-side filtering. Archived offers ARE included — the // "Mostra offerte archiviate" toggle filters client-side per UI-SPEC. export async function getOfferListCards(): Promise { - const rows = await db + const macros = await db .select({ id: offer_macros.id, internal_name: offer_macros.internal_name, @@ -124,7 +131,57 @@ export async function getOfferListCards(): Promise { }) .from(offer_macros) .orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at)); - return rows; + + if (macros.length === 0) return []; + + const macroIds = macros.map((m) => m.id); + + // Fetch all tiers for these macros, ordered A -> B -> C + const tierOrder = sql`CASE ${offer_micros.tier_letter} WHEN 'A' THEN 1 WHEN 'B' THEN 2 WHEN 'C' THEN 3 ELSE 4 END`; + const tierRows = await db + .select({ + id: offer_micros.id, + macro_id: offer_micros.macro_id, + tier_letter: offer_micros.tier_letter, + public_name: offer_micros.public_name, + public_price: offer_micros.public_price, + }) + .from(offer_micros) + .where(inArray(offer_micros.macro_id, macroIds)) + .orderBy(asc(tierOrder)); + + const allTierIds = tierRows.map((t) => t.id); + + // Compute servicesTotal per tier in batch + let totalsByTierId = new Map(); + if (allTierIds.length > 0) { + const totalRows = await db + .select({ + tier_id: offer_tier_services.tier_id, + servicesTotal: sql`coalesce(sum(${services.unit_price}::numeric), 0)`, + }) + .from(offer_tier_services) + .innerJoin(services, eq(offer_tier_services.service_id, services.id)) + .where(inArray(offer_tier_services.tier_id, allTierIds)) + .groupBy(offer_tier_services.tier_id); + + totalsByTierId = new Map(totalRows.map((r) => [r.tier_id, r.servicesTotal])); + } + + // Group tiers under each macro + const tiersByMacroId = new Map(); + for (const t of tierRows) { + const list = tiersByMacroId.get(t.macro_id) ?? []; + list.push({ + tier_letter: t.tier_letter, + public_name: t.public_name, + public_price: t.public_price, + servicesTotal: totalsByTierId.get(t.id) ?? "0", + }); + tiersByMacroId.set(t.macro_id, list); + } + + return macros.map((m) => ({ ...m, tiers: tiersByMacroId.get(m.id) ?? [] })); } export type OfferTierData = {