This commit is contained in:
LogiFlow committed 2025-08-11 14:43:48 +00:00
1 parent e3fb29ab1d
commit 2485f71dd7
95 files changed
+41842

No files matched your search

+140
View File
@@ -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,
}
+771
View File
@@ -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,
}
+15
View File
@@ -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 }
+26
View File
@@ -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 }
+27
View File
@@ -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 }
+117
View File
@@ -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,
}
+53
View File
@@ -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 }
+22
View File
@@ -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 }
+127
View File
@@ -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,
}
+33
View File
@@ -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>
)
}
+61
View File
@@ -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 }
+43
View File
@@ -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 }
+30
View File
@@ -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 }
+19
View File
@@ -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
}
+191
View File
@@ -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 }
+28
View File
@@ -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,
};
}
+70
View File
@@ -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'
};
}
+63
View File
@@ -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
};
}
+205
View File
@@ -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'
};
}
}
+255
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export function isUnauthorizedError(error: Error): boolean {
return /^401: .*Unauthorized/.test(error.message);
}
+71
View File
@@ -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();
}
+80
View File
@@ -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,
},
},
});
+58
View File
@@ -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'
};
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(<App />);
+298
View File
@@ -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
+265
View File
@@ -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>
);
}
+984
View File
@@ -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>
);
}
+527
View File
@@ -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>
);
}
+528
View File
@@ -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>
);
}
+806
View File
@@ -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>
);
}
+632
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+665
View File
@@ -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>
);
}
+466
View File
@@ -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>
);
}
+936
View File
@@ -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>
);
}
+741
View File
@@ -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>
);
}
+542
View File
@@ -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>
);
}
+562
View File
@@ -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>
);
}
+494
View File
@@ -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
+21
View File
@@ -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>
);
}