TASKS-1823 WebManager module
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { axiosClient } from '#infrastructure/axios';
|
||||
import { ErrorResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
|
||||
import { MODULE_API_ENDPOINT } from '../config/meta';
|
||||
|
||||
export const useCreate = () => {
|
||||
return useMutation<SuccessMutationResponse, ErrorResponse, Record<string, unknown>>({
|
||||
mutationFn: async (payload) => {
|
||||
return axiosClient
|
||||
.post<SuccessMutationResponse>(MODULE_API_ENDPOINT, payload)
|
||||
.then((data) => data.data);
|
||||
},
|
||||
onSuccess(data) {
|
||||
toast.success(data.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { axiosClient } from '#infrastructure/axios';
|
||||
import { ErrorResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
|
||||
import { MODULE_API_ENDPOINT } from '../config/meta';
|
||||
|
||||
export const useDelete = () => {
|
||||
return useMutation<SuccessMutationResponse, ErrorResponse, { id: number }>({
|
||||
mutationFn: async (payload) => {
|
||||
return axiosClient
|
||||
.delete<SuccessMutationResponse>(`${MODULE_API_ENDPOINT}/${payload.id}`)
|
||||
.then((data) => data.data);
|
||||
},
|
||||
onSuccess(data) {
|
||||
toast.success(data.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { queryClient } from '#app/providers/QueryProvider';
|
||||
import { axiosClient } from '#infrastructure/axios';
|
||||
import { MODULE_API_ENDPOINT } from '../config/meta';
|
||||
import { Entity } from '../entities/Entity';
|
||||
import { detailedKeyBuilder } from './keys';
|
||||
|
||||
export const invalidateDetailed = async (id?: number) => {
|
||||
if (id === undefined) {
|
||||
await queryClient.invalidateQueries(detailedKeyBuilder.invalidateAll());
|
||||
} else {
|
||||
await queryClient.invalidateQueries(detailedKeyBuilder.invalidate({ id: String(id) }));
|
||||
}
|
||||
};
|
||||
|
||||
export interface DetailedPayload {
|
||||
id: string;
|
||||
}
|
||||
export const useGetDetailed = (payload?: DetailedPayload) => {
|
||||
return useQuery({
|
||||
queryKey: detailedKeyBuilder.store(payload as DetailedPayload),
|
||||
queryFn: async () => {
|
||||
return axiosClient
|
||||
.get<Entity>(`${MODULE_API_ENDPOINT}/${payload?.id}`)
|
||||
.then((data) => data.data);
|
||||
},
|
||||
enabled: Boolean(payload?.id),
|
||||
throwOnError(_error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createKeyBuilder, ListQueryParams } from '#infrastructure/reactQuery';
|
||||
import { MODULE_NAME } from '../config/meta';
|
||||
import { DetailedPayload } from './detailed';
|
||||
|
||||
const ROOT = MODULE_NAME;
|
||||
|
||||
export const listKeyBuilder = createKeyBuilder<ListQueryParams>(ROOT, 'LIST');
|
||||
|
||||
export const detailedKeyBuilder = createKeyBuilder<DetailedPayload>(ROOT, 'DETAILED');
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { queryClient } from '#app/providers/QueryProvider';
|
||||
import { axiosClient } from '#infrastructure/axios';
|
||||
import { getPaginationInfo, ListQueryParams, SuccessListResponse } from '#infrastructure/reactQuery';
|
||||
import { MODULE_API_ENDPOINT } from '../config/meta';
|
||||
import { Entity } from '../entities/Entity';
|
||||
import { listKeyBuilder } from './keys';
|
||||
|
||||
export const invalidateList = async () => {
|
||||
await queryClient.invalidateQueries(listKeyBuilder.invalidateAll());
|
||||
};
|
||||
|
||||
export const useGetList = (params: ListQueryParams = { page: 1, perPage: 10 }) => {
|
||||
const { data, ...query } = useQuery({
|
||||
queryKey: listKeyBuilder.store(params),
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
return axiosClient
|
||||
.get<SuccessListResponse<Entity>>(MODULE_API_ENDPOINT, { params })
|
||||
.then((response) => response.data);
|
||||
},
|
||||
throwOnError(_error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const list = React.useMemo(() => {
|
||||
return data?.data ?? [];
|
||||
}, [data?.data]);
|
||||
|
||||
const paginationInfo = getPaginationInfo(data?.meta);
|
||||
|
||||
return { ...query, ...paginationInfo, list };
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { axiosClient } from '#infrastructure/axios';
|
||||
import { ErrorResponse, SuccessMutationResponse } from '#infrastructure/reactQuery';
|
||||
import { MODULE_API_ENDPOINT } from '../config/meta';
|
||||
|
||||
export const useUpdate = () => {
|
||||
return useMutation<
|
||||
SuccessMutationResponse,
|
||||
ErrorResponse,
|
||||
{ id: number } & Record<string, unknown>
|
||||
>({
|
||||
mutationFn: async (payload) => {
|
||||
return axiosClient
|
||||
.put<SuccessMutationResponse>(`${MODULE_API_ENDPOINT}/${payload.id}`, payload)
|
||||
.then((data) => data.data);
|
||||
},
|
||||
onSuccess(data) {
|
||||
toast.success(data.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NotepadText } from 'lucide-react';
|
||||
import { IntlShape } from 'react-intl';
|
||||
import { MenuItem } from '#app/config/types';
|
||||
import { MODULE_NAME_LABEL, MODULE_PERMISSION_VIEW, MODULE_ROOT_PATH } from './meta';
|
||||
|
||||
export default (intl: IntlShape): MenuItem => ({
|
||||
title: intl.formatMessage({
|
||||
id: `MODULES.${MODULE_NAME_LABEL.toUpperCase()}.TITLE`,
|
||||
defaultMessage: MODULE_NAME_LABEL
|
||||
}),
|
||||
icon: NotepadText,
|
||||
path: MODULE_ROOT_PATH,
|
||||
permissions: [MODULE_PERMISSION_VIEW]
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import arTranslation from './translation/ar.json';
|
||||
import enTranslation from './translation/en.json';
|
||||
import ukTranslation from './translation/ua.json';
|
||||
|
||||
export default {
|
||||
en: enTranslation,
|
||||
ar: arTranslation,
|
||||
uk: ukTranslation
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export const MODULE_NAME = '{{name}}'
|
||||
export const MODULE_NAME_LABEL = MODULE_NAME.replace(/^./, (char) => char.toUpperCase()); // prettier-ignore
|
||||
export const MODULE_API_ENDPOINT = '/' + MODULE_NAME
|
||||
export const MODULE_ROOT_PATH = '/' + MODULE_NAME
|
||||
export const MODULE_PERMISSION_ROOT = MODULE_NAME + 's'
|
||||
export const MODULE_PERMISSION_CREATE = MODULE_PERMISSION_ROOT + '.create'
|
||||
export const MODULE_PERMISSION_UPDATE = MODULE_PERMISSION_ROOT + '.update'
|
||||
export const MODULE_PERMISSION_DELETE = MODULE_PERMISSION_ROOT + '.delete'
|
||||
export const MODULE_PERMISSION_VIEW = MODULE_PERMISSION_ROOT + '.view'
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AppModuleConfig } from '#app/services/modules/types';
|
||||
import menu from './menu';
|
||||
import messages from './messages';
|
||||
import routes from './routing';
|
||||
|
||||
export default {
|
||||
id: 'template',
|
||||
area: 'business',
|
||||
routeScope: 'protected',
|
||||
order: 100,
|
||||
routes,
|
||||
menu,
|
||||
messages
|
||||
} satisfies AppModuleConfig;
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Handles all module related routes.
|
||||
* This component is mounted at /[moduleName]/* in the main application router.
|
||||
*/
|
||||
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import ListPage from '../pages/ListPage';
|
||||
import Layout from '../view/layouts/Layout';
|
||||
import { MODULE_ROOT_PATH } from './meta';
|
||||
|
||||
export default {
|
||||
path: MODULE_ROOT_PATH,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="list" replace /> },
|
||||
{
|
||||
path: `${MODULE_ROOT_PATH}/*`,
|
||||
element: <Navigate to="/error/404" replace />
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
element: <ListPage />
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"MODULES.TEMPLATE.TITLE": "قالب الوحدة"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"MODULES.TEMPLATE.TITLE": "Module template"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"MODULES.TEMPLATE.TITLE": "Шаблон модуля"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { IsoDateString } from '@crux-kit/types';
|
||||
|
||||
export interface Entity {
|
||||
{{#each fields}}
|
||||
{{name}}: {{type}}
|
||||
{{/each}}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { PermissionPageGate } from '#core/Permission';
|
||||
import { Container } from '#shared/widgets/common/Container';
|
||||
import { MODULE_PERMISSION_VIEW } from '../../config/meta';
|
||||
import { DataTable } from '../../view/widgets/DataTable';
|
||||
|
||||
const ListPage: React.FC = () => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<PermissionPageGate requiredPermissions={[MODULE_PERMISSION_VIEW]}>
|
||||
<Container>
|
||||
<DataTable />
|
||||
</Container>
|
||||
</PermissionPageGate>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListPage;
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { FormElement, FormGrid, GridElement, InputText } from '#shared/forms/composed';
|
||||
import { Form as FormProvider } from '#shared/ui/form';
|
||||
import { FORM_ID } from './constants';
|
||||
import { FormValues, SubmitFunc, validationSchema } from './form';
|
||||
|
||||
export const MainForm: React.FC<{
|
||||
initialValues: FormValues;
|
||||
onSubmit: SubmitFunc;
|
||||
}> = ({ initialValues, onSubmit }) => {
|
||||
const form = useForm<FormValues>({
|
||||
values: initialValues,
|
||||
resolver: zodResolver(validationSchema)
|
||||
});
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<FormElement id={FORM_ID} onSubmit={form.handleSubmit((values) => onSubmit(values, form))}>
|
||||
<FormGrid columns={2}>
|
||||
<GridElement colSpan={1}>
|
||||
<InputText name="name" type="text" placeholder="" label="" />
|
||||
</GridElement>
|
||||
<GridElement colSpan={1}>
|
||||
<InputText name="name2" type="text" placeholder="" label="" />
|
||||
</GridElement>
|
||||
<GridElement colSpan={1}>
|
||||
<InputText name="order" type="number" placeholder="" label="" />
|
||||
</GridElement>
|
||||
<GridElement colSpan={1}>
|
||||
<InputText name="number" type="number" placeholder="" label="" />
|
||||
</GridElement>
|
||||
</FormGrid>
|
||||
</FormElement>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { uid } from '@crux-kit/utils';
|
||||
|
||||
export const FORM_ID = uid();
|
||||
@@ -0,0 +1,33 @@
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import z from 'zod';
|
||||
import { Entity } from '../../../entities/Entity';
|
||||
|
||||
export const validationSchema = z.object({
|
||||
id: z.number().nullable(),
|
||||
name: z.string(),
|
||||
name2: z.string(),
|
||||
order: z.coerce.string(),
|
||||
number: z.coerce.string()
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof validationSchema>;
|
||||
|
||||
export type SubmitFunc = (values: FormValues, handlers: UseFormReturn<FormValues>) => void;
|
||||
|
||||
export function prepareInitialValues(data: Entity): FormValues {
|
||||
return {
|
||||
id: data.id ?? null,
|
||||
name: data.name ?? '',
|
||||
name2: data.name2 ?? '',
|
||||
order: data.order.toString() ?? '',
|
||||
number: data.number.toString() ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
export const initialValues: FormValues = {
|
||||
id: null,
|
||||
name: '',
|
||||
name2: '',
|
||||
number: '',
|
||||
order: ''
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './form';
|
||||
export * from './Component';
|
||||
export * from './submit';
|
||||
export { FORM_ID } from './constants';
|
||||
@@ -0,0 +1,71 @@
|
||||
import { extractValidationErrors } from '#infrastructure/reactQuery';
|
||||
import { useCreate } from '../../../api/create';
|
||||
import { invalidateDetailed } from '../../../api/detailed';
|
||||
import { invalidateList } from '../../../api/list';
|
||||
import { useUpdate } from '../../../api/update';
|
||||
import { SubmitFunc } from './form';
|
||||
|
||||
interface Params {
|
||||
onSuccess?: (id?: number) => void;
|
||||
}
|
||||
export const useSubmit = (params?: Params) => {
|
||||
const onSuccess = params?.onSuccess;
|
||||
|
||||
const { mutate: createMutation, isPending: isCreatePending } = useCreate();
|
||||
const { mutate: updateMutation, isPending: isUpdateending } = useUpdate();
|
||||
|
||||
const handleSubmit: SubmitFunc = (values, handlers) => {
|
||||
if (values.id) {
|
||||
handleUpdate(values, handlers);
|
||||
} else {
|
||||
handleCreate(values, handlers);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate: SubmitFunc = (values, handlers) => {
|
||||
createMutation(values, {
|
||||
onSuccess() {
|
||||
invalidateList();
|
||||
if (typeof onSuccess === 'function') onSuccess();
|
||||
},
|
||||
onError(error) {
|
||||
extractValidationErrors(error).forEach((e) => {
|
||||
handlers.setError(e.field as keyof typeof values, {
|
||||
type: 'apiError',
|
||||
message: e.message
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdate: SubmitFunc = (values, handlers) => {
|
||||
if (!values.id) return;
|
||||
|
||||
updateMutation(
|
||||
{ ...values, id: values.id },
|
||||
{
|
||||
onSuccess() {
|
||||
invalidateList();
|
||||
if (values.id) invalidateDetailed(values.id);
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess(values.id ?? undefined);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
extractValidationErrors(error).forEach((e) => {
|
||||
handlers.setError(e.field as keyof typeof values, {
|
||||
type: 'apiError',
|
||||
message: e.message
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
submit: handleSubmit,
|
||||
isSubmitting: isCreatePending || isUpdateending
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
const Layout: React.FC = () => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Outlet />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -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