sidebar e navigazione; CRUD: table, dialog, badge; Toast per feedback operazioni
All checks were successful
Deploy to VPS / build (push) Successful in 23s
Deploy to VPS / deploy (push) Successful in 23s

This commit is contained in:
AV77web 2026-06-24 18:53:46 +02:00
parent ea7f5d18f1
commit 6c5c86fcb4
22 changed files with 2379 additions and 21 deletions

32
package-lock.json generated
View file

@ -12,9 +12,12 @@
"@tailwindcss/vite": "^4.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next-themes": "^0.4.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.18.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
},
@ -2542,6 +2545,15 @@
"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": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -2599,6 +2611,16 @@
"dev": true,
"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": {
"version": "2.0.48",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
@ -2888,6 +2910,16 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View file

@ -14,9 +14,12 @@
"@tailwindcss/vite": "^4.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next-themes": "^0.4.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.18.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.1"
},

View file

@ -1,9 +1,14 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import GuestRoute from "./components/GuestRoute";
import ProtectedRoute from "./components/ProtectedRoute";
import DashboardLayout from "./components/layout/DashboardLayout";
import { Toaster } from "./components/ui/sonner";
import { AuthProvider } from "./context/AuthContext";
import ClientiPage from "./pages/ClientiPage";
import ConsegnePage from "./pages/ConsegnePage";
import Dashboard from "./pages/Dashboard";
import Login from "./pages/Login";
import UtentiPage from "./pages/UtentiPage";
export default function App() {
return (
@ -12,17 +17,29 @@ export default function App() {
<Routes>
<Route path="/login" element={<GuestRoute><Login /></GuestRoute>} />
<Route
path="/dashboard"
element={
<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>
}
/>
</Route>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</BrowserRouter>
<Toaster />
</AuthProvider>
);
}

View 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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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,
}

View 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 }

View 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,
}

View 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,
}

View 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,
}

View 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 }

View 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
View 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,
}

View 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
View 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
View 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&apos;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
View 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>
);
}

View file

@ -1,26 +1,73 @@
import { Link } from "react-router-dom";
import { Package, Users, UserCog } from "lucide-react";
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() {
const { user, logout } = useAuth();
const { user } = useAuth();
const isAdmin = user?.ruolo === "Amministratore";
return (
<div className="min-h-svh bg-background">
<header className="flex items-center justify-between bg-primary px-8 py-6 text-primary-foreground">
<div>
<h1 className="text-2xl font-semibold">Dashboard</h1>
<p className="mt-1 text-sm opacity-90">
Benvenuto, {user?.nome} {user?.cognome}
{user?.ruolo === "Amministratore" ? " (Amministratore)" : ""}
</p>
</div>
<Button variant="secondary" onClick={() => logout()}>
Esci
</Button>
</header>
<main className="mx-auto max-w-5xl p-8">
<p className="text-muted-foreground">Area riservata agli operatori corrieri.</p>
</main>
<div className="space-y-8">
<div>
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
<p className="mt-1 text-muted-foreground">
Benvenuto, {user?.nome} {user?.cognome}
{isAdmin ? " (Amministratore)" : ""}
</p>
</div>
<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>
</Link>
</CardContent>
</Card>
))}
</div>
</div>
);
}

331
src/pages/UtentiPage.tsx Normal file
View 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>
);
}