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