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>
This commit is contained in:
@@ -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).
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
|
import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
|
||||||
import { getAllPools } from "@/lib/taxonomy";
|
import { getAllPools } from "@/lib/taxonomy";
|
||||||
import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager";
|
import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager";
|
||||||
|
import { PageHeader } from "@/components/admin/PageHeader";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
@@ -16,8 +17,8 @@ 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-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 max-w-md">
|
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 max-w-md">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
|
import { getLeadsWithTags, getLeadFieldOptions } from "@/lib/admin-queries";
|
||||||
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
|
import { LeadsViewToggle } from "@/components/admin/leads/LeadsViewToggle";
|
||||||
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
|
import { CreateLeadModal } from "@/components/admin/leads/LeadForm";
|
||||||
|
import { PageHeader } from "@/components/admin/PageHeader";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
@@ -12,11 +13,7 @@ export default async function LeadsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
<PageHeader title="Lead Pipeline" action={<CreateLeadModal />} />
|
||||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Lead Pipeline</h1>
|
|
||||||
<CreateLeadModal />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LeadsViewToggle leads={leads} options={options} />
|
<LeadsViewToggle leads={leads} options={options} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { getRevenueForecast12Months, getOffersSoldBreakdown } from "@/lib/foreca
|
|||||||
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
|
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
|
||||||
import { ForecastChart } from "@/components/admin/ForecastChart";
|
import { ForecastChart } from "@/components/admin/ForecastChart";
|
||||||
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
|
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
|
||||||
|
import { PageHeader } from "@/components/admin/PageHeader";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
@@ -95,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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">{title}</h1>
|
||||||
|
{subtitle && <p className="mt-1 text-sm text-[#71717a]">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
function handleSave() {
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
const payload: SaveOfferEditorPayload = {
|
const payload: SaveOfferEditorPayload = {
|
||||||
@@ -176,15 +182,18 @@ export function OfferEditorClient({
|
|||||||
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,
|
||||||
};
|
};
|
||||||
@@ -474,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>
|
||||||
|
|||||||
@@ -105,16 +105,23 @@ export async function getMicroAssignedServiceIds(microId: string): Promise<strin
|
|||||||
const OFFER_TIPO_ENTITY = "offer_macros.tipo";
|
const OFFER_TIPO_ENTITY = "offer_macros.tipo";
|
||||||
const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo";
|
const OFFER_OBIETTIVO_ENTITY = "offer_macros.obiettivo";
|
||||||
|
|
||||||
|
export type OfferListTier = {
|
||||||
|
tier_letter: string | null;
|
||||||
|
public_name: string;
|
||||||
|
public_price: string | null;
|
||||||
|
servicesTotal: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type OfferListCard = Pick<
|
export type OfferListCard = Pick<
|
||||||
OfferMacro,
|
OfferMacro,
|
||||||
"id" | "internal_name" | "description" | "category" | "is_archived"
|
"id" | "internal_name" | "description" | "category" | "is_archived"
|
||||||
>;
|
> & { tiers: OfferListTier[] };
|
||||||
|
|
||||||
// List-page cards (Plan 04): one row per offer_macros, with category + archive
|
// List-page cards (Plan 04): one row per offer_macros, with category + archive
|
||||||
// status for client-side filtering. Archived offers ARE included — the
|
// status for client-side filtering. Archived offers ARE included — the
|
||||||
// "Mostra offerte archiviate" toggle filters client-side per UI-SPEC.
|
// "Mostra offerte archiviate" toggle filters client-side per UI-SPEC.
|
||||||
export async function getOfferListCards(): Promise<OfferListCard[]> {
|
export async function getOfferListCards(): Promise<OfferListCard[]> {
|
||||||
const rows = await db
|
const macros = await db
|
||||||
.select({
|
.select({
|
||||||
id: offer_macros.id,
|
id: offer_macros.id,
|
||||||
internal_name: offer_macros.internal_name,
|
internal_name: offer_macros.internal_name,
|
||||||
@@ -124,7 +131,57 @@ export async function getOfferListCards(): Promise<OfferListCard[]> {
|
|||||||
})
|
})
|
||||||
.from(offer_macros)
|
.from(offer_macros)
|
||||||
.orderBy(asc(offer_macros.sort_order), asc(offer_macros.created_at));
|
.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<string, string>();
|
||||||
|
if (allTierIds.length > 0) {
|
||||||
|
const totalRows = await db
|
||||||
|
.select({
|
||||||
|
tier_id: offer_tier_services.tier_id,
|
||||||
|
servicesTotal: sql<string>`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<string, OfferListTier[]>();
|
||||||
|
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 = {
|
export type OfferTierData = {
|
||||||
|
|||||||
Reference in New Issue
Block a user