feat: add configuration module for managing branches, divisions, and customers

- Introduced a new Configuration module with routes for managing branches, divisions, and customers.
- Implemented UI components for creating, editing, and viewing branch details, including general information, location, and working schedule.
- Added validation schemas for branch data and integrated language support for English and Indonesian.
- Developed comprehensive unit tests for the branches remote data service and transformer to ensure functionality and reliability.

This commit enhances the application by providing a structured approach to configuration management, improving user experience and data handling.
This commit is contained in:
shancheas
2026-08-25 22:56:07 +07:00
parent 44b0ef0168
commit 73d84f3874
92 changed files with 3271 additions and 78 deletions
+2
View File
@@ -8,6 +8,7 @@ const SystemSetting = lazy(() => import('./modules/system/setting'));
const SystemInformation = lazy(() => import('./modules/system/information'));
const SystemNotification = lazy(() => import('./modules/system/notification'));
const PrivilegesModule = lazy(() => import('./modules/system/privileges/presentation/factory'));
const ConfigurationModule = lazy(() => import('./modules/configuration'));
export default function AppModule() {
return (
@@ -19,6 +20,7 @@ export default function AppModule() {
<Route path="/system/information" element={<SystemInformation />} />
<Route path="/system/notifications" element={<SystemNotification />} />
<Route path="/system/privileges/*" element={<PrivilegesModule />} />
<Route path="/configuration/*" element={<ConfigurationModule />} />
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
@@ -8,6 +8,8 @@ import {
Box,
Layers,
Warehouse,
Building2,
MapPin,
Activity,
Globe,
Briefcase,
@@ -235,6 +237,35 @@ export const MENU_ITEMS: MenuItemType[] = [
},
],
},
{
key: 'configuration',
label: 'nav:configuration',
icon: Building2,
path: '/app/configuration',
children: [
{
key: 'configuration-divisions',
label: 'nav:configuration-divisions',
icon: Layers,
path: '/app/configuration/divisions/index',
moduleKey: 'CONFIGURATION.DIVISION',
},
{
key: 'configuration-branches',
label: 'nav:configuration-branches',
icon: MapPin,
path: '/app/configuration/branches/index',
moduleKey: 'CONFIGURATION.BRANCH',
},
{
key: 'configuration-customers',
label: 'nav:configuration-customers',
icon: Users,
path: '/app/configuration/customers/index',
moduleKey: 'CONFIGURATION.CUSTOMER',
},
],
},
{
key: 'system-group',
label: 'nav:system',
@@ -34,5 +34,9 @@
"example-full-page": "Example Full Page",
"example-single-page": "Example Single Page",
"system": "System",
"system-privileges": "Privileges"
"system-privileges": "Privileges",
"configuration": "Configuration",
"configuration-divisions": "Divisions",
"configuration-branches": "Branches",
"configuration-customers": "Customers"
}
@@ -34,5 +34,9 @@
"example-full-page": "Contoh Halaman Penuh",
"example-single-page": "Contoh Halaman Tunggal",
"system": "Sistem",
"system-privileges": "Hak Akses"
"system-privileges": "Hak Akses",
"configuration": "Konfigurasi",
"configuration-divisions": "Divisi",
"configuration-branches": "Cabang",
"configuration-customers": "Pelanggan"
}
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosInstance } from '@repo/core-api/http-client';
import { BranchesRemoteDataServices } from './branch.remote.service';
import { BranchesRemoteDataTransformer } from '../domain/transformers/branch.remote.transformer';
function createMockHttpClient(): AxiosInstance {
return {
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
defaults: {} as AxiosInstance['defaults'],
interceptors: {
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
},
getUri: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
head: vi.fn(),
options: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
postForm: vi.fn(),
putForm: vi.fn(),
patchForm: vi.fn(),
} as unknown as AxiosInstance;
}
describe('BranchesRemoteDataServices', () => {
let httpClient: AxiosInstance;
let service: BranchesRemoteDataServices;
beforeEach(() => {
httpClient = createMockHttpClient();
service = new BranchesRemoteDataServices(httpClient, {
apiUrl: '/branches',
moduleKey: 'CONFIGURATION.BRANCH',
transformer: new BranchesRemoteDataTransformer(),
});
});
it('uses PATCH when editing a branch', async () => {
await service.edit('br-1', { name: 'Jakarta Pusat', code: 'JKT_01' } as any);
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({ url: '/branches/br-1', method: 'PATCH' }),
);
});
it('bulk-deletes via POST /branches/bulk-delete', async () => {
await service.batchDelete(['br-1']);
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/branches/bulk-delete',
method: 'POST',
}),
);
});
});
@@ -0,0 +1,13 @@
import type { AxiosInstance } from '@repo/core-api/http-client';
import type { DataServicesConfig } from '@repo/core-api/data-services';
import { TrackGoRemoteDataServices } from '../../../../../../core/lib/trackgo-remote-data-services';
import type { BranchEntity } from '../domain/entities';
export class BranchesRemoteDataServices extends TrackGoRemoteDataServices<BranchEntity> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<BranchEntity>) {
super(httpClient, {
...config,
apiUrl: config.apiUrl ?? '/branches',
});
}
}
@@ -0,0 +1,11 @@
import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { BranchEntity } from '../entities';
export const branchesModuleConfig: ModuleConfigEntity<BranchEntity> = {
moduleKey: 'CONFIGURATION.BRANCH',
translationNamespace: 'BRANCHES',
apiUrl: '/branches',
webUrl: '/app/configuration/branches',
moduleCategory: 'FULL_PAGE',
moduleType: 'MASTER_DATA',
} as const;
@@ -0,0 +1 @@
export * from './branch.constants';
@@ -0,0 +1,46 @@
import { BaseEntity } from '@repo/core-api/data-services';
import type { ConfigurationStatus } from '../../../divisions/domain/entities';
import type { Weekday } from '../../../../../../../core/domain/configuration-field-validators';
import type { DivisionEntity } from '../../../divisions/domain/entities';
export interface BranchEntity extends BaseEntity {
code: string;
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
workingDaysStart: Weekday;
workingDaysEnd: Weekday;
workingHoursStart: string;
workingHoursEnd: string;
nfcId?: string | null;
divisionId?: string | null;
division?: DivisionEntity | null;
status?: ConfigurationStatus;
createdAt?: number;
updatedAt?: number;
createdBy?: string;
updatedBy?: string;
}
export interface BranchDto {
id?: string;
code: string;
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
workingDaysStart: Weekday;
workingDaysEnd: Weekday;
workingHoursStart: string;
workingHoursEnd: string;
nfcId?: string | null;
divisionId?: string | null;
status?: ConfigurationStatus;
createdAt?: number;
updatedAt?: number;
createdBy?: string;
updatedBy?: string;
}
@@ -0,0 +1 @@
export * from './branch.entity';
@@ -0,0 +1,12 @@
import { apiClient } from '../../../../../../../core/lib/api-client';
import { BranchesRemoteDataServices } from '../../data/branch.remote.service';
import { branchesModuleConfig } from '../constants/branch.constants';
import { BranchesRemoteDataTransformer } from '../transformers/branch.remote.transformer';
export const branchesDataTransformer = new BranchesRemoteDataTransformer();
export const branchesDataService = new BranchesRemoteDataServices(apiClient, {
apiUrl: branchesModuleConfig.apiUrl,
moduleKey: branchesModuleConfig.moduleKey,
transformer: branchesDataTransformer,
});
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { BranchesRemoteDataTransformer } from './branch.remote.transformer';
import type { BranchEntity } from '../entities';
const transformer = new BranchesRemoteDataTransformer();
const dto = {
id: 'br-1',
code: 'JKT_01',
name: 'Jakarta Pusat',
phone: '+6281234567890',
address: 'Jl Sudirman No 1',
latitude: -6.2,
longitude: 106.8,
workingDaysStart: 'monday' as const,
workingDaysEnd: 'friday' as const,
workingHoursStart: '08:00',
workingHoursEnd: '17:00',
nfcId: 'NFC-001',
divisionId: 'div-1',
status: 'active' as const,
createdAt: 1,
updatedAt: 2,
createdBy: 'u1',
updatedBy: 'u2',
};
describe('BranchesRemoteDataTransformer', () => {
it('keeps divisionId without inventing a division name', () => {
const entity = transformer.transformToEntity(dto);
expect(entity.divisionId).toBe('div-1');
expect(entity.division).toBeNull();
});
it('maps division object to divisionId on create and omits empty optionals', () => {
const entity: BranchEntity = {
...dto,
division: { id: 'div-1', name: 'Sales', code: 'SALES' },
nfcId: '',
latitude: undefined,
};
const payload = transformer.transformCreatePayload(entity);
expect(payload.divisionId).toBe('div-1');
expect(payload).not.toHaveProperty('division');
expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('nfcId');
expect(payload).not.toHaveProperty('latitude');
});
it('sends null for cleared optional fields on edit', () => {
const payload = transformer.transformEditPayload({
...dto,
nfcId: '',
latitude: undefined,
division: null,
divisionId: undefined,
});
expect(payload.nfcId).toBeNull();
expect(payload.latitude).toBeNull();
expect(payload.divisionId).toBeNull();
expect(payload).not.toHaveProperty('status');
});
it('flattens division filter objects to divisionId', () => {
const filter = transformer.transformPayloadFilter({
code: 'JKT',
division: { id: 'div-1', name: 'Sales' },
});
expect(filter).toEqual({ code: 'JKT', divisionId: 'div-1' });
});
});
@@ -0,0 +1,85 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import type { BranchDto, BranchEntity } from '../entities';
function resolveDivisionId(entity: Partial<BranchEntity>): string | null | undefined {
if (entity.division && typeof entity.division === 'object') {
const id = entity.division.id;
return id == null ? null : String(id);
}
return entity.divisionId;
}
export class BranchesRemoteDataTransformer extends BaseDataTransformer<BranchEntity> {
transformToEntity(dto: BranchDto | BranchEntity): BranchEntity {
const divisionId = 'divisionId' in dto ? dto.divisionId : (dto as BranchEntity).divisionId;
return {
id: dto.id,
code: dto.code,
name: dto.name,
phone: dto.phone,
address: dto.address,
latitude: dto.latitude ?? null,
longitude: dto.longitude ?? null,
workingDaysStart: dto.workingDaysStart,
workingDaysEnd: dto.workingDaysEnd,
workingHoursStart: dto.workingHoursStart,
workingHoursEnd: dto.workingHoursEnd,
nfcId: dto.nfcId ?? null,
divisionId: divisionId ?? null,
division: (dto as BranchEntity).division ?? null,
status: dto.status,
createdAt: dto.createdAt,
updatedAt: dto.updatedAt,
createdBy: dto.createdBy,
updatedBy: dto.updatedBy,
};
}
transformToDTO(entity: BranchEntity): BranchEntity {
return { ...entity };
}
transformCreatePayload(entity: Partial<BranchEntity>): Partial<BranchEntity> {
return omitEmptyFields({
code: entity.code,
name: entity.name,
phone: entity.phone,
address: entity.address,
latitude: entity.latitude,
longitude: entity.longitude,
workingDaysStart: entity.workingDaysStart,
workingDaysEnd: entity.workingDaysEnd,
workingHoursStart: entity.workingHoursStart,
workingHoursEnd: entity.workingHoursEnd,
nfcId: entity.nfcId,
divisionId: resolveDivisionId(entity),
});
}
transformEditPayload(entity: Partial<BranchEntity>): Partial<BranchEntity> {
return {
code: entity.code,
name: entity.name,
phone: entity.phone,
address: entity.address,
latitude: emptyToNull(entity.latitude) as number | null,
longitude: emptyToNull(entity.longitude) as number | null,
workingDaysStart: entity.workingDaysStart,
workingDaysEnd: entity.workingDaysEnd,
workingHoursStart: entity.workingHoursStart,
workingHoursEnd: entity.workingHoursEnd,
nfcId: emptyToNull(entity.nfcId) as string | null,
divisionId: emptyToNull(resolveDivisionId(entity)) as string | null,
};
}
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
const next = { ...filter };
if (next.division && typeof next.division === 'object') {
next.divisionId = next.division.id;
delete next.division;
}
return omitEmptyFields(next);
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { createBranchSchema } from './branch.validator';
describe('createBranchSchema', () => {
const t = (key: string) => key;
const schema = createBranchSchema(t);
const valid = {
code: 'JKT_01',
name: 'Jakarta Pusat',
phone: '+6281234567890',
address: 'Jl Sudirman No 1',
workingDaysStart: 'monday',
workingDaysEnd: 'friday',
workingHoursStart: '08:00',
workingHoursEnd: '17:00',
};
it('accepts a complete required payload', () => {
expect(schema.safeParse(valid).success).toBe(true);
});
it('rejects a missing phone', () => {
expect(schema.safeParse({ ...valid, phone: '' }).success).toBe(false);
});
it('rejects invalid working hours', () => {
expect(schema.safeParse({ ...valid, workingHoursStart: '8:00' }).success).toBe(false);
});
it('rejects latitude outside range', () => {
expect(schema.safeParse({ ...valid, latitude: -91 }).success).toBe(false);
});
it('accepts optional coordinates and nfc id', () => {
expect(
schema.safeParse({
...valid,
latitude: -6.2,
longitude: 106.8,
nfcId: 'NFC-001',
division: { id: 'div-1', name: 'Sales', code: 'SALES' },
}).success,
).toBe(true);
});
});
@@ -0,0 +1,36 @@
import { z } from 'zod';
import {
configAddressSchema,
configCodeSchema,
configNameSchema,
configPhoneSchema,
optionalLatitudeSchema,
optionalLongitudeSchema,
optionalNfcIdSchema,
weekdaySchema,
workingHoursSchema,
} from '../../../../../../../core/domain/configuration-field-validators';
export const createBranchSchema = (t: (key: string) => string) => {
return z.object({
code: configCodeSchema(t),
name: configNameSchema(t),
phone: configPhoneSchema(t),
address: configAddressSchema(t),
nfcId: optionalNfcIdSchema(t),
latitude: optionalLatitudeSchema(),
longitude: optionalLongitudeSchema(),
division: z
.object({
id: z.string(),
name: z.string().optional(),
code: z.string().optional(),
})
.nullable()
.optional(),
workingDaysStart: weekdaySchema(t),
workingDaysEnd: weekdaySchema(t),
workingHoursStart: workingHoursSchema(t),
workingHoursEnd: workingHoursSchema(t),
});
};
@@ -0,0 +1,41 @@
import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { BranchEntity } from '../../../domain/entities';
export function DetailGeneral() {
const { detailData } = useDetailPageContext<BranchEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const data = detailData;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} 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.phone')} value={data?.phone} />
<FieldValue label={t('common:fields.nfcId')} value={data?.nfcId} />
<FieldValue label={t('common:fields.address')} value={data?.address} />
<FieldValue
label={t('common:fields.status')}
value={data?.status}
render={(val) => <StatusBadge status={String(val ?? '')} />}
/>
<FieldValue
label={t('common:fields.createdAt')}
value={data?.createdAt}
render={(val) => <RenderDate value={val as any} />}
/>
<FieldValue
label={t('common:fields.updatedAt')}
value={data?.updatedAt}
render={(val) => <RenderDate value={val as any} />}
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,25 @@
import { Box, Paper, SimpleGrid, FieldValue, Text } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { BranchEntity } from '../../../domain/entities';
export function DetailLocation() {
const { detailData } = useDetailPageContext<BranchEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const data = detailData;
const divisionLabel = data?.division ? `${data.division.code} - ${data.division.name}` : data?.divisionId;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_location')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
<FieldValue label={t('common:fields.latitude')} value={data?.latitude} />
<FieldValue label={t('common:fields.longitude')} value={data?.longitude} />
<FieldValue label={t('common:fields.division')} value={divisionLabel} />
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,31 @@
import { Box, Paper, SimpleGrid, FieldValue, Text } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { BranchEntity } from '../../../domain/entities';
export function DetailSchedule() {
const { detailData } = useDetailPageContext<BranchEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const data = detailData;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_schedule')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
<FieldValue
label={t('common:fields.workingDaysStart')}
value={data?.workingDaysStart ? t(`weekday_${data.workingDaysStart}`) : undefined}
/>
<FieldValue
label={t('common:fields.workingDaysEnd')}
value={data?.workingDaysEnd ? t(`weekday_${data.workingDaysEnd}`) : undefined}
/>
<FieldValue label={t('common:fields.workingHoursStart')} value={data?.workingHoursStart} />
<FieldValue label={t('common:fields.workingHoursEnd')} value={data?.workingHoursEnd} />
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,61 @@
import { Box, FieldTextInput, FieldTextarea, Paper, SimpleGrid, Text } 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">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={formControl.control}
name="code"
label={t('common:fields.code')}
placeholder="e.g. JKT_01"
required
radius="md"
/>
<FieldTextInput
name="name"
control={formControl.control}
label={t('common:fields.name')}
placeholder="e.g. Jakarta Pusat"
required
radius="md"
/>
<FieldTextInput
control={formControl.control}
name="phone"
label={t('common:fields.phone')}
placeholder="+6281234567890"
required
radius="md"
/>
<FieldTextInput
control={formControl.control}
name="nfcId"
label={t('common:fields.nfcId')}
placeholder="e.g. NFC-001"
radius="md"
/>
</SimpleGrid>
<Box mt="md">
<FieldTextarea
control={formControl.control}
name="address"
label={t('common:fields.address')}
placeholder="Street address"
required
minRows={3}
radius="md"
/>
</Box>
</Box>
</Paper>
);
}
@@ -0,0 +1,64 @@
import { useEffect } from 'react';
import { Box, FieldNumberInput, FieldAsyncSelect, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { loadDivisionOptions } from '../../../../shared/load-division-options';
import { divisionsDataService } from '../../../../divisions/domain/factories';
import type { DivisionEntity } from '../../../../divisions/domain/entities';
export function FormLocation() {
const { formControl } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext();
const division = formControl.watch('division');
useEffect(() => {
const current = formControl.getValues('division') as DivisionEntity | null | undefined;
const id = current?.id ?? formControl.getValues('divisionId');
if (!id) return;
if (current?.code && current.name && current.name !== String(current.id)) return;
void divisionsDataService.getOne(String(id)).then((result) => {
const entity = (result.data as { data?: DivisionEntity } | undefined)?.data;
if (entity) formControl.setValue('division', entity);
});
}, [formControl]);
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_location')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldNumberInput
control={formControl.control}
name="latitude"
label={t('common:fields.latitude')}
placeholder="-6.2"
decimalScale={6}
radius="md"
/>
<FieldNumberInput
control={formControl.control}
name="longitude"
label={t('common:fields.longitude')}
placeholder="106.8"
decimalScale={6}
radius="md"
/>
<FieldAsyncSelect<DivisionEntity>
control={formControl.control}
name="division"
label={t('common:fields.division')}
placeholder={t('common:fields.division')}
valueKey="id"
labelKey="name"
clearable
searchable
loadOptions={loadDivisionOptions}
defaultOptions={division ? [division] : []}
renderLabel={(item) => `${item.code} - ${item.name}`}
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,53 @@
import { Box, FieldSelect, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { weekdaySelectOptions } from '../../../../shared/weekday-options';
export function FormSchedule() {
const { formControl } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext();
const weekdayOptions = weekdaySelectOptions(t);
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_schedule')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldSelect
control={formControl.control}
name="workingDaysStart"
label={t('common:fields.workingDaysStart')}
data={weekdayOptions}
required
radius="md"
/>
<FieldSelect
control={formControl.control}
name="workingDaysEnd"
label={t('common:fields.workingDaysEnd')}
data={weekdayOptions}
required
radius="md"
/>
<FieldTextInput
control={formControl.control}
name="workingHoursStart"
label={t('common:fields.workingHoursStart')}
placeholder="08:00"
required
radius="md"
/>
<FieldTextInput
control={formControl.control}
name="workingHoursEnd"
label={t('common:fields.workingHoursEnd')}
placeholder="17:00"
required
radius="md"
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,50 @@
import { SimpleGrid } from '@repo/ui/components';
import { FieldTextInput, FieldSelect, FieldAsyncSelect } from '@repo/ui/form';
import { UseFormReturn } from 'react-hook-form';
import { statusFilterOptions } from '../../../../shared/status-filter-options';
import { loadDivisionOptions } from '../../../../shared/load-division-options';
import type { DivisionEntity } from '../../../../divisions/domain/entities';
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={form.control}
name="code"
label={t('common:fields.code')}
placeholder={`Enter ${t('common:fields.code')}`}
/>
<FieldTextInput
control={form.control}
name="name"
label={t('common:fields.name')}
placeholder={`Enter ${t('common:fields.name')}`}
/>
<FieldTextInput
control={form.control}
name="phone"
label={t('common:fields.phone')}
placeholder={`Enter ${t('common:fields.phone')}`}
/>
<FieldAsyncSelect<DivisionEntity>
control={form.control}
name="division"
label={t('common:fields.division')}
placeholder={t('common:fields.division')}
valueKey="id"
labelKey="name"
clearable
searchable
loadOptions={loadDivisionOptions}
renderLabel={(item) => `${item.code} - ${item.name}`}
/>
<FieldSelect
control={form.control}
name="status"
label={t('common:fields.status')}
clearable
data={statusFilterOptions(t)}
/>
</SimpleGrid>
);
};
@@ -0,0 +1,40 @@
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 { branchesModuleConfig } from '../../domain/constants';
import { branchesDataService } from '../../domain/factories';
import { BranchEntity } from '../../domain/entities';
import { branchesStore } from '../store';
import branchesId from '../languages/id/branches.json';
import branchesEn from '../languages/en/branches.json';
const IndexPage = lazy(() => import('../pages/branch.page.index'));
const FormPage = lazy(() => import('../pages/branch.page.form'));
const DetailPage = lazy(() => import('../pages/branch.page.detail'));
registerModuleNamespace(branchesModuleConfig.translationNamespace, {
id: branchesId,
en: branchesEn,
});
export default function BranchesModule() {
return (
<EnterpriseModuleProvider<BranchEntity>
config={branchesModuleConfig}
dataServices={branchesDataService}
store={branchesStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
<Route path="/" element={<Navigate to={`${branchesModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,25 @@
{
"title": "Branches",
"detail_page_title": "Branch Detail",
"create_page_title": "New Branch",
"edit_page_title": "Edit Branch",
"duplicate_page_title": "Duplicate Branch",
"description": "Manage company <1>branches</1>, locations, and working hours.",
"detail_page_description": "Review branch identity, location, and working schedule.",
"create_page_description": "Create a branch with contact details, location, and working hours.",
"edit_page_description": "Update branch identity, location, and working schedule.",
"duplicate_page_description": "Copy an existing branch to create a new one.",
"section_general": "General",
"section_location": "Location",
"section_schedule": "Working Schedule",
"status_draft": "Draft",
"status_active": "Active",
"status_archived": "Archived",
"weekday_monday": "Monday",
"weekday_tuesday": "Tuesday",
"weekday_wednesday": "Wednesday",
"weekday_thursday": "Thursday",
"weekday_friday": "Friday",
"weekday_saturday": "Saturday",
"weekday_sunday": "Sunday"
}
@@ -0,0 +1,25 @@
{
"title": "Cabang",
"detail_page_title": "Detail Cabang",
"create_page_title": "Cabang Baru",
"edit_page_title": "Ubah Cabang",
"duplicate_page_title": "Duplikat Cabang",
"description": "Kelola <1>cabang</1> perusahaan, lokasi, dan jam kerja.",
"detail_page_description": "Tinjau identitas, lokasi, dan jadwal kerja cabang.",
"create_page_description": "Buat cabang dengan kontak, lokasi, dan jam kerja.",
"edit_page_description": "Perbarui identitas, lokasi, dan jadwal kerja cabang.",
"duplicate_page_description": "Salin cabang yang ada untuk membuat data baru.",
"section_general": "Umum",
"section_location": "Lokasi",
"section_schedule": "Jadwal Kerja",
"status_draft": "Draft",
"status_active": "Aktif",
"status_archived": "Diarsipkan",
"weekday_monday": "Senin",
"weekday_tuesday": "Selasa",
"weekday_wednesday": "Rabu",
"weekday_thursday": "Kamis",
"weekday_friday": "Jumat",
"weekday_saturday": "Sabtu",
"weekday_sunday": "Minggu"
}
@@ -0,0 +1,30 @@
import { Stack } from '@repo/ui/components';
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { branchesModuleConfig } from '../../domain/constants';
import { DetailGeneral } from '../components/detail-component/detail-general';
import { DetailLocation } from '../components/detail-component/detail-location';
import { DetailSchedule } from '../components/detail-component/detail-schedule';
export default function BranchPageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
return (
<EnterpriseDetailPageProvider
highlightDataKey="code"
pageHeaderProps={{
title: t('detail_page_title'),
description: t('detail_page_description'),
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-branches'), type: 'link', href: `${branchesModuleConfig.webUrl}/index` },
],
}}
>
<Stack gap="md">
<DetailGeneral />
<DetailLocation />
<DetailSchedule />
</Stack>
</EnterpriseDetailPageProvider>
);
}
@@ -0,0 +1,54 @@
import { useMemo } from 'react';
import { Stack } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { branchesModuleConfig } from '../../domain/constants';
import { createBranchSchema } from '../../domain/validators/branch.validator';
import { FormGeneral } from '../components/form-component/form-general';
import { FormLocation } from '../components/form-component/form-location';
import { FormSchedule } from '../components/form-component/form-schedule';
export default function BranchPageForm({ formPageType }: { formPageType: FormPageType }) {
const { t } = useEnterpriseModuleTranslationContext();
const title = useMemo(() => {
if (formPageType === 'CREATE') {
return { title: t('create_page_title'), description: t('create_page_description') };
}
if (formPageType === 'EDIT') {
return { title: t('edit_page_title'), description: t('edit_page_description') };
}
if (formPageType === 'DUPLICATE') {
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
}
return { title: '', description: '' };
}, [formPageType, t]);
const validator = useMemo(() => createBranchSchema(t), [t]);
const formControl = useForm({ resolver: zodResolver(validator) });
return (
<EnterpriseFormPageProvider
formControl={formControl}
formPageType={formPageType}
ignoreKeyDuplicate={['code']}
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id', 'divisionId']}
highlightDataKey="code"
pageHeaderProps={{
title: title?.title,
description: title?.description,
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-branches'), type: 'link', href: `${branchesModuleConfig.webUrl}/index` },
],
}}
>
<Stack gap="md">
<FormGeneral />
<FormLocation />
<FormSchedule />
</Stack>
</EnterpriseFormPageProvider>
);
}
@@ -0,0 +1,51 @@
import { useMemo } from 'react';
import {
EnterpriseIndexPageProvider,
useEnterpriseModuleTranslationContext,
EnterpriseDataTable,
} from '@repo/ui/foundations';
import { ColDef, Text } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n';
import { MapPin } from 'lucide-react';
import { FilterFormContent } from '../components/index-component/filter-content';
import type { BranchEntity } from '../../domain/entities';
export default function BranchPageIndex() {
const { t } = useEnterpriseModuleTranslationContext();
const columnDefs: ColDef<BranchEntity>[] = useMemo(() => {
return [
{ field: 'code', headerName: t('common:fields.code'), minWidth: 140 },
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
{ field: 'phone', headerName: t('common:fields.phone'), minWidth: 160 },
{ field: 'address', headerName: t('common:fields.address'), minWidth: 240 },
];
}, [t]);
const filterConfig = useMemo(() => {
return {
renderBody: (form: any) => {
if (!form) return null;
return <FilterFormContent form={form} t={t} />;
},
};
}, [t]);
return (
<EnterpriseIndexPageProvider
pageHeaderProps={{
title: t('title'),
description: (
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
),
icon: MapPin,
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-branches'), type: 'text' },
],
}}
>
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
</EnterpriseIndexPageProvider>
);
}
@@ -0,0 +1,22 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import { BranchEntity } from '../../domain/entities';
export interface BranchesStoreState extends EnterpriseModuleState<BranchEntity> {}
export const branchesStore = create<BranchesStoreState>((set) => ({
metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }),
filterData: {},
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
tableConfig: null,
setTableConfig: (config) => set({ tableConfig: config }),
}));
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosInstance } from '@repo/core-api/http-client';
import { CustomersRemoteDataServices } from './customer.remote.service';
import { CustomersRemoteDataTransformer } from '../domain/transformers/customer.remote.transformer';
function createMockHttpClient(): AxiosInstance {
return {
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
defaults: {} as AxiosInstance['defaults'],
interceptors: {
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
},
getUri: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
head: vi.fn(),
options: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
postForm: vi.fn(),
putForm: vi.fn(),
patchForm: vi.fn(),
} as unknown as AxiosInstance;
}
const customerDetail = {
id: 'cust-1',
code: 'CUST_01',
name: 'Toko Maju',
phone: '+6281234567890',
address: 'Jl Gatot Subroto No 8',
contacts: [{ id: 'ct-1', name: 'Andi Pratama' }],
};
describe('CustomersRemoteDataServices', () => {
let httpClient: AxiosInstance;
let service: CustomersRemoteDataServices;
beforeEach(() => {
httpClient = createMockHttpClient();
service = new CustomersRemoteDataServices(httpClient, {
apiUrl: '/customers',
moduleKey: 'CONFIGURATION.CUSTOMER',
transformer: new CustomersRemoteDataTransformer(),
});
});
it('creates a contact via POST /customers/:id/contacts and wraps the customer', async () => {
vi.mocked(httpClient.request).mockResolvedValueOnce({ data: customerDetail, status: 200 });
const result = await service.createContact('cust-1', { name: 'Andi Pratama' });
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/customers/cust-1/contacts',
method: 'POST',
data: { name: 'Andi Pratama' },
}),
);
expect(result.data).toEqual({ data: expect.objectContaining({ id: 'cust-1', contacts: expect.any(Array) }) });
});
it('updates a contact via PATCH /customers/:id/contacts/:contactId', async () => {
vi.mocked(httpClient.request).mockResolvedValueOnce({ data: customerDetail, status: 200 });
await service.updateContact('cust-1', 'ct-1', { jobTitle: 'Manager' });
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/customers/cust-1/contacts/ct-1',
method: 'PATCH',
data: { jobTitle: 'Manager' },
}),
);
});
it('deletes a contact via DELETE /customers/:id/contacts/:contactId', async () => {
vi.mocked(httpClient.request).mockResolvedValueOnce({ data: undefined, status: 204 });
await service.deleteContact('cust-1', 'ct-1');
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/customers/cust-1/contacts/ct-1',
method: 'DELETE',
}),
);
});
});
@@ -0,0 +1,64 @@
import type { AxiosInstance, AxiosRequestConfig } from '@repo/core-api/http-client';
import { REQUEST_ACTION, type DataServicesConfig } from '@repo/core-api/data-services';
import type { ApiResponse } from '@repo/core-api/http-client';
import { TrackGoRemoteDataServices, unwrapBareDetail } from '../../../../../../core/lib/trackgo-remote-data-services';
import type { CustomerContactInput, CustomerDto, CustomerEntity } from '../domain/entities';
export class CustomersRemoteDataServices extends TrackGoRemoteDataServices<CustomerEntity> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<CustomerEntity>) {
super(httpClient, {
...config,
apiUrl: config.apiUrl ?? '/customers',
});
}
private contactsBaseUrl() {
return this.urls.getManyUrl.replace(/\/$/, '');
}
async createContact(customerId: string, payload: CustomerContactInput, config?: AxiosRequestConfig) {
const result = await this.customRequest<CustomerDto>({
...config,
url: `${this.contactsBaseUrl()}/${customerId}/contacts`,
method: 'POST',
data: payload,
headers: { ...config?.headers, 'ex-module-action': REQUEST_ACTION.EDIT },
});
return this.wrapCustomer(result);
}
async updateContact(
customerId: string,
contactId: string,
payload: Partial<CustomerContactInput>,
config?: AxiosRequestConfig,
) {
const result = await this.customRequest<CustomerDto>({
...config,
url: `${this.contactsBaseUrl()}/${customerId}/contacts/${contactId}`,
method: 'PATCH',
data: payload,
headers: { ...config?.headers, 'ex-module-action': REQUEST_ACTION.EDIT },
});
return this.wrapCustomer(result);
}
async deleteContact(customerId: string, contactId: string, config?: AxiosRequestConfig) {
return this.customRequest<void>({
...config,
url: `${this.contactsBaseUrl()}/${customerId}/contacts/${contactId}`,
method: 'DELETE',
headers: { ...config?.headers, 'ex-module-action': REQUEST_ACTION.DELETE },
});
}
private wrapCustomer(result: ApiResponse<CustomerDto>): ApiResponse<{ data: CustomerEntity }> {
const dto = unwrapBareDetail<CustomerDto>(result.data);
const entity = this.transformer
? this.transformer.transformGetOneResponse
? this.transformer.transformGetOneResponse(dto as unknown as CustomerEntity)
: this.transformer.transformToEntity(dto as unknown as CustomerEntity)
: (dto as unknown as CustomerEntity);
return { ...result, data: { data: entity } };
}
}
@@ -0,0 +1,11 @@
import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { CustomerEntity } from '../entities';
export const customersModuleConfig: ModuleConfigEntity<CustomerEntity> = {
moduleKey: 'CONFIGURATION.CUSTOMER',
translationNamespace: 'CUSTOMERS',
apiUrl: '/customers',
webUrl: '/app/configuration/customers',
moduleCategory: 'FULL_PAGE',
moduleType: 'MASTER_DATA',
} as const;
@@ -0,0 +1 @@
export * from './customer.constants';
@@ -0,0 +1,52 @@
import { BaseEntity } from '@repo/core-api/data-services';
import type { ConfigurationStatus } from '../../../divisions/domain/entities';
export interface CustomerContactEntity {
id?: string;
name: string;
jobTitle?: string | null;
phone?: string | null;
mobilePhone?: string | null;
notes?: string | null;
}
export interface CustomerEntity extends BaseEntity {
code: string;
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
nfcId?: string | null;
status?: ConfigurationStatus;
contacts?: CustomerContactEntity[];
createdAt?: number;
updatedAt?: number;
createdBy?: string;
updatedBy?: string;
}
export interface CustomerDto {
id?: string;
code: string;
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
nfcId?: string | null;
status?: ConfigurationStatus;
contacts?: CustomerContactEntity[];
createdAt?: number;
updatedAt?: number;
createdBy?: string;
updatedBy?: string;
}
export interface CustomerContactInput {
name: string;
jobTitle?: string;
phone?: string;
mobilePhone?: string;
notes?: string;
}
@@ -0,0 +1 @@
export * from './customer.entity';
@@ -0,0 +1,12 @@
import { apiClient } from '../../../../../../../core/lib/api-client';
import { CustomersRemoteDataServices } from '../../data/customer.remote.service';
import { customersModuleConfig } from '../constants/customer.constants';
import { CustomersRemoteDataTransformer } from '../transformers/customer.remote.transformer';
export const customersDataTransformer = new CustomersRemoteDataTransformer();
export const customersDataService = new CustomersRemoteDataServices(apiClient, {
apiUrl: customersModuleConfig.apiUrl,
moduleKey: customersModuleConfig.moduleKey,
transformer: customersDataTransformer,
});
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { CustomersRemoteDataTransformer } from './customer.remote.transformer';
import type { CustomerEntity } from '../entities';
const transformer = new CustomersRemoteDataTransformer();
const dto = {
id: 'cust-1',
code: 'CUST_01',
name: 'Toko Maju',
phone: '+6281234567890',
address: 'Jl Gatot Subroto No 8',
latitude: -6.2,
longitude: 106.8,
nfcId: 'NFC-C1',
status: 'active' as const,
contacts: [{ id: 'ct-1', name: 'Andi Pratama', jobTitle: 'Manager', phone: '+6281111111111' }],
createdAt: 1,
updatedAt: 2,
createdBy: 'u1',
updatedBy: 'u2',
};
describe('CustomersRemoteDataTransformer', () => {
it('maps contacts on the entity', () => {
const entity = transformer.transformToEntity(dto);
expect(entity.contacts?.[0]).toMatchObject({ id: 'ct-1', name: 'Andi Pratama', jobTitle: 'Manager' });
});
it('includes named contacts on create and omits status', () => {
const payload = transformer.transformCreatePayload(dto);
expect(payload.contacts).toEqual([
{ name: 'Andi Pratama', jobTitle: 'Manager', phone: '+6281111111111' },
]);
expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('id');
});
it('omits contacts from edit payload and nulls cleared optionals', () => {
const entity: CustomerEntity = { ...dto, nfcId: '', latitude: undefined };
const payload = transformer.transformEditPayload(entity);
expect(payload).not.toHaveProperty('contacts');
expect(payload.nfcId).toBeNull();
expect(payload.latitude).toBeNull();
});
});
@@ -0,0 +1,82 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import type { CustomerContactEntity, CustomerDto, CustomerEntity } from '../entities';
function mapContact(contact: CustomerContactEntity): CustomerContactEntity {
return {
id: contact.id,
name: contact.name,
jobTitle: contact.jobTitle ?? null,
phone: contact.phone ?? null,
mobilePhone: contact.mobilePhone ?? null,
notes: contact.notes ?? null,
};
}
function writableContact(contact: CustomerContactEntity): CustomerContactEntity {
return omitEmptyFields({
name: contact.name,
jobTitle: contact.jobTitle,
phone: contact.phone,
mobilePhone: contact.mobilePhone,
notes: contact.notes,
}) as CustomerContactEntity;
}
export class CustomersRemoteDataTransformer extends BaseDataTransformer<CustomerEntity> {
transformToEntity(dto: CustomerDto | CustomerEntity): CustomerEntity {
return {
id: dto.id,
code: dto.code,
name: dto.name,
phone: dto.phone,
address: dto.address,
latitude: dto.latitude ?? null,
longitude: dto.longitude ?? null,
nfcId: dto.nfcId ?? null,
status: dto.status,
contacts: (dto.contacts ?? []).map(mapContact),
createdAt: dto.createdAt,
updatedAt: dto.updatedAt,
createdBy: dto.createdBy,
updatedBy: dto.updatedBy,
};
}
transformToDTO(entity: CustomerEntity): CustomerEntity {
return { ...entity };
}
transformCreatePayload(entity: Partial<CustomerEntity>): Partial<CustomerEntity> {
const contacts = (entity.contacts ?? [])
.filter((contact): contact is CustomerContactEntity => Boolean(contact?.name))
.map((contact) => writableContact(contact));
const payload: Partial<CustomerEntity> = omitEmptyFields({
code: entity.code,
name: entity.name,
phone: entity.phone,
address: entity.address,
latitude: entity.latitude,
longitude: entity.longitude,
nfcId: entity.nfcId,
});
if (contacts.length > 0) {
return { ...payload, contacts };
}
return payload;
}
transformEditPayload(entity: Partial<CustomerEntity>): Partial<CustomerEntity> {
return {
code: entity.code,
name: entity.name,
phone: entity.phone,
address: entity.address,
latitude: emptyToNull(entity.latitude) as number | null,
longitude: emptyToNull(entity.longitude) as number | null,
nfcId: emptyToNull(entity.nfcId) as string | null,
};
}
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { createCustomerContactSchema, createCustomerSchema } from './customer.validator';
describe('createCustomerSchema', () => {
const t = (key: string) => key;
const schema = createCustomerSchema(t);
const valid = {
code: 'CUST_01',
name: 'Toko Maju',
phone: '+6281234567890',
address: 'Jl Gatot Subroto No 8',
};
it('accepts required customer fields', () => {
expect(schema.safeParse(valid).success).toBe(true);
});
it('rejects a missing address', () => {
expect(schema.safeParse({ ...valid, address: '' }).success).toBe(false);
});
it('accepts optional contacts on create', () => {
expect(
schema.safeParse({
...valid,
contacts: [{ name: 'Andi Pratama', jobTitle: 'Manager', phone: '+6281111111111' }],
}).success,
).toBe(true);
});
});
describe('createCustomerContactSchema', () => {
const t = (key: string) => key;
const schema = createCustomerContactSchema(t);
it('requires a contact name', () => {
expect(schema.safeParse({ name: '' }).success).toBe(false);
});
it('accepts a named contact without extra fields', () => {
expect(schema.safeParse({ name: 'Andi Pratama' }).success).toBe(true);
});
it('rejects notes longer than 255 characters', () => {
expect(schema.safeParse({ name: 'Andi Pratama', notes: 'A'.repeat(256) }).success).toBe(false);
});
});
@@ -0,0 +1,43 @@
import { z } from 'zod';
import {
CONTACT_NOTES_MAX,
configAddressSchema,
configCodeSchema,
configNameSchema,
configPhoneSchema,
optionalLatitudeSchema,
optionalLongitudeSchema,
optionalNfcIdSchema,
optionalPhoneSchema,
} from '../../../../../../../core/domain/configuration-field-validators';
import { compose, maxLength } from '@repo/ui/validators';
export const createCustomerContactSchema = (t: (key: string) => string) => {
return z.object({
id: z.string().optional(),
name: configNameSchema(t),
jobTitle: z.preprocess(
(value) => (value === '' ? undefined : value),
compose(z.string(), maxLength(64, t('common:fields.jobTitle'))).optional(),
),
phone: optionalPhoneSchema(),
mobilePhone: optionalPhoneSchema(),
notes: z.preprocess(
(value) => (value === '' ? undefined : value),
compose(z.string(), maxLength(CONTACT_NOTES_MAX, t('common:fields.notes'))).optional(),
),
});
};
export const createCustomerSchema = (t: (key: string) => string) => {
return z.object({
code: configCodeSchema(t),
name: configNameSchema(t),
phone: configPhoneSchema(t),
address: configAddressSchema(t),
nfcId: optionalNfcIdSchema(t),
latitude: optionalLatitudeSchema(),
longitude: optionalLongitudeSchema(),
contacts: z.array(createCustomerContactSchema(t)).optional(),
});
};
@@ -0,0 +1,169 @@
import { useMemo, useState } from 'react';
import {
ActionIcon,
Box,
Button,
FieldTextInput,
FieldTextarea,
Group,
Modal,
Paper,
SimpleGrid,
Table,
Text,
notifications,
} from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleConfigContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import { customersDataService } from '../../../domain/factories';
import { createCustomerContactSchema } from '../../../domain/validators/customer.validator';
import type { CustomerContactEntity, CustomerContactInput, CustomerEntity } from '../../../domain/entities';
export function DetailContacts() {
const { detailData, reload } = useDetailPageContext<CustomerEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const { privileges } = useEnterpriseModuleConfigContext();
const canEdit = privileges.ALLOW_EDIT;
const contacts = detailData?.contacts ?? [];
const [editor, setEditor] = useState<{ open: boolean; contact?: CustomerContactEntity }>({ open: false });
const validator = useMemo(() => createCustomerContactSchema(t), [t]);
const formControl = useForm({ resolver: zodResolver(validator) });
const openCreate = () => {
formControl.reset({ name: '', jobTitle: '', phone: '', mobilePhone: '', notes: '' });
setEditor({ open: true });
};
const openEdit = (contact: CustomerContactEntity) => {
formControl.reset({
name: contact.name,
jobTitle: contact.jobTitle ?? '',
phone: contact.phone ?? '',
mobilePhone: contact.mobilePhone ?? '',
notes: contact.notes ?? '',
});
setEditor({ open: true, contact });
};
const closeEditor = () => setEditor({ open: false });
const handleSave = formControl.handleSubmit(async (values) => {
if (!detailData?.id) return;
const payload: CustomerContactInput = {
name: values.name ?? '',
jobTitle: values.jobTitle,
phone: values.phone,
mobilePhone: values.mobilePhone,
notes: values.notes,
};
try {
if (editor.contact?.id) {
await customersDataService.updateContact(String(detailData.id), editor.contact.id, payload);
} else {
await customersDataService.createContact(String(detailData.id), payload);
}
closeEditor();
await reload();
} catch {
notifications.show({
title: t('common:error'),
message: t('save_contact_failed'),
color: 'red',
});
}
});
const handleDelete = async (contact: CustomerContactEntity) => {
if (!detailData?.id || !contact.id) return;
if (!window.confirm(t('confirm_delete_contact'))) return;
try {
await customersDataService.deleteContact(String(detailData.id), contact.id);
await reload();
} catch {
notifications.show({
title: t('common:error'),
message: t('delete_contact_failed'),
color: 'red',
});
}
};
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Group justify="space-between" mb="md">
<Text fw={600}>{t('section_contacts')}</Text>
{canEdit ? (
<Button variant="light" size="xs" leftSection={<Plus size={14} />} onClick={openCreate}>
{t('add_contact')}
</Button>
) : null}
</Group>
{contacts.length === 0 ? (
<Text size="sm" c="dimmed">
{t('empty_contacts')}
</Text>
) : (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('common:fields.name')}</Table.Th>
<Table.Th>{t('common:fields.jobTitle')}</Table.Th>
<Table.Th>{t('common:fields.phone')}</Table.Th>
<Table.Th>{t('common:fields.mobilePhone')}</Table.Th>
<Table.Th>{t('common:fields.notes')}</Table.Th>
{canEdit ? <Table.Th w={80} /> : null}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{contacts.map((contact) => (
<Table.Tr key={contact.id ?? contact.name}>
<Table.Td>{contact.name}</Table.Td>
<Table.Td>{contact.jobTitle ?? '-'}</Table.Td>
<Table.Td>{contact.phone ?? '-'}</Table.Td>
<Table.Td>{contact.mobilePhone ?? '-'}</Table.Td>
<Table.Td>{contact.notes ?? '-'}</Table.Td>
{canEdit ? (
<Table.Td>
<Group gap="xs" justify="flex-end">
<ActionIcon variant="subtle" onClick={() => openEdit(contact)} aria-label={t('edit_contact')}>
<Pencil size={16} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" onClick={() => handleDelete(contact)} aria-label={t('delete_contact')}>
<Trash2 size={16} />
</ActionIcon>
</Group>
</Table.Td>
) : null}
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Modal opened={editor.open} onClose={closeEditor} title={editor.contact ? t('edit_contact') : t('add_contact')}>
<form onSubmit={handleSave}>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput control={formControl.control} name="name" label={t('common:fields.name')} required />
<FieldTextInput control={formControl.control} name="jobTitle" label={t('common:fields.jobTitle')} />
<FieldTextInput control={formControl.control} name="phone" label={t('common:fields.phone')} />
<FieldTextInput control={formControl.control} name="mobilePhone" label={t('common:fields.mobilePhone')} />
</SimpleGrid>
<Box mt="md">
<FieldTextarea control={formControl.control} name="notes" label={t('common:fields.notes')} minRows={2} />
</Box>
<Group justify="flex-end" mt="md">
<Button variant="default" type="button" onClick={closeEditor}>
{t('common:cancel')}
</Button>
<Button type="submit" loading={formControl.formState.isSubmitting}>
{t('common:save')}
</Button>
</Group>
</form>
</Modal>
</Paper>
);
}
@@ -0,0 +1,41 @@
import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { CustomerEntity } from '../../../domain/entities';
export function DetailGeneral() {
const { detailData } = useDetailPageContext<CustomerEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const data = detailData;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} 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.phone')} value={data?.phone} />
<FieldValue label={t('common:fields.nfcId')} value={data?.nfcId} />
<FieldValue label={t('common:fields.address')} value={data?.address} />
<FieldValue
label={t('common:fields.status')}
value={data?.status}
render={(val) => <StatusBadge status={String(val ?? '')} />}
/>
<FieldValue
label={t('common:fields.createdAt')}
value={data?.createdAt}
render={(val) => <RenderDate value={val as any} />}
/>
<FieldValue
label={t('common:fields.updatedAt')}
value={data?.updatedAt}
render={(val) => <RenderDate value={val as any} />}
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,23 @@
import { Box, Paper, SimpleGrid, FieldValue, Text } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { CustomerEntity } from '../../../domain/entities';
export function DetailLocation() {
const { detailData } = useDetailPageContext<CustomerEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const data = detailData;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_location')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
<FieldValue label={t('common:fields.latitude')} value={data?.latitude} />
<FieldValue label={t('common:fields.longitude')} value={data?.longitude} />
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,88 @@
import { ActionIcon, Box, Button, FieldTextInput, FieldTextarea, Group, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { useFieldArray } from '@repo/ui/form';
import { Plus, Trash2 } from 'lucide-react';
export function FormContacts() {
const { formControl, isCreate, isDuplicate } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext();
const { fields, append, remove } = useFieldArray({
control: formControl.control,
name: 'contacts',
});
if (!isCreate && !isDuplicate) {
return null;
}
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Group justify="space-between" mb="md">
<Text fw={600}>{t('section_contacts')}</Text>
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
onClick={() => append({ name: '', jobTitle: '', phone: '', mobilePhone: '', notes: '' })}
>
{t('add_contact')}
</Button>
</Group>
<Stack gap="md">
{fields.length === 0 ? (
<Text size="sm" c="dimmed">
{t('empty_contacts')}
</Text>
) : (
fields.map((field, index) => (
<Paper key={field.id} withBorder radius="md" p="md">
<Group justify="flex-end" mb="xs">
<ActionIcon variant="subtle" color="red" onClick={() => remove(index)} aria-label={t('delete_contact')}>
<Trash2 size={16} />
</ActionIcon>
</Group>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={formControl.control}
name={`contacts.${index}.name`}
label={t('common:fields.name')}
required
radius="md"
/>
<FieldTextInput
control={formControl.control}
name={`contacts.${index}.jobTitle`}
label={t('common:fields.jobTitle')}
radius="md"
/>
<FieldTextInput
control={formControl.control}
name={`contacts.${index}.phone`}
label={t('common:fields.phone')}
radius="md"
/>
<FieldTextInput
control={formControl.control}
name={`contacts.${index}.mobilePhone`}
label={t('common:fields.mobilePhone')}
radius="md"
/>
</SimpleGrid>
<Box mt="md">
<FieldTextarea
control={formControl.control}
name={`contacts.${index}.notes`}
label={t('common:fields.notes')}
minRows={2}
radius="md"
/>
</Box>
</Box>
</Paper>
))
)}
</Stack>
</Paper>
);
}
@@ -0,0 +1,61 @@
import { Box, FieldTextInput, FieldTextarea, Paper, SimpleGrid, Text } 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">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={formControl.control}
name="code"
label={t('common:fields.code')}
placeholder="e.g. CUST_01"
required
radius="md"
/>
<FieldTextInput
name="name"
control={formControl.control}
label={t('common:fields.name')}
placeholder="e.g. Toko Maju"
required
radius="md"
/>
<FieldTextInput
control={formControl.control}
name="phone"
label={t('common:fields.phone')}
placeholder="+6281234567890"
required
radius="md"
/>
<FieldTextInput
control={formControl.control}
name="nfcId"
label={t('common:fields.nfcId')}
placeholder="e.g. NFC-001"
radius="md"
/>
</SimpleGrid>
<Box mt="md">
<FieldTextarea
control={formControl.control}
name="address"
label={t('common:fields.address')}
placeholder="Street address"
required
minRows={3}
radius="md"
/>
</Box>
</Box>
</Paper>
);
}
@@ -0,0 +1,35 @@
import { Box, FieldNumberInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
export function FormLocation() {
const { formControl } = useFormPageContext();
const { t } = useEnterpriseModuleTranslationContext();
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_location')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldNumberInput
control={formControl.control}
name="latitude"
label={t('common:fields.latitude')}
placeholder="-6.2"
decimalScale={6}
radius="md"
/>
<FieldNumberInput
control={formControl.control}
name="longitude"
label={t('common:fields.longitude')}
placeholder="106.8"
decimalScale={6}
radius="md"
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,42 @@
import { SimpleGrid } from '@repo/ui/components';
import { FieldTextInput, FieldSelect } from '@repo/ui/form';
import { UseFormReturn } from 'react-hook-form';
import { statusFilterOptions } from '../../../../shared/status-filter-options';
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={form.control}
name="code"
label={t('common:fields.code')}
placeholder={`Enter ${t('common:fields.code')}`}
/>
<FieldTextInput
control={form.control}
name="name"
label={t('common:fields.name')}
placeholder={`Enter ${t('common:fields.name')}`}
/>
<FieldTextInput
control={form.control}
name="phone"
label={t('common:fields.phone')}
placeholder={`Enter ${t('common:fields.phone')}`}
/>
<FieldTextInput
control={form.control}
name="nfcId"
label={t('common:fields.nfcId')}
placeholder={`Enter ${t('common:fields.nfcId')}`}
/>
<FieldSelect
control={form.control}
name="status"
label={t('common:fields.status')}
clearable
data={statusFilterOptions(t)}
/>
</SimpleGrid>
);
};
@@ -0,0 +1,40 @@
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 { customersModuleConfig } from '../../domain/constants';
import { customersDataService } from '../../domain/factories';
import { CustomerEntity } from '../../domain/entities';
import { customersStore } from '../store';
import customersId from '../languages/id/customers.json';
import customersEn from '../languages/en/customers.json';
const IndexPage = lazy(() => import('../pages/customer.page.index'));
const FormPage = lazy(() => import('../pages/customer.page.form'));
const DetailPage = lazy(() => import('../pages/customer.page.detail'));
registerModuleNamespace(customersModuleConfig.translationNamespace, {
id: customersId,
en: customersEn,
});
export default function CustomersModule() {
return (
<EnterpriseModuleProvider<CustomerEntity>
config={customersModuleConfig}
dataServices={customersDataService}
store={customersStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
<Route path="/" element={<Navigate to={`${customersModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,25 @@
{
"title": "Customers",
"detail_page_title": "Customer Detail",
"create_page_title": "New Customer",
"edit_page_title": "Edit Customer",
"duplicate_page_title": "Duplicate Customer",
"description": "Manage customer master data, locations, and <1>contacts</1>.",
"detail_page_description": "Review customer identity, location, and contacts.",
"create_page_description": "Create a customer and optionally add contacts.",
"edit_page_description": "Update customer identity and location. Manage contacts from the detail page.",
"duplicate_page_description": "Copy an existing customer to create a new one.",
"section_general": "General",
"section_location": "Location",
"section_contacts": "Contacts",
"add_contact": "Add contact",
"edit_contact": "Edit contact",
"delete_contact": "Delete contact",
"empty_contacts": "No contacts yet.",
"confirm_delete_contact": "Delete this contact? This cannot be undone.",
"save_contact_failed": "Could not save the contact.",
"delete_contact_failed": "Could not delete the contact.",
"status_draft": "Draft",
"status_active": "Active",
"status_archived": "Archived"
}
@@ -0,0 +1,25 @@
{
"title": "Pelanggan",
"detail_page_title": "Detail Pelanggan",
"create_page_title": "Pelanggan Baru",
"edit_page_title": "Ubah Pelanggan",
"duplicate_page_title": "Duplikat Pelanggan",
"description": "Kelola data master pelanggan, lokasi, dan <1>kontak</1>.",
"detail_page_description": "Tinjau identitas, lokasi, dan kontak pelanggan.",
"create_page_description": "Buat pelanggan dan tambahkan kontak jika perlu.",
"edit_page_description": "Perbarui identitas dan lokasi pelanggan. Kelola kontak dari halaman detail.",
"duplicate_page_description": "Salin pelanggan yang ada untuk membuat data baru.",
"section_general": "Umum",
"section_location": "Lokasi",
"section_contacts": "Kontak",
"add_contact": "Tambah kontak",
"edit_contact": "Ubah kontak",
"delete_contact": "Hapus kontak",
"empty_contacts": "Belum ada kontak.",
"confirm_delete_contact": "Hapus kontak ini? Tindakan ini tidak dapat dibatalkan.",
"save_contact_failed": "Kontak tidak dapat disimpan.",
"delete_contact_failed": "Kontak tidak dapat dihapus.",
"status_draft": "Draft",
"status_active": "Aktif",
"status_archived": "Diarsipkan"
}
@@ -0,0 +1,30 @@
import { Stack } from '@repo/ui/components';
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { customersModuleConfig } from '../../domain/constants';
import { DetailGeneral } from '../components/detail-component/detail-general';
import { DetailLocation } from '../components/detail-component/detail-location';
import { DetailContacts } from '../components/detail-component/detail-contacts';
export default function CustomerPageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
return (
<EnterpriseDetailPageProvider
highlightDataKey="code"
pageHeaderProps={{
title: t('detail_page_title'),
description: t('detail_page_description'),
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-customers'), type: 'link', href: `${customersModuleConfig.webUrl}/index` },
],
}}
>
<Stack gap="md">
<DetailGeneral />
<DetailLocation />
<DetailContacts />
</Stack>
</EnterpriseDetailPageProvider>
);
}
@@ -0,0 +1,54 @@
import { useMemo } from 'react';
import { Stack } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { customersModuleConfig } from '../../domain/constants';
import { createCustomerSchema } from '../../domain/validators/customer.validator';
import { FormGeneral } from '../components/form-component/form-general';
import { FormLocation } from '../components/form-component/form-location';
import { FormContacts } from '../components/form-component/form-contacts';
export default function CustomerPageForm({ formPageType }: { formPageType: FormPageType }) {
const { t } = useEnterpriseModuleTranslationContext();
const title = useMemo(() => {
if (formPageType === 'CREATE') {
return { title: t('create_page_title'), description: t('create_page_description') };
}
if (formPageType === 'EDIT') {
return { title: t('edit_page_title'), description: t('edit_page_description') };
}
if (formPageType === 'DUPLICATE') {
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
}
return { title: '', description: '' };
}, [formPageType, t]);
const validator = useMemo(() => createCustomerSchema(t), [t]);
const formControl = useForm({ resolver: zodResolver(validator) });
return (
<EnterpriseFormPageProvider
formControl={formControl}
formPageType={formPageType}
ignoreKeyDuplicate={['code']}
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id', 'contacts']}
highlightDataKey="code"
pageHeaderProps={{
title: title?.title,
description: title?.description,
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-customers'), type: 'link', href: `${customersModuleConfig.webUrl}/index` },
],
}}
>
<Stack gap="md">
<FormGeneral />
<FormLocation />
<FormContacts />
</Stack>
</EnterpriseFormPageProvider>
);
}
@@ -0,0 +1,51 @@
import { useMemo } from 'react';
import {
EnterpriseIndexPageProvider,
useEnterpriseModuleTranslationContext,
EnterpriseDataTable,
} from '@repo/ui/foundations';
import { ColDef, Text } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n';
import { Users } from 'lucide-react';
import { FilterFormContent } from '../components/index-component/filter-content';
import type { CustomerEntity } from '../../domain/entities';
export default function CustomerPageIndex() {
const { t } = useEnterpriseModuleTranslationContext();
const columnDefs: ColDef<CustomerEntity>[] = useMemo(() => {
return [
{ field: 'code', headerName: t('common:fields.code'), minWidth: 140 },
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
{ field: 'phone', headerName: t('common:fields.phone'), minWidth: 160 },
{ field: 'address', headerName: t('common:fields.address'), minWidth: 240 },
];
}, [t]);
const filterConfig = useMemo(() => {
return {
renderBody: (form: any) => {
if (!form) return null;
return <FilterFormContent form={form} t={t} />;
},
};
}, [t]);
return (
<EnterpriseIndexPageProvider
pageHeaderProps={{
title: t('title'),
description: (
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
),
icon: Users,
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-customers'), type: 'text' },
],
}}
>
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
</EnterpriseIndexPageProvider>
);
}
@@ -0,0 +1,22 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import { CustomerEntity } from '../../domain/entities';
export interface CustomersStoreState extends EnterpriseModuleState<CustomerEntity> {}
export const customersStore = create<CustomersStoreState>((set) => ({
metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }),
filterData: {},
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
tableConfig: null,
setTableConfig: (config) => set({ tableConfig: config }),
}));
@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosInstance } from '@repo/core-api/http-client';
import { DivisionsRemoteDataServices } from './division.remote.service';
import { DivisionsRemoteDataTransformer } from '../domain/transformers/division.remote.transformer';
function createMockHttpClient(): AxiosInstance {
return {
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
defaults: {} as AxiosInstance['defaults'],
interceptors: {
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
},
getUri: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
head: vi.fn(),
options: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
postForm: vi.fn(),
putForm: vi.fn(),
patchForm: vi.fn(),
} as unknown as AxiosInstance;
}
const unwrappedDetail = {
id: 'div-1',
name: 'Sales Division',
code: 'SALES_DIV',
status: 'active',
createdAt: 1,
updatedAt: 2,
createdBy: 'u1',
updatedBy: 'u2',
};
describe('DivisionsRemoteDataServices', () => {
let httpClient: AxiosInstance;
let service: DivisionsRemoteDataServices;
beforeEach(() => {
httpClient = createMockHttpClient();
service = new DivisionsRemoteDataServices(httpClient, {
apiUrl: '/divisions',
moduleKey: 'CONFIGURATION.DIVISION',
transformer: new DivisionsRemoteDataTransformer(),
});
});
it('wraps an unwrapped getOne body as { data: entity }', async () => {
vi.mocked(httpClient.request).mockResolvedValueOnce({ data: unwrappedDetail, status: 200 });
const result = await service.getOne('div-1');
expect(result.data).toEqual({
data: expect.objectContaining({ id: 'div-1', code: 'SALES_DIV' }),
});
});
it('uses PATCH when editing', async () => {
await service.edit('div-1', { name: 'Sales Division', code: 'SALES_DIV' });
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({ url: '/divisions/div-1', method: 'PATCH' }),
);
});
it('activates via PATCH /divisions/:id/status', async () => {
await service.activate('div-1');
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/divisions/div-1/status',
method: 'PATCH',
data: { status: 'active' },
}),
);
});
});
@@ -0,0 +1,13 @@
import type { AxiosInstance } from '@repo/core-api/http-client';
import type { DataServicesConfig } from '@repo/core-api/data-services';
import { TrackGoRemoteDataServices } from '../../../../../../core/lib/trackgo-remote-data-services';
import type { DivisionEntity } from '../domain/entities';
export class DivisionsRemoteDataServices extends TrackGoRemoteDataServices<DivisionEntity> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<DivisionEntity>) {
super(httpClient, {
...config,
apiUrl: config.apiUrl ?? '/divisions',
});
}
}
@@ -0,0 +1,11 @@
import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { DivisionEntity } from '../entities';
export const divisionsModuleConfig: ModuleConfigEntity<DivisionEntity> = {
moduleKey: 'CONFIGURATION.DIVISION',
translationNamespace: 'DIVISIONS',
apiUrl: '/divisions',
webUrl: '/app/configuration/divisions',
moduleCategory: 'FULL_PAGE',
moduleType: 'MASTER_DATA',
} as const;
@@ -0,0 +1 @@
export * from './division.constants';
@@ -0,0 +1,24 @@
import { BaseEntity } from '@repo/core-api/data-services';
export type ConfigurationStatus = 'draft' | 'active' | 'archived';
export interface DivisionEntity extends BaseEntity {
name: string;
code: string;
status?: ConfigurationStatus;
createdAt?: number;
updatedAt?: number;
createdBy?: string;
updatedBy?: string;
}
export interface DivisionDto {
id?: string;
name: string;
code: string;
status?: ConfigurationStatus;
createdAt?: number;
updatedAt?: number;
createdBy?: string;
updatedBy?: string;
}
@@ -0,0 +1 @@
export * from './division.entity';
@@ -0,0 +1,12 @@
import { apiClient } from '../../../../../../../core/lib/api-client';
import { DivisionsRemoteDataServices } from '../../data/division.remote.service';
import { divisionsModuleConfig } from '../constants/division.constants';
import { DivisionsRemoteDataTransformer } from '../transformers/division.remote.transformer';
export const divisionsDataTransformer = new DivisionsRemoteDataTransformer();
export const divisionsDataService = new DivisionsRemoteDataServices(apiClient, {
apiUrl: divisionsModuleConfig.apiUrl,
moduleKey: divisionsModuleConfig.moduleKey,
transformer: divisionsDataTransformer,
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { DivisionsRemoteDataTransformer } from './division.remote.transformer';
import type { DivisionEntity } from '../entities';
const transformer = new DivisionsRemoteDataTransformer();
const dto = {
id: 'div-1',
name: 'Sales Division',
code: 'SALES_DIV',
status: 'active' as const,
createdAt: 1,
updatedAt: 2,
createdBy: 'u1',
updatedBy: 'u2',
};
describe('DivisionsRemoteDataTransformer', () => {
it('maps dto fields onto the entity', () => {
const entity = transformer.transformToEntity(dto);
expect(entity).toMatchObject({
id: 'div-1',
name: 'Sales Division',
code: 'SALES_DIV',
status: 'active',
});
});
it('builds create payload with name and code only', () => {
const entity: DivisionEntity = {
...dto,
status: 'draft',
};
const payload = transformer.transformCreatePayload(entity);
expect(payload).toEqual({ name: 'Sales Division', code: 'SALES_DIV' });
expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('id');
});
it('builds edit payload without status or audit fields', () => {
const payload = transformer.transformEditPayload(dto);
expect(payload).toEqual({ name: 'Sales Division', code: 'SALES_DIV' });
expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('id');
});
});
@@ -0,0 +1,35 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import type { DivisionDto, DivisionEntity } from '../entities';
export class DivisionsRemoteDataTransformer extends BaseDataTransformer<DivisionEntity> {
transformToEntity(dto: DivisionDto | DivisionEntity): DivisionEntity {
return {
id: dto.id,
name: dto.name,
code: dto.code,
status: dto.status,
createdAt: dto.createdAt,
updatedAt: dto.updatedAt,
createdBy: dto.createdBy,
updatedBy: dto.updatedBy,
};
}
transformToDTO(entity: DivisionEntity): DivisionEntity {
return { ...entity };
}
transformCreatePayload(entity: Partial<DivisionEntity>): Partial<DivisionEntity> {
return {
name: entity.name,
code: entity.code,
};
}
transformEditPayload(entity: Partial<DivisionEntity>): Partial<DivisionEntity> {
return {
name: entity.name,
code: entity.code,
};
}
}
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { createDivisionSchema } from './division.validator';
describe('createDivisionSchema', () => {
const t = (key: string) => key;
const schema = createDivisionSchema(t);
it('rejects empty name and code', () => {
expect(schema.safeParse({ name: '', code: '' }).success).toBe(false);
});
it('rejects a code with spaces', () => {
expect(schema.safeParse({ name: 'Sales Division', code: 'SALES DIV' }).success).toBe(false);
});
it('rejects a name with digits', () => {
expect(schema.safeParse({ name: 'Sales 1', code: 'SALES' }).success).toBe(false);
});
it('accepts a valid name and code', () => {
expect(schema.safeParse({ name: 'Sales Division', code: 'SALES_DIV' }).success).toBe(true);
});
});
@@ -0,0 +1,9 @@
import { z } from 'zod';
import { configCodeSchema, configNameSchema } from '../../../../../../../core/domain/configuration-field-validators';
export const createDivisionSchema = (t: (key: string) => string) => {
return z.object({
name: configNameSchema(t),
code: configCodeSchema(t),
});
};
@@ -0,0 +1,38 @@
import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { DivisionEntity } from '../../../domain/entities';
export function DetailGeneral() {
const { detailData } = useDetailPageContext<DivisionEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const data = detailData;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} 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.status')}
value={data?.status}
render={(val) => <StatusBadge status={String(val ?? '')} />}
/>
<FieldValue
label={t('common:fields.createdAt')}
value={data?.createdAt}
render={(val) => <RenderDate value={val as any} />}
/>
<FieldValue
label={t('common:fields.updatedAt')}
value={data?.updatedAt}
render={(val) => <RenderDate value={val as any} />}
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,35 @@
import { Box, FieldTextInput, Paper, SimpleGrid, Text } 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">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={formControl.control}
name="code"
label={t('common:fields.code')}
placeholder="e.g. SALES_DIV"
required
radius="md"
/>
<FieldTextInput
name="name"
control={formControl.control}
label={t('common:fields.name')}
placeholder="e.g. Sales Division"
required
radius="md"
/>
</SimpleGrid>
</Box>
</Paper>
);
}
@@ -0,0 +1,33 @@
import { SimpleGrid } from '@repo/ui/components';
import { FieldTextInput, FieldSelect } from '@repo/ui/form';
import { UseFormReturn } from 'react-hook-form';
export const FilterFormContent = ({ form, t }: { form: UseFormReturn<any>; t: (key: string) => string }) => {
return (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldTextInput
control={form.control}
name="code"
label={t('common:fields.code')}
placeholder={`Enter ${t('common:fields.code')}`}
/>
<FieldTextInput
control={form.control}
name="name"
label={t('common:fields.name')}
placeholder={`Enter ${t('common:fields.name')}`}
/>
<FieldSelect
control={form.control}
name="status"
label={t('common:fields.status')}
clearable
data={[
{ value: 'draft', label: t('status_draft') },
{ value: 'active', label: t('status_active') },
{ value: 'archived', label: t('status_archived') },
]}
/>
</SimpleGrid>
);
};
@@ -0,0 +1,40 @@
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 { divisionsModuleConfig } from '../../domain/constants';
import { divisionsDataService } from '../../domain/factories';
import { DivisionEntity } from '../../domain/entities';
import { divisionsStore } from '../store';
import divisionsId from '../languages/id/divisions.json';
import divisionsEn from '../languages/en/divisions.json';
const IndexPage = lazy(() => import('../pages/division.page.index'));
const FormPage = lazy(() => import('../pages/division.page.form'));
const DetailPage = lazy(() => import('../pages/division.page.detail'));
registerModuleNamespace(divisionsModuleConfig.translationNamespace, {
id: divisionsId,
en: divisionsEn,
});
export default function DivisionsModule() {
return (
<EnterpriseModuleProvider<DivisionEntity>
config={divisionsModuleConfig}
dataServices={divisionsDataService}
store={divisionsStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/detail/:dataId" element={<DetailPage />} />
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
<Route path="/" element={<Navigate to={`${divisionsModuleConfig.webUrl}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,16 @@
{
"title": "Divisions",
"detail_page_title": "Division Detail",
"create_page_title": "New Division",
"edit_page_title": "Edit Division",
"duplicate_page_title": "Duplicate Division",
"description": "Manage organizational <1>divisions</1> used across branches and transactions.",
"detail_page_description": "Review the division profile and its current status.",
"create_page_description": "Create a division with a unique code and display name.",
"edit_page_description": "Update the division name and code.",
"duplicate_page_description": "Copy an existing division to create a new one.",
"section_general": "General",
"status_draft": "Draft",
"status_active": "Active",
"status_archived": "Archived"
}
@@ -0,0 +1,16 @@
{
"title": "Divisi",
"detail_page_title": "Detail Divisi",
"create_page_title": "Divisi Baru",
"edit_page_title": "Ubah Divisi",
"duplicate_page_title": "Duplikat Divisi",
"description": "Kelola <1>divisi</1> organisasi yang dipakai di cabang dan transaksi.",
"detail_page_description": "Tinjau profil divisi dan statusnya.",
"create_page_description": "Buat divisi dengan kode unik dan nama tampilan.",
"edit_page_description": "Perbarui nama dan kode divisi.",
"duplicate_page_description": "Salin divisi yang ada untuk membuat data baru.",
"section_general": "Umum",
"status_draft": "Draft",
"status_active": "Aktif",
"status_archived": "Diarsipkan"
}
@@ -0,0 +1,23 @@
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { divisionsModuleConfig } from '../../domain/constants';
import { DetailGeneral } from '../components/detail-component/detail-general';
export default function DivisionPageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
return (
<EnterpriseDetailPageProvider
highlightDataKey="code"
pageHeaderProps={{
title: t('detail_page_title'),
description: t('detail_page_description'),
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-divisions'), type: 'link', href: `${divisionsModuleConfig.webUrl}/index` },
],
}}
>
<DetailGeneral />
</EnterpriseDetailPageProvider>
);
}
@@ -0,0 +1,47 @@
import { useMemo } from 'react';
import { useEnterpriseModuleTranslationContext, EnterpriseFormPageProvider, FormPageType } from '@repo/ui/foundations';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { divisionsModuleConfig } from '../../domain/constants';
import { createDivisionSchema } from '../../domain/validators/division.validator';
import { FormGeneral } from '../components/form-component/form-general';
export default function DivisionPageForm({ formPageType }: { formPageType: FormPageType }) {
const { t } = useEnterpriseModuleTranslationContext();
const title = useMemo(() => {
if (formPageType === 'CREATE') {
return { title: t('create_page_title'), description: t('create_page_description') };
}
if (formPageType === 'EDIT') {
return { title: t('edit_page_title'), description: t('edit_page_description') };
}
if (formPageType === 'DUPLICATE') {
return { title: t('duplicate_page_title'), description: t('duplicate_page_description') };
}
return { title: '', description: '' };
}, [formPageType, t]);
const validator = useMemo(() => createDivisionSchema(t), [t]);
const formControl = useForm({ resolver: zodResolver(validator) });
return (
<EnterpriseFormPageProvider
formControl={formControl}
formPageType={formPageType}
ignoreKeyDuplicate={['code']}
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id']}
highlightDataKey="code"
pageHeaderProps={{
title: title?.title,
description: title?.description,
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-divisions'), type: 'link', href: `${divisionsModuleConfig.webUrl}/index` },
],
}}
>
<FormGeneral />
</EnterpriseFormPageProvider>
);
}
@@ -0,0 +1,49 @@
import { useMemo } from 'react';
import {
EnterpriseIndexPageProvider,
useEnterpriseModuleTranslationContext,
EnterpriseDataTable,
} from '@repo/ui/foundations';
import { ColDef, Text } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n';
import { Layers } from 'lucide-react';
import { FilterFormContent } from '../components/index-component/filter-content';
import type { DivisionEntity } from '../../domain/entities';
export default function DivisionPageIndex() {
const { t } = useEnterpriseModuleTranslationContext();
const columnDefs: ColDef<DivisionEntity>[] = useMemo(() => {
return [
{ field: 'code', headerName: t('common:fields.code'), minWidth: 160 },
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
];
}, [t]);
const filterConfig = useMemo(() => {
return {
renderBody: (form: any) => {
if (!form) return null;
return <FilterFormContent form={form} t={t} />;
},
};
}, [t]);
return (
<EnterpriseIndexPageProvider
pageHeaderProps={{
title: t('title'),
description: (
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
),
icon: Layers,
breadcrumbs: [
{ label: t('nav:configuration'), type: 'text' },
{ label: t('nav:configuration-divisions'), type: 'text' },
],
}}
>
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
</EnterpriseIndexPageProvider>
);
}
@@ -0,0 +1,22 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import { DivisionEntity } from '../../domain/entities';
export interface DivisionsStoreState extends EnterpriseModuleState<DivisionEntity> {}
export const divisionsStore = create<DivisionsStoreState>((set) => ({
metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }),
filterData: {},
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
tableConfig: null,
setTableConfig: (config) => set({ tableConfig: config }),
}));
@@ -0,0 +1,17 @@
import { lazy } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
const DivisionsModule = lazy(() => import('./divisions/presentation/factory'));
const BranchesModule = lazy(() => import('./branches/presentation/factory'));
const CustomersModule = lazy(() => import('./customers/presentation/factory'));
export default function ConfigurationModule() {
return (
<Routes>
<Route path="/divisions/*" element={<DivisionsModule />} />
<Route path="/branches/*" element={<BranchesModule />} />
<Route path="/customers/*" element={<CustomersModule />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
);
}
@@ -0,0 +1,12 @@
import type { LoadOptionsFn } from '@repo/ui/form';
import { divisionsDataService } from '../divisions/domain/factories';
import type { DivisionEntity } from '../divisions/domain/entities';
export const loadDivisionOptions: LoadOptionsFn<DivisionEntity> = async (search, page) => {
const result = await divisionsDataService.getMany({
params: { search, page, limit: 20 },
});
const rows = (result.data as { data?: DivisionEntity[]; meta?: { totalPages?: number } })?.data ?? [];
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
return { options: rows, hasMore: page < totalPages };
};
@@ -0,0 +1,7 @@
export function statusFilterOptions(t: (key: string) => string) {
return [
{ value: 'draft', label: t('status_draft') },
{ value: 'active', label: t('status_active') },
{ value: 'archived', label: t('status_archived') },
];
}
@@ -0,0 +1,8 @@
import { WEEKDAYS } from '../../../../../core/domain/configuration-field-validators';
export function weekdaySelectOptions(t: (key: string) => string) {
return WEEKDAYS.map((value) => ({
value,
label: t(`weekday_${value}`),
}));
}
@@ -1,82 +1,15 @@
import type { AxiosInstance, AxiosRequestConfig } from '@repo/core-api/http-client';
import { BaseRemoteDataServices, DESCRIPTORS, type DataServicesConfig } from '@repo/core-api/data-services';
import type { AxiosInstance } from '@repo/core-api/http-client';
import type { DataServicesConfig } from '@repo/core-api/data-services';
import type { ApiResponse } from '@repo/core-api/http-client';
import { TrackGoRemoteDataServices } from '../../../../../../core/lib/trackgo-remote-data-services';
import { mapPrivilegeKey } from '../domain/transformers/privilege.remote.transformer';
import type { PrivilegeDto, PrivilegeEntity, PrivilegeKeyDto, PrivilegeKeyEntity } from '../domain/entities';
import type { PrivilegeEntity, PrivilegeKeyDto, PrivilegeKeyEntity } from '../domain/entities';
function unwrapPrivilegeDetail(raw: unknown): PrivilegeDto {
if (raw && typeof raw === 'object' && 'id' in raw && 'code' in raw && 'name' in raw) {
return raw as PrivilegeDto;
}
if (raw && typeof raw === 'object' && 'data' in raw) {
const nested = (raw as { data: unknown }).data;
if (nested && typeof nested === 'object' && 'id' in nested) {
return nested as PrivilegeDto;
}
}
throw new Error('Unexpected privilege detail response');
}
export class PrivilegesRemoteDataServices extends BaseRemoteDataServices<PrivilegeEntity> {
export class PrivilegesRemoteDataServices extends TrackGoRemoteDataServices<PrivilegeEntity> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<PrivilegeEntity>) {
const apiUrl = config.apiUrl ?? '/privileges';
super(httpClient, {
...config,
urls: {
batchDeleteUrl: `${apiUrl}/bulk-delete`,
activateUrl: `${apiUrl}/:id/status`,
deactivateUrl: `${apiUrl}/:id/status`,
batchActivateUrl: `${apiUrl}/bulk-status`,
batchDeactivateUrl: `${apiUrl}/bulk-status`,
...config.urls,
},
methods: {
editMethod: 'PATCH',
batchDeleteMethod: 'POST',
batchActivateMethod: 'POST',
batchDeactivateMethod: 'POST',
...config.methods,
},
});
}
async getOne<T = { data: PrivilegeEntity }>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const result = await this.execute<PrivilegeDto | { data: PrivilegeDto }>(DESCRIPTORS.getOne, {
variableURL: { id },
config,
});
const dto = unwrapPrivilegeDetail(result.data);
const entity = this.transformer
? this.transformer.transformGetOneResponse
? this.transformer.transformGetOneResponse(dto as unknown as PrivilegeEntity)
: this.transformer.transformToEntity(dto as unknown as PrivilegeEntity)
: (dto as unknown as PrivilegeEntity);
return { ...result, data: { data: entity } as T };
}
activate(id: string, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
return this.execute<void>(DESCRIPTORS.activate, {
variableURL: { id },
config: { ...config, data: { status: 'active' } },
});
}
deactivate(id: string, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
return this.execute<void>(DESCRIPTORS.deactivate, {
variableURL: { id },
config: { ...config, data: { status: 'archived' } },
});
}
batchActivate(ids: Array<string | number>, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
return this.execute<void>(DESCRIPTORS.batchActivate, {
config: { ...config, data: { ids, status: 'active' } },
});
}
batchDeactivate(ids: Array<string | number>, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
return this.execute<void>(DESCRIPTORS.batchDeactivate, {
config: { ...config, data: { ids, status: 'archived' } },
apiUrl: config.apiUrl ?? '/privileges',
});
}
+3
View File
@@ -4,4 +4,7 @@ export const API_URL = {
AUTH_REFRESH: '/auth/refresh',
AUTH_REVOKE: '/auth/revoke',
AUTH_ME: '/auth/me',
DIVISIONS: '/divisions',
BRANCHES: '/branches',
CUSTOMERS: '/customers',
} as const;
+6 -1
View File
@@ -1 +1,6 @@
export const MODULE_KEY = {};
export const MODULE_KEY = {
PRIVILEGES: 'PRIVILEGES',
CONFIGURATION_DIVISION: 'CONFIGURATION.DIVISION',
CONFIGURATION_BRANCH: 'CONFIGURATION.BRANCH',
CONFIGURATION_CUSTOMER: 'CONFIGURATION.CUSTOMER',
} as const;
+5 -1
View File
@@ -1 +1,5 @@
export const WEB_URL = {};
export const WEB_URL = {
DIVISIONS: '/app/configuration/divisions',
BRANCHES: '/app/configuration/branches',
CUSTOMERS: '/app/configuration/customers',
} as const;
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import {
configAddressSchema,
configCodeSchema,
configNameSchema,
configPhoneSchema,
optionalLatitudeSchema,
optionalLongitudeSchema,
optionalNfcIdSchema,
weekdaySchema,
workingHoursSchema,
} from './configuration-field-validators';
const t = (key: string) => key;
describe('configuration field validators', () => {
describe('configCodeSchema', () => {
it('accepts alphanumeric underscore codes up to 16 characters', () => {
expect(configCodeSchema(t).safeParse('JKT_01').success).toBe(true);
});
it('rejects empty, spaced, or oversized codes', () => {
expect(configCodeSchema(t).safeParse('').success).toBe(false);
expect(configCodeSchema(t).safeParse('JKT 01').success).toBe(false);
expect(configCodeSchema(t).safeParse('A'.repeat(17)).success).toBe(false);
});
});
describe('configNameSchema', () => {
it('accepts letters with single spaces up to 64 characters', () => {
expect(configNameSchema(t).safeParse('Jakarta Pusat').success).toBe(true);
});
it('rejects empty, double-spaced, numeric, or oversized names', () => {
expect(configNameSchema(t).safeParse('').success).toBe(false);
expect(configNameSchema(t).safeParse('Jakarta Pusat').success).toBe(false);
expect(configNameSchema(t).safeParse('Jakarta1').success).toBe(false);
expect(configNameSchema(t).safeParse('A'.repeat(65)).success).toBe(false);
});
});
describe('configPhoneSchema', () => {
it('accepts E.164 Indonesian numbers', () => {
expect(configPhoneSchema(t).safeParse('+6281234567890').success).toBe(true);
});
it('rejects local-format phones', () => {
expect(configPhoneSchema(t).safeParse('081234567890').success).toBe(false);
});
});
describe('configAddressSchema', () => {
it('accepts addresses up to 255 characters', () => {
expect(configAddressSchema(t).safeParse('Jl Sudirman No 1').success).toBe(true);
});
it('rejects empty or oversized addresses', () => {
expect(configAddressSchema(t).safeParse('').success).toBe(false);
expect(configAddressSchema(t).safeParse('A'.repeat(256)).success).toBe(false);
});
});
describe('weekdaySchema', () => {
it('accepts monday through sunday', () => {
expect(weekdaySchema(t).safeParse('monday').success).toBe(true);
expect(weekdaySchema(t).safeParse('sunday').success).toBe(true);
});
it('rejects unknown weekdays', () => {
expect(weekdaySchema(t).safeParse('mon').success).toBe(false);
});
});
describe('workingHoursSchema', () => {
it('accepts 24-hour HH:mm', () => {
expect(workingHoursSchema(t).safeParse('08:00').success).toBe(true);
expect(workingHoursSchema(t).safeParse('17:30').success).toBe(true);
});
it('rejects invalid hours', () => {
expect(workingHoursSchema(t).safeParse('8:00').success).toBe(false);
expect(workingHoursSchema(t).safeParse('24:00').success).toBe(false);
});
});
describe('optional coordinates and nfc', () => {
it('accepts empty optional coordinates', () => {
expect(optionalLatitudeSchema().safeParse(undefined).success).toBe(true);
expect(optionalLatitudeSchema().safeParse('').success).toBe(true);
expect(optionalLongitudeSchema().safeParse('').success).toBe(true);
});
it('accepts valid latitude and longitude', () => {
expect(optionalLatitudeSchema().safeParse(-6.2).success).toBe(true);
expect(optionalLongitudeSchema().safeParse(106.8).success).toBe(true);
});
it('rejects out-of-range coordinates', () => {
expect(optionalLatitudeSchema().safeParse(-91).success).toBe(false);
expect(optionalLongitudeSchema().safeParse(181).success).toBe(false);
});
it('accepts optional nfc ids up to 64 characters', () => {
expect(optionalNfcIdSchema(t).safeParse('NFC-001').success).toBe(true);
expect(optionalNfcIdSchema(t).safeParse('').success).toBe(true);
expect(optionalNfcIdSchema(t).safeParse('A'.repeat(65)).success).toBe(false);
});
});
});
@@ -0,0 +1,125 @@
import { z } from 'zod';
import { compose, required, maxLength, phoneValidator, minValue, maxValue } from '@repo/ui/validators';
export const CONFIG_CODE_MAX = 16;
export const CONFIG_NAME_MAX = 64;
export const CONFIG_ADDRESS_MAX = 255;
export const CONFIG_NFC_ID_MAX = 64;
export const CONTACT_NOTES_MAX = 255;
export const WEEKDAYS = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] as const;
export type Weekday = (typeof WEEKDAYS)[number];
const CODE_PATTERN = /^[A-Za-z0-9_]+$/;
const NAME_PATTERN = /^[A-Za-z]+(?: [A-Za-z]+)*$/;
const HOURS_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
function emptyToUndefined(value: unknown) {
if (value === '' || value === null || value === undefined) {
return undefined;
}
return value;
}
function emptyToUndefinedNumber(value: unknown) {
if (value === '' || value === null || value === undefined) {
return undefined;
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
}
export function configCodeSchema(t: (key: string) => string, max = CONFIG_CODE_MAX) {
return compose(
z.string(),
required(t('common:fields.code')),
maxLength(max, t('common:fields.code')),
).regex(CODE_PATTERN, {
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t('common:fields.code') } }),
});
}
export function configNameSchema(t: (key: string) => string) {
return compose(
z.string(),
required(t('common:fields.name')),
maxLength(CONFIG_NAME_MAX, t('common:fields.name')),
).regex(NAME_PATTERN, {
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t('common:fields.name') } }),
});
}
export function configPhoneSchema(t: (key: string) => string) {
return compose(z.string(), required(t('common:fields.phone')), phoneValidator());
}
export function optionalPhoneSchema() {
return z.preprocess(
emptyToUndefined,
z
.string()
.regex(/^\+62\d{8,13}$/, {
message: JSON.stringify({ key: 'validation:invalid_phone' }),
})
.optional(),
);
}
export function configAddressSchema(t: (key: string) => string) {
return compose(z.string(), required(t('common:fields.address')), maxLength(CONFIG_ADDRESS_MAX, t('common:fields.address')));
}
export function weekdaySchema(t: (key: string) => string) {
return z.enum(WEEKDAYS, {
required_error: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.weekday') } }),
invalid_type_error: JSON.stringify({ key: 'validation:invalid_format', values: { field: t('common:fields.weekday') } }),
});
}
export function workingHoursSchema(t: (key: string) => string) {
return compose(z.string(), required(t('common:fields.workingHours'))).regex(HOURS_PATTERN, {
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t('common:fields.workingHours') } }),
});
}
export function optionalNfcIdSchema(t: (key: string) => string) {
return z.preprocess(emptyToUndefined, compose(z.string(), maxLength(CONFIG_NFC_ID_MAX, t('common:fields.nfcId'))).optional());
}
export function optionalLatitudeSchema() {
return z.preprocess(
emptyToUndefinedNumber,
compose(
z.number({ invalid_type_error: JSON.stringify({ key: 'validation:invalid_format', values: { field: 'latitude' } }) }),
minValue(-90, 'latitude'),
maxValue(90, 'latitude'),
).optional(),
);
}
export function optionalLongitudeSchema() {
return z.preprocess(
emptyToUndefinedNumber,
compose(
z.number({ invalid_type_error: JSON.stringify({ key: 'validation:invalid_format', values: { field: 'longitude' } }) }),
minValue(-180, 'longitude'),
maxValue(180, 'longitude'),
).optional(),
);
}
export function omitEmptyFields<T extends Record<string, unknown>>(payload: T): Partial<T> {
return Object.fromEntries(
Object.entries(payload).filter(([, value]) => value !== undefined && value !== ''),
) as Partial<T>;
}
export function emptyToNull(value: unknown) {
if (value === '' || value === undefined) {
return null;
}
return value;
}
@@ -0,0 +1,132 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AxiosInstance } from '@repo/core-api/http-client';
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { TrackGoRemoteDataServices } from './trackgo-remote-data-services';
interface SampleEntity {
id?: string;
name: string;
code: string;
}
class SampleTransformer extends BaseDataTransformer<SampleEntity> {
transformToEntity(dto: SampleEntity): SampleEntity {
return { ...dto };
}
}
class SampleRemoteDataServices extends TrackGoRemoteDataServices<SampleEntity> {}
function createMockHttpClient(): AxiosInstance {
return {
request: vi.fn().mockResolvedValue({ data: {}, status: 200 }),
defaults: {} as AxiosInstance['defaults'],
interceptors: {
request: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
response: { use: vi.fn(), eject: vi.fn(), clear: vi.fn() },
},
getUri: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
head: vi.fn(),
options: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
postForm: vi.fn(),
putForm: vi.fn(),
patchForm: vi.fn(),
} as unknown as AxiosInstance;
}
const unwrappedDetail = {
id: 'row-1',
name: 'Jakarta',
code: 'JKT',
};
describe('TrackGoRemoteDataServices', () => {
let httpClient: AxiosInstance;
let service: SampleRemoteDataServices;
beforeEach(() => {
httpClient = createMockHttpClient();
service = new SampleRemoteDataServices(httpClient, {
apiUrl: '/divisions',
moduleKey: 'CONFIGURATION.DIVISION',
transformer: new SampleTransformer(),
});
});
it('wraps an unwrapped getOne body as { data: entity }', async () => {
vi.mocked(httpClient.request).mockResolvedValueOnce({ data: unwrappedDetail, status: 200 });
const result = await service.getOne('row-1');
expect(result.data).toEqual({
data: expect.objectContaining({ id: 'row-1', code: 'JKT' }),
});
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({ url: '/divisions/row-1', method: 'GET' }),
);
});
it('uses PATCH when editing', async () => {
await service.edit('row-1', { name: 'Jakarta', code: 'JKT' });
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/divisions/row-1',
method: 'PATCH',
}),
);
});
it('activates via PATCH /:id/status with status active', async () => {
await service.activate('row-1');
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/divisions/row-1/status',
method: 'PATCH',
data: { status: 'active' },
}),
);
});
it('deactivates via PATCH /:id/status with status archived', async () => {
await service.deactivate('row-1');
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/divisions/row-1/status',
method: 'PATCH',
data: { status: 'archived' },
}),
);
});
it('bulk-deletes via POST /bulk-delete', async () => {
await service.batchDelete(['row-1', 'row-2']);
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/divisions/bulk-delete',
method: 'POST',
data: expect.objectContaining({ ids: ['row-1', 'row-2'] }),
}),
);
});
it('bulk-activates via POST /bulk-status', async () => {
await service.batchActivate(['row-1']);
expect(httpClient.request).toHaveBeenCalledWith(
expect.objectContaining({
url: '/divisions/bulk-status',
method: 'POST',
data: { ids: ['row-1'], status: 'active' },
}),
);
});
});
@@ -0,0 +1,81 @@
import type { AxiosInstance, AxiosRequestConfig } from '@repo/core-api/http-client';
import { BaseRemoteDataServices, DESCRIPTORS, type DataServicesConfig } from '@repo/core-api/data-services';
import type { ApiResponse } from '@repo/core-api/http-client';
import type { BaseEntity } from '@repo/core-api/data-services';
export function unwrapBareDetail<T>(raw: unknown): T {
if (raw && typeof raw === 'object' && 'id' in raw) {
return raw as T;
}
if (raw && typeof raw === 'object' && 'data' in raw) {
const nested = (raw as { data: unknown }).data;
if (nested && typeof nested === 'object' && 'id' in nested) {
return nested as T;
}
}
throw new Error('Unexpected detail response');
}
export class TrackGoRemoteDataServices<E extends BaseEntity> extends BaseRemoteDataServices<E> {
constructor(httpClient: AxiosInstance, config: DataServicesConfig<E>) {
const apiUrl = config.apiUrl ?? '';
super(httpClient, {
...config,
urls: {
batchDeleteUrl: `${apiUrl}/bulk-delete`,
activateUrl: `${apiUrl}/:id/status`,
deactivateUrl: `${apiUrl}/:id/status`,
batchActivateUrl: `${apiUrl}/bulk-status`,
batchDeactivateUrl: `${apiUrl}/bulk-status`,
...config.urls,
},
methods: {
editMethod: 'PATCH',
batchDeleteMethod: 'POST',
batchActivateMethod: 'POST',
batchDeactivateMethod: 'POST',
...config.methods,
},
});
}
async getOne<T = { data: E }>(id: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
const result = await this.execute<E | { data: E }>(DESCRIPTORS.getOne, {
variableURL: { id },
config,
});
const dto = unwrapBareDetail<E>(result.data);
const entity = this.transformer
? this.transformer.transformGetOneResponse
? this.transformer.transformGetOneResponse(dto)
: this.transformer.transformToEntity(dto)
: dto;
return { ...result, data: { data: entity } as T };
}
activate(id: string, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
return this.execute<void>(DESCRIPTORS.activate, {
variableURL: { id },
config: { ...config, data: { status: 'active' } },
});
}
deactivate(id: string, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
return this.execute<void>(DESCRIPTORS.deactivate, {
variableURL: { id },
config: { ...config, data: { status: 'archived' } },
});
}
batchActivate(ids: Array<string | number>, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
return this.execute<void>(DESCRIPTORS.batchActivate, {
config: { ...config, data: { ids, status: 'active' } },
});
}
batchDeactivate(ids: Array<string | number>, _meta?: Record<string, unknown>, config?: AxiosRequestConfig) {
return this.execute<void>(DESCRIPTORS.batchDeactivate, {
config: { ...config, data: { ids, status: 'archived' } },
});
}
}