diff --git a/package-lock.json b/package-lock.json
index c7ab063..5f3e324 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 4b2d38c..c5255bb 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/src/App.tsx b/src/App.tsx
index ca9b3dd..cff0f21 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
} />
-
+
}
- />
+ >
+ } />
+ } />
+ } />
+
+
+
+ }
+ />
+
} />
} />
+
);
}
diff --git a/src/components/ConfirmDeleteDialog.tsx b/src/components/ConfirmDeleteDialog.tsx
new file mode 100644
index 0000000..137d993
--- /dev/null
+++ b/src/components/ConfirmDeleteDialog.tsx
@@ -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 (
+
+
+
+ {title}
+ {description}
+
+
+ Annulla
+ {
+ e.preventDefault();
+ onConfirm();
+ }}
+ >
+ {loading ? "Eliminazione..." : "Elimina"}
+
+
+
+
+ );
+}
diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx
new file mode 100644
index 0000000..e0d5a37
--- /dev/null
+++ b/src/components/FormField.tsx
@@ -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 (
+
+
+ {children}
+ {error &&
{error}
}
+
+ );
+}
diff --git a/src/components/StatoConsegnaBadge.tsx b/src/components/StatoConsegnaBadge.tsx
new file mode 100644
index 0000000..cbbee7f
--- /dev/null
+++ b/src/components/StatoConsegnaBadge.tsx
@@ -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 {stato};
+}
diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx
new file mode 100644
index 0000000..d33f1cc
--- /dev/null
+++ b/src/components/layout/AppSidebar.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/components/layout/DashboardLayout.tsx b/src/components/layout/DashboardLayout.tsx
new file mode 100644
index 0000000..410a7ac
--- /dev/null
+++ b/src/components/layout/DashboardLayout.tsx
@@ -0,0 +1,15 @@
+import { Outlet } from "react-router-dom";
+import AppSidebar from "./AppSidebar";
+
+export default function DashboardLayout() {
+ return (
+
+ );
+}
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..ead776d
--- /dev/null
+++ b/src/components/ui/alert-dialog.tsx
@@ -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
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Popup.Props & {
+ size?: "default" | "sm"
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick, "variant" | "size">) {
+ return (
+ }
+ {...props}
+ />
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..b20959d
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -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) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ })
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..2fbe702
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -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
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..9d5ebbd
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -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
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
+}
+
+function DropdownMenuContent({
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ className,
+ ...props
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: MenuPrimitive.Item.Props & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ inset,
+ ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ inset,
+ ...props
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: MenuPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 0000000..e8021f5
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -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 (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+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 (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..4f65961
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -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 (
+
+ )
+}
+
+export { Separator }
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
new file mode 100644
index 0000000..949d015
--- /dev/null
+++ b/src/components/ui/sonner.tsx
@@ -0,0 +1,16 @@
+import { Toaster as Sonner } from "sonner";
+
+export function Toaster() {
+ return (
+
+ );
+}
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
new file mode 100644
index 0000000..ac9585e
--- /dev/null
+++ b/src/components/ui/table.tsx
@@ -0,0 +1,114 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ |
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ |
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/src/lib/errors.ts b/src/lib/errors.ts
new file mode 100644
index 0000000..a2dba55
--- /dev/null
+++ b/src/lib/errors.ts
@@ -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 | undefined>
+): Record {
+ return Object.assign({}, ...sources.filter(Boolean));
+}
diff --git a/src/pages/ClientiPage.tsx b/src/pages/ClientiPage.tsx
new file mode 100644
index 0000000..caf93cd
--- /dev/null
+++ b/src/pages/ClientiPage.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(true);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form, setForm] = useState(emptyForm);
+ const [fieldErrors, setFieldErrors] = useState>({});
+ const [saving, setSaving] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(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 (
+
+
+
+
Clienti
+
+ Gestisci l'anagrafica clienti
+
+
+
+
+
+ {loading ? (
+
Caricamento...
+ ) : clienti.length === 0 ? (
+
Nessun cliente registrato.
+ ) : (
+
+
+
+
+ Nome
+ Comune
+ Telefono
+ Email
+ Azioni
+
+
+
+ {clienti.map((cliente) => (
+
+
+ {cliente.nome} {cliente.cognome}
+
+
+ {cliente.comune} ({cliente.provincia})
+
+ {cliente.telefono}
+ {cliente.email}
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
!open && setDeleteTarget(null)}
+ title="Elimina cliente"
+ description={`Confermi l'eliminazione di ${deleteTarget?.nome} ${deleteTarget?.cognome}?`}
+ onConfirm={handleDelete}
+ loading={deleting}
+ />
+
+ );
+}
diff --git a/src/pages/ConsegnePage.tsx b/src/pages/ConsegnePage.tsx
new file mode 100644
index 0000000..8c17997
--- /dev/null
+++ b/src/pages/ConsegnePage.tsx
@@ -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([]);
+ const [clienti, setClienti] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form, setForm] = useState(emptyForm);
+ const [fieldErrors, setFieldErrors] = useState>({});
+ const [saving, setSaving] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(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 (
+
+
+
+
Consegne
+
+ Gestisci ritiri e consegne
+
+
+
+
+
+ {clienti.length === 0 && !loading && (
+
+ Crea almeno un cliente prima di registrare una consegna.
+
+ )}
+
+ {loading ? (
+
Caricamento...
+ ) : consegne.length === 0 ? (
+
Nessuna consegna registrata.
+ ) : (
+
+
+
+
+ Cliente
+ Chiave
+ Ritiro
+ Consegna
+ Stato
+ Azioni
+
+
+
+ {consegne.map((consegna) => (
+
+
+ {consegna.clienteNome} {consegna.clienteCognome}
+
+ {consegna.chiaveConsegna}
+ {formatDate(consegna.dataRitiro)}
+ {formatDate(consegna.dataConsegna)}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
!open && setDeleteTarget(null)}
+ title="Elimina consegna"
+ description={`Confermi l'eliminazione della consegna ${deleteTarget?.chiaveConsegna}?`}
+ onConfirm={handleDelete}
+ loading={deleting}
+ />
+
+ );
+}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index 0aa4843..d2e9dd4 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -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 (
-
-
-
- Area riservata agli operatori corrieri.
-
+
+
+
Dashboard
+
+ Benvenuto, {user?.nome} {user?.cognome}
+ {isAdmin ? " (Amministratore)" : ""}
+
+
+
+
+ {sections
+ .filter((section) => !("adminOnly" in section && section.adminOnly) || isAdmin)
+ .map(({ to, title, description, icon: Icon }) => (
+
+
+
+
+
+ {title}
+ {description}
+
+
+
+
+
+
+
+ ))}
+
);
}
diff --git a/src/pages/UtentiPage.tsx b/src/pages/UtentiPage.tsx
new file mode 100644
index 0000000..bae82f8
--- /dev/null
+++ b/src/pages/UtentiPage.tsx
@@ -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
([]);
+ const [loading, setLoading] = useState(true);
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editing, setEditing] = useState(null);
+ const [form, setForm] = useState(emptyForm);
+ const [fieldErrors, setFieldErrors] = useState>({});
+ const [saving, setSaving] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(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 = { ...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 (
+
+
+
+
Utenti
+
+ Gestione account operatori e amministratori
+
+
+
+
+
+ {loading ? (
+
Caricamento...
+ ) : utenti.length === 0 ? (
+
Nessun utente registrato.
+ ) : (
+
+
+
+
+ Nome
+ Email
+ Ruolo
+ Azioni
+
+
+
+ {utenti.map((utente) => (
+
+
+ {utente.nome} {utente.cognome}
+ {utente.id === currentUser?.id && (
+ (tu)
+ )}
+
+ {utente.email}
+
+
+ {utente.ruolo}
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
!open && setDeleteTarget(null)}
+ title="Elimina utente"
+ description={`Confermi l'eliminazione di ${deleteTarget?.nome} ${deleteTarget?.cognome}?`}
+ onConfirm={handleDelete}
+ loading={deleting}
+ />
+
+ );
+}