feat(opendock): added in new article link setup for fine tuning how od works
This commit is contained in:
21
backend/db/schema/opendock_docks.ts
Normal file
21
backend/db/schema/opendock_docks.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import type { z } from "zod";
|
||||
|
||||
export const opendockDockSetup = pgTable("opendock_dock_setup", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
dockID: text("dock_id").notNull(),
|
||||
upd_date: timestamp("upd_date").notNull().defaultNow(),
|
||||
upd_user: text("upd_user").notNull().default("lst-system"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
add_user: text("add_user").notNull().default("lst-system"),
|
||||
});
|
||||
|
||||
export const opendockDockSetupSchema = createSelectSchema(opendockDockSetup);
|
||||
export const newOpendockDockSetupSchema = createInsertSchema(opendockDockSetup);
|
||||
|
||||
export type OpendockArticleSetup = z.infer<typeof opendockDockSetupSchema>;
|
||||
export type NewOpendockArticleSetup = z.infer<
|
||||
typeof newOpendockDockSetupSchema
|
||||
>;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type NewOpendockArticleSetup,
|
||||
opendockArticleSetup,
|
||||
} from "../db/schema/opendock_articleSetup.js";
|
||||
import { opendockDockSetup } from "../db/schema/opendock_docks.js";
|
||||
import { prodQuery } from "../prodSql/prodSqlQuery.controller.js";
|
||||
import {
|
||||
type SqlQuery,
|
||||
@@ -34,6 +35,11 @@ const newArticleLink = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
const newDockLink = z.object({
|
||||
name: z.string(),
|
||||
dockID: z.string(),
|
||||
});
|
||||
|
||||
r.post("/", async (req, res) => {
|
||||
try {
|
||||
const validated = newArticleLink.parse(req.body) as NewOpendockArticleSetup;
|
||||
@@ -190,7 +196,80 @@ r.get("/customers/:av", async (req, res) => {
|
||||
module: "opendock",
|
||||
subModule: "articleCheck",
|
||||
message: `All customers linked to av: ${av}`,
|
||||
data: data as any,
|
||||
data: data?.data ?? ([] as any),
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
r.post("/dock", async (req, res) => {
|
||||
try {
|
||||
const validated = newDockLink.parse(req.body) as any;
|
||||
|
||||
const newLink = await db
|
||||
.insert(opendockDockSetup)
|
||||
.values({
|
||||
name: validated.name,
|
||||
dockID: validated.dockID,
|
||||
add_user: req.user?.username ?? "lst_user",
|
||||
})
|
||||
.returning();
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "opendock",
|
||||
subModule: "articleCheck",
|
||||
message: `${validated.name} was just added `,
|
||||
data: newLink as any,
|
||||
status: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const flattened = z.flattenError(err);
|
||||
// return res.status(400).json({
|
||||
// error: "Validation failed",
|
||||
// details: flattened,
|
||||
// });
|
||||
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error", //connect.success ? "info" : "error",
|
||||
module: "opendock",
|
||||
subModule: "articleCheck",
|
||||
message: "Validation failed",
|
||||
data: [flattened.fieldErrors],
|
||||
status: 400, //connect.success ? 200 : 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error", //connect.success ? "info" : "error",
|
||||
module: "opendock",
|
||||
subModule: "articleCheck",
|
||||
message: "Internal Server Error adding dock link",
|
||||
data: [err],
|
||||
status: 400, //connect.success ? 200 : 400,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
r.get("/dock", async (_, res) => {
|
||||
const { data } = await tryCatch(
|
||||
db
|
||||
.select()
|
||||
.from(opendockDockSetup)
|
||||
.orderBy(desc(opendockDockSetup.name))
|
||||
.limit(1500),
|
||||
);
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "opendock",
|
||||
subModule: "articleCheck",
|
||||
message: `All dock links`,
|
||||
data: data ?? [],
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,12 @@ import { adminAc, defaultStatements } from "better-auth/plugins/admin/access";
|
||||
|
||||
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);
|
||||
@@ -15,24 +16,50 @@ 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"]
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
*/
|
||||
|
||||
94
frontend/src/components/Sidebar/TransportationBar.tsx
Normal file
94
frontend/src/components/Sidebar/TransportationBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" ||
|
||||
|
||||
299
frontend/src/components/ui/combobox.tsx
Normal file
299
frontend/src/components/ui/combobox.tsx
Normal 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,
|
||||
}
|
||||
154
frontend/src/components/ui/input-group.tsx
Normal file
154
frontend/src/components/ui/input-group.tsx
Normal 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,
|
||||
};
|
||||
@@ -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 }
|
||||
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -13,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 };
|
||||
},
|
||||
|
||||
*/
|
||||
|
||||
84
frontend/src/lib/formSutff/ComboBox.Field.tsx
Normal file
84
frontend/src/lib/formSutff/ComboBox.Field.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
frontend/src/lib/queries/getActiveArticles.ts
Normal file
25
frontend/src/lib/queries/getActiveArticles.ts
Normal 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 ?? [];
|
||||
};
|
||||
25
frontend/src/lib/queries/getArticleLinks.ts
Normal file
25
frontend/src/lib/queries/getArticleLinks.ts
Normal 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 ?? [];
|
||||
};
|
||||
26
frontend/src/lib/queries/getCustomerByAv.ts
Normal file
26
frontend/src/lib/queries/getCustomerByAv.ts
Normal 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 ?? [];
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
122
frontend/src/routes/transportation/opendock/index.tsx
Normal file
122
frontend/src/routes/transportation/opendock/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -58,19 +58,25 @@ export default function TabsLayout() {
|
||||
// },
|
||||
}}
|
||||
/>
|
||||
{/* <Tabs.Screen
|
||||
<Tabs.Screen
|
||||
name="ppoo"
|
||||
options={{
|
||||
title: "PPOO",
|
||||
href: isNormalScanner ? null : "/(tabs)/ppoo",
|
||||
href:
|
||||
isNormalScanner || !hasRole(["admin", "manager"])
|
||||
? null
|
||||
: "/(tabs)/ppoo",
|
||||
tabBarIcon: ({ color, size }) => <Boxes size={size} color={color} />,
|
||||
}}
|
||||
/> */}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="laneCheck"
|
||||
options={{
|
||||
title: "Lane Check",
|
||||
href: isNormalScanner ? null : "/(tabs)/laneCheck",
|
||||
href:
|
||||
isNormalScanner || !hasRole(["admin", "manager"])
|
||||
? null
|
||||
: "/(tabs)/laneCheck",
|
||||
tabBarIcon: ({ color, size }) => <Rows4 size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
|
||||
12
migrations/0056_shallow_chimera.sql
Normal file
12
migrations/0056_shallow_chimera.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE "opendock_dock_setup" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"dock_id" text NOT NULL,
|
||||
"upd_date" timestamp DEFAULT now() NOT NULL,
|
||||
"upd_user" text DEFAULT 'lst-system' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"add_user" text DEFAULT 'lst-system' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "opendock_article_setup" DROP CONSTRAINT "opendock_article_setup_av_unique";--> statement-breakpoint
|
||||
ALTER TABLE "opendock_article_setup" ADD CONSTRAINT "uq_opendock_article_setup_av_customer" UNIQUE("av","customer");
|
||||
2515
migrations/meta/0056_snapshot.json
Normal file
2515
migrations/meta/0056_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -393,6 +393,13 @@
|
||||
"when": 1779399354404,
|
||||
"tag": "0055_nosy_amphibian",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 56,
|
||||
"version": "7",
|
||||
"when": 1779454561527,
|
||||
"tag": "0056_shallow_chimera",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user