TASKS-1823 WebManager module

This commit is contained in:
Igor Martyniuk
2026-08-28 08:35:58 +00:00
parent fdb2cf3837
commit c93d8c025b
654 changed files with 26232 additions and 6075 deletions
+4 -1
View File
@@ -1,12 +1,15 @@
import { BaseModel, column{{#if imports}}, {{imports}}{{/if}} } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import { DateTime } from 'luxon'
import { {{modelSchemaName}} } from '#database/schema'
{{#if hasPassword}}
import hash from '@adonisjs/core/services/hash'
import { beforeSave } from '@adonisjs/lucid/orm'
{{/if}}
export default class {{pascalCaseName}} extends BaseModel {
export default class {{pascalCaseName}} extends {{modelSchemaName}} {
public static table = '{{tableName}}'
static searchable = {
string: [{{#each columns}}{{#if (and (eq validatorType "string") (not isPassword))}}'{{camelCase}}'{{#unless @last}}, {{/unless}}{{/if}}{{/each}}],
number: [{{#each columns}}{{#if (eq validatorType "number")}}'{{camelCase}}'{{#unless @last}}, {{/unless}}{{/if}}{{/each}}],
@@ -1 +0,0 @@
{{detaTable}}
@@ -1 +0,0 @@
{{form}}
+18
View File
@@ -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);
}
});
};
+18
View File
@@ -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);
}
});
};
+32
View File
@@ -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;
}
});
};
+9
View File
@@ -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
View File
@@ -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 };
};
+22
View File
@@ -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);
}
});
};
+14
View File
@@ -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,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,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;
@@ -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>
);
};