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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user