feat(opendock): added in new article link setup for fine tuning how od works

This commit is contained in:
2026-05-23 11:22:02 -05:00
parent 3a27fd8542
commit 389211186f
24 changed files with 3903 additions and 72 deletions

View File

@@ -0,0 +1,94 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { ChevronRight, Link as link } from "lucide-react";
import { permissionQuery } from "../../lib/queries/permsCheck";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from "../ui/sidebar";
export default function TransportationBar() {
const { data: canCreate = false } = useQuery(
permissionQuery({
openDock: ["create"],
}),
);
const { setOpen } = useSidebar();
const items = [
{
title: "Open Dock",
url: "/transportation",
//icon,
isActive: canCreate,
items: [
{
title: "ArticleLink",
icon: link,
url: "/transportation/opendock",
},
],
},
];
return (
<SidebarGroup>
<SidebarGroupLabel>Transportation</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<div key={item.title}>
{item.isActive && (
<Collapsible
asChild
//defaultOpen={isNotifications}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.title}
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild>
<Link
to={subItem.url}
onClick={() => setOpen(false)}
>
<subItem.icon />
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
)}
</div>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}

View File

@@ -1,4 +1,4 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import {
Sidebar,
SidebarContent,
@@ -8,13 +8,20 @@ import {
} from "@/components/ui/sidebar";
import { useSession } from "@/lib/auth-client";
import { getSettings } from "../../lib/queries/getSettings";
import { permissionQuery } from "../../lib/queries/permsCheck";
import AdminSidebar from "./AdminBar";
import DocBar from "./DocBar";
import MobileBar from "./MobileBar";
import TransportationBar from "./TransportationBar";
export function AppSidebar() {
const { data: session } = useSession();
const { data: settings, isLoading } = useSuspenseQuery(getSettings());
const { data: canRead = false } = useQuery(
permissionQuery({
openDock: ["read"],
}),
);
return (
<Sidebar
@@ -32,6 +39,11 @@ export function AppSidebar() {
<MobileBar />
)}
{!isLoading &&
settings.filter((n: any) => n.name === "opendock_sync")[0]
?.active &&
canRead && <TransportationBar />}
{session &&
(session.user.role === "admin" ||
session.user.role === "systemAdmin" ||

View File

@@ -0,0 +1,299 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
asChild
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
>
<ComboboxTrigger />
</InputGroupButton>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}

View File

@@ -0,0 +1,154 @@
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
/* eslint-disable */
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className,
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
},
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
},
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className,
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
};

View File

@@ -1,21 +1,19 @@
import type * as React from "react";
import * as React from "react"
import { cn } from "@/lib/utils";
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
/>
);
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input };
export { Input }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -13,17 +13,19 @@ type SelectableRole = {
export const selectableRoles: SelectableRole[] = [
{ label: "User", value: "user" },
{ label: "Manager", value: "manager" },
{ label: "Transport", value: "transport" },
{ label: "Admin", value: "admin" },
{ label: "System Admin", value: "systemAdmin" },
];
export const statement = {
...defaultStatements,
app: ["read", "create", "share", "update", "delete", "readAll"],
quality: ["read", "create", "share", "update", "delete", "readAll"],
logistics: ["read", "create", "share", "update", "delete", "readAll"],
mobile: ["read", "create", "share", "update", "delete", "readAll"],
notifications: ["read", "create", "share", "update", "delete", "readAll"],
app: ["read", "create", "update", "delete", "readAll"],
quality: ["read", "create", "update", "delete", "readAll"],
logistics: ["read", "create", "update", "delete", "readAll"],
mobile: ["read", "create", "update", "delete", "readAll"],
openDock: ["read", "create", "update", "delete"],
notifications: ["read", "create", "update", "delete", "readAll"],
} as const;
export const ac = createAccessControl(statement);
@@ -31,41 +33,79 @@ export const ac = createAccessControl(statement);
export const user = ac.newRole({
app: ["read", "create"],
notifications: ["read", "create"],
openDock: ["read"],
});
export const manager = ac.newRole({
app: ["read", "create", "update"],
mobile: ["read", "create", "update"],
openDock: ["read", "create", "update"],
});
export const transport = ac.newRole({
app: ["read", "create", "update"],
openDock: ["read", "create", "update"],
});
export const admin = ac.newRole({
app: ["read", "create", "update"],
mobile: ["read", "create", "update"],
user: ["create", "update"],
user: ["create", "update", "ban"],
openDock: ["read", "create", "update"],
});
export const systemAdmin = ac.newRole({
...adminAc.statements,
app: ["read", "create", "share", "update", "delete", "readAll"],
quality: ["read", "create", "share", "update", "delete", "readAll"],
mobile: ["read", "create", "share", "update", "delete", "readAll"],
logistics: ["read", "create", "share", "update", "delete", "readAll"],
notifications: ["read", "create", "share", "update", "delete", "readAll"],
app: ["read", "create", "update", "delete", "readAll"],
quality: ["read", "create", "update", "delete", "readAll"],
mobile: ["read", "create", "update", "delete", "readAll"],
logistics: ["read", "create", "update", "delete", "readAll"],
notifications: ["read", "create", "update", "delete", "readAll"],
openDock: ["read", "create", "update", "delete"],
});
/* example usage
const canCreateProject = await authClient.admin.hasPermission({
permissions: {
project: ["create"],
},
});
// You can also check multiple resource permissions at the same time
const canCreateProjectAndCreateSale = await authClient.admin.hasPermission({
permissions: {
project: ["create"],
sale: ["create"]
},
});
/*
inside a component
*/
const { data: canImpersonate = false } = useQuery(
permissionQuery({
user: ["impersonate"],
logistics: ["delete"]
}),
);
on the before load use this example
beforeLoad: async ({ location }) => {
const { data: session } = await authClient.getSession();
//const allowedRole = ["systemAdmin", "admin", "manager"];
const canAccess = await authClient.admin.hasPermission({
permissions: {
opendock: ["create"],
logistics: ["delete"]
},
});
if (!session?.user) {
throw redirect({
to: "/",
search: {
redirect: location.href,
},
});
}
//if (!allowedRole.includes(session.user.role as string)) {
if (!canAccess) {
throw redirect({
to: "/",
});
}
return { user: session.user };
},
*/

View File

@@ -0,0 +1,84 @@
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "../../components/ui/combobox";
import { Label } from "../../components/ui/label";
import { useFieldContext } from ".";
import { FieldErrors } from "./Errors.Field";
// type SelectOption = {
// value: string;
// label: string;
// };
type ComboBoxFieldProps = {
data: string[];
label: string;
placeholder: string;
};
export const ComboBoxField = ({
data = [],
label,
placeholder = "",
}: ComboBoxFieldProps) => {
const field = useFieldContext<string>();
return (
<div className="grid gap-3">
<div className="grid gap-3">
<Label htmlFor={field.name}>{label}</Label>
{/* <Select
value={field.state.value}
onValueChange={(value) => field.handleChange(value)}
>
<SelectTrigger
id={field.name}
onBlur={field.handleBlur}
className="w-min-2/3 w-max-fit"
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent position={"popper"}>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select> */}
<Combobox
items={data}
//value={field.state.value ?? ""}
// onValueChange={(value) => {
// console.log(value);
// field.handleChange(value);
// }}
>
<ComboboxInput placeholder={placeholder} />
{/* <ComboboxInput
//showTrigger={false}
placeholder={placeholder}
onBlur={field.handleBlur}
showClear
/> */}
<ComboboxContent className="max-h-72 overflow-y-auto">
<ComboboxEmpty>No items found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<FieldErrors meta={field.state.meta} />
</div>
);
};

View File

@@ -18,12 +18,14 @@ type SelectFieldProps = {
label: string;
options: SelectOption[];
placeholder?: string;
disabled?: boolean;
};
export const SelectField = ({
label,
options,
placeholder,
disabled = false,
}: SelectFieldProps) => {
const field = useFieldContext<string>();
@@ -34,6 +36,7 @@ export const SelectField = ({
<Select
value={field.state.value}
onValueChange={(value) => field.handleChange(value)}
disabled={disabled}
>
<SelectTrigger
id={field.name}

View File

@@ -1,5 +1,6 @@
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
import { CheckboxField } from "./CheckBox.Field";
import { ComboBoxField } from "./ComboBox.Field";
import { DynamicInputField } from "./DynamicInput.Field";
import { InputField } from "./Input.Field";
import { InputPasswordField } from "./InputPassword.Field";
@@ -21,6 +22,7 @@ export const { useAppForm } = createFormHook({
//Searchable,
SwitchField,
DynamicInputField,
ComboBoxField,
},
formComponents: { SubmitButton },
fieldContext,

View File

@@ -0,0 +1,25 @@
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { api } from "../apiHelper";
export function getActiveArticle() {
return queryOptions({
queryKey: ["getActiveArticle"],
queryFn: () => dataFetch(),
staleTime: 5000,
refetchOnWindowFocus: true,
placeholderData: keepPreviousData,
});
}
const dataFetch = async () => {
if (window.location.hostname === "localhost") {
await new Promise((res) => setTimeout(res, 1500));
}
const { data } = await api.get("/datamart/activeArticles");
if (!data.success) {
throw new Error(data.message ?? "Failed to load articles");
}
return data.data ?? [];
};

View File

@@ -0,0 +1,25 @@
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { api } from "../apiHelper";
export function getArticleLinks() {
return queryOptions({
queryKey: ["getArticleLinks"],
queryFn: () => dataFetch(),
staleTime: 5000,
refetchOnWindowFocus: true,
placeholderData: keepPreviousData,
});
}
const dataFetch = async () => {
if (window.location.hostname === "localhost") {
await new Promise((res) => setTimeout(res, 1500));
}
const { data } = await api.get("/opendock/articleCheck");
if (!data.success) {
throw new Error(data.message ?? "Failed to load article links");
}
return data.data ?? [];
};

View File

@@ -0,0 +1,26 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../apiHelper";
export function getCustomerByAv(av: string) {
return queryOptions({
queryKey: ["getCustomerByAv", av],
queryFn: () => dataFetch(av),
staleTime: 5000,
enabled: !!av,
refetchOnWindowFocus: true,
//placeholderData: keepPreviousData,
});
}
const dataFetch = async (av: string) => {
if (window.location.hostname === "localhost") {
await new Promise((res) => setTimeout(res, 1500));
}
const { data } = await api.get(`/opendock/articleCheck/customers/${av}`);
if (!data.success) {
throw new Error(data.message ?? "Failed to load customers");
}
return data.data ?? [];
};

View File

@@ -22,6 +22,7 @@ import { Route as AdminScanUsersRouteImport } from './routes/admin/scanUsers'
import { Route as AdminNotificationsRouteImport } from './routes/admin/notifications'
import { Route as AdminLogsRouteImport } from './routes/admin/logs'
import { Route as authLoginRouteImport } from './routes/(auth)/login'
import { Route as TransportationOpendockIndexRouteImport } from './routes/transportation/opendock/index'
import { Route as authUserSignupRouteImport } from './routes/(auth)/user.signup'
import { Route as authUserResetpasswordRouteImport } from './routes/(auth)/user.resetpassword'
import { Route as authUserProfileRouteImport } from './routes/(auth)/user.profile'
@@ -91,6 +92,12 @@ const authLoginRoute = authLoginRouteImport.update({
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const TransportationOpendockIndexRoute =
TransportationOpendockIndexRouteImport.update({
id: '/transportation/opendock/',
path: '/transportation/opendock/',
getParentRoute: () => rootRouteImport,
} as any)
const authUserSignupRoute = authUserSignupRouteImport.update({
id: '/(auth)/user/signup',
path: '/user/signup',
@@ -124,6 +131,7 @@ export interface FileRoutesByFullPath {
'/user/profile': typeof authUserProfileRoute
'/user/resetpassword': typeof authUserResetpasswordRoute
'/user/signup': typeof authUserSignupRoute
'/transportation/opendock/': typeof TransportationOpendockIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@@ -142,6 +150,7 @@ export interface FileRoutesByTo {
'/user/profile': typeof authUserProfileRoute
'/user/resetpassword': typeof authUserResetpasswordRoute
'/user/signup': typeof authUserSignupRoute
'/transportation/opendock': typeof TransportationOpendockIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -161,6 +170,7 @@ export interface FileRoutesById {
'/(auth)/user/profile': typeof authUserProfileRoute
'/(auth)/user/resetpassword': typeof authUserResetpasswordRoute
'/(auth)/user/signup': typeof authUserSignupRoute
'/transportation/opendock/': typeof TransportationOpendockIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -181,6 +191,7 @@ export interface FileRouteTypes {
| '/user/profile'
| '/user/resetpassword'
| '/user/signup'
| '/transportation/opendock/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@@ -199,6 +210,7 @@ export interface FileRouteTypes {
| '/user/profile'
| '/user/resetpassword'
| '/user/signup'
| '/transportation/opendock'
id:
| '__root__'
| '/'
@@ -217,6 +229,7 @@ export interface FileRouteTypes {
| '/(auth)/user/profile'
| '/(auth)/user/resetpassword'
| '/(auth)/user/signup'
| '/transportation/opendock/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -236,6 +249,7 @@ export interface RootRouteChildren {
authUserProfileRoute: typeof authUserProfileRoute
authUserResetpasswordRoute: typeof authUserResetpasswordRoute
authUserSignupRoute: typeof authUserSignupRoute
TransportationOpendockIndexRoute: typeof TransportationOpendockIndexRoute
}
declare module '@tanstack/react-router' {
@@ -331,6 +345,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof authLoginRouteImport
parentRoute: typeof rootRouteImport
}
'/transportation/opendock/': {
id: '/transportation/opendock/'
path: '/transportation/opendock'
fullPath: '/transportation/opendock/'
preLoaderRoute: typeof TransportationOpendockIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/(auth)/user/signup': {
id: '/(auth)/user/signup'
path: '/user/signup'
@@ -372,6 +393,7 @@ const rootRouteChildren: RootRouteChildren = {
authUserProfileRoute: authUserProfileRoute,
authUserResetpasswordRoute: authUserResetpasswordRoute,
authUserSignupRoute: authUserSignupRoute,
TransportationOpendockIndexRoute: TransportationOpendockIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -1,9 +1,8 @@
import { createFileRoute } from "@tanstack/react-router";
import z from "zod";
import { Button } from "../components/ui/button";
import { useSession } from "../lib/auth-client";
import { runtimeConfig, trackLstEvent } from "../lib/umami.utils";
import { trackLstEvent } from "../lib/umami.utils";
export const Route = createFileRoute("/")({
validateSearch: z.object({
@@ -14,7 +13,7 @@ export const Route = createFileRoute("/")({
});
function Index() {
const { data: session, isPending } = useSession();
const { isPending } = useSession();
if (isPending)
return <div className="flex justify-center mt-10">Loading...</div>;
@@ -38,15 +37,15 @@ function Index() {
});
};
const checkConfig = () => {
console.log(runtimeConfig);
trackLstEvent("config_click", {
module: "app",
action: "click",
label: "configCheck",
page: window.location.pathname,
});
};
// const checkConfig = () => {
// console.log(runtimeConfig);
// trackLstEvent("config_click", {
// module: "app",
// action: "click",
// label: "configCheck",
// page: window.location.pathname,
// });
// };
return (
<div className="flex justify-center m-10 flex-col">
@@ -77,9 +76,9 @@ function Index() {
</a>
</button>
</p>
{session && session.user.role === "systemAdmin" && (
{/* {session && session.user.role === "systemAdmin" && (
<Button onClick={checkConfig}>Check config</Button>
)}
)} */}
</div>
);
}

View File

@@ -0,0 +1,241 @@
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "../../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../../../components/ui/dialog";
import { api } from "../../../../lib/apiHelper";
import { useAppForm } from "../../../../lib/formSutff";
import { getActiveArticle } from "../../../../lib/queries/getActiveArticles";
import { getCustomerByAv } from "../../../../lib/queries/getCustomerByAv";
export default function NewArticleLink({ refetch }: { refetch: any }) {
const [open, setOpen] = useState(false);
const [selectedAv, setSelectedAv] = useState<string>("");
const { data: articleData } = useSuspenseQuery(getActiveArticle());
const {
data: customerData,
isPending,
isLoading,
} = useQuery(getCustomerByAv(selectedAv.split(" - ")[0]));
const form = useAppForm({
defaultValues: {
av: "",
description: "",
customer: "",
customerDescription: "",
loadType: "",
dock: "",
},
onSubmit: async ({ value }) => {
const corrected = {
av: parseInt(value.av.split(" - ")[0], 10),
description: value.av.split(" - ")[1],
customer: value.customer.split(" - ")[0],
customerDescription: value.customer.split(" - ")[1],
loadType: value.loadType,
dock: value.dock,
};
try {
const res = await api.post("/opendock/articleCheck", corrected, {
validateStatus: () => true,
});
if (res.data.success) {
toast.success(`The link for ${value.av} was just created :D`);
refetch();
form.reset();
setSelectedAv("");
setOpen(false);
}
if (!res.data.success) {
toast.error(
"The article customer combo are not allowed to be created twice please select a different customer.",
);
form.setFieldValue("customer", "");
console.log(res.data);
return;
}
} catch (error) {
console.log(error);
}
},
});
const closeModel = (e: boolean) => {
setOpen(e);
if (!e) {
form.reset();
setSelectedAv("");
}
};
const openForm = () => {
setOpen(true);
form.reset;
setSelectedAv("");
};
let n: any = [];
if (articleData) {
n = articleData.map((i: any) => ({
label: `${i.article} - ${i.Bezeichnung}`,
value: `${i.article} - ${i.Bezeichnung}`,
}));
}
let c: any = [];
if ((selectedAv && !isPending) || !isLoading) {
const cusData = customerData ?? [];
c = cusData.map((i: any) => ({
label: `${i.customer} - ${i.customerDescription}`,
value: `${i.customer} - ${i.customerDescription}`,
}));
}
// TODO: get this from lst as well once we get the actual docks in to link to.
// this will be live || drop but also the actaul load types so we can have a little more refined times
const loadType = [
{
label: "Live",
value: "live",
},
{
label: "Drop",
value: "drop",
},
];
//TODO: get the docks from lst to help refine and actually link the dock correctly
const dock = [
{
label: "Cermac",
value: "cermac",
},
{
label: "Gerber",
value: "gerber",
},
{
label: "Matrix",
value: "matrix",
},
];
return (
<Dialog onOpenChange={(e) => closeModel(e)} open={open}>
<Button onClick={openForm}>Create Article link</Button>
<DialogContent showCloseButton={false} className="min-w-2xl">
<DialogHeader>
<DialogTitle>Create Article Link.</DialogTitle>
<DialogDescription>
Create the fine tuned per article setup, selecting an av will pull
in only the sales prices for the av, After filling in the form all{" "}
<p className="underline">NEW</p> release created will use this as
the new default settings.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
>
<div className="w-fit">
<div className="w-fill">
<form.AppField
name="av"
listeners={{
onChange: ({ value }) => {
setSelectedAv(value);
if (form.getFieldValue("customer")) {
form.setFieldValue("customer", "");
}
},
}}
>
{(field) => (
<field.SelectField
label="Select Article"
placeholder="Select av to link"
options={n}
/>
)}
</form.AppField>
</div>
</div>
<div className="w-fit">
<div className="w-fill">
<form.AppField name="customer">
{(field) => (
<field.SelectField
label="Select Customer"
placeholder={
!selectedAv
? "Select AV first"
: isLoading
? "Loading customers..."
: c.length === 0
? "No customers to select"
: "Select customer"
}
options={c}
disabled={!selectedAv || (isLoading && c.length > 0)}
/>
)}
</form.AppField>
</div>
</div>
<div className=" flex flex-row w-fit mt-3">
<div className="w-fill">
<form.AppField name="loadType">
{(field) => (
<field.SelectField
label="Select Load Type"
placeholder={"Select LoadType"}
options={loadType}
disabled={!selectedAv}
/>
)}
</form.AppField>
</div>
<div className="w-fill">
<form.AppField name="dock">
{(field) => (
<field.SelectField
label="Select Dock"
placeholder={"Select dock"}
options={dock}
disabled={!selectedAv}
/>
)}
</form.AppField>
</div>
</div>
<div className="flex justify-end mt-2 ">
<form.AppForm>
<form.SubmitButton>Submit</form.SubmitButton>
</form.AppForm>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,122 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { createColumnHelper } from "@tanstack/react-table";
import { Suspense } from "react";
import { authClient } from "../../../lib/auth-client";
import { getArticleLinks } from "../../../lib/queries/getArticleLinks";
import LstTable from "../../../lib/tableStuff/LstTable";
import SearchableHeader from "../../../lib/tableStuff/SearchableHeader";
import SkellyTable from "../../../lib/tableStuff/SkellyTable";
import NewArticleLink from "./-components/NewArticleLink";
export const Route = createFileRoute("/transportation/opendock/")({
beforeLoad: async ({ location }) => {
const { data: session } = await authClient.getSession();
//const allowedRole = ["systemAdmin", "admin", "manager"];
const canAccess = await authClient.admin.hasPermission({
permissions: {
openDock: ["create"],
},
});
if (!session?.user) {
throw redirect({
to: "/",
search: {
redirect: location.href,
},
});
}
//if (!allowedRole.includes(session.user.role as string)) {
if (!canAccess) {
throw redirect({
to: "/",
});
}
return { user: session.user };
},
component: RouteComponent,
});
const ArticleLinkTable = () => {
const { data, refetch } = useSuspenseQuery(getArticleLinks());
const columnHelper = createColumnHelper<any>();
const columns = [
columnHelper.accessor("av", {
header: ({ column }) => (
<SearchableHeader column={column} title="Article" searchable={true} />
),
filterFn: "includesString",
cell: (i) => i.getValue(),
}),
columnHelper.accessor("description", {
header: ({ column }) => (
<SearchableHeader
column={column}
title="Description"
searchable={true}
/>
),
filterFn: "includesString",
cell: (i) => i.getValue(),
}),
columnHelper.accessor("customer", {
header: ({ column }) => (
<SearchableHeader column={column} title="Customer" searchable={true} />
),
filterFn: "includesString",
cell: (i) => (
<span>
{i.row.original.customer} - {i.row.original.customerDescription}
</span>
),
}),
columnHelper.accessor("loadType", {
header: ({ column }) => (
<SearchableHeader column={column} title="Load Type" searchable={true} />
),
filterFn: "includesString",
cell: (i) => i.getValue(),
}),
columnHelper.accessor("dock", {
header: ({ column }) => (
<SearchableHeader column={column} title="Dock" searchable={true} />
),
filterFn: "includesString",
cell: (i) => i.getValue(),
}),
];
return (
<div>
<div>
<div className="flex justify-end m-2">
<Suspense
fallback={
<div>
<p>Loading...</p>
</div>
}
>
<NewArticleLink refetch={refetch} />
</Suspense>
</div>
<div>
<LstTable data={data} columns={columns} pageSize={50} />
</div>
</div>
</div>
);
};
function RouteComponent() {
return (
<Suspense fallback={<SkellyTable />}>
<ArticleLinkTable />
</Suspense>
);
}