TASKS-1823 WebManager module
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
import { Column, ColumnDef, getCoreRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, SortingState, useReactTable } from '@tanstack/react-table';
|
||||
import { LoaderCircleIcon, SearchIcon, Settings2, SquarePen } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useDebounceSearch } from '@crux-kit/hooks';
|
||||
import { mapTableStateToListQueryParams } from '#infrastructure/reactQuery';
|
||||
import { PermissionGate } from '#core/Permission';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import { Card, CardFooter, CardHeader, CardTable, CardTitle, CardToolbar } from '#shared/ui/Card';
|
||||
import { Input, InputWrapper } from '#shared/ui/Input';
|
||||
import { ScrollArea, ScrollBar } from '#shared/ui/ScrollArea';
|
||||
import { Skeleton } from '#shared/ui/Skeleton';
|
||||
import { DataGrid, DataGridColumnHeader, DataGridColumnVisibility, DataGridPagination, DataGridTable, useDataGrid } from '#shared/widgets/DataGrid';
|
||||
import { useGetList } from '../../api/list';
|
||||
import { MODULE_NAME_LABEL, MODULE_PERMISSION_CREATE, MODULE_PERMISSION_DELETE, MODULE_PERMISSION_UPDATE } from '../../config/meta';
|
||||
import { Entity } from '../../entities/Entity';
|
||||
import { DeleteTrigger } from './DeleteTrigger';
|
||||
import { ManageTrigger } from './ManageTrigger';
|
||||
|
||||
interface IColumnFilterProps<TData, TValue> {
|
||||
column: Column<TData, TValue>;
|
||||
}
|
||||
|
||||
export const DataTable = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10
|
||||
});
|
||||
|
||||
const [search, setSearch] = React.useState<string>('');
|
||||
|
||||
const { handleChange } = useDebounceSearch({
|
||||
minQueryNumber: 3,
|
||||
delay: 400,
|
||||
initialQuery: search,
|
||||
onTimeOut: setSearch
|
||||
});
|
||||
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
|
||||
const { list, total, isFetching, isLoading, pagesCount } = useGetList(
|
||||
mapTableStateToListQueryParams(pagination, sorting, search)
|
||||
);
|
||||
|
||||
const columns = React.useMemo<ColumnDef<Entity>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'id',
|
||||
accessorFn: (row) => row.id,
|
||||
header: ({ column }) => <DataGridColumnHeader title="id" column={column} />,
|
||||
enableSorting: true,
|
||||
size: 80,
|
||||
meta: {
|
||||
cellClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
accessorFn: (row) => row.name,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="name"
|
||||
filter={<ColumnInputFilter column={column} />}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: (info) => info.getValue(),
|
||||
enableSorting: true,
|
||||
meta: {
|
||||
cellClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'name2',
|
||||
accessorFn: (row) => row.name2,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader
|
||||
title="name2"
|
||||
filter={<ColumnInputFilter column={column} />}
|
||||
column={column}
|
||||
/>
|
||||
),
|
||||
cell: (info) => info.getValue(),
|
||||
enableSorting: true,
|
||||
meta: {
|
||||
cellClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'order',
|
||||
accessorFn: (row) => row.order,
|
||||
header: ({ column }) => <DataGridColumnHeader title="order" column={column} />,
|
||||
cell: (info) => info.getValue(),
|
||||
enableSorting: true,
|
||||
meta: {
|
||||
cellClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'number',
|
||||
accessorFn: (row) => row.number,
|
||||
header: ({ column }) => <DataGridColumnHeader title="number" column={column} />,
|
||||
cell: (info) => info.getValue(),
|
||||
enableSorting: true,
|
||||
meta: {
|
||||
cellClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'action',
|
||||
header: () => intl.formatMessage({ id: 'ACTIONS' }),
|
||||
cell: (info) => {
|
||||
const data = info.row.original;
|
||||
const id = data.id;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<PermissionGate requiredPermissions={MODULE_PERMISSION_UPDATE}>
|
||||
<ManageTrigger title={`Edit ${MODULE_NAME_LABEL}`} id={id}>
|
||||
<Button variant="secondary" size="sm" shape="default" mode="icon">
|
||||
<SquarePen />
|
||||
</Button>
|
||||
</ManageTrigger>
|
||||
</PermissionGate>
|
||||
<PermissionGate requiredPermissions={MODULE_PERMISSION_DELETE}>
|
||||
<DeleteTrigger id={id} deleteTarget={id} />
|
||||
</PermissionGate>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
size: 96,
|
||||
meta: {
|
||||
headerClassName: '',
|
||||
skeleton: <Skeleton className="h-4" />
|
||||
}
|
||||
}
|
||||
],
|
||||
[intl]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
columns,
|
||||
autoResetPageIndex: false,
|
||||
enableMultiSort: false,
|
||||
data: list,
|
||||
manualPagination: true,
|
||||
pageCount: pagesCount,
|
||||
getRowId: (row) => String(row.id),
|
||||
state: {
|
||||
pagination,
|
||||
sorting
|
||||
},
|
||||
onPaginationChange: setPagination,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel()
|
||||
});
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={total || 0}
|
||||
emptyMessage={intl.formatMessage({
|
||||
id: 'TABLE.COMMON.NO_DATA_AVAILABLE'
|
||||
})}
|
||||
loadingMode="skeleton"
|
||||
isLoading={isLoading}
|
||||
tableLayout={{
|
||||
columnsDraggable: false,
|
||||
columnsResizable: false,
|
||||
width: 'auto',
|
||||
columnsPinnable: true,
|
||||
columnsMovable: true,
|
||||
columnsVisibility: true,
|
||||
cellBorder: true
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<InputWrapper>
|
||||
{search.length > 0 && isFetching ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SearchIcon className="size-4" />
|
||||
)}
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={intl.formatMessage({ id: 'ACTION.SEARCH' })}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
/>
|
||||
</InputWrapper>
|
||||
</CardTitle>
|
||||
<Toolbar />
|
||||
</CardHeader>
|
||||
<CardTable>
|
||||
<ScrollArea>
|
||||
<DataGridTable />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</CardTable>
|
||||
<CardFooter>
|
||||
<DataGridPagination />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</DataGrid>
|
||||
);
|
||||
};
|
||||
|
||||
const Toolbar = () => {
|
||||
const { table } = useDataGrid();
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<CardToolbar>
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<PermissionGate requiredPermissions={MODULE_PERMISSION_CREATE}>
|
||||
<ManageTrigger
|
||||
id={null}
|
||||
title={intl.formatMessage(
|
||||
{ id: 'MODULE.COMMON.CREATE_NEW_ENTITY' },
|
||||
{ entity: MODULE_NAME_LABEL }
|
||||
)}
|
||||
>
|
||||
<Button>{intl.formatMessage({ id: 'ACTION.CREATE' })}</Button>
|
||||
</ManageTrigger>
|
||||
</PermissionGate>
|
||||
|
||||
<DataGridColumnVisibility
|
||||
table={table}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Settings2 />
|
||||
{intl.formatMessage({ id: 'TABLE.COMMON.COLUMNS' })}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardToolbar>
|
||||
);
|
||||
};
|
||||
|
||||
const ColumnInputFilter = <TData, TValue>({ column }: IColumnFilterProps<TData, TValue>) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder={intl.formatMessage({ id: 'FILTER' })}
|
||||
value={(column.getFilterValue() as string) ?? ''}
|
||||
onChange={(event) => column.setFilterValue(event.target.value)}
|
||||
className="h-9 w-full max-w-40"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { LoaderCircleIcon, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '#shared/ui/Dialog';
|
||||
import { useDelete } from '../../api/delete';
|
||||
import { invalidateList } from '../../api/list';
|
||||
|
||||
export const DeleteTrigger: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
id: number;
|
||||
deleteTarget?: React.ReactNode;
|
||||
}>
|
||||
> = ({ id, deleteTarget, children }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const mutationDelete = useDelete();
|
||||
|
||||
const handleDelete = () => {
|
||||
mutationDelete.mutate(
|
||||
{ id: id },
|
||||
{
|
||||
onSuccess() {
|
||||
invalidateList();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<Button variant="destructive" size="sm" shape="default" mode="icon">
|
||||
<X />
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{intl.formatMessage({ id: 'ACTION.CONFIRM' })}</DialogTitle>
|
||||
<DialogDescription className="sr-only"></DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody className="text-sm">
|
||||
{deleteTarget ? (
|
||||
<React.Fragment>
|
||||
{intl.formatMessage(
|
||||
{ id: 'MODULE.COMMON.REMOVE_ITEM_QUESTION' },
|
||||
{ target: deleteTarget }
|
||||
)}
|
||||
<span className="ml-1">
|
||||
{intl.formatMessage({
|
||||
id: 'MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC'
|
||||
})}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
intl.formatMessage({ id: 'MODULE.COMMON.ARE_YOU_SURE?' }) +
|
||||
' ' +
|
||||
intl.formatMessage({
|
||||
id: 'MODULE.COMMON.REMOVE_ITEM_QUESTION_DESC'
|
||||
})
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="destructive" disabled={mutationDelete.isPending} onClick={handleDelete}>
|
||||
{mutationDelete.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||
{intl.formatMessage({ id: 'STATE.REMOVING' })}...
|
||||
</span>
|
||||
) : (
|
||||
intl.formatMessage({ id: 'ACTION.REMOVE' })
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">{intl.formatMessage({ id: 'ACTION.CANCEL' })}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { LoaderCircleIcon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useDisclosure } from '@crux-kit/hooks';
|
||||
import { Button } from '#shared/ui/Button';
|
||||
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '#shared/ui/Dialog';
|
||||
import { Skeleton } from '#shared/ui/Skeleton';
|
||||
import { useGetDetailed } from '../../api/detailed';
|
||||
import { FORM_ID, initialValues, MainForm, prepareInitialValues, useSubmit } from '../forms/MainForm';
|
||||
|
||||
export const ManageTrigger: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
title: string;
|
||||
id: number | null;
|
||||
}>
|
||||
> = ({ id, children, title }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const dialog = useDisclosure(false);
|
||||
|
||||
const { data, isLoading } = useGetDetailed(
|
||||
id == null || dialog.isOpen === false ? undefined : { id: String(id) }
|
||||
);
|
||||
|
||||
const { isSubmitting, submit } = useSubmit({ onSuccess: dialog.close });
|
||||
|
||||
return (
|
||||
<Dialog open={dialog.isOpen} onOpenChange={dialog.toggle}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent variant="fullscreen">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only"></DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
<Skeleton className="h-15" />
|
||||
</div>
|
||||
) : (
|
||||
<MainForm
|
||||
initialValues={data ? prepareInitialValues(data) : initialValues}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<LoaderCircleIcon className="h-4 w-4 animate-spin" />
|
||||
{intl.formatMessage({ id: 'STATE.SAVING' })}...
|
||||
</span>
|
||||
) : (
|
||||
intl.formatMessage({ id: 'ACTION.SAVE' })
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">{intl.formatMessage({ id: 'ACTION.CANCEL' })}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user