mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-12 01:37:40 +02:00
latest
This commit is contained in:
1 parent
e3fb29ab1d
commit
2485f71dd7
95 files changed
+41842
No files matched your search
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { VariantProps, cva } from "class-variance-authority"
|
||||
import { PanelLeft } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile
|
||||
? setOpenMobile((open) => !open)
|
||||
: setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
SidebarProvider.displayName = "SidebarProvider"
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Sidebar.displayName = "Sidebar"
|
||||
|
||||
const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
SidebarTrigger.displayName = "SidebarTrigger"
|
||||
|
||||
const SidebarRail = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button">
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarRail.displayName = "SidebarRail"
|
||||
|
||||
const SidebarInset = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"main">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarInset.displayName = "SidebarInset"
|
||||
|
||||
const SidebarInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
React.ComponentProps<typeof Input>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarInput.displayName = "SidebarInput"
|
||||
|
||||
const SidebarHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarHeader.displayName = "SidebarHeader"
|
||||
|
||||
const SidebarFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarFooter.displayName = "SidebarFooter"
|
||||
|
||||
const SidebarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof Separator>,
|
||||
React.ComponentProps<typeof Separator>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarSeparator.displayName = "SidebarSeparator"
|
||||
|
||||
const SidebarContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarContent.displayName = "SidebarContent"
|
||||
|
||||
const SidebarGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroup.displayName = "SidebarGroup"
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
||||
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction"
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent"
|
||||
|
||||
const SidebarMenu = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenu.displayName = "SidebarMenu"
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
)
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton"
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction"
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
||||
|
||||
const SidebarMenuSkeleton = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}
|
||||
>(({ className, showIcon = false, ...props }, ref) => {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-[--skeleton-width] flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub"
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-gray-50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-gray-50 data-[state=selected]:bg-gray-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full border border-input bg-white px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-green-50 text-green-800 border-green-200",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, children, ...props }, ref) => (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
))
|
||||
|
||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, children, variant, size, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
|
||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3 min-w-10",
|
||||
sm: "h-9 px-2.5 min-w-9",
|
||||
lg: "h-11 px-5 min-w-11",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>
|
||||
>(({ className, variant, size, ...props }, ref) => (
|
||||
<TogglePrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function useAuth() {
|
||||
const { data: user, isLoading, error } = useQuery({
|
||||
queryKey: ["/api/user"],
|
||||
retry: (failureCount, error: any) => {
|
||||
// Ne pas retry si c'est une erreur d'authentification
|
||||
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true, // Permettre le rechargement au montage
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
return {
|
||||
user: user || null,
|
||||
isLoading,
|
||||
isAuthenticated: !!user,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Hook d'authentification spécifique pour la production
|
||||
// Utilise fetch direct sans React Query pour éviter les problèmes de cache
|
||||
export function useAuthProduction() {
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('🔄 Production auth check starting...');
|
||||
|
||||
const response = await fetch('/api/user', {
|
||||
credentials: 'include',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🔄 Production auth response:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
console.log('✅ Production auth success:', { username: userData?.username, id: userData?.id });
|
||||
setUser(userData);
|
||||
setError(null);
|
||||
} else if (response.status === 401) {
|
||||
console.log('❌ Production auth 401 - user not authenticated');
|
||||
setUser(null);
|
||||
setError(null);
|
||||
} else {
|
||||
throw new Error(`Auth failed: ${response.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Production auth error:', err);
|
||||
setError(err);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
console.log('🔄 Production auth check completed');
|
||||
}
|
||||
};
|
||||
|
||||
// Authentification au montage et lors des triggers
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [refreshTrigger]);
|
||||
|
||||
// Fonction pour rafraîchir l'authentification
|
||||
const refreshAuth = () => {
|
||||
console.log('🔄 RefreshAuth called for production');
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: !!user,
|
||||
error,
|
||||
refreshAuth,
|
||||
environment: 'production'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Hook d'authentification simplifié qui fonctionne dans tous les environnements
|
||||
export function useAuthSimple() {
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<any>(null);
|
||||
const [refreshCounter, setRefreshCounter] = useState(0);
|
||||
|
||||
// Fonction pour vérifier l'authentification
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await fetch('/api/user', {
|
||||
credentials: 'include',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
console.log('✅ Auth check successful:', { username: userData?.username, id: userData?.id });
|
||||
setUser(userData);
|
||||
setError(null);
|
||||
} else if (response.status === 401) {
|
||||
console.log('❌ Auth check 401 - user not authenticated');
|
||||
setUser(null);
|
||||
setError(null);
|
||||
} else {
|
||||
throw new Error(`Auth failed: ${response.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Auth check error:', err);
|
||||
setError(err);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour forcer un refresh de l'authentification
|
||||
const refreshAuth = () => {
|
||||
console.log('🔄 Refreshing authentication...');
|
||||
setRefreshCounter(prev => prev + 1);
|
||||
};
|
||||
|
||||
// Vérifier l'authentification au montage et lors des refresh
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [refreshCounter]);
|
||||
|
||||
return {
|
||||
user,
|
||||
isLoading,
|
||||
error,
|
||||
isAuthenticated: !!user,
|
||||
refreshAuth
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
// Hook d'authentification unifié qui s'adapte automatiquement
|
||||
// En production utilise fetch direct, en développement utilise React Query
|
||||
export function useAuthUnified() {
|
||||
// Détection d'environnement plus robuste
|
||||
const isDevelopment = typeof window !== 'undefined' &&
|
||||
(window.location.hostname === 'localhost' ||
|
||||
window.location.hostname.includes('replit.dev')) &&
|
||||
import.meta.env.DEV === true;
|
||||
|
||||
// Debug logging pour comprendre l'environnement
|
||||
console.log('🔍 Auth Environment Debug:', {
|
||||
hostname: typeof window !== 'undefined' ? window.location.hostname : 'N/A',
|
||||
isDev: import.meta.env.DEV,
|
||||
environment: isDevelopment ? 'development' : 'production'
|
||||
});
|
||||
|
||||
// État pour la version production (fetch direct)
|
||||
const [productionUser, setProductionUser] = useState<any>(null);
|
||||
const [productionLoading, setProductionLoading] = useState(true);
|
||||
const [productionError, setProductionError] = useState<any>(null);
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// Hook React Query pour le développement
|
||||
const developmentQuery = useQuery({
|
||||
queryKey: ["/api/user"],
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes de cache pour l'auth
|
||||
gcTime: 15 * 60 * 1000, // 15 minutes
|
||||
enabled: isDevelopment, // Seulement en développement
|
||||
});
|
||||
|
||||
// Fonction pour rafraîchir l'authentification
|
||||
const refreshAuth = () => {
|
||||
console.log('🔄 RefreshAuth called, isDevelopment:', isDevelopment);
|
||||
if (!isDevelopment) {
|
||||
console.log('🔄 Triggering production auth refresh');
|
||||
setRefreshTrigger(prev => {
|
||||
const newValue = prev + 1;
|
||||
console.log('🔄 Production refresh trigger updated:', prev, '->', newValue);
|
||||
return newValue;
|
||||
});
|
||||
} else {
|
||||
console.log('🔄 Development mode - using React Query refresh');
|
||||
developmentQuery.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour rafraîchir de manière synchrone (pour après login)
|
||||
const forceAuthRefresh = async () => {
|
||||
console.log('🔄 ForceAuthRefresh called, isDevelopment:', isDevelopment);
|
||||
|
||||
if (!isDevelopment) {
|
||||
// En production, faire un fetch immédiat et forcer un re-render
|
||||
try {
|
||||
console.log('🔄 Production force refresh - fetching user data');
|
||||
setProductionLoading(true);
|
||||
|
||||
const response = await fetch('/api/user', {
|
||||
credentials: 'include',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
console.log('✅ Production force refresh success:', { username: userData?.username, id: userData?.id });
|
||||
setProductionUser(userData);
|
||||
setProductionError(null);
|
||||
setProductionLoading(false);
|
||||
|
||||
// Forcer un trigger de refresh pour déclencher les re-renders
|
||||
setRefreshTrigger(prev => prev + 1);
|
||||
|
||||
return userData;
|
||||
} else {
|
||||
console.log('❌ Production force refresh failed:', response.status);
|
||||
setProductionUser(null);
|
||||
setProductionError(null);
|
||||
setProductionLoading(false);
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Production force refresh error:', error);
|
||||
setProductionError(error);
|
||||
setProductionUser(null);
|
||||
setProductionLoading(false);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// En développement, forcer un refetch avec invalidation du cache
|
||||
console.log('🔄 Development mode - invalidating cache and refetching');
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
|
||||
|
||||
// Attendre un court délai pour la propagation
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const result = await developmentQuery.refetch();
|
||||
console.log('🔄 Development refetch result:', {
|
||||
success: result.isSuccess,
|
||||
hasData: !!result.data,
|
||||
userId: result.data?.id
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
};
|
||||
|
||||
// Authentification production (fetch direct)
|
||||
useEffect(() => {
|
||||
if (isDevelopment) return; // Ne pas exécuter en développement
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
console.log('🔄 Production auth check starting, refreshTrigger:', refreshTrigger);
|
||||
setProductionLoading(true);
|
||||
|
||||
const response = await fetch('/api/user', {
|
||||
credentials: 'include',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('🔄 Production auth response:', response.status);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
console.log('✅ Production auth success:', { username: userData?.username, id: userData?.id });
|
||||
if (isMounted) {
|
||||
setProductionUser(userData);
|
||||
setProductionError(null);
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
console.log('❌ Production auth 401 - user not authenticated');
|
||||
if (isMounted) {
|
||||
setProductionUser(null);
|
||||
setProductionError(null);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Auth failed: ${response.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Production auth error:', err);
|
||||
if (isMounted) {
|
||||
setProductionError(err);
|
||||
setProductionUser(null);
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
console.log('🔄 Production auth check complete, loading set to false');
|
||||
setProductionLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [isDevelopment, refreshTrigger]); // Ajout du refreshTrigger
|
||||
|
||||
// Retourner les bonnes données selon l'environnement
|
||||
if (isDevelopment) {
|
||||
return {
|
||||
user: developmentQuery.data || null,
|
||||
isLoading: developmentQuery.isLoading,
|
||||
isAuthenticated: !!developmentQuery.data,
|
||||
error: developmentQuery.error,
|
||||
refreshAuth: refreshAuth,
|
||||
forceAuthRefresh: forceAuthRefresh,
|
||||
environment: 'development'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
user: productionUser,
|
||||
isLoading: productionLoading,
|
||||
isAuthenticated: !!productionUser,
|
||||
error: productionError,
|
||||
refreshAuth: refreshAuth,
|
||||
forceAuthRefresh: forceAuthRefresh,
|
||||
environment: 'production'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(20, 14.3%, 4.1%);
|
||||
--muted: hsl(60, 4.8%, 95.9%);
|
||||
--muted-foreground: hsl(25, 5.3%, 44.7%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-foreground: hsl(20, 14.3%, 4.1%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(20, 14.3%, 4.1%);
|
||||
--border: hsl(20, 5.9%, 90%);
|
||||
--input: hsl(20, 5.9%, 90%);
|
||||
--primary: hsl(207, 90%, 54%);
|
||||
--primary-foreground: hsl(211, 100%, 99%);
|
||||
--secondary: hsl(120, 61%, 34%);
|
||||
--secondary-foreground: hsl(0, 0%, 100%);
|
||||
--accent: hsl(35, 100%, 47%);
|
||||
--accent-foreground: hsl(0, 0%, 100%);
|
||||
--destructive: hsl(0, 84.2%, 60.2%);
|
||||
--destructive-foreground: hsl(60, 9.1%, 97.8%);
|
||||
--ring: hsl(20, 14.3%, 4.1%);
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Custom La Foir'Fouille colors */
|
||||
--surface: hsl(0, 0%, 98%);
|
||||
--delivered: hsl(0, 0%, 46%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(240, 10%, 3.9%);
|
||||
--foreground: hsl(0, 0%, 98%);
|
||||
--muted: hsl(240, 3.7%, 15.9%);
|
||||
--muted-foreground: hsl(240, 5%, 64.9%);
|
||||
--popover: hsl(240, 10%, 3.9%);
|
||||
--popover-foreground: hsl(0, 0%, 98%);
|
||||
--card: hsl(240, 10%, 3.9%);
|
||||
--card-foreground: hsl(0, 0%, 98%);
|
||||
--border: hsl(240, 3.7%, 15.9%);
|
||||
--input: hsl(240, 3.7%, 15.9%);
|
||||
--primary: hsl(207, 90%, 54%);
|
||||
--primary-foreground: hsl(211, 100%, 99%);
|
||||
--secondary: hsl(120, 61%, 34%);
|
||||
--secondary-foreground: hsl(0, 0%, 100%);
|
||||
--accent: hsl(35, 100%, 47%);
|
||||
--accent-foreground: hsl(0, 0%, 100%);
|
||||
--destructive: hsl(0, 62.8%, 30.6%);
|
||||
--destructive-foreground: hsl(0, 0%, 98%);
|
||||
--ring: hsl(240, 4.9%, 83.9%);
|
||||
--radius: 0.5rem;
|
||||
|
||||
--surface: hsl(240, 10%, 8%);
|
||||
--delivered: hsl(0, 0%, 46%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-sans antialiased bg-surface text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.bg-surface {
|
||||
background-color: var(--surface);
|
||||
}
|
||||
|
||||
.bg-primary {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
.bg-secondary {
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
|
||||
.bg-accent {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
.bg-delivered {
|
||||
background-color: var(--delivered);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.text-delivered {
|
||||
color: var(--delivered);
|
||||
}
|
||||
|
||||
.border-primary {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.border-secondary {
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
.border-accent {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hover\:bg-primary:hover {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
.hover\:bg-secondary:hover {
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
|
||||
.hover\:bg-accent:hover {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Supprimer le contour orange de la date du jour dans le calendrier */
|
||||
button[aria-selected="true"][aria-current="date"],
|
||||
button[aria-current="date"],
|
||||
.rdp-day_today,
|
||||
.rdp-day_selected,
|
||||
[data-today="true"] {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
ring: 0 !important;
|
||||
--tw-ring-shadow: none !important;
|
||||
--tw-ring-offset-shadow: none !important;
|
||||
}
|
||||
|
||||
button[aria-selected="true"][aria-current="date"]:focus,
|
||||
button[aria-current="date"]:focus,
|
||||
.rdp-day_today:focus,
|
||||
.rdp-day_selected:focus,
|
||||
[data-today="true"]:focus {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
ring: 0 !important;
|
||||
--tw-ring-shadow: none !important;
|
||||
--tw-ring-offset-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Cibler spécifiquement le calendrier dans Tasks */
|
||||
.rounded-md.border button[aria-current="date"],
|
||||
.rounded-md.border button[data-today],
|
||||
.rounded-md.border .rdp-day_today {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
ring: 0 !important;
|
||||
}
|
||||
|
||||
/* Suppression spécifique du contour orange sur la date du jour */
|
||||
.rdp button,
|
||||
.rdp-day,
|
||||
.rdp-day_today,
|
||||
.rdp-day_selected {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Cibler spécifiquement les pseudo-éléments qui créent le contour orange */
|
||||
.rdp-day_today::before,
|
||||
.rdp-day_today::after,
|
||||
.rdp-day_selected::before,
|
||||
.rdp-day_selected::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Suppression de la bordure orange Tailwind */
|
||||
.bg-blue-600 {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
--tw-ring-color: transparent !important;
|
||||
--tw-ring-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Cibler toutes les variantes possibles du contour orange */
|
||||
button[aria-current="date"],
|
||||
button[data-today="true"],
|
||||
.rdp-day[aria-current="date"],
|
||||
.rdp-day_today,
|
||||
.rdp-button[aria-current="date"] {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
--tw-ring-width: 0px !important;
|
||||
--tw-ring-color: transparent !important;
|
||||
--tw-ring-offset-width: 0px !important;
|
||||
}
|
||||
|
||||
/* Force suppression avec sélecteurs très spécifiques */
|
||||
.rounded-md button[aria-current="date"],
|
||||
.rounded-md .rdp-day_today,
|
||||
[role="gridcell"] button[aria-current="date"] {
|
||||
border-color: transparent !important;
|
||||
outline-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Force suppression contour sur focus */
|
||||
.rdp-day_today:focus-visible,
|
||||
.rdp-day_today:focus {
|
||||
outline: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
--tw-ring-shadow: none !important;
|
||||
}
|
||||
|
||||
/* REMPLACEMENT COULEUR ORANGE PAR BLEU POUR SÉLECTION */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #2563eb !important; /* Bleu au lieu d'orange */
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
/* Couleur de sélection bleue pour le calendrier */
|
||||
button[aria-current="date"]:focus,
|
||||
button[aria-current="date"]:focus-visible,
|
||||
.rdp-day:focus,
|
||||
.rdp-day:focus-visible {
|
||||
outline: 2px solid #2563eb !important; /* Bleu comme la date du jour */
|
||||
outline-offset: 2px !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Variables CSS pour forcer le bleu sur tous les focus */
|
||||
:root {
|
||||
--focus-color: #2563eb !important; /* Bleu */
|
||||
}
|
||||
|
||||
/* Override focus sur tous les éléments pour utiliser le bleu */
|
||||
* {
|
||||
--tw-ring-color: #2563eb !important;
|
||||
}
|
||||
|
||||
/* Focus bleu spécifique pour les boutons du calendrier */
|
||||
.rdp button:focus-visible {
|
||||
outline: 2px solid #2563eb !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isUnauthorizedError(error: Error): boolean {
|
||||
return /^401: .*Unauthorized/.test(error.message);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
/**
|
||||
* Formate une date de façon sécurisée pour éviter "Invalid time value"
|
||||
* Retourne une chaîne par défaut si la date est invalide
|
||||
*/
|
||||
export function safeFormat(
|
||||
date: string | Date | null | undefined,
|
||||
formatStr: string = "dd/MM/yyyy",
|
||||
options: { locale?: Locale; defaultValue?: string } = {}
|
||||
): string {
|
||||
const { locale = fr, defaultValue = "Date non disponible" } = options;
|
||||
|
||||
if (!date) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
|
||||
// Vérifier si la date est valide
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return format(dateObj, formatStr, { locale });
|
||||
} catch (error) {
|
||||
console.warn('Date formatting error:', error, 'for date:', date);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée un objet Date de façon sécurisée
|
||||
* Retourne null si la date est invalide
|
||||
*/
|
||||
export function safeDate(date: string | Date | null | undefined): Date | null {
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
|
||||
// Vérifier si la date est valide
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return dateObj;
|
||||
} catch (error) {
|
||||
console.warn('Date creation error:', error, 'for date:', date);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare deux dates de façon sécurisée
|
||||
* Retourne 0 si l'une des dates est invalide
|
||||
*/
|
||||
export function safeCompareDate(a: string | Date | null | undefined, b: string | Date | null | undefined): number {
|
||||
const dateA = safeDate(a);
|
||||
const dateB = safeDate(b);
|
||||
|
||||
if (!dateA || !dateB) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dateA.getTime() - dateB.getTime();
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { QueryClient, QueryFunction } from "@tanstack/react-query";
|
||||
|
||||
async function throwIfResNotOk(res: Response) {
|
||||
if (!res.ok) {
|
||||
const text = (await res.text()) || res.statusText;
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest(
|
||||
url: string,
|
||||
method: string = 'GET',
|
||||
body?: unknown,
|
||||
headers: Record<string, string> = {}
|
||||
): Promise<any> {
|
||||
console.log("🌐 API Request:", { url, method, body });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(body ? { "Content-Type": "application/json" } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
console.log("🌐 API Response:", { status: res.status, ok: res.ok });
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
|
||||
// Return JSON response
|
||||
if (res.headers.get('content-type')?.includes('application/json')) {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
type UnauthorizedBehavior = "returnNull" | "throw";
|
||||
export const getQueryFn: <T>(options: {
|
||||
on401: UnauthorizedBehavior;
|
||||
}) => QueryFunction<T> =
|
||||
({ on401: unauthorizedBehavior }) =>
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetch(queryKey[0] as string, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
queryFn: getQueryFn({ on401: "returnNull" }),
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: 30 * 1000, // 30 secondes de cache pour de meilleures performances
|
||||
gcTime: 10 * 60 * 1000, // 10 minutes
|
||||
retry: (failureCount, error: any) => {
|
||||
// Ne pas retry les erreurs d'authentification
|
||||
if (error?.message?.includes('401') || error?.message?.includes('Unauthorized')) {
|
||||
return false;
|
||||
}
|
||||
return failureCount < 2;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Utilitaire pour harmoniser les couleurs des rôles dans toute l'application
|
||||
|
||||
export const ROLE_COLORS = {
|
||||
admin: '#f87171', // Rouge clair (Administrateur)
|
||||
manager: '#60a5fa', // Bleu clair (Manager)
|
||||
employee: '#4ade80', // Vert clair (Employé)
|
||||
directeur: '#a78bfa', // Violet clair (Directeur)
|
||||
} as const;
|
||||
|
||||
export const ROLE_DISPLAY_NAMES = {
|
||||
admin: 'Administrateur',
|
||||
manager: 'Manager',
|
||||
employee: 'Employé',
|
||||
directeur: 'Directeur',
|
||||
} as const;
|
||||
|
||||
export type RoleType = keyof typeof ROLE_COLORS;
|
||||
|
||||
export function getRoleColor(roleName: string): string {
|
||||
const roleKey = roleName.toLowerCase() as RoleType;
|
||||
return ROLE_COLORS[roleKey] || '#666666';
|
||||
}
|
||||
|
||||
export function getRoleDisplayName(roleName: string): string {
|
||||
const roleKey = roleName.toLowerCase() as RoleType;
|
||||
return ROLE_DISPLAY_NAMES[roleKey] || roleName;
|
||||
}
|
||||
|
||||
// Utilitaire pour les classes Tailwind (pour Users.tsx) - couleurs claires et douces
|
||||
export function getRoleTailwindClasses(roleName: string) {
|
||||
switch (roleName.toLowerCase()) {
|
||||
case 'admin':
|
||||
return {
|
||||
badgeClass: 'bg-red-50 text-red-600 border border-red-200',
|
||||
iconClass: 'text-red-400'
|
||||
};
|
||||
case 'manager':
|
||||
return {
|
||||
badgeClass: 'bg-blue-50 text-blue-600 border border-blue-200',
|
||||
iconClass: 'text-blue-400'
|
||||
};
|
||||
case 'employee':
|
||||
return {
|
||||
badgeClass: 'bg-green-50 text-green-600 border border-green-200',
|
||||
iconClass: 'text-green-400'
|
||||
};
|
||||
case 'directeur':
|
||||
return {
|
||||
badgeClass: 'bg-purple-50 text-purple-600 border border-purple-200',
|
||||
iconClass: 'text-purple-400'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
badgeClass: 'bg-gray-50 text-gray-600 border border-gray-200',
|
||||
iconClass: 'text-gray-400'
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Store, LogIn, User, Info } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
// Fonction pour détecter l'environnement
|
||||
const getEnvironment = () => {
|
||||
const hostname = window.location.hostname;
|
||||
const isDev = hostname.includes('replit.dev') || hostname === 'localhost';
|
||||
return isDev ? 'development' : 'production';
|
||||
};
|
||||
|
||||
export default function AuthPage() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { user, isLoading, refreshAuth, forceAuthRefresh, isAuthenticated } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const [showDefaultCredentials, setShowDefaultCredentials] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [loginData, setLoginData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const currentEnvironment = getEnvironment();
|
||||
|
||||
// Check if admin user still has default password
|
||||
const { data: defaultCredentialsCheck } = useQuery({
|
||||
queryKey: ['/api/default-credentials-check'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await apiRequest("/api/default-credentials-check", "GET");
|
||||
return response;
|
||||
} catch (error) {
|
||||
// If the endpoint doesn't exist or fails, assume we should show credentials
|
||||
return { showDefault: true };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Fonction de login simple pour production
|
||||
const handleProductionLogin = async (data: typeof loginData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast({
|
||||
title: "Connexion réussie",
|
||||
description: "Redirection en cours...",
|
||||
});
|
||||
|
||||
// En production, recharger la page pour éviter les problèmes de synchronisation
|
||||
setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 500);
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
toast({
|
||||
title: "Erreur de connexion",
|
||||
description: errorData.message || "Identifiant ou mot de passe incorrect",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "Erreur de connexion",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Mutation complexe pour développement
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (data: typeof loginData) => {
|
||||
const response = await apiRequest("/api/login", "POST", data);
|
||||
return response;
|
||||
},
|
||||
onSuccess: async (loginResult) => {
|
||||
console.log('✅ Login successful, refreshing auth state...');
|
||||
|
||||
toast({
|
||||
title: "Connexion réussie",
|
||||
description: "Bienvenue dans LogiFlow",
|
||||
});
|
||||
|
||||
// Force invalidation of auth queries and refresh auth state
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/default-credentials-check'] });
|
||||
|
||||
// Force immediate authentication state refresh
|
||||
const refreshedUserData = await forceAuthRefresh();
|
||||
console.log('🔄 Force auth refresh result:', refreshedUserData);
|
||||
|
||||
if (refreshedUserData && refreshedUserData.id) {
|
||||
console.log('🔄 User data confirmed, forcing redirect...');
|
||||
setLocation("/");
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
forceAuthRefresh().then((retryUserData) => {
|
||||
if (retryUserData && retryUserData.id) {
|
||||
setLocation("/");
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur de connexion",
|
||||
description: error.message || "Identifiant ou mot de passe incorrect",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Debug logging for authentication state
|
||||
console.log('🔍 AuthPage Debug:', {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
userId: user?.id,
|
||||
username: user?.username
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultCredentialsCheck) {
|
||||
setShowDefaultCredentials(defaultCredentialsCheck.showDefault);
|
||||
}
|
||||
}, [defaultCredentialsCheck]);
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated && user) {
|
||||
console.log('🔄 User authenticated, redirecting to dashboard...', { user: user.username, authenticated: isAuthenticated });
|
||||
// Force immediate redirect to home page
|
||||
setLocation("/");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, user, setLocation]);
|
||||
|
||||
const handleLogin = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (currentEnvironment === 'production') {
|
||||
// Utiliser la méthode simple pour production
|
||||
handleProductionLogin(loginData);
|
||||
} else {
|
||||
// Utiliser React Query pour développement
|
||||
loginMutation.mutate(loginData);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex">
|
||||
{/* Left side - Auth forms */}
|
||||
<div className="flex-1 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center">
|
||||
<Store className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
|
||||
LogiFlow
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Gestion des commandes et livraisons
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LogIn className="w-5 h-5" />
|
||||
Connexion
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connectez-vous à votre compte LogiFlow
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="login-username">Identifiant</Label>
|
||||
<Input
|
||||
id="login-username"
|
||||
type="text"
|
||||
value={loginData.username}
|
||||
onChange={(e) => setLoginData({...loginData, username: e.target.value})}
|
||||
required
|
||||
placeholder="Votre identifiant"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="login-password">Mot de passe</Label>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={loginData.password}
|
||||
onChange={(e) => setLoginData({...loginData, password: e.target.value})}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={currentEnvironment === 'production' ? isSubmitting : loginMutation.isPending}
|
||||
>
|
||||
{(currentEnvironment === 'production' ? isSubmitting : loginMutation.isPending) ? "Connexion..." : "Se connecter"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Default credentials info - only show if admin password hasn't been changed */}
|
||||
{showDefaultCredentials && (
|
||||
<Card className="mt-4 bg-blue-50 border-blue-200">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-blue-800 text-sm">
|
||||
<Info className="w-4 h-4" />
|
||||
Première connexion
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="text-sm text-blue-700 space-y-1">
|
||||
<p><strong>Identifiant :</strong> admin</p>
|
||||
<p><strong>Mot de passe :</strong> admin</p>
|
||||
<p className="text-xs text-blue-600 mt-2">
|
||||
Changez le mot de passe après votre première connexion
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Hero section */}
|
||||
<div className="hidden lg:block relative w-0 flex-1">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary to-primary/80 flex items-center justify-center">
|
||||
<div className="text-center text-white max-w-md">
|
||||
<Store className="w-20 h-20 mx-auto mb-6 opacity-90" />
|
||||
<h1 className="text-4xl font-bold mb-4">
|
||||
Gérez vos commandes efficacement
|
||||
</h1>
|
||||
<p className="text-xl opacity-90 mb-8">
|
||||
LogiFlow centralise la gestion de vos commandes et livraisons across tous vos magasins
|
||||
</p>
|
||||
<div className="space-y-4 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Calendrier centralisé</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Gestion multi-magasins</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-white rounded-full"></div>
|
||||
<span>Suivi en temps réel</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,265 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronLeft, ChevronRight, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import CalendarGrid from "@/components/CalendarGrid";
|
||||
import QuickCreateMenu from "@/components/modals/QuickCreateMenu";
|
||||
import OrderDetailModal from "@/components/modals/OrderDetailModal";
|
||||
import CreateOrderModal from "@/components/modals/CreateOrderModal";
|
||||
import CreateDeliveryModal from "@/components/modals/CreateDeliveryModal";
|
||||
import StatsPanel from "@/components/StatsPanel";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { format, startOfMonth, endOfMonth } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
export default function Calendar() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const queryClient = useQueryClient();
|
||||
const [currentDate, setCurrentDate] = useState(new Date(2025, 6, 1)); // July 2025
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [showQuickCreate, setShowQuickCreate] = useState(false);
|
||||
const [showOrderDetail, setShowOrderDetail] = useState(false);
|
||||
const [showCreateOrder, setShowCreateOrder] = useState(false);
|
||||
const [showCreateDelivery, setShowCreateDelivery] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<any>(null);
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
|
||||
// Fetch orders and deliveries for the current month with store filtering
|
||||
const { data: orders = [], isLoading: loadingOrders } = useQuery({
|
||||
queryKey: ['/api/orders', selectedStoreId, {
|
||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
||||
}],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
||||
});
|
||||
if (selectedStoreId && user?.role === 'admin') {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const url = `/api/orders?${params.toString()}`;
|
||||
console.log('📅 Calendar fetching orders:', {
|
||||
url,
|
||||
selectedStoreId,
|
||||
userRole: user?.role,
|
||||
params: params.toString()
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch orders');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('📅 Calendar orders received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: deliveries = [], isLoading: loadingDeliveries } = useQuery({
|
||||
queryKey: ['/api/deliveries', selectedStoreId, {
|
||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
||||
}],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
startDate: format(monthStart, 'yyyy-MM-dd'),
|
||||
endDate: format(monthEnd, 'yyyy-MM-dd')
|
||||
});
|
||||
if (selectedStoreId && user?.role === 'admin') {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const url = `/api/deliveries?${params.toString()}`;
|
||||
console.log('📅 Calendar fetching deliveries:', {
|
||||
url,
|
||||
selectedStoreId,
|
||||
userRole: user?.role,
|
||||
params: params.toString()
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deliveries');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('📅 Calendar deliveries received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const navigateMonth = (direction: 'prev' | 'next') => {
|
||||
const newDate = new Date(currentDate);
|
||||
if (direction === 'prev') {
|
||||
newDate.setMonth(newDate.getMonth() - 1);
|
||||
} else {
|
||||
newDate.setMonth(newDate.getMonth() + 1);
|
||||
}
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
setSelectedDate(date);
|
||||
setShowQuickCreate(true);
|
||||
};
|
||||
|
||||
const handleItemClick = (item: any, type: 'order' | 'delivery') => {
|
||||
// Rafraîchir les données avant d'ouvrir le modal pour s'assurer d'avoir les liaisons à jour
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/orders', selectedStoreId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries', selectedStoreId] });
|
||||
|
||||
setSelectedItem({ ...item, type });
|
||||
setShowOrderDetail(true);
|
||||
};
|
||||
|
||||
const handleCreateOrder = () => {
|
||||
setShowQuickCreate(false);
|
||||
setShowCreateOrder(true);
|
||||
};
|
||||
|
||||
const handleCreateDelivery = () => {
|
||||
setShowQuickCreate(false);
|
||||
setShowCreateDelivery(true);
|
||||
};
|
||||
|
||||
const isLoading = loadingOrders || loadingDeliveries;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Calendrier des Commandes & Livraisons
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
{format(currentDate, 'MMMM yyyy', { locale: fr })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Legend */}
|
||||
<div className="flex items-center space-x-6 text-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-primary rounded"></div>
|
||||
<span className="text-gray-600">Commandes</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-secondary rounded"></div>
|
||||
<span className="text-gray-600">Livraisons</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-3 h-3 bg-delivered rounded"></div>
|
||||
<span className="text-gray-600">Livré</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Navigation */}
|
||||
<div className="p-6 pb-0">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateMonth('prev')}
|
||||
className="p-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{format(currentDate, 'MMMM yyyy', { locale: fr })}
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateMonth('next')}
|
||||
className="p-2"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowQuickCreate(true)}
|
||||
className="bg-accent hover:bg-orange-600 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouveau
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<CalendarGrid
|
||||
currentDate={currentDate}
|
||||
orders={orders}
|
||||
deliveries={deliveries}
|
||||
onDateClick={handleDateClick}
|
||||
onItemClick={handleItemClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showQuickCreate && (
|
||||
<QuickCreateMenu
|
||||
isOpen={showQuickCreate}
|
||||
onClose={() => setShowQuickCreate(false)}
|
||||
onCreateOrder={handleCreateOrder}
|
||||
onCreateDelivery={handleCreateDelivery}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showOrderDetail && selectedItem && (
|
||||
<OrderDetailModal
|
||||
isOpen={showOrderDetail}
|
||||
onClose={() => setShowOrderDetail(false)}
|
||||
item={selectedItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCreateOrder && (
|
||||
<CreateOrderModal
|
||||
isOpen={showCreateOrder}
|
||||
onClose={() => setShowCreateOrder(false)}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCreateDelivery && (
|
||||
<CreateDeliveryModal
|
||||
isOpen={showCreateDelivery}
|
||||
onClose={() => setShowCreateDelivery(false)}
|
||||
selectedDate={selectedDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats Panel */}
|
||||
<StatsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
import { Plus, Edit, Trash2, Phone, PhoneCall, Printer, Eye, Package } from "lucide-react";
|
||||
import JsBarcode from 'jsbarcode';
|
||||
import { safeFormat, safeDate } from "@/lib/dateUtils";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import type { CustomerOrderWithRelations, Group } from "@shared/schema";
|
||||
import { CustomerOrderForm } from "@/components/CustomerOrderForm";
|
||||
import { CustomerOrderDetails } from "@/components/CustomerOrderDetails";
|
||||
import { ConfirmationModal } from "@/components/ConfirmationModal";
|
||||
import { useStore } from "@/components/Layout";
|
||||
|
||||
export default function CustomerOrders() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showStatusModal, setShowStatusModal] = useState(false);
|
||||
const [selectedOrder, setSelectedOrder] = useState<CustomerOrderWithRelations | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortBy, setSortBy] = useState<"date" | "status" | "supplier">("date");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [filterSupplier, setFilterSupplier] = useState<string>("all");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
|
||||
// Fetch groups for store filter
|
||||
const { data: groups = [] } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
// Fetch suppliers for filter
|
||||
const { data: suppliers = [] } = useQuery({
|
||||
queryKey: ['/api/suppliers'],
|
||||
});
|
||||
|
||||
// Fetch customer orders with store filtering for admins
|
||||
const customerOrdersUrl = `/api/customer-orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
||||
const { data: customerOrders = [], isLoading } = useQuery<CustomerOrderWithRelations[]>({
|
||||
queryKey: [customerOrdersUrl, selectedStoreId],
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => apiRequest('/api/customer-orders', 'POST', data),
|
||||
onSuccess: () => {
|
||||
// Force refresh of the query with store context
|
||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
queryClient.refetchQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
setShowCreateModal(false);
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Commande client créée avec succès",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la création de la commande",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: any }) =>
|
||||
apiRequest(`/api/customer-orders/${id}`, 'PUT', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
setShowEditModal(false);
|
||||
setSelectedOrder(null);
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Commande client mise à jour avec succès",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la mise à jour de la commande",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => apiRequest(`/api/customer-orders/${id}`, 'DELETE'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
setShowDeleteModal(false);
|
||||
setSelectedOrder(null);
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Commande client supprimée avec succès",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la suppression de la commande",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Status mutation
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ id, status }: { id: number; status: string }) =>
|
||||
apiRequest(`/api/customer-orders/${id}`, 'PUT', { status }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Statut de la commande mis à jour",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Notification mutation
|
||||
const notificationMutation = useMutation({
|
||||
mutationFn: ({ id, customerNotified }: { id: number; customerNotified: boolean }) =>
|
||||
apiRequest(`/api/customer-orders/${id}`, 'PUT', { customerNotified }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0]?.toString().includes('/api/customer-orders') });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Statut de notification mis à jour",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "En attente de Commande":
|
||||
return "bg-yellow-50 text-yellow-700 border border-yellow-200";
|
||||
case "Commande en Cours":
|
||||
return "bg-blue-50 text-blue-700 border border-blue-200";
|
||||
case "Disponible":
|
||||
return "bg-green-50 text-green-700 border border-green-200";
|
||||
case "Retiré":
|
||||
return "bg-gray-50 text-gray-700 border border-gray-200";
|
||||
case "Annulé":
|
||||
return "bg-red-50 text-red-700 border border-red-200";
|
||||
default:
|
||||
return "bg-gray-50 text-gray-700 border border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const isGrayedOut = (status: string) => {
|
||||
return status === "Retiré" || status === "Annulé";
|
||||
};
|
||||
|
||||
const canShowButtons = (status: string) => {
|
||||
return status === "Disponible";
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
"En attente de Commande",
|
||||
"Commande en Cours",
|
||||
"Disponible",
|
||||
"Retiré",
|
||||
"Annulé"
|
||||
];
|
||||
|
||||
const handleStatusChange = (id: number, newStatus: string) => {
|
||||
statusMutation.mutate({ id, status: newStatus });
|
||||
};
|
||||
|
||||
const handleCreateOrder = (data: any) => {
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleEditOrder = (data: any) => {
|
||||
if (selectedOrder) {
|
||||
updateMutation.mutate({ id: selectedOrder.id, data });
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (order: CustomerOrderWithRelations) => {
|
||||
setSelectedOrder(order);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const openDetailsModal = (order: CustomerOrderWithRelations) => {
|
||||
setSelectedOrder(order);
|
||||
setShowDetailsModal(true);
|
||||
};
|
||||
|
||||
const openDeleteModal = (order: CustomerOrderWithRelations) => {
|
||||
setSelectedOrder(order);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const openStatusModal = (order: CustomerOrderWithRelations) => {
|
||||
setSelectedOrder(order);
|
||||
setShowStatusModal(true);
|
||||
};
|
||||
|
||||
const handleNotificationToggle = (order: CustomerOrderWithRelations) => {
|
||||
notificationMutation.mutate({
|
||||
id: order.id,
|
||||
customerNotified: !order.customerNotified
|
||||
});
|
||||
};
|
||||
|
||||
// Fonction pour calculer le checksum EAN13
|
||||
const calculateEAN13Checksum = (code: string): string => {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const digit = parseInt(code[i]);
|
||||
sum += i % 2 === 0 ? digit : digit * 3;
|
||||
}
|
||||
const checksum = (10 - (sum % 10)) % 10;
|
||||
return checksum.toString();
|
||||
};
|
||||
|
||||
// Fonction pour générer un code-barres scannable
|
||||
const generateEAN13Barcode = (code: string): string => {
|
||||
try {
|
||||
// Créer un canvas temporaire
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
let processedCode = code.replace(/[^0-9]/g, ''); // Garder que les chiffres
|
||||
|
||||
// Gérer différentes longueurs de codes
|
||||
if (processedCode.length < 12) {
|
||||
processedCode = processedCode.padStart(12, '0');
|
||||
} else if (processedCode.length > 13) {
|
||||
processedCode = processedCode.substring(0, 12);
|
||||
} else if (processedCode.length === 13) {
|
||||
processedCode = processedCode.substring(0, 12);
|
||||
}
|
||||
|
||||
// Ajouter le checksum pour avoir un EAN13 valide
|
||||
const checksum = calculateEAN13Checksum(processedCode);
|
||||
const ean13 = processedCode + checksum;
|
||||
|
||||
console.log('Code original:', code, 'EAN13 généré:', ean13);
|
||||
|
||||
// Générer le code-barres avec jsbarcode
|
||||
JsBarcode(canvas, ean13, {
|
||||
format: "EAN13",
|
||||
width: 2,
|
||||
height: 60,
|
||||
displayValue: false,
|
||||
background: "#ffffff",
|
||||
lineColor: "#000000",
|
||||
margin: 5
|
||||
});
|
||||
|
||||
// Retourner l'image en base64
|
||||
return canvas.toDataURL('image/png');
|
||||
} catch (error) {
|
||||
console.error('Erreur génération code-barres:', error);
|
||||
// Fallback SVG professionnel
|
||||
return 'data:image/svg+xml;base64,' + btoa(`
|
||||
<svg width="250" height="60" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0" y="0" width="250" height="60" fill="white" stroke="#ddd"/>
|
||||
<g stroke="black" stroke-width="2">
|
||||
<line x1="15" y1="10" x2="15" y2="45"/>
|
||||
<line x1="20" y1="10" x2="20" y2="45"/>
|
||||
<line x1="25" y1="10" x2="25" y2="45"/>
|
||||
<line x1="35" y1="10" x2="35" y2="45"/>
|
||||
<line x1="40" y1="10" x2="40" y2="45"/>
|
||||
<line x1="50" y1="10" x2="50" y2="45"/>
|
||||
<line x1="55" y1="10" x2="55" y2="45"/>
|
||||
<line x1="65" y1="10" x2="65" y2="45"/>
|
||||
<line x1="70" y1="10" x2="70" y2="45"/>
|
||||
<line x1="80" y1="10" x2="80" y2="45"/>
|
||||
<line x1="85" y1="10" x2="85" y2="45"/>
|
||||
<line x1="95" y1="10" x2="95" y2="45"/>
|
||||
<line x1="100" y1="10" x2="100" y2="45"/>
|
||||
<line x1="110" y1="10" x2="110" y2="45"/>
|
||||
<line x1="115" y1="10" x2="115" y2="45"/>
|
||||
<line x1="125" y1="10" x2="125" y2="45"/>
|
||||
<line x1="130" y1="10" x2="130" y2="45"/>
|
||||
<line x1="140" y1="10" x2="140" y2="45"/>
|
||||
<line x1="145" y1="10" x2="145" y2="45"/>
|
||||
<line x1="155" y1="10" x2="155" y2="45"/>
|
||||
<line x1="160" y1="10" x2="160" y2="45"/>
|
||||
<line x1="170" y1="10" x2="170" y2="45"/>
|
||||
<line x1="175" y1="10" x2="175" y2="45"/>
|
||||
<line x1="185" y1="10" x2="185" y2="45"/>
|
||||
<line x1="190" y1="10" x2="190" y2="45"/>
|
||||
<line x1="200" y1="10" x2="200" y2="45"/>
|
||||
<line x1="205" y1="10" x2="205" y2="45"/>
|
||||
<line x1="215" y1="10" x2="215" y2="45"/>
|
||||
<line x1="220" y1="10" x2="220" y2="45"/>
|
||||
<line x1="230" y1="10" x2="230" y2="45"/>
|
||||
<line x1="235" y1="10" x2="235" y2="45"/>
|
||||
</g>
|
||||
<text x="125" y="55" text-anchor="middle" font-family="monospace" font-size="10" fill="black">${code}</text>
|
||||
</svg>
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrintLabel = (order: CustomerOrderWithRelations) => {
|
||||
// Ouvrir une nouvelle fenêtre pour imprimer l'étiquette
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
const barcodeDisplay = order.gencode ? generateEAN13Barcode(order.gencode) : '';
|
||||
const isImageBarcode = barcodeDisplay.startsWith('data:');
|
||||
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Commande #${order.id}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #111827;
|
||||
}
|
||||
.status-badge {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f9fafb;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.section-content {
|
||||
color: #1f2937;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.field-row {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.field-label {
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
min-width: 120px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.field-value {
|
||||
color: #111827;
|
||||
}
|
||||
.reference-code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
.quantity-badge {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
}
|
||||
.barcode-section {
|
||||
text-align: center;
|
||||
margin: 15px 0;
|
||||
}
|
||||
.barcode {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #000000;
|
||||
letter-spacing: -1px;
|
||||
margin: 15px 0;
|
||||
line-height: 0.8;
|
||||
background: white;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
text-align: center;
|
||||
word-spacing: -2px;
|
||||
}
|
||||
.barcode-image {
|
||||
max-width: 300px;
|
||||
height: auto;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.barcode-number {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.store-badge {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
}
|
||||
.creation-date {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
.deposit-info {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #fbbf24;
|
||||
}
|
||||
.promo-badge {
|
||||
background-color: #fef2f2;
|
||||
color: #dc2626;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="title">Commande #${order.id}</div>
|
||||
<div class="status-badge">${order.status}</div>
|
||||
</div>
|
||||
<div class="creation-date">Créée le ${safeFormat(order.createdAt, 'dd MMMM yyyy')} à ${safeFormat(order.createdAt, 'HH:mm')}</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
Informations Client
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="field-row">
|
||||
<span class="field-label">Nom:</span>
|
||||
<span class="field-value">${order.customerName}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">📞 Téléphone:</span>
|
||||
<span class="field-value">${order.customerPhone}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
Informations Produit
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="field-row">
|
||||
<span class="field-label">Désignation:</span>
|
||||
</div>
|
||||
<div style="background-color: #f3f4f6; padding: 8px; border-radius: 4px; margin-bottom: 12px;">
|
||||
${order.productDesignation}
|
||||
</div>
|
||||
${order.productReference ? `
|
||||
<div class="field-row">
|
||||
<span class="field-label">Référence:</span>
|
||||
<span class="field-value">
|
||||
<span class="reference-code">${order.productReference}</span>
|
||||
</span>
|
||||
</div>` : ''}
|
||||
<div class="field-row">
|
||||
<span class="field-label">Quantité:</span>
|
||||
<span class="field-value">
|
||||
<span class="quantity-badge">${order.quantity || 1}</span>
|
||||
</span>
|
||||
</div>
|
||||
${order.deposit && parseFloat(order.deposit) > 0 ? `
|
||||
<div class="deposit-info">
|
||||
💰 Acompte versé: ${parseFloat(order.deposit).toFixed(2)}€
|
||||
</div>` : ''}
|
||||
${order.isPromotionalPrice ? `
|
||||
<div class="field-row">
|
||||
<span class="field-label">Prix:</span>
|
||||
<span class="field-value">
|
||||
<span class="promo-badge">🏷️ PRIX PUBLICITÉ</span>
|
||||
</span>
|
||||
</div>` : ''}
|
||||
${order.gencode ? `
|
||||
<div class="field-row">
|
||||
<span class="field-label">Code à barres EAN13:</span>
|
||||
</div>
|
||||
<div class="barcode-section">
|
||||
${isImageBarcode ?
|
||||
`<img src="${barcodeDisplay}" alt="Code-barres EAN13" class="barcode-image" style="max-width: 250px; height: auto; margin: 10px auto; display: block;" />` :
|
||||
`<div class="barcode">${barcodeDisplay}</div>`
|
||||
}
|
||||
<div class="barcode-number">${order.gencode}</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
Informations Commande
|
||||
</div>
|
||||
<div class="section-content">
|
||||
<div class="field-row">
|
||||
<span class="field-label">Prise par:</span>
|
||||
<span class="field-value">${order.orderTaker}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">🏪 Magasin:</span>
|
||||
<span class="field-value">
|
||||
<span class="store-badge">${order.group.name}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">📅 Date de création:</span>
|
||||
<span class="field-value">${safeFormat(order.createdAt, 'dd/MM/yyyy')} à ${safeFormat(order.createdAt, 'HH:mm')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>window.print(); window.close();</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter orders based on search term, supplier, and status
|
||||
const filteredOrders = Array.isArray(customerOrders) ? customerOrders.filter(order => {
|
||||
// Search term filter
|
||||
const matchesSearch = !searchTerm ||
|
||||
order.customerName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.productDesignation.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.customerPhone.includes(searchTerm) ||
|
||||
(order.productReference && order.productReference.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(order.gencode && order.gencode.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||
(order.supplier && order.supplier.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
|
||||
// Supplier filter
|
||||
const matchesSupplier = filterSupplier === "all" ||
|
||||
(order.supplier && order.supplier.id.toString() === filterSupplier);
|
||||
|
||||
// Status filter
|
||||
const matchesStatus = filterStatus === "all" || order.status === filterStatus;
|
||||
|
||||
return matchesSearch && matchesSupplier && matchesStatus;
|
||||
}) : [];
|
||||
|
||||
// Sort orders
|
||||
const sortedOrders = [...filteredOrders].sort((a, b) => {
|
||||
let aValue: string | number;
|
||||
let bValue: string | number;
|
||||
|
||||
switch (sortBy) {
|
||||
case "date":
|
||||
const dateA = safeDate(a.createdAt);
|
||||
const dateB = safeDate(b.createdAt);
|
||||
aValue = dateA ? dateA.getTime() : 0;
|
||||
bValue = dateB ? dateB.getTime() : 0;
|
||||
break;
|
||||
case "status":
|
||||
aValue = a.status;
|
||||
bValue = b.status;
|
||||
break;
|
||||
case "supplier":
|
||||
aValue = a.supplier?.name || "";
|
||||
bValue = b.supplier?.name || "";
|
||||
break;
|
||||
default:
|
||||
const defaultDateA = safeDate(a.createdAt);
|
||||
const defaultDateB = safeDate(b.createdAt);
|
||||
aValue = defaultDateA ? defaultDateA.getTime() : 0;
|
||||
bValue = defaultDateB ? defaultDateB.getTime() : 0;
|
||||
}
|
||||
|
||||
if (sortOrder === "desc") {
|
||||
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
|
||||
} else {
|
||||
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
totalPages,
|
||||
paginatedData: paginatedOrders,
|
||||
totalItems
|
||||
} = usePagination(sortedOrders, 10);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Package className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Commandes Client
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestion des commandes clients et étiquettes
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)} className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle Commande
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Recherche et Filtres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<Input
|
||||
placeholder="Nom client, produit, téléphone, référence, gencode, fournisseur..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select value={filterSupplier} onValueChange={setFilterSupplier}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Fournisseurs" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Fournisseurs</SelectItem>
|
||||
{suppliers.map((supplier: any) => (
|
||||
<SelectItem key={supplier.id} value={supplier.id.toString()}>
|
||||
{supplier.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Status</SelectItem>
|
||||
{statusOptions.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Orders Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Commandes ({totalItems})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div>Chargement...</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>Téléphone</TableHead>
|
||||
<TableHead>Produit</TableHead>
|
||||
<TableHead>Quantité</TableHead>
|
||||
<TableHead>Fournisseur</TableHead>
|
||||
<TableHead>Gencode</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedOrders.map((order) => (
|
||||
<TableRow
|
||||
key={order.id}
|
||||
className={isGrayedOut(order.status) ? "opacity-50" : ""}
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
{order.customerName}
|
||||
</TableCell>
|
||||
<TableCell>{order.customerPhone}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{order.productDesignation}</div>
|
||||
{order.productReference && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Ref: {order.productReference}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{order.quantity || 1}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium text-sm">
|
||||
{order.supplier?.name || "N/A"}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-sm">
|
||||
{order.gencode || "-"}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
className={`${getStatusColor(order.status)} cursor-pointer hover:opacity-80 transition-opacity rounded-none`}
|
||||
onClick={() => openStatusModal(order)}
|
||||
>
|
||||
{order.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{safeFormat(order.createdAt, 'dd/MM/yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDetailsModal(order)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditModal(order)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{canShowButtons(order.status) && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePrintLabel(order)}
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNotificationToggle(order)}
|
||||
className={order.customerNotified ? "bg-green-100" : ""}
|
||||
>
|
||||
{order.customerNotified ? (
|
||||
<PhoneCall className="h-4 w-4" />
|
||||
) : (
|
||||
<Phone className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{user?.role === 'admin' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDeleteModal(order)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalItems > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
className="mt-4 border-t border-gray-200 pt-4"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogContent className="max-w-3xl" style={{ height: 'auto', maxHeight: 'none' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nouvelle Commande Client</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div style={{ transform: 'scale(0.9)', transformOrigin: 'top left', width: '111%' }}>
|
||||
<CustomerOrderForm
|
||||
onSubmit={handleCreateOrder}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent className="max-w-3xl" style={{ height: 'auto', maxHeight: 'none' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la Commande</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div style={{ transform: 'scale(0.9)', transformOrigin: 'top left', width: '111%' }}>
|
||||
{selectedOrder && (
|
||||
<CustomerOrderForm
|
||||
order={selectedOrder}
|
||||
onSubmit={handleEditOrder}
|
||||
onCancel={() => setShowEditModal(false)}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Details Modal */}
|
||||
<Dialog open={showDetailsModal} onOpenChange={setShowDetailsModal}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Détails de la Commande</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedOrder && (
|
||||
<CustomerOrderDetails order={selectedOrder} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Status Change Modal */}
|
||||
<Dialog open={showStatusModal} onOpenChange={setShowStatusModal}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Changer le statut</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sélectionnez un nouveau statut pour cette commande client.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Commande: <span className="font-medium">{selectedOrder?.productDesignation}</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Client: <span className="font-medium">{selectedOrder?.customerName}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
{statusOptions.map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={selectedOrder?.status === status ? "default" : "outline"}
|
||||
className="justify-start"
|
||||
onClick={() => {
|
||||
if (selectedOrder) {
|
||||
handleStatusChange(selectedOrder.id, status);
|
||||
setShowStatusModal(false);
|
||||
}
|
||||
}}
|
||||
disabled={statusMutation.isPending}
|
||||
>
|
||||
<Badge className={`${getStatusColor(status)} mr-2 rounded-none`}>
|
||||
{status}
|
||||
</Badge>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<ConfirmationModal
|
||||
open={showDeleteModal}
|
||||
onOpenChange={setShowDeleteModal}
|
||||
onConfirm={() => selectedOrder && deleteMutation.mutate(selectedOrder.id)}
|
||||
title="Supprimer la commande"
|
||||
description={`Êtes-vous sûr de vouloir supprimer la commande de ${selectedOrder?.customerName} ? Cette action est irréversible.`}
|
||||
confirmText="Supprimer"
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar, Package, ShoppingCart, TrendingUp, Clock, MapPin, User, AlertTriangle, CheckCircle, Truck, FileText, BarChart3, Megaphone, Shield, XCircle, CheckSquare } from "lucide-react";
|
||||
import { safeFormat, safeDate } from "@/lib/dateUtils";
|
||||
import type { PublicityWithRelations } from "@shared/schema";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['/api/stats/monthly', selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const currentDate = new Date();
|
||||
const params = new URLSearchParams({
|
||||
year: currentDate.getFullYear().toString(),
|
||||
month: (currentDate.getMonth() + 1).toString(),
|
||||
});
|
||||
|
||||
if (selectedStoreId && user?.role === 'admin') {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/stats/monthly?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch stats');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
// Construire les URLs pour récupérer toutes les données (pas de filtrage par date)
|
||||
const ordersUrl = `/api/orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
||||
const deliveriesUrl = `/api/deliveries${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
||||
const customerOrdersUrl = `/api/customer-orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
||||
|
||||
// Utiliser les mêmes clés de cache que les autres pages pour assurer la cohérence
|
||||
const { data: allOrders = [] } = useQuery({
|
||||
queryKey: [ordersUrl, selectedStoreId],
|
||||
});
|
||||
|
||||
const { data: allDeliveries = [] } = useQuery({
|
||||
queryKey: [deliveriesUrl, selectedStoreId],
|
||||
});
|
||||
|
||||
const { data: customerOrders = [] } = useQuery({
|
||||
queryKey: [customerOrdersUrl, selectedStoreId],
|
||||
});
|
||||
|
||||
// Récupérer les publicités à venir (chercher dans 2024 ET 2025) - TOUTES les publicités
|
||||
const { data: upcomingPublicities = [] } = useQuery<PublicityWithRelations[]>({
|
||||
queryKey: ['/api/publicities', 'upcoming'],
|
||||
queryFn: async () => {
|
||||
// Essayer d'abord 2024, puis 2025 pour avoir toutes les publicités
|
||||
const years = [2024, 2025];
|
||||
let allPublicities: PublicityWithRelations[] = [];
|
||||
|
||||
for (const year of years) {
|
||||
const params = new URLSearchParams();
|
||||
params.append('year', year.toString());
|
||||
// NE PAS filtrer par magasin - on veut toutes les publicités
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/publicities?${params}`, { credentials: 'include' });
|
||||
if (response.ok) {
|
||||
const yearPublicities = await response.json();
|
||||
allPublicities = [...allPublicities, ...yearPublicities];
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Erreur lors de la récupération des publicités ${year}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les publicités à venir et les trier par date
|
||||
const futurePublicities = allPublicities
|
||||
.filter((publicity: any) => new Date(publicity.startDate) > new Date())
|
||||
.sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
|
||||
return futurePublicities;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch DLC stats
|
||||
const { data: dlcStats = { active: 0, expiringSoon: 0, expired: 0 } } = useQuery({
|
||||
queryKey: ["/api/dlc-products/stats", selectedStoreId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId && user?.role === 'admin') params.append("storeId", selectedStoreId.toString());
|
||||
return fetch(`/api/dlc-products/stats?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
}).then(res => res.json());
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch tasks
|
||||
const { data: tasks = [] } = useQuery({
|
||||
queryKey: ["/api/tasks", selectedStoreId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId && user?.role === 'admin') params.append("storeId", selectedStoreId.toString());
|
||||
return fetch(`/api/tasks?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
}).then(res => res.json());
|
||||
},
|
||||
});
|
||||
|
||||
// Données dérivées pour les sections
|
||||
const recentOrders = Array.isArray(allOrders) ? allOrders
|
||||
.sort((a: any, b: any) => {
|
||||
const dateA = safeDate(a.createdAt);
|
||||
const dateB = safeDate(b.createdAt);
|
||||
return (dateB ? dateB.getTime() : 0) - (dateA ? dateA.getTime() : 0);
|
||||
})
|
||||
.slice(0, 3) : [];
|
||||
|
||||
const upcomingDeliveries = Array.isArray(allDeliveries) ? allDeliveries
|
||||
.filter((d: any) => {
|
||||
const isPlanned = d.status === 'planned';
|
||||
console.log('🚚 Dashboard - Delivery filter:', { id: d.id, status: d.status, isPlanned, scheduledDate: d.scheduledDate });
|
||||
return isPlanned;
|
||||
})
|
||||
.sort((a: any, b: any) => new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime())
|
||||
.slice(0, 2) : [];
|
||||
|
||||
console.log('🚚 Dashboard - Upcoming deliveries result:', upcomingDeliveries.length, upcomingDeliveries);
|
||||
|
||||
// Calculs pour les statistiques
|
||||
const pendingOrdersCount = Array.isArray(allOrders) ? allOrders.filter((order: any) => order.status === 'pending').length : 0;
|
||||
const averageDeliveryTime = Math.round(stats?.averageDeliveryTime || 0);
|
||||
const deliveredThisMonth = Array.isArray(allDeliveries) ? allDeliveries.filter((delivery: any) => {
|
||||
const deliveryDate = safeDate(delivery.deliveredDate || delivery.createdAt);
|
||||
const now = new Date();
|
||||
return deliveryDate.getMonth() === now.getMonth() &&
|
||||
deliveryDate.getFullYear() === now.getFullYear() &&
|
||||
delivery.status === 'delivered';
|
||||
}).length : 0;
|
||||
|
||||
// Calculer le total réel des palettes
|
||||
const totalPalettes = Array.isArray(allDeliveries) ? allDeliveries.reduce((total: number, delivery: any) => {
|
||||
if (delivery.unit === 'palettes') {
|
||||
return total + (delivery.quantity || 0);
|
||||
}
|
||||
return total;
|
||||
}, 0) : 0;
|
||||
|
||||
console.log('📊 Dashboard Debug - Raw Data:', {
|
||||
allOrders: Array.isArray(allOrders) ? allOrders.length : 'NOT_ARRAY',
|
||||
allDeliveries: Array.isArray(allDeliveries) ? allDeliveries.length : 'NOT_ARRAY',
|
||||
customerOrders: Array.isArray(customerOrders) ? customerOrders.length : 'NOT_ARRAY',
|
||||
upcomingPublicities: Array.isArray(upcomingPublicities) ? upcomingPublicities.length : 'NOT_ARRAY',
|
||||
stats: stats,
|
||||
samples: {
|
||||
order: allOrders[0],
|
||||
delivery: allDeliveries[0],
|
||||
customerOrder: customerOrders[0],
|
||||
publicity: upcomingPublicities[0]
|
||||
}
|
||||
});
|
||||
|
||||
// Statistiques pour les commandes clients
|
||||
const ordersByStatus = {
|
||||
pending: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'pending').length : 0,
|
||||
planned: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'planned').length : 0,
|
||||
delivered: Array.isArray(allOrders) ? allOrders.filter((o: any) => o.status === 'delivered').length : 0,
|
||||
total: Array.isArray(allOrders) ? allOrders.length : 0
|
||||
};
|
||||
|
||||
// Statistiques pour les commandes clients (nouveau module)
|
||||
const customerOrderStats = {
|
||||
waiting: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'En attente de Commande').length : 0,
|
||||
inProgress: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Commande en Cours').length : 0,
|
||||
available: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Disponible').length : 0,
|
||||
withdrawn: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Retiré').length : 0,
|
||||
canceled: Array.isArray(customerOrders) ? customerOrders.filter((o: any) => o.status === 'Annulé').length : 0,
|
||||
total: Array.isArray(customerOrders) ? customerOrders.length : 0
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">Tableau de Bord</h2>
|
||||
<p className="text-gray-600 mt-1">Vue d'ensemble des performances et statistiques</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
<div className="space-y-3">
|
||||
{pendingOrdersCount > 0 && (
|
||||
<div className="bg-orange-50 border-l-4 border-orange-400 p-4 flex items-center space-x-3 shadow-sm">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-600" />
|
||||
<span className="text-sm font-medium text-orange-800">
|
||||
<strong>{pendingOrdersCount} commande(s) en attente</strong> nécessitent une planification
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dlcStats.expiringSoon > 0 && (
|
||||
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 flex items-center space-x-3 shadow-sm">
|
||||
<Clock className="h-5 w-5 text-yellow-600" />
|
||||
<span className="text-sm font-medium text-yellow-800">
|
||||
<strong>{dlcStats.expiringSoon} produit(s) DLC</strong> expirent dans les 15 prochains jours
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dlcStats.expired > 0 && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4 flex items-center space-x-3 shadow-sm">
|
||||
<XCircle className="h-5 w-5 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-800">
|
||||
<strong>{dlcStats.expired} produit(s) DLC</strong> sont expirés et nécessitent une action immédiate
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Livraisons ce mois</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">{deliveredThisMonth}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-green-100 flex items-center justify-center">
|
||||
<Truck className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Commandes en attente</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">{pendingOrdersCount}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-orange-100 flex items-center justify-center">
|
||||
<Clock className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Délai moyen (jours)</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">{averageDeliveryTime}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-blue-100 flex items-center justify-center">
|
||||
<BarChart3 className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total palettes</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">{totalPalettes}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 bg-purple-100 flex items-center justify-center">
|
||||
<Package className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Dernières Commandes */}
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<FileText className="h-5 w-5 mr-3 text-blue-600" />
|
||||
Dernières Commandes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
{recentOrders.length > 0 ? recentOrders.map((order: any) => (
|
||||
<div key={order.id} className="flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors border-l-3 border-blue-500">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-2 w-2 bg-blue-500"></div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{order.supplier?.name}</p>
|
||||
<p className="text-sm text-gray-600">{order.group?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge
|
||||
variant={order.status === 'delivered' ? 'default' : order.status === 'planned' ? 'secondary' : 'destructive'}
|
||||
className="text-xs"
|
||||
>
|
||||
{order.status === 'delivered' ? 'Livrée' : order.status === 'planned' ? 'Planifiée' : 'En attente'}
|
||||
</Badge>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{safeFormat(order.plannedDate, "d MMM")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<p className="text-gray-600 text-center py-8">Aucune commande récente</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Livraisons à Venir */}
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<Calendar className="h-5 w-5 mr-3 text-green-600" />
|
||||
Livraisons à Venir
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
{Array.isArray(upcomingDeliveries) && upcomingDeliveries.length > 0 ? upcomingDeliveries.map((delivery: any) => (
|
||||
<div key={delivery.id} className="flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors border-l-3 border-green-500">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-2 w-2 bg-green-500"></div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{delivery.supplier?.name}</p>
|
||||
<p className="text-sm text-gray-600">{delivery.group?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-gray-900 text-sm">
|
||||
{delivery.quantity} {delivery.unit}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{safeFormat(delivery.scheduledDate, "d MMM")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Aucune livraison programmée</p>
|
||||
<p className="text-xs text-gray-400 mt-1">({Array.isArray(allDeliveries) ? allDeliveries.length : 0} livraisons totales)</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Publicités à Venir */}
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<Megaphone className="h-5 w-5 mr-3 text-purple-600" />
|
||||
Publicités à Venir
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
{upcomingPublicities
|
||||
.slice(0, 3)
|
||||
.map((publicity: any) => {
|
||||
const participatingStores = publicity.participations || [];
|
||||
const isCurrentStoreParticipating = selectedStoreId && participatingStores.some((p: any) => p.groupId === parseInt(selectedStoreId));
|
||||
|
||||
return (
|
||||
<div key={publicity.id} className="p-4 bg-gray-50 hover:bg-gray-100 transition-colors border-l-3 border-purple-500 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-2 w-2 bg-purple-500"></div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{publicity.pubNumber}</p>
|
||||
<p className="text-sm text-gray-600 truncate max-w-40">{publicity.designation}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge className="bg-blue-100 text-blue-800 text-xs">
|
||||
À venir
|
||||
</Badge>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{safeFormat(publicity.startDate, "d MMM")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Magasins participants */}
|
||||
<div className="flex items-center space-x-2 text-xs">
|
||||
<span className="text-gray-500">Magasins:</span>
|
||||
{participatingStores.length === 0 ? (
|
||||
<span className="text-red-400">Aucun magasin</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{participatingStores.map((participation: any) => (
|
||||
<Badge
|
||||
key={participation.groupId}
|
||||
className={`text-xs ${
|
||||
selectedStoreId && participation.groupId === parseInt(selectedStoreId)
|
||||
? 'bg-green-100 text-green-800 border border-green-300'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{participation.group.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!Array.isArray(upcomingPublicities) || upcomingPublicities.length === 0) && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Aucune publicité à venir</p>
|
||||
<p className="text-xs text-gray-400 mt-1">(API: {Array.isArray(upcomingPublicities) ? upcomingPublicities.length : 'NOT_ARRAY'})</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Section Rapprochement BL */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Statistiques des commandes clients */}
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<User className="h-5 w-5 mr-3 text-purple-600" />
|
||||
Commandes Clients
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-3 w-3 bg-red-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700">En attente</span>
|
||||
</div>
|
||||
<span className="font-semibold text-red-600 text-lg">{customerOrderStats.waiting}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-3 w-3 bg-orange-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700">En cours</span>
|
||||
</div>
|
||||
<span className="font-semibold text-orange-600 text-lg">{customerOrderStats.inProgress}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-3 w-3 bg-green-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700">Disponibles</span>
|
||||
</div>
|
||||
<span className="font-semibold text-green-600 text-lg">{customerOrderStats.available}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-3 w-3 bg-blue-500"></div>
|
||||
<span className="text-sm font-medium text-gray-700">Retirées</span>
|
||||
</div>
|
||||
<span className="font-semibold text-blue-600 text-lg">{customerOrderStats.withdrawn}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-gray-200 pt-3 mt-3 p-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-3 w-3 bg-gray-500"></div>
|
||||
<span className="text-sm font-semibold text-gray-800">Total commandes</span>
|
||||
</div>
|
||||
<span className="font-bold text-xl text-gray-800">{customerOrderStats.total}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tâches à faire */}
|
||||
<Card className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardHeader className="pb-4 border-b border-gray-100">
|
||||
<CardTitle className="text-lg font-semibold text-gray-800 flex items-center">
|
||||
<CheckSquare className="h-5 w-5 mr-3 text-blue-600" />
|
||||
Tâches à faire
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 p-6">
|
||||
{Array.isArray(tasks) && tasks.length > 0 ? tasks
|
||||
.filter((task: any) => task.status !== 'completed')
|
||||
.sort((a: any, b: any) => {
|
||||
const dateA = safeDate(a.createdAt);
|
||||
const dateB = safeDate(b.createdAt);
|
||||
return (dateA ? dateA.getTime() : 0) - (dateB ? dateB.getTime() : 0);
|
||||
})
|
||||
.slice(0, 5)
|
||||
.map((task: any) => (
|
||||
<div key={task.id} className="flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 transition-colors border-l-3 border-blue-500">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-2 w-2 bg-blue-500"></div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-gray-900 truncate">{task.title}</p>
|
||||
<p className="text-sm text-gray-600 truncate">{task.assignedTo}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Badge
|
||||
variant={task.status === 'completed' ? 'default' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{task.status === 'completed' ? 'Terminé' : 'En cours'}
|
||||
</Badge>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{safeFormat(task.createdAt, "d MMM")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Aucune tâche en cours</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Toutes les tâches sont terminées</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Truck,
|
||||
Calendar,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Building,
|
||||
User,
|
||||
Check,
|
||||
Package
|
||||
} from "lucide-react";
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
import CreateDeliveryModal from "@/components/modals/CreateDeliveryModal";
|
||||
import EditDeliveryModal from "@/components/modals/EditDeliveryModal";
|
||||
import OrderDetailModal from "@/components/modals/OrderDetailModal";
|
||||
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
|
||||
import type { DeliveryWithRelations } from "@shared/schema";
|
||||
|
||||
export default function Deliveries() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Redirection pour les employés
|
||||
if (user?.role === 'employee') {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-orange-50 border-l-4 border-orange-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Truck className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-orange-700">
|
||||
<strong>Accès restreint</strong><br />
|
||||
Seuls les managers et administrateurs peuvent accéder à la page des livraisons.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedDelivery, setSelectedDelivery] = useState<DeliveryWithRelations | null>(null);
|
||||
const [deliveryToDelete, setDeliveryToDelete] = useState<DeliveryWithRelations | null>(null);
|
||||
|
||||
// Construire l'URL pour l'historique complet sans filtrage par date
|
||||
const deliveriesUrl = `/api/deliveries${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
||||
|
||||
const { data: deliveriesData = [], isLoading } = useQuery<DeliveryWithRelations[]>({
|
||||
queryKey: ['/api/deliveries', selectedStoreId, user?.role],
|
||||
queryFn: async () => {
|
||||
const url = deliveriesUrl;
|
||||
console.log('🚚 Fetching deliveries from:', url);
|
||||
const response = await fetch(url, { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch deliveries');
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log('🚚 Deliveries received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items', data.slice(0, 2));
|
||||
console.log('🚚 Sample delivery data:', data[0]);
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
});
|
||||
|
||||
// Production Bug Fix: Ensure array safety for all data operations
|
||||
const deliveries = Array.isArray(deliveriesData) ? deliveriesData : [];
|
||||
|
||||
console.log('🚚 Deliveries Debug:', {
|
||||
isLoading,
|
||||
deliveriesCount: deliveries?.length,
|
||||
deliveries: deliveries?.slice(0, 2),
|
||||
selectedStoreId,
|
||||
deliveriesUrl
|
||||
});
|
||||
|
||||
const { data: groupsData = [] } = useQuery({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
const groups = Array.isArray(groupsData) ? groupsData : [];
|
||||
|
||||
const validateMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest(`/api/deliveries/${id}/validate`, "POST");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Livraison validée avec succès",
|
||||
});
|
||||
// Invalider tous les caches liés aux livraisons
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
|
||||
// Invalider tous les caches BL/Rapprochement
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === '/api/deliveries/bl' ||
|
||||
query.queryKey[0] === '/api/deliveries'
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/orders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/stats/monthly'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de valider la livraison",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest(`/api/deliveries/${id}`, "DELETE");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Livraison supprimée avec succès",
|
||||
});
|
||||
// Invalider tous les caches liés aux livraisons
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/deliveries'] });
|
||||
queryClient.invalidateQueries({ queryKey: [deliveriesUrl] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/stats/monthly'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la livraison",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filteredDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => {
|
||||
console.log('🔍 Filtering delivery:', delivery.id, { searchTerm, statusFilter });
|
||||
const matchesSearch = delivery.supplier?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
delivery.group?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
delivery.notes?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === "all" || delivery.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
}) : [];
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
totalPages,
|
||||
paginatedData: paginatedDeliveries,
|
||||
totalItems
|
||||
} = usePagination(filteredDeliveries, 20);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge variant="secondary">En attente</Badge>;
|
||||
case 'planned':
|
||||
return <Badge className="bg-blue-100 text-blue-800">Planifié</Badge>;
|
||||
case 'delivered':
|
||||
return <Badge className="bg-green-100 text-green-800">Livré</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatQuantity = (quantity: number, unit: string) => {
|
||||
return `${quantity} ${unit === 'palettes' ? 'P' : 'C'}`;
|
||||
};
|
||||
|
||||
const handleViewDelivery = (delivery: DeliveryWithRelations) => {
|
||||
setSelectedDelivery({ ...delivery, type: 'delivery' });
|
||||
setShowDetailModal(true);
|
||||
};
|
||||
|
||||
const handleEditDelivery = (delivery: DeliveryWithRelations) => {
|
||||
setSelectedDelivery(delivery);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleValidateDelivery = (id: number) => {
|
||||
if (window.confirm("Êtes-vous sûr de vouloir valider cette livraison ?")) {
|
||||
validateMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDelivery = (delivery: DeliveryWithRelations) => {
|
||||
setDeliveryToDelete(delivery);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteDelivery = () => {
|
||||
if (deliveryToDelete) {
|
||||
deleteMutation.mutate(deliveryToDelete.id);
|
||||
setShowDeleteModal(false);
|
||||
setDeliveryToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canCreate = true; // All users can create deliveries
|
||||
const canEdit = user?.role === 'admin' || user?.role === 'manager';
|
||||
const canDelete = user?.role === 'admin' || user?.role === 'manager';
|
||||
const canValidate = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Truck className="w-6 h-6 mr-3 text-green-600" />
|
||||
Gestion des Livraisons
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{filteredDeliveries.length} livraison{filteredDeliveries.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-green-600 hover:bg-green-700 text-white shadow-md"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle Livraison
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex-1 min-w-64">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher par fournisseur, groupe ou commentaire..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-48 border border-gray-300 shadow-sm">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
<SelectValue placeholder="Filtrer par statut" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="pending">En attente</SelectItem>
|
||||
<SelectItem value="planned">Planifié</SelectItem>
|
||||
<SelectItem value="delivered">Livré</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deliveries List */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-secondary"></div>
|
||||
</div>
|
||||
) : (console.log('🚚 Filtered deliveries length:', filteredDeliveries.length) || filteredDeliveries.length === 0) ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
||||
<Truck className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium mb-2">Aucune livraison trouvée</h3>
|
||||
<p className="text-center max-w-md">
|
||||
{searchTerm || statusFilter !== "all"
|
||||
? "Aucune livraison ne correspond à vos critères de recherche."
|
||||
: "Vous n'avez pas encore de livraisons. Créez votre première livraison pour commencer."}
|
||||
</p>
|
||||
{canCreate && !searchTerm && statusFilter === "all" && (
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="mt-4 bg-secondary hover:bg-green-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer une livraison
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fournisseur
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Groupe
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-black text-gray-800 uppercase tracking-wider">
|
||||
Date prévue
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-black text-gray-800 uppercase tracking-wider">
|
||||
Quantité
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-black text-gray-800 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Commande liée
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Créé par
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedDeliveries.map((delivery) => (
|
||||
<tr key={delivery.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Building className="w-5 h-5 text-gray-400 mr-2" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{delivery.supplier?.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
#{delivery.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: delivery.group?.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{delivery.group?.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 text-gray-400 mr-2" />
|
||||
{safeFormat(delivery.scheduledDate, 'dd MMM yyyy')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{formatQuantity(delivery.quantity, delivery.unit)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(delivery.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{delivery.order ? (
|
||||
<div className="flex items-center">
|
||||
<Package className="w-4 h-4 text-primary mr-2" />
|
||||
<span>#{delivery.order.id}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">Aucune</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div className="flex items-center">
|
||||
<User className="w-4 h-4 text-gray-400 mr-2" />
|
||||
{delivery.creator?.firstName} {delivery.creator?.lastName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDelivery(delivery)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditDelivery(delivery)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canValidate && delivery.status !== 'delivered' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleValidateDelivery(delivery.id)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
disabled={validateMutation.isPending}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteDelivery(delivery)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
className="mt-4 p-4 border-t border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showCreateModal && (
|
||||
<CreateDeliveryModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
selectedDate={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEditModal && selectedDelivery && (
|
||||
<EditDeliveryModal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
delivery={selectedDelivery}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDetailModal && selectedDelivery && (
|
||||
<OrderDetailModal
|
||||
isOpen={showDetailModal}
|
||||
onClose={() => setShowDetailModal(false)}
|
||||
item={selectedDelivery}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
<ConfirmDeleteModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeliveryToDelete(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteDelivery}
|
||||
title="Supprimer la livraison"
|
||||
description="Êtes-vous sûr de vouloir supprimer cette livraison ?"
|
||||
itemName={deliveryToDelete ? `${deliveryToDelete.supplier?.name} - ${safeFormat(deliveryToDelete.scheduledDate, 'dd/MM/yyyy')}` : undefined}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertTriangle, Plus, Eye, Edit, Trash2, CheckCircle, Package, Clock, AlertCircle, Filter, Download, FileText } from "lucide-react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import type { DlcProductWithRelations, InsertDlcProduct } from "@shared/schema";
|
||||
|
||||
const dlcFormSchema = z.object({
|
||||
productName: z.string().min(1, "Le nom du produit est obligatoire"),
|
||||
gencode: z.string().optional(),
|
||||
dlcDate: z.string().min(1, "La date d'expiration est obligatoire"),
|
||||
dateType: z.enum(["dlc", "ddm", "dluo"], { required_error: "Le type de date est obligatoire" }),
|
||||
supplierId: z.number().min(1, "Le fournisseur est obligatoire"),
|
||||
status: z.enum(["en_cours", "expires_soon", "expires", "valides"]).default("en_cours"),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
type DlcFormData = z.infer<typeof dlcFormSchema>;
|
||||
|
||||
export default function DlcPage() {
|
||||
const { user, isLoading: authLoading } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedStoreId } = useStore();
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [supplierFilter, setSupplierFilter] = useState<string>("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<DlcProductWithRelations | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [productToDelete, setProductToDelete] = useState<number | null>(null);
|
||||
|
||||
// Fetch stores/groups
|
||||
const { data: stores = [] } = useQuery({
|
||||
queryKey: ["/api/groups"],
|
||||
enabled: !authLoading,
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Fetch suppliers with DLC enabled only
|
||||
const { data: suppliers = [] } = useQuery({
|
||||
queryKey: ["/api/suppliers", "dlc"],
|
||||
queryFn: () => apiRequest("/api/suppliers?dlc=true"),
|
||||
enabled: !authLoading,
|
||||
});
|
||||
|
||||
// Fetch DLC products
|
||||
const { data: dlcProducts = [], isLoading: productsLoading } = useQuery({
|
||||
queryKey: ["/api/dlc-products", selectedStoreId, statusFilter, supplierFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId && user?.role === 'admin') params.append("storeId", selectedStoreId.toString());
|
||||
if (statusFilter && statusFilter !== "all") params.append("status", statusFilter);
|
||||
if (supplierFilter && supplierFilter !== "all") params.append("supplierId", supplierFilter);
|
||||
|
||||
return apiRequest(`/api/dlc-products?${params.toString()}`);
|
||||
},
|
||||
enabled: !authLoading,
|
||||
});
|
||||
|
||||
// Fetch DLC stats
|
||||
const { data: stats = { active: 0, expiringSoon: 0, expired: 0 } } = useQuery({
|
||||
queryKey: ["/api/dlc-products/stats", selectedStoreId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId && user?.role === 'admin') params.append("storeId", selectedStoreId.toString());
|
||||
return apiRequest(`/api/dlc-products/stats?${params.toString()}`);
|
||||
},
|
||||
enabled: !authLoading,
|
||||
});
|
||||
|
||||
// Form setup
|
||||
const form = useForm<DlcFormData>({
|
||||
resolver: zodResolver(dlcFormSchema),
|
||||
defaultValues: {
|
||||
productName: "",
|
||||
gencode: "",
|
||||
dateType: "dlc",
|
||||
status: "en_cours",
|
||||
notes: "",
|
||||
},
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: InsertDlcProduct) => apiRequest("/api/dlc-products", "POST", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] });
|
||||
toast({ title: "Produit DLC créé avec succès" });
|
||||
setIsDialogOpen(false);
|
||||
form.reset();
|
||||
setEditingProduct(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur lors de la création",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<InsertDlcProduct> }) =>
|
||||
apiRequest(`/api/dlc-products/${id}`, "PUT", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] });
|
||||
toast({ title: "Produit DLC mis à jour avec succès" });
|
||||
setIsDialogOpen(false);
|
||||
form.reset();
|
||||
setEditingProduct(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur lors de la mise à jour",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Validate mutation
|
||||
const validateMutation = useMutation({
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}/validate`, "PUT"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] });
|
||||
toast({ title: "Produit validé avec succès" });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur lors de la validation",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => apiRequest(`/api/dlc-products/${id}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/dlc-products/stats"] });
|
||||
toast({ title: "Produit supprimé avec succès" });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur lors de la suppression",
|
||||
description: error.message || "Une erreur est survenue",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: DlcFormData) => {
|
||||
// Calculer la date d'expiration et le seuil d'alerte (15 jours avant)
|
||||
const dlcDate = new Date(data.dlcDate);
|
||||
|
||||
const dlcData: InsertDlcProduct = {
|
||||
...data,
|
||||
dlcDate,
|
||||
quantity: 1, // Valeur par défaut
|
||||
unit: "unité", // Valeur par défaut
|
||||
location: "Magasin", // Valeur par défaut
|
||||
alertThreshold: 15, // Toujours 15 jours
|
||||
groupId: selectedStoreId || stores[0]?.id || 2, // Utilise le magasin sélectionné ou le premier disponible
|
||||
};
|
||||
|
||||
if (editingProduct) {
|
||||
updateMutation.mutate({ id: editingProduct.id, data: dlcData });
|
||||
} else {
|
||||
createMutation.mutate(dlcData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (product: DlcProductWithRelations) => {
|
||||
setEditingProduct(product);
|
||||
form.reset({
|
||||
productName: product.productName,
|
||||
gencode: product.gencode || "",
|
||||
dlcDate: product.dlcDate ? format(new Date(product.dlcDate), "yyyy-MM-dd") : "",
|
||||
dateType: product.dateType as "dlc" | "ddm" | "dluo",
|
||||
supplierId: product.supplierId,
|
||||
status: product.status as "en_cours" | "expires_soon" | "expires" | "valides",
|
||||
notes: product.notes || "",
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
setProductToDelete(id);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (productToDelete) {
|
||||
deleteMutation.mutate(productToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setProductToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleValidate = (id: number) => {
|
||||
validateMutation.mutate(id);
|
||||
};
|
||||
|
||||
// Fonction pour déterminer si un produit doit afficher le bouton de validation
|
||||
const shouldShowValidateButton = (product: DlcProductWithRelations) => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
const daysUntilExpiry = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Afficher le bouton si le produit expire dans 15 jours ou moins, ou est déjà expiré
|
||||
return (daysUntilExpiry <= 15 && product.status !== "valide");
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string, dlcDate: string | null) => {
|
||||
if (!dlcDate) return <Badge variant="outline">Non défini</Badge>;
|
||||
|
||||
// LOGIQUE SIMPLIFIÉE ET COHÉRENTE :
|
||||
// 1. Si le statut en base est "valides", afficher "Validé" (peu importe la date)
|
||||
// 2. Sinon, calculer selon la date d'expiration
|
||||
|
||||
if (status === "valides") {
|
||||
return <Badge variant="outline" className="bg-gray-100 text-gray-800">Validé</Badge>;
|
||||
}
|
||||
|
||||
// Pour tous les autres statuts, calculer selon la date
|
||||
const today = new Date();
|
||||
const expiry = new Date(dlcDate);
|
||||
const daysUntilExpiry = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (daysUntilExpiry < 0) {
|
||||
return <Badge variant="destructive">Expiré</Badge>;
|
||||
} else if (daysUntilExpiry <= 15) {
|
||||
return <Badge variant="secondary" className="bg-orange-100 text-orange-800">Expire bientôt</Badge>;
|
||||
} else {
|
||||
return <Badge variant="default" className="bg-green-100 text-green-800">Actif</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const printExpiringSoon = () => {
|
||||
const expiringSoon = filteredProducts.filter(product => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
const diffTime = expiry.getTime() - today.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return diffDays > 0 && diffDays <= 15;
|
||||
});
|
||||
|
||||
const printContent = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Produits expirant bientôt</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
h1 { color: #ff6b35; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Produits expirant dans les 15 prochains jours</h1>
|
||||
<p>Date d'impression: ${format(new Date(), "dd/MM/yyyy HH:mm")}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Produit</th>
|
||||
<th>Code EAN13</th>
|
||||
<th>Date d'expiration</th>
|
||||
<th>Type</th>
|
||||
<th>Fournisseur</th>
|
||||
<th>Jours restants</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${expiringSoon.map(product => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return `
|
||||
<tr>
|
||||
<td>${product.productName}</td>
|
||||
<td>${product.gencode || '-'}</td>
|
||||
<td>${product.dlcDate ? format(new Date(product.dlcDate), "dd/MM/yyyy") : 'N/A'}</td>
|
||||
<td>${product.dateType.toUpperCase()}</td>
|
||||
<td>${product.supplier?.name || 'N/A'}</td>
|
||||
<td>${diffDays}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(printContent);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
}
|
||||
};
|
||||
|
||||
const printExpired = () => {
|
||||
const expired = filteredProducts.filter(product => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
return expiry < today;
|
||||
});
|
||||
|
||||
const printContent = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Produits expirés</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background-color: #f2f2f2; }
|
||||
h1 { color: #dc2626; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Produits expirés</h1>
|
||||
<p>Date d'impression: ${format(new Date(), "dd/MM/yyyy HH:mm")}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Produit</th>
|
||||
<th>Code EAN13</th>
|
||||
<th>Date d'expiration</th>
|
||||
<th>Type</th>
|
||||
<th>Fournisseur</th>
|
||||
<th>Jours dépassés</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${expired.map(product => {
|
||||
const today = new Date();
|
||||
const expiry = new Date(product.dlcDate || new Date());
|
||||
const diffDays = Math.ceil((today.getTime() - expiry.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return `
|
||||
<tr>
|
||||
<td>${product.productName}</td>
|
||||
<td>${product.gencode || '-'}</td>
|
||||
<td>${product.dlcDate ? format(new Date(product.dlcDate), "dd/MM/yyyy") : 'N/A'}</td>
|
||||
<td>${product.dateType.toUpperCase()}</td>
|
||||
<td>${product.supplier?.name || 'N/A'}</td>
|
||||
<td>${diffDays}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(printContent);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
}
|
||||
};
|
||||
|
||||
const filteredProducts = dlcProducts.filter(product => {
|
||||
if (searchTerm) {
|
||||
return product.productName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(product.supplier?.name && product.supplier.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
totalPages,
|
||||
paginatedData: paginatedProducts,
|
||||
totalItems
|
||||
} = usePagination(filteredProducts, 10);
|
||||
|
||||
if (authLoading) {
|
||||
return <div className="flex justify-center items-center h-64">Chargement...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Clock className="w-6 h-6 mr-3 text-orange-600" />
|
||||
Gestion DLC
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestion des dates limites de consommation
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouveau produit DLC
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProduct ? "Modifier le produit DLC" : "Nouveau produit DLC"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="productName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom du produit</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Nom du produit" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="supplierId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Fournisseur</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => field.onChange(parseInt(value))}
|
||||
value={field.value?.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionner un fournisseur" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{suppliers.map((supplier: any) => (
|
||||
<SelectItem key={supplier.id} value={supplier.id.toString()}>
|
||||
{supplier.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gencode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Code EAN13 (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
placeholder="1234567890123"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dlcDate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Date d'expiration</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type de date</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Sélectionner le type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="dlc">DLC (Date Limite de Consommation)</SelectItem>
|
||||
<SelectItem value="ddm">DDM (Date de Durabilité Minimale)</SelectItem>
|
||||
<SelectItem value="dluo">DLUO (Date Limite d'Utilisation Optimale)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} placeholder="Notes ou observations..." />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||
{editingProduct ? "Mettre à jour" : "Créer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5" />
|
||||
Filtres
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Statut</label>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="en_cours">Actifs</SelectItem>
|
||||
<SelectItem value="expires_soon">Expire bientôt</SelectItem>
|
||||
<SelectItem value="expires">Expirés</SelectItem>
|
||||
<SelectItem value="valides">Validés</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Fournisseur</label>
|
||||
<Select value={supplierFilter} onValueChange={setSupplierFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Tous les fournisseurs" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les fournisseurs</SelectItem>
|
||||
{suppliers.map((supplier: any) => (
|
||||
<SelectItem key={supplier.id} value={supplier.id.toString()}>
|
||||
{supplier.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Recherche</label>
|
||||
<Input
|
||||
placeholder="Rechercher un produit..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Produits Actifs</CardTitle>
|
||||
<Package className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
|
||||
<p className="text-xs text-muted-foreground">Produits en cours</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Expire Bientôt</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-orange-600" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={printExpiringSoon}
|
||||
className="h-6 w-6 p-0 hover:bg-orange-100"
|
||||
>
|
||||
<FileText className="h-3 w-3 text-orange-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-orange-600">{stats.expiringSoon}</div>
|
||||
<p className="text-xs text-muted-foreground">Dans les 15 prochains jours</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Expirés</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={printExpired}
|
||||
className="h-6 w-6 p-0 hover:bg-red-100"
|
||||
>
|
||||
<FileText className="h-3 w-3 text-red-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">{stats.expired}</div>
|
||||
<p className="text-xs text-muted-foreground">Nécessitent une action</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Products Table Section */}
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>Produits DLC ({totalItems})</CardTitle>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exporter PDF
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{productsLoading ? (
|
||||
<div className="flex justify-center items-center h-32">Chargement des produits...</div>
|
||||
) : totalItems === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Aucun produit DLC trouvé
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Produit</TableHead>
|
||||
<TableHead>Code EAN13</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Date d'expiration</TableHead>
|
||||
<TableHead>Fournisseur</TableHead>
|
||||
<TableHead>Statut</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedProducts.map((product) => (
|
||||
<TableRow
|
||||
key={product.id}
|
||||
className={product.status === "valide" ? "opacity-60 bg-gray-50 dark:bg-gray-800" : ""}
|
||||
>
|
||||
<TableCell className="font-medium">{product.productName}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{product.gencode || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{product.dateType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{product.dlcDate ? format(new Date(product.dlcDate), "dd/MM/yyyy", { locale: fr }) : 'Date non définie'}
|
||||
</TableCell>
|
||||
<TableCell>{product.supplier?.name || 'Non défini'}</TableCell>
|
||||
<TableCell>{getStatusBadge(product.status, product.dlcDate)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(product)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{shouldShowValidateButton(product) && (user?.role === 'admin' || user?.role === 'manager') && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleValidate(product.id)}
|
||||
disabled={validateMutation.isPending}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(product.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalItems > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
className="mt-4 border-t border-gray-200 pt-4"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmer la suppression</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Êtes-vous sûr de vouloir supprimer ce produit DLC ? Cette action est irréversible.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||
Supprimer
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Users,
|
||||
Edit,
|
||||
Trash2,
|
||||
Package,
|
||||
Truck,
|
||||
Palette
|
||||
} from "lucide-react";
|
||||
import type { Group, NocodbConfig } from "@shared/schema";
|
||||
|
||||
const colorOptions = [
|
||||
{ value: '#1976D2', label: 'Bleu' },
|
||||
{ value: '#388E3C', label: 'Vert' },
|
||||
{ value: '#FF6F00', label: 'Orange' },
|
||||
{ value: '#D32F2F', label: 'Rouge' },
|
||||
{ value: '#7B1FA2', label: 'Violet' },
|
||||
{ value: '#F57C00', label: 'Ambre' },
|
||||
{ value: '#5D4037', label: 'Marron' },
|
||||
{ value: '#455A64', label: 'Bleu gris' },
|
||||
{ value: '#E91E63', label: 'Rose' },
|
||||
{ value: '#00796B', label: 'Sarcelle' },
|
||||
];
|
||||
|
||||
export default function Groups() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
|
||||
const { data: groups = [], isLoading } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
const { data: rawNocodbConfigs = [] } = useQuery<NocodbConfig[]>({
|
||||
queryKey: ['/api/nocodb-config'],
|
||||
});
|
||||
|
||||
// Protection renforcée contre les erreurs TypeError
|
||||
const nocodbConfigs = Array.isArray(rawNocodbConfigs) ? rawNocodbConfigs : [];
|
||||
|
||||
console.log('🔍 Groups NocoDB Debug:', {
|
||||
rawNocodbConfigs,
|
||||
rawType: typeof rawNocodbConfigs,
|
||||
nocodbConfigs,
|
||||
configsType: typeof nocodbConfigs,
|
||||
isArray: Array.isArray(rawNocodbConfigs),
|
||||
length: nocodbConfigs.length,
|
||||
environment: window.location.hostname
|
||||
});
|
||||
|
||||
const { data: orders = [] } = useQuery({
|
||||
queryKey: ['/api/orders'],
|
||||
});
|
||||
|
||||
const { data: deliveries = [] } = useQuery({
|
||||
queryKey: ['/api/deliveries'],
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
console.log('🏪 Frontend: Creating group with data:', data);
|
||||
const result = await apiRequest("/api/groups", "POST", data);
|
||||
console.log('🏪 Frontend: Group creation result:', result);
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe créé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowCreateModal(false);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de créer le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
await apiRequest(`/api/groups/${selectedGroup?.id}`, "PUT", data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe modifié avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de modifier le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest(`/api/groups/${id}`, "DELETE");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe supprimé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filteredGroups = Array.isArray(groups) ? groups.filter(group =>
|
||||
group.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
console.log('🏪 Groups Debug:', {
|
||||
groups: Array.isArray(groups) ? groups.length : 'NOT_ARRAY',
|
||||
orders: Array.isArray(orders) ? orders.length : 'NOT_ARRAY',
|
||||
deliveries: Array.isArray(deliveries) ? deliveries.length : 'NOT_ARRAY',
|
||||
groupsData: groups?.slice(0, 2),
|
||||
ordersData: orders?.slice(0, 2),
|
||||
deliveriesData: deliveries?.slice(0, 2)
|
||||
});
|
||||
|
||||
const getGroupStats = (groupId: number) => {
|
||||
const groupOrders = Array.isArray(orders) ? orders.filter(order => {
|
||||
const match = order.groupId === groupId;
|
||||
if (match) console.log(`🎯 Order ${order.id} matches group ${groupId}`, order);
|
||||
return match;
|
||||
}) : [];
|
||||
|
||||
const groupDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => {
|
||||
const match = delivery.groupId === groupId;
|
||||
if (match) console.log(`🎯 Delivery ${delivery.id} matches group ${groupId}`, delivery);
|
||||
return match;
|
||||
}) : [];
|
||||
|
||||
console.log(`🏪 Group ${groupId} stats:`, {
|
||||
groupOrders: groupOrders.length,
|
||||
groupDeliveries: groupDeliveries.length,
|
||||
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
|
||||
ordersSample: groupOrders.slice(0, 2),
|
||||
deliveriesSample: groupDeliveries.slice(0, 2),
|
||||
allOrdersCount: orders?.length,
|
||||
allDeliveriesCount: deliveries?.length
|
||||
});
|
||||
|
||||
return {
|
||||
orders: groupOrders.length,
|
||||
deliveries: groupDeliveries.length,
|
||||
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (group: Group) => {
|
||||
setSelectedGroup(group);
|
||||
setFormData({
|
||||
name: group.name,
|
||||
color: group.color,
|
||||
nocodbConfigId: group.nocodbConfigId?.toString() || "",
|
||||
nocodbTableId: group.nocodbTableId || "",
|
||||
nocodbTableName: group.nocodbTableName || "",
|
||||
invoiceColumnName: group.invoiceColumnName || "Ref Facture",
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (group: Group) => {
|
||||
const stats = getGroupStats(group.id);
|
||||
|
||||
if (stats.orders > 0 || stats.deliveries > 0) {
|
||||
toast({
|
||||
title: "Suppression impossible",
|
||||
description: "Ce groupe a des commandes ou livraisons associées",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`Êtes-vous sûr de vouloir supprimer le groupe "${group.name}" ?`)) {
|
||||
deleteMutation.mutate(group.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Le nom du groupe est requis",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data with proper type conversion
|
||||
const submitData = {
|
||||
...formData,
|
||||
nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null,
|
||||
};
|
||||
|
||||
if (selectedGroup) {
|
||||
updateMutation.mutate(submitData);
|
||||
} else {
|
||||
createMutation.mutate(submitData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const canManage = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Users className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Gestion des Groupes/Magasins
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{filteredGroups.length} groupe{filteredGroups.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouveau Groupe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-gray-50 border-b border-gray-200 p-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher un groupe..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groups List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
||||
<Users className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{searchTerm ? "Aucun groupe trouvé" : "Aucun groupe"}
|
||||
</h3>
|
||||
<p className="text-center max-w-md">
|
||||
{searchTerm
|
||||
? "Aucun groupe ne correspond à votre recherche."
|
||||
: "Vous n'avez pas encore de groupes. Créez votre premier groupe pour commencer."}
|
||||
</p>
|
||||
{!searchTerm && canManage && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="mt-4 bg-primary hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer un groupe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredGroups.map((group) => {
|
||||
const stats = getGroupStats(group.id);
|
||||
return (
|
||||
<div key={group.id} className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-10 h-10 flex items-center justify-center mr-3"
|
||||
style={{ backgroundColor: group.color }}
|
||||
>
|
||||
<Users className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{group.name}</h3>
|
||||
<p className="text-sm text-gray-500">#{group.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(group)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(group)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-4">
|
||||
<Palette className="w-4 h-4 mr-2 text-gray-500" />
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-4 h-4 rounded mr-2"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">{group.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-primary mb-1">
|
||||
<Package className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.orders}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Commandes</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-secondary mb-1">
|
||||
<Truck className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.deliveries}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Livraisons</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.deliveries > 0 && (
|
||||
<div className="mt-2 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
{stats.delivered} livrées ({Math.round((stats.delivered / stats.deliveries) * 100)}%)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Dialog open={showCreateModal || showEditModal} onOpenChange={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="group-modal-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedGroup ? 'Modifier' : 'Nouveau'} Groupe
|
||||
</DialogTitle>
|
||||
<p id="group-modal-description" className="text-sm text-gray-600 mt-1">
|
||||
{selectedGroup ? 'Modifier les informations du groupe' : 'Créer un nouveau groupe/magasin'}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nom du groupe *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Nom du groupe/magasin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="color">Couleur</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded border"
|
||||
style={{ backgroundColor: formData.color }}
|
||||
/>
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => handleChange('color', e.target.value)}
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{formData.color}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{colorOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`w-8 h-8 rounded border-2 ${
|
||||
formData.color === option.value ? 'border-gray-400' : 'border-gray-200'
|
||||
}`}
|
||||
style={{ backgroundColor: option.value }}
|
||||
onClick={() => handleChange('color', option.value)}
|
||||
title={option.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Configuration NocoDB */}
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<div className="w-4 h-4 bg-blue-500 rounded"></div>
|
||||
<h3 className="font-medium text-gray-900">Configuration NocoDB (Optionnel)</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Configurez la vérification automatique des références de facture dans une table NocoDB.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="nocodbConfig">Configuration NocoDB</Label>
|
||||
<select
|
||||
id="nocodbConfig"
|
||||
value={formData.nocodbConfigId}
|
||||
onChange={(e) => handleChange('nocodbConfigId', e.target.value)}
|
||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Aucune configuration</option>
|
||||
{(nocodbConfigs || []).map(config => (
|
||||
<option key={config.id} value={config.id.toString()}>
|
||||
{config.name} - {config.baseUrl}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.nocodbConfigId && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="nocodbTableId">ID de la table</Label>
|
||||
<Input
|
||||
id="nocodbTableId"
|
||||
value={formData.nocodbTableId}
|
||||
onChange={(e) => handleChange('nocodbTableId', e.target.value)}
|
||||
placeholder="m_xxxxxxxxxxxx (ID de la table NocoDB)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="nocodbTableName">Nom de la table</Label>
|
||||
<Input
|
||||
id="nocodbTableName"
|
||||
value={formData.nocodbTableName}
|
||||
onChange={(e) => handleChange('nocodbTableName', e.target.value)}
|
||||
placeholder="Nom de la table (pour affichage)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="invoiceColumnName">Nom de la colonne des références facture</Label>
|
||||
<Input
|
||||
id="invoiceColumnName"
|
||||
value={formData.invoiceColumnName}
|
||||
onChange={(e) => handleChange('invoiceColumnName', e.target.value)}
|
||||
placeholder="Ref Facture"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
className="bg-primary hover:bg-blue-700"
|
||||
>
|
||||
{createMutation.isPending || updateMutation.isPending
|
||||
? "Enregistrement..."
|
||||
: (selectedGroup ? "Modifier" : "Créer")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Users,
|
||||
Edit,
|
||||
Trash2,
|
||||
Package,
|
||||
Truck,
|
||||
Palette
|
||||
} from "lucide-react";
|
||||
import type { Group, NocodbConfig } from "@shared/schema";
|
||||
|
||||
const colorOptions = [
|
||||
{ value: '#1976D2', label: 'Bleu' },
|
||||
{ value: '#388E3C', label: 'Vert' },
|
||||
{ value: '#FF6F00', label: 'Orange' },
|
||||
{ value: '#D32F2F', label: 'Rouge' },
|
||||
{ value: '#7B1FA2', label: 'Violet' },
|
||||
{ value: '#F57C00', label: 'Ambre' },
|
||||
{ value: '#5D4037', label: 'Marron' },
|
||||
{ value: '#455A64', label: 'Bleu gris' },
|
||||
{ value: '#E91E63', label: 'Rose' },
|
||||
{ value: '#00796B', label: 'Sarcelle' },
|
||||
];
|
||||
|
||||
export default function Groups() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
|
||||
const { data: groups = [], isLoading } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
const { data: rawNocodbConfigs = [] } = useQuery<NocodbConfig[]>({
|
||||
queryKey: ['/api/nocodb-config'],
|
||||
});
|
||||
|
||||
// Protection contre les erreurs TypeError
|
||||
const nocodbConfigs = Array.isArray(rawNocodbConfigs) ? rawNocodbConfigs : [];
|
||||
|
||||
console.log('🔍 Groups NocoDB Debug:', {
|
||||
rawNocodbConfigs,
|
||||
nocodbConfigs,
|
||||
isArray: Array.isArray(rawNocodbConfigs)
|
||||
});
|
||||
|
||||
const { data: orders = [] } = useQuery({
|
||||
queryKey: ['/api/orders'],
|
||||
});
|
||||
|
||||
const { data: deliveries = [] } = useQuery({
|
||||
queryKey: ['/api/deliveries'],
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
await apiRequest("/api/groups", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe créé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowCreateModal(false);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de créer le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
await apiRequest(`/api/groups/${selectedGroup?.id}`, {
|
||||
method: "PUT",
|
||||
body: data,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe modifié avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de modifier le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest("DELETE", `/api/groups/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Groupe supprimé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer le groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filteredGroups = Array.isArray(groups) ? groups.filter(group =>
|
||||
group.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
console.log('🏪 Groups Debug:', {
|
||||
groups: Array.isArray(groups) ? groups.length : 'NOT_ARRAY',
|
||||
orders: Array.isArray(orders) ? orders.length : 'NOT_ARRAY',
|
||||
deliveries: Array.isArray(deliveries) ? deliveries.length : 'NOT_ARRAY',
|
||||
groupsData: groups?.slice(0, 2),
|
||||
ordersData: orders?.slice(0, 2),
|
||||
deliveriesData: deliveries?.slice(0, 2)
|
||||
});
|
||||
|
||||
const getGroupStats = (groupId: number) => {
|
||||
const groupOrders = Array.isArray(orders) ? orders.filter(order => {
|
||||
const match = order.groupId === groupId;
|
||||
if (match) console.log(`🎯 Order ${order.id} matches group ${groupId}`, order);
|
||||
return match;
|
||||
}) : [];
|
||||
|
||||
const groupDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => {
|
||||
const match = delivery.groupId === groupId;
|
||||
if (match) console.log(`🎯 Delivery ${delivery.id} matches group ${groupId}`, delivery);
|
||||
return match;
|
||||
}) : [];
|
||||
|
||||
console.log(`🏪 Group ${groupId} stats:`, {
|
||||
groupOrders: groupOrders.length,
|
||||
groupDeliveries: groupDeliveries.length,
|
||||
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
|
||||
ordersSample: groupOrders.slice(0, 2),
|
||||
deliveriesSample: groupDeliveries.slice(0, 2),
|
||||
allOrdersCount: orders?.length,
|
||||
allDeliveriesCount: deliveries?.length
|
||||
});
|
||||
|
||||
return {
|
||||
orders: groupOrders.length,
|
||||
deliveries: groupDeliveries.length,
|
||||
delivered: groupDeliveries.filter(d => d.status === 'delivered').length,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (group: Group) => {
|
||||
setSelectedGroup(group);
|
||||
setFormData({
|
||||
name: group.name,
|
||||
color: group.color,
|
||||
nocodbConfigId: group.nocodbConfigId?.toString() || "",
|
||||
nocodbTableId: group.nocodbTableId || "",
|
||||
nocodbTableName: group.nocodbTableName || "",
|
||||
invoiceColumnName: group.invoiceColumnName || "Ref Facture",
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (group: Group) => {
|
||||
const stats = getGroupStats(group.id);
|
||||
|
||||
if (stats.orders > 0 || stats.deliveries > 0) {
|
||||
toast({
|
||||
title: "Suppression impossible",
|
||||
description: "Ce groupe a des commandes ou livraisons associées",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`Êtes-vous sûr de vouloir supprimer le groupe "${group.name}" ?`)) {
|
||||
deleteMutation.mutate(group.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Le nom du groupe est requis",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare data with proper type conversion
|
||||
const submitData = {
|
||||
...formData,
|
||||
nocodbConfigId: formData.nocodbConfigId ? parseInt(formData.nocodbConfigId) : null,
|
||||
};
|
||||
|
||||
if (selectedGroup) {
|
||||
updateMutation.mutate(submitData);
|
||||
} else {
|
||||
createMutation.mutate(submitData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const canManage = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Users className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Gestion des Groupes/Magasins
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{filteredGroups.length} groupe{filteredGroups.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouveau Groupe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-gray-50 border-b border-gray-200 p-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher un groupe..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Groups List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : filteredGroups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
||||
<Users className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{searchTerm ? "Aucun groupe trouvé" : "Aucun groupe"}
|
||||
</h3>
|
||||
<p className="text-center max-w-md">
|
||||
{searchTerm
|
||||
? "Aucun groupe ne correspond à votre recherche."
|
||||
: "Vous n'avez pas encore de groupes. Créez votre premier groupe pour commencer."}
|
||||
</p>
|
||||
{!searchTerm && canManage && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="mt-4 bg-primary hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer un groupe
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredGroups.map((group) => {
|
||||
const stats = getGroupStats(group.id);
|
||||
return (
|
||||
<div key={group.id} className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-10 h-10 flex items-center justify-center mr-3"
|
||||
style={{ backgroundColor: group.color }}
|
||||
>
|
||||
<Users className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{group.name}</h3>
|
||||
<p className="text-sm text-gray-500">#{group.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(group)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(group)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-4">
|
||||
<Palette className="w-4 h-4 mr-2 text-gray-500" />
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-4 h-4 rounded mr-2"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">{group.color}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-primary mb-1">
|
||||
<Package className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.orders}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Commandes</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-secondary mb-1">
|
||||
<Truck className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.deliveries}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Livraisons</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.deliveries > 0 && (
|
||||
<div className="mt-2 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
{stats.delivered} livrées ({Math.round((stats.delivered / stats.deliveries) * 100)}%)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Dialog open={showCreateModal || showEditModal} onOpenChange={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="group-modal-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedGroup ? 'Modifier' : 'Nouveau'} Groupe
|
||||
</DialogTitle>
|
||||
<p id="group-modal-description" className="text-sm text-gray-600 mt-1">
|
||||
{selectedGroup ? 'Modifier les informations du groupe' : 'Créer un nouveau groupe/magasin'}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nom du groupe *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Nom du groupe/magasin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="color">Couleur</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded border"
|
||||
style={{ backgroundColor: formData.color }}
|
||||
/>
|
||||
<Input
|
||||
id="color"
|
||||
type="color"
|
||||
value={formData.color}
|
||||
onChange={(e) => handleChange('color', e.target.value)}
|
||||
className="w-20 h-8"
|
||||
/>
|
||||
<span className="text-sm text-gray-500">{formData.color}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{colorOptions.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`w-8 h-8 rounded border-2 ${
|
||||
formData.color === option.value ? 'border-gray-400' : 'border-gray-200'
|
||||
}`}
|
||||
style={{ backgroundColor: option.value }}
|
||||
onClick={() => handleChange('color', option.value)}
|
||||
title={option.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Configuration NocoDB */}
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<div className="w-4 h-4 bg-blue-500 rounded"></div>
|
||||
<h3 className="font-medium text-gray-900">Configuration NocoDB (Optionnel)</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Configurez la vérification automatique des références de facture dans une table NocoDB.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="nocodbConfig">Configuration NocoDB</Label>
|
||||
<select
|
||||
id="nocodbConfig"
|
||||
value={formData.nocodbConfigId}
|
||||
onChange={(e) => handleChange('nocodbConfigId', e.target.value)}
|
||||
className="w-full mt-1 p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Aucune configuration</option>
|
||||
{(nocodbConfigs || []).map(config => (
|
||||
<option key={config.id} value={config.id.toString()}>
|
||||
{config.name} - {config.baseUrl}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{formData.nocodbConfigId && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="nocodbTableId">ID de la table</Label>
|
||||
<Input
|
||||
id="nocodbTableId"
|
||||
value={formData.nocodbTableId}
|
||||
onChange={(e) => handleChange('nocodbTableId', e.target.value)}
|
||||
placeholder="m_xxxxxxxxxxxx (ID de la table NocoDB)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="nocodbTableName">Nom de la table</Label>
|
||||
<Input
|
||||
id="nocodbTableName"
|
||||
value={formData.nocodbTableName}
|
||||
onChange={(e) => handleChange('nocodbTableName', e.target.value)}
|
||||
placeholder="Nom de la table (pour affichage)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="invoiceColumnName">Nom de la colonne des références facture</Label>
|
||||
<Input
|
||||
id="invoiceColumnName"
|
||||
value={formData.invoiceColumnName}
|
||||
onChange={(e) => handleChange('invoiceColumnName', e.target.value)}
|
||||
placeholder="Ref Facture"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedGroup(null);
|
||||
setFormData({
|
||||
name: "",
|
||||
color: "#1976D2",
|
||||
nocodbConfigId: "",
|
||||
nocodbTableId: "",
|
||||
nocodbTableName: "",
|
||||
invoiceColumnName: "Ref Facture",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
className="bg-primary hover:bg-blue-700"
|
||||
>
|
||||
{createMutation.isPending || updateMutation.isPending
|
||||
? "Enregistrement..."
|
||||
: (selectedGroup ? "Modifier" : "Créer")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Store, Truck, Package, BarChart3 } from "lucide-react";
|
||||
|
||||
export default function Landing() {
|
||||
const handleLogin = () => {
|
||||
window.location.href = "/api/login";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-accent rounded-lg flex items-center justify-center">
|
||||
<Store className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">LogiFlow</h1>
|
||||
<p className="text-sm text-gray-500">La Foir'Fouille</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleLogin} className="bg-primary hover:bg-blue-700">
|
||||
Se connecter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Système de Gestion Logistique
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8 max-w-3xl mx-auto">
|
||||
Gérez efficacement vos commandes et livraisons avec notre plateforme
|
||||
centralisée pour tous vos magasins La Foir'Fouille.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
size="lg"
|
||||
className="bg-primary hover:bg-blue-700 text-white px-8 py-3 text-lg"
|
||||
>
|
||||
Commencer maintenant
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div className="mt-20 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<Card className="text-center">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 bg-primary rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Package className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Gestion des Commandes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600">
|
||||
Suivez et gérez toutes vos commandes fournisseurs en temps réel
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="text-center">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 bg-secondary rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Truck className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Suivi des Livraisons</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600">
|
||||
Planifiez et validez vos livraisons avec quantités précises
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="text-center">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 bg-accent rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<BarChart3 className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Tableaux de Bord</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600">
|
||||
Analysez vos performances avec des statistiques détaillées
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="text-center">
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 bg-delivered rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Store className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Multi-Magasins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-600">
|
||||
Gérez plusieurs magasins avec des permissions granulaires
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Call to Action */}
|
||||
<div className="mt-20 text-center">
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 max-w-2xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Prêt à optimiser votre logistique ?
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Connectez-vous pour accéder à votre tableau de bord personnalisé et
|
||||
commencer à gérer vos commandes et livraisons dès maintenant.
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
size="lg"
|
||||
className="bg-primary hover:bg-blue-700 text-white px-8 py-3"
|
||||
>
|
||||
Accéder à LogiFlow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Database,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import type { NocodbConfig } from "@shared/schema";
|
||||
|
||||
// Form schemas
|
||||
const nocodbConfigFormSchema = z.object({
|
||||
name: z.string().min(1, "Le nom est obligatoire"),
|
||||
baseUrl: z.string().url("L'URL doit être valide").min(1, "L'URL de base est obligatoire"),
|
||||
apiToken: z.string().min(1, "Le token API est obligatoire"),
|
||||
projectId: z.string().min(1, "L'ID du projet est obligatoire"),
|
||||
description: z.string().optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type NocodbConfigForm = z.infer<typeof nocodbConfigFormSchema>;
|
||||
|
||||
export default function NocoDBConfig() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedConfig, setSelectedConfig] = useState<NocodbConfig | null>(null);
|
||||
const [showTokens, setShowTokens] = useState<{ [key: number]: boolean }>({});
|
||||
|
||||
// Queries avec protection complète
|
||||
const { data: rawConfigs, isLoading, error } = useQuery({
|
||||
queryKey: ['/api/nocodb-config'],
|
||||
enabled: user?.role === 'admin',
|
||||
});
|
||||
|
||||
// Protection quadruple couche pour éviter les erreurs TypeError
|
||||
const configs = rawConfigs || [];
|
||||
const safeConfigs = Array.isArray(configs) ? configs : [];
|
||||
|
||||
// Log pour debug production avec plus de détails
|
||||
console.log('🔍 NocoDBConfig Debug:', {
|
||||
rawConfigs,
|
||||
rawConfigsType: typeof rawConfigs,
|
||||
configs,
|
||||
configsType: typeof configs,
|
||||
isArray: Array.isArray(configs),
|
||||
safeConfigs,
|
||||
safeConfigsType: typeof safeConfigs,
|
||||
length: safeConfigs.length,
|
||||
error,
|
||||
userRole: user?.role,
|
||||
environment: window.location.hostname
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createConfigMutation = useMutation({
|
||||
mutationFn: (data: NocodbConfigForm) =>
|
||||
apiRequest('/api/nocodb-config', 'POST', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowCreateModal(false);
|
||||
toast({
|
||||
title: "Configuration créée",
|
||||
description: "La configuration NocoDB a été créée avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la création de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<NocodbConfigForm> }) =>
|
||||
apiRequest(`/api/nocodb-config/${id}`, 'PUT', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedConfig(null);
|
||||
toast({
|
||||
title: "Configuration mise à jour",
|
||||
description: "La configuration NocoDB a été mise à jour avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la mise à jour de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteConfigMutation = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiRequest(`/api/nocodb-config/${id}`, 'DELETE'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowDeleteModal(false);
|
||||
setSelectedConfig(null);
|
||||
toast({
|
||||
title: "Configuration supprimée",
|
||||
description: "La configuration NocoDB a été supprimée avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la suppression de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Forms
|
||||
const createForm = useForm<NocodbConfigForm>({
|
||||
resolver: zodResolver(nocodbConfigFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
apiToken: "",
|
||||
projectId: "",
|
||||
description: "",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const editForm = useForm<NocodbConfigForm>({
|
||||
resolver: zodResolver(nocodbConfigFormSchema),
|
||||
});
|
||||
|
||||
// Handle create config
|
||||
const handleCreateConfig = (data: NocodbConfigForm) => {
|
||||
createConfigMutation.mutate(data);
|
||||
};
|
||||
|
||||
// Handle edit config
|
||||
const handleEditConfig = (data: NocodbConfigForm) => {
|
||||
if (!selectedConfig) return;
|
||||
updateConfigMutation.mutate({ id: selectedConfig.id, data });
|
||||
};
|
||||
|
||||
// Handle delete config
|
||||
const handleDeleteConfig = () => {
|
||||
if (!selectedConfig) return;
|
||||
deleteConfigMutation.mutate(selectedConfig.id);
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEditModal = (config: NocodbConfig) => {
|
||||
setSelectedConfig(config);
|
||||
editForm.reset({
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
apiToken: config.apiToken,
|
||||
projectId: config.projectId,
|
||||
description: config.description || "",
|
||||
isActive: config.isActive,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Open delete modal
|
||||
const openDeleteModal = (config: NocodbConfig) => {
|
||||
setSelectedConfig(config);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// Toggle token visibility
|
||||
const toggleTokenVisibility = (configId: number) => {
|
||||
setShowTokens(prev => ({
|
||||
...prev,
|
||||
[configId]: !prev[configId]
|
||||
}));
|
||||
};
|
||||
|
||||
// Format token for display
|
||||
const formatToken = (token: string, isVisible: boolean) => {
|
||||
if (isVisible) return token;
|
||||
return '•'.repeat(Math.min(token.length, 20));
|
||||
};
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-yellow-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Accès restreint</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Seuls les administrateurs peuvent gérer les configurations NocoDB.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Database className="h-8 w-8 text-blue-600" />
|
||||
Configuration NocoDB
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les connexions aux bases de données NocoDB externes pour récupérer des informations.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Nouvelle Configuration
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Configurations List */}
|
||||
<div className="grid gap-6">
|
||||
{safeConfigs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center">
|
||||
<Database className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Aucune configuration</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Commencez par créer votre première configuration NocoDB.
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateModal(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Créer une configuration
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
safeConfigs.map((config: NocodbConfig) => (
|
||||
<Card key={config.id} className="border shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-lg">{config.name}</CardTitle>
|
||||
<Badge variant={config.isActive ? "default" : "secondary"}>
|
||||
{config.isActive ? (
|
||||
<><CheckCircle className="h-3 w-3 mr-1" /> Actif</>
|
||||
) : (
|
||||
<><XCircle className="h-3 w-3 mr-1" /> Inactif</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
{config.description && (
|
||||
<CardDescription className="mt-1">
|
||||
{config.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditModal(config)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDeleteModal(config)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">URL de base</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Link className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{config.baseUrl}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">ID du projet</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{config.projectId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Token API</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded flex-1">
|
||||
{formatToken(config.apiToken, showTokens[config.id])}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleTokenVisibility(config.id)}
|
||||
>
|
||||
{showTokens[config.id] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="create-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nouvelle Configuration NocoDB</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="create-config-description" className="sr-only">
|
||||
Créer une nouvelle configuration pour se connecter à une base de données NocoDB externe
|
||||
</div>
|
||||
<Form {...createForm}>
|
||||
<form onSubmit={createForm.handleSubmit(handleCreateConfig)} className="space-y-4">
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom de la configuration</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ex: Production NocoDB" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="baseUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL de base</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://your-nocodb-instance.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Personal API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xc-token-xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID du projet</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="p_xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description de cette configuration..."
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Configuration active</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Activer cette configuration pour l'utiliser
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
disabled={createConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createConfigMutation.isPending}
|
||||
>
|
||||
{createConfigMutation.isPending ? "Création..." : "Créer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="edit-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="edit-config-description" className="sr-only">
|
||||
Modifier les paramètres de la configuration NocoDB sélectionnée
|
||||
</div>
|
||||
<Form {...editForm}>
|
||||
<form onSubmit={editForm.handleSubmit(handleEditConfig)} className="space-y-4">
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom de la configuration</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ex: Production NocoDB" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="baseUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL de base</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://your-nocodb-instance.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Personal API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xc-token-xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID du projet</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="p_xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description de cette configuration..."
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Configuration active</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Activer cette configuration pour l'utiliser
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowEditModal(false)}
|
||||
disabled={updateConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateConfigMutation.isPending}
|
||||
>
|
||||
{updateConfigMutation.isPending ? "Mise à jour..." : "Mettre à jour"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="delete-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmer la suppression</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="delete-config-description" className="sr-only">
|
||||
Confirmer la suppression définitive de la configuration NocoDB
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Êtes-vous sûr de vouloir supprimer la configuration "{selectedConfig?.name}" ?
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
disabled={deleteConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfig}
|
||||
disabled={deleteConfigMutation.isPending}
|
||||
>
|
||||
{deleteConfigMutation.isPending ? "Suppression..." : "Supprimer"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Database,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Link,
|
||||
} from "lucide-react";
|
||||
import type { NocodbConfig } from "@shared/schema";
|
||||
|
||||
// Form schemas
|
||||
const nocodbConfigFormSchema = z.object({
|
||||
name: z.string().min(1, "Le nom est obligatoire"),
|
||||
baseUrl: z.string().url("L'URL doit être valide").min(1, "L'URL de base est obligatoire"),
|
||||
apiToken: z.string().min(1, "Le token API est obligatoire"),
|
||||
projectId: z.string().min(1, "L'ID du projet est obligatoire"),
|
||||
description: z.string().optional(),
|
||||
isActive: z.boolean().default(true),
|
||||
});
|
||||
|
||||
type NocodbConfigForm = z.infer<typeof nocodbConfigFormSchema>;
|
||||
|
||||
export default function NocoDBConfig() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedConfig, setSelectedConfig] = useState<NocodbConfig | null>(null);
|
||||
const [showTokens, setShowTokens] = useState<{ [key: number]: boolean }>({});
|
||||
|
||||
// Queries avec protection complète
|
||||
const { data: rawConfigs, isLoading, error } = useQuery({
|
||||
queryKey: ['/api/nocodb-config'],
|
||||
enabled: user?.role === 'admin',
|
||||
});
|
||||
|
||||
// Protection triple couche pour éviter les erreurs TypeError
|
||||
const configs = rawConfigs || [];
|
||||
const safeConfigs = Array.isArray(configs) ? configs : [];
|
||||
|
||||
// Log pour debug production
|
||||
console.log('🔍 NocoDBConfig Debug:', {
|
||||
rawConfigs,
|
||||
configs,
|
||||
isArray: Array.isArray(configs),
|
||||
safeConfigs,
|
||||
length: safeConfigs.length,
|
||||
error,
|
||||
userRole: user?.role
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createConfigMutation = useMutation({
|
||||
mutationFn: (data: NocodbConfigForm) =>
|
||||
apiRequest('/api/nocodb-config', 'POST', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowCreateModal(false);
|
||||
toast({
|
||||
title: "Configuration créée",
|
||||
description: "La configuration NocoDB a été créée avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la création de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: Partial<NocodbConfigForm> }) =>
|
||||
apiRequest(`/api/nocodb-config/${id}`, 'PUT', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedConfig(null);
|
||||
toast({
|
||||
title: "Configuration mise à jour",
|
||||
description: "La configuration NocoDB a été mise à jour avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la mise à jour de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteConfigMutation = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
apiRequest(`/api/nocodb-config/${id}`, 'DELETE'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/nocodb-config'] });
|
||||
setShowDeleteModal(false);
|
||||
setSelectedConfig(null);
|
||||
toast({
|
||||
title: "Configuration supprimée",
|
||||
description: "La configuration NocoDB a été supprimée avec succès.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: error.message || "Erreur lors de la suppression de la configuration.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Forms
|
||||
const createForm = useForm<NocodbConfigForm>({
|
||||
resolver: zodResolver(nocodbConfigFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
baseUrl: "",
|
||||
apiToken: "",
|
||||
projectId: "",
|
||||
description: "",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const editForm = useForm<NocodbConfigForm>({
|
||||
resolver: zodResolver(nocodbConfigFormSchema),
|
||||
});
|
||||
|
||||
// Handle create config
|
||||
const handleCreateConfig = (data: NocodbConfigForm) => {
|
||||
createConfigMutation.mutate(data);
|
||||
};
|
||||
|
||||
// Handle edit config
|
||||
const handleEditConfig = (data: NocodbConfigForm) => {
|
||||
if (!selectedConfig) return;
|
||||
updateConfigMutation.mutate({ id: selectedConfig.id, data });
|
||||
};
|
||||
|
||||
// Handle delete config
|
||||
const handleDeleteConfig = () => {
|
||||
if (!selectedConfig) return;
|
||||
deleteConfigMutation.mutate(selectedConfig.id);
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEditModal = (config: NocodbConfig) => {
|
||||
setSelectedConfig(config);
|
||||
editForm.reset({
|
||||
name: config.name,
|
||||
baseUrl: config.baseUrl,
|
||||
apiToken: config.apiToken,
|
||||
projectId: config.projectId,
|
||||
description: config.description || "",
|
||||
isActive: config.isActive,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
// Open delete modal
|
||||
const openDeleteModal = (config: NocodbConfig) => {
|
||||
setSelectedConfig(config);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// Toggle token visibility
|
||||
const toggleTokenVisibility = (configId: number) => {
|
||||
setShowTokens(prev => ({
|
||||
...prev,
|
||||
[configId]: !prev[configId]
|
||||
}));
|
||||
};
|
||||
|
||||
// Format token for display
|
||||
const formatToken = (token: string, isVisible: boolean) => {
|
||||
if (isVisible) return token;
|
||||
return '•'.repeat(Math.min(token.length, 20));
|
||||
};
|
||||
|
||||
if (user?.role !== 'admin') {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-yellow-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Accès restreint</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Seuls les administrateurs peuvent gérer les configurations NocoDB.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<Database className="h-8 w-8 text-blue-600" />
|
||||
Configuration NocoDB
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Gérez les connexions aux bases de données NocoDB externes pour récupérer des informations.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Nouvelle Configuration
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Configurations List */}
|
||||
<div className="grid gap-6">
|
||||
{safeConfigs.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center">
|
||||
<Database className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Aucune configuration</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Commencez par créer votre première configuration NocoDB.
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateModal(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Créer une configuration
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
safeConfigs.map((config: NocodbConfig) => (
|
||||
<Card key={config.id} className="border shadow-sm">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-lg">{config.name}</CardTitle>
|
||||
<Badge variant={config.isActive ? "default" : "secondary"}>
|
||||
{config.isActive ? (
|
||||
<><CheckCircle className="h-3 w-3 mr-1" /> Actif</>
|
||||
) : (
|
||||
<><XCircle className="h-3 w-3 mr-1" /> Inactif</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
{config.description && (
|
||||
<CardDescription className="mt-1">
|
||||
{config.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditModal(config)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openDeleteModal(config)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">URL de base</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Link className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{config.baseUrl}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">ID du projet</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
|
||||
{config.projectId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-600">Token API</label>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-sm font-mono bg-gray-100 px-2 py-1 rounded flex-1">
|
||||
{formatToken(config.apiToken, showTokens[config.id])}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleTokenVisibility(config.id)}
|
||||
>
|
||||
{showTokens[config.id] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="create-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nouvelle Configuration NocoDB</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="create-config-description" className="sr-only">
|
||||
Créer une nouvelle configuration pour se connecter à une base de données NocoDB externe
|
||||
</div>
|
||||
<Form {...createForm}>
|
||||
<form onSubmit={createForm.handleSubmit(handleCreateConfig)} className="space-y-4">
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom de la configuration</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ex: Production NocoDB" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="baseUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL de base</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://your-nocodb-instance.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Personal API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xc-token-xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID du projet</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="p_xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description de cette configuration..."
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Configuration active</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Activer cette configuration pour l'utiliser
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
disabled={createConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createConfigMutation.isPending}
|
||||
>
|
||||
{createConfigMutation.isPending ? "Création..." : "Créer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="edit-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la Configuration</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="edit-config-description" className="sr-only">
|
||||
Modifier les paramètres de la configuration NocoDB sélectionnée
|
||||
</div>
|
||||
<Form {...editForm}>
|
||||
<form onSubmit={editForm.handleSubmit(handleEditConfig)} className="space-y-4">
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nom de la configuration</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Ex: Production NocoDB" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="baseUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>URL de base</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://your-nocodb-instance.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Personal API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="xc-token-xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="projectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID du projet</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="p_xxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optionnel)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description de cette configuration..."
|
||||
rows={3}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Configuration active</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Activer cette configuration pour l'utiliser
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowEditModal(false)}
|
||||
disabled={updateConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateConfigMutation.isPending}
|
||||
>
|
||||
{updateConfigMutation.isPending ? "Mise à jour..." : "Mettre à jour"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="delete-config-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmer la suppression</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div id="delete-config-description" className="sr-only">
|
||||
Confirmer la suppression définitive de la configuration NocoDB
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Êtes-vous sûr de vouloir supprimer la configuration "{selectedConfig?.name}" ?
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
disabled={deleteConfigMutation.isPending}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteConfig}
|
||||
disabled={deleteConfigMutation.isPending}
|
||||
>
|
||||
{deleteConfigMutation.isPending ? "Suppression..." : "Supprimer"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Package,
|
||||
Calendar,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Building,
|
||||
User
|
||||
} from "lucide-react";
|
||||
import { safeFormat } from "@/lib/dateUtils";
|
||||
import CreateOrderModal from "@/components/modals/CreateOrderModal";
|
||||
import EditOrderModal from "@/components/modals/EditOrderModal";
|
||||
import OrderDetailModal from "@/components/modals/OrderDetailModal";
|
||||
import ConfirmDeleteModal from "@/components/modals/ConfirmDeleteModal";
|
||||
import type { OrderWithRelations } from "@shared/schema";
|
||||
|
||||
export default function Orders() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Redirection pour les employés
|
||||
if (user?.role === 'employee') {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-orange-50 border-l-4 border-orange-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Package className="h-5 w-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-orange-700">
|
||||
<strong>Accès restreint</strong><br />
|
||||
Seuls les managers et administrateurs peuvent accéder à la page des commandes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedOrder, setSelectedOrder] = useState<OrderWithRelations | null>(null);
|
||||
const [orderToDelete, setOrderToDelete] = useState<OrderWithRelations | null>(null);
|
||||
|
||||
// Construire l'URL pour l'historique complet sans filtrage par date
|
||||
const ordersUrl = `/api/orders${selectedStoreId && user?.role === 'admin' ? `?storeId=${selectedStoreId}` : ''}`;
|
||||
|
||||
const { data: ordersData = [], isLoading } = useQuery<OrderWithRelations[]>({
|
||||
queryKey: [ordersUrl, selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(ordersUrl, { credentials: 'include' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch orders');
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log('📦 Orders received:', Array.isArray(data) ? data.length : 'NOT_ARRAY', 'items', data.slice(0, 2));
|
||||
console.log('📦 Sample order data:', data[0]);
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
});
|
||||
|
||||
// Production Bug Fix: Ensure array safety for all data operations
|
||||
const orders = Array.isArray(ordersData) ? ordersData : [];
|
||||
|
||||
console.log('📦 Orders Debug:', {
|
||||
isLoading,
|
||||
ordersCount: orders?.length,
|
||||
orders: orders?.slice(0, 2),
|
||||
selectedStoreId,
|
||||
ordersUrl
|
||||
});
|
||||
|
||||
const { data: groupsData = [] } = useQuery({
|
||||
queryKey: ['/api/groups'],
|
||||
});
|
||||
|
||||
const groups = Array.isArray(groupsData) ? groupsData : [];
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
console.log('🗑️ Deleting order:', id);
|
||||
await apiRequest(`/api/orders/${id}`, "DELETE");
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log('✅ Order deleted successfully, invalidating cache...');
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Commande supprimée avec succès",
|
||||
});
|
||||
|
||||
console.log('🗑️ Order deleted, clearing ALL cache to avoid inconsistencies:', {
|
||||
ordersUrl,
|
||||
selectedStoreId
|
||||
});
|
||||
|
||||
// Sauvegarder le selectedStoreId avant le nettoyage
|
||||
if (selectedStoreId) {
|
||||
localStorage.setItem('selectedStoreId', selectedStoreId.toString());
|
||||
}
|
||||
|
||||
// SOLUTION HYBRIDE : Invalidation sélective pour éviter perte storeId
|
||||
console.log('🧹 Using selective invalidation to preserve storeId context...');
|
||||
|
||||
// Invalidation ciblée sans clear() pour préserver le contexte
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey[0]?.toString() || '';
|
||||
return key.includes('/api/orders') || key.includes('/api/deliveries');
|
||||
}
|
||||
});
|
||||
|
||||
// Force refetch pour garantir synchronisation immédiate
|
||||
queryClient.refetchQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey[0]?.toString() || '';
|
||||
return key.includes('/api/orders') || key.includes('/api/deliveries');
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la commande",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filteredOrders = Array.isArray(orders) ? orders.filter(order => {
|
||||
console.log('🔍 Filtering order:', order.id, { searchTerm, statusFilter });
|
||||
const matchesSearch = order.supplier?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.group?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
order.notes?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === "all" || order.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
}) : [];
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
totalPages,
|
||||
paginatedData: paginatedOrders,
|
||||
totalItems
|
||||
} = usePagination(filteredOrders, 20);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge variant="secondary">En attente</Badge>;
|
||||
case 'planned':
|
||||
return <Badge className="bg-blue-100 text-blue-800">Planifié</Badge>;
|
||||
case 'delivered':
|
||||
return <Badge className="bg-green-100 text-green-800">Livré</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewOrder = (order: OrderWithRelations) => {
|
||||
setSelectedOrder({ ...order, type: 'order' });
|
||||
setShowDetailModal(true);
|
||||
};
|
||||
|
||||
const handleEditOrder = (order: OrderWithRelations) => {
|
||||
setSelectedOrder(order);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteOrder = (order: OrderWithRelations) => {
|
||||
setOrderToDelete(order);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const confirmDeleteOrder = () => {
|
||||
if (orderToDelete) {
|
||||
deleteMutation.mutate(orderToDelete.id);
|
||||
setShowDeleteModal(false);
|
||||
setOrderToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canCreate = true; // All users can create orders
|
||||
const canEdit = user?.role === 'admin' || user?.role === 'manager';
|
||||
const canDelete = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Package className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Gestion des Commandes
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{filteredOrders.length} commande{filteredOrders.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle Commande
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-gray-50 border border-gray-200 p-4 rounded-lg">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex-1 min-w-64">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher par fournisseur, groupe ou commentaire..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-48 border border-gray-300 shadow-sm">
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
<SelectValue placeholder="Filtrer par statut" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="pending">En attente</SelectItem>
|
||||
<SelectItem value="planned">Planifié</SelectItem>
|
||||
<SelectItem value="delivered">Livré</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders List */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (console.log('📦 Filtered orders length:', filteredOrders.length) || filteredOrders.length === 0) ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
||||
<Package className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium mb-2">Aucune commande trouvée</h3>
|
||||
<p className="text-center max-w-md">
|
||||
{searchTerm || statusFilter !== "all"
|
||||
? "Aucune commande ne correspond à vos critères de recherche."
|
||||
: "Vous n'avez pas encore de commandes. Créez votre première commande pour commencer."}
|
||||
</p>
|
||||
{canCreate && !searchTerm && statusFilter === "all" && (
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="mt-4 bg-primary hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer une commande
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Fournisseur
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Groupe
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date prévue
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Créé par
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Building className="w-5 h-5 text-gray-400 mr-2" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{order.supplier?.name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
#{order.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: order.group?.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-900">{order.group?.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 text-gray-400 mr-2" />
|
||||
{safeFormat(order.plannedDate, 'dd MMM yyyy')}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(order.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div className="flex items-center">
|
||||
<User className="w-4 h-4 text-gray-400 mr-2" />
|
||||
{order.creator?.firstName} {order.creator?.lastName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewOrder(order)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditOrder(order)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteOrder(order)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
className="mt-4 p-4 border-t border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showCreateModal && (
|
||||
<CreateOrderModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
selectedDate={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEditModal && selectedOrder && (
|
||||
<EditOrderModal
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
order={selectedOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDetailModal && selectedOrder && (
|
||||
<OrderDetailModal
|
||||
isOpen={showDetailModal}
|
||||
onClose={() => setShowDetailModal(false)}
|
||||
item={selectedOrder}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
<ConfirmDeleteModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false);
|
||||
setOrderToDelete(null);
|
||||
}}
|
||||
onConfirm={confirmDeleteOrder}
|
||||
title="Supprimer la commande"
|
||||
description="Êtes-vous sûr de vouloir supprimer cette commande ?"
|
||||
itemName={orderToDelete ? `${orderToDelete.supplier?.name} - ${safeFormat(orderToDelete.plannedDate, 'dd/MM/yyyy')}` : undefined}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,936 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Calendar, Edit, Trash2, Eye, Filter, Grid, List } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { format, startOfMonth, endOfMonth, eachWeekOfInterval, eachDayOfInterval, startOfWeek, endOfWeek, isSameMonth, isWithinInterval, startOfYear, endOfYear, getWeek, getMonth } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { safeFormat, safeDate, safeCompareDate } from "@/lib/dateUtils";
|
||||
import type { PublicityWithRelations, Group } from "@shared/schema";
|
||||
import PublicityForm from "@/components/PublicityForm";
|
||||
|
||||
export default function Publicities() {
|
||||
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); // Année courante par défaut
|
||||
const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth()); // Mois courant par défaut
|
||||
const [viewMode, setViewMode] = useState<'list' | 'calendar'>('list'); // Mode d'affichage
|
||||
const [showYearOverview, setShowYearOverview] = useState(true); // Affichage vue d'ensemble
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||
const [selectedPublicity, setSelectedPublicity] = useState<PublicityWithRelations | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Seuls les admins peuvent modifier/supprimer
|
||||
const canModify = user?.role === 'admin';
|
||||
|
||||
// Generate year options (current year ± 5 years)
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearOptions = Array.from({ length: 11 }, (_, i) => currentYear - 5 + i);
|
||||
|
||||
const { data: publicities = [], isLoading } = useQuery({
|
||||
queryKey: ['/api/publicities', selectedYear, selectedStoreId],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
params.append('year', selectedYear.toString());
|
||||
if (selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId.toString());
|
||||
}
|
||||
const response = await fetch(`/api/publicities?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch publicities');
|
||||
}
|
||||
const data = await response.json();
|
||||
console.log('📢 Publicities received:', data.length, 'items', data.slice(0, 2));
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
});
|
||||
|
||||
console.log('📢 Publicities Debug:', {
|
||||
isLoading,
|
||||
publicitiesCount: publicities?.length,
|
||||
selectedYear,
|
||||
selectedStoreId,
|
||||
publicities: publicities?.slice(0, 2)
|
||||
});
|
||||
|
||||
console.log('📢 Publicities Debug:', {
|
||||
isLoading,
|
||||
publicitiesCount: publicities?.length,
|
||||
selectedYear,
|
||||
selectedStoreId,
|
||||
publicities: publicities?.slice(0, 2)
|
||||
});
|
||||
|
||||
const { data: groups = [] } = useQuery<Group[]>({
|
||||
queryKey: ['/api/groups'],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const response = await fetch(`/api/publicities/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Erreur lors de la suppression');
|
||||
}
|
||||
// Handle empty response (204 No Content) in production
|
||||
if (response.status === 204) {
|
||||
return { message: "Publicity deleted successfully" };
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/publicities'] });
|
||||
toast({ description: "Publicité supprimée avec succès" });
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelectedPublicity(null);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
description: error.message || "Erreur lors de la suppression"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Generate month options
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: i,
|
||||
label: format(new Date(2024, i, 1), 'MMMM', { locale: fr })
|
||||
}));
|
||||
|
||||
// Calendar helper functions
|
||||
const getWeekParticipation = (weekStart: Date, weekEnd: Date) => {
|
||||
// Vérifier que publicities est un tableau
|
||||
if (!Array.isArray(publicities)) {
|
||||
return {
|
||||
publicities: [],
|
||||
participatingStores: [],
|
||||
storeColors: []
|
||||
};
|
||||
}
|
||||
|
||||
const weekPublicities = publicities.filter(pub => {
|
||||
const pubStart = safeDate(pub.startDate);
|
||||
const pubEnd = safeDate(pub.endDate);
|
||||
|
||||
// More robust overlap detection: check if any part of the publicity period overlaps with the week
|
||||
return pubStart && pubEnd && (pubStart <= weekEnd && pubEnd >= weekStart);
|
||||
});
|
||||
|
||||
const participatingStores = new Set<number>();
|
||||
weekPublicities.forEach(pub => {
|
||||
// Vérifier que participations existe et est un tableau
|
||||
if (pub.participations && Array.isArray(pub.participations)) {
|
||||
pub.participations.forEach(participation => {
|
||||
participatingStores.add(participation.groupId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
publicities: weekPublicities,
|
||||
participatingStores: Array.from(participatingStores),
|
||||
storeColors: Array.from(participatingStores).map(storeId => {
|
||||
const group = groups.find(g => g.id === storeId);
|
||||
return group?.color || '#666666';
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
// Year overview - Generate weeks for the selected year
|
||||
const getYearWeeks = () => {
|
||||
const yearStart = startOfYear(new Date(selectedYear, 0, 1));
|
||||
const yearEnd = endOfYear(new Date(selectedYear, 11, 31));
|
||||
|
||||
const weeks = eachWeekOfInterval(
|
||||
{ start: yearStart, end: yearEnd },
|
||||
{ weekStartsOn: 1 }
|
||||
);
|
||||
|
||||
console.log(`📅 getYearWeeks debug pour ${selectedYear}:`, {
|
||||
yearStart: format(yearStart, 'yyyy-MM-dd EEEE', { locale: fr }),
|
||||
yearEnd: format(yearEnd, 'yyyy-MM-dd EEEE', { locale: fr }),
|
||||
totalWeeks: weeks.length
|
||||
});
|
||||
|
||||
return weeks.map((weekStart, index) => {
|
||||
const weekEnd = endOfWeek(weekStart, { weekStartsOn: 1 });
|
||||
// Utiliser l'index + 1 pour une numérotation séquentielle cohérente
|
||||
// plutôt que getWeek() qui peut créer des doublons en fin d'année
|
||||
const weekNumber = index + 1;
|
||||
|
||||
// Déterminer le mois basé sur la majorité des jours de la semaine
|
||||
// Si la semaine chevauche deux mois, prendre le mois qui contient le plus de jours
|
||||
const weekMidpoint = new Date(weekStart.getTime() + (3.5 * 24 * 60 * 60 * 1000)); // Milieu de la semaine
|
||||
let month = getMonth(weekMidpoint);
|
||||
|
||||
// Si c'est une semaine qui chevauche décembre de l'année précédente et janvier de l'année sélectionnée,
|
||||
// la placer en janvier (mois 0) si elle contient plus de jours de janvier
|
||||
if (weekStart.getFullYear() < selectedYear && weekEnd.getFullYear() === selectedYear) {
|
||||
month = 0; // Janvier
|
||||
}
|
||||
// Si c'est une semaine qui chevauche décembre et janvier de l'année suivante,
|
||||
// la placer en décembre (mois 11) si elle contient plus de jours de décembre
|
||||
else if (weekStart.getFullYear() === selectedYear && weekEnd.getFullYear() > selectedYear) {
|
||||
month = 11; // Décembre
|
||||
}
|
||||
|
||||
const participation = getWeekParticipation(weekStart, weekEnd);
|
||||
|
||||
// Debug première et dernières semaines pour vérifier
|
||||
if (index < 3 || index >= weeks.length - 3) {
|
||||
console.log(`📅 Semaine ${weekNumber} (index ${index}):`, {
|
||||
debut: format(weekStart, 'yyyy-MM-dd EEEE', { locale: fr }),
|
||||
fin: format(weekEnd, 'yyyy-MM-dd EEEE', { locale: fr }),
|
||||
weekStartYear: weekStart.getFullYear(),
|
||||
weekEndYear: weekEnd.getFullYear(),
|
||||
selectedYear,
|
||||
mois: month + 1, // +1 car getMonth retourne 0-11
|
||||
participations: participation.publicities.length
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
weekStart,
|
||||
weekEnd,
|
||||
weekNumber,
|
||||
month,
|
||||
...participation
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const yearWeeks = getYearWeeks();
|
||||
|
||||
const handleView = (publicity: PublicityWithRelations) => {
|
||||
setSelectedPublicity(publicity);
|
||||
setIsViewModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (publicity: PublicityWithRelations) => {
|
||||
setSelectedPublicity(publicity);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (publicity: PublicityWithRelations) => {
|
||||
setSelectedPublicity(publicity);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (selectedPublicity) {
|
||||
deleteMutation.mutate(selectedPublicity.id);
|
||||
}
|
||||
};
|
||||
|
||||
const closeModals = () => {
|
||||
setIsCreateModalOpen(false);
|
||||
setIsEditModalOpen(false);
|
||||
setIsViewModalOpen(false);
|
||||
setIsDeleteModalOpen(false);
|
||||
setSelectedPublicity(null);
|
||||
};
|
||||
|
||||
const canCreateOrEdit = user?.role === 'admin' || user?.role === 'manager';
|
||||
const canDelete = user?.role === 'admin';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Calendar className="w-6 h-6 mr-3 text-green-600" />
|
||||
Publicités
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Gestion des campagnes publicitaires
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center bg-gray-100 rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
className="h-8"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'calendar' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('calendar')}
|
||||
className="h-8"
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Year Overview Toggle */}
|
||||
<Button
|
||||
variant={showYearOverview ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setShowYearOverview(!showYearOverview)}
|
||||
className="h-8"
|
||||
>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Vue d'ensemble
|
||||
</Button>
|
||||
|
||||
{/* Year Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-gray-500" />
|
||||
<Select
|
||||
value={selectedYear.toString()}
|
||||
onValueChange={(value) => setSelectedYear(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{yearOptions.map((year) => (
|
||||
<SelectItem key={year} value={year.toString()}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Month Filter (only for calendar view) */}
|
||||
{viewMode === 'calendar' && (
|
||||
<Select
|
||||
value={selectedMonth.toString()}
|
||||
onValueChange={(value) => setSelectedMonth(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{monthOptions.map((month) => (
|
||||
<SelectItem key={month.value} value={month.value.toString()}>
|
||||
{month.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{canModify && (
|
||||
<Button onClick={() => setIsCreateModalOpen(true)} className="bg-green-600 hover:bg-green-700 text-white shadow-md">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nouvelle publicité
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Calendar className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total {selectedYear}</p>
|
||||
<p className="text-2xl font-semibold">{Array.isArray(publicities) ? publicities.length : 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 rounded-lg">
|
||||
<Calendar className="h-5 w-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">En cours</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{publicities.filter(p => {
|
||||
const now = new Date();
|
||||
const start = safeDate(p.startDate);
|
||||
const end = safeDate(p.endDate);
|
||||
return start && end && start <= now && now <= end;
|
||||
}).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 rounded-lg">
|
||||
<Calendar className="h-5 w-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">À venir</p>
|
||||
<p className="text-2xl font-semibold">
|
||||
{publicities.filter(p => {
|
||||
const startDate = safeDate(p.startDate);
|
||||
return startDate && startDate > new Date();
|
||||
}).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Year Overview - Weekly Timeline */}
|
||||
{showYearOverview && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Vue d'ensemble {selectedYear} - Semaines avec publicités
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Months Grid */}
|
||||
<div className="grid grid-cols-12 gap-2">
|
||||
{Array.from({ length: 12 }, (_, monthIndex) => {
|
||||
const monthName = format(new Date(2024, monthIndex, 1), 'MMM', { locale: fr });
|
||||
const monthWeeks = yearWeeks.filter(week => week.month === monthIndex);
|
||||
|
||||
return (
|
||||
<div key={monthIndex} className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600 text-center">
|
||||
{monthName}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{monthWeeks.map((week, weekIndex) => {
|
||||
const hasPublicity = week.publicities.length > 0;
|
||||
const storeColors = week.storeColors;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${monthIndex}-${weekIndex}-${week.weekNumber}`}
|
||||
className={`
|
||||
h-8 rounded border text-xs flex flex-col items-center justify-center cursor-pointer relative
|
||||
${hasPublicity
|
||||
? 'bg-blue-50 border-blue-200 text-blue-800'
|
||||
: 'bg-gray-50 border-gray-200 text-gray-500'
|
||||
}
|
||||
hover:shadow-sm transition-shadow
|
||||
`}
|
||||
title={`Semaine ${week.weekNumber} (${format(week.weekStart, 'dd/MM', { locale: fr })} - ${format(week.weekEnd, 'dd/MM', { locale: fr })}) - ${hasPublicity ? `${week.publicities.map(p => p.pubNumber).join(', ')}` : 'Aucune publicité'}`}
|
||||
>
|
||||
<span className="text-xs font-medium">{week.weekNumber}</span>
|
||||
{/* Store participation indicators */}
|
||||
{storeColors.length > 0 && (
|
||||
<div className="absolute top-0.5 right-0.5 flex flex-wrap gap-0.5 max-w-[16px]">
|
||||
{storeColors.slice(0, 4).map((color, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
title={`Magasin ${idx + 1}`}
|
||||
/>
|
||||
))}
|
||||
{storeColors.length > 4 && (
|
||||
<div
|
||||
className="w-1.5 h-1.5 rounded-full bg-gray-400 text-white text-xs flex items-center justify-center"
|
||||
title={`+${storeColors.length - 4} autres magasins`}
|
||||
>
|
||||
+
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-blue-50 border border-blue-200 rounded"></div>
|
||||
<span>Semaine avec publicité</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-gray-50 border border-gray-200 rounded"></div>
|
||||
<span>Semaine sans publicité</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{groups.slice(0, 3).map(group => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: group?.color || '#666666' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>Indicateurs magasins (coin supérieur droit)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>• Survolez les semaines pour voir les détails des publicités</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : publicities.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Calendar className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucune publicité pour {selectedYear}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Commencez par créer votre première campagne publicitaire.
|
||||
</p>
|
||||
{canModify && (
|
||||
<Button onClick={() => setIsCreateModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Créer une publicité
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : viewMode === 'calendar' ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Vue calendrier - {monthOptions[selectedMonth].label} {selectedYear}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{/* Days of week headers */}
|
||||
{['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'].map(day => (
|
||||
<div key={day} className="p-2 text-center text-sm font-medium text-gray-500">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Calendar days */}
|
||||
{(() => {
|
||||
const monthStart = startOfMonth(new Date(selectedYear, selectedMonth, 1));
|
||||
const monthEnd = endOfMonth(monthStart);
|
||||
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||
|
||||
const days = eachDayOfInterval({ start: startDate, end: endDate });
|
||||
|
||||
return days.map(day => {
|
||||
const isCurrentMonth = isSameMonth(day, monthStart);
|
||||
const dayPublicities = publicities.filter(pub => {
|
||||
const pubStart = safeDate(pub.startDate);
|
||||
const pubEnd = safeDate(pub.endDate);
|
||||
return pubStart && pubEnd && day >= pubStart && day <= pubEnd;
|
||||
});
|
||||
|
||||
const participatingStores = new Set<number>();
|
||||
dayPublicities.forEach(pub => {
|
||||
pub.participations.forEach(participation => {
|
||||
participatingStores.add(participation.groupId);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className={`
|
||||
p-2 min-h-[60px] border border-gray-200 rounded-lg
|
||||
${isCurrentMonth ? 'bg-white' : 'bg-gray-50'}
|
||||
${dayPublicities.length > 0 ? 'ring-2 ring-blue-200' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900 mb-1">
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
{dayPublicities.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{dayPublicities.slice(0, 2).map(pub => (
|
||||
<div
|
||||
key={pub.id}
|
||||
className="text-xs bg-blue-100 text-blue-800 rounded px-1 py-0.5 truncate"
|
||||
title={pub.designation}
|
||||
>
|
||||
{pub.pubNumber}
|
||||
</div>
|
||||
))}
|
||||
{dayPublicities.length > 2 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
+{dayPublicities.length - 2} autre{dayPublicities.length > 3 ? 's' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Store participation indicators */}
|
||||
{participatingStores.size > 0 && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
{Array.from(participatingStores).slice(0, 3).map(storeId => {
|
||||
const group = groups.find(g => g.id === storeId);
|
||||
return (
|
||||
<div
|
||||
key={storeId}
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: group?.color || '#666666' }}
|
||||
title={group?.name || 'Magasin'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{participatingStores.size > 3 && (
|
||||
<div className="text-xs text-gray-500">+{participatingStores.size - 3}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Légende</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 bg-blue-100 border border-blue-200 rounded"></div>
|
||||
<span>Jour avec publicité</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{groups.slice(0, 3).map(group => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: group?.color || '#666666' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>Magasins participants</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
N° PUB
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Désignation
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Période
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Statut
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Magasins
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Créé par
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{publicities
|
||||
.sort((a, b) => safeCompareDate(b.startDate, a.startDate))
|
||||
.map((publicity) => {
|
||||
const now = new Date();
|
||||
const start = safeDate(publicity.startDate);
|
||||
const end = safeDate(publicity.endDate);
|
||||
const isActive = start && end && now >= start && now <= end;
|
||||
const isUpcoming = start && start > now;
|
||||
const isPast = end && end < now;
|
||||
|
||||
return (
|
||||
<tr key={publicity.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{publicity.pubNumber}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-900 max-w-xs truncate">
|
||||
{publicity.designation}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{safeFormat(publicity.startDate, "dd/MM/yy")} - {safeFormat(publicity.endDate, "dd/MM/yy")}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{isActive && <Badge className="bg-green-100 text-green-800">En cours</Badge>}
|
||||
{isUpcoming && <Badge className="bg-blue-100 text-blue-800">À venir</Badge>}
|
||||
{isPast && <Badge variant="secondary">Terminée</Badge>}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{publicity.participations.length === 0 ? (
|
||||
<Badge className="text-xs bg-red-100 text-red-800 hover:bg-red-100">
|
||||
Aucun magasin
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
{publicity.participations.slice(0, 2).map((participation) => (
|
||||
<Badge key={participation.groupId} variant="outline" className="text-xs">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full mr-1"
|
||||
style={{ backgroundColor: participation.group?.color || '#666666' }}
|
||||
/>
|
||||
{participation.group?.name || 'Magasin'}
|
||||
</Badge>
|
||||
))}
|
||||
{publicity.participations.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{publicity.participations.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-600">
|
||||
{publicity.creator?.name || publicity.creator?.username || 'Utilisateur'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleView(publicity)}
|
||||
title="Voir les détails"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{canModify && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(publicity)}
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(publicity)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer une nouvelle publicité</DialogTitle>
|
||||
<DialogDescription>
|
||||
Remplissez les informations de la campagne publicitaire et sélectionnez les magasins participants.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PublicityForm
|
||||
groups={groups}
|
||||
selectedYear={selectedYear}
|
||||
onSuccess={closeModals}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la publicité</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modifiez les informations de la campagne publicitaire.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PublicityForm
|
||||
publicity={selectedPublicity}
|
||||
groups={groups}
|
||||
selectedYear={selectedYear}
|
||||
onSuccess={closeModals}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* View Modal */}
|
||||
<Dialog open={isViewModalOpen} onOpenChange={setIsViewModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Détails de la publicité</DialogTitle>
|
||||
<DialogDescription>
|
||||
Informations complètes de la campagne publicitaire.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedPublicity && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">N° PUB</label>
|
||||
<p className="text-lg">{selectedPublicity.pubNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Année</label>
|
||||
<p className="text-lg">{selectedPublicity.year}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Désignation</label>
|
||||
<p className="mt-1">{selectedPublicity.designation}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Date de début</label>
|
||||
<p>{safeFormat(selectedPublicity.startDate, "dd/MM/yyyy")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Date de fin</label>
|
||||
<p>{safeFormat(selectedPublicity.endDate, "dd/MM/yyyy")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Magasins participants</label>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{selectedPublicity.participations.length === 0 ? (
|
||||
<Badge className="bg-red-100 text-red-800 hover:bg-red-100">Aucun magasin</Badge>
|
||||
) : (
|
||||
selectedPublicity.participations.map((participation) => (
|
||||
<Badge key={participation.groupId} variant="outline">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: participation.group?.color || '#666666' }}
|
||||
/>
|
||||
{participation.group?.name || 'Magasin'}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-gray-600">
|
||||
<div>
|
||||
<label className="font-medium">Créé par</label>
|
||||
<p>{selectedPublicity.creator?.name || selectedPublicity.creator?.username || 'Utilisateur'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-medium">Créé le</label>
|
||||
<p>{safeFormat(selectedPublicity.createdAt, "dd/MM/yyyy à HH:mm")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Supprimer la publicité</DialogTitle>
|
||||
<DialogDescription>
|
||||
Êtes-vous sûr de vouloir supprimer cette publicité ? Cette action est irréversible.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedPublicity && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<p className="font-medium">{selectedPublicity.pubNumber}</p>
|
||||
<p className="text-sm text-gray-600">{selectedPublicity.designation}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsDeleteModalOpen(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? 'Suppression...' : 'Supprimer'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { getRoleColor, getRoleDisplayName, ROLE_COLORS } from "@/lib/roleUtils";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Plus, Edit, Trash2, Shield, Users, Settings } from "lucide-react";
|
||||
import type { Role, Permission, User } from "@shared/schema";
|
||||
|
||||
// Fonction pour forcer l'affichage des noms français même si displayName est incorrect
|
||||
function getPermissionDisplayName(permission: any): string {
|
||||
// Si displayName existe et n'est pas le même que name, l'utiliser
|
||||
if (permission.displayName && permission.displayName !== permission.name) {
|
||||
return permission.displayName;
|
||||
}
|
||||
|
||||
// Sinon, mapper manuellement selon le nom technique
|
||||
const frenchNames: Record<string, string> = {
|
||||
'dashboard_read': 'Voir tableau de bord',
|
||||
'groups_read': 'Voir magasins',
|
||||
'groups_create': 'Créer magasins',
|
||||
'groups_update': 'Modifier magasins',
|
||||
'groups_delete': 'Supprimer magasins',
|
||||
'suppliers_read': 'Voir fournisseurs',
|
||||
'suppliers_create': 'Créer fournisseurs',
|
||||
'suppliers_update': 'Modifier fournisseurs',
|
||||
'suppliers_delete': 'Supprimer fournisseurs',
|
||||
'orders_read': 'Voir commandes',
|
||||
'orders_create': 'Créer commandes',
|
||||
'orders_update': 'Modifier commandes',
|
||||
'orders_delete': 'Supprimer commandes',
|
||||
'deliveries_read': 'Voir livraisons',
|
||||
'deliveries_create': 'Créer livraisons',
|
||||
'deliveries_update': 'Modifier livraisons',
|
||||
'deliveries_delete': 'Supprimer livraisons',
|
||||
'deliveries_validate': 'Valider livraisons',
|
||||
'deliveries_reconcile': 'Rapprocher livraisons',
|
||||
'publicities_read': 'Voir publicités',
|
||||
'publicities_create': 'Créer publicités',
|
||||
'publicities_update': 'Modifier publicités',
|
||||
'publicities_delete': 'Supprimer publicités',
|
||||
'customer_orders_read': 'Voir commandes clients',
|
||||
'customer_orders_create': 'Créer commandes clients',
|
||||
'customer_orders_update': 'Modifier commandes clients',
|
||||
'customer_orders_delete': 'Supprimer commandes clients',
|
||||
'customer_orders_print': 'Imprimer commandes clients',
|
||||
'customer_orders_export': 'Exporter commandes clients',
|
||||
'users_read': 'Voir utilisateurs',
|
||||
'users_create': 'Créer utilisateurs',
|
||||
'users_update': 'Modifier utilisateurs',
|
||||
'users_delete': 'Supprimer utilisateurs',
|
||||
'roles_read': 'Voir rôles',
|
||||
'roles_create': 'Créer rôles',
|
||||
'roles_update': 'Modifier rôles',
|
||||
'roles_delete': 'Supprimer rôles',
|
||||
'permissions_assign': 'Assigner permissions',
|
||||
'calendar_read': 'Voir calendrier',
|
||||
'calendar_create': 'Créer événement calendrier',
|
||||
'calendar_update': 'Modifier événement calendrier',
|
||||
'calendar_delete': 'Supprimer événement calendrier',
|
||||
'reconciliation_read': 'Voir rapprochement',
|
||||
'reconciliation_update': 'Modifier rapprochement',
|
||||
'tasks_read': 'Voir tâches',
|
||||
'tasks_create': 'Créer tâches',
|
||||
'tasks_update': 'Modifier tâches',
|
||||
'tasks_delete': 'Supprimer tâches',
|
||||
'tasks_assign': 'Assigner tâches',
|
||||
'dlc_read': 'Voir DLC',
|
||||
'dlc_create': 'Créer DLC',
|
||||
'dlc_update': 'Modifier DLC',
|
||||
'dlc_delete': 'Supprimer DLC',
|
||||
'dlc_validate': 'Valider DLC',
|
||||
'dlc_print': 'Imprimer DLC',
|
||||
'dlc_stats': 'Statistiques DLC',
|
||||
'nocodb_config': 'Configuration NocoDB',
|
||||
'magasins_read': 'Voir magasins',
|
||||
'magasins_create': 'Créer magasins',
|
||||
'magasins_update': 'Modifier magasins',
|
||||
'magasins_delete': 'Supprimer magasins'
|
||||
};
|
||||
|
||||
return frenchNames[permission.name] || permission.displayName || permission.name;
|
||||
}
|
||||
|
||||
interface RoleWithPermissions extends Role {
|
||||
rolePermissions: Array<{
|
||||
roleId: number;
|
||||
permissionId: number;
|
||||
createdAt: Date;
|
||||
permission: Permission;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface UserWithRoles extends User {
|
||||
userRoles: Array<{
|
||||
userId: string;
|
||||
roleId: number;
|
||||
assignedBy: string;
|
||||
assignedAt: Date;
|
||||
role: Role;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function RoleManagement() {
|
||||
const [selectedRole, setSelectedRole] = useState<RoleWithPermissions | null>(null);
|
||||
const [createRoleOpen, setCreateRoleOpen] = useState(false);
|
||||
const [editRoleOpen, setEditRoleOpen] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch roles
|
||||
const { data: rolesData = [], isLoading: rolesLoading, error: rolesError, refetch: refetchRoles } = useQuery<Role[]>({
|
||||
queryKey: ['/api/roles'],
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false
|
||||
});
|
||||
|
||||
// Fetch permissions with forced refetch
|
||||
const { data: permissionsData = [], isLoading: permissionsLoading, refetch: refetchPermissions, error: permissionsError } = useQuery<Permission[]>({
|
||||
queryKey: ['/api/permissions'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/permissions', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch permissions: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false
|
||||
});
|
||||
|
||||
// Force refresh permissions on mount
|
||||
useEffect(() => {
|
||||
queryClient.removeQueries({ queryKey: ['/api/permissions'] });
|
||||
refetchPermissions();
|
||||
}, [refetchPermissions, queryClient]);
|
||||
|
||||
// Force refresh permissions function
|
||||
const forceRefreshPermissions = () => {
|
||||
queryClient.removeQueries({ queryKey: ['/api/permissions'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/permissions'] });
|
||||
queryClient.resetQueries({ queryKey: ['/api/permissions'] });
|
||||
refetchPermissions();
|
||||
};
|
||||
|
||||
// Debug function removed after successful DLC permissions fix
|
||||
|
||||
|
||||
|
||||
// Protection Array.isArray et logs debug RENFORCÉS
|
||||
const roles = Array.isArray(rolesData) ? rolesData : [];
|
||||
const permissions = Array.isArray(permissionsData) ? permissionsData : [];
|
||||
|
||||
// Auto-select first role if none selected and roles are available
|
||||
useEffect(() => {
|
||||
if (!selectedRole && roles.length > 0) {
|
||||
setSelectedRole(roles[0] as RoleWithPermissions);
|
||||
}
|
||||
}, [roles, selectedRole]);
|
||||
|
||||
|
||||
|
||||
// Check for task permissions availability
|
||||
const taskPermissions = permissions.filter(p => p.category === 'gestion_taches');
|
||||
|
||||
// Get role with permissions
|
||||
const { data: roleWithPermissions } = useQuery<RoleWithPermissions>({
|
||||
queryKey: ['/api/roles', selectedRole?.id],
|
||||
enabled: !!selectedRole,
|
||||
});
|
||||
|
||||
// Get role permissions specifically
|
||||
const { data: rolePermissions = [] } = useQuery<any[]>({
|
||||
queryKey: [`/api/roles/${selectedRole?.id}/permissions`],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/roles/${selectedRole?.id}/permissions`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch role permissions: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!selectedRole,
|
||||
});
|
||||
|
||||
// Task permissions check for role
|
||||
const taskPermissionsInRole = rolePermissions?.filter(rp => {
|
||||
const permission = permissions.find(p => p.id === rp.permissionId);
|
||||
return permission?.category === 'gestion_taches';
|
||||
});
|
||||
|
||||
// Traduction des catégories en français
|
||||
const categoryTranslations: Record<string, string> = {
|
||||
'administration': 'Administration',
|
||||
'calendrier': 'Calendrier',
|
||||
'commandes': 'Commandes',
|
||||
'commandes_clients': 'Commandes Clients',
|
||||
'fournisseurs': 'Fournisseurs',
|
||||
'gestion_dlc': 'Gestion DLC',
|
||||
'gestion_roles': 'Gestion des Rôles',
|
||||
'gestion_taches': 'Gestion des Tâches',
|
||||
'livraisons': 'Livraisons',
|
||||
'magasins': 'Magasins',
|
||||
'publicites': 'Publicités',
|
||||
'rapprochement': 'Rapprochement',
|
||||
'tableau_de_bord': 'Tableau de Bord',
|
||||
'utilisateurs': 'Utilisateurs',
|
||||
// Fallbacks pour les anciennes catégories techniques
|
||||
'customer_orders': 'Commandes Clients',
|
||||
'groups': 'Magasins',
|
||||
'roles': 'Gestion des Rôles',
|
||||
// Fallbacks pour les anciennes catégories en anglais
|
||||
'Calendar': 'Calendrier',
|
||||
'Dashboard': 'Tableau de Bord',
|
||||
'Deliveries': 'Livraisons',
|
||||
'Orders': 'Commandes',
|
||||
'Publicities': 'Publicités',
|
||||
'Reconciliation': 'Rapprochement',
|
||||
'Suppliers': 'Fournisseurs',
|
||||
'Customer Orders': 'Commandes Clients',
|
||||
'Users': 'Utilisateurs',
|
||||
'Roles': 'Gestion des Rôles',
|
||||
'Administration': 'Administration'
|
||||
};
|
||||
|
||||
// Group permissions by category
|
||||
const permissionsByCategory = Array.isArray(permissions) ? permissions.reduce((acc, permission) => {
|
||||
if (!acc[permission.category]) {
|
||||
acc[permission.category] = [];
|
||||
}
|
||||
acc[permission.category].push(permission);
|
||||
return acc;
|
||||
}, {} as Record<string, Permission[]>) : {};
|
||||
|
||||
// Check permission categories availability
|
||||
const hasTasksCategory = !!permissionsByCategory['gestion_taches'];
|
||||
const hasAdminCategory = !!permissionsByCategory['administration'];
|
||||
|
||||
// Debug permissions (cleaned up after successful DLC fix)
|
||||
|
||||
// Create role mutation
|
||||
const createRoleMutation = useMutation({
|
||||
mutationFn: async (data: { name: string; displayName: string; description: string; color: string }) => {
|
||||
return await apiRequest('/api/roles', 'POST', data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/roles'] });
|
||||
setCreateRoleOpen(false);
|
||||
toast({ title: "Rôle créé avec succès" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ title: "Erreur lors de la création du rôle", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// Update role mutation
|
||||
const updateRoleMutation = useMutation({
|
||||
mutationFn: async (data: { id: number; name: string; displayName: string; description: string; color: string }) => {
|
||||
return await apiRequest(`/api/roles/${data.id}`, 'PUT', data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/roles'] });
|
||||
setEditRoleOpen(false);
|
||||
toast({ title: "Rôle modifié avec succès" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ title: "Erreur lors de la modification du rôle", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete role mutation
|
||||
const deleteRoleMutation = useMutation({
|
||||
mutationFn: async (roleId: number) => {
|
||||
return await apiRequest(`/api/roles/${roleId}`, 'DELETE');
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/roles'] });
|
||||
toast({ title: "Rôle supprimé avec succès" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ title: "Erreur lors de la suppression du rôle", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// Update role permissions mutation
|
||||
const updateRolePermissionsMutation = useMutation({
|
||||
mutationFn: async (data: { roleId: number; permissionIds: number[] }) => {
|
||||
return await apiRequest(`/api/roles/${data.roleId}/permissions`, 'POST', { permissionIds: data.permissionIds });
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log("🚀 Permission mutation success - invalidating cache");
|
||||
|
||||
// Invalider seulement les queries nécessaires
|
||||
if (selectedRole) {
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/roles/${selectedRole.id}/permissions`] });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/permissions'] });
|
||||
|
||||
toast({ title: "Permissions mises à jour avec succès" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({ title: "Erreur lors de la mise à jour des permissions", description: error.message, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
// Fix admin permissions mutation
|
||||
const fixAdminPermissionsMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return await apiRequest('/api/admin/fix-permissions', 'POST');
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
// Rafraîchir toutes les données
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/roles'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/permissions'] });
|
||||
if (selectedRole) {
|
||||
queryClient.invalidateQueries({ queryKey: [`/api/roles/${selectedRole.id}/permissions`] });
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Permissions administrateur corrigées",
|
||||
description: `${data.added} permissions ajoutées. Total: ${data.after}/${data.total}`
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Erreur lors de la correction",
|
||||
description: error.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleCreateRole = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
createRoleMutation.mutate({
|
||||
name: formData.get('name') as string,
|
||||
displayName: formData.get('displayName') as string,
|
||||
description: formData.get('description') as string,
|
||||
color: formData.get('color') as string,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateRole = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!selectedRole) return;
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
updateRoleMutation.mutate({
|
||||
id: selectedRole.id,
|
||||
name: formData.get('name') as string,
|
||||
displayName: formData.get('displayName') as string,
|
||||
description: formData.get('description') as string,
|
||||
color: formData.get('color') as string,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePermissionToggle = (permissionId: number, checked: boolean) => {
|
||||
if (!selectedRole) return;
|
||||
|
||||
const currentPermissions = rolePermissions?.map(rp => rp.permissionId) || [];
|
||||
const newPermissions = checked
|
||||
? [...currentPermissions, permissionId]
|
||||
: currentPermissions.filter(id => id !== permissionId);
|
||||
|
||||
console.log("🔍 Permission toggle:", {
|
||||
permissionId,
|
||||
checked,
|
||||
currentPermissions,
|
||||
newPermissions,
|
||||
roleId: selectedRole.id
|
||||
});
|
||||
|
||||
updateRolePermissionsMutation.mutate({
|
||||
roleId: selectedRole.id,
|
||||
permissionIds: newPermissions,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Gestion des Rôles et Permissions</h1>
|
||||
<p className="text-muted-foreground">Gérez les rôles utilisateurs et leurs permissions</p>
|
||||
</div>
|
||||
<Dialog open={createRoleOpen} onOpenChange={setCreateRoleOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer un Rôle
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer un Nouveau Rôle</DialogTitle>
|
||||
<DialogDescription>Ajoutez un nouveau rôle au système</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreateRole} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nom technique</Label>
|
||||
<Input id="name" name="name" required />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="displayName">Nom d'affichage</Label>
|
||||
<Input id="displayName" name="displayName" required />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input id="description" name="description" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="color">Couleur</Label>
|
||||
<Input id="color" name="color" type="color" defaultValue="#3B82F6" />
|
||||
</div>
|
||||
<Button type="submit" disabled={createRoleMutation.isPending}>
|
||||
{createRoleMutation.isPending ? 'Création...' : 'Créer'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="roles" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="roles">
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Rôles
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="permissions">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Permissions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="roles" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Roles List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Liste des Rôles</CardTitle>
|
||||
<CardDescription>Sélectionnez un rôle pour voir ses permissions</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fixAdminPermissionsMutation.mutate()}
|
||||
disabled={fixAdminPermissionsMutation.isPending}
|
||||
className="text-xs"
|
||||
>
|
||||
{fixAdminPermissionsMutation.isPending ? 'Correction...' : '🔧 Corriger Admin'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🔄 Manual refetch triggered");
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/roles'] });
|
||||
refetchRoles();
|
||||
}}
|
||||
>
|
||||
🔄 Actualiser
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rolesLoading ? (
|
||||
<div className="text-center py-4">Chargement des rôles...</div>
|
||||
) : rolesError ? (
|
||||
<div className="text-center py-4 text-red-500">
|
||||
Erreur: {rolesError.message}
|
||||
</div>
|
||||
) : roles.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
Aucun rôle trouvé
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{roles.map((role) => {
|
||||
// Utiliser la couleur de la base de données, avec fallback sur getRoleColor
|
||||
const roleColor = role.color || getRoleColor(role.name);
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
selectedRole?.id === role.id ? 'border-primary bg-primary/5' : 'hover:bg-muted'
|
||||
}`}
|
||||
onClick={() => setSelectedRole(role)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full border"
|
||||
style={{
|
||||
backgroundColor: roleColor,
|
||||
borderColor: roleColor
|
||||
}}
|
||||
title={`Couleur: ${roleColor}`}
|
||||
/>
|
||||
<span className="font-medium">{role.displayName}</span>
|
||||
{role.isSystem && (
|
||||
<Badge variant="secondary" className="text-xs">Système</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedRole(role);
|
||||
setEditRoleOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
{!role.isSystem && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteRoleMutation.mutate(role.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{role.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Role Permissions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Permissions du Rôle</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedRole ? `Permissions pour ${selectedRole.displayName}` : 'Sélectionnez un rôle'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedRole ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(permissionsByCategory)
|
||||
.filter(([category, categoryPermissions]) => {
|
||||
return Array.isArray(categoryPermissions) && categoryPermissions.length > 0;
|
||||
})
|
||||
.map(([category, categoryPermissions]) => (
|
||||
<div key={category} className="space-y-2 border-l-2 border-blue-200 pl-3">
|
||||
<h4 className="font-medium text-sm text-blue-800">
|
||||
{categoryTranslations[category] || category}
|
||||
<span className="text-xs text-gray-400 ml-2 bg-gray-100 px-1 rounded">({categoryPermissions.length})</span>
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{categoryPermissions.map((permission) => {
|
||||
const hasPermission = rolePermissions?.some(
|
||||
rp => rp.permissionId === permission.id
|
||||
);
|
||||
return (
|
||||
<div key={permission.id} className="flex items-center space-x-2 bg-gray-50 p-1 rounded">
|
||||
<Checkbox
|
||||
id={`permission-${permission.id}`}
|
||||
checked={hasPermission || false}
|
||||
onCheckedChange={(checked) => handlePermissionToggle(permission.id, checked as boolean)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`permission-${permission.id}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 flex-1"
|
||||
>
|
||||
{getPermissionDisplayName(permission)}
|
||||
</label>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{permission.action}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">Sélectionnez un rôle pour voir ses permissions</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
|
||||
<TabsContent value="permissions" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Liste des Permissions</CardTitle>
|
||||
<CardDescription>Toutes les permissions disponibles dans le système</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={forceRefreshPermissions}
|
||||
>
|
||||
🔄 Actualiser Permissions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(permissionsByCategory)
|
||||
.filter(([category, categoryPermissions]) => {
|
||||
return Array.isArray(categoryPermissions) && categoryPermissions.length > 0;
|
||||
})
|
||||
.map(([category, categoryPermissions]) => (
|
||||
<div key={category} className="space-y-2">
|
||||
<h3 className="font-medium text-lg border-b pb-1">
|
||||
{categoryTranslations[category] || category}
|
||||
<span className="text-xs text-gray-500 ml-2 bg-blue-100 px-2 py-1 rounded">
|
||||
({categoryPermissions.length})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{categoryPermissions.map((permission) => (
|
||||
<div key={permission.id} className="flex items-center justify-between p-3 border rounded-lg shadow-sm">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{getPermissionDisplayName(permission)}</p>
|
||||
<p className="text-xs text-muted-foreground">{permission.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{permission.action}
|
||||
</Badge>
|
||||
{permission.isSystem && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Système
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Edit Role Dialog */}
|
||||
<Dialog open={editRoleOpen} onOpenChange={setEditRoleOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier le Rôle</DialogTitle>
|
||||
<DialogDescription>Modifiez les informations du rôle</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedRole && (
|
||||
<form onSubmit={handleUpdateRole} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-name">Nom technique</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
defaultValue={selectedRole.name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-displayName">Nom d'affichage</Label>
|
||||
<Input
|
||||
id="edit-displayName"
|
||||
name="displayName"
|
||||
defaultValue={selectedRole.displayName}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<Input
|
||||
id="edit-description"
|
||||
name="description"
|
||||
defaultValue={selectedRole.description || ''}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-color">Couleur</Label>
|
||||
<Input
|
||||
id="edit-color"
|
||||
name="color"
|
||||
type="color"
|
||||
defaultValue={selectedRole.color}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={updateRoleMutation.isPending}>
|
||||
{updateRoleMutation.isPending ? 'Modification...' : 'Modifier'}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { isUnauthorizedError } from "@/lib/authUtils";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Building,
|
||||
Phone,
|
||||
Mail,
|
||||
Edit,
|
||||
Trash2,
|
||||
Package,
|
||||
Truck,
|
||||
CheckCircle,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import type { Supplier } from "@shared/schema";
|
||||
|
||||
export default function Suppliers() {
|
||||
const { user } = useAuthUnified();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<Supplier | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
contact: "",
|
||||
phone: "",
|
||||
hasDlc: false,
|
||||
});
|
||||
|
||||
const { data: suppliers = [], isLoading } = useQuery<Supplier[]>({
|
||||
queryKey: ['/api/suppliers'],
|
||||
enabled: user?.role === 'admin' || user?.role === 'manager',
|
||||
});
|
||||
|
||||
const { data: orders = [] } = useQuery({
|
||||
queryKey: ['/api/orders'],
|
||||
enabled: user?.role === 'admin' || user?.role === 'manager',
|
||||
});
|
||||
|
||||
const { data: deliveries = [] } = useQuery({
|
||||
queryKey: ['/api/deliveries'],
|
||||
enabled: user?.role === 'admin' || user?.role === 'manager',
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
console.log('🚚 Frontend: Creating supplier with data:', data);
|
||||
const result = await apiRequest("/api/suppliers", "POST", data);
|
||||
console.log('🚚 Frontend: Supplier creation result:', result);
|
||||
return result;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Fournisseur créé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
|
||||
setShowCreateModal(false);
|
||||
setFormData({ name: "", contact: "", phone: "", hasDlc: false });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de créer le fournisseur",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
console.log('🔧 Frontend: Updating supplier with data:', data);
|
||||
const result = await apiRequest(`/api/suppliers/${selectedSupplier?.id}`, "PUT", data);
|
||||
console.log('🔧 Frontend: Supplier update result:', result);
|
||||
return result;
|
||||
},
|
||||
onMutate: async (newData) => {
|
||||
// Mise à jour optimiste - mettre à jour l'interface immédiatement
|
||||
console.log('🚀 Frontend: Optimistic update starting...');
|
||||
await queryClient.cancelQueries({ queryKey: ['/api/suppliers'] });
|
||||
|
||||
const previousSuppliers = queryClient.getQueryData(['/api/suppliers']);
|
||||
|
||||
if (previousSuppliers && selectedSupplier) {
|
||||
const updatedSuppliers = (previousSuppliers as any[]).map(supplier =>
|
||||
supplier.id === selectedSupplier.id
|
||||
? { ...supplier, ...newData, updatedAt: new Date().toISOString() }
|
||||
: supplier
|
||||
);
|
||||
queryClient.setQueryData(['/api/suppliers'], updatedSuppliers);
|
||||
console.log('✅ Frontend: Optimistic update applied');
|
||||
}
|
||||
|
||||
return { previousSuppliers };
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
console.log('✅ Frontend: Update mutation successful, result:', result);
|
||||
console.log('🔄 Frontend: Starting cache invalidation...');
|
||||
|
||||
// Forcer une actualisation complète du cache
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ['/api/suppliers'],
|
||||
type: 'active'
|
||||
});
|
||||
|
||||
// Attendre un petit délai pour s'assurer que les données sont fraîches
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log('✅ Frontend: Cache invalidation complete');
|
||||
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Fournisseur modifié avec succès",
|
||||
});
|
||||
|
||||
setShowEditModal(false);
|
||||
setSelectedSupplier(null);
|
||||
setFormData({ name: "", contact: "", phone: "", hasDlc: false });
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
// Rollback optimiste en cas d'erreur
|
||||
if (context?.previousSuppliers) {
|
||||
console.log('❌ Frontend: Error occurred, rolling back optimistic update');
|
||||
queryClient.setQueryData(['/api/suppliers'], context.previousSuppliers);
|
||||
}
|
||||
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de modifier le fournisseur",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await apiRequest(`/api/suppliers/${id}`, "DELETE");
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Fournisseur supprimé avec succès",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/suppliers'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
toast({
|
||||
title: "Non autorisé",
|
||||
description: "Vous êtes déconnecté. Reconnexion...",
|
||||
variant: "destructive",
|
||||
});
|
||||
setTimeout(() => {
|
||||
window.location.href = "/api/login";
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer le fournisseur",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filteredSuppliers = Array.isArray(suppliers) ? suppliers.filter(supplier =>
|
||||
supplier.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.contact?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.phone?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
) : [];
|
||||
|
||||
const getSupplierStats = (supplierId: number) => {
|
||||
const supplierOrders = Array.isArray(orders) ? orders.filter(order => order.supplierId === supplierId) : [];
|
||||
const supplierDeliveries = Array.isArray(deliveries) ? deliveries.filter(delivery => delivery.supplierId === supplierId) : [];
|
||||
|
||||
return {
|
||||
orders: supplierOrders.length,
|
||||
deliveries: supplierDeliveries.length,
|
||||
delivered: supplierDeliveries.filter(d => d.status === 'delivered').length,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormData({ name: "", contact: "", phone: "", hasDlc: false });
|
||||
setShowCreateModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (supplier: Supplier) => {
|
||||
setSelectedSupplier(supplier);
|
||||
setFormData({
|
||||
name: supplier.name,
|
||||
contact: supplier.contact || "",
|
||||
phone: supplier.phone || "",
|
||||
hasDlc: supplier.hasDlc || false,
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (supplier: Supplier) => {
|
||||
const stats = getSupplierStats(supplier.id);
|
||||
|
||||
if (stats.orders > 0 || stats.deliveries > 0) {
|
||||
toast({
|
||||
title: "Suppression impossible",
|
||||
description: "Ce fournisseur a des commandes ou livraisons associées",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`Êtes-vous sûr de vouloir supprimer le fournisseur "${supplier.name}" ?`)) {
|
||||
deleteMutation.mutate(supplier.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Le nom du fournisseur est requis",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSupplier) {
|
||||
updateMutation.mutate(formData);
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: string, value: string | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const canManage = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Building className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Accès restreint
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
Vous n'avez pas les permissions nécessaires pour accéder à cette page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<Building className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Gestion des Fournisseurs
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{filteredSuppliers.length} fournisseur{filteredSuppliers.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouveau Fournisseur
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-gray-50 border-b border-gray-200 p-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher un fournisseur..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 border border-gray-300 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suppliers List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : filteredSuppliers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
||||
<Building className="w-16 h-16 mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-medium mb-2">
|
||||
{searchTerm ? "Aucun fournisseur trouvé" : "Aucun fournisseur"}
|
||||
</h3>
|
||||
<p className="text-center max-w-md">
|
||||
{searchTerm
|
||||
? "Aucun fournisseur ne correspond à votre recherche."
|
||||
: "Vous n'avez pas encore de fournisseurs. Créez votre premier fournisseur pour commencer."}
|
||||
</p>
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="mt-4 bg-primary hover:bg-blue-700 text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Créer un fournisseur
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredSuppliers.map((supplier) => {
|
||||
const stats = getSupplierStats(supplier.id);
|
||||
return (
|
||||
<div key={supplier.id} className="bg-white border border-gray-200 shadow-lg hover:shadow-xl transition-shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 bg-blue-600 flex items-center justify-center mr-3">
|
||||
<Building className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{supplier.name}</h3>
|
||||
<p className="text-sm text-gray-500">#{supplier.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(supplier)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(supplier)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{supplier.contact && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
{supplier.contact}
|
||||
</div>
|
||||
)}
|
||||
{supplier.phone && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<Phone className="w-4 h-4 mr-2" />
|
||||
{supplier.phone}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center text-sm">
|
||||
{supplier.hasDlc ? (
|
||||
<div className="flex items-center text-green-600">
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
DLC activé
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-gray-400">
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
DLC désactivé
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-primary mb-1">
|
||||
<Package className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.orders}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Commandes</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center text-secondary mb-1">
|
||||
<Truck className="w-4 h-4 mr-1" />
|
||||
<span className="font-semibold">{stats.deliveries}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Livraisons</p>
|
||||
</div>
|
||||
</div>
|
||||
{stats.deliveries > 0 && (
|
||||
<div className="mt-2 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
{stats.delivered} livrées ({Math.round((stats.delivered / stats.deliveries) * 100)}%)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Dialog open={showCreateModal || showEditModal} onOpenChange={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedSupplier(null);
|
||||
setFormData({ name: "", contact: "", phone: "", hasDlc: false });
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md" aria-describedby="supplier-modal-description">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{selectedSupplier ? 'Modifier' : 'Nouveau'} Fournisseur
|
||||
</DialogTitle>
|
||||
<p id="supplier-modal-description" className="text-sm text-gray-600 mt-1">
|
||||
{selectedSupplier ? 'Modifier les informations du fournisseur' : 'Créer un nouveau fournisseur'}
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nom du fournisseur *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="Nom du fournisseur"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="contact">Contact</Label>
|
||||
<Input
|
||||
id="contact"
|
||||
value={formData.contact}
|
||||
onChange={(e) => handleChange('contact', e.target.value)}
|
||||
placeholder="Nom du contact"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="phone">Téléphone</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
placeholder="Numéro de téléphone"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="hasDlc"
|
||||
checked={formData.hasDlc}
|
||||
onCheckedChange={(checked) => handleChange('hasDlc', checked === true)}
|
||||
/>
|
||||
<Label htmlFor="hasDlc" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
Gestion DLC
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setShowEditModal(false);
|
||||
setSelectedSupplier(null);
|
||||
setFormData({ name: "", contact: "", phone: "", hasDlc: false });
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
className="bg-primary hover:bg-blue-700"
|
||||
>
|
||||
{createMutation.isPending || updateMutation.isPending
|
||||
? "Enregistrement..."
|
||||
: (selectedSupplier ? "Modifier" : "Créer")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Pagination, usePagination } from "@/components/ui/pagination";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import {
|
||||
ListTodo,
|
||||
Plus,
|
||||
Search,
|
||||
Circle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Edit,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import TaskForm from "@/components/tasks/TaskForm";
|
||||
import { Task } from "@shared/schema";
|
||||
|
||||
type TaskWithRelations = Task & {
|
||||
assignedUser: { id: string; username: string; firstName?: string; lastName?: string; };
|
||||
creator: { id: string; username: string; firstName?: string; lastName?: string; };
|
||||
group: { id: number; name: string; color: string; };
|
||||
};
|
||||
|
||||
export default function Tasks() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// États locaux
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>("all");
|
||||
|
||||
// Modals
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null);
|
||||
const [taskToDelete, setTaskToDelete] = useState<TaskWithRelations | null>(null);
|
||||
|
||||
// Fetch tasks
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/tasks", selectedStoreId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId);
|
||||
}
|
||||
return fetch(`/api/tasks?${params.toString()}`, {
|
||||
credentials: 'include'
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Fetch users for task assignment
|
||||
const { data: users = [] } = useQuery({
|
||||
queryKey: ["/api/users"],
|
||||
queryFn: () => fetch('/api/users', {
|
||||
credentials: 'include'
|
||||
}).then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
|
||||
|
||||
const handleEditTask = (task: TaskWithRelations) => {
|
||||
setSelectedTask(task);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleCompleteTask = async (taskId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskId}/complete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la completion de la tâche');
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche marquée comme terminée",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error completing task:", error);
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de terminer la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (task: TaskWithRelations) => {
|
||||
setTaskToDelete(task);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!taskToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${taskToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la suppression');
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche supprimée avec succès",
|
||||
});
|
||||
|
||||
setShowDeleteModal(false);
|
||||
setTaskToDelete(null);
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Filtrer les tâches
|
||||
const filteredTasks = tasks.filter((task: TaskWithRelations) => {
|
||||
// Filtre par recherche
|
||||
if (searchTerm && !task.title.toLowerCase().includes(searchTerm.toLowerCase()) &&
|
||||
!task.description?.toLowerCase().includes(searchTerm.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtre par statut
|
||||
if (statusFilter !== "all" && task.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filtre par priorité
|
||||
if (priorityFilter !== "all" && task.priority !== priorityFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const {
|
||||
currentPage,
|
||||
setCurrentPage,
|
||||
itemsPerPage,
|
||||
setItemsPerPage,
|
||||
totalPages,
|
||||
paginatedData: paginatedTasks,
|
||||
totalItems
|
||||
} = usePagination(filteredTasks, 10);
|
||||
|
||||
const getPriorityConfig = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return {
|
||||
color: 'destructive' as const,
|
||||
icon: AlertTriangle,
|
||||
label: 'Élevée'
|
||||
};
|
||||
case 'medium':
|
||||
return {
|
||||
color: 'default' as const,
|
||||
icon: Clock,
|
||||
label: 'Moyenne'
|
||||
};
|
||||
case 'low':
|
||||
return {
|
||||
color: 'secondary' as const,
|
||||
icon: Circle,
|
||||
label: 'Faible'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'secondary' as const,
|
||||
icon: Circle,
|
||||
label: 'Moyenne'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const canCreateTasks = user?.role === 'admin' || user?.role === 'manager';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-6 shadow-sm -m-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
||||
<ListTodo className="w-6 h-6 mr-3 text-blue-600" />
|
||||
Gestion des Tâches
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{totalItems} tâche{totalItems !== 1 ? 's' : ''} trouvée{totalItems !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canCreateTasks && (
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white shadow-md">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nouvelle Tâche
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer une nouvelle tâche</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TaskForm
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Sidebar avec filtres */}
|
||||
<div className="w-80 bg-gray-50 border-r border-gray-200 p-4 flex-shrink-0">
|
||||
{/* Filtres */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">Filtres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Recherche */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Rechercher..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filtre par statut */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Statut
|
||||
</label>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes</SelectItem>
|
||||
<SelectItem value="pending">En cours</SelectItem>
|
||||
<SelectItem value="completed">Terminées</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Filtre par priorité */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Priorité
|
||||
</label>
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes</SelectItem>
|
||||
<SelectItem value="high">Élevée</SelectItem>
|
||||
<SelectItem value="medium">Moyenne</SelectItem>
|
||||
<SelectItem value="low">Faible</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Zone principale avec les tâches */}
|
||||
<div className="flex-1">
|
||||
{/* Liste des tâches */}
|
||||
<div className="p-6">
|
||||
{totalItems === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<ListTodo className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Aucune tâche
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Aucune tâche trouvée avec les filtres sélectionnés.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Tâches en cours */}
|
||||
{paginatedTasks.filter(task => task.status === 'pending').length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-3">
|
||||
Tâches en cours ({paginatedTasks.filter(task => task.status === 'pending').length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{paginatedTasks
|
||||
.filter(task => task.status === 'pending')
|
||||
.map((task) => {
|
||||
const priorityConfig = getPriorityConfig(task.priority);
|
||||
const PriorityIcon = priorityConfig.icon;
|
||||
|
||||
return (
|
||||
<Card key={task.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h5 className="font-medium text-gray-900 truncate">
|
||||
{task.title}
|
||||
</h5>
|
||||
<Badge variant={priorityConfig.color} className="flex items-center gap-1">
|
||||
<PriorityIcon className="w-3 h-3" />
|
||||
{priorityConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-sm text-gray-600 mb-2 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
Assigné à: {task.assignedTo}
|
||||
</span>
|
||||
<span>
|
||||
Créée: {format(new Date(task.createdAt), 'dd/MM/yyyy HH:mm', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCompleteTask(task.id)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEditTask(task)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDeleteClick(task)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tâches terminées */}
|
||||
{paginatedTasks.filter(task => task.status === 'completed').length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-3 mt-8">
|
||||
Tâches terminées ({paginatedTasks.filter(task => task.status === 'completed').length})
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{paginatedTasks
|
||||
.filter(task => task.status === 'completed')
|
||||
.map((task) => {
|
||||
const priorityConfig = getPriorityConfig(task.priority);
|
||||
const PriorityIcon = priorityConfig.icon;
|
||||
|
||||
return (
|
||||
<Card key={task.id} className="opacity-60 bg-gray-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h5 className="font-medium text-gray-500 truncate line-through">
|
||||
{task.title}
|
||||
</h5>
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Terminée
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-sm text-gray-400 mb-2 line-clamp-2 line-through">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>
|
||||
Assigné à: {task.assignedTo}
|
||||
</span>
|
||||
<span>
|
||||
Créée: {format(new Date(task.createdAt), 'dd/MM/yyyy HH:mm', { locale: fr })}
|
||||
</span>
|
||||
{task.completedAt && (
|
||||
<span>
|
||||
Terminée: {format(new Date(task.completedAt), 'dd/MM/yyyy HH:mm', { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDeleteClick(task)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalItems > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={setCurrentPage}
|
||||
onItemsPerPageChange={setItemsPerPage}
|
||||
className="mt-4 border-t border-gray-200 pt-4 mx-6"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal d'édition */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la tâche</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedTask && (
|
||||
<TaskForm
|
||||
task={selectedTask}
|
||||
onClose={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Modal de confirmation de suppression */}
|
||||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-red-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Confirmer la suppression
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-gray-700">
|
||||
Êtes-vous sûr de vouloir supprimer la tâche "{taskToDelete?.title}" ?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Cette action est irréversible.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setTaskToDelete(null);
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
||||
import { useStore } from "@/components/Layout";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import {
|
||||
ListTodo,
|
||||
Plus,
|
||||
Search,
|
||||
Circle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Edit,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { Task } from "@shared/schema";
|
||||
|
||||
type TaskWithRelations = Task & {
|
||||
creator: { id: string; username: string; name: string; };
|
||||
group: { id: number; name: string; color: string; };
|
||||
};
|
||||
|
||||
export default function TasksSimplified() {
|
||||
const { user } = useAuthUnified();
|
||||
const { selectedStoreId } = useStore();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// États locaux
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [priorityFilter, setPriorityFilter] = useState<string>("all");
|
||||
|
||||
// Modals et tâche sélectionnée
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<TaskWithRelations | null>(null);
|
||||
|
||||
// Récupération des tâches
|
||||
const { data: tasks = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/tasks", selectedStoreId],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedStoreId) {
|
||||
params.append('storeId', selectedStoreId);
|
||||
}
|
||||
return fetch(`/api/tasks?${params.toString()}`).then(res => res.json());
|
||||
},
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Récupération des magasins
|
||||
const { data: stores = [] } = useQuery({
|
||||
queryKey: ["/api/groups"],
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Filtrer les tâches
|
||||
const filteredTasks = tasks.filter((task: TaskWithRelations) => {
|
||||
const matchesSearch = task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
task.assignedTo?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
|
||||
const matchesPriority = priorityFilter === "all" || task.priority === priorityFilter;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesPriority;
|
||||
});
|
||||
|
||||
// Création d'une tâche
|
||||
const handleCreateTask = async (taskData: any) => {
|
||||
try {
|
||||
await apiRequest('/api/tasks', 'POST', {
|
||||
...taskData,
|
||||
groupId: selectedStoreId ? parseInt(selectedStoreId) : stores[0]?.id
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
setShowCreateModal(false);
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche créée avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de créer la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Modification d'une tâche
|
||||
const handleUpdateTask = async (taskData: any) => {
|
||||
if (!selectedTask) return;
|
||||
|
||||
try {
|
||||
await apiRequest(`/api/tasks/${selectedTask.id}`, 'PUT', taskData);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
setShowEditModal(false);
|
||||
setSelectedTask(null);
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche modifiée avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de modifier la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Complétion d'une tâche
|
||||
const handleCompleteTask = async (taskId: number) => {
|
||||
try {
|
||||
await apiRequest(`/api/tasks/${taskId}/complete`, 'POST');
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche marquée comme terminée",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de compléter la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Suppression d'une tâche
|
||||
const handleDeleteTask = async (taskId: number) => {
|
||||
try {
|
||||
await apiRequest(`/api/tasks/${taskId}`, 'DELETE');
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/tasks"] });
|
||||
toast({
|
||||
title: "Succès",
|
||||
description: "Tâche supprimée avec succès",
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de supprimer la tâche",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTask = (task: TaskWithRelations) => {
|
||||
setSelectedTask(task);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed": return "bg-green-100 text-green-800";
|
||||
case "pending": return "bg-yellow-100 text-yellow-800";
|
||||
default: return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case "high": return "bg-red-100 text-red-800";
|
||||
case "medium": return "bg-blue-100 text-blue-800";
|
||||
case "low": return "bg-green-100 text-green-800";
|
||||
default: return "bg-gray-100 text-gray-800";
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-lg">Chargement des tâches...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-lg">Vous devez être connecté pour accéder aux tâches.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ListTodo className="h-6 w-6 text-blue-600" />
|
||||
<h1 className="text-2xl font-bold">Gestion des Tâches</h1>
|
||||
</div>
|
||||
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nouvelle Tâche
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Créer une nouvelle tâche</DialogTitle>
|
||||
</DialogHeader>
|
||||
<TaskForm onSubmit={handleCreateTask} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Filtres */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Filtres</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="Rechercher une tâche..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-[150px]">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Statut" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||||
<SelectItem value="pending">En attente</SelectItem>
|
||||
<SelectItem value="completed">Terminée</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-w-[150px]">
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Priorité" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toutes priorités</SelectItem>
|
||||
<SelectItem value="high">Haute</SelectItem>
|
||||
<SelectItem value="medium">Moyenne</SelectItem>
|
||||
<SelectItem value="low">Basse</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Liste des tâches */}
|
||||
<div className="space-y-4">
|
||||
{filteredTasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center h-32">
|
||||
<div className="text-center">
|
||||
<ListTodo className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-500">Aucune tâche trouvée</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTasks.map((task: TaskWithRelations) => (
|
||||
<Card key={task.id} className={`hover:shadow-md transition-shadow ${task.status === 'completed' ? 'opacity-60 bg-gray-50' : ''}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className={`font-semibold text-lg ${task.status === 'completed' ? 'line-through text-gray-500' : ''}`}>{task.title}</h3>
|
||||
<Badge className={getStatusColor(task.status)}>
|
||||
{task.status === "completed" ? "Terminée" : "En attente"}
|
||||
</Badge>
|
||||
<Badge className={getPriorityColor(task.priority)}>
|
||||
{task.priority === "high" ? "Haute" : task.priority === "medium" ? "Moyenne" : "Basse"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className={`mb-3 ${task.status === 'completed' ? 'text-gray-400 line-through' : 'text-gray-600'}`}>{task.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="font-medium">Assigné à:</span>
|
||||
<span>{task.assignedTo || "Non assigné"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="font-medium">Magasin:</span>
|
||||
<span className="px-2 py-1 rounded text-xs" style={{ backgroundColor: task.group.color + '20', color: task.group.color }}>
|
||||
{task.group.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="font-medium">Créé par:</span>
|
||||
<span>{task.creator.name || task.creator.username}</span>
|
||||
</div>
|
||||
|
||||
{task.dueDate && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
Échéance: {format(new Date(task.dueDate), 'dd/MM/yyyy HH:mm', { locale: fr })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{task.status !== "completed" && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCompleteTask(task.id)}
|
||||
className="text-green-600 hover:text-green-700"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEditTask(task)}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDeleteTask(task.id)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal de modification */}
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifier la tâche</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedTask && (
|
||||
<TaskForm
|
||||
initialData={selectedTask}
|
||||
onSubmit={handleUpdateTask}
|
||||
isEditing={true}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Composant de formulaire simplifié
|
||||
function TaskForm({ onSubmit, initialData, isEditing = false }: {
|
||||
onSubmit: (data: any) => void;
|
||||
initialData?: TaskWithRelations;
|
||||
isEditing?: boolean;
|
||||
}) {
|
||||
const [title, setTitle] = useState(initialData?.title || "");
|
||||
const [description, setDescription] = useState(initialData?.description || "");
|
||||
const [assignedTo, setAssignedTo] = useState(initialData?.assignedTo || "");
|
||||
const [priority, setPriority] = useState(initialData?.priority || "medium");
|
||||
const [dueDate, setDueDate] = useState(
|
||||
initialData?.dueDate ? format(new Date(initialData.dueDate), 'yyyy-MM-dd') : ""
|
||||
);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim() || !assignedTo.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
assignedTo: assignedTo.trim(),
|
||||
priority,
|
||||
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
||||
};
|
||||
|
||||
onSubmit(taskData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium mb-1">
|
||||
Titre de la tâche *
|
||||
</label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Titre de la tâche"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description de la tâche"
|
||||
className="w-full p-2 border rounded-md"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="assignedTo" className="block text-sm font-medium mb-1">
|
||||
Assigné à *
|
||||
</label>
|
||||
<Input
|
||||
id="assignedTo"
|
||||
value={assignedTo}
|
||||
onChange={(e) => setAssignedTo(e.target.value)}
|
||||
placeholder="Nom de la personne responsable"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="priority" className="block text-sm font-medium mb-1">
|
||||
Priorité
|
||||
</label>
|
||||
<Select value={priority} onValueChange={setPriority}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Basse</SelectItem>
|
||||
<SelectItem value="medium">Moyenne</SelectItem>
|
||||
<SelectItem value="high">Haute</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="dueDate" className="block text-sm font-medium mb-1">
|
||||
Date d'échéance
|
||||
</label>
|
||||
<Input
|
||||
id="dueDate"
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="submit">
|
||||
{isEditing ? "Modifier" : "Créer"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,21 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex mb-4 gap-2">
|
||||
<AlertCircle className="h-8 w-8 text-red-500" />
|
||||
<h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Did you forget to add the page to the router?
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user