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:
Generated
+56
@@ -8,6 +8,9 @@
|
|||||||
"name": "clienthub",
|
"name": "clienthub",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
@@ -303,6 +306,59 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@drizzle-team/brocli": {
|
"node_modules/@drizzle-team/brocli": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PhasesTab } from "@/components/admin/tabs/PhasesTab";
|
|||||||
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab";
|
||||||
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
||||||
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
import { CommentsTab } from "@/components/admin/tabs/CommentsTab";
|
||||||
|
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
@@ -51,7 +52,11 @@ export default async function ClientDetailPage({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="phases">
|
<TabsContent value="phases">
|
||||||
<PhasesTab phases={phases} clientId={client.id} />
|
<PhasesViewToggle
|
||||||
|
listView={<PhasesTab phases={phases} clientId={client.id} />}
|
||||||
|
phases={phases}
|
||||||
|
clientId={client.id}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="payments">
|
<TabsContent value="payments">
|
||||||
<PaymentsTab
|
<PaymentsTab
|
||||||
|
|||||||
@@ -74,17 +74,23 @@ function AdminLoginForm() {
|
|||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
<div className="min-h-screen flex items-center justify-center bg-[#f9f9f9]">
|
||||||
<Card className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
<CardHeader>
|
<div className="text-center mb-8">
|
||||||
<CardTitle className="text-xl">Admin — ClientHub</CardTitle>
|
<span className="text-2xl font-bold text-[#1A463C] tracking-tight">iamcavalli</span>
|
||||||
|
<p className="text-sm text-[#71717a] mt-1">Area riservata</p>
|
||||||
|
</div>
|
||||||
|
<Card className="border border-[#e5e7eb] shadow-sm">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-base font-semibold text-[#1a1a1a]">Accedi</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Suspense fallback={<div className="text-sm text-gray-500">Caricamento...</div>}>
|
<Suspense fallback={<div className="text-sm text-[#71717a]">Caricamento...</div>}>
|
||||||
<AdminLoginForm />
|
<AdminLoginForm />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -11,42 +11,42 @@ export default async function AdminDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Clienti</h1>
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
|
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{clients.length === 0 ? (
|
{clients.length === 0 ? (
|
||||||
<div className="text-center py-20 text-gray-400">
|
<div className="text-center py-20 text-[#71717a]">
|
||||||
<p>Nessun cliente ancora.</p>
|
<p>Nessun cliente ancora.</p>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
<Link
|
<Link
|
||||||
href="/admin/clients/new"
|
href="/admin/clients/new"
|
||||||
className="text-blue-600 hover:underline"
|
className="text-[#1A463C] hover:underline font-medium"
|
||||||
>
|
>
|
||||||
Crea il primo cliente
|
Crea il primo cliente
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left py-3 px-4 font-medium text-gray-600">
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">
|
||||||
Cliente
|
Cliente
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left py-3 px-4 font-medium text-gray-600">
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">
|
||||||
Totale
|
Totale
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left py-3 px-4 font-medium text-gray-600">
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">
|
||||||
Acconto
|
Acconto
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left py-3 px-4 font-medium text-gray-600">
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">
|
||||||
Saldo
|
Saldo
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left py-3 px-4 font-medium text-gray-600">
|
<th className="text-left py-3 px-4 font-medium text-[#71717a]">
|
||||||
Link
|
Link
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+47
-15
@@ -4,28 +4,60 @@
|
|||||||
@source not "../../scripts/**";
|
@source not "../../scripts/**";
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
Design tokens — light & clean palette (Tailwind v4 @theme)
|
Design tokens — iamcavalli brand palette (Tailwind v4 @theme)
|
||||||
========================================================= */
|
========================================================= */
|
||||||
@theme inline {
|
@theme inline {
|
||||||
/* Colori base */
|
|
||||||
--color-background: #ffffff;
|
|
||||||
--color-foreground: #171717;
|
|
||||||
|
|
||||||
/* Font */
|
/* Font */
|
||||||
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont,
|
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
"Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
"Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
|
|
||||||
/* Palette brand iamcavalli — light & clean */
|
/* Base */
|
||||||
--color-primary: #1a1a1a; /* charcoal per testo principale */
|
--color-background: #ffffff;
|
||||||
--color-secondary: #666666; /* grigio medio per testo secondario */
|
--color-foreground: #1a1a1a;
|
||||||
--color-tertiary: #999999; /* grigio chiaro per hint e timestamp */
|
|
||||||
--color-bg-subtle: #f9f9f9; /* grigio molto chiaro per sfondi sezione */
|
/* Brand primary — verde scuro iamcavalli */
|
||||||
--color-border-light: #e5e5e5; /* bordo sottile */
|
--color-primary: #1A463C;
|
||||||
--color-accent: #0066cc; /* blu accent */
|
--color-primary-foreground: #ffffff;
|
||||||
--color-success: #16a34a; /* verde per done/saldato */
|
|
||||||
--color-warning: #ca8a04; /* giallo per in_progress/inviata */
|
/* Brand accent — giallo lime */
|
||||||
--color-info: #2563eb; /* blu per pending/da_saldare */
|
--color-accent: #DEF168;
|
||||||
|
--color-accent-foreground: #1A463C;
|
||||||
|
|
||||||
|
/* Secondary — grigio neutro */
|
||||||
|
--color-secondary: #f4f4f5;
|
||||||
|
--color-secondary-foreground: #1a1a1a;
|
||||||
|
|
||||||
|
/* Muted — per testi e sfondi secondari */
|
||||||
|
--color-muted: #f9f9f9;
|
||||||
|
--color-muted-foreground: #71717a;
|
||||||
|
|
||||||
|
/* Destructive */
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #1a1a1a;
|
||||||
|
|
||||||
|
/* Popover */
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #1a1a1a;
|
||||||
|
|
||||||
|
/* Border / Input / Ring */
|
||||||
|
--color-border: #e5e7eb;
|
||||||
|
--color-input: #e5e7eb;
|
||||||
|
--color-ring: #1A463C;
|
||||||
|
|
||||||
|
/* Semantic — stato task/pagamenti */
|
||||||
|
--color-success: #16a34a;
|
||||||
|
--color-warning: #ca8a04;
|
||||||
|
--color-info: #2563eb;
|
||||||
|
|
||||||
|
/* Legacy — usati inline nei componenti esistenti */
|
||||||
|
--color-tertiary: #999999;
|
||||||
|
--color-bg-subtle: #f9f9f9;
|
||||||
|
--color-border-light: #e5e5e5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import type { ClientWithPayments } from "@/lib/admin-queries";
|
|||||||
|
|
||||||
const statusConfig: Record<
|
const statusConfig: Record<
|
||||||
string,
|
string,
|
||||||
{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }
|
{ label: string; className: string }
|
||||||
> = {
|
> = {
|
||||||
da_saldare: { label: "Da saldare", variant: "destructive" },
|
da_saldare: { label: "Da saldare", className: "bg-red-100 text-red-700 border-transparent" },
|
||||||
inviata: { label: "Inviata", variant: "secondary" },
|
inviata: { label: "Inviata", className: "bg-[#DEF168]/30 text-[#1A463C] border-transparent" },
|
||||||
saldato: { label: "Saldato", variant: "default" },
|
saldato: { label: "Saldato", className: "bg-[#1A463C]/10 text-[#1A463C] border-transparent font-medium" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ClientRow({ client }: { client: ClientWithPayments }) {
|
export function ClientRow({ client }: { client: ClientWithPayments }) {
|
||||||
@@ -34,14 +34,14 @@ export function ClientRow({ client }: { client: ClientWithPayments }) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
{acconto && (
|
{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}
|
Acconto: {statusConfig[acconto.status]?.label ?? acconto.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
{saldo && (
|
{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}
|
Saldo: {statusConfig[saldo.status]?.label ?? saldo.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import { Button } from "@/components/ui/button";
|
|||||||
|
|
||||||
export function NavBar() {
|
export function NavBar() {
|
||||||
return (
|
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">
|
<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
|
<Link
|
||||||
href="/admin"
|
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
|
Clienti
|
||||||
</Link>
|
</Link>
|
||||||
@@ -20,7 +20,7 @@ export function NavBar() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => signOut({ callbackUrl: "/admin/login" })}
|
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
|
Esci
|
||||||
</Button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { PhaseTimeline } from './phase-timeline';
|
|||||||
import { PaymentStatus } from './payment-status';
|
import { PaymentStatus } from './payment-status';
|
||||||
import { DocumentsSection } from './documents-section';
|
import { DocumentsSection } from './documents-section';
|
||||||
import { NotesSection } from './notes-section';
|
import { NotesSection } from './notes-section';
|
||||||
|
import { PhaseViewToggle } from './client/kanban/PhaseViewToggle';
|
||||||
|
|
||||||
interface ClientDashboardProps {
|
interface ClientDashboardProps {
|
||||||
view: ClientView;
|
view: ClientView;
|
||||||
@@ -57,10 +58,14 @@ export function ClientDashboard({ view, token, comments }: ClientDashboardProps)
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Timeline fasi — now with interactive ApproveButton + CommentForm/List */}
|
{/* Fasi — toggle timeline/kanban */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-bold text-[#1a1a1a] mb-6">Fasi del Progetto</h2>
|
<PhaseViewToggle
|
||||||
<PhaseTimeline phases={view.phases} token={token} comments={comments} />
|
timelineView={<PhaseTimeline phases={view.phases} token={token} comments={comments} />}
|
||||||
|
phases={view.phases}
|
||||||
|
token={token}
|
||||||
|
comments={comments}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Stato pagamenti — sempre visibile (D-10) */}
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user