sidebar e navigazione; CRUD: table, dialog, badge; Toast per feedback operazioni
This commit is contained in:
parent
ea7f5d18f1
commit
6c5c86fcb4
22 changed files with 2379 additions and 21 deletions
32
package-lock.json
generated
32
package-lock.json
generated
|
|
@ -12,9 +12,12 @@
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.21.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.18.0",
|
"react-router-dom": "^7.18.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.1"
|
"tailwindcss": "^4.3.1"
|
||||||
},
|
},
|
||||||
|
|
@ -2542,6 +2545,15 @@
|
||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
|
||||||
|
"integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|
@ -2599,6 +2611,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/next-themes": {
|
||||||
|
"version": "0.4.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||||
|
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.48",
|
"version": "2.0.48",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
|
||||||
|
|
@ -2888,6 +2910,16 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sonner": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,12 @@
|
||||||
"@tailwindcss/vite": "^4.3.1",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.21.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.18.0",
|
"react-router-dom": "^7.18.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss": "^4.3.1"
|
"tailwindcss": "^4.3.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
21
src/App.tsx
21
src/App.tsx
|
|
@ -1,9 +1,14 @@
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
import GuestRoute from "./components/GuestRoute";
|
import GuestRoute from "./components/GuestRoute";
|
||||||
import ProtectedRoute from "./components/ProtectedRoute";
|
import ProtectedRoute from "./components/ProtectedRoute";
|
||||||
|
import DashboardLayout from "./components/layout/DashboardLayout";
|
||||||
|
import { Toaster } from "./components/ui/sonner";
|
||||||
import { AuthProvider } from "./context/AuthContext";
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
import ClientiPage from "./pages/ClientiPage";
|
||||||
|
import ConsegnePage from "./pages/ConsegnePage";
|
||||||
import Dashboard from "./pages/Dashboard";
|
import Dashboard from "./pages/Dashboard";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
|
import UtentiPage from "./pages/UtentiPage";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -12,17 +17,29 @@ export default function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<GuestRoute><Login /></GuestRoute>} />
|
<Route path="/login" element={<GuestRoute><Login /></GuestRoute>} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Dashboard />
|
<DashboardLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
|
<Route path="/clienti" element={<ClientiPage />} />
|
||||||
|
<Route path="/consegne" element={<ConsegnePage />} />
|
||||||
|
<Route
|
||||||
|
path="/utenti"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute adminOnly>
|
||||||
|
<UtentiPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</Route>
|
||||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
<Toaster />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
src/components/ConfirmDeleteDialog.tsx
Normal file
50
src/components/ConfirmDeleteDialog.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
export default function ConfirmDeleteDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onConfirm,
|
||||||
|
loading = false,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={loading}>Annulla</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onConfirm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Eliminazione..." : "Elimina"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/FormField.tsx
Normal file
24
src/components/FormField.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function FormField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/components/StatoConsegnaBadge.tsx
Normal file
16
src/components/StatoConsegnaBadge.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { StatoConsegna } from "@/types";
|
||||||
|
|
||||||
|
const statoVariant: Record<
|
||||||
|
StatoConsegna,
|
||||||
|
"default" | "secondary" | "outline" | "destructive"
|
||||||
|
> = {
|
||||||
|
"da ritirare": "secondary",
|
||||||
|
"in consegna": "default",
|
||||||
|
"in giacenza": "outline",
|
||||||
|
"in deposito": "secondary",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatoConsegnaBadge({ stato }: { stato: StatoConsegna }) {
|
||||||
|
return <Badge variant={statoVariant[stato]}>{stato}</Badge>;
|
||||||
|
}
|
||||||
67
src/components/layout/AppSidebar.tsx
Normal file
67
src/components/layout/AppSidebar.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { LayoutDashboard, LogOut, Package, UserCog, Users } from "lucide-react";
|
||||||
|
import { NavLink } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ to: "/clienti", label: "Clienti", icon: Users },
|
||||||
|
{ to: "/consegne", label: "Consegne", icon: Package },
|
||||||
|
{ to: "/utenti", label: "Utenti", icon: UserCog, adminOnly: true },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function AppSidebar() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const isAdmin = user?.ruolo === "Amministratore";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex w-60 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground">
|
||||||
|
<div className="px-5 py-6">
|
||||||
|
<p className="text-lg font-semibold">Gestione Corrieri</p>
|
||||||
|
<p className="mt-1 text-xs text-sidebar-foreground/70">Pannello operatore</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-sidebar-border" />
|
||||||
|
|
||||||
|
<nav className="flex flex-1 flex-col gap-1 p-3">
|
||||||
|
{navItems
|
||||||
|
.filter((item) => !("adminOnly" in item && item.adminOnly) || isAdmin)
|
||||||
|
.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||||
|
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 shrink-0" />
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-sidebar-border p-4">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{user?.nome} {user?.cognome}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-sidebar-foreground/70">{user?.email}</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3 w-full justify-start gap-2 text-sidebar-foreground hover:bg-sidebar-accent/60"
|
||||||
|
onClick={() => logout()}
|
||||||
|
>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
Esci
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/layout/DashboardLayout.tsx
Normal file
15
src/components/layout/DashboardLayout.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import AppSidebar from "./AppSidebar";
|
||||||
|
|
||||||
|
export default function DashboardLayout() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh bg-background">
|
||||||
|
<AppSidebar />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<main className="flex-1 overflow-auto p-6 md:p-8">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
src/components/ui/alert-dialog.tsx
Normal file
185
src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Backdrop
|
||||||
|
data-slot="alert-dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Popup.Props & {
|
||||||
|
size?: "default" | "sm"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Popup
|
||||||
|
data-slot="alert-dialog-content"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-header"
|
||||||
|
className={cn(
|
||||||
|
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogMedia({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-dialog-media"
|
||||||
|
className={cn(
|
||||||
|
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot="alert-dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot="alert-dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-slot="alert-dialog-action"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AlertDialogPrimitive.Close.Props &
|
||||||
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Close
|
||||||
|
data-slot="alert-dialog-cancel"
|
||||||
|
className={cn(className)}
|
||||||
|
render={<Button variant={variant} size={size} />}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogMedia,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
}
|
||||||
52
src/components/ui/badge.tsx
Normal file
52
src/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
160
src/components/ui/dialog.tsx
Normal file
160
src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
268
src/components/ui/dropdown-menu.tsx
Normal file
268
src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
201
src/components/ui/select.tsx
Normal file
201
src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
23
src/components/ui/separator.tsx
Normal file
23
src/components/ui/separator.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
16
src/components/ui/sonner.tsx
Normal file
16
src/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Toaster as Sonner } from "sonner";
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
position="top-right"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/ui/table.tsx
Normal file
114
src/components/ui/table.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
13
src/lib/errors.ts
Normal file
13
src/lib/errors.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { ApiError } from "@/api/client";
|
||||||
|
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof ApiError) return error.message;
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return "Errore imprevisto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeFieldErrors(
|
||||||
|
...sources: Array<Record<string, string> | undefined>
|
||||||
|
): Record<string, string> {
|
||||||
|
return Object.assign({}, ...sources.filter(Boolean));
|
||||||
|
}
|
||||||
335
src/pages/ClientiPage.tsx
Normal file
335
src/pages/ClientiPage.tsx
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
createCliente,
|
||||||
|
deleteCliente,
|
||||||
|
getClienti,
|
||||||
|
updateCliente,
|
||||||
|
ApiError,
|
||||||
|
} from "@/api/client";
|
||||||
|
import ConfirmDeleteDialog from "@/components/ConfirmDeleteDialog";
|
||||||
|
import FormField from "@/components/FormField";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { getErrorMessage, mergeFieldErrors } from "@/lib/errors";
|
||||||
|
import type { Cliente } from "@/types";
|
||||||
|
import { CLIENTE_CONSTRAINTS } from "@/validation/constraints";
|
||||||
|
import {
|
||||||
|
validateClienteCreate,
|
||||||
|
validateClienteUpdate,
|
||||||
|
} from "@/validation/cliente";
|
||||||
|
|
||||||
|
type ClienteFormState = {
|
||||||
|
nome: string;
|
||||||
|
cognome: string;
|
||||||
|
via: string;
|
||||||
|
comune: string;
|
||||||
|
provincia: string;
|
||||||
|
telefono: string;
|
||||||
|
email: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = (): ClienteFormState => ({
|
||||||
|
nome: "",
|
||||||
|
cognome: "",
|
||||||
|
via: "",
|
||||||
|
comune: "",
|
||||||
|
provincia: "",
|
||||||
|
telefono: "",
|
||||||
|
email: "",
|
||||||
|
note: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
function toFormState(cliente: Cliente): ClienteFormState {
|
||||||
|
return {
|
||||||
|
nome: cliente.nome,
|
||||||
|
cognome: cliente.cognome,
|
||||||
|
via: cliente.via,
|
||||||
|
comune: cliente.comune,
|
||||||
|
provincia: cliente.provincia,
|
||||||
|
telefono: cliente.telefono,
|
||||||
|
email: cliente.email,
|
||||||
|
note: cliente.note ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientiPage() {
|
||||||
|
const [clienti, setClienti] = useState<Cliente[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Cliente | null>(null);
|
||||||
|
const [form, setForm] = useState<ClienteFormState>(emptyForm);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Cliente | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const loadClienti = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setClienti(await getClienti());
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadClienti();
|
||||||
|
}, [loadClienti]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setFieldErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (cliente: Cliente) => {
|
||||||
|
setEditing(cliente);
|
||||||
|
setForm(toFormState(cliente));
|
||||||
|
setFieldErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (field: keyof ClienteFormState, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
|
const raw = { ...form, note: form.note || null };
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
const validation = validateClienteUpdate(raw);
|
||||||
|
if (!validation.success) {
|
||||||
|
setFieldErrors(validation.errors);
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateCliente(editing.id, validation.data);
|
||||||
|
toast.success("Cliente aggiornato");
|
||||||
|
} else {
|
||||||
|
const validation = validateClienteCreate(raw);
|
||||||
|
if (!validation.success) {
|
||||||
|
setFieldErrors(validation.errors);
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createCliente(validation.data);
|
||||||
|
toast.success("Cliente creato");
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
await loadClienti();
|
||||||
|
} catch (error) {
|
||||||
|
setFieldErrors(
|
||||||
|
mergeFieldErrors(error instanceof ApiError ? error.fields : undefined)
|
||||||
|
);
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteCliente(deleteTarget.id);
|
||||||
|
toast.success("Cliente eliminato");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await loadClienti();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Clienti</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Gestisci l'anagrafica clienti
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>Nuovo cliente</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted-foreground">Caricamento...</p>
|
||||||
|
) : clienti.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">Nessun cliente registrato.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nome</TableHead>
|
||||||
|
<TableHead>Comune</TableHead>
|
||||||
|
<TableHead>Telefono</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead className="text-right">Azioni</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{clienti.map((cliente) => (
|
||||||
|
<TableRow key={cliente.id}>
|
||||||
|
<TableCell>
|
||||||
|
{cliente.nome} {cliente.cognome}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{cliente.comune} ({cliente.provincia})
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{cliente.telefono}</TableCell>
|
||||||
|
<TableCell>{cliente.email}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openEdit(cliente)}>
|
||||||
|
Modifica
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteTarget(cliente)}
|
||||||
|
>
|
||||||
|
Elimina
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? "Modifica cliente" : "Nuovo cliente"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||||
|
{fieldErrors._form && (
|
||||||
|
<p className="text-sm text-destructive">{fieldErrors._form}</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField id="nome" label="Nome" error={fieldErrors.nome}>
|
||||||
|
<Input
|
||||||
|
id="nome"
|
||||||
|
value={form.nome}
|
||||||
|
onChange={(e) => updateField("nome", e.target.value)}
|
||||||
|
maxLength={CLIENTE_CONSTRAINTS.nome.maxLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField id="cognome" label="Cognome" error={fieldErrors.cognome}>
|
||||||
|
<Input
|
||||||
|
id="cognome"
|
||||||
|
value={form.cognome}
|
||||||
|
onChange={(e) => updateField("cognome", e.target.value)}
|
||||||
|
maxLength={CLIENTE_CONSTRAINTS.cognome.maxLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormField id="via" label="Via" error={fieldErrors.via}>
|
||||||
|
<Input
|
||||||
|
id="via"
|
||||||
|
value={form.via}
|
||||||
|
onChange={(e) => updateField("via", e.target.value)}
|
||||||
|
maxLength={CLIENTE_CONSTRAINTS.via.maxLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<FormField id="comune" label="Comune" error={fieldErrors.comune} className="sm:col-span-2">
|
||||||
|
<Input
|
||||||
|
id="comune"
|
||||||
|
value={form.comune}
|
||||||
|
onChange={(e) => updateField("comune", e.target.value)}
|
||||||
|
maxLength={CLIENTE_CONSTRAINTS.comune.maxLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField id="provincia" label="Provincia" error={fieldErrors.provincia}>
|
||||||
|
<Input
|
||||||
|
id="provincia"
|
||||||
|
value={form.provincia}
|
||||||
|
onChange={(e) => updateField("provincia", e.target.value.toUpperCase())}
|
||||||
|
maxLength={CLIENTE_CONSTRAINTS.provincia.maxLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField id="telefono" label="Telefono" error={fieldErrors.telefono}>
|
||||||
|
<Input
|
||||||
|
id="telefono"
|
||||||
|
value={form.telefono}
|
||||||
|
onChange={(e) => updateField("telefono", e.target.value)}
|
||||||
|
maxLength={CLIENTE_CONSTRAINTS.telefono.maxLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField id="email" label="Email" error={fieldErrors.email}>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => updateField("email", e.target.value)}
|
||||||
|
maxLength={CLIENTE_CONSTRAINTS.email.maxLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormField id="note" label="Note" error={fieldErrors.note}>
|
||||||
|
<Textarea
|
||||||
|
id="note"
|
||||||
|
value={form.note}
|
||||||
|
onChange={(e) => updateField("note", e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
Annulla
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? "Salvataggio..." : "Salva"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
|
title="Elimina cliente"
|
||||||
|
description={`Confermi l'eliminazione di ${deleteTarget?.nome} ${deleteTarget?.cognome}?`}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
loading={deleting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
371
src/pages/ConsegnePage.tsx
Normal file
371
src/pages/ConsegnePage.tsx
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
createConsegna,
|
||||||
|
deleteConsegna,
|
||||||
|
getClienti,
|
||||||
|
getConsegne,
|
||||||
|
updateConsegna,
|
||||||
|
} from "@/api/client";
|
||||||
|
import ConfirmDeleteDialog from "@/components/ConfirmDeleteDialog";
|
||||||
|
import FormField from "@/components/FormField";
|
||||||
|
import StatoConsegnaBadge from "@/components/StatoConsegnaBadge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { getErrorMessage, mergeFieldErrors } from "@/lib/errors";
|
||||||
|
import type { Cliente, Consegna, StatoConsegna } from "@/types";
|
||||||
|
import { CONSEGNA_CONSTRAINTS } from "@/validation/constraints";
|
||||||
|
import {
|
||||||
|
validateConsegnaCreate,
|
||||||
|
validateConsegnaUpdate,
|
||||||
|
} from "@/validation/consegna";
|
||||||
|
|
||||||
|
type ConsegnaFormState = {
|
||||||
|
clienteId: string;
|
||||||
|
dataRitiro: string;
|
||||||
|
dataConsegna: string;
|
||||||
|
stato: StatoConsegna;
|
||||||
|
chiaveConsegna: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function todayIso(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm = (): ConsegnaFormState => ({
|
||||||
|
clienteId: "",
|
||||||
|
dataRitiro: todayIso(),
|
||||||
|
dataConsegna: todayIso(),
|
||||||
|
stato: "in deposito",
|
||||||
|
chiaveConsegna: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
function toFormState(consegna: Consegna): ConsegnaFormState {
|
||||||
|
return {
|
||||||
|
clienteId: String(consegna.clienteId),
|
||||||
|
dataRitiro: consegna.dataRitiro,
|
||||||
|
dataConsegna: consegna.dataConsegna,
|
||||||
|
stato: consegna.stato,
|
||||||
|
chiaveConsegna: consegna.chiaveConsegna,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
const [y, m, d] = value.split("-");
|
||||||
|
return `${d}/${m}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConsegnePage() {
|
||||||
|
const [consegne, setConsegne] = useState<Consegna[]>([]);
|
||||||
|
const [clienti, setClienti] = useState<Cliente[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<Consegna | null>(null);
|
||||||
|
const [form, setForm] = useState<ConsegnaFormState>(emptyForm);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Consegna | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [consegneData, clientiData] = await Promise.all([
|
||||||
|
getConsegne(),
|
||||||
|
getClienti(),
|
||||||
|
]);
|
||||||
|
setConsegne(consegneData);
|
||||||
|
setClienti(clientiData);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setFieldErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (consegna: Consegna) => {
|
||||||
|
setEditing(consegna);
|
||||||
|
setForm(toFormState(consegna));
|
||||||
|
setFieldErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
|
const raw = {
|
||||||
|
clienteId: form.clienteId,
|
||||||
|
dataRitiro: form.dataRitiro,
|
||||||
|
dataConsegna: form.dataConsegna,
|
||||||
|
stato: form.stato,
|
||||||
|
chiaveConsegna: form.chiaveConsegna,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
const validation = validateConsegnaUpdate(raw);
|
||||||
|
if (!validation.success) {
|
||||||
|
setFieldErrors(validation.errors);
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateConsegna(editing.id, validation.data);
|
||||||
|
toast.success("Consegna aggiornata");
|
||||||
|
} else {
|
||||||
|
const validation = validateConsegnaCreate(raw);
|
||||||
|
if (!validation.success) {
|
||||||
|
setFieldErrors(validation.errors);
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createConsegna(validation.data);
|
||||||
|
toast.success("Consegna creata");
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
setFieldErrors(
|
||||||
|
mergeFieldErrors(error instanceof ApiError ? error.fields : undefined)
|
||||||
|
);
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteConsegna(deleteTarget.id);
|
||||||
|
toast.success("Consegna eliminata");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await loadData();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Consegne</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Gestisci ritiri e consegne
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate} disabled={clienti.length === 0}>
|
||||||
|
Nuova consegna
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{clienti.length === 0 && !loading && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Crea almeno un cliente prima di registrare una consegna.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted-foreground">Caricamento...</p>
|
||||||
|
) : consegne.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">Nessuna consegna registrata.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Cliente</TableHead>
|
||||||
|
<TableHead>Chiave</TableHead>
|
||||||
|
<TableHead>Ritiro</TableHead>
|
||||||
|
<TableHead>Consegna</TableHead>
|
||||||
|
<TableHead>Stato</TableHead>
|
||||||
|
<TableHead className="text-right">Azioni</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{consegne.map((consegna) => (
|
||||||
|
<TableRow key={consegna.id}>
|
||||||
|
<TableCell>
|
||||||
|
{consegna.clienteNome} {consegna.clienteCognome}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono">{consegna.chiaveConsegna}</TableCell>
|
||||||
|
<TableCell>{formatDate(consegna.dataRitiro)}</TableCell>
|
||||||
|
<TableCell>{formatDate(consegna.dataConsegna)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatoConsegnaBadge stato={consegna.stato} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openEdit(consegna)}>
|
||||||
|
Modifica
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteTarget(consegna)}
|
||||||
|
>
|
||||||
|
Elimina
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? "Modifica consegna" : "Nuova consegna"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||||
|
{fieldErrors._form && (
|
||||||
|
<p className="text-sm text-destructive">{fieldErrors._form}</p>
|
||||||
|
)}
|
||||||
|
<FormField id="clienteId" label="Cliente" error={fieldErrors.clienteId}>
|
||||||
|
<Select
|
||||||
|
value={form.clienteId}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setForm((prev) => ({ ...prev, clienteId: value ?? "" }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Seleziona cliente" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{clienti.map((cliente) => (
|
||||||
|
<SelectItem key={cliente.id} value={String(cliente.id)}>
|
||||||
|
{cliente.nome} {cliente.cognome}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField id="dataRitiro" label="Data ritiro" error={fieldErrors.dataRitiro}>
|
||||||
|
<Input
|
||||||
|
id="dataRitiro"
|
||||||
|
type="date"
|
||||||
|
value={form.dataRitiro}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, dataRitiro: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField id="dataConsegna" label="Data consegna" error={fieldErrors.dataConsegna}>
|
||||||
|
<Input
|
||||||
|
id="dataConsegna"
|
||||||
|
type="date"
|
||||||
|
value={form.dataConsegna}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({ ...prev, dataConsegna: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormField id="stato" label="Stato" error={fieldErrors.stato}>
|
||||||
|
<Select
|
||||||
|
value={form.stato}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
stato: (value ?? "in deposito") as StatoConsegna,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CONSEGNA_CONSTRAINTS.stato.allowedValues.map((stato) => (
|
||||||
|
<SelectItem key={stato} value={stato}>
|
||||||
|
{stato}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
id="chiaveConsegna"
|
||||||
|
label="Chiave consegna"
|
||||||
|
error={fieldErrors.chiaveConsegna}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="chiaveConsegna"
|
||||||
|
value={form.chiaveConsegna}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
chiaveConsegna: e.target.value.toUpperCase(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
maxLength={CONSEGNA_CONSTRAINTS.chiaveConsegna.maxLength}
|
||||||
|
className="font-mono uppercase"
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
Annulla
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? "Salvataggio..." : "Salva"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
|
title="Elimina consegna"
|
||||||
|
description={`Confermi l'eliminazione della consegna ${deleteTarget?.chiaveConsegna}?`}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
loading={deleting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,26 +1,73 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Package, Users, UserCog } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
to: "/clienti",
|
||||||
|
title: "Clienti",
|
||||||
|
description: "Anagrafica e contatti dei clienti",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/consegne",
|
||||||
|
title: "Consegne",
|
||||||
|
description: "Ritiri, consegne e stati spedizione",
|
||||||
|
icon: Package,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/utenti",
|
||||||
|
title: "Utenti",
|
||||||
|
description: "Account operatori e amministratori",
|
||||||
|
icon: UserCog,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { user, logout } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const isAdmin = user?.ruolo === "Amministratore";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh bg-background">
|
<div className="space-y-8">
|
||||||
<header className="flex items-center justify-between bg-primary px-8 py-6 text-primary-foreground">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Dashboard</h1>
|
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
||||||
<p className="mt-1 text-sm opacity-90">
|
<p className="mt-1 text-muted-foreground">
|
||||||
Benvenuto, {user?.nome} {user?.cognome}
|
Benvenuto, {user?.nome} {user?.cognome}
|
||||||
{user?.ruolo === "Amministratore" ? " (Amministratore)" : ""}
|
{isAdmin ? " (Amministratore)" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={() => logout()}>
|
|
||||||
Esci
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{sections
|
||||||
|
.filter((section) => !("adminOnly" in section && section.adminOnly) || isAdmin)
|
||||||
|
.map(({ to, title, description, icon: Icon }) => (
|
||||||
|
<Card key={to}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-2 flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Link to={to}>
|
||||||
|
<Button variant="outline" className="w-full">
|
||||||
|
Apri sezione
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</Link>
|
||||||
<main className="mx-auto max-w-5xl p-8">
|
</CardContent>
|
||||||
<p className="text-muted-foreground">Area riservata agli operatori corrieri.</p>
|
</Card>
|
||||||
</main>
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
331
src/pages/UtentiPage.tsx
Normal file
331
src/pages/UtentiPage.tsx
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
createUtente,
|
||||||
|
deleteUtente,
|
||||||
|
getUtenti,
|
||||||
|
updateUtente,
|
||||||
|
} from "@/api/client";
|
||||||
|
import ConfirmDeleteDialog from "@/components/ConfirmDeleteDialog";
|
||||||
|
import FormField from "@/components/FormField";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { getErrorMessage, mergeFieldErrors } from "@/lib/errors";
|
||||||
|
import type { Ruolo, UtenteAdmin } from "@/types";
|
||||||
|
import { UTENTE_CONSTRAINTS } from "@/validation/constraints";
|
||||||
|
import {
|
||||||
|
validateUtenteCreate,
|
||||||
|
validateUtenteUpdate,
|
||||||
|
} from "@/validation/utente";
|
||||||
|
|
||||||
|
type UtenteFormState = {
|
||||||
|
nome: string;
|
||||||
|
cognome: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
ruolo: Ruolo;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyForm = (): UtenteFormState => ({
|
||||||
|
nome: "",
|
||||||
|
cognome: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
ruolo: "Operatore",
|
||||||
|
});
|
||||||
|
|
||||||
|
function toFormState(utente: UtenteAdmin): UtenteFormState {
|
||||||
|
return {
|
||||||
|
nome: utente.nome,
|
||||||
|
cognome: utente.cognome,
|
||||||
|
email: utente.email,
|
||||||
|
password: "",
|
||||||
|
ruolo: utente.ruolo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UtentiPage() {
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const [utenti, setUtenti] = useState<UtenteAdmin[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<UtenteAdmin | null>(null);
|
||||||
|
const [form, setForm] = useState<UtenteFormState>(emptyForm);
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<UtenteAdmin | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const loadUtenti = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setUtenti(await getUtenti());
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadUtenti();
|
||||||
|
}, [loadUtenti]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditing(null);
|
||||||
|
setForm(emptyForm());
|
||||||
|
setFieldErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (utente: UtenteAdmin) => {
|
||||||
|
setEditing(utente);
|
||||||
|
setForm(toFormState(utente));
|
||||||
|
setFieldErrors({});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = (field: keyof UtenteFormState, value: string) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
|
const raw: Record<string, unknown> = { ...form };
|
||||||
|
if (editing && !form.password) {
|
||||||
|
delete raw.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
const validation = validateUtenteUpdate(raw);
|
||||||
|
if (!validation.success) {
|
||||||
|
setFieldErrors(validation.errors);
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateUtente(editing.id, validation.data);
|
||||||
|
toast.success("Utente aggiornato");
|
||||||
|
} else {
|
||||||
|
const validation = validateUtenteCreate(raw);
|
||||||
|
if (!validation.success) {
|
||||||
|
setFieldErrors(validation.errors);
|
||||||
|
setSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createUtente(validation.data);
|
||||||
|
toast.success("Utente creato");
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
await loadUtenti();
|
||||||
|
} catch (error) {
|
||||||
|
setFieldErrors(
|
||||||
|
mergeFieldErrors(error instanceof ApiError ? error.fields : undefined)
|
||||||
|
);
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteUtente(deleteTarget.id);
|
||||||
|
toast.success("Utente eliminato");
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await loadUtenti();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Utenti</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Gestione account operatori e amministratori
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate}>Nuovo utente</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-muted-foreground">Caricamento...</p>
|
||||||
|
) : utenti.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">Nessun utente registrato.</p>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Nome</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Ruolo</TableHead>
|
||||||
|
<TableHead className="text-right">Azioni</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{utenti.map((utente) => (
|
||||||
|
<TableRow key={utente.id}>
|
||||||
|
<TableCell>
|
||||||
|
{utente.nome} {utente.cognome}
|
||||||
|
{utente.id === currentUser?.id && (
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">(tu)</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{utente.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={utente.ruolo === "Amministratore" ? "default" : "secondary"}>
|
||||||
|
{utente.ruolo}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openEdit(utente)}>
|
||||||
|
Modifica
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={utente.id === currentUser?.id}
|
||||||
|
onClick={() => setDeleteTarget(utente)}
|
||||||
|
>
|
||||||
|
Elimina
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editing ? "Modifica utente" : "Nuovo utente"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
||||||
|
{fieldErrors._form && (
|
||||||
|
<p className="text-sm text-destructive">{fieldErrors._form}</p>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<FormField id="nome" label="Nome" error={fieldErrors.nome}>
|
||||||
|
<Input
|
||||||
|
id="nome"
|
||||||
|
value={form.nome}
|
||||||
|
onChange={(e) => updateField("nome", e.target.value)}
|
||||||
|
maxLength={UTENTE_CONSTRAINTS.nome.maxLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField id="cognome" label="Cognome" error={fieldErrors.cognome}>
|
||||||
|
<Input
|
||||||
|
id="cognome"
|
||||||
|
value={form.cognome}
|
||||||
|
onChange={(e) => updateField("cognome", e.target.value)}
|
||||||
|
maxLength={UTENTE_CONSTRAINTS.cognome.maxLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormField id="email" label="Email" error={fieldErrors.email}>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => updateField("email", e.target.value)}
|
||||||
|
maxLength={UTENTE_CONSTRAINTS.email.maxLength}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField
|
||||||
|
id="password"
|
||||||
|
label={editing ? "Nuova password (opzionale)" : "Password"}
|
||||||
|
error={fieldErrors.password}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => updateField("password", e.target.value)}
|
||||||
|
maxLength={UTENTE_CONSTRAINTS.password.maxLength}
|
||||||
|
autoComplete={editing ? "new-password" : "new-password"}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField id="ruolo" label="Ruolo" error={fieldErrors.ruolo}>
|
||||||
|
<Select
|
||||||
|
value={form.ruolo}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateField("ruolo", (value ?? "Operatore") as Ruolo)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{UTENTE_CONSTRAINTS.ruolo.allowedValues.map((ruolo) => (
|
||||||
|
<SelectItem key={ruolo} value={ruolo}>
|
||||||
|
{ruolo}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormField>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||||
|
Annulla
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? "Salvataggio..." : "Salva"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={deleteTarget !== null}
|
||||||
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
|
title="Elimina utente"
|
||||||
|
description={`Confermi l'eliminazione di ${deleteTarget?.nome} ${deleteTarget?.cognome}?`}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
loading={deleting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue