feat: pagina Preventivi redesign Quiet Luxury — tabella tokenizzata + badge stato dual-mode
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -263,6 +263,25 @@ righe `hover:bg-muted/40` / `divide` via `border-border`.
|
|||||||
sono stati tokenizzati — erano rimasti con hex hardcoded mentre `OptionMultiSelect` era
|
sono stati tokenizzati — erano rimasti con hex hardcoded mentre `OptionMultiSelect` era
|
||||||
già token-based. Il fix vale per tutte le pagine che li usano (Offerte, Lead).
|
già token-based. Il fix vale per tutte le pagine che li usano (Offerte, Lead).
|
||||||
|
|
||||||
|
### Preventivi (lista) notes
|
||||||
|
|
||||||
|
`/admin/preventivi` migrata a "Quiet Luxury" (replica `design-reference/pagina-preventivi`).
|
||||||
|
Tabella in contenitore `bg-card rounded-xl border-border shadow-card overflow-hidden`, header
|
||||||
|
uppercase muted (`bg-muted/50`, `text-[11px] font-semibold uppercase tracking-wider`), righe
|
||||||
|
`hover:bg-muted/40` con `divide-y divide-border`.
|
||||||
|
|
||||||
|
- **Header**: `PageHeader` "Preventivi" + sottotitolo, azione `Button` "Genera preventivo"
|
||||||
|
(`+`, link a `/admin/preventivi/genera`).
|
||||||
|
- **Colonne**: Titolo (link → dettaglio, `font-semibold hover:text-primary`), Lead/Cliente,
|
||||||
|
Offerta (muted), Stato (badge centrato), Data (`font-mono text-xs`), Azione.
|
||||||
|
- **Badge stato**: pill rounded-full `text-[10px] uppercase border` con dual light/dark —
|
||||||
|
Bozza→amber, Pubblicato→sky, Accettato→emerald, Rifiutato→red (stesso linguaggio di
|
||||||
|
`StatusBadge`; label ITA mappate localmente perché gli stati proposal ≠ lead stages).
|
||||||
|
- **Azione**: solo per `published`/`accepted` link "Apri →" (`ArrowRight` con group-hover
|
||||||
|
translate) al preventivo pubblico `/preventivo/<slug>` in new tab; altrimenti `—` muted.
|
||||||
|
- **Empty state**: `border-dashed border-border rounded-xl`, `FileText` muted + CTA
|
||||||
|
"Genera il primo preventivo →". Footer "Mostrando N di N preventivi generati".
|
||||||
|
|
||||||
### Client Portal restyle notes
|
### Client Portal restyle notes
|
||||||
|
|
||||||
The `/client/[token]` portal was migrated to the same "Quiet Luxury" tokens
|
The `/client/[token]` portal was migrated to the same "Quiet Luxury" tokens
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
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, ArrowRight } from "lucide-react";
|
||||||
import { PageHeader } from "@/components/admin/PageHeader";
|
import { PageHeader } from "@/components/admin/PageHeader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
const STATE_LABELS: Record<string, { label: string; className: string }> = {
|
const STATE_STYLES: Record<string, { label: string; className: string }> = {
|
||||||
draft: { label: "Bozza", className: "bg-yellow-100 text-yellow-800" },
|
draft: {
|
||||||
published: { label: "Pubblicato", className: "bg-blue-100 text-blue-800" },
|
label: "Bozza",
|
||||||
accepted: { label: "Accettato", className: "bg-green-100 text-green-800" },
|
className:
|
||||||
rejected: { label: "Rifiutato", className: "bg-red-100 text-red-800" },
|
"bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900",
|
||||||
|
},
|
||||||
|
published: {
|
||||||
|
label: "Pubblicato",
|
||||||
|
className:
|
||||||
|
"bg-sky-50 text-sky-700 border-sky-100 dark:bg-sky-950/40 dark:text-sky-300 dark:border-sky-900",
|
||||||
|
},
|
||||||
|
accepted: {
|
||||||
|
label: "Accettato",
|
||||||
|
className:
|
||||||
|
"bg-emerald-50 text-emerald-600 border-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900",
|
||||||
|
},
|
||||||
|
rejected: {
|
||||||
|
label: "Rifiutato",
|
||||||
|
className:
|
||||||
|
"bg-red-50 text-red-600 border-red-100 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function PreventiviPage() {
|
export default async function PreventiviPage() {
|
||||||
const proposals = await listProposals();
|
const proposals = await listProposals();
|
||||||
|
|
||||||
const preventiviAction = (
|
const preventiviAction = (
|
||||||
<Link
|
<Button asChild className="self-start sm:self-auto">
|
||||||
href="/admin/preventivi/genera"
|
<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
|
||||||
<Plus size={16} />
|
</Link>
|
||||||
Genera preventivo
|
</Button>
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,69 +49,91 @@ export default async function PreventiviPage() {
|
|||||||
action={preventiviAction}
|
action={preventiviAction}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Lista */}
|
|
||||||
{proposals.length === 0 ? (
|
{proposals.length === 0 ? (
|
||||||
<div className="text-center py-20 border border-dashed border-[#e5e7eb] rounded-lg">
|
<div className="text-center py-20 border border-dashed border-border rounded-xl">
|
||||||
<FileText size={40} className="mx-auto text-[#71717a] mb-4" />
|
<FileText size={40} className="mx-auto text-muted-foreground mb-4" />
|
||||||
<p className="text-[#71717a] text-sm">Nessun preventivo ancora.</p>
|
<p className="text-muted-foreground 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-[#1A463C] hover:underline"
|
className="mt-4 inline-flex items-center gap-2 text-sm text-primary hover:underline font-medium"
|
||||||
>
|
>
|
||||||
Genera il primo preventivo →
|
Genera il primo preventivo →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border border-[#e5e7eb] rounded-lg overflow-hidden">
|
<div className="bg-card rounded-xl border border-border shadow-card overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<div className="overflow-x-auto">
|
||||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
<table className="w-full text-left text-sm border-collapse">
|
||||||
<tr>
|
<thead>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Titolo</th>
|
<tr className="border-b border-border bg-muted/50 text-muted-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||||
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Lead / Cliente</th>
|
<th className="py-4 px-6 w-1/3">Titolo</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Offerta</th>
|
<th className="py-4 px-6">Lead / Cliente</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Stato</th>
|
<th className="py-4 px-6">Offerta</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-[#71717a]">Data</th>
|
<th className="py-4 px-6 text-center">Stato</th>
|
||||||
<th className="px-4 py-3" />
|
<th className="py-4 px-6">Data</th>
|
||||||
</tr>
|
<th className="py-4 px-6 text-right">Azione</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
{proposals.map((p) => {
|
<tbody className="divide-y divide-border">
|
||||||
const stateInfo = STATE_LABELS[p.state] ?? { label: p.state, className: "bg-gray-100 text-gray-700" };
|
{proposals.map((p) => {
|
||||||
const subject = p.clientName ?? p.leadName ?? "—";
|
const stateInfo =
|
||||||
return (
|
STATE_STYLES[p.state] ?? {
|
||||||
<tr key={p.id} className="border-b border-[#e5e7eb] last:border-0 hover:bg-[#f9f9f9] transition-colors">
|
label: p.state,
|
||||||
<td className="px-4 py-3 font-medium">
|
className: "bg-muted text-muted-foreground border-border",
|
||||||
<Link href={`/admin/preventivi/${p.id}`} className="hover:text-[#1A463C] transition-colors">
|
};
|
||||||
{p.title ?? "Senza titolo"}
|
const subject = p.clientName ?? p.leadName ?? "—";
|
||||||
</Link>
|
const isPublic = p.state === "published" || p.state === "accepted";
|
||||||
</td>
|
return (
|
||||||
<td className="px-4 py-3 text-[#71717a]">{subject}</td>
|
<tr key={p.id} className="hover:bg-muted/40 transition-colors">
|
||||||
<td className="px-4 py-3 text-[#71717a]">{p.offerName}</td>
|
<td className="py-4 px-6">
|
||||||
<td className="px-4 py-3">
|
<Link
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${stateInfo.className}`}>
|
href={`/admin/preventivi/${p.id}`}
|
||||||
{stateInfo.label}
|
className="font-semibold text-foreground hover:text-primary transition-colors"
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-[#71717a]">
|
|
||||||
{p.createdAt.toLocaleDateString("it-IT")}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
{p.state === "published" || p.state === "accepted" ? (
|
|
||||||
<a
|
|
||||||
href={`/preventivo/${p.slug}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-xs text-[#1A463C] hover:underline"
|
|
||||||
>
|
>
|
||||||
Apri →
|
{p.title ?? "Senza titolo"}
|
||||||
</a>
|
</Link>
|
||||||
) : null}
|
</td>
|
||||||
</td>
|
<td className="py-4 px-6 text-muted-foreground">{subject}</td>
|
||||||
</tr>
|
<td className="py-4 px-6 font-medium text-muted-foreground">{p.offerName}</td>
|
||||||
);
|
<td className="py-4 px-6 text-center">
|
||||||
})}
|
<span
|
||||||
</tbody>
|
className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide uppercase border whitespace-nowrap ${stateInfo.className}`}
|
||||||
</table>
|
>
|
||||||
|
{stateInfo.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-xs font-mono text-muted-foreground">
|
||||||
|
{p.createdAt.toLocaleDateString("it-IT")}
|
||||||
|
</td>
|
||||||
|
<td className="py-4 px-6 text-right">
|
||||||
|
{isPublic ? (
|
||||||
|
<a
|
||||||
|
href={`/preventivo/${p.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="group inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
Apri
|
||||||
|
<ArrowRight
|
||||||
|
size={14}
|
||||||
|
className="transform group-hover:translate-x-0.5 transition-transform"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/50 text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border px-6 py-4 text-xs text-muted-foreground">
|
||||||
|
Mostrando {proposals.length} di {proposals.length}{" "}
|
||||||
|
{proposals.length === 1 ? "preventivo generato" : "preventivi generati"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user