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>branches, 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>cabang 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 ? ( + + ) : 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')} + + + + {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>contacts.", + "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>kontak.", + "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>divisions 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>divisi 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({