feat: brand color system + Kanban view (admin + client)

- Fix button contrast: add all missing shadcn tokens (primary-foreground,
  ring, input, muted, destructive) aligned to iamcavalli brand
- NavBar: #1A463C green bar with white text
- Login page: clean brand layout with iamcavalli wordmark
- Admin pages: brand colors on headings, borders, links
- Admin ClientRow: semantic payment badges (green/yellow/red)
- Admin phases tab: Lista ↔ Kanban toggle with @dnd-kit drag & drop
  between Da fare / In corso / Fatto columns (optimistic updates)
- Client dashboard: Timeline ↔ Kanban toggle, expandable task cards
  with approve button + comment form inline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simone Cavalli
2026-05-15 23:14:29 +02:00
parent 80d93993a9
commit 7af917fe80
13 changed files with 684 additions and 50 deletions
+6 -6
View File
@@ -4,11 +4,11 @@ import type { ClientWithPayments } from "@/lib/admin-queries";
const statusConfig: Record<
string,
{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }
{ label: string; className: string }
> = {
da_saldare: { label: "Da saldare", variant: "destructive" },
inviata: { label: "Inviata", variant: "secondary" },
saldato: { label: "Saldato", variant: "default" },
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" },
};
export function ClientRow({ client }: { client: ClientWithPayments }) {
@@ -34,14 +34,14 @@ export function ClientRow({ client }: { client: ClientWithPayments }) {
</td>
<td className="py-3 px-4">
{acconto && (
<Badge variant={statusConfig[acconto.status]?.variant ?? "outline"}>
<Badge className={statusConfig[acconto.status]?.className ?? "border-transparent bg-gray-100 text-gray-600"}>
Acconto: {statusConfig[acconto.status]?.label ?? acconto.status}
</Badge>
)}
</td>
<td className="py-3 px-4">
{saldo && (
<Badge variant={statusConfig[saldo.status]?.variant ?? "outline"}>
<Badge className={statusConfig[saldo.status]?.className ?? "border-transparent bg-gray-100 text-gray-600"}>
Saldo: {statusConfig[saldo.status]?.label ?? saldo.status}
</Badge>
)}
+4 -4
View File
@@ -6,12 +6,12 @@ import { Button } from "@/components/ui/button";
export function NavBar() {
return (
<nav className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
<nav className="bg-[#1A463C] px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-6">
<span className="font-semibold text-gray-900">ClientHub</span>
<span className="font-bold text-white tracking-tight">iamcavalli</span>
<Link
href="/admin"
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
className="text-sm text-white/70 hover:text-white transition-colors"
>
Clienti
</Link>
@@ -20,7 +20,7 @@ export function NavBar() {
variant="ghost"
size="sm"
onClick={() => signOut({ callbackUrl: "/admin/login" })}
className="text-sm text-gray-500"
className="text-sm text-white/70 hover:text-white hover:bg-white/10"
>
Esci
</Button>
+232
View File
@@ -0,0 +1,232 @@
"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 { updateTaskStatus } from "@/app/admin/clients/[id]/actions";
import type { ClientFullDetail } from "@/lib/admin-queries";
type Task = ClientFullDetail["phases"][number]["tasks"][number] & {
phaseTitle: string;
};
type Status = "todo" | "in_progress" | "done";
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] = [
{
id: "todo",
label: "Da fare",
headerClass: "text-[#71717a]",
dotClass: "bg-[#d4d4d8]",
},
{
id: "in_progress",
label: "In corso",
headerClass: "text-[#1A463C]",
dotClass: "bg-[#DEF168]",
},
{
id: "done",
label: "Fatto",
headerClass: "text-[#1A463C]",
dotClass: "bg-[#1A463C]",
},
];
function DroppableColumn({
id,
label,
headerClass,
dotClass,
tasks,
activeId,
}: {
id: Status;
label: string;
headerClass: string;
dotClass: string;
tasks: Task[];
activeId: string | null;
}) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
<div
ref={setNodeRef}
className={`flex flex-col rounded-xl border-2 transition-colors ${
isOver
? "border-[#1A463C] bg-[#1A463C]/5"
: "border-[#e5e7eb] bg-[#f9f9f9]"
} min-h-[240px]`}
>
<div className={`px-4 py-3 flex items-center justify-between ${headerClass}`}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${dotClass}`} />
<span className="text-xs font-bold uppercase tracking-wider">{label}</span>
</div>
<span className="text-xs font-semibold tabular-nums bg-white rounded-full px-2 py-0.5 border border-[#e5e7eb]">
{tasks.length}
</span>
</div>
<div className="flex-1 p-3 space-y-2">
{tasks.map((task) => (
<DraggableCard
key={task.id}
task={task}
isActive={activeId === task.id}
/>
))}
{tasks.length === 0 && (
<p className="text-xs text-[#d4d4d8] italic text-center py-10 select-none">
Nessun task
</p>
)}
</div>
</div>
);
}
function DraggableCard({
task,
isActive,
}: {
task: Task;
isActive: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({ id: task.id });
const style = transform
? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)` }
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...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 ${
isDragging ? "opacity-30" : "hover:border-[#1A463C]/40 hover:shadow"
}`}
>
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
{task.phaseTitle}
</p>
<p className="text-sm font-medium text-[#1a1a1a] leading-snug">{task.title}</p>
{task.description && (
<p className="text-xs text-[#71717a] mt-1 leading-snug line-clamp-2">
{task.description}
</p>
)}
</div>
);
}
export function KanbanBoard({
phases,
clientId,
}: {
phases: ClientFullDetail["phases"];
clientId: string;
}) {
const router = useRouter();
const [, startTransition] = useTransition();
const [activeId, setActiveId] = useState<string | null>(null);
const [taskStatuses, setTaskStatuses] = useState<Record<string, Status>>(
() => {
const map: Record<string, Status> = {};
for (const phase of phases) {
for (const task of phase.tasks) {
map[task.id] = task.status as Status;
}
}
return map;
}
);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor)
);
const allTasks: Task[] = phases.flatMap((phase) =>
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
);
const tasksByStatus = COLUMNS.reduce(
(acc, col) => {
acc[col.id] = allTasks.filter(
(t) => (taskStatuses[t.id] ?? t.status) === col.id
);
return acc;
},
{} as Record<Status, Task[]>
);
const activeTask = activeId ? allTasks.find((t) => t.id === activeId) : null;
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const taskId = active.id as string;
const newStatus = over.id as Status;
const currentStatus = taskStatuses[taskId];
if (newStatus === currentStatus) return;
if (!(["todo", "in_progress", "done"] as string[]).includes(newStatus))
return;
setTaskStatuses((prev) => ({ ...prev, [taskId]: newStatus }));
startTransition(async () => {
await updateTaskStatus(taskId, clientId, newStatus);
router.refresh();
});
}
return (
<DndContext
sensors={sensors}
onDragStart={(e) => setActiveId(e.active.id as string)}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-3 gap-4">
{COLUMNS.map((col) => (
<DroppableColumn
key={col.id}
{...col}
tasks={tasksByStatus[col.id]}
activeId={activeId}
/>
))}
</div>
<DragOverlay dropAnimation={null}>
{activeTask && (
<div className="bg-white rounded-lg border-2 border-[#1A463C] px-3 py-2.5 shadow-xl rotate-1 pointer-events-none">
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1">
{activeTask.phaseTitle}
</p>
<p className="text-sm font-medium text-[#1a1a1a]">
{activeTask.title}
</p>
</div>
)}
</DragOverlay>
</DndContext>
);
}
@@ -0,0 +1,50 @@
"use client";
import { useState, type ReactNode } from "react";
import { KanbanBoard } from "./KanbanBoard";
import type { ClientFullDetail } from "@/lib/admin-queries";
export function PhasesViewToggle({
listView,
phases,
clientId,
}: {
listView: ReactNode;
phases: ClientFullDetail["phases"];
clientId: string;
}) {
const [view, setView] = useState<"list" | "kanban">("list");
return (
<div>
<div className="flex items-center gap-1 mb-5 bg-[#f4f4f5] rounded-lg p-1 w-fit">
<button
onClick={() => setView("list")}
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
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>
{view === "list" ? (
listView
) : (
<KanbanBoard phases={phases} clientId={clientId} />
)}
</div>
);
}
+8 -3
View File
@@ -5,6 +5,7 @@ import { PhaseTimeline } from './phase-timeline';
import { PaymentStatus } from './payment-status';
import { DocumentsSection } from './documents-section';
import { NotesSection } from './notes-section';
import { PhaseViewToggle } from './client/kanban/PhaseViewToggle';
interface ClientDashboardProps {
view: ClientView;
@@ -57,10 +58,14 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
</section>
)}
{/* Timeline fasi — now with interactive ApproveButton + CommentForm/List */}
{/* Fasi — toggle timeline/kanban */}
<section>
<h2 className="text-xl font-bold text-[#1a1a1a] mb-6">Fasi del Progetto</h2>
<PhaseTimeline phases={view.phases} token={token} comments={comments} />
<PhaseViewToggle
timelineView={<PhaseTimeline phases={view.phases} token={token} comments={comments} />}
phases={view.phases}
token={token}
comments={comments}
/>
</section>
{/* Stato pagamenti — sempre visibile (D-10) */}
@@ -0,0 +1,189 @@
"use client";
import { useState } from "react";
import type { ClientView } from "@/lib/client-view";
import type { Comment } from "@/db/schema";
import { ApproveButton } from "@/components/client/ApproveButton";
import { CommentList } from "@/components/client/CommentList";
import { CommentForm } from "@/components/client/CommentForm";
type Task = ClientView["phases"][number]["tasks"][number] & {
phaseTitle: string;
};
const COLUMNS: { id: "todo" | "in_progress" | "done"; label: string; dotClass: string; headerClass: string }[] = [
{
id: "todo",
label: "Da fare",
dotClass: "bg-[#d4d4d8]",
headerClass: "text-[#71717a]",
},
{
id: "in_progress",
label: "In corso",
dotClass: "bg-[#DEF168]",
headerClass: "text-[#1A463C]",
},
{
id: "done",
label: "Fatto",
dotClass: "bg-[#1A463C]",
headerClass: "text-[#1A463C]",
},
];
function TaskCard({
task,
token,
comments,
}: {
task: Task;
token: string;
comments: Comment[];
}) {
const [expanded, setExpanded] = useState(false);
const taskComments = comments.filter((c) => c.entity_id === task.id);
const hasDeliverables = task.deliverables.length > 0;
const hasActivity = taskComments.length > 0 || hasDeliverables;
return (
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden shadow-sm">
<button
onClick={() => setExpanded((v) => !v)}
className="w-full text-left px-3 py-2.5 hover:bg-[#f9f9f9] transition-colors"
>
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
{task.phaseTitle}
</p>
<div className="flex items-start justify-between gap-2">
<p
className={`text-sm font-medium leading-snug ${
task.status === "done"
? "line-through text-[#71717a]"
: "text-[#1a1a1a]"
}`}
>
{task.title}
</p>
{hasActivity && (
<span className="text-[10px] text-[#71717a] mt-0.5 shrink-0">
{expanded ? "▲" : "▼"}
</span>
)}
</div>
{task.description && !expanded && (
<p className="text-xs text-[#71717a] mt-0.5 leading-snug line-clamp-2">
{task.description}
</p>
)}
</button>
{expanded && (
<div className="border-t border-[#f4f4f5] px-3 pb-3 pt-2 space-y-3">
{task.description && (
<p className="text-xs text-[#71717a] leading-snug">{task.description}</p>
)}
{hasDeliverables && (
<div className="space-y-2">
{task.deliverables.map((d) => {
const delivComments = comments.filter((c) => c.entity_id === d.id);
return (
<div
key={d.id}
className="bg-[#f9f9f9] rounded-lg px-3 py-2 space-y-2"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-[#1a1a1a] truncate">
{d.title}
</span>
{(d.status === "pending" ||
d.status === "submitted" ||
d.approved_at !== null) && (
<ApproveButton
deliverableId={d.id}
token={token}
approvedAt={d.approved_at}
/>
)}
</div>
<CommentList comments={delivComments} />
<CommentForm
token={token}
entityType="deliverable"
entityId={d.id}
/>
</div>
);
})}
</div>
)}
<CommentList comments={taskComments} />
<CommentForm token={token} entityType="task" entityId={task.id} />
</div>
)}
</div>
);
}
export function ClientKanban({
phases,
token,
comments,
}: {
phases: ClientView["phases"];
token: string;
comments: Comment[];
}) {
const allTasks: Task[] = phases.flatMap((phase) =>
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
);
const tasksByStatus = {
todo: allTasks.filter((t) => t.status === "todo"),
in_progress: allTasks.filter((t) => t.status === "in_progress"),
done: allTasks.filter((t) => t.status === "done"),
};
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{COLUMNS.map((col) => (
<div
key={col.id}
className="flex flex-col rounded-xl border border-[#e5e7eb] bg-[#f9f9f9] min-h-[200px]"
>
<div
className={`px-4 py-3 flex items-center justify-between ${col.headerClass}`}
>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${col.dotClass}`} />
<span className="text-xs font-bold uppercase tracking-wider">
{col.label}
</span>
</div>
<span className="text-xs font-semibold tabular-nums bg-white rounded-full px-2 py-0.5 border border-[#e5e7eb]">
{tasksByStatus[col.id].length}
</span>
</div>
<div className="flex-1 p-3 space-y-2">
{tasksByStatus[col.id].map((task) => (
<TaskCard
key={task.id}
task={task}
token={token}
comments={comments}
/>
))}
{tasksByStatus[col.id].length === 0 && (
<p className="text-xs text-[#d4d4d8] italic text-center py-10">
Nessun task
</p>
)}
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useState, type ReactNode } from "react";
import { ClientKanban } from "./ClientKanban";
import type { ClientView } from "@/lib/client-view";
import type { Comment } from "@/db/schema";
export function PhaseViewToggle({
timelineView,
phases,
token,
comments,
}: {
timelineView: ReactNode;
phases: ClientView["phases"];
token: string;
comments: Comment[];
}) {
const [view, setView] = useState<"timeline" | "kanban">("timeline");
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-[#1a1a1a]">Fasi del Progetto</h2>
<div className="flex items-center gap-1 bg-[#f4f4f5] rounded-lg p-1">
<button
onClick={() => setView("timeline")}
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-all ${
view === "timeline"
? "bg-white text-[#1A463C] shadow-sm"
: "text-[#71717a] hover:text-[#1a1a1a]"
}`}
>
Timeline
</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>
{view === "timeline" ? (
timelineView
) : (
<ClientKanban phases={phases} token={token} comments={comments} />
)}
</div>
);
}