Merge pull request 'core/page-provider' (#33) from core/page-provider into main
Reviewed-on: eigen/fe-monorepo-template#33
This commit is contained in:
+105
-70
@@ -3,9 +3,13 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
|
import { Button, Paper, Title, Group, Stack, Code, Divider } from '@repo/ui/components';
|
||||||
import {
|
import {
|
||||||
FieldTextInput, FieldPasswordInput, FieldNumberInput,
|
FieldTextInput,
|
||||||
FieldLocalSelect, FieldAsyncSelect, FieldRichTextEditor
|
FieldPasswordInput,
|
||||||
|
FieldNumberInput,
|
||||||
|
FieldLocalSelect,
|
||||||
|
FieldAsyncSelect,
|
||||||
|
FieldRichTextEditor,
|
||||||
} from '@repo/ui/form';
|
} from '@repo/ui/form';
|
||||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||||
|
|
||||||
@@ -30,16 +34,16 @@ const mockFetchUsers: LoadOptionsFn<Assignee> = async (search, page) => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
const allUsers = Array.from({ length: 20 }, (_, i) => ({
|
const allUsers = Array.from({ length: 20 }, (_, i) => ({
|
||||||
id: i + 1,
|
id: i + 1,
|
||||||
email: `user${i + 1}@company.com`
|
email: `user${i + 1}@company.com`,
|
||||||
}));
|
}));
|
||||||
const filtered = allUsers.filter(u => u.email.toLowerCase().includes(search.toLowerCase()));
|
const filtered = allUsers.filter((u) => u.email.toLowerCase().includes(search.toLowerCase()));
|
||||||
const pageSize = 5;
|
const pageSize = 5;
|
||||||
const start = (page - 1) * pageSize;
|
const start = (page - 1) * pageSize;
|
||||||
const paginated = filtered.slice(start, start + pageSize);
|
const paginated = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
options: paginated,
|
options: paginated,
|
||||||
hasMore: start + pageSize < filtered.length
|
hasMore: start + pageSize < filtered.length,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,34 +54,53 @@ const MOCK_VENDORS = [
|
|||||||
|
|
||||||
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
|
const mockFetchVendors: LoadOptionsFn<any> = async (search, _page) => {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
const filtered = MOCK_VENDORS.filter(v => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()));
|
const filtered = MOCK_VENDORS.filter(
|
||||||
|
(v) => v.name.toLowerCase().includes(search.toLowerCase()) || v.code.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
return { options: filtered, hasMore: false };
|
return { options: filtered, hasMore: false };
|
||||||
};
|
};
|
||||||
import {
|
import {
|
||||||
compose, required, rangeLength,
|
compose,
|
||||||
positiveNumber, simplePassword,
|
required,
|
||||||
complexPassword, phoneValidator, rangeValue
|
rangeLength,
|
||||||
|
positiveNumber,
|
||||||
|
simplePassword,
|
||||||
|
complexPassword,
|
||||||
|
phoneValidator,
|
||||||
|
rangeValue,
|
||||||
} from '@repo/ui/validators';
|
} from '@repo/ui/validators';
|
||||||
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
import { useFormDemoTranslation } from '../i18n/useFormDemoTranslation';
|
||||||
|
|
||||||
export default function ValidationBankDemo() {
|
export default function ValidationBankDemo() {
|
||||||
const t = useFormDemoTranslation();
|
const t = useFormDemoTranslation();
|
||||||
|
|
||||||
// Compose the Zod schema using the atomic validators
|
// Compose the Zod schema using the atomic validators
|
||||||
const validationSchema = useMemo(() => z.object({
|
const validationSchema = useMemo(
|
||||||
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
|
() =>
|
||||||
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
|
z.object({
|
||||||
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
|
username: compose(z.string(), required(t.fields.customerName), rangeLength(3, 15, t.fields.customerName)),
|
||||||
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
|
simplePass: compose(z.string(), required(t.validation.simplePassword), simplePassword(6)),
|
||||||
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
|
complexPass: compose(z.string(), required(t.validation.complexPassword), complexPassword(8)),
|
||||||
phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
|
age: compose(z.number(), required(t.fields.age), rangeValue(18, 65, t.fields.age)),
|
||||||
department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }),
|
score: compose(z.number(), required(t.validation.score), positiveNumber(t.validation.score)),
|
||||||
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees),
|
phone: compose(z.string(), required(t.validation.phone), phoneValidator()),
|
||||||
prefilledVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
department: z.object({ code: z.string(), name: z.string() }, { required_error: t.errors.departmentRequired }),
|
||||||
emptyVendor: z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }, { required_error: t.errors.vendorRequired }),
|
assignees: z.array(z.object({ id: z.number(), email: z.string() })).min(2, t.errors.min2Assignees),
|
||||||
prefilledAsyncMulti: z.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() })).min(1, t.errors.min1Vendor),
|
prefilledVendor: z.object(
|
||||||
richTextNotes: z.string().min(15, t.errors.notesMin15),
|
{ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() },
|
||||||
}), [t]);
|
{ required_error: t.errors.vendorRequired },
|
||||||
|
),
|
||||||
|
emptyVendor: z.object(
|
||||||
|
{ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() },
|
||||||
|
{ required_error: t.errors.vendorRequired },
|
||||||
|
),
|
||||||
|
prefilledAsyncMulti: z
|
||||||
|
.array(z.object({ id: z.union([z.string(), z.number()]), code: z.string(), name: z.string() }))
|
||||||
|
.min(1, t.errors.min1Vendor),
|
||||||
|
richTextNotes: z.string().min(15, t.errors.notesMin15),
|
||||||
|
}),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
type ValidationFormValues = z.infer<typeof validationSchema>;
|
type ValidationFormValues = z.infer<typeof validationSchema>;
|
||||||
|
|
||||||
@@ -96,10 +119,10 @@ export default function ValidationBankDemo() {
|
|||||||
emptyVendor: null as any,
|
emptyVendor: null as any,
|
||||||
prefilledAsyncMulti: [
|
prefilledAsyncMulti: [
|
||||||
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
{ id: 'V1', code: 'VN-01', name: 'Vendor One' },
|
||||||
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' }
|
{ id: 'V2', code: 'VN-02', name: 'Vendor Two' },
|
||||||
] as any,
|
] as any,
|
||||||
richTextNotes: '',
|
richTextNotes: '',
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data);
|
const onSubmit = (data: ValidationFormValues) => console.log('Validation Passed:', data);
|
||||||
@@ -110,62 +133,66 @@ export default function ValidationBankDemo() {
|
|||||||
<Paper p="xl" withBorder radius="md">
|
<Paper p="xl" withBorder radius="md">
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Title order={5} c="brand">{t.sections.validationBankTitle}</Title>
|
<Title order={5} c="brand">
|
||||||
|
{t.sections.validationBankTitle}
|
||||||
|
</Title>
|
||||||
<Divider mb="sm" />
|
<Divider mb="sm" />
|
||||||
|
|
||||||
<FieldTextInput
|
<FieldTextInput
|
||||||
name="username"
|
name="username"
|
||||||
control={control}
|
control={control}
|
||||||
label={t.fields.customerName}
|
label={t.fields.customerName}
|
||||||
description={t.validation.usernameRange}
|
description={t.validation.usernameRange}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
<Group grow align="flex-start">
|
||||||
<FieldPasswordInput
|
<FieldPasswordInput
|
||||||
name="simplePass"
|
name="simplePass"
|
||||||
control={control}
|
control={control}
|
||||||
label={t.validation.simplePassword}
|
label={t.validation.simplePassword}
|
||||||
description={t.descriptions.min6Chars}
|
description={t.descriptions.min6Chars}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
<FieldPasswordInput
|
<FieldPasswordInput
|
||||||
name="complexPass"
|
name="complexPass"
|
||||||
control={control}
|
control={control}
|
||||||
label={t.validation.complexPassword}
|
label={t.validation.complexPassword}
|
||||||
description={t.descriptions.min8Complex}
|
description={t.descriptions.min8Complex}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
<Group grow align="flex-start">
|
||||||
<FieldNumberInput
|
<FieldNumberInput
|
||||||
name="age"
|
name="age"
|
||||||
control={control}
|
control={control}
|
||||||
label={t.fields.age}
|
label={t.fields.age}
|
||||||
description={t.validation.ageRange}
|
description={t.validation.ageRange}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
<FieldNumberInput
|
<FieldNumberInput
|
||||||
name="score"
|
name="score"
|
||||||
control={control}
|
control={control}
|
||||||
label={t.validation.score}
|
label={t.validation.score}
|
||||||
description={t.descriptions.mustBePositive}
|
description={t.descriptions.mustBePositive}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<FieldTextInput
|
<FieldTextInput
|
||||||
name="phone"
|
name="phone"
|
||||||
control={control}
|
control={control}
|
||||||
label={t.validation.phone}
|
label={t.validation.phone}
|
||||||
description={t.descriptions.formatPhone}
|
description={t.descriptions.formatPhone}
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Title order={5} c="brand" mt="md">{t.sections.objectLevelValidations}</Title>
|
<Title order={5} c="brand" mt="md">
|
||||||
|
{t.sections.objectLevelValidations}
|
||||||
|
</Title>
|
||||||
<Divider mb="sm" />
|
<Divider mb="sm" />
|
||||||
|
|
||||||
<FieldLocalSelect<Department>
|
<FieldLocalSelect<Department>
|
||||||
name="department"
|
name="department"
|
||||||
control={control as any}
|
control={control as any}
|
||||||
@@ -176,7 +203,7 @@ export default function ValidationBankDemo() {
|
|||||||
clearable
|
clearable
|
||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FieldAsyncSelect<Assignee>
|
<FieldAsyncSelect<Assignee>
|
||||||
multiple
|
multiple
|
||||||
name="assignees"
|
name="assignees"
|
||||||
@@ -190,9 +217,11 @@ export default function ValidationBankDemo() {
|
|||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Title order={5} c="brand" mt="lg">{t.sections.validatedPrefilledObjects}</Title>
|
<Title order={5} c="brand" mt="lg">
|
||||||
|
{t.sections.validatedPrefilledObjects}
|
||||||
|
</Title>
|
||||||
<Divider mb="sm" />
|
<Divider mb="sm" />
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
<Group grow align="flex-start">
|
||||||
<FieldAsyncSelect
|
<FieldAsyncSelect
|
||||||
name="emptyVendor"
|
name="emptyVendor"
|
||||||
@@ -229,7 +258,9 @@ export default function ValidationBankDemo() {
|
|||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Title order={5} c="brand" mt="lg">{t.sections.richTextValidations}</Title>
|
<Title order={5} c="brand" mt="lg">
|
||||||
|
{t.sections.richTextValidations}
|
||||||
|
</Title>
|
||||||
<Divider mb="sm" />
|
<Divider mb="sm" />
|
||||||
|
|
||||||
<FieldRichTextEditor
|
<FieldRichTextEditor
|
||||||
@@ -240,13 +271,17 @@ export default function ValidationBankDemo() {
|
|||||||
withAsterisk
|
withAsterisk
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" mt="md">{t.common.submit}</Button>
|
<Button type="submit" mt="md">
|
||||||
|
{t.common.submit}
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
<Paper p="md" withBorder radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
<Title order={6} mb="xs">{t.common.submittedData}</Title>
|
<Title order={6} mb="xs">
|
||||||
|
{t.common.submittedData}
|
||||||
|
</Title>
|
||||||
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
<Code block>{JSON.stringify(data, null, 2)}</Code>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
+2
-15
@@ -12,22 +12,9 @@ import { compose, required, rangeLength } from '@repo/ui/validators';
|
|||||||
export const createFullPageSchema = (t: any) => {
|
export const createFullPageSchema = (t: any) => {
|
||||||
return z.object({
|
return z.object({
|
||||||
// Code: Required, length between 3 and 10 characters.
|
// Code: Required, length between 3 and 10 characters.
|
||||||
code: compose(z.string(), required(t.fields.code), rangeLength(3, 10, t.fields.code)),
|
code: compose(z.string(), required(t('common:fields.code'))),
|
||||||
|
|
||||||
// Name: Required, length between 3 and 50 characters.
|
// Name: Required, length between 3 and 50 characters.
|
||||||
name: compose(z.string(), required(t.fields.name), rangeLength(3, 50, t.fields.name)),
|
name: compose(z.string(), required(t('common:fields.name')), rangeLength(3, 50, t('common:fields.name'))),
|
||||||
|
|
||||||
// Status: Required selection (typically from a dropdown/select).
|
|
||||||
status: compose(z.string(), required(t.fields.status)),
|
|
||||||
|
|
||||||
// Description: Optional text field.
|
|
||||||
description: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Data Transfer Object (DTO) for the Full Page form.
|
|
||||||
* This type is automatically inferred from the Zod schema factory.
|
|
||||||
* Use this type as a generic for form initialization, e.g., `useForm<FullPageFormDTO>()`.
|
|
||||||
*/
|
|
||||||
export type FullPageFormDTO = z.infer<ReturnType<typeof createFullPageSchema>>;
|
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
import { Box, Paper, SimpleGrid, FieldValue, RenderDate } from '@repo/ui/components';
|
||||||
|
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
|
import { FullPageEntity } from '../../../domain/entities';
|
||||||
|
|
||||||
|
export function DetailGeneral() {
|
||||||
|
const { detailData } = useDetailPageContext();
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
|
||||||
|
// Using mock data as fallback for UI demonstration
|
||||||
|
const data: FullPageEntity = detailData as any;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Box>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 8 }} spacing="md" verticalSpacing="xl">
|
||||||
|
<FieldValue label={t('common:fields.code')} value={data?.code} />
|
||||||
|
<FieldValue label={t('common:fields.name')} value={data?.name} />
|
||||||
|
<FieldValue
|
||||||
|
label={t('common:fields.createdAt')}
|
||||||
|
value={data?.created_at}
|
||||||
|
render={(val) => <RenderDate value={val as any} />}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
import { Box, FieldTextInput, Paper, SimpleGrid } from '@repo/ui/components';
|
||||||
|
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||||
|
|
||||||
|
export function FormGeneral() {
|
||||||
|
const { formControl } = useFormPageContext();
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
|
<Box>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md">
|
||||||
|
<FieldTextInput
|
||||||
|
control={formControl.control}
|
||||||
|
name="code"
|
||||||
|
label={t('common:fields.code')}
|
||||||
|
placeholder="e.g. WID-001"
|
||||||
|
required
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<FieldTextInput
|
||||||
|
name="name"
|
||||||
|
control={formControl.control}
|
||||||
|
label={t('common:fields.name')}
|
||||||
|
placeholder="e.g. Dashboard Widget"
|
||||||
|
required
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
-4
@@ -8,15 +8,15 @@ export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: an
|
|||||||
<FieldTextInput
|
<FieldTextInput
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="code"
|
name="code"
|
||||||
label={t('fields.code')}
|
label={t('common:fields.code')}
|
||||||
placeholder={`Enter ${t('fields.code')}`}
|
placeholder={`Enter ${t('common:fields.code')}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FieldTextInput
|
<FieldTextInput
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
label={t('fields.name')}
|
label={t('common:fields.name')}
|
||||||
placeholder={`Enter ${t('fields.name')}`}
|
placeholder={`Enter ${t('common:fields.name')}`}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
@@ -37,9 +37,9 @@ export default function FullPageModule() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/index" element={<IndexPage />} />
|
<Route path="/index" element={<IndexPage />} />
|
||||||
<Route path="/detail/:dataId" element={<DetailPage />} />
|
<Route path="/detail/:dataId" element={<DetailPage />} />
|
||||||
<Route path="/edit/:dataId" element={<FormPage formPageType="edit" />} />
|
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
|
||||||
<Route path="/duplicate/:dataId" element={<FormPage formPageType="duplicate" />} />
|
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
|
||||||
<Route path="/create" element={<FormPage formPageType="create" />} />
|
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
|
||||||
<Route path="/" element={<Navigate to={`${fullPageModuleConfig.webUrl}/index`} replace={true} />} />
|
<Route path="/" element={<Navigate to={`${fullPageModuleConfig.webUrl}/index`} replace={true} />} />
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
+3
-5
@@ -2,13 +2,11 @@
|
|||||||
"title": "Full Page",
|
"title": "Full Page",
|
||||||
"detail_page_title": "Detail Full Page",
|
"detail_page_title": "Detail Full Page",
|
||||||
"create_page_title": "New Full Page",
|
"create_page_title": "New Full Page",
|
||||||
|
"edit_page_title": "Edit Full Page",
|
||||||
"duplicate_page_title": "Duplicate Full Page",
|
"duplicate_page_title": "Duplicate Full Page",
|
||||||
"description": "An example module of a <1>full page layout</1> for detailed forms.",
|
"description": "An example module of a <1>full page layout</1> for detailed forms.",
|
||||||
"detail_page_description": "View and manage detailed information for this record.",
|
"detail_page_description": "View and manage detailed information for this record.",
|
||||||
"create_page_description": "Fill out the form below to add a new record to the system.",
|
"create_page_description": "Fill out the form below to add a new record to the system.",
|
||||||
"duplicate_page_description": "Copy and modify information from an existing record to quickly create a new one.",
|
"edit_page_description": "Update and modify the information of the selected record.",
|
||||||
"fields": {
|
"duplicate_page_description": "Copy and modify information from an existing record to quickly create a new one."
|
||||||
"code": "Code",
|
|
||||||
"name": "Name"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+3
-5
@@ -2,13 +2,11 @@
|
|||||||
"title": "Halaman Penuh",
|
"title": "Halaman Penuh",
|
||||||
"detail_page_title": "Detail Halaman Penuh",
|
"detail_page_title": "Detail Halaman Penuh",
|
||||||
"create_page_title": "Buat Halaman Penuh Baru",
|
"create_page_title": "Buat Halaman Penuh Baru",
|
||||||
|
"edit_page_title": "Ubah Halaman Penuh",
|
||||||
"duplicate_page_title": "Duplikat Halaman Penuh",
|
"duplicate_page_title": "Duplikat Halaman Penuh",
|
||||||
"description": "Contoh penerapan <1>tata letak halaman penuh</1> untuk formulir detail.",
|
"description": "Contoh penerapan <1>tata letak halaman penuh</1> untuk formulir detail.",
|
||||||
"detail_page_description": "Lihat dan kelola informasi terperinci terkait data ini.",
|
"detail_page_description": "Lihat dan kelola informasi terperinci terkait data ini.",
|
||||||
"create_page_description": "Lengkapi formulir di bawah ini untuk menambahkan data baru ke dalam sistem.",
|
"create_page_description": "Lengkapi formulir di bawah ini untuk menambahkan data baru ke dalam sistem.",
|
||||||
"duplicate_page_description": "Salin dan sesuaikan informasi dari data yang sudah ada untuk mempercepat pembuatan data baru.",
|
"edit_page_description": "Perbarui dan ubah informasi pada data yang dipilih.",
|
||||||
"fields": {
|
"duplicate_page_description": "Salin dan sesuaikan informasi dari data yang sudah ada untuk mempercepat pembuatan data baru."
|
||||||
"code": "Kode",
|
|
||||||
"name": "Nama"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+5
-1
@@ -1,5 +1,6 @@
|
|||||||
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import { fullPageModuleConfig } from '../../domain/constants';
|
import { fullPageModuleConfig } from '../../domain/constants';
|
||||||
|
import { DetailGeneral } from '../components/detail-component/detail-general';
|
||||||
|
|
||||||
export default function FullPagePageDetail() {
|
export default function FullPagePageDetail() {
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
@@ -15,6 +16,9 @@ export default function FullPagePageDetail() {
|
|||||||
{ label: t('nav:example-full-page'), type: 'link', href: `${fullPageModuleConfig.webUrl}/index` },
|
{ label: t('nav:example-full-page'), type: 'link', href: `${fullPageModuleConfig.webUrl}/index` },
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
></EnterpriseDetailPageProvider>
|
>
|
||||||
|
|
||||||
|
<DetailGeneral/>
|
||||||
|
</EnterpriseDetailPageProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-140
@@ -1,146 +1,49 @@
|
|||||||
import {
|
import { Paper, Box, Stack } from '@repo/ui/components';
|
||||||
Paper,
|
import { FieldTextInput } from '@repo/ui/form';
|
||||||
Box,
|
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
|
||||||
Text,
|
import { useMemo } from 'react';
|
||||||
Divider,
|
import { fullPageModuleConfig } from '../../domain/constants';
|
||||||
TextInput,
|
import { createFullPageSchema } from '../../domain/validators/full-page.validator';
|
||||||
Select,
|
import { useForm } from 'react-hook-form';
|
||||||
Textarea,
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
Stack,
|
import { FormGeneral } from '../components/form-component/form-general';
|
||||||
Breadcrumbs,
|
|
||||||
Anchor,
|
|
||||||
Flex,
|
|
||||||
Title,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
} from '@repo/ui/components';
|
|
||||||
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
|
||||||
import { ChevronRight, Database } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function FullPagePageForm({ formPageType }: { formPageType: 'edit' | 'create' | 'duplicate' }) {
|
export default function FullPagePageForm({ formPageType }: { formPageType: FormPageType }) {
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
|
||||||
|
const title = useMemo(() => {
|
||||||
|
if (formPageType === 'CREATE') {
|
||||||
|
return { title: t('create_page_title'), description: t('create_page_description') };
|
||||||
|
} else if (formPageType === 'EDIT') {
|
||||||
|
return { title: t('edit_page_title'), description: t('edit_page_description') };
|
||||||
|
} else if (formPageType === 'DUPLICATE') {
|
||||||
|
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
|
||||||
|
}
|
||||||
|
return { title: '', description: '' };
|
||||||
|
}, [formPageType, t]);
|
||||||
|
|
||||||
|
const validator = useMemo(() => {
|
||||||
|
return createFullPageSchema(t);
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const formControl = useForm({ resolver: zodResolver(validator) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box pb="xl">
|
<EnterpriseFormPageProvider
|
||||||
{/* --- INLINED PAGE HEADER --- */}
|
formControl={formControl}
|
||||||
<Box mb="xl" mt="xs">
|
formPageType={formPageType}
|
||||||
<Breadcrumbs
|
ignoreKeyDuplicate={['code']}
|
||||||
mb="md"
|
highlightDataKey="code"
|
||||||
separator={<ChevronRight size={12} strokeWidth={3} style={{ color: 'var(--mantine-color-gray-5)' }} />}
|
pageHeaderProps={{
|
||||||
>
|
title: title?.title,
|
||||||
<Anchor
|
description: title?.description,
|
||||||
href="#"
|
breadcrumbs: [
|
||||||
c="dimmed"
|
{ label: t('nav:example-module'), type: 'text' },
|
||||||
size="xs"
|
{ label: t('nav:example-full-page'), type: 'link', href: `${fullPageModuleConfig.webUrl}/index` },
|
||||||
fw={500}
|
],
|
||||||
style={{ transition: 'color 0.2s ease', letterSpacing: '0.2px' }}
|
}}
|
||||||
>
|
>
|
||||||
Database Clusters
|
<FormGeneral />
|
||||||
</Anchor>
|
</EnterpriseFormPageProvider>
|
||||||
<Text
|
|
||||||
size="xs"
|
|
||||||
fw={600}
|
|
||||||
style={{
|
|
||||||
color: 'light-dark(var(--mantine-color-gray-8), var(--mantine-color-dark-0))',
|
|
||||||
letterSpacing: '0.2px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formPageType === 'create' ? 'Create Cluster' : 'Edit Cluster'}
|
|
||||||
</Text>
|
|
||||||
</Breadcrumbs>
|
|
||||||
|
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
|
||||||
<Flex gap="md" align="center">
|
|
||||||
<Box>
|
|
||||||
<Title
|
|
||||||
order={2}
|
|
||||||
fw={600}
|
|
||||||
mb={4}
|
|
||||||
style={{
|
|
||||||
color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))',
|
|
||||||
letterSpacing: '-0.3px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formPageType === 'create' ? 'Create New Cluster' : 'Edit Cluster Configuration'}
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed" fw={400}>
|
|
||||||
Configure and provision a new highly available database cluster.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
<Group gap="sm">
|
|
||||||
<Button variant="default" radius="md">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="filled"
|
|
||||||
color="indigo"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Database size={16} strokeWidth={2.5} />}
|
|
||||||
style={{ boxShadow: '0 4px 14px 0 rgba(76, 110, 245, 0.39)' }}
|
|
||||||
>
|
|
||||||
{formPageType === 'create' ? 'Deploy Cluster' : 'Save Changes'}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Divider mt={28} mb={0} color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
|
|
||||||
</Box>
|
|
||||||
{/* --- END INLINED PAGE HEADER --- */}
|
|
||||||
|
|
||||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
|
||||||
<Text
|
|
||||||
size="lg"
|
|
||||||
fw={600}
|
|
||||||
mb="xs"
|
|
||||||
style={{
|
|
||||||
color: 'light-dark(var(--mantine-color-gray-9), var(--mantine-color-dark-0))',
|
|
||||||
letterSpacing: '-0.3px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{formPageType === 'create' ? 'Create Entity' : 'Edit Entity'}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" c="dimmed" mb="xl">
|
|
||||||
Fill in the required information to configure your entity properly. Fields marked with * are required.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Divider mb="xl" color="light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))" />
|
|
||||||
|
|
||||||
<Box maw={600}>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<TextInput
|
|
||||||
label={t('fields.code')}
|
|
||||||
placeholder="e.g. WID-001"
|
|
||||||
required
|
|
||||||
defaultValue={formPageType === 'edit' ? 'WID-001' : ''}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label={t('fields.name')}
|
|
||||||
placeholder="e.g. Dashboard Widget"
|
|
||||||
required
|
|
||||||
defaultValue={formPageType === 'edit' ? 'Dashboard Widget' : ''}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label={t('fields.status')}
|
|
||||||
placeholder="Select status"
|
|
||||||
data={['ACTIVE', 'INACTIVE']}
|
|
||||||
defaultValue="ACTIVE"
|
|
||||||
required
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
label={t('fields.description')}
|
|
||||||
placeholder="Enter a detailed description..."
|
|
||||||
minRows={4}
|
|
||||||
defaultValue={formPageType === 'edit' ? 'Main dashboard widget' : ''}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -8,15 +8,15 @@ import { Trans } from '@repo/core-i18n';
|
|||||||
import { Text } from '@repo/ui/components';
|
import { Text } from '@repo/ui/components';
|
||||||
import { LayoutDashboard } from 'lucide-react';
|
import { LayoutDashboard } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { FilterFormContent } from '../components/filter-content';
|
import { FilterFormContent } from '../components/index-component/filter-content';
|
||||||
|
|
||||||
export default function FullPagePageIndex() {
|
export default function FullPagePageIndex() {
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
|
||||||
const columnDefs: ColDef<any>[] = useMemo(() => {
|
const columnDefs: ColDef<any>[] = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{ field: 'code', headerName: t('fields.code'), minWidth: 150 },
|
{ field: 'code', headerName: t('common:fields.code'), minWidth: 150 },
|
||||||
{ field: 'name', headerName: t('fields.name'), minWidth: 150 },
|
{ field: 'name', headerName: t('common:fields.name'), minWidth: 150 },
|
||||||
];
|
];
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { lazy } from 'react';
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
const FullPageModule = lazy(() => import('./full-page/presentation/factory'));
|
const FullPageModule = lazy(() => import('./full-page/presentation/factory'));
|
||||||
const SinglePageModule = lazy(() => import('./single-page/presentation/factory'));
|
// const SinglePageModule = lazy(() => import('./single-page/presentation/factory'));
|
||||||
|
|
||||||
export default function ExampleModule() {
|
export default function ExampleModule() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/full-page/*" element={<FullPageModule />} />
|
<Route path="/full-page/*" element={<FullPageModule />} />
|
||||||
<Route path="/single-page/*" element={<SinglePageModule />} />
|
{/* <Route path="/single-page/*" element={<SinglePageModule />} /> */}
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { BaseRemoteDataServices } from '@repo/core-api/data-services';
|
|
||||||
import { SinglePageDTO, SinglePageEntity } from '../domain/entities';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full Page Remote Data Services
|
|
||||||
*
|
|
||||||
* Provides core data services for the single-page module by extending the base remote data services.
|
|
||||||
* While this class automatically handles standard CRUD operations and data transformations out-of-the-box,
|
|
||||||
* implementers can freely extend it by adding custom methods to support domain-specific API endpoints
|
|
||||||
* or complex business logic as needed.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* export class SinglePageRemoteDataServices extends BaseRemoteDataServices<SinglePageEntity, SinglePageDTO> {
|
|
||||||
* // Example of adding a custom method tailored to specific module needs
|
|
||||||
* public async getDashboardMetrics(status: string): Promise<MetricsPayload> {
|
|
||||||
* const response = await this.httpClient.get(`${this.apiUrl}/metrics`, {
|
|
||||||
* params: { status }
|
|
||||||
* });
|
|
||||||
* return response.data;
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class SinglePageRemoteDataServices extends BaseRemoteDataServices<SinglePageEntity, SinglePageDTO> {}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './single-page.constants';
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
import { ModuleConfigEntity } from '@repo/ui/foundations';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Core configuration and constants for the Full Page module.
|
|
||||||
* Used across Domain, Data, and Presentation layers.
|
|
||||||
*/
|
|
||||||
export const singlePageModuleConfig: ModuleConfigEntity = {
|
|
||||||
/** Unique identifier for permissions, caching, and i18n */
|
|
||||||
moduleKey: 'EXAMPLE_SINGLE_PAGE',
|
|
||||||
|
|
||||||
/** Translation namespace — must match the namespace used in registerModuleNamespace() */
|
|
||||||
translationNamespace: 'single-page',
|
|
||||||
|
|
||||||
/** Base API endpoint for remote data services */
|
|
||||||
apiUrl: '/single-page',
|
|
||||||
|
|
||||||
/** Base Web Router URL for UI navigation */
|
|
||||||
webUrl: '/app/example/single-page',
|
|
||||||
|
|
||||||
/** Architectural category of the module, used for rendering and routing logic */
|
|
||||||
moduleCategory: 'SINGLE_PAGE',
|
|
||||||
|
|
||||||
/** */
|
|
||||||
moduleType: 'MASTER_DATA',
|
|
||||||
} as const;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './single-page.entity';
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { BaseEntity } from '@repo/core-api/data-services';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a single-page entity in the frontend domain model.
|
|
||||||
*
|
|
||||||
* This entity is derived from the API's `SinglePageDTO` via the
|
|
||||||
* {@link SinglePageTransformer}, which handles field name mapping
|
|
||||||
* and computed field derivation.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export interface SinglePageEntity extends BaseEntity {
|
|
||||||
status?: string;
|
|
||||||
name?: string;
|
|
||||||
code?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents the raw data structure returned by the API for a single-page resource.
|
|
||||||
*
|
|
||||||
* This DTO uses snake_case field names matching the backend's JSON serialization.
|
|
||||||
* It is transformed into a {@link SinglePageEntity} by the {@link SinglePageTransformer}.
|
|
||||||
*/
|
|
||||||
export interface SinglePageDTO extends SinglePageEntity {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* Full Page Factory
|
|
||||||
*
|
|
||||||
* This module acts as the dependency injection and configuration center for the single-page feature.
|
|
||||||
* It pre-configures and exports singleton instances of the data transformer and data service.
|
|
||||||
* By centralizing the instantiation here, it ensures that all UI components and hooks
|
|
||||||
* within the module share the same API client, configuration, and transformation logic.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from '../../../../../../core/lib/api-client';
|
|
||||||
import { SinglePageRemoteDataServices } from '../../data/single-page.remote.service';
|
|
||||||
import { singlePageModuleConfig } from '../constants/single-page.constants';
|
|
||||||
import { SinglePageRemoteDataTransformer } from '../transformers/single-page.remote.transformer';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Singleton instance of the SinglePageRemoteDataTransformer.
|
|
||||||
* Exported for potential direct usage if manual data mapping is required outside the standard API flow.
|
|
||||||
*/
|
|
||||||
export const singlePageDataTransformer = new SinglePageRemoteDataTransformer();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-configured singleton instance of the SinglePageRemoteDataServices.
|
|
||||||
* Ready to be consumed by UI components, state managers, or module providers.
|
|
||||||
* It is fully wired with the HTTP client and automatically handles data mapping via the injected transformer.
|
|
||||||
*/
|
|
||||||
export const singlePageDataService = new SinglePageRemoteDataServices(apiClient, {
|
|
||||||
apiUrl: singlePageModuleConfig.apiUrl,
|
|
||||||
moduleKey: singlePageModuleConfig.moduleKey,
|
|
||||||
transformer: singlePageDataTransformer,
|
|
||||||
});
|
|
||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
|
||||||
import { SinglePageEntity, SinglePageDTO } from '../entities';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full Page Remote Data Transformer
|
|
||||||
*
|
|
||||||
* Responsible for transforming data between the raw API data transfer objects (DTOs)
|
|
||||||
* and the frontend domain entities for the single-page module. By extending the base transformer,
|
|
||||||
* it ensures strict type safety and decouples data parsing logic from the API service layer.
|
|
||||||
*
|
|
||||||
* Implementers must define the core mapping rules (`transformToEntity`, `transformToDTO`)
|
|
||||||
* and can freely add custom transformation methods for specific API responses.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```ts
|
|
||||||
* export class SinglePageRemoteDataTransformer extends BaseDataTransformer<SinglePageEntity, SinglePageDTO> {
|
|
||||||
* // Map snake_case API payload to camelCase frontend entity
|
|
||||||
* public transformToEntity(dto: SinglePageDTO): SinglePageEntity {
|
|
||||||
* return {
|
|
||||||
* id: dto.id,
|
|
||||||
* documentNumber: dto.document_number,
|
|
||||||
* createdAt: new Date(dto.created_at),
|
|
||||||
* // ... other property mappings
|
|
||||||
* };
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // Map camelCase frontend entity back to snake_case API payload
|
|
||||||
* public transformToDTO(entity: SinglePageEntity): SinglePageDTO {
|
|
||||||
* return {
|
|
||||||
* id: entity.id,
|
|
||||||
* document_number: entity.documentNumber,
|
|
||||||
* // ... other property mappings
|
|
||||||
* };
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* // Example of adding a custom transformation method for a specific feature
|
|
||||||
* public transformMetrics(rawData: any): MetricsEntity {
|
|
||||||
* return {
|
|
||||||
* totalActive: rawData.total_active_count ?? 0,
|
|
||||||
* };
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export class SinglePageRemoteDataTransformer extends BaseDataTransformer<SinglePageEntity, SinglePageDTO> {}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { lazy } from 'react';
|
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
|
||||||
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
|
||||||
import { registerModuleNamespace } from '@repo/core-i18n';
|
|
||||||
import { singlePageModuleConfig } from '../../domain/constants';
|
|
||||||
import { singlePageDataService } from '../../domain/factories';
|
|
||||||
import { SinglePageEntity } from '../../domain/entities';
|
|
||||||
import { singlePageStore } from '../store';
|
|
||||||
|
|
||||||
import singlePageId from '../languages/id/single-page.json';
|
|
||||||
import singlePageEn from '../languages/en/single-page.json';
|
|
||||||
|
|
||||||
const IndexPage = lazy(() => import('../pages/single-page.page.index'));
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Namespace Registration (Module Scope)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Called once at import time — safe, idempotent, outside React render cycle.
|
|
||||||
// The namespace 'single-page' must match config.translationNamespace.
|
|
||||||
registerModuleNamespace(singlePageModuleConfig.translationNamespace, {
|
|
||||||
id: singlePageId,
|
|
||||||
en: singlePageEn,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Module Factory
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
export default function SinglePageModule() {
|
|
||||||
return (
|
|
||||||
<EnterpriseModuleProvider<SinglePageEntity>
|
|
||||||
config={singlePageModuleConfig}
|
|
||||||
dataServices={singlePageDataService}
|
|
||||||
store={singlePageStore}
|
|
||||||
>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/index" element={<IndexPage />} />
|
|
||||||
<Route path="/" element={<Navigate to={`${singlePageModuleConfig.webUrl}/index`} replace={true} />} />
|
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
|
||||||
</Routes>
|
|
||||||
</EnterpriseModuleProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Single Page Management",
|
|
||||||
"fields": {
|
|
||||||
"status": "Status",
|
|
||||||
"name": "Name",
|
|
||||||
"code": "Code",
|
|
||||||
"description": "Description"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"title": "Manajemen Halaman Tunggal",
|
|
||||||
"fields": {
|
|
||||||
"status": "Status",
|
|
||||||
"name": "Nama",
|
|
||||||
"code": "Kode",
|
|
||||||
"description": "Deskripsi"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-99
@@ -1,99 +0,0 @@
|
|||||||
import { Table, Box, Title, Paper } from '@repo/ui/components';
|
|
||||||
import { PageActions } from '@repo/ui/components';
|
|
||||||
import { useEnterpriseModuleTranslationContext, useEnterpriseModuleNavigationContext } from '@repo/ui/foundations';
|
|
||||||
import { SinglePageEntity } from '../../domain/entities';
|
|
||||||
import { Edit2, Eye, Copy, Trash2 } from 'lucide-react';
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
|
|
||||||
// TODO: Replace this mock data with real API data loaded from DataService via useEnterpriseModuleDataServiceContext
|
|
||||||
const MOCK_DATA: SinglePageEntity[] = [
|
|
||||||
{ id: '1', name: 'Dashboard Widget', code: 'WID-001', status: 'ACTIVE', description: 'Main dashboard widget' },
|
|
||||||
{ id: '2', name: 'Report Generator', code: 'REP-002', status: 'INACTIVE', description: 'Generates monthly reports' },
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
name: 'User Management',
|
|
||||||
code: 'USR-003',
|
|
||||||
status: 'ACTIVE',
|
|
||||||
description: 'Manages user roles and permissions',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function SinglePagePageIndex() {
|
|
||||||
// Translation is scoped to ['SINGLE_PAGE', 'common'] — no prefix needed for module keys
|
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
|
||||||
|
|
||||||
const { navigateToDetail, navigateToEdit, navigateToDuplicate } = useEnterpriseModuleNavigationContext();
|
|
||||||
|
|
||||||
const getRowActions = useCallback(
|
|
||||||
(row: SinglePageEntity) => [
|
|
||||||
{
|
|
||||||
key: 'view',
|
|
||||||
label: t('common:view'),
|
|
||||||
icon: <Eye size={14} />,
|
|
||||||
onClick: () => navigateToDetail(row.id as string),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'edit',
|
|
||||||
label: t('common:edit'),
|
|
||||||
icon: <Edit2 size={14} />,
|
|
||||||
onClick: () => navigateToEdit(row.id as string),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'duplicate',
|
|
||||||
label: t('common:duplicate'),
|
|
||||||
icon: <Copy size={14} />,
|
|
||||||
onClick: () => navigateToDuplicate(row.id as string),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'divider' as const,
|
|
||||||
key: 'div-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'delete',
|
|
||||||
label: t('common:delete'),
|
|
||||||
icon: <Trash2 size={14} />,
|
|
||||||
intent: 'destructive' as const,
|
|
||||||
onClick: () => {
|
|
||||||
// Mock delete action
|
|
||||||
alert(`Delete ${row.name}`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t, navigateToDetail, navigateToEdit, navigateToDuplicate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const rows = MOCK_DATA.map((item) => (
|
|
||||||
<Table.Tr key={item.id}>
|
|
||||||
<Table.Td>{item.code}</Table.Td>
|
|
||||||
<Table.Td>{item.name}</Table.Td>
|
|
||||||
<Table.Td>{item.status}</Table.Td>
|
|
||||||
<Table.Td>{item.description}</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<PageActions actions={getRowActions(item)} />
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box p="md">
|
|
||||||
<Title order={2} mb="md">
|
|
||||||
{t('title')}
|
|
||||||
</Title>
|
|
||||||
|
|
||||||
<Paper withBorder shadow="sm" radius="md">
|
|
||||||
<Table striped highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>{t('fields.code')}</Table.Th>
|
|
||||||
<Table.Th>{t('fields.name')}</Table.Th>
|
|
||||||
<Table.Th>{t('fields.status')}</Table.Th>
|
|
||||||
<Table.Th>{t('fields.description')}</Table.Th>
|
|
||||||
<Table.Th w={200}>{t('common:edit')}</Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>{rows}</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
|
||||||
import { SinglePageEntity } from '../../domain/entities';
|
|
||||||
|
|
||||||
export interface SinglePageStoreState extends EnterpriseModuleState<SinglePageEntity> {}
|
|
||||||
|
|
||||||
export const singlePageStore = create<SinglePageStoreState>((set) => ({
|
|
||||||
metaData: null,
|
|
||||||
setMetaData: (data) => set({ metaData: data }),
|
|
||||||
|
|
||||||
filterData: { page: 1, limit: 10 },
|
|
||||||
setFilterData: (data) => set({ filterData: data }),
|
|
||||||
|
|
||||||
selectedRows: [],
|
|
||||||
setSelectedRows: (rows) => set({ selectedRows: rows }),
|
|
||||||
|
|
||||||
privileges: [],
|
|
||||||
setPrivileges: (privileges) => set({ privileges }),
|
|
||||||
}));
|
|
||||||
@@ -247,12 +247,12 @@ export const MENU_ITEMS: MenuItemType[] = [
|
|||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
path: '/app/example/full-page/index',
|
path: '/app/example/full-page/index',
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
key: 'example-single-page',
|
// key: 'example-single-page',
|
||||||
label: 'nav:example-single-page',
|
// label: 'nav:example-single-page',
|
||||||
icon: FileText,
|
// icon: FileText,
|
||||||
path: '/app/example/single-page/index',
|
// path: '/app/example/single-page/index',
|
||||||
},
|
// },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"save_changes": "Save Changes",
|
||||||
"print": "Print",
|
"print": "Print",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -97,13 +98,28 @@
|
|||||||
"hold": {
|
"hold": {
|
||||||
"title": "Hold Data",
|
"title": "Hold Data",
|
||||||
"description": "Are you sure you want to hold this data?"
|
"description": "Are you sure you want to hold this data?"
|
||||||
|
},
|
||||||
|
"save": {
|
||||||
|
"title": "Save Data",
|
||||||
|
"description": "Are you sure you want to save this data?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"draft": {
|
"draft": {
|
||||||
"recoveryTitle": "Draft Found",
|
"recoveryTitle": "Draft Found",
|
||||||
"recoveryMessage": "You have an unsaved draft from {{date}}. Would you like to continue editing?",
|
"recoveryMessage": "You have an unsaved draft from {{date}}. Would you like to continue editing?",
|
||||||
"continueEditing": "Continue Editing",
|
"continueEditing": "Continue Editing",
|
||||||
"discardDraft": "Start Fresh"
|
"discardDraft": "Start Fresh",
|
||||||
|
"autoSaved": "Draft auto-saved",
|
||||||
|
"expired": "Previous draft has expired and was discarded"
|
||||||
|
},
|
||||||
|
"formPage": {
|
||||||
|
"createTitle": "Create {{module}}",
|
||||||
|
"editTitle": "Edit {{module}}",
|
||||||
|
"duplicateTitle": "Duplicate {{module}}",
|
||||||
|
"loadingData": "Loading data...",
|
||||||
|
"loadError": "Failed to load data",
|
||||||
|
"saveError": "Failed to save data",
|
||||||
|
"validationError": "Please fix the form errors before saving"
|
||||||
},
|
},
|
||||||
"bulkAction": {
|
"bulkAction": {
|
||||||
"totalData": "Total Data",
|
"totalData": "Total Data",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"edit": "Ubah",
|
"edit": "Ubah",
|
||||||
"delete": "Hapus",
|
"delete": "Hapus",
|
||||||
"save": "Simpan",
|
"save": "Simpan",
|
||||||
|
"save_changes": "Simpan Perubahan",
|
||||||
"print": "Cetak",
|
"print": "Cetak",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -97,13 +98,28 @@
|
|||||||
"hold": {
|
"hold": {
|
||||||
"title": "Hold Data",
|
"title": "Hold Data",
|
||||||
"description": "Apakah Anda yakin ingin menahan data ini?"
|
"description": "Apakah Anda yakin ingin menahan data ini?"
|
||||||
|
},
|
||||||
|
"save": {
|
||||||
|
"title": "Simpan Data",
|
||||||
|
"description": "Apakah Anda yakin ingin menyimpan data ini?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"draft": {
|
"draft": {
|
||||||
"recoveryTitle": "Draf Ditemukan",
|
"recoveryTitle": "Draf Ditemukan",
|
||||||
"recoveryMessage": "Anda memiliki draf yang belum disimpan dari {{date}}. Apakah Anda ingin melanjutkan?",
|
"recoveryMessage": "Anda memiliki draf yang belum disimpan dari {{date}}. Apakah Anda ingin melanjutkan?",
|
||||||
"continueEditing": "Lanjutkan",
|
"continueEditing": "Lanjutkan",
|
||||||
"discardDraft": "Mulai Baru"
|
"discardDraft": "Mulai Baru",
|
||||||
|
"autoSaved": "Draf tersimpan otomatis",
|
||||||
|
"expired": "Draf sebelumnya telah kedaluwarsa dan dihapus"
|
||||||
|
},
|
||||||
|
"formPage": {
|
||||||
|
"createTitle": "Buat {{module}}",
|
||||||
|
"editTitle": "Ubah {{module}}",
|
||||||
|
"duplicateTitle": "Duplikat {{module}}",
|
||||||
|
"loadingData": "Memuat data...",
|
||||||
|
"loadError": "Gagal memuat data",
|
||||||
|
"saveError": "Gagal menyimpan data",
|
||||||
|
"validationError": "Mohon perbaiki kesalahan formulir sebelum menyimpan"
|
||||||
},
|
},
|
||||||
"bulkAction": {
|
"bulkAction": {
|
||||||
"totalData": "Total Data",
|
"totalData": "Total Data",
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
"min_length": "{{field}} minimal {{min}} karakter",
|
"min_length": "{{field}} minimal {{min}} karakter",
|
||||||
"min_len": "{{field}} minimal {{min}} karakter",
|
"min_len": "{{field}} minimal {{min}} karakter",
|
||||||
"max_len": "{{field}} maksimal {{max}} karakter",
|
"max_len": "{{field}} maksimal {{max}} karakter",
|
||||||
"range_len": "{{field}} harus antara {{min}} dan {{max}} karakter",
|
"range_len": "Panjang {{field}} harus antara {{min}} hingga {{max}} karakter",
|
||||||
"min_val": "{{field}} minimal bernilai {{min}}",
|
"min_val": "Nilai {{field}} minimal {{min}}",
|
||||||
"max_val": "{{field}} maksimal bernilai {{max}}",
|
"max_val": "Nilai {{field}} maksimal {{max}}",
|
||||||
"range_val": "{{field}} harus bernilai antara {{min}} dan {{max}}",
|
"range_val": "Nilai {{field}} harus berada di antara {{min}} hingga {{max}}",
|
||||||
"must_be_positive": "{{field}} harus bernilai positif",
|
"must_be_positive": "{{field}} harus bernilai positif",
|
||||||
"invalid_email": "Format email tidak valid",
|
"invalid_email": "Format email tidak valid",
|
||||||
"invalid_phone": "Format nomor telepon tidak valid",
|
"invalid_phone": "Format nomor telepon tidak valid",
|
||||||
"invalid_password_simple": "Kata sandi minimal {{min}} karakter",
|
"invalid_password_simple": "Kata sandi minimal {{min}} karakter",
|
||||||
"invalid_password_complex": "Kata sandi harus mengandung minimal 1 huruf besar, 1 huruf kecil, 1 angka, dan 1 karakter spesial"
|
"invalid_password_complex": "Kata sandi harus mengandung minimal 1 huruf besar, 1 huruf kecil, 1 angka, dan 1 karakter spesial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,12 +78,7 @@ export interface UseAsyncPaginateReturn<T> {
|
|||||||
export function useAsyncPaginate<T extends Record<string, any>>(
|
export function useAsyncPaginate<T extends Record<string, any>>(
|
||||||
options: UseAsyncPaginateOptions<T>,
|
options: UseAsyncPaginateOptions<T>,
|
||||||
): UseAsyncPaginateReturn<T> {
|
): UseAsyncPaginateReturn<T> {
|
||||||
const {
|
const { loadOptions, valueKey, debounceMs = 300, defaultOptions } = options;
|
||||||
loadOptions,
|
|
||||||
valueKey,
|
|
||||||
debounceMs = 300,
|
|
||||||
defaultOptions,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
@@ -191,9 +186,7 @@ export function useAsyncPaginate<T extends Record<string, any>>(
|
|||||||
|
|
||||||
// If the API returned items but ALL were duplicates, treat as exhausted.
|
// If the API returned items but ALL were duplicates, treat as exhausted.
|
||||||
// This prevents infinite scroll loops on naive APIs that ignore pagination.
|
// This prevents infinite scroll loops on naive APIs that ignore pagination.
|
||||||
const effectiveHasMore = newItems.length > 0 && newUniqueCount === 0
|
const effectiveHasMore = newItems.length > 0 && newUniqueCount === 0 ? false : hasMore;
|
||||||
? false
|
|
||||||
: hasMore;
|
|
||||||
|
|
||||||
setCache((prev) => {
|
setCache((prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -235,7 +228,7 @@ export function useAsyncPaginate<T extends Record<string, any>>(
|
|||||||
// on every cache update. Instead, we read cache inside via the state setter's prev.
|
// on every cache update. Instead, we read cache inside via the state setter's prev.
|
||||||
// The `cache.get(searchTerm)` read above is for prevOptions passed to loadOptions —
|
// The `cache.get(searchTerm)` read above is for prevOptions passed to loadOptions —
|
||||||
// this is acceptable because the callback is only called when we're NOT already fetching.
|
// this is acceptable because the callback is only called when we're NOT already fetching.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -259,7 +252,6 @@ export function useAsyncPaginate<T extends Record<string, any>>(
|
|||||||
|
|
||||||
// No cache entry — fetch page 1
|
// No cache entry — fetch page 1
|
||||||
fetchPage(debouncedSearch, 1);
|
fetchPage(debouncedSearch, 1);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [debouncedSearch]);
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Stack, Text, StackProps } from '@mantine/core';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface FieldValueProps extends StackProps {
|
||||||
|
label: React.ReactNode;
|
||||||
|
value?: React.ReactNode;
|
||||||
|
render?: (value?: React.ReactNode) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldValue({ label, value, render, ...stackProps }: FieldValueProps) {
|
||||||
|
return (
|
||||||
|
<Stack gap={4} {...stackProps}>
|
||||||
|
<Text size="xs" fw={600}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{render ? (
|
||||||
|
render(value)
|
||||||
|
) : (
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{value || '-'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './field-value';
|
||||||
|
export * from './render-date';
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DateUtils } from '@repo/utils';
|
||||||
|
|
||||||
|
export interface RenderDateProps {
|
||||||
|
value?: string | number | Date | DateUtils | null;
|
||||||
|
format?: string;
|
||||||
|
fallback?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenderDate({ value, format = 'DD-MM-YYYY, HH:mm', fallback = '-' }: RenderDateProps) {
|
||||||
|
if (!value) return <>{fallback}</>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedValue = typeof value === 'string' && !isNaN(Number(value)) ? Number(value) : value;
|
||||||
|
return <>{new DateUtils(parsedValue as any).format(format)}</>;
|
||||||
|
} catch (error) {
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,3 +13,4 @@ export * from './core-app-shell';
|
|||||||
export * from './actions-tools';
|
export * from './actions-tools';
|
||||||
export * from './status-badge';
|
export * from './status-badge';
|
||||||
export * from './ag-grid';
|
export * from './ag-grid';
|
||||||
|
export * from './field-value';
|
||||||
|
|||||||
+5
@@ -63,6 +63,11 @@ export const ACTION_TRANSLATION_MAP: Record<string, { titleKey: string; confirmK
|
|||||||
confirmKey: 'common:actions.hold',
|
confirmKey: 'common:actions.hold',
|
||||||
descriptionKey: 'common:confirmDialog.hold.description',
|
descriptionKey: 'common:confirmDialog.hold.description',
|
||||||
},
|
},
|
||||||
|
[ModuleAction.SAVE]: {
|
||||||
|
titleKey: 'common:confirmDialog.save.title',
|
||||||
|
confirmKey: 'common:actions.save',
|
||||||
|
descriptionKey: 'common:confirmDialog.save.description',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -368,9 +368,10 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`;
|
const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`;
|
||||||
|
const message = error?.response?.data?.message;
|
||||||
const defaultErrorMessage = t('common:notifications.actionFailed', {
|
const defaultErrorMessage = t('common:notifications.actionFailed', {
|
||||||
action: t(actionKey),
|
action: t(actionKey),
|
||||||
message: error?.message || 'Unknown error',
|
message: message ?? error?.message ?? 'Unknown error',
|
||||||
});
|
});
|
||||||
const customErrorMessage =
|
const customErrorMessage =
|
||||||
typeof currentConfig?.errorMessage === 'function'
|
typeof currentConfig?.errorMessage === 'function'
|
||||||
@@ -505,11 +506,12 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
|
|||||||
total_failed: 0,
|
total_failed: 0,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
const message = error?.response?.data?.message;
|
||||||
return {
|
return {
|
||||||
total_items: ids.length,
|
total_items: ids.length,
|
||||||
total_success: 0,
|
total_success: 0,
|
||||||
total_failed: ids.length,
|
total_failed: ids.length,
|
||||||
messages: [error?.message || 'Unknown error'],
|
messages: [message ?? error?.message ?? 'Unknown error'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -766,7 +768,12 @@ export function EnterpriseDataTable<E extends BaseEntity>(props: EnterpriseDataT
|
|||||||
params.success({ rowData, rowCount });
|
params.success({ rowData, rowCount });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Display an error notification if the request fails
|
// Display an error notification if the request fails
|
||||||
notifications.show({ title: t('common:notifications.errorTitle'), message: error?.message, color: 'red' });
|
const message = error?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
title: t('common:notifications.errorTitle'),
|
||||||
|
message: message ?? error?.message,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
params.fail();
|
params.fail();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -190,53 +190,6 @@ export interface TranslationSlice {
|
|||||||
t: (key: string, options?: Record<string, unknown>) => string;
|
t: (key: string, options?: Record<string, unknown>) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Page-Level Configurations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lifecycle interceptors for form processing.
|
|
||||||
* @template E The base database entity.
|
|
||||||
* @template TFormData The payload structure (defaults to Partial<E> for DTOs).
|
|
||||||
*/
|
|
||||||
export interface EnterpriseFormLifecycleHooks<E extends BaseEntity, TFormData = Partial<E>> {
|
|
||||||
onValidate?: (data: TFormData) => Promise<boolean>;
|
|
||||||
beforeSave?: (data: TFormData) => Promise<TFormData>;
|
|
||||||
/** Overrides the default repository save implementation. */
|
|
||||||
save?: (data: TFormData) => Promise<E>;
|
|
||||||
afterSave?: (result: E) => Promise<void>;
|
|
||||||
afterGetData?: (data: E) => Promise<TFormData>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BasePageConfig {
|
|
||||||
children?: ReactNode;
|
|
||||||
px?: string | number;
|
|
||||||
py?: string | number;
|
|
||||||
pageHeaderProps?: Omit<ModulePageHeaderProps, 'actions' | 'moduleKey'>;
|
|
||||||
}
|
|
||||||
export interface EnterpriseIndexPageConfig extends BasePageConfig {
|
|
||||||
customPageActions?: (actions: PageActionsProps['actions']) => PageActionsProps['actions'];
|
|
||||||
onClickCreate?: (key: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> extends EnterpriseFormLifecycleHooks<E> {
|
|
||||||
children?: ReactNode;
|
|
||||||
showPageHeader?: boolean;
|
|
||||||
useDefaultPadding?: boolean;
|
|
||||||
customHiddenActions?: (data: Partial<E>, defaultHidden: string[]) => string[];
|
|
||||||
/** Strongly typed event handler for custom form interactions. */
|
|
||||||
onCustomActionClick?: (key: ModuleActionType, data?: unknown) => void;
|
|
||||||
|
|
||||||
draftConfig?: DraftConfig;
|
|
||||||
/** Type-safe array of entity keys to exclude during an update operation. */
|
|
||||||
ignoreKeyUpdate?: (keyof E)[];
|
|
||||||
/** Type-safe array of entity keys to exclude when duplicating a record. */
|
|
||||||
ignoreKeyDuplicate?: (keyof E)[];
|
|
||||||
initialValue?: Partial<E>;
|
|
||||||
|
|
||||||
presetDuplicate?: (data: E) => Promise<Partial<E>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Action Confirmation Modal Configuration
|
// Action Confirmation Modal Configuration
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -336,10 +289,46 @@ export interface BulkActionResult {
|
|||||||
messages?: string[];
|
messages?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PrivilegeEntity {
|
||||||
|
ALLOW_VIEW: boolean;
|
||||||
|
ALLOW_CREATE: boolean;
|
||||||
|
ALLOW_EDIT: boolean;
|
||||||
|
ALLOW_DELETE: boolean;
|
||||||
|
|
||||||
|
ALLOW_PRINT: boolean;
|
||||||
|
ALLOW_PRINT_COPY: boolean;
|
||||||
|
|
||||||
|
ALLOW_APPROVAL: boolean;
|
||||||
|
ALLOW_ACTIVATE: boolean;
|
||||||
|
ALLOW_DEACTIVATE: boolean;
|
||||||
|
|
||||||
|
ALLOW_CONFIRM: boolean;
|
||||||
|
ALLOW_CANCEL: boolean;
|
||||||
|
ALLOW_ROLLBACK: boolean;
|
||||||
|
ALLOW_HOLD: boolean;
|
||||||
|
|
||||||
|
ALLOW_LOGS: boolean;
|
||||||
|
ALLOW_NOTES: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page-Level Configurations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
interface BasePageConfig {
|
||||||
|
children?: ReactNode;
|
||||||
|
px?: string | number;
|
||||||
|
py?: string | number;
|
||||||
|
pageHeaderProps?: Omit<ModulePageHeaderProps, 'actions' | 'moduleKey'>;
|
||||||
|
}
|
||||||
|
export interface EnterpriseIndexPageConfig extends BasePageConfig {
|
||||||
|
customPageActions?: (actions: PageActionsProps['actions']) => PageActionsProps['actions'];
|
||||||
|
onClickCreate?: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
|
export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
|
||||||
editMode?: 'FULL' | 'PARTIAL';
|
editMode?: 'FULL' | 'PARTIAL';
|
||||||
customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions'];
|
customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions'];
|
||||||
onDetailLoaded?: (data: E) => void;
|
onDataLoaded?: (data: E) => void;
|
||||||
|
|
||||||
showHighlightData?: boolean;
|
showHighlightData?: boolean;
|
||||||
showHighlightDataOnBreadcrumbs?: boolean;
|
showHighlightDataOnBreadcrumbs?: boolean;
|
||||||
@@ -375,24 +364,28 @@ export interface EnterpriseDetailPageConfig<E extends BaseEntity = BaseEntity> e
|
|||||||
holdModalConfig?: ActionModalConfig;
|
holdModalConfig?: ActionModalConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrivilegeEntity {
|
export interface EnterpriseFormPageConfig<E extends BaseEntity = BaseEntity> extends BasePageConfig {
|
||||||
ALLOW_VIEW: boolean;
|
formPageType: FormPageType;
|
||||||
ALLOW_CREATE: boolean;
|
formControl: UseFormReturn<any>;
|
||||||
ALLOW_EDIT: boolean;
|
|
||||||
ALLOW_DELETE: boolean;
|
|
||||||
|
|
||||||
ALLOW_PRINT: boolean;
|
customPageActions?: (data: E, actions: PageActionsProps['actions']) => PageActionsProps['actions'];
|
||||||
ALLOW_PRINT_COPY: boolean;
|
onDataLoaded?: (data: E) => void;
|
||||||
|
|
||||||
ALLOW_APPROVAL: boolean;
|
showHighlightData?: boolean;
|
||||||
ALLOW_ACTIVATE: boolean;
|
showHighlightDataOnBreadcrumbs?: boolean;
|
||||||
ALLOW_DEACTIVATE: boolean;
|
highlightDataKey?: string;
|
||||||
|
|
||||||
ALLOW_CONFIRM: boolean;
|
/** Key to reference status data in the entity, used to render the status badge automatically. @default 'status' */
|
||||||
ALLOW_CANCEL: boolean;
|
statusKey?: string;
|
||||||
ALLOW_ROLLBACK: boolean;
|
/** Custom callback to provide dynamic status badge properties */
|
||||||
ALLOW_HOLD: boolean;
|
getCustomStatusBadgeConfig?: (status: string) => Partial<import('@mantine/core').BadgeProps>;
|
||||||
|
|
||||||
ALLOW_LOGS: boolean;
|
/** Type-safe array of entity keys to exclude during an update operation. */
|
||||||
ALLOW_NOTES: boolean;
|
ignoreKeyUpdate?: (keyof E)[];
|
||||||
|
/** Type-safe array of entity keys to exclude when duplicating a record. */
|
||||||
|
ignoreKeyDuplicate?: (keyof E)[];
|
||||||
|
presetDuplicate?(payload: any): Promise<any>;
|
||||||
|
|
||||||
|
/** Configuration for the save confirmation modal. When provided, a confirmation dialog is shown before saving. */
|
||||||
|
saveModalConfig?: ActionModalConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||||
import { FormPageType } from '../entities/entity';
|
import { FormPageType } from '../entities/entity';
|
||||||
|
import { UseFormReturn } from 'react-hook-form';
|
||||||
export interface FormPageContextValue<E extends BaseEntity = BaseEntity> {
|
export interface FormPageContextValue<E extends BaseEntity = BaseEntity> {
|
||||||
|
formControl: UseFormReturn<any>;
|
||||||
formType: FormPageType;
|
formType: FormPageType;
|
||||||
isCreate: boolean;
|
isCreate: boolean;
|
||||||
isEdit: boolean;
|
isEdit: boolean;
|
||||||
@@ -11,8 +12,8 @@ export interface FormPageContextValue<E extends BaseEntity = BaseEntity> {
|
|||||||
initialData: Partial<E> | null;
|
initialData: Partial<E> | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
save: (data: any) => Promise<void>;
|
handleSave: (data: any) => Promise<void>;
|
||||||
cancel: () => void;
|
handleCancel: () => void;
|
||||||
// Draft specific
|
// Draft specific
|
||||||
hasDraft: boolean;
|
hasDraft: boolean;
|
||||||
applyDraft: () => void;
|
applyDraft: () => void;
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ export * from './constant/';
|
|||||||
|
|
||||||
export * from './entities/entity';
|
export * from './entities/entity';
|
||||||
|
|
||||||
|
export * from './hooks/use-detail-page.context';
|
||||||
|
export * from './hooks/use-form-draft.context';
|
||||||
|
export * from './hooks/use-form-page.context';
|
||||||
|
export * from './hooks/use-index-page.context';
|
||||||
export * from './hooks/use-module.context';
|
export * from './hooks/use-module.context';
|
||||||
|
|
||||||
export * from './providers/module.provider';
|
export * from './providers/module.provider';
|
||||||
export * from './providers/index-page.provider';
|
export * from './providers/index-page.provider';
|
||||||
export * from './providers/detail-page.provider';
|
export * from './providers/detail-page.provider';
|
||||||
|
export * from './providers/form-page.provider';
|
||||||
export * from './components/module-page-header';
|
export * from './components/module-page-header';
|
||||||
export * from './components/action-confirmation-modal';
|
export * from './components/action-confirmation-modal';
|
||||||
export * from './components/bulk-action-confirmation';
|
export * from './components/bulk-action-confirmation';
|
||||||
|
|||||||
+171
-115
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { BaseEntity } from '@repo/core-api/data-services';
|
import { BaseEntity } from '@repo/core-api/data-services';
|
||||||
import { Check, Unlock, Copy, SquarePen, PauseCircle, Plus, RotateCcw, Trash, X, Lock } from 'lucide-react';
|
import { Check, Unlock, Copy, SquarePen, PauseCircle, Plus, RotateCcw, Trash, X, Lock } from 'lucide-react';
|
||||||
@@ -17,20 +17,79 @@ import {
|
|||||||
} from '../hooks/use-module.context';
|
} from '../hooks/use-module.context';
|
||||||
import { shortcutsData } from '../../../constants';
|
import { shortcutsData } from '../../../constants';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helper functions (extracted outside component to avoid re-creation)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
|
||||||
|
const staticTitle = pageProvide?.title;
|
||||||
|
if (!showHighlight || !detailData) {
|
||||||
|
return { flatTitle: staticTitle, title: staticTitle };
|
||||||
|
} else {
|
||||||
|
const highlightData = detailData && detailData[key];
|
||||||
|
const flatTitle = `${staticTitle} | ${highlightData}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
flatTitle,
|
||||||
|
title: (
|
||||||
|
<span>
|
||||||
|
{staticTitle}
|
||||||
|
{highlightData && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 400,
|
||||||
|
marginLeft: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
| {highlightData}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBreadcrumbs(
|
||||||
|
showHighlight: boolean,
|
||||||
|
key: string,
|
||||||
|
pageProvide: ModulePageHeaderProps | any,
|
||||||
|
detailData: any,
|
||||||
|
) {
|
||||||
|
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
|
||||||
|
if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) {
|
||||||
|
return pageProvide.breadcrumbs;
|
||||||
|
} else {
|
||||||
|
const highlightData = detailData && detailData[key];
|
||||||
|
const breadcrumbs = [
|
||||||
|
...staticBreadcrumbs,
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
label: `${highlightData}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return breadcrumbs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EnterpriseDetailPageProvider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseDetailPageConfig<E>) {
|
export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseDetailPageConfig<E>) {
|
||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
editMode = 'FULL',
|
|
||||||
pageHeaderProps,
|
pageHeaderProps,
|
||||||
px,
|
px,
|
||||||
py,
|
py,
|
||||||
|
|
||||||
|
editMode = 'FULL',
|
||||||
showHighlightData = true,
|
showHighlightData = true,
|
||||||
showHighlightDataOnBreadcrumbs = true,
|
showHighlightDataOnBreadcrumbs = true,
|
||||||
highlightDataKey = 'code',
|
highlightDataKey = 'code',
|
||||||
statusKey = 'status',
|
statusKey = 'status',
|
||||||
getCustomStatusBadgeConfig,
|
getCustomStatusBadgeConfig,
|
||||||
onDetailLoaded,
|
onDataLoaded,
|
||||||
|
|
||||||
customPageActions,
|
customPageActions,
|
||||||
onClickCreate,
|
onClickCreate,
|
||||||
@@ -69,30 +128,77 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
const [detailData, setDetailData] = useState<E | any>();
|
const [detailData, setDetailData] = useState<E | any>();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
// ---------------------------------------------------------------------------
|
||||||
if (!dataId) return;
|
// Stable refs for consumer-provided callbacks to prevent infinite loops.
|
||||||
setIsLoading(true);
|
// These callbacks may be unstable (new reference each render) if the consumer
|
||||||
try {
|
// doesn't memoize them. Using refs lets us reference the latest version
|
||||||
const response = await dataServices.getOne(dataId);
|
// without adding them to useCallback dependency arrays.
|
||||||
if (response && response.data) {
|
// ---------------------------------------------------------------------------
|
||||||
const data = response.data?.data;
|
const onDataLoadedRef = useRef(onDataLoaded);
|
||||||
setDetailData(data as E);
|
onDataLoadedRef.current = onDataLoaded;
|
||||||
if (onDetailLoaded) onDetailLoaded(data as E);
|
|
||||||
|
const onClickCreateRef = useRef(onClickCreate);
|
||||||
|
onClickCreateRef.current = onClickCreate;
|
||||||
|
|
||||||
|
const onClickEditRef = useRef(onClickEdit);
|
||||||
|
onClickEditRef.current = onClickEdit;
|
||||||
|
|
||||||
|
const onClickDuplicateRef = useRef(onClickDuplicate);
|
||||||
|
onClickDuplicateRef.current = onClickDuplicate;
|
||||||
|
|
||||||
|
const onClickDeleteRef = useRef(onClickDelete);
|
||||||
|
onClickDeleteRef.current = onClickDelete;
|
||||||
|
|
||||||
|
const onClickActivateRef = useRef(onClickActivate);
|
||||||
|
onClickActivateRef.current = onClickActivate;
|
||||||
|
|
||||||
|
const onClickDeactivateRef = useRef(onClickDeactivate);
|
||||||
|
onClickDeactivateRef.current = onClickDeactivate;
|
||||||
|
|
||||||
|
const onClickConfirmRef = useRef(onClickConfirm);
|
||||||
|
onClickConfirmRef.current = onClickConfirm;
|
||||||
|
|
||||||
|
const onClickCancelRef = useRef(onClickCancel);
|
||||||
|
onClickCancelRef.current = onClickCancel;
|
||||||
|
|
||||||
|
const onClickRollbackRef = useRef(onClickRollback);
|
||||||
|
onClickRollbackRef.current = onClickRollback;
|
||||||
|
|
||||||
|
const onClickHoldRef = useRef(onClickHold);
|
||||||
|
onClickHoldRef.current = onClickHold;
|
||||||
|
|
||||||
|
const loadData = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await dataServices.getOne(id);
|
||||||
|
if (response && response.data) {
|
||||||
|
const data = response.data?.data;
|
||||||
|
setDetailData(data as E);
|
||||||
|
onDataLoadedRef.current?.(data as E);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
title: t('common:notifications.errorTitle'),
|
||||||
|
message: message ?? error?.message,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
},
|
||||||
notifications.show({
|
[dataServices],
|
||||||
title: t('common:notifications.errorTitle'),
|
);
|
||||||
message: error?.message,
|
|
||||||
color: 'red',
|
// Ref always holds the latest loadData to avoid stale closures in the effect
|
||||||
});
|
const loadDataRef = useRef(loadData);
|
||||||
} finally {
|
loadDataRef.current = loadData;
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [dataId, dataServices]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
if (dataId) loadDataRef.current(dataId);
|
||||||
}, [loadData]);
|
}, [dataId]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 1. Action Confirmation Modal State & Handlers
|
// 1. Action Confirmation Modal State & Handlers
|
||||||
@@ -211,13 +317,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
navigation.navigateToIndex();
|
navigation.navigateToIndex();
|
||||||
} else {
|
} else {
|
||||||
closeActionModal();
|
closeActionModal();
|
||||||
await loadData();
|
if (dataId) loadDataRef.current(dataId);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`;
|
const actionKey = ACTION_TRANSLATION_MAP[action]?.confirmKey || `common:actions.${action.toLowerCase()}`;
|
||||||
|
const message = error?.response?.data?.message;
|
||||||
const defaultErrorMessage = t('common:notifications.actionFailed', {
|
const defaultErrorMessage = t('common:notifications.actionFailed', {
|
||||||
action: t(actionKey),
|
action: t(actionKey),
|
||||||
message: error?.message || 'Unknown error',
|
message: message ?? error?.message ?? 'Unknown error',
|
||||||
});
|
});
|
||||||
|
|
||||||
const customErrorMessage =
|
const customErrorMessage =
|
||||||
@@ -236,7 +343,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dataServices, closeActionModal, navigation, loadData, modalConfigMap, t],
|
[dataServices, closeActionModal, navigation, dataId, modalConfigMap, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Handler functions that open the confirmation modal ---
|
// --- Handler functions that open the confirmation modal ---
|
||||||
@@ -270,7 +377,7 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 2. Action Dispatcher (Optimized with Switch Case & Complete Deps)
|
// 2. Action Dispatcher (Optimized with Switch Case & Stable Deps via Refs)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const handleActionClick = useCallback(
|
const handleActionClick = useCallback(
|
||||||
async (key: string) => {
|
async (key: string) => {
|
||||||
@@ -282,14 +389,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
// --- CREATE ---
|
// --- CREATE ---
|
||||||
case ModuleAction.CREATE:
|
case ModuleAction.CREATE:
|
||||||
if (!privileges.ALLOW_CREATE) return;
|
if (!privileges.ALLOW_CREATE) return;
|
||||||
if (onClickCreate) onClickCreate();
|
if (onClickCreateRef.current) onClickCreateRef.current();
|
||||||
else navigation.navigateToCreate();
|
else navigation.navigateToCreate();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- EDIT ---
|
// --- EDIT ---
|
||||||
case ModuleAction.EDIT:
|
case ModuleAction.EDIT:
|
||||||
if (!privileges.ALLOW_EDIT || !hasValidData) return;
|
if (!privileges.ALLOW_EDIT || !hasValidData) return;
|
||||||
if (onClickEdit) onClickEdit(currentData);
|
if (onClickEditRef.current) onClickEditRef.current(currentData);
|
||||||
else if (editMode === 'FULL') navigation.navigateToEdit(dataId!);
|
else if (editMode === 'FULL') navigation.navigateToEdit(dataId!);
|
||||||
else setIsActiveEditMode(true);
|
else setIsActiveEditMode(true);
|
||||||
break;
|
break;
|
||||||
@@ -297,56 +404,56 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
// --- DUPLICATE ---
|
// --- DUPLICATE ---
|
||||||
case ModuleAction.DUPLICATE:
|
case ModuleAction.DUPLICATE:
|
||||||
if (!privileges.ALLOW_CREATE || !hasValidData) return;
|
if (!privileges.ALLOW_CREATE || !hasValidData) return;
|
||||||
if (onClickDuplicate) onClickDuplicate(currentData);
|
if (onClickDuplicateRef.current) onClickDuplicateRef.current(currentData);
|
||||||
else navigation.navigateToDuplicate(dataId!);
|
else navigation.navigateToDuplicate(dataId!);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- DELETE ---
|
// --- DELETE ---
|
||||||
case ModuleAction.DELETE:
|
case ModuleAction.DELETE:
|
||||||
if (!privileges.ALLOW_DELETE || !hasValidData) return;
|
if (!privileges.ALLOW_DELETE || !hasValidData) return;
|
||||||
if (onClickDelete) onClickDelete(currentData);
|
if (onClickDeleteRef.current) onClickDeleteRef.current(currentData);
|
||||||
else await handleDelete(currentData);
|
else await handleDelete(currentData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- ACTIVATE ---
|
// --- ACTIVATE ---
|
||||||
case ModuleAction.ACTIVATE:
|
case ModuleAction.ACTIVATE:
|
||||||
if (!privileges.ALLOW_ACTIVATE || !hasValidData) return;
|
if (!privileges.ALLOW_ACTIVATE || !hasValidData) return;
|
||||||
if (onClickActivate) onClickActivate(currentData);
|
if (onClickActivateRef.current) onClickActivateRef.current(currentData);
|
||||||
else await handleActivate(currentData);
|
else await handleActivate(currentData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- DEACTIVATE ---
|
// --- DEACTIVATE ---
|
||||||
case ModuleAction.DEACTIVATE:
|
case ModuleAction.DEACTIVATE:
|
||||||
if (!privileges.ALLOW_DEACTIVATE || !hasValidData) return;
|
if (!privileges.ALLOW_DEACTIVATE || !hasValidData) return;
|
||||||
if (onClickDeactivate) onClickDeactivate(currentData);
|
if (onClickDeactivateRef.current) onClickDeactivateRef.current(currentData);
|
||||||
else await handleDeactivate(currentData);
|
else await handleDeactivate(currentData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- CONFIRM ---
|
// --- CONFIRM ---
|
||||||
case ModuleAction.CONFIRM:
|
case ModuleAction.CONFIRM:
|
||||||
if (!privileges.ALLOW_CONFIRM || !hasValidData) return;
|
if (!privileges.ALLOW_CONFIRM || !hasValidData) return;
|
||||||
if (onClickConfirm) onClickConfirm(currentData);
|
if (onClickConfirmRef.current) onClickConfirmRef.current(currentData);
|
||||||
else await handleConfirm(currentData);
|
else await handleConfirm(currentData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- CANCEL ---
|
// --- CANCEL ---
|
||||||
case ModuleAction.CANCEL:
|
case ModuleAction.CANCEL:
|
||||||
if (!privileges.ALLOW_CANCEL || !hasValidData) return;
|
if (!privileges.ALLOW_CANCEL || !hasValidData) return;
|
||||||
if (onClickCancel) onClickCancel(currentData);
|
if (onClickCancelRef.current) onClickCancelRef.current(currentData);
|
||||||
else await handleCancel(currentData);
|
else await handleCancel(currentData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- ROLLBACK ---
|
// --- ROLLBACK ---
|
||||||
case ModuleAction.ROLLBACK:
|
case ModuleAction.ROLLBACK:
|
||||||
if (!privileges.ALLOW_ROLLBACK || !hasValidData) return;
|
if (!privileges.ALLOW_ROLLBACK || !hasValidData) return;
|
||||||
if (onClickRollback) onClickRollback(currentData);
|
if (onClickRollbackRef.current) onClickRollbackRef.current(currentData);
|
||||||
else await handleRollback(currentData);
|
else await handleRollback(currentData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- HOLD ---
|
// --- HOLD ---
|
||||||
case ModuleAction.HOLD:
|
case ModuleAction.HOLD:
|
||||||
if (!privileges.ALLOW_HOLD || !hasValidData) return;
|
if (!privileges.ALLOW_HOLD || !hasValidData) return;
|
||||||
if (onClickHold) onClickHold(currentData);
|
if (onClickHoldRef.current) onClickHoldRef.current(currentData);
|
||||||
else await handleHold(currentData);
|
else await handleHold(currentData);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -355,29 +462,12 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[navigation, privileges, dataId, detailData, editMode],
|
||||||
navigation,
|
|
||||||
privileges,
|
|
||||||
dataId,
|
|
||||||
detailData,
|
|
||||||
editMode,
|
|
||||||
setIsActiveEditMode,
|
|
||||||
onClickCreate,
|
|
||||||
onClickEdit,
|
|
||||||
onClickDuplicate,
|
|
||||||
onClickDelete,
|
|
||||||
onClickActivate,
|
|
||||||
onClickDeactivate,
|
|
||||||
onClickConfirm,
|
|
||||||
onClickCancel,
|
|
||||||
onClickRollback,
|
|
||||||
onClickHold,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Platform-aware shortcut label for the Create action. */
|
/** Platform-aware shortcut label for the Create action. */
|
||||||
const CREATE_SHORTCUT_LABEL = useMemo(() => {
|
const CREATE_SHORTCUT_LABEL = useMemo(() => {
|
||||||
const shortcutData = shortcutsData.find((s) => s.key === 'collapse_sidebar');
|
const shortcutData = shortcutsData.find((s) => s.key === 'create_data');
|
||||||
return IS_MACOS ? shortcutData?.macKeyIcons.join(' ') : shortcutData?.winKeyIcons.join(' ');
|
return IS_MACOS ? shortcutData?.macKeyIcons.join(' ') : shortcutData?.winKeyIcons.join(' ');
|
||||||
}, [IS_MACOS]);
|
}, [IS_MACOS]);
|
||||||
|
|
||||||
@@ -549,73 +639,32 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
() => ({
|
() => ({
|
||||||
detailData,
|
detailData,
|
||||||
isLoading,
|
isLoading,
|
||||||
reload: loadData,
|
reload: async () => {
|
||||||
|
if (dataId) await loadDataRef.current(dataId);
|
||||||
|
},
|
||||||
isPartialEdit: editMode === 'PARTIAL',
|
isPartialEdit: editMode === 'PARTIAL',
|
||||||
isActiveEditMode,
|
isActiveEditMode,
|
||||||
setIsActiveEditMode,
|
setIsActiveEditMode,
|
||||||
}),
|
}),
|
||||||
[detailData, isLoading, loadData, editMode, isActiveEditMode, setIsActiveEditMode],
|
[detailData, isLoading, dataId, editMode, isActiveEditMode, setIsActiveEditMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
|
// ---------------------------------------------------------------------------
|
||||||
const staticTitle = pageProvide?.title;
|
// Page header (title, breadcrumbs)
|
||||||
if (!showHighlight || !detailData) {
|
// ---------------------------------------------------------------------------
|
||||||
return { flatTitle: staticTitle, title: staticTitle };
|
|
||||||
} else {
|
|
||||||
const highlightData = detailData && detailData[key];
|
|
||||||
const flatTitle = `${staticTitle} | ${highlightData}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
flatTitle,
|
|
||||||
title: (
|
|
||||||
<span>
|
|
||||||
{staticTitle}
|
|
||||||
{highlightData && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontWeight: 400,
|
|
||||||
marginLeft: '8px',
|
|
||||||
// color: 'var(--mantine-color-dimmed)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
| {highlightData}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeBreadcrumbs(
|
|
||||||
showHighlight: boolean,
|
|
||||||
key: string,
|
|
||||||
pageProvide: ModulePageHeaderProps | any,
|
|
||||||
detailData: any,
|
|
||||||
) {
|
|
||||||
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
|
|
||||||
if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) {
|
|
||||||
return pageProvide.breadcrumbs;
|
|
||||||
} else {
|
|
||||||
const highlightData = detailData && detailData[key];
|
|
||||||
const breadcrumbs = [
|
|
||||||
...staticBreadcrumbs,
|
|
||||||
{
|
|
||||||
type: 'link',
|
|
||||||
label: `${highlightData}`,
|
|
||||||
href: `${config.webUrl}/detail/${dataId}`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return breadcrumbs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageHeaderPropsValue = useMemo(() => {
|
const pageHeaderPropsValue = useMemo(() => {
|
||||||
const title = makeTitle(showHighlightData, highlightDataKey, pageHeaderProps, detailData);
|
const title = makeTitle(showHighlightData, highlightDataKey, pageHeaderProps, detailData);
|
||||||
document.title = title.flatTitle;
|
|
||||||
const breadcrumbs = makeBreadcrumbs(showHighlightDataOnBreadcrumbs, highlightDataKey, pageHeaderProps, detailData);
|
const breadcrumbs = makeBreadcrumbs(showHighlightDataOnBreadcrumbs, highlightDataKey, pageHeaderProps, detailData);
|
||||||
return { ...pageHeaderProps, title: title.title, breadcrumbs: breadcrumbs };
|
return { ...pageHeaderProps, title: title.title, breadcrumbs, _flatTitle: title.flatTitle };
|
||||||
}, [pageHeaderProps, dataId, detailData, showHighlightData, showHighlightDataOnBreadcrumbs, highlightDataKey]);
|
}, [pageHeaderProps, detailData, showHighlightData, showHighlightDataOnBreadcrumbs, highlightDataKey]);
|
||||||
|
|
||||||
|
// Side effect: update document.title (must NOT be inside useMemo)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pageHeaderPropsValue._flatTitle) {
|
||||||
|
document.title = pageHeaderPropsValue._flatTitle;
|
||||||
|
}
|
||||||
|
}, [pageHeaderPropsValue._flatTitle]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailPageContext.Provider value={contextValue}>
|
<DetailPageContext.Provider value={contextValue}>
|
||||||
@@ -628,7 +677,14 @@ export function EnterpriseDetailPageProvider<E extends BaseEntity = BaseEntity>(
|
|||||||
return {
|
return {
|
||||||
size: 'xs',
|
size: 'xs',
|
||||||
p: action.key === ModuleAction.CREATE ? undefined : 5,
|
p: action.key === ModuleAction.CREATE ? undefined : 5,
|
||||||
style: { fontSize: 12 },
|
style:
|
||||||
|
action.key === ModuleAction.CREATE
|
||||||
|
? {
|
||||||
|
fontSize: 12,
|
||||||
|
boxShadow:
|
||||||
|
'0 4px 14px 0 color-mix(in srgb, var(--mantine-primary-color-filled) 40%, transparent)',
|
||||||
|
}
|
||||||
|
: { fontSize: 12 },
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
actions={detailData ? pageActions : []}
|
actions={detailData ? pageActions : []}
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import { BaseEntity } from '@repo/core-api/data-services';
|
||||||
|
import {
|
||||||
|
ActionModalState,
|
||||||
|
EnterpriseFormPageConfig,
|
||||||
|
FormPageType,
|
||||||
|
ModuleAction,
|
||||||
|
ModuleActionType,
|
||||||
|
} from '../entities/entity';
|
||||||
|
import { SaveCheck } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useEnterpriseModuleConfigContext,
|
||||||
|
useEnterpriseModuleDataServiceContext,
|
||||||
|
useEnterpriseModuleNavigationContext,
|
||||||
|
useEnterpriseModuleTranslationContext,
|
||||||
|
} from '../hooks/use-module.context';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { lodash } from '@repo/utils';
|
||||||
|
import { FormPageContext } from '../hooks/use-form-page.context';
|
||||||
|
import { ModulePageHeader, ModulePageHeaderProps } from '../components/module-page-header';
|
||||||
|
import { FormProvider } from 'react-hook-form';
|
||||||
|
import { CorePageContainer, PageActionProps, StatusBadge } from '../../../components';
|
||||||
|
import { ActionConfirmationModal } from '../components/action-confirmation-modal';
|
||||||
|
|
||||||
|
const EMPTY_ARRAY: any[] = [];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helper functions (extracted outside component to avoid re-creation)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeTitle(showHighlight: boolean, key: string, pageProvide: ModulePageHeaderProps | any, detailData: any) {
|
||||||
|
const staticTitle = pageProvide?.title;
|
||||||
|
if (!showHighlight || !detailData) {
|
||||||
|
return { flatTitle: staticTitle, title: staticTitle };
|
||||||
|
} else {
|
||||||
|
const highlightData = detailData && detailData[key];
|
||||||
|
const flatTitle = `${staticTitle} | ${highlightData}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
flatTitle,
|
||||||
|
title: (
|
||||||
|
<span>
|
||||||
|
{staticTitle}
|
||||||
|
{highlightData && <span style={{ fontWeight: 400, marginLeft: '8px' }}>| {highlightData}</span>}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBreadcrumbs(
|
||||||
|
showHighlight: boolean,
|
||||||
|
key: string,
|
||||||
|
pageProvide: ModulePageHeaderProps | any,
|
||||||
|
detailData: any,
|
||||||
|
formPageType: FormPageType,
|
||||||
|
labelBreadcrumbsCreate: string,
|
||||||
|
labelBreadcrumbsEdit: string,
|
||||||
|
) {
|
||||||
|
const staticBreadcrumbs = pageProvide.breadcrumbs ?? [];
|
||||||
|
if (!showHighlight || staticBreadcrumbs.length === 0 || !detailData) {
|
||||||
|
const breadcrumbs = pageProvide?.breadcrumbs ?? [];
|
||||||
|
if (formPageType === 'CREATE' && breadcrumbs.length > 0) {
|
||||||
|
const isCreate = formPageType === 'CREATE' || formPageType === 'DUPLICATE';
|
||||||
|
|
||||||
|
return [
|
||||||
|
...breadcrumbs,
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
label: isCreate ? labelBreadcrumbsCreate : labelBreadcrumbsEdit,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return breadcrumbs;
|
||||||
|
} else {
|
||||||
|
const highlightData = detailData && detailData[key];
|
||||||
|
|
||||||
|
const breadcrumbs = [
|
||||||
|
...staticBreadcrumbs,
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
label: `${highlightData}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return breadcrumbs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EnterpriseFormPageProvider
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function EnterpriseFormPageProvider<E extends BaseEntity = BaseEntity>(props: EnterpriseFormPageConfig<E>) {
|
||||||
|
const {
|
||||||
|
children,
|
||||||
|
pageHeaderProps,
|
||||||
|
px,
|
||||||
|
py,
|
||||||
|
|
||||||
|
formPageType,
|
||||||
|
onDataLoaded,
|
||||||
|
customPageActions,
|
||||||
|
showHighlightData = true,
|
||||||
|
showHighlightDataOnBreadcrumbs = true,
|
||||||
|
highlightDataKey = 'code',
|
||||||
|
statusKey = 'status',
|
||||||
|
getCustomStatusBadgeConfig,
|
||||||
|
|
||||||
|
ignoreKeyUpdate = EMPTY_ARRAY,
|
||||||
|
ignoreKeyDuplicate = EMPTY_ARRAY,
|
||||||
|
presetDuplicate,
|
||||||
|
formControl,
|
||||||
|
|
||||||
|
saveModalConfig,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
|
const navigation = useEnterpriseModuleNavigationContext();
|
||||||
|
const { config, privileges } = useEnterpriseModuleConfigContext();
|
||||||
|
const { dataServices } = useEnterpriseModuleDataServiceContext<E>();
|
||||||
|
|
||||||
|
const params = useParams();
|
||||||
|
const dataId = params.dataId;
|
||||||
|
|
||||||
|
const { moduleKey } = config;
|
||||||
|
|
||||||
|
const [detailData, setDetailData] = useState<E | any>();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const { reset, handleSubmit } = formControl;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stable refs for consumer-provided callbacks to prevent infinite loops.
|
||||||
|
// These callbacks may be unstable (new reference each render) if the consumer
|
||||||
|
// doesn't memoize them. Using refs lets us reference the latest version
|
||||||
|
// without adding them to useCallback dependency arrays.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const onDataLoadedRef = useRef(onDataLoaded);
|
||||||
|
onDataLoadedRef.current = onDataLoaded;
|
||||||
|
|
||||||
|
const presetDuplicateRef = useRef(presetDuplicate);
|
||||||
|
presetDuplicateRef.current = presetDuplicate;
|
||||||
|
|
||||||
|
const loadData = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await dataServices.getOne(id);
|
||||||
|
if (response && response.data) {
|
||||||
|
const data = response.data?.data;
|
||||||
|
setDetailData(data as E);
|
||||||
|
onDataLoadedRef.current?.(data as E);
|
||||||
|
|
||||||
|
let formPayload = { ...data };
|
||||||
|
if (formPageType === 'EDIT') formPayload = lodash.omit(formPayload, ignoreKeyUpdate) as E;
|
||||||
|
if (formPageType === 'DUPLICATE') {
|
||||||
|
formPayload = lodash.omit(formPayload, ignoreKeyDuplicate) as E;
|
||||||
|
if (presetDuplicateRef.current) formPayload = await presetDuplicateRef.current(formPayload);
|
||||||
|
}
|
||||||
|
reset(formPayload);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
title: t('common:notifications.errorTitle'),
|
||||||
|
message: message ?? error?.message,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dataServices],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ref always holds the latest loadData to avoid stale closures in the effect
|
||||||
|
const loadDataRef = useRef(loadData);
|
||||||
|
loadDataRef.current = loadData;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dataId) loadDataRef.current(dataId);
|
||||||
|
}, [dataId]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Save Confirmation Modal State & Handlers
|
||||||
|
// (Pattern follows detail-page.provider.tsx)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Default (closed) modal state — stable reference to avoid re-creating on every render. */
|
||||||
|
const CLOSED_MODAL: ActionModalState<E> = useMemo(() => ({ opened: false, action: null, data: null }), []);
|
||||||
|
|
||||||
|
const [actionModalState, setActionModalState] = useState<ActionModalState<E>>(CLOSED_MODAL);
|
||||||
|
|
||||||
|
/** Pending form data to be saved after modal confirmation */
|
||||||
|
const pendingFormDataRef = useRef<any>(null);
|
||||||
|
|
||||||
|
/** Close the confirmation modal and reset state. */
|
||||||
|
const closeActionModal = useCallback(() => {
|
||||||
|
pendingFormDataRef.current = null;
|
||||||
|
setActionModalState(CLOSED_MODAL);
|
||||||
|
}, [CLOSED_MODAL]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (data: any) => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
if (formPageType === 'CREATE' || formPageType === 'DUPLICATE') {
|
||||||
|
await dataServices.create(data);
|
||||||
|
notifications.show({
|
||||||
|
title: t('common:notifications.successTitle'),
|
||||||
|
message: t('common:notifications.createSuccess'),
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
} else if (formPageType === 'EDIT') {
|
||||||
|
if (!dataId) throw new Error('Data ID is required for editing');
|
||||||
|
await dataServices.edit(dataId, data);
|
||||||
|
notifications.show({
|
||||||
|
title: t('common:notifications.successTitle'),
|
||||||
|
message: t('common:notifications.updateSuccess'),
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
navigation.navigateToIndex();
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
title: t('common:notifications.errorTitle'),
|
||||||
|
message: message ?? error?.message,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dataId, dataServices, formPageType, navigation, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate save flow: validate form first, then always open confirmation modal.
|
||||||
|
* Uses saveModalConfig for custom overrides, or default SAVE translations from ACTION_TRANSLATION_MAP.
|
||||||
|
*/
|
||||||
|
const initiateSave = useCallback(() => {
|
||||||
|
handleSubmit((validData: any) => {
|
||||||
|
// Store validated data and show confirmation modal
|
||||||
|
pendingFormDataRef.current = validData;
|
||||||
|
setActionModalState({
|
||||||
|
opened: true,
|
||||||
|
action: ModuleAction.SAVE,
|
||||||
|
data: validData,
|
||||||
|
config: saveModalConfig,
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
}, [handleSubmit, saveModalConfig]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute save after modal confirmation.
|
||||||
|
* Called by ActionConfirmationModal after the user confirms.
|
||||||
|
*/
|
||||||
|
const executeSave = useCallback(
|
||||||
|
async (_action: ModuleActionType, _data: E, meta?: Record<string, unknown>) => {
|
||||||
|
const formData = pendingFormDataRef.current;
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
// Merge optional meta from modal form body into the save payload
|
||||||
|
const payload = meta ? { ...formData, ...meta } : formData;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-useless-catch
|
||||||
|
try {
|
||||||
|
await handleSave(payload);
|
||||||
|
closeActionModal();
|
||||||
|
} catch (error) {
|
||||||
|
// Rethrow so the modal knows submission failed
|
||||||
|
// and re-enables the buttons for the user to try again or cancel
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSave, closeActionModal],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page header (title, breadcrumbs)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const pageHeaderPropsValue = useMemo(() => {
|
||||||
|
const title = makeTitle(showHighlightData, highlightDataKey, pageHeaderProps, detailData);
|
||||||
|
const breadcrumbs = makeBreadcrumbs(
|
||||||
|
showHighlightDataOnBreadcrumbs,
|
||||||
|
highlightDataKey,
|
||||||
|
pageHeaderProps,
|
||||||
|
detailData,
|
||||||
|
formPageType,
|
||||||
|
t('common:actions.create'),
|
||||||
|
t('common:actions.edit'),
|
||||||
|
);
|
||||||
|
return { ...pageHeaderProps, title: title.title, breadcrumbs, _flatTitle: title.flatTitle };
|
||||||
|
}, [
|
||||||
|
pageHeaderProps,
|
||||||
|
detailData,
|
||||||
|
showHighlightData,
|
||||||
|
showHighlightDataOnBreadcrumbs,
|
||||||
|
highlightDataKey,
|
||||||
|
formPageType,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Side effect: update document.title (must NOT be inside useMemo)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pageHeaderPropsValue._flatTitle) {
|
||||||
|
document.title = pageHeaderPropsValue._flatTitle;
|
||||||
|
}
|
||||||
|
}, [pageHeaderPropsValue._flatTitle]);
|
||||||
|
|
||||||
|
const pageActions = useMemo(() => {
|
||||||
|
const { ALLOW_CREATE, ALLOW_EDIT } = privileges;
|
||||||
|
const isCreate = formPageType === 'CREATE' || formPageType === 'DUPLICATE';
|
||||||
|
const canSave = isCreate ? ALLOW_CREATE : ALLOW_EDIT;
|
||||||
|
|
||||||
|
// 1. Declare action with Privilege & Module Type conditions directly
|
||||||
|
const rawActions = [
|
||||||
|
canSave && {
|
||||||
|
key: ModuleAction.SAVE,
|
||||||
|
label: isCreate ? t('common:actions.save') : t('common:actions.save_changes'),
|
||||||
|
icon: <SaveCheck size={16} />,
|
||||||
|
intent: 'primary',
|
||||||
|
variant: 'filled',
|
||||||
|
onClick: () => initiateSave(),
|
||||||
|
},
|
||||||
|
].filter(Boolean) as PageActionProps[]; // Remove all false/null/undefined
|
||||||
|
|
||||||
|
// 3. Inject custom actions
|
||||||
|
return customPageActions && detailData ? customPageActions(detailData, rawActions) : rawActions;
|
||||||
|
}, [t, customPageActions, privileges, formPageType, initiateSave, detailData]);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
formType: formPageType,
|
||||||
|
formControl: formControl,
|
||||||
|
isCreate: formPageType === 'CREATE',
|
||||||
|
isEdit: formPageType === 'EDIT',
|
||||||
|
isDuplicate: formPageType === 'DUPLICATE',
|
||||||
|
dataId,
|
||||||
|
initialData: detailData,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
handleSave,
|
||||||
|
handleCancel: () => {},
|
||||||
|
hasDraft: false,
|
||||||
|
applyDraft: () => {},
|
||||||
|
discardDraft: () => {},
|
||||||
|
}),
|
||||||
|
[formPageType, dataId, detailData, isLoading, isSaving, handleSave],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormPageContext.Provider value={contextValue}>
|
||||||
|
<CorePageContainer
|
||||||
|
px={px}
|
||||||
|
py={py}
|
||||||
|
headerSlot={
|
||||||
|
<ModulePageHeader
|
||||||
|
actions={formPageType === 'CREATE' || detailData ? pageActions : []}
|
||||||
|
{...pageHeaderPropsValue}
|
||||||
|
moduleKey={moduleKey}
|
||||||
|
titleProps={{
|
||||||
|
fz: { base: 16, sm: 18 },
|
||||||
|
}}
|
||||||
|
miniTitleProps={{
|
||||||
|
fz: { base: 'md', sm: 'lg' },
|
||||||
|
}}
|
||||||
|
badges={
|
||||||
|
detailData ? (
|
||||||
|
<StatusBadge
|
||||||
|
status={detailData[statusKey as keyof typeof detailData] as string}
|
||||||
|
getCustomConfig={getCustomStatusBadgeConfig}
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormProvider {...formControl}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
initiateSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
</CorePageContainer>
|
||||||
|
|
||||||
|
{/* Save Confirmation Modal */}
|
||||||
|
<ActionConfirmationModal<E>
|
||||||
|
modalState={actionModalState}
|
||||||
|
onClose={closeActionModal}
|
||||||
|
onExecute={executeSave}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
</FormPageContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { EnterpriseIndexPageConfig, ModuleAction } from '../entities/entity';
|
|||||||
import { IndexPageContext } from '../hooks/use-index-page.context';
|
import { IndexPageContext } from '../hooks/use-index-page.context';
|
||||||
import { CorePageContainer, PageActionProps } from '../../../components';
|
import { CorePageContainer, PageActionProps } from '../../../components';
|
||||||
import { ModulePageHeader } from '../components/module-page-header';
|
import { ModulePageHeader } from '../components/module-page-header';
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useEnterpriseModuleConfigContext,
|
useEnterpriseModuleConfigContext,
|
||||||
@@ -20,15 +20,20 @@ export function EnterpriseIndexPageProvider(props: EnterpriseIndexPageConfig) {
|
|||||||
const { moduleKey } = config;
|
const { moduleKey } = config;
|
||||||
const { ALLOW_CREATE } = privileges;
|
const { ALLOW_CREATE } = privileges;
|
||||||
|
|
||||||
|
// Stable refs for consumer-provided callbacks to prevent infinite loops.
|
||||||
|
// These callbacks may be unstable (new reference each render) if the consumer doesn't memoize them.
|
||||||
|
const onClickCreateRef = useRef(onClickCreate);
|
||||||
|
onClickCreateRef.current = onClickCreate;
|
||||||
|
|
||||||
// Stable reference so the useEffect doesn't re-attach on every render.
|
// Stable reference so the useEffect doesn't re-attach on every render.
|
||||||
const handleActionClick = useCallback(
|
const handleActionClick = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
if (key === ModuleAction.CREATE && ALLOW_CREATE) {
|
if (key === ModuleAction.CREATE && ALLOW_CREATE) {
|
||||||
if (onClickCreate) onClickCreate(key);
|
if (onClickCreateRef.current) onClickCreateRef.current(key);
|
||||||
else navigation.navigateToCreate();
|
else navigation.navigateToCreate();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onClickCreate, navigation, ALLOW_CREATE],
|
[navigation, ALLOW_CREATE],
|
||||||
);
|
);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -37,7 +42,7 @@ export function EnterpriseIndexPageProvider(props: EnterpriseIndexPageConfig) {
|
|||||||
|
|
||||||
/** Platform-aware shortcut label for the Create action. */
|
/** Platform-aware shortcut label for the Create action. */
|
||||||
const CREATE_SHORTCUT_LABEL = useMemo(() => {
|
const CREATE_SHORTCUT_LABEL = useMemo(() => {
|
||||||
const shortcutData = shortcutsData.find((s) => s.key === 'collapse_sidebar');
|
const shortcutData = shortcutsData.find((s) => s.key === 'create_data');
|
||||||
return IS_MACOS ? shortcutData?.macKeyIcons.join(' ') : shortcutData?.winKeyIcons.join(' ');
|
return IS_MACOS ? shortcutData?.macKeyIcons.join(' ') : shortcutData?.winKeyIcons.join(' ');
|
||||||
}, [IS_MACOS]);
|
}, [IS_MACOS]);
|
||||||
|
|
||||||
@@ -76,6 +81,12 @@ export function EnterpriseIndexPageProvider(props: EnterpriseIndexPageConfig) {
|
|||||||
return customPageActions ? customPageActions(actions) : (actions as any[]);
|
return customPageActions ? customPageActions(actions) : (actions as any[]);
|
||||||
}, [t, customPageActions, handleActionClick, ALLOW_CREATE]);
|
}, [t, customPageActions, handleActionClick, ALLOW_CREATE]);
|
||||||
|
|
||||||
|
// Set document title — uses explicit tabTitle or falls back to translation key 'title'
|
||||||
|
useEffect(() => {
|
||||||
|
const title = config.tabTitle || t('title');
|
||||||
|
if (title) document.title = title;
|
||||||
|
}, [config.tabTitle, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IndexPageContext.Provider value={{}}>
|
<IndexPageContext.Provider value={{}}>
|
||||||
<CorePageContainer
|
<CorePageContainer
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function EnterpriseModuleProvider<
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 1. Config Slice (Static)
|
// Config Slice (Static)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const storePrivileges = store((state: S) => state.privileges);
|
const storePrivileges = store((state: S) => state.privileges);
|
||||||
@@ -60,29 +60,21 @@ export function EnterpriseModuleProvider<
|
|||||||
}, [config, storePrivileges]);
|
}, [config, storePrivileges]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 1b. Translation Slice (Dedicated context — decoupled from config)
|
// Translation Slice (Dedicated context — decoupled from config)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const namespaces = useMemo(() => [config.translationNamespace, 'common'], [config.translationNamespace]);
|
const namespaces = useMemo(() => [config.translationNamespace, 'common'], [config.translationNamespace]);
|
||||||
const { t } = useTranslation(namespaces);
|
const { t } = useTranslation(namespaces);
|
||||||
const translationSlice = useMemo(() => ({ t: t as (key: string, options?: Record<string, unknown>) => string }), [t]);
|
const translationSlice = useMemo(() => ({ t: t as (key: string, options?: Record<string, unknown>) => string }), [t]);
|
||||||
|
|
||||||
// Set document title — uses explicit tabTitle or falls back to translation key 'title'
|
|
||||||
// useEffect(() => {
|
|
||||||
// const title = config.tabTitle || t('title');
|
|
||||||
// if (title) {
|
|
||||||
// document.title = title;
|
|
||||||
// }
|
|
||||||
// }, [config.tabTitle, t]);
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 2. Data Service Slice (Stable refs)
|
// Data Service Slice (Stable refs)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const dataServiceSlice = useMemo(() => {
|
const dataServiceSlice = useMemo(() => {
|
||||||
return { dataServices };
|
return { dataServices };
|
||||||
}, [dataServices]);
|
}, [dataServices]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 3. Selection Slice (Dynamic state)
|
// Selection Slice (Dynamic state)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const selectedRows = store((state: S) => state.selectedRows);
|
const selectedRows = store((state: S) => state.selectedRows);
|
||||||
const setSelectedRows = store((state: S) => state.setSelectedRows);
|
const setSelectedRows = store((state: S) => state.setSelectedRows);
|
||||||
@@ -104,7 +96,7 @@ export function EnterpriseModuleProvider<
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 4. Modal Slice (For Single Page mode)
|
// Modal Slice (For Single Page mode)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const [formState, setFormState] = useState<SinglePageFormState>({ open: false, formType: 'CREATE' });
|
const [formState, setFormState] = useState<SinglePageFormState>({ open: false, formType: 'CREATE' });
|
||||||
const [detailState, setDetailState] = useState<SinglePageModalState>({ open: false });
|
const [detailState, setDetailState] = useState<SinglePageModalState>({ open: false });
|
||||||
@@ -115,7 +107,7 @@ export function EnterpriseModuleProvider<
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 5. Navigation Slice
|
// Navigation Slice
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const navigationSlice = useMemo(() => {
|
const navigationSlice = useMemo(() => {
|
||||||
const isSingle = config.moduleCategory === 'SINGLE_PAGE';
|
const isSingle = config.moduleCategory === 'SINGLE_PAGE';
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dayjs": "^1.11.19"
|
"dayjs": "^1.11.19",
|
||||||
|
"lodash": "^4.18.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@repo/eslint-config": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@repo/typescript-config": "workspace:*",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
|
"@types/lodash": "^4.17.24",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"typescript": "5.5.4",
|
"typescript": "5.5.4",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from './encryption/encryption.utils';
|
|||||||
export * from './date/date.utils';
|
export * from './date/date.utils';
|
||||||
export * from './currency/currency.utils';
|
export * from './currency/currency.utils';
|
||||||
export * from './string/string.utils';
|
export * from './string/string.utils';
|
||||||
|
export * from './lodash/lodash.utils';
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import lodash from 'lodash';
|
||||||
|
|
||||||
|
export { lodash };
|
||||||
Generated
+14
@@ -670,6 +670,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.19
|
specifier: ^1.11.19
|
||||||
version: 1.11.19
|
version: 1.11.19
|
||||||
|
lodash:
|
||||||
|
specifier: ^4.18.1
|
||||||
|
version: 4.18.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@repo/eslint-config':
|
'@repo/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
@@ -680,6 +683,9 @@ importers:
|
|||||||
'@types/crypto-js':
|
'@types/crypto-js':
|
||||||
specifier: ^4.2.2
|
specifier: ^4.2.2
|
||||||
version: 4.2.2
|
version: 4.2.2
|
||||||
|
'@types/lodash':
|
||||||
|
specifier: ^4.17.24
|
||||||
|
version: 4.17.24
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^8.57.1
|
specifier: ^8.57.1
|
||||||
version: 8.57.1
|
version: 8.57.1
|
||||||
@@ -3835,6 +3841,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/lodash@4.17.24:
|
||||||
|
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/markdown-it@14.1.2:
|
/@types/markdown-it@14.1.2:
|
||||||
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -8815,6 +8825,10 @@ packages:
|
|||||||
/lodash@4.17.21:
|
/lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
|
/lodash@4.18.1:
|
||||||
|
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/log-symbols@4.1.0:
|
/log-symbols@4.1.0:
|
||||||
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|||||||
Reference in New Issue
Block a user