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:
@@ -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"
|
||||
}
|
||||
|
||||
+59
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
+11
@@ -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';
|
||||
+46
@@ -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,
|
||||
});
|
||||
+74
@@ -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' });
|
||||
});
|
||||
});
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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);
|
||||
});
|
||||
});
|
||||
+36
@@ -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),
|
||||
});
|
||||
};
|
||||
+41
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
@@ -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>
|
||||
);
|
||||
}
|
||||
+31
@@ -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>
|
||||
);
|
||||
}
|
||||
+61
@@ -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>
|
||||
);
|
||||
}
|
||||
+64
@@ -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>
|
||||
);
|
||||
}
|
||||
+53
@@ -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>
|
||||
);
|
||||
}
|
||||
+50
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+30
@@ -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>
|
||||
);
|
||||
}
|
||||
+54
@@ -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>
|
||||
);
|
||||
}
|
||||
+51
@@ -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 }),
|
||||
}));
|
||||
+91
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+64
@@ -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 } };
|
||||
}
|
||||
}
|
||||
+11
@@ -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';
|
||||
+52
@@ -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,
|
||||
});
|
||||
+46
@@ -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();
|
||||
});
|
||||
});
|
||||
+82
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+48
@@ -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);
|
||||
});
|
||||
});
|
||||
+43
@@ -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(),
|
||||
});
|
||||
};
|
||||
+169
@@ -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>
|
||||
);
|
||||
}
|
||||
+41
@@ -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>
|
||||
);
|
||||
}
|
||||
+23
@@ -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>
|
||||
);
|
||||
}
|
||||
+88
@@ -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>
|
||||
);
|
||||
}
|
||||
+61
@@ -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>
|
||||
);
|
||||
}
|
||||
+35
@@ -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>
|
||||
);
|
||||
}
|
||||
+42
@@ -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>
|
||||
);
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+30
@@ -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>
|
||||
);
|
||||
}
|
||||
+54
@@ -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>
|
||||
);
|
||||
}
|
||||
+51
@@ -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 }),
|
||||
}));
|
||||
+81
@@ -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' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+13
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
+11
@@ -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';
|
||||
+24
@@ -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,
|
||||
});
|
||||
+48
@@ -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');
|
||||
});
|
||||
});
|
||||
+35
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+23
@@ -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);
|
||||
});
|
||||
});
|
||||
+9
@@ -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),
|
||||
});
|
||||
};
|
||||
+38
@@ -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>
|
||||
);
|
||||
}
|
||||
+35
@@ -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>
|
||||
);
|
||||
}
|
||||
+33
@@ -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>
|
||||
);
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+23
@@ -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>
|
||||
);
|
||||
}
|
||||
+47
@@ -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>
|
||||
);
|
||||
}
|
||||
+49
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' } },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -185,6 +185,18 @@
|
||||
"address": "Address",
|
||||
"phone": "Phone Number",
|
||||
"email": "Email",
|
||||
"latitude": "Latitude",
|
||||
"longitude": "Longitude",
|
||||
"nfcId": "NFC ID",
|
||||
"division": "Division",
|
||||
"workingDaysStart": "Working Days Start",
|
||||
"workingDaysEnd": "Working Days End",
|
||||
"workingHoursStart": "Working Hours Start",
|
||||
"workingHoursEnd": "Working Hours End",
|
||||
"weekday": "Weekday",
|
||||
"workingHours": "Working Hours",
|
||||
"jobTitle": "Job Title",
|
||||
"mobilePhone": "Mobile Phone",
|
||||
"user": "User",
|
||||
"role": "Role",
|
||||
"notes": "Notes",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"must_be_positive": "{{field}} must be a positive number",
|
||||
"invalid_email": "Invalid email format",
|
||||
"invalid_phone": "Invalid phone number format",
|
||||
"invalid_format": "{{field}} format is invalid",
|
||||
"invalid_password_simple": "Password must be at least {{min}} characters",
|
||||
"invalid_password_complex": "Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character"
|
||||
}
|
||||
|
||||
@@ -185,6 +185,18 @@
|
||||
"address": "Alamat",
|
||||
"phone": "No. Telepon",
|
||||
"email": "Email",
|
||||
"latitude": "Latitude",
|
||||
"longitude": "Longitude",
|
||||
"nfcId": "ID NFC",
|
||||
"division": "Divisi",
|
||||
"workingDaysStart": "Hari Kerja Mulai",
|
||||
"workingDaysEnd": "Hari Kerja Selesai",
|
||||
"workingHoursStart": "Jam Kerja Mulai",
|
||||
"workingHoursEnd": "Jam Kerja Selesai",
|
||||
"weekday": "Hari",
|
||||
"workingHours": "Jam Kerja",
|
||||
"jobTitle": "Jabatan",
|
||||
"mobilePhone": "No. HP",
|
||||
"user": "Pengguna",
|
||||
"role": "Peran",
|
||||
"notes": "Catatan",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"must_be_positive": "{{field}} harus bernilai positif",
|
||||
"invalid_email": "Format email tidak valid",
|
||||
"invalid_phone": "Format nomor telepon tidak valid",
|
||||
"invalid_format": "Format {{field}} tidak valid",
|
||||
"invalid_password_simple": "Kata sandi minimal {{min}} karakter",
|
||||
"invalid_password_complex": "Kata sandi harus mengandung minimal 1 huruf besar, 1 huruf kecil, 1 angka, dan 1 karakter spesial"
|
||||
}
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ export function RowActionMenu({ data, onActionClick, statusKey = 'status', custo
|
||||
const isMasterData = moduleType === 'MASTER_DATA';
|
||||
|
||||
const isDataActive = status === 'active';
|
||||
const isDataInActive = status === 'inactive' || status === 'draft';
|
||||
const isDataInActive = status === 'inactive' || status === 'draft' || status === 'archived';
|
||||
|
||||
// View Details
|
||||
actions.push({
|
||||
|
||||
Reference in New Issue
Block a user