diff --git a/apps/web/src/apps/main/index.tsx b/apps/web/src/apps/main/index.tsx
index 36149eb..f12be37 100644
--- a/apps/web/src/apps/main/index.tsx
+++ b/apps/web/src/apps/main/index.tsx
@@ -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() {
} />
} />
} />
+ } />
} />
} />
diff --git a/apps/web/src/apps/main/layouts/data/menu.data.ts b/apps/web/src/apps/main/layouts/data/menu.data.ts
index 7ee05ae..bbd0eb2 100644
--- a/apps/web/src/apps/main/layouts/data/menu.data.ts
+++ b/apps/web/src/apps/main/layouts/data/menu.data.ts
@@ -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',
diff --git a/apps/web/src/apps/main/layouts/languages/en/nav.json b/apps/web/src/apps/main/layouts/languages/en/nav.json
index 10d9146..08346af 100644
--- a/apps/web/src/apps/main/layouts/languages/en/nav.json
+++ b/apps/web/src/apps/main/layouts/languages/en/nav.json
@@ -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"
}
diff --git a/apps/web/src/apps/main/layouts/languages/id/nav.json b/apps/web/src/apps/main/layouts/languages/id/nav.json
index e82c9dc..edfde42 100644
--- a/apps/web/src/apps/main/layouts/languages/id/nav.json
+++ b/apps/web/src/apps/main/layouts/languages/id/nav.json
@@ -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"
}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/data/branch.remote.service.test.ts b/apps/web/src/apps/main/modules/configuration/branches/data/branch.remote.service.test.ts
new file mode 100644
index 0000000..be4fc75
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/data/branch.remote.service.test.ts
@@ -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',
+ }),
+ );
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/branches/data/branch.remote.service.ts b/apps/web/src/apps/main/modules/configuration/branches/data/branch.remote.service.ts
new file mode 100644
index 0000000..6e3da9c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/data/branch.remote.service.ts
@@ -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 {
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ super(httpClient, {
+ ...config,
+ apiUrl: config.apiUrl ?? '/branches',
+ });
+ }
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/domain/constants/branch.constants.ts b/apps/web/src/apps/main/modules/configuration/branches/domain/constants/branch.constants.ts
new file mode 100644
index 0000000..c2b11db
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/domain/constants/branch.constants.ts
@@ -0,0 +1,11 @@
+import { ModuleConfigEntity } from '@repo/ui/foundations';
+import type { BranchEntity } from '../entities';
+
+export const branchesModuleConfig: ModuleConfigEntity = {
+ moduleKey: 'CONFIGURATION.BRANCH',
+ translationNamespace: 'BRANCHES',
+ apiUrl: '/branches',
+ webUrl: '/app/configuration/branches',
+ moduleCategory: 'FULL_PAGE',
+ moduleType: 'MASTER_DATA',
+} as const;
diff --git a/apps/web/src/apps/main/modules/configuration/branches/domain/constants/index.ts b/apps/web/src/apps/main/modules/configuration/branches/domain/constants/index.ts
new file mode 100644
index 0000000..fccb28f
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/domain/constants/index.ts
@@ -0,0 +1 @@
+export * from './branch.constants';
diff --git a/apps/web/src/apps/main/modules/configuration/branches/domain/entities/branch.entity.ts b/apps/web/src/apps/main/modules/configuration/branches/domain/entities/branch.entity.ts
new file mode 100644
index 0000000..795da4c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/domain/entities/branch.entity.ts
@@ -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;
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/domain/entities/index.ts b/apps/web/src/apps/main/modules/configuration/branches/domain/entities/index.ts
new file mode 100644
index 0000000..32610c0
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/domain/entities/index.ts
@@ -0,0 +1 @@
+export * from './branch.entity';
diff --git a/apps/web/src/apps/main/modules/configuration/branches/domain/factories/index.ts b/apps/web/src/apps/main/modules/configuration/branches/domain/factories/index.ts
new file mode 100644
index 0000000..f1f5eb8
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/domain/factories/index.ts
@@ -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,
+});
diff --git a/apps/web/src/apps/main/modules/configuration/branches/domain/transformers/branch.remote.transformer.test.ts b/apps/web/src/apps/main/modules/configuration/branches/domain/transformers/branch.remote.transformer.test.ts
new file mode 100644
index 0000000..e0bef85
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/domain/transformers/branch.remote.transformer.test.ts
@@ -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' });
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/branches/domain/transformers/branch.remote.transformer.ts b/apps/web/src/apps/main/modules/configuration/branches/domain/transformers/branch.remote.transformer.ts
new file mode 100644
index 0000000..ec0f7a8
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/domain/transformers/branch.remote.transformer.ts
@@ -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): 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 {
+ 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): Partial {
+ 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): Partial {
+ 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): Record {
+ const next = { ...filter };
+ if (next.division && typeof next.division === 'object') {
+ next.divisionId = next.division.id;
+ delete next.division;
+ }
+ return omitEmptyFields(next);
+ }
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/domain/validators/branch.validator.test.ts b/apps/web/src/apps/main/modules/configuration/branches/domain/validators/branch.validator.test.ts
new file mode 100644
index 0000000..4fee6ef
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/domain/validators/branch.validator.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/branches/domain/validators/branch.validator.ts b/apps/web/src/apps/main/modules/configuration/branches/domain/validators/branch.validator.ts
new file mode 100644
index 0000000..8d91e65
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/domain/validators/branch.validator.ts
@@ -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),
+ });
+};
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-general.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-general.tsx
new file mode 100644
index 0000000..6d8f851
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-general.tsx
@@ -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();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const data = detailData;
+
+ return (
+
+
+ {t('section_general')}
+
+
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-location.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-location.tsx
new file mode 100644
index 0000000..5c8c4c9
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-location.tsx
@@ -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();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const data = detailData;
+ const divisionLabel = data?.division ? `${data.division.code} - ${data.division.name}` : data?.divisionId;
+
+ return (
+
+
+ {t('section_location')}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-schedule.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-schedule.tsx
new file mode 100644
index 0000000..cc27b28
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/detail-component/detail-schedule.tsx
@@ -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();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const data = detailData;
+
+ return (
+
+
+ {t('section_schedule')}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-general.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-general.tsx
new file mode 100644
index 0000000..e7e2204
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-general.tsx
@@ -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 (
+
+
+ {t('section_general')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-location.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-location.tsx
new file mode 100644
index 0000000..8255523
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-location.tsx
@@ -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 (
+
+
+ {t('section_location')}
+
+
+
+
+
+
+ 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}`}
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-schedule.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-schedule.tsx
new file mode 100644
index 0000000..1b4dbe1
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/form-component/form-schedule.tsx
@@ -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 (
+
+
+ {t('section_schedule')}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/components/index-component/filter-content.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/index-component/filter-content.tsx
new file mode 100644
index 0000000..d53a179
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/components/index-component/filter-content.tsx
@@ -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; t: (key: string) => string }) => {
+ return (
+
+
+
+
+
+ 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}`}
+ />
+
+
+ );
+};
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/factory/index.tsx
new file mode 100644
index 0000000..b992737
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/factory/index.tsx
@@ -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 (
+
+ config={branchesModuleConfig}
+ dataServices={branchesDataService}
+ store={branchesStore}
+ >
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/languages/en/branches.json b/apps/web/src/apps/main/modules/configuration/branches/presentation/languages/en/branches.json
new file mode 100644
index 0000000..735052a
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/languages/en/branches.json
@@ -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>branches1>, 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"
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/languages/id/branches.json b/apps/web/src/apps/main/modules/configuration/branches/presentation/languages/id/branches.json
new file mode 100644
index 0000000..645ddc3
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/languages/id/branches.json
@@ -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>cabang1> 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"
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/pages/branch.page.detail.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/pages/branch.page.detail.tsx
new file mode 100644
index 0000000..1ee5623
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/pages/branch.page.detail.tsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/pages/branch.page.form.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/pages/branch.page.form.tsx
new file mode 100644
index 0000000..57232b6
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/pages/branch.page.form.tsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/pages/branch.page.index.tsx b/apps/web/src/apps/main/modules/configuration/branches/presentation/pages/branch.page.index.tsx
new file mode 100644
index 0000000..edc1c50
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/pages/branch.page.index.tsx
@@ -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[] = 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 ;
+ },
+ };
+ }, [t]);
+
+ return (
+ }} />
+ ),
+ icon: MapPin,
+ breadcrumbs: [
+ { label: t('nav:configuration'), type: 'text' },
+ { label: t('nav:configuration-branches'), type: 'text' },
+ ],
+ }}
+ >
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/branches/presentation/store/index.ts b/apps/web/src/apps/main/modules/configuration/branches/presentation/store/index.ts
new file mode 100644
index 0000000..8feda7a
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/branches/presentation/store/index.ts
@@ -0,0 +1,22 @@
+import { create } from 'zustand';
+import { EnterpriseModuleState } from '@repo/ui/foundations';
+import { BranchEntity } from '../../domain/entities';
+
+export interface BranchesStoreState extends EnterpriseModuleState {}
+
+export const branchesStore = create((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 }),
+}));
diff --git a/apps/web/src/apps/main/modules/configuration/customers/data/customer.remote.service.test.ts b/apps/web/src/apps/main/modules/configuration/customers/data/customer.remote.service.test.ts
new file mode 100644
index 0000000..730d916
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/data/customer.remote.service.test.ts
@@ -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',
+ }),
+ );
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/customers/data/customer.remote.service.ts b/apps/web/src/apps/main/modules/configuration/customers/data/customer.remote.service.ts
new file mode 100644
index 0000000..5f5f7c9
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/data/customer.remote.service.ts
@@ -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 {
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ 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({
+ ...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,
+ config?: AxiosRequestConfig,
+ ) {
+ const result = await this.customRequest({
+ ...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({
+ ...config,
+ url: `${this.contactsBaseUrl()}/${customerId}/contacts/${contactId}`,
+ method: 'DELETE',
+ headers: { ...config?.headers, 'ex-module-action': REQUEST_ACTION.DELETE },
+ });
+ }
+
+ private wrapCustomer(result: ApiResponse): ApiResponse<{ data: CustomerEntity }> {
+ const dto = unwrapBareDetail(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 } };
+ }
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/domain/constants/customer.constants.ts b/apps/web/src/apps/main/modules/configuration/customers/domain/constants/customer.constants.ts
new file mode 100644
index 0000000..e1012f4
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/domain/constants/customer.constants.ts
@@ -0,0 +1,11 @@
+import { ModuleConfigEntity } from '@repo/ui/foundations';
+import type { CustomerEntity } from '../entities';
+
+export const customersModuleConfig: ModuleConfigEntity = {
+ moduleKey: 'CONFIGURATION.CUSTOMER',
+ translationNamespace: 'CUSTOMERS',
+ apiUrl: '/customers',
+ webUrl: '/app/configuration/customers',
+ moduleCategory: 'FULL_PAGE',
+ moduleType: 'MASTER_DATA',
+} as const;
diff --git a/apps/web/src/apps/main/modules/configuration/customers/domain/constants/index.ts b/apps/web/src/apps/main/modules/configuration/customers/domain/constants/index.ts
new file mode 100644
index 0000000..63f4c97
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/domain/constants/index.ts
@@ -0,0 +1 @@
+export * from './customer.constants';
diff --git a/apps/web/src/apps/main/modules/configuration/customers/domain/entities/customer.entity.ts b/apps/web/src/apps/main/modules/configuration/customers/domain/entities/customer.entity.ts
new file mode 100644
index 0000000..67da3f5
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/domain/entities/customer.entity.ts
@@ -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;
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/domain/entities/index.ts b/apps/web/src/apps/main/modules/configuration/customers/domain/entities/index.ts
new file mode 100644
index 0000000..342b1d3
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/domain/entities/index.ts
@@ -0,0 +1 @@
+export * from './customer.entity';
diff --git a/apps/web/src/apps/main/modules/configuration/customers/domain/factories/index.ts b/apps/web/src/apps/main/modules/configuration/customers/domain/factories/index.ts
new file mode 100644
index 0000000..b912ce1
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/domain/factories/index.ts
@@ -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,
+});
diff --git a/apps/web/src/apps/main/modules/configuration/customers/domain/transformers/customer.remote.transformer.test.ts b/apps/web/src/apps/main/modules/configuration/customers/domain/transformers/customer.remote.transformer.test.ts
new file mode 100644
index 0000000..a5ebc21
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/domain/transformers/customer.remote.transformer.test.ts
@@ -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();
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/customers/domain/transformers/customer.remote.transformer.ts b/apps/web/src/apps/main/modules/configuration/customers/domain/transformers/customer.remote.transformer.ts
new file mode 100644
index 0000000..15432bb
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/domain/transformers/customer.remote.transformer.ts
@@ -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 {
+ 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): Partial {
+ const contacts = (entity.contacts ?? [])
+ .filter((contact): contact is CustomerContactEntity => Boolean(contact?.name))
+ .map((contact) => writableContact(contact));
+
+ const payload: Partial = 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): Partial {
+ 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,
+ };
+ }
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/domain/validators/customer.validator.test.ts b/apps/web/src/apps/main/modules/configuration/customers/domain/validators/customer.validator.test.ts
new file mode 100644
index 0000000..95c2335
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/domain/validators/customer.validator.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/customers/domain/validators/customer.validator.ts b/apps/web/src/apps/main/modules/configuration/customers/domain/validators/customer.validator.ts
new file mode 100644
index 0000000..bd8cd60
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/domain/validators/customer.validator.ts
@@ -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(),
+ });
+};
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-contacts.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-contacts.tsx
new file mode 100644
index 0000000..73ea453
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-contacts.tsx
@@ -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();
+ 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 (
+
+
+ {t('section_contacts')}
+ {canEdit ? (
+ } onClick={openCreate}>
+ {t('add_contact')}
+
+ ) : null}
+
+ {contacts.length === 0 ? (
+
+ {t('empty_contacts')}
+
+ ) : (
+
+
+
+ {t('common:fields.name')}
+ {t('common:fields.jobTitle')}
+ {t('common:fields.phone')}
+ {t('common:fields.mobilePhone')}
+ {t('common:fields.notes')}
+ {canEdit ? : null}
+
+
+
+ {contacts.map((contact) => (
+
+ {contact.name}
+ {contact.jobTitle ?? '-'}
+ {contact.phone ?? '-'}
+ {contact.mobilePhone ?? '-'}
+ {contact.notes ?? '-'}
+ {canEdit ? (
+
+
+ openEdit(contact)} aria-label={t('edit_contact')}>
+
+
+ handleDelete(contact)} aria-label={t('delete_contact')}>
+
+
+
+
+ ) : null}
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-general.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-general.tsx
new file mode 100644
index 0000000..97a429f
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-general.tsx
@@ -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();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const data = detailData;
+
+ return (
+
+
+ {t('section_general')}
+
+
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-location.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-location.tsx
new file mode 100644
index 0000000..df03079
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/detail-component/detail-location.tsx
@@ -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();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const data = detailData;
+
+ return (
+
+
+ {t('section_location')}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-contacts.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-contacts.tsx
new file mode 100644
index 0000000..9bb5981
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-contacts.tsx
@@ -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 (
+
+
+ {t('section_contacts')}
+ }
+ onClick={() => append({ name: '', jobTitle: '', phone: '', mobilePhone: '', notes: '' })}
+ >
+ {t('add_contact')}
+
+
+
+ {fields.length === 0 ? (
+
+ {t('empty_contacts')}
+
+ ) : (
+ fields.map((field, index) => (
+
+
+ remove(index)} aria-label={t('delete_contact')}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-general.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-general.tsx
new file mode 100644
index 0000000..8960640
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-general.tsx
@@ -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 (
+
+
+ {t('section_general')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-location.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-location.tsx
new file mode 100644
index 0000000..f6a01cd
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/form-component/form-location.tsx
@@ -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 (
+
+
+ {t('section_location')}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/components/index-component/filter-content.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/index-component/filter-content.tsx
new file mode 100644
index 0000000..8f4024d
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/components/index-component/filter-content.tsx
@@ -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; t: (key: string) => string }) => {
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/factory/index.tsx
new file mode 100644
index 0000000..33b5478
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/factory/index.tsx
@@ -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 (
+
+ config={customersModuleConfig}
+ dataServices={customersDataService}
+ store={customersStore}
+ >
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/languages/en/customers.json b/apps/web/src/apps/main/modules/configuration/customers/presentation/languages/en/customers.json
new file mode 100644
index 0000000..a310bda
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/languages/en/customers.json
@@ -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>contacts1>.",
+ "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"
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/languages/id/customers.json b/apps/web/src/apps/main/modules/configuration/customers/presentation/languages/id/customers.json
new file mode 100644
index 0000000..377b3d2
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/languages/id/customers.json
@@ -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>kontak1>.",
+ "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"
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/pages/customer.page.detail.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/pages/customer.page.detail.tsx
new file mode 100644
index 0000000..de9a8b9
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/pages/customer.page.detail.tsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/pages/customer.page.form.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/pages/customer.page.form.tsx
new file mode 100644
index 0000000..89948d1
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/pages/customer.page.form.tsx
@@ -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 (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/pages/customer.page.index.tsx b/apps/web/src/apps/main/modules/configuration/customers/presentation/pages/customer.page.index.tsx
new file mode 100644
index 0000000..8172dae
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/pages/customer.page.index.tsx
@@ -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[] = 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 ;
+ },
+ };
+ }, [t]);
+
+ return (
+ }} />
+ ),
+ icon: Users,
+ breadcrumbs: [
+ { label: t('nav:configuration'), type: 'text' },
+ { label: t('nav:configuration-customers'), type: 'text' },
+ ],
+ }}
+ >
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/customers/presentation/store/index.ts b/apps/web/src/apps/main/modules/configuration/customers/presentation/store/index.ts
new file mode 100644
index 0000000..3c6ccd9
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/customers/presentation/store/index.ts
@@ -0,0 +1,22 @@
+import { create } from 'zustand';
+import { EnterpriseModuleState } from '@repo/ui/foundations';
+import { CustomerEntity } from '../../domain/entities';
+
+export interface CustomersStoreState extends EnterpriseModuleState {}
+
+export const customersStore = create((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 }),
+}));
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/data/division.remote.service.test.ts b/apps/web/src/apps/main/modules/configuration/divisions/data/division.remote.service.test.ts
new file mode 100644
index 0000000..333ae67
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/data/division.remote.service.test.ts
@@ -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' },
+ }),
+ );
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/data/division.remote.service.ts b/apps/web/src/apps/main/modules/configuration/divisions/data/division.remote.service.ts
new file mode 100644
index 0000000..84515fb
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/data/division.remote.service.ts
@@ -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 {
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ super(httpClient, {
+ ...config,
+ apiUrl: config.apiUrl ?? '/divisions',
+ });
+ }
+}
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/domain/constants/division.constants.ts b/apps/web/src/apps/main/modules/configuration/divisions/domain/constants/division.constants.ts
new file mode 100644
index 0000000..6a66ea0
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/domain/constants/division.constants.ts
@@ -0,0 +1,11 @@
+import { ModuleConfigEntity } from '@repo/ui/foundations';
+import type { DivisionEntity } from '../entities';
+
+export const divisionsModuleConfig: ModuleConfigEntity = {
+ moduleKey: 'CONFIGURATION.DIVISION',
+ translationNamespace: 'DIVISIONS',
+ apiUrl: '/divisions',
+ webUrl: '/app/configuration/divisions',
+ moduleCategory: 'FULL_PAGE',
+ moduleType: 'MASTER_DATA',
+} as const;
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/domain/constants/index.ts b/apps/web/src/apps/main/modules/configuration/divisions/domain/constants/index.ts
new file mode 100644
index 0000000..6213a9b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/domain/constants/index.ts
@@ -0,0 +1 @@
+export * from './division.constants';
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/domain/entities/division.entity.ts b/apps/web/src/apps/main/modules/configuration/divisions/domain/entities/division.entity.ts
new file mode 100644
index 0000000..4ac2bd3
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/domain/entities/division.entity.ts
@@ -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;
+}
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/domain/entities/index.ts b/apps/web/src/apps/main/modules/configuration/divisions/domain/entities/index.ts
new file mode 100644
index 0000000..6801d8c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/domain/entities/index.ts
@@ -0,0 +1 @@
+export * from './division.entity';
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/domain/factories/index.ts b/apps/web/src/apps/main/modules/configuration/divisions/domain/factories/index.ts
new file mode 100644
index 0000000..7ecce87
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/domain/factories/index.ts
@@ -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,
+});
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/domain/transformers/division.remote.transformer.test.ts b/apps/web/src/apps/main/modules/configuration/divisions/domain/transformers/division.remote.transformer.test.ts
new file mode 100644
index 0000000..736a95b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/domain/transformers/division.remote.transformer.test.ts
@@ -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');
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/domain/transformers/division.remote.transformer.ts b/apps/web/src/apps/main/modules/configuration/divisions/domain/transformers/division.remote.transformer.ts
new file mode 100644
index 0000000..1e77788
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/domain/transformers/division.remote.transformer.ts
@@ -0,0 +1,35 @@
+import { BaseDataTransformer } from '@repo/core-api/data-services';
+import type { DivisionDto, DivisionEntity } from '../entities';
+
+export class DivisionsRemoteDataTransformer extends BaseDataTransformer {
+ 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): Partial {
+ return {
+ name: entity.name,
+ code: entity.code,
+ };
+ }
+
+ transformEditPayload(entity: Partial): Partial {
+ return {
+ name: entity.name,
+ code: entity.code,
+ };
+ }
+}
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/domain/validators/division.validator.test.ts b/apps/web/src/apps/main/modules/configuration/divisions/domain/validators/division.validator.test.ts
new file mode 100644
index 0000000..82e0f1f
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/domain/validators/division.validator.test.ts
@@ -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);
+ });
+});
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/domain/validators/division.validator.ts b/apps/web/src/apps/main/modules/configuration/divisions/domain/validators/division.validator.ts
new file mode 100644
index 0000000..d8b3d70
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/domain/validators/division.validator.ts
@@ -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),
+ });
+};
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/presentation/components/detail-component/detail-general.tsx b/apps/web/src/apps/main/modules/configuration/divisions/presentation/components/detail-component/detail-general.tsx
new file mode 100644
index 0000000..64b7080
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/presentation/components/detail-component/detail-general.tsx
@@ -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();
+ const { t } = useEnterpriseModuleTranslationContext();
+ const data = detailData;
+
+ return (
+
+
+ {t('section_general')}
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/presentation/components/form-component/form-general.tsx b/apps/web/src/apps/main/modules/configuration/divisions/presentation/components/form-component/form-general.tsx
new file mode 100644
index 0000000..4881233
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/presentation/components/form-component/form-general.tsx
@@ -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 (
+
+
+ {t('section_general')}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/presentation/components/index-component/filter-content.tsx b/apps/web/src/apps/main/modules/configuration/divisions/presentation/components/index-component/filter-content.tsx
new file mode 100644
index 0000000..906c8ed
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/presentation/components/index-component/filter-content.tsx
@@ -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; t: (key: string) => string }) => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/configuration/divisions/presentation/factory/index.tsx
new file mode 100644
index 0000000..ce59b86
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/presentation/factory/index.tsx
@@ -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 (
+
+ config={divisionsModuleConfig}
+ dataServices={divisionsDataService}
+ store={divisionsStore}
+ >
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/presentation/languages/en/divisions.json b/apps/web/src/apps/main/modules/configuration/divisions/presentation/languages/en/divisions.json
new file mode 100644
index 0000000..93b91ca
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/presentation/languages/en/divisions.json
@@ -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>divisions1> 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"
+}
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/presentation/languages/id/divisions.json b/apps/web/src/apps/main/modules/configuration/divisions/presentation/languages/id/divisions.json
new file mode 100644
index 0000000..212d35d
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/presentation/languages/id/divisions.json
@@ -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>divisi1> 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"
+}
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/presentation/pages/division.page.detail.tsx b/apps/web/src/apps/main/modules/configuration/divisions/presentation/pages/division.page.detail.tsx
new file mode 100644
index 0000000..6854e07
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/presentation/pages/division.page.detail.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/presentation/pages/division.page.form.tsx b/apps/web/src/apps/main/modules/configuration/divisions/presentation/pages/division.page.form.tsx
new file mode 100644
index 0000000..29b5110
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/presentation/pages/division.page.form.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/presentation/pages/division.page.index.tsx b/apps/web/src/apps/main/modules/configuration/divisions/presentation/pages/division.page.index.tsx
new file mode 100644
index 0000000..f63efae
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/presentation/pages/division.page.index.tsx
@@ -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[] = 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 ;
+ },
+ };
+ }, [t]);
+
+ return (
+ }} />
+ ),
+ icon: Layers,
+ breadcrumbs: [
+ { label: t('nav:configuration'), type: 'text' },
+ { label: t('nav:configuration-divisions'), type: 'text' },
+ ],
+ }}
+ >
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/divisions/presentation/store/index.ts b/apps/web/src/apps/main/modules/configuration/divisions/presentation/store/index.ts
new file mode 100644
index 0000000..e58034e
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/divisions/presentation/store/index.ts
@@ -0,0 +1,22 @@
+import { create } from 'zustand';
+import { EnterpriseModuleState } from '@repo/ui/foundations';
+import { DivisionEntity } from '../../domain/entities';
+
+export interface DivisionsStoreState extends EnterpriseModuleState {}
+
+export const divisionsStore = create((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 }),
+}));
diff --git a/apps/web/src/apps/main/modules/configuration/index.tsx b/apps/web/src/apps/main/modules/configuration/index.tsx
new file mode 100644
index 0000000..7d6f605
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/index.tsx
@@ -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 (
+
+ } />
+ } />
+ } />
+ } />
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/shared/load-division-options.ts b/apps/web/src/apps/main/modules/configuration/shared/load-division-options.ts
new file mode 100644
index 0000000..986e2d2
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/shared/load-division-options.ts
@@ -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 = 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 };
+};
diff --git a/apps/web/src/apps/main/modules/configuration/shared/status-filter-options.ts b/apps/web/src/apps/main/modules/configuration/shared/status-filter-options.ts
new file mode 100644
index 0000000..199a65c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/shared/status-filter-options.ts
@@ -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') },
+ ];
+}
diff --git a/apps/web/src/apps/main/modules/configuration/shared/weekday-options.ts b/apps/web/src/apps/main/modules/configuration/shared/weekday-options.ts
new file mode 100644
index 0000000..acd86a8
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/shared/weekday-options.ts
@@ -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}`),
+ }));
+}
diff --git a/apps/web/src/apps/main/modules/system/privileges/data/privilege.remote.service.ts b/apps/web/src/apps/main/modules/system/privileges/data/privilege.remote.service.ts
index 77c725d..9ddc60d 100644
--- a/apps/web/src/apps/main/modules/system/privileges/data/privilege.remote.service.ts
+++ b/apps/web/src/apps/main/modules/system/privileges/data/privilege.remote.service.ts
@@ -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 {
+export class PrivilegesRemoteDataServices extends TrackGoRemoteDataServices {
constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
- 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(id: string, config?: AxiosRequestConfig): Promise> {
- const result = await this.execute(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, config?: AxiosRequestConfig) {
- return this.execute(DESCRIPTORS.activate, {
- variableURL: { id },
- config: { ...config, data: { status: 'active' } },
- });
- }
-
- deactivate(id: string, _meta?: Record, config?: AxiosRequestConfig) {
- return this.execute(DESCRIPTORS.deactivate, {
- variableURL: { id },
- config: { ...config, data: { status: 'archived' } },
- });
- }
-
- batchActivate(ids: Array, _meta?: Record, config?: AxiosRequestConfig) {
- return this.execute(DESCRIPTORS.batchActivate, {
- config: { ...config, data: { ids, status: 'active' } },
- });
- }
-
- batchDeactivate(ids: Array, _meta?: Record, config?: AxiosRequestConfig) {
- return this.execute(DESCRIPTORS.batchDeactivate, {
- config: { ...config, data: { ids, status: 'archived' } },
+ apiUrl: config.apiUrl ?? '/privileges',
});
}
diff --git a/apps/web/src/core/constants/api-url.ts b/apps/web/src/core/constants/api-url.ts
index 26379f5..0707540 100644
--- a/apps/web/src/core/constants/api-url.ts
+++ b/apps/web/src/core/constants/api-url.ts
@@ -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;
diff --git a/apps/web/src/core/constants/module-key.ts b/apps/web/src/core/constants/module-key.ts
index 9edbf5e..ada0767 100644
--- a/apps/web/src/core/constants/module-key.ts
+++ b/apps/web/src/core/constants/module-key.ts
@@ -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;
diff --git a/apps/web/src/core/constants/web-url.ts b/apps/web/src/core/constants/web-url.ts
index dbb9428..5a2661d 100644
--- a/apps/web/src/core/constants/web-url.ts
+++ b/apps/web/src/core/constants/web-url.ts
@@ -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;
diff --git a/apps/web/src/core/domain/configuration-field-validators.test.ts b/apps/web/src/core/domain/configuration-field-validators.test.ts
new file mode 100644
index 0000000..8a5ca3c
--- /dev/null
+++ b/apps/web/src/core/domain/configuration-field-validators.test.ts
@@ -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);
+ });
+ });
+});
diff --git a/apps/web/src/core/domain/configuration-field-validators.ts b/apps/web/src/core/domain/configuration-field-validators.ts
new file mode 100644
index 0000000..b6f2011
--- /dev/null
+++ b/apps/web/src/core/domain/configuration-field-validators.ts
@@ -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>(payload: T): Partial {
+ return Object.fromEntries(
+ Object.entries(payload).filter(([, value]) => value !== undefined && value !== ''),
+ ) as Partial;
+}
+
+export function emptyToNull(value: unknown) {
+ if (value === '' || value === undefined) {
+ return null;
+ }
+ return value;
+}
diff --git a/apps/web/src/core/lib/trackgo-remote-data-services.test.ts b/apps/web/src/core/lib/trackgo-remote-data-services.test.ts
new file mode 100644
index 0000000..663e613
--- /dev/null
+++ b/apps/web/src/core/lib/trackgo-remote-data-services.test.ts
@@ -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 {
+ transformToEntity(dto: SampleEntity): SampleEntity {
+ return { ...dto };
+ }
+}
+
+class SampleRemoteDataServices extends TrackGoRemoteDataServices {}
+
+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' },
+ }),
+ );
+ });
+});
diff --git a/apps/web/src/core/lib/trackgo-remote-data-services.ts b/apps/web/src/core/lib/trackgo-remote-data-services.ts
new file mode 100644
index 0000000..3729a06
--- /dev/null
+++ b/apps/web/src/core/lib/trackgo-remote-data-services.ts
@@ -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(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 extends BaseRemoteDataServices {
+ constructor(httpClient: AxiosInstance, config: DataServicesConfig) {
+ 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(id: string, config?: AxiosRequestConfig): Promise> {
+ const result = await this.execute(DESCRIPTORS.getOne, {
+ variableURL: { id },
+ config,
+ });
+ const dto = unwrapBareDetail(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, config?: AxiosRequestConfig) {
+ return this.execute(DESCRIPTORS.activate, {
+ variableURL: { id },
+ config: { ...config, data: { status: 'active' } },
+ });
+ }
+
+ deactivate(id: string, _meta?: Record, config?: AxiosRequestConfig) {
+ return this.execute(DESCRIPTORS.deactivate, {
+ variableURL: { id },
+ config: { ...config, data: { status: 'archived' } },
+ });
+ }
+
+ batchActivate(ids: Array, _meta?: Record, config?: AxiosRequestConfig) {
+ return this.execute(DESCRIPTORS.batchActivate, {
+ config: { ...config, data: { ids, status: 'active' } },
+ });
+ }
+
+ batchDeactivate(ids: Array, _meta?: Record, config?: AxiosRequestConfig) {
+ return this.execute(DESCRIPTORS.batchDeactivate, {
+ config: { ...config, data: { ids, status: 'archived' } },
+ });
+ }
+}
diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json
index e020d1c..fd47a59 100644
--- a/packages/core-i18n/src/languages/en/common.json
+++ b/packages/core-i18n/src/languages/en/common.json
@@ -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",
diff --git a/packages/core-i18n/src/languages/en/validation.json b/packages/core-i18n/src/languages/en/validation.json
index 07c7e53..758784e 100644
--- a/packages/core-i18n/src/languages/en/validation.json
+++ b/packages/core-i18n/src/languages/en/validation.json
@@ -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"
}
diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json
index 8722b2a..5350c49 100644
--- a/packages/core-i18n/src/languages/id/common.json
+++ b/packages/core-i18n/src/languages/id/common.json
@@ -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",
diff --git a/packages/core-i18n/src/languages/id/validation.json b/packages/core-i18n/src/languages/id/validation.json
index f6de2e2..7bd13db 100644
--- a/packages/core-i18n/src/languages/id/validation.json
+++ b/packages/core-i18n/src/languages/id/validation.json
@@ -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"
}
diff --git a/packages/ui/src/foundations/enterprise-module/components/data-table/components/row-actions.tsx b/packages/ui/src/foundations/enterprise-module/components/data-table/components/row-actions.tsx
index 16d9a13..c23c4c3 100644
--- a/packages/ui/src/foundations/enterprise-module/components/data-table/components/row-actions.tsx
+++ b/packages/ui/src/foundations/enterprise-module/components/data-table/components/row-actions.tsx
@@ -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({