feat: pagina Progetti redesign Quiet Luxury — tabella tokenizzata + badge pagamento/timer dual-mode
- projects/page.tsx: tabella in contenitore bg-card/shadow-card, header uppercase muted - ProjectRow: badge pagamento pill rounded-full dual light/dark (saldato/da_saldare/inviata) - TimerCell: pill rounded-full mono, idle neutro + running emerald con contatore live - ConversationsView: bordo item attivo 3px per coerenza - DESIGN-SYSTEM.md: note pagina Progetti Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { getAllProjectsWithPayments } from "@/lib/admin-queries";
|
||||
import { ProjectRow } from "@/components/admin/ProjectRow";
|
||||
import Link from "next/link";
|
||||
import { FolderOpen, Plus } from "lucide-react";
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
@@ -9,43 +11,57 @@ export default async function ProjectsPage() {
|
||||
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>
|
||||
<Button asChild className="self-start sm:self-auto">
|
||||
<Link href="/admin/projects/new">
|
||||
<Plus size={16} />
|
||||
Nuovo Progetto
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Progetti" action={projectiAction} />
|
||||
<PageHeader
|
||||
title="Progetti"
|
||||
subtitle="Traccia le attività, monitora gli incassi e verifica la redditività oraria effettiva"
|
||||
action={projectiAction}
|
||||
/>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-12 text-center">
|
||||
<p className="text-[#71717a]">Nessun progetto ancora. Creane uno dal dettaglio di un cliente.</p>
|
||||
<div className="text-center py-20 border border-dashed border-border rounded-xl">
|
||||
<FolderOpen size={40} className="mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Nessun progetto ancora. Creane uno dal dettaglio di un cliente.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">Progetto</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">Valore</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">Acconto</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">Saldo</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">Timer</th>
|
||||
<th className="text-left py-3 px-4 text-xs font-semibold text-[#71717a] uppercase tracking-wider">€/h</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((project) => (
|
||||
<ProjectRow key={project.id} project={project} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="bg-card rounded-xl border border-border shadow-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50 text-muted-foreground text-[11px] font-semibold uppercase tracking-wider">
|
||||
<th className="py-4 px-6 w-1/4">Progetto</th>
|
||||
<th className="py-4 px-6 text-right">Valore Totale</th>
|
||||
<th className="py-4 px-6 text-center">Acconto</th>
|
||||
<th className="py-4 px-6 text-center">Saldo</th>
|
||||
<th className="py-4 px-6 text-center">Timer</th>
|
||||
<th className="py-4 px-6 text-right">Redditività (€/H)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{projects.map((project) => (
|
||||
<ProjectRow key={project.id} project={project} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-6 py-4 text-xs text-muted-foreground">
|
||||
Mostrando {projects.length} di {projects.length}{" "}
|
||||
{projects.length === 1 ? "progetto attivo" : "progetti attivi"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,111 @@
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TimerCell } from "@/components/admin/TimerCell";
|
||||
import type { ProjectWithPayments } from "@/lib/admin-queries";
|
||||
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
da_saldare: { label: "Da saldare", className: "bg-red-100 text-red-700 border-transparent" },
|
||||
inviata: { label: "Inviata", className: "bg-[#DEF168]/30 text-[#1A463C] border-transparent" },
|
||||
saldato: { label: "Saldato", className: "bg-[#1A463C]/10 text-[#1A463C] border-transparent font-medium" },
|
||||
da_saldare: {
|
||||
label: "Da saldare",
|
||||
className:
|
||||
"bg-rose-50 text-rose-700 border-rose-100 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-900",
|
||||
},
|
||||
inviata: {
|
||||
label: "Inviata",
|
||||
className:
|
||||
"bg-amber-50 text-amber-600 border-amber-100 dark:bg-amber-950/40 dark:text-amber-300 dark:border-amber-900",
|
||||
},
|
||||
saldato: {
|
||||
label: "Saldato",
|
||||
className:
|
||||
"bg-emerald-50 text-emerald-700 border-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-900",
|
||||
},
|
||||
};
|
||||
|
||||
function PaymentBadge({ prefix, status }: { prefix: string; status: string }) {
|
||||
const info = statusConfig[status] ?? {
|
||||
label: status,
|
||||
className: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-semibold tracking-wide uppercase border whitespace-nowrap ${info.className}`}
|
||||
>
|
||||
{prefix}: {info.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectRow({ project }: { project: ProjectWithPayments }) {
|
||||
const acconto = project.payments.find((p) => p.label.toLowerCase().includes("acconto"));
|
||||
const saldo = project.payments.find((p) => p.label.toLowerCase().includes("saldo"));
|
||||
const saldo = project.payments.find((p) => p.label.toLowerCase().includes("saldo"));
|
||||
|
||||
const hours = project.totalTrackedSeconds / 3600;
|
||||
const eurPerHour = hours > 0 ? parseFloat(project.accepted_total) / hours : null;
|
||||
|
||||
return (
|
||||
<tr className={`border-b border-[#f4f4f5] hover:bg-[#f9f9f9] transition-colors ${project.archived ? "opacity-60" : ""}`}>
|
||||
<td className="py-3 px-4">
|
||||
<tr
|
||||
className={`hover:bg-muted/40 transition-colors ${project.archived ? "opacity-60" : ""}`}
|
||||
>
|
||||
<td className="py-4 px-6">
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}`}
|
||||
className="font-medium text-[#1a1a1a] hover:text-[#1A463C] hover:underline"
|
||||
className="font-semibold text-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
{project.name}
|
||||
</Link>
|
||||
<p className="text-xs text-[#71717a]">{project.client.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">{project.client.name}</p>
|
||||
{project.archived && (
|
||||
<span className="text-[10px] text-[#71717a] bg-[#f4f4f5] px-1.5 py-0.5 rounded-full">
|
||||
<span className="mt-1 inline-block text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded-full">
|
||||
Archiviato
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-[#1a1a1a]">
|
||||
|
||||
<td className="py-4 px-6 text-right font-medium text-foreground font-mono whitespace-nowrap">
|
||||
€{parseFloat(project.accepted_total).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{acconto && (
|
||||
<Badge className={statusConfig[acconto.status]?.className ?? "border-transparent bg-[#f4f4f5] text-[#71717a]"}>
|
||||
Acconto: {statusConfig[acconto.status]?.label ?? acconto.status}
|
||||
</Badge>
|
||||
|
||||
<td className="py-4 px-6 text-center">
|
||||
{acconto ? (
|
||||
<PaymentBadge prefix="Acconto" status={acconto.status} />
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{saldo && (
|
||||
<Badge className={statusConfig[saldo.status]?.className ?? "border-transparent bg-[#f4f4f5] text-[#71717a]"}>
|
||||
Saldo: {statusConfig[saldo.status]?.label ?? saldo.status}
|
||||
</Badge>
|
||||
|
||||
<td className="py-4 px-6 text-center">
|
||||
{saldo ? (
|
||||
<PaymentBadge prefix="Saldo" status={saldo.status} />
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<TimerCell
|
||||
clientId={project.id}
|
||||
projectId={project.id}
|
||||
activeEntryId={project.activeTimerEntryId}
|
||||
activeStartedAt={project.activeTimerStartedAt}
|
||||
totalTrackedSeconds={project.totalTrackedSeconds}
|
||||
/>
|
||||
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex justify-center">
|
||||
<TimerCell
|
||||
clientId={project.id}
|
||||
projectId={project.id}
|
||||
activeEntryId={project.activeTimerEntryId}
|
||||
activeStartedAt={project.activeTimerStartedAt}
|
||||
totalTrackedSeconds={project.totalTrackedSeconds}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-[#71717a] tabular-nums">
|
||||
{eurPerHour !== null ? `€${eurPerHour.toFixed(2)}/h` : "—"}
|
||||
|
||||
<td className="py-4 px-6 text-right whitespace-nowrap">
|
||||
{eurPerHour === null ? (
|
||||
<span className="text-muted-foreground/40 font-medium">—</span>
|
||||
) : eurPerHour > 0 ? (
|
||||
<span className="inline-flex items-center justify-end gap-1.5 font-mono font-bold text-emerald-700 dark:text-emerald-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
€{eurPerHour.toFixed(2)}/h
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono font-semibold text-muted-foreground">
|
||||
€{eurPerHour.toFixed(2)}/h
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,36 +60,37 @@ export function TimerCell({
|
||||
const displayTotal = formatDuration(totalTrackedSeconds + (isRunning ? elapsed : 0));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`inline-flex items-center gap-2 rounded-full border pl-2 pr-3 py-1 text-xs font-mono tabular-nums transition-colors ${
|
||||
isRunning
|
||||
? "bg-emerald-50/60 border-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:border-emerald-900 dark:text-emerald-300 font-semibold"
|
||||
: "bg-muted border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
title={isRunning ? "Ferma timer" : "Avvia timer"}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center transition-colors shrink-0 ${
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center transition-colors shrink-0 ${
|
||||
isRunning
|
||||
? "bg-red-100 text-red-600 hover:bg-red-200"
|
||||
: "bg-[#1A463C]/10 text-[#1A463C] hover:bg-[#1A463C]/20"
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-600"
|
||||
: "bg-foreground/10 text-foreground hover:bg-foreground/20"
|
||||
}`}
|
||||
>
|
||||
{isRunning ? (
|
||||
// Stop square
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 16 16">
|
||||
<rect x="3" y="3" width="10" height="10" rx="1.5" />
|
||||
// Pause bars
|
||||
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 16 16">
|
||||
<rect x="4" y="3" width="3" height="10" rx="1" />
|
||||
<rect x="9" y="3" width="3" height="10" rx="1" />
|
||||
</svg>
|
||||
) : (
|
||||
// Play triangle
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 16 16">
|
||||
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4 3.5v9l9-4.5-9-4.5z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span
|
||||
className={`text-xs tabular-nums font-mono ${
|
||||
isRunning ? "text-red-600 font-semibold" : "text-[#71717a]"
|
||||
}`}
|
||||
>
|
||||
{isRunning ? formatDuration(elapsed) : displayTotal}
|
||||
</span>
|
||||
{isRunning ? formatDuration(elapsed) : displayTotal}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -109,8 +109,8 @@ function ConversationListItem({
|
||||
href={`/admin/conversazioni?c=${conv.clientId}`}
|
||||
className={cn(
|
||||
"block p-4 transition-colors hover:bg-muted/40",
|
||||
active && "bg-muted/60 border-l-2 border-primary",
|
||||
!active && "border-l-2 border-transparent"
|
||||
active && "bg-muted/60 border-l-[3px] border-l-primary",
|
||||
!active && "border-l-[3px] border-l-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
||||
Reference in New Issue
Block a user