From 5287da918c41aaf659cbfe9e986ebe723daaa205 Mon Sep 17 00:00:00 2001 From: shancheas Date: Wed, 26 Aug 2026 07:34:03 +0700 Subject: [PATCH] feat: introduce employees and logistics management modules - Added new modules for managing employees and logistics, including routes for creating, editing, and viewing employee details and logistics cycles. - Implemented UI components for employee forms and detail views, with validation schemas for employee data. - Integrated language support for English and Indonesian in the new modules. - Developed unit tests for employee remote data services and transformers to ensure functionality and reliability. This commit enhances the application by providing structured management for employees and logistics, improving user experience and data handling. --- apps/web/src/apps/main/index.tsx | 4 + .../src/apps/main/layouts/data/menu.data.ts | 44 +++++++ .../apps/main/layouts/languages/en/nav.json | 8 +- .../apps/main/layouts/languages/id/nav.json | 8 +- .../data/employee.remote.service.test.ts | 54 ++++++++ .../employees/data/employee.remote.service.ts | 13 ++ .../domain/constants/employee.constants.ts | 11 ++ .../employees/domain/constants/index.ts | 1 + .../domain/entities/employee.entity.ts | 30 +++++ .../employees/domain/entities/index.ts | 1 + .../employees/domain/factories/index.ts | 12 ++ .../employee.remote.transformer.test.ts | 55 ++++++++ .../employee.remote.transformer.ts | 41 ++++++ .../validators/employee.validator.test.ts | 25 ++++ .../domain/validators/employee.validator.ts | 18 +++ .../detail-component/detail-general.tsx | 44 +++++++ .../form-component/form-general.tsx | 52 ++++++++ .../index-component/filter-content.tsx | 44 +++++++ .../employees/presentation/factory/index.tsx | 40 ++++++ .../presentation/languages/en/employees.json | 19 +++ .../presentation/languages/id/employees.json | 19 +++ .../pages/employee.page.detail.tsx | 23 ++++ .../presentation/pages/employee.page.form.tsx | 47 +++++++ .../pages/employee.page.index.tsx | 56 +++++++++ .../employees/presentation/store/index.ts | 22 ++++ .../apps/main/modules/configuration/index.tsx | 2 + .../cycles/data/cycle.remote.service.test.ts | 54 ++++++++ .../field/cycles/data/cycle.remote.service.ts | 13 ++ .../domain/constants/cycle.constants.ts | 25 ++++ .../field/cycles/domain/constants/index.ts | 1 + .../cycles/domain/entities/cycle.entity.ts | 66 ++++++++++ .../field/cycles/domain/entities/index.ts | 1 + .../field/cycles/domain/factories/index.ts | 18 +++ .../cycle.remote.transformer.test.ts | 76 +++++++++++ .../transformers/cycle.remote.transformer.ts | 100 +++++++++++++++ .../domain/validators/cycle.validator.test.ts | 44 +++++++ .../domain/validators/cycle.validator.ts | 68 ++++++++++ .../detail-component/detail-general.tsx | 40 ++++++ .../detail-component/detail-weekdays.tsx | 39 ++++++ .../form-component/form-general.tsx | 44 +++++++ .../form-component/form-weekdays.tsx | 82 ++++++++++++ .../index-component/filter-content.tsx | 40 ++++++ .../cycles/presentation/factory/index.tsx | 41 ++++++ .../presentation/languages/en/cycles.json | 28 +++++ .../presentation/languages/id/cycles.json | 28 +++++ .../presentation/pages/cycle.page.detail.tsx | 36 ++++++ .../presentation/pages/cycle.page.form.tsx | 64 ++++++++++ .../presentation/pages/cycle.page.index.tsx | 63 ++++++++++ .../field/cycles/presentation/store/index.ts | 23 ++++ .../main/modules/field/logistics/index.tsx | 15 +++ .../plans/data/plan.remote.service.test.ts | 78 ++++++++++++ .../field/plans/data/plan.remote.service.ts | 49 ++++++++ .../field/plans/domain/constants/index.ts | 1 + .../plans/domain/constants/plan.constants.ts | 14 +++ .../field/plans/domain/entities/index.ts | 1 + .../plans/domain/entities/plan.entity.ts | 46 +++++++ .../field/plans/domain/factories/index.ts | 22 ++++ .../plan.remote.transformer.test.ts | 66 ++++++++++ .../transformers/plan.remote.transformer.ts | 92 ++++++++++++++ .../domain/validators/plan.validator.test.ts | 26 ++++ .../plans/domain/validators/plan.validator.ts | 67 ++++++++++ .../detail-component/detail-general.tsx | 98 +++++++++++++++ .../form-component/form-general.tsx | 118 ++++++++++++++++++ .../index-component/filter-content.tsx | 33 +++++ .../index-component/generate-modal.tsx | 63 ++++++++++ .../plans/presentation/factory/index.tsx | 41 ++++++ .../presentation/languages/en/plans.json | 27 ++++ .../presentation/languages/id/plans.json | 27 ++++ .../presentation/pages/plan.page.detail.tsx | 31 +++++ .../presentation/pages/plan.page.form.tsx | 56 +++++++++ .../presentation/pages/plan.page.index.tsx | 79 ++++++++++++ .../field/plans/presentation/store/index.ts | 23 ++++ .../apps/main/modules/field/sales/index.tsx | 15 +++ .../field/shared/create-option-loader.ts | 14 +++ .../field/shared/load-branch-options.ts | 5 + .../field/shared/load-customer-options.ts | 5 + .../field/shared/load-employee-options.ts | 5 + .../modules/field/shared/lookup.entity.ts | 6 + .../modules/field/shared/lookup.factories.ts | 22 ++++ .../field/shared/lookup.remote.service.ts | 10 ++ .../modules/field/shared/relation-label.ts | 7 ++ apps/web/src/core/constants/api-url.ts | 5 + apps/web/src/core/constants/module-key.ts | 5 + apps/web/src/core/constants/web-url.ts | 5 + apps/web/src/core/domain/field-purpose.ts | 7 ++ apps/web/src/core/domain/relation-ref.ts | 5 + .../core-i18n/src/languages/en/common.json | 16 ++- .../core-i18n/src/languages/id/common.json | 16 ++- packages/ui/package.json | 7 +- .../Form/__tests__/date-picker.field.test.tsx | 29 +++++ .../Form/fields/date-picker.field.tsx | 46 +++++++ .../components/Form/fields/date-value.test.ts | 32 +++++ .../src/components/Form/fields/date-value.ts | 22 ++++ packages/ui/src/components/Form/index.ts | 7 ++ packages/ui/src/components/map/index.ts | 4 + .../src/components/map/route-geometry.test.ts | 25 ++++ .../ui/src/components/map/route-geometry.ts | 13 ++ packages/ui/src/components/map/route-map.tsx | 53 ++++++++ packages/ui/src/theme.css | 1 + pnpm-lock.yaml | 72 ++++++++++- 100 files changed, 3208 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/apps/main/modules/configuration/employees/data/employee.remote.service.test.ts create mode 100644 apps/web/src/apps/main/modules/configuration/employees/data/employee.remote.service.ts create mode 100644 apps/web/src/apps/main/modules/configuration/employees/domain/constants/employee.constants.ts create mode 100644 apps/web/src/apps/main/modules/configuration/employees/domain/constants/index.ts create mode 100644 apps/web/src/apps/main/modules/configuration/employees/domain/entities/employee.entity.ts create mode 100644 apps/web/src/apps/main/modules/configuration/employees/domain/entities/index.ts create mode 100644 apps/web/src/apps/main/modules/configuration/employees/domain/factories/index.ts create mode 100644 apps/web/src/apps/main/modules/configuration/employees/domain/transformers/employee.remote.transformer.test.ts create mode 100644 apps/web/src/apps/main/modules/configuration/employees/domain/transformers/employee.remote.transformer.ts create mode 100644 apps/web/src/apps/main/modules/configuration/employees/domain/validators/employee.validator.test.ts create mode 100644 apps/web/src/apps/main/modules/configuration/employees/domain/validators/employee.validator.ts create mode 100644 apps/web/src/apps/main/modules/configuration/employees/presentation/components/detail-component/detail-general.tsx create mode 100644 apps/web/src/apps/main/modules/configuration/employees/presentation/components/form-component/form-general.tsx create mode 100644 apps/web/src/apps/main/modules/configuration/employees/presentation/components/index-component/filter-content.tsx create mode 100644 apps/web/src/apps/main/modules/configuration/employees/presentation/factory/index.tsx create mode 100644 apps/web/src/apps/main/modules/configuration/employees/presentation/languages/en/employees.json create mode 100644 apps/web/src/apps/main/modules/configuration/employees/presentation/languages/id/employees.json create mode 100644 apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.page.detail.tsx create mode 100644 apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.page.form.tsx create mode 100644 apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.page.index.tsx create mode 100644 apps/web/src/apps/main/modules/configuration/employees/presentation/store/index.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/data/cycle.remote.service.test.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/data/cycle.remote.service.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/domain/constants/cycle.constants.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/domain/constants/index.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/domain/entities/cycle.entity.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/domain/entities/index.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/domain/factories/index.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/domain/transformers/cycle.remote.transformer.test.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/domain/transformers/cycle.remote.transformer.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/domain/validators/cycle.validator.test.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/domain/validators/cycle.validator.ts create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/components/detail-component/detail-general.tsx create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/components/detail-component/detail-weekdays.tsx create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/components/form-component/form-general.tsx create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/components/form-component/form-weekdays.tsx create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/components/index-component/filter-content.tsx create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/factory/index.tsx create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/languages/en/cycles.json create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/languages/id/cycles.json create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.detail.tsx create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.form.tsx create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.index.tsx create mode 100644 apps/web/src/apps/main/modules/field/cycles/presentation/store/index.ts create mode 100644 apps/web/src/apps/main/modules/field/logistics/index.tsx create mode 100644 apps/web/src/apps/main/modules/field/plans/data/plan.remote.service.test.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/data/plan.remote.service.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/constants/index.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/constants/plan.constants.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/entities/index.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/entities/plan.entity.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/factories/index.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/transformers/plan.remote.transformer.test.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/transformers/plan.remote.transformer.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.test.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.ts create mode 100644 apps/web/src/apps/main/modules/field/plans/presentation/components/detail-component/detail-general.tsx create mode 100644 apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-general.tsx create mode 100644 apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/filter-content.tsx create mode 100644 apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/generate-modal.tsx create mode 100644 apps/web/src/apps/main/modules/field/plans/presentation/factory/index.tsx create mode 100644 apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json create mode 100644 apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json create mode 100644 apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.detail.tsx create mode 100644 apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.form.tsx create mode 100644 apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.index.tsx create mode 100644 apps/web/src/apps/main/modules/field/plans/presentation/store/index.ts create mode 100644 apps/web/src/apps/main/modules/field/sales/index.tsx create mode 100644 apps/web/src/apps/main/modules/field/shared/create-option-loader.ts create mode 100644 apps/web/src/apps/main/modules/field/shared/load-branch-options.ts create mode 100644 apps/web/src/apps/main/modules/field/shared/load-customer-options.ts create mode 100644 apps/web/src/apps/main/modules/field/shared/load-employee-options.ts create mode 100644 apps/web/src/apps/main/modules/field/shared/lookup.entity.ts create mode 100644 apps/web/src/apps/main/modules/field/shared/lookup.factories.ts create mode 100644 apps/web/src/apps/main/modules/field/shared/lookup.remote.service.ts create mode 100644 apps/web/src/apps/main/modules/field/shared/relation-label.ts create mode 100644 apps/web/src/core/domain/field-purpose.ts create mode 100644 apps/web/src/core/domain/relation-ref.ts create mode 100644 packages/ui/src/components/Form/__tests__/date-picker.field.test.tsx create mode 100644 packages/ui/src/components/Form/fields/date-picker.field.tsx create mode 100644 packages/ui/src/components/Form/fields/date-value.test.ts create mode 100644 packages/ui/src/components/Form/fields/date-value.ts create mode 100644 packages/ui/src/components/map/index.ts create mode 100644 packages/ui/src/components/map/route-geometry.test.ts create mode 100644 packages/ui/src/components/map/route-geometry.ts create mode 100644 packages/ui/src/components/map/route-map.tsx diff --git a/apps/web/src/apps/main/index.tsx b/apps/web/src/apps/main/index.tsx index f12be37..9e209ac 100644 --- a/apps/web/src/apps/main/index.tsx +++ b/apps/web/src/apps/main/index.tsx @@ -9,6 +9,8 @@ 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')); +const SalesFieldModule = lazy(() => import('./modules/field/sales')); +const LogisticsFieldModule = lazy(() => import('./modules/field/logistics')); export default function AppModule() { return ( @@ -21,6 +23,8 @@ 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 bbd0eb2..1546114 100644 --- a/apps/web/src/apps/main/layouts/data/menu.data.ts +++ b/apps/web/src/apps/main/layouts/data/menu.data.ts @@ -25,6 +25,7 @@ import { Clock, Shield, FileSearch, + Repeat, } from 'lucide-react'; import type { MenuItemType } from '../types/menu.types'; @@ -91,6 +92,42 @@ export const MENU_ITEMS: MenuItemType[] = [ icon: Receipt, path: '/app/sales/invoices', }, + { + key: 'sales-cycles', + label: 'nav:sales-cycles', + icon: Repeat, + path: '/app/sales/cycles/index', + moduleKey: 'SALES.CYCLE', + }, + { + key: 'sales-plans', + label: 'nav:sales-plans', + icon: Calendar, + path: '/app/sales/plans/index', + moduleKey: 'SALES.PLAN', + }, + ], + }, + { + key: 'logistics', + label: 'nav:logistics', + icon: Truck, + path: '/app/logistics', + children: [ + { + key: 'logistics-cycles', + label: 'nav:logistics-cycles', + icon: Repeat, + path: '/app/logistics/cycles/index', + moduleKey: 'LOGISTICS.CYCLE', + }, + { + key: 'logistics-plans', + label: 'nav:logistics-plans', + icon: Calendar, + path: '/app/logistics/plans/index', + moduleKey: 'LOGISTICS.PLAN', + }, ], }, { @@ -264,6 +301,13 @@ export const MENU_ITEMS: MenuItemType[] = [ path: '/app/configuration/customers/index', moduleKey: 'CONFIGURATION.CUSTOMER', }, + { + key: 'configuration-employees', + label: 'nav:configuration-employees', + icon: Users, + path: '/app/configuration/employees/index', + moduleKey: 'CONFIGURATION.EMPLOYEE', + }, ], }, { 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 08346af..c9b0b10 100644 --- a/apps/web/src/apps/main/layouts/languages/en/nav.json +++ b/apps/web/src/apps/main/layouts/languages/en/nav.json @@ -38,5 +38,11 @@ "configuration": "Configuration", "configuration-divisions": "Divisions", "configuration-branches": "Branches", - "configuration-customers": "Customers" + "configuration-customers": "Customers", + "sales-cycles": "Sales Cycles", + "sales-plans": "Sales Plans", + "logistics": "Logistics", + "logistics-cycles": "Logistics Cycles", + "logistics-plans": "Logistics Plans", + "configuration-employees": "Employees" } 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 edfde42..568f6a0 100644 --- a/apps/web/src/apps/main/layouts/languages/id/nav.json +++ b/apps/web/src/apps/main/layouts/languages/id/nav.json @@ -38,5 +38,11 @@ "configuration": "Konfigurasi", "configuration-divisions": "Divisi", "configuration-branches": "Cabang", - "configuration-customers": "Pelanggan" + "configuration-customers": "Pelanggan", + "sales-cycles": "Siklus Penjualan", + "sales-plans": "Rencana Penjualan", + "logistics": "Logistik", + "logistics-cycles": "Siklus Logistik", + "logistics-plans": "Rencana Logistik", + "configuration-employees": "Karyawan" } diff --git a/apps/web/src/apps/main/modules/configuration/employees/data/employee.remote.service.test.ts b/apps/web/src/apps/main/modules/configuration/employees/data/employee.remote.service.test.ts new file mode 100644 index 0000000..bd3a616 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/data/employee.remote.service.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AxiosInstance } from '@repo/core-api/http-client'; +import { EmployeesRemoteDataServices } from './employee.remote.service'; +import { EmployeesRemoteDataTransformer } from '../domain/transformers/employee.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('EmployeesRemoteDataServices', () => { + let httpClient: AxiosInstance; + let service: EmployeesRemoteDataServices; + + beforeEach(() => { + httpClient = createMockHttpClient(); + service = new EmployeesRemoteDataServices(httpClient, { + apiUrl: '/employees', + moduleKey: 'CONFIGURATION.EMPLOYEE', + transformer: new EmployeesRemoteDataTransformer(), + }); + }); + + it('uses PATCH when editing an employee', async () => { + await service.edit('emp-1', { name: 'Ada Lovelace', code: 'EMP_01' } as any); + expect(httpClient.request).toHaveBeenCalledWith( + expect.objectContaining({ url: '/employees/emp-1', method: 'PATCH' }), + ); + }); + + it('bulk-deletes via POST /employees/bulk-delete', async () => { + await service.batchDelete(['emp-1']); + expect(httpClient.request).toHaveBeenCalledWith( + expect.objectContaining({ url: '/employees/bulk-delete', method: 'POST' }), + ); + }); +}); diff --git a/apps/web/src/apps/main/modules/configuration/employees/data/employee.remote.service.ts b/apps/web/src/apps/main/modules/configuration/employees/data/employee.remote.service.ts new file mode 100644 index 0000000..48f3299 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/data/employee.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 { EmployeeEntity } from '../domain/entities'; + +export class EmployeesRemoteDataServices extends TrackGoRemoteDataServices { + constructor(httpClient: AxiosInstance, config: DataServicesConfig) { + super(httpClient, { + ...config, + apiUrl: config.apiUrl ?? '/employees', + }); + } +} diff --git a/apps/web/src/apps/main/modules/configuration/employees/domain/constants/employee.constants.ts b/apps/web/src/apps/main/modules/configuration/employees/domain/constants/employee.constants.ts new file mode 100644 index 0000000..41cd5e5 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/domain/constants/employee.constants.ts @@ -0,0 +1,11 @@ +import { ModuleConfigEntity } from '@repo/ui/foundations'; +import type { EmployeeEntity } from '../entities'; + +export const employeesModuleConfig: ModuleConfigEntity = { + moduleKey: 'CONFIGURATION.EMPLOYEE', + translationNamespace: 'EMPLOYEES', + apiUrl: '/employees', + webUrl: '/app/configuration/employees', + moduleCategory: 'FULL_PAGE', + moduleType: 'MASTER_DATA', +} as const; diff --git a/apps/web/src/apps/main/modules/configuration/employees/domain/constants/index.ts b/apps/web/src/apps/main/modules/configuration/employees/domain/constants/index.ts new file mode 100644 index 0000000..19be492 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/domain/constants/index.ts @@ -0,0 +1 @@ +export * from './employee.constants'; diff --git a/apps/web/src/apps/main/modules/configuration/employees/domain/entities/employee.entity.ts b/apps/web/src/apps/main/modules/configuration/employees/domain/entities/employee.entity.ts new file mode 100644 index 0000000..9b2b737 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/domain/entities/employee.entity.ts @@ -0,0 +1,30 @@ +import { BaseEntity } from '@repo/core-api/data-services'; +import type { ConfigurationStatus } from '../../../divisions/domain/entities'; + +export const EMPLOYEE_POSITIONS = ['sales', 'driver', 'crew'] as const; +export type EmployeePosition = (typeof EMPLOYEE_POSITIONS)[number]; + +export interface EmployeeEntity extends BaseEntity { + code: string; + name: string; + phone: string; + position: EmployeePosition; + status?: ConfigurationStatus; + createdAt?: number; + updatedAt?: number; + createdBy?: string; + updatedBy?: string; +} + +export interface EmployeeDto { + id?: string; + code: string; + name: string; + phone: string; + position: EmployeePosition; + status?: ConfigurationStatus; + createdAt?: number; + updatedAt?: number; + createdBy?: string; + updatedBy?: string; +} diff --git a/apps/web/src/apps/main/modules/configuration/employees/domain/entities/index.ts b/apps/web/src/apps/main/modules/configuration/employees/domain/entities/index.ts new file mode 100644 index 0000000..af86785 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './employee.entity'; diff --git a/apps/web/src/apps/main/modules/configuration/employees/domain/factories/index.ts b/apps/web/src/apps/main/modules/configuration/employees/domain/factories/index.ts new file mode 100644 index 0000000..90a2389 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/domain/factories/index.ts @@ -0,0 +1,12 @@ +import { apiClient } from '../../../../../../../core/lib/api-client'; +import { EmployeesRemoteDataServices } from '../../data/employee.remote.service'; +import { employeesModuleConfig } from '../constants/employee.constants'; +import { EmployeesRemoteDataTransformer } from '../transformers/employee.remote.transformer'; + +export const employeesDataTransformer = new EmployeesRemoteDataTransformer(); + +export const employeesDataService = new EmployeesRemoteDataServices(apiClient, { + apiUrl: employeesModuleConfig.apiUrl, + moduleKey: employeesModuleConfig.moduleKey, + transformer: employeesDataTransformer, +}); diff --git a/apps/web/src/apps/main/modules/configuration/employees/domain/transformers/employee.remote.transformer.test.ts b/apps/web/src/apps/main/modules/configuration/employees/domain/transformers/employee.remote.transformer.test.ts new file mode 100644 index 0000000..caa7d78 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/domain/transformers/employee.remote.transformer.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { EmployeesRemoteDataTransformer } from './employee.remote.transformer'; +import type { EmployeeEntity } from '../entities'; + +const transformer = new EmployeesRemoteDataTransformer(); + +const dto = { + id: 'emp-1', + code: 'EMP_01', + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales' as const, + status: 'active' as const, + createdAt: 1, + updatedAt: 2, + createdBy: 'u1', + updatedBy: 'u2', +}; + +describe('EmployeesRemoteDataTransformer', () => { + it('maps dto fields onto the entity', () => { + const entity = transformer.transformToEntity(dto); + expect(entity).toMatchObject({ + id: 'emp-1', + code: 'EMP_01', + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', + }); + }); + + it('builds create payload without status', () => { + const entity: EmployeeEntity = { ...dto, status: 'draft' }; + const payload = transformer.transformCreatePayload(entity); + expect(payload).toEqual({ + code: 'EMP_01', + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', + }); + expect(payload).not.toHaveProperty('status'); + }); + + it('builds edit payload without status or audit fields', () => { + const payload = transformer.transformEditPayload(dto); + expect(payload).toEqual({ + code: 'EMP_01', + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', + }); + expect(payload).not.toHaveProperty('status'); + expect(payload).not.toHaveProperty('id'); + }); +}); diff --git a/apps/web/src/apps/main/modules/configuration/employees/domain/transformers/employee.remote.transformer.ts b/apps/web/src/apps/main/modules/configuration/employees/domain/transformers/employee.remote.transformer.ts new file mode 100644 index 0000000..04e8ac5 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/domain/transformers/employee.remote.transformer.ts @@ -0,0 +1,41 @@ +import { BaseDataTransformer } from '@repo/core-api/data-services'; +import type { EmployeeDto, EmployeeEntity } from '../entities'; + +export class EmployeesRemoteDataTransformer extends BaseDataTransformer { + transformToEntity(dto: EmployeeDto | EmployeeEntity): EmployeeEntity { + return { + id: dto.id, + code: dto.code, + name: dto.name, + phone: dto.phone, + position: dto.position, + status: dto.status, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + createdBy: dto.createdBy, + updatedBy: dto.updatedBy, + }; + } + + transformToDTO(entity: EmployeeEntity): EmployeeEntity { + return { ...entity }; + } + + transformCreatePayload(entity: Partial): Partial { + return { + code: entity.code, + name: entity.name, + phone: entity.phone, + position: entity.position, + }; + } + + transformEditPayload(entity: Partial): Partial { + return { + code: entity.code, + name: entity.name, + phone: entity.phone, + position: entity.position, + }; + } +} diff --git a/apps/web/src/apps/main/modules/configuration/employees/domain/validators/employee.validator.test.ts b/apps/web/src/apps/main/modules/configuration/employees/domain/validators/employee.validator.test.ts new file mode 100644 index 0000000..3bacc02 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/domain/validators/employee.validator.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { createEmployeeSchema } from './employee.validator'; + +describe('createEmployeeSchema', () => { + const t = (key: string) => key; + const schema = createEmployeeSchema(t); + const valid = { + code: 'EMP_01', + name: 'Ada Lovelace', + phone: '+6281234567890', + position: 'sales', + }; + + it('accepts a complete payload', () => { + expect(schema.safeParse(valid).success).toBe(true); + }); + + it('rejects an empty phone', () => { + expect(schema.safeParse({ ...valid, phone: '' }).success).toBe(false); + }); + + it('rejects an unknown position', () => { + expect(schema.safeParse({ ...valid, position: 'manager' }).success).toBe(false); + }); +}); diff --git a/apps/web/src/apps/main/modules/configuration/employees/domain/validators/employee.validator.ts b/apps/web/src/apps/main/modules/configuration/employees/domain/validators/employee.validator.ts new file mode 100644 index 0000000..c89df8c --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/domain/validators/employee.validator.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { + configCodeSchema, + configNameSchema, + configPhoneSchema, +} from '../../../../../../../core/domain/configuration-field-validators'; +import { EMPLOYEE_POSITIONS } from '../entities'; + +export const createEmployeeSchema = (t: (key: string) => string) => { + return z.object({ + code: configCodeSchema(t), + name: configNameSchema(t), + phone: configPhoneSchema(t), + position: z.enum(EMPLOYEE_POSITIONS, { + required_error: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.position') } }), + }), + }); +}; diff --git a/apps/web/src/apps/main/modules/configuration/employees/presentation/components/detail-component/detail-general.tsx b/apps/web/src/apps/main/modules/configuration/employees/presentation/components/detail-component/detail-general.tsx new file mode 100644 index 0000000..24b609c --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/presentation/components/detail-component/detail-general.tsx @@ -0,0 +1,44 @@ +import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components'; +import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; +import type { EmployeeEntity } from '../../../domain/entities'; + +export function DetailGeneral() { + const { detailData } = useDetailPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + const data = detailData; + + return ( + + + {t('section_general')} + + + + + + + t(`position_${String(val ?? '')}`)} + /> + } + /> + } + /> + } + /> + + + + ); +} diff --git a/apps/web/src/apps/main/modules/configuration/employees/presentation/components/form-component/form-general.tsx b/apps/web/src/apps/main/modules/configuration/employees/presentation/components/form-component/form-general.tsx new file mode 100644 index 0000000..7f2bfc2 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/presentation/components/form-component/form-general.tsx @@ -0,0 +1,52 @@ +import { Box, FieldTextInput, FieldSelect, Paper, SimpleGrid, Text } from '@repo/ui/components'; +import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; +import { EMPLOYEE_POSITIONS } from '../../../domain/entities'; + +export function FormGeneral() { + const { formControl } = useFormPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + + return ( + + + {t('section_general')} + + + + + + + ({ value, label: t(`position_${value}`) }))} + required + radius="md" + /> + + + + ); +} diff --git a/apps/web/src/apps/main/modules/configuration/employees/presentation/components/index-component/filter-content.tsx b/apps/web/src/apps/main/modules/configuration/employees/presentation/components/index-component/filter-content.tsx new file mode 100644 index 0000000..b225bbe --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/presentation/components/index-component/filter-content.tsx @@ -0,0 +1,44 @@ +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'; +import { EMPLOYEE_POSITIONS } from '../../../domain/entities'; + +export const FilterFormContent = ({ form, t }: { form: UseFormReturn; t: (key: string) => string }) => { + return ( + + + + + ({ value, label: t(`position_${value}`) }))} + /> + + + ); +}; diff --git a/apps/web/src/apps/main/modules/configuration/employees/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/configuration/employees/presentation/factory/index.tsx new file mode 100644 index 0000000..69ff1a5 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/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 { employeesModuleConfig } from '../../domain/constants'; +import { employeesDataService } from '../../domain/factories'; +import { EmployeeEntity } from '../../domain/entities'; +import { employeesStore } from '../store'; + +import employeesId from '../languages/id/employees.json'; +import employeesEn from '../languages/en/employees.json'; + +const IndexPage = lazy(() => import('../pages/employee.page.index')); +const FormPage = lazy(() => import('../pages/employee.page.form')); +const DetailPage = lazy(() => import('../pages/employee.page.detail')); + +registerModuleNamespace(employeesModuleConfig.translationNamespace, { + id: employeesId, + en: employeesEn, +}); + +export default function EmployeesModule() { + return ( + + config={employeesModuleConfig} + dataServices={employeesDataService} + store={employeesStore} + > + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/apps/main/modules/configuration/employees/presentation/languages/en/employees.json b/apps/web/src/apps/main/modules/configuration/employees/presentation/languages/en/employees.json new file mode 100644 index 0000000..9e343a4 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/presentation/languages/en/employees.json @@ -0,0 +1,19 @@ +{ + "title": "Employees", + "detail_page_title": "Employee Detail", + "create_page_title": "New Employee", + "edit_page_title": "Edit Employee", + "duplicate_page_title": "Duplicate Employee", + "description": "Manage <1>employees used as sales people and logistics staff.", + "detail_page_description": "Review employee identity, contact, and position.", + "create_page_description": "Create an employee with a unique code, phone, and position.", + "edit_page_description": "Update employee identity, contact, and position.", + "duplicate_page_description": "Copy an existing employee to create a new one.", + "section_general": "General", + "status_draft": "Draft", + "status_active": "Active", + "status_archived": "Archived", + "position_sales": "Sales", + "position_driver": "Driver", + "position_crew": "Crew" +} diff --git a/apps/web/src/apps/main/modules/configuration/employees/presentation/languages/id/employees.json b/apps/web/src/apps/main/modules/configuration/employees/presentation/languages/id/employees.json new file mode 100644 index 0000000..33707e2 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/presentation/languages/id/employees.json @@ -0,0 +1,19 @@ +{ + "title": "Karyawan", + "detail_page_title": "Detail Karyawan", + "create_page_title": "Karyawan Baru", + "edit_page_title": "Ubah Karyawan", + "duplicate_page_title": "Duplikat Karyawan", + "description": "Kelola <1>karyawan yang dipakai sebagai tenaga penjualan dan staf logistik.", + "detail_page_description": "Tinjau identitas, kontak, dan posisi karyawan.", + "create_page_description": "Buat karyawan dengan kode unik, telepon, dan posisi.", + "edit_page_description": "Perbarui identitas, kontak, dan posisi karyawan.", + "duplicate_page_description": "Salin karyawan yang ada untuk membuat data baru.", + "section_general": "Umum", + "status_draft": "Draft", + "status_active": "Aktif", + "status_archived": "Diarsipkan", + "position_sales": "Penjualan", + "position_driver": "Pengemudi", + "position_crew": "Kru" +} diff --git a/apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.page.detail.tsx b/apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.page.detail.tsx new file mode 100644 index 0000000..4a602c4 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.page.detail.tsx @@ -0,0 +1,23 @@ +import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; +import { employeesModuleConfig } from '../../domain/constants'; +import { DetailGeneral } from '../components/detail-component/detail-general'; + +export default function EmployeePageDetail() { + const { t } = useEnterpriseModuleTranslationContext(); + + return ( + + + + ); +} diff --git a/apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.page.form.tsx b/apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.page.form.tsx new file mode 100644 index 0000000..d51bce5 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.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 { employeesModuleConfig } from '../../domain/constants'; +import { createEmployeeSchema } from '../../domain/validators/employee.validator'; +import { FormGeneral } from '../components/form-component/form-general'; + +export default function EmployeePageForm({ 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(() => createEmployeeSchema(t), [t]); + const formControl = useForm({ resolver: zodResolver(validator) }); + + return ( + + + + ); +} diff --git a/apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.page.index.tsx b/apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.page.index.tsx new file mode 100644 index 0000000..65a83c0 --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/presentation/pages/employee.page.index.tsx @@ -0,0 +1,56 @@ +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 { EmployeeEntity } from '../../domain/entities'; + +export default function EmployeePageIndex() { + 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: 'position', + headerName: t('common:fields.position'), + minWidth: 140, + valueFormatter: ({ value }) => t(`position_${String(value ?? '')}`), + }, + ]; + }, [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-employees'), type: 'text' }, + ], + }} + > + + + ); +} diff --git a/apps/web/src/apps/main/modules/configuration/employees/presentation/store/index.ts b/apps/web/src/apps/main/modules/configuration/employees/presentation/store/index.ts new file mode 100644 index 0000000..650d71d --- /dev/null +++ b/apps/web/src/apps/main/modules/configuration/employees/presentation/store/index.ts @@ -0,0 +1,22 @@ +import { create } from 'zustand'; +import { EnterpriseModuleState } from '@repo/ui/foundations'; +import { EmployeeEntity } from '../../domain/entities'; + +export interface EmployeesStoreState extends EnterpriseModuleState {} + +export const employeesStore = 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 index 7d6f605..bb13de1 100644 --- a/apps/web/src/apps/main/modules/configuration/index.tsx +++ b/apps/web/src/apps/main/modules/configuration/index.tsx @@ -4,6 +4,7 @@ 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')); +const EmployeesModule = lazy(() => import('./employees/presentation/factory')); export default function ConfigurationModule() { return ( @@ -11,6 +12,7 @@ export default function ConfigurationModule() { } /> } /> } /> + } /> } /> ); diff --git a/apps/web/src/apps/main/modules/field/cycles/data/cycle.remote.service.test.ts b/apps/web/src/apps/main/modules/field/cycles/data/cycle.remote.service.test.ts new file mode 100644 index 0000000..8e52fc9 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/data/cycle.remote.service.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AxiosInstance } from '@repo/core-api/http-client'; +import { CyclesRemoteDataServices } from './cycle.remote.service'; +import { CyclesRemoteDataTransformer } from '../domain/transformers/cycle.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('CyclesRemoteDataServices', () => { + let httpClient: AxiosInstance; + let service: CyclesRemoteDataServices; + + beforeEach(() => { + httpClient = createMockHttpClient(); + service = new CyclesRemoteDataServices(httpClient, { + apiUrl: '/cycles', + moduleKey: 'SALES.CYCLE', + transformer: new CyclesRemoteDataTransformer('sales'), + }); + }); + + it('uses PATCH when editing a cycle', async () => { + await service.edit('cyc-1', { cycleNumber: 2 } as any); + expect(httpClient.request).toHaveBeenCalledWith( + expect.objectContaining({ url: '/cycles/cyc-1', method: 'PATCH' }), + ); + }); + + it('bulk-deletes via POST /cycles/bulk-delete', async () => { + await service.batchDelete(['cyc-1']); + expect(httpClient.request).toHaveBeenCalledWith( + expect.objectContaining({ url: '/cycles/bulk-delete', method: 'POST' }), + ); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/cycles/data/cycle.remote.service.ts b/apps/web/src/apps/main/modules/field/cycles/data/cycle.remote.service.ts new file mode 100644 index 0000000..9bb40b3 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/data/cycle.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 { CycleEntity } from '../domain/entities'; + +export class CyclesRemoteDataServices extends TrackGoRemoteDataServices { + constructor(httpClient: AxiosInstance, config: DataServicesConfig) { + super(httpClient, { + ...config, + apiUrl: config.apiUrl ?? '/cycles', + }); + } +} diff --git a/apps/web/src/apps/main/modules/field/cycles/domain/constants/cycle.constants.ts b/apps/web/src/apps/main/modules/field/cycles/domain/constants/cycle.constants.ts new file mode 100644 index 0000000..e809998 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/domain/constants/cycle.constants.ts @@ -0,0 +1,25 @@ +import { ModuleConfigEntity } from '@repo/ui/foundations'; +import { WEEKDAYS } from '../../../../../../../core/domain/configuration-field-validators'; +import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose'; +import type { CycleEntity, CycleWeekdayRow } from '../entities'; + +export function createCycleModuleConfig(purpose: FieldPurpose): ModuleConfigEntity { + return { + moduleKey: purpose === 'sales' ? 'SALES.CYCLE' : 'LOGISTICS.CYCLE', + translationNamespace: 'CYCLES', + apiUrl: '/cycles', + webUrl: `/app/${purpose}/cycles`, + moduleCategory: 'FULL_PAGE', + moduleType: 'MASTER_DATA', + }; +} + +export function createEmptyWeekdayRows(): CycleWeekdayRow[] { + return WEEKDAYS.map((weekday) => ({ + weekday, + enabled: false, + startBranch: null, + endBranch: null, + customers: [], + })); +} diff --git a/apps/web/src/apps/main/modules/field/cycles/domain/constants/index.ts b/apps/web/src/apps/main/modules/field/cycles/domain/constants/index.ts new file mode 100644 index 0000000..6148448 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/domain/constants/index.ts @@ -0,0 +1 @@ +export * from './cycle.constants'; diff --git a/apps/web/src/apps/main/modules/field/cycles/domain/entities/cycle.entity.ts b/apps/web/src/apps/main/modules/field/cycles/domain/entities/cycle.entity.ts new file mode 100644 index 0000000..0b014ae --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/domain/entities/cycle.entity.ts @@ -0,0 +1,66 @@ +import { BaseEntity } from '@repo/core-api/data-services'; +import type { ConfigurationStatus } from '../../../../configuration/divisions/domain/entities'; +import type { Weekday } from '../../../../../../../core/domain/configuration-field-validators'; +import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose'; +import type { RelationRef } from '../../../../../../../core/domain/relation-ref'; + +export interface RouteGeometry { + type: 'LineString'; + coordinates: Array<[number, number]>; +} + +export interface CycleDestinationEntity { + id?: string; + customerId: string; + sortOrder: number; +} + +export interface CycleWeekdayEntity { + id?: string; + weekday: Weekday; + startBranchId: string; + endBranchId: string; + routeGeometry?: RouteGeometry | null; + destinations: CycleDestinationEntity[]; +} + +export interface CycleWeekdayRow { + weekday: Weekday; + enabled: boolean; + startBranch?: RelationRef | null; + endBranch?: RelationRef | null; + customers: RelationRef[]; +} + +export interface CycleEntity extends BaseEntity { + employeeId: string; + employee?: RelationRef | null; + purpose: FieldPurpose; + cycleNumber: number; + weekdays?: CycleWeekdayEntity[]; + weekdayRows?: CycleWeekdayRow[]; + status?: ConfigurationStatus; + createdAt?: number; + updatedAt?: number; + createdBy?: string; + updatedBy?: string; +} + +export interface CycleDto { + id?: string; + employeeId: string; + purpose: FieldPurpose; + cycleNumber: number; + weekdays?: CycleWeekdayEntity[] | Record; + status?: ConfigurationStatus; + createdAt?: number; + updatedAt?: number; + createdBy?: string; + updatedBy?: string; +} + +export interface CycleWeekdayWrite { + startBranchId: string; + endBranchId: string; + customerIds: string[]; +} diff --git a/apps/web/src/apps/main/modules/field/cycles/domain/entities/index.ts b/apps/web/src/apps/main/modules/field/cycles/domain/entities/index.ts new file mode 100644 index 0000000..727d426 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './cycle.entity'; diff --git a/apps/web/src/apps/main/modules/field/cycles/domain/factories/index.ts b/apps/web/src/apps/main/modules/field/cycles/domain/factories/index.ts new file mode 100644 index 0000000..ad05402 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/domain/factories/index.ts @@ -0,0 +1,18 @@ +import { apiClient } from '../../../../../../../core/lib/api-client'; +import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose'; +import { CyclesRemoteDataServices } from '../../data/cycle.remote.service'; +import { createCycleModuleConfig } from '../constants/cycle.constants'; +import { CyclesRemoteDataTransformer } from '../transformers/cycle.remote.transformer'; + +export function createCycleDataService(purpose: FieldPurpose) { + const config = createCycleModuleConfig(purpose); + const transformer = new CyclesRemoteDataTransformer(purpose); + return new CyclesRemoteDataServices(apiClient, { + apiUrl: config.apiUrl, + moduleKey: config.moduleKey, + transformer, + }); +} + +export const salesCyclesDataService = createCycleDataService('sales'); +export const logisticsCyclesDataService = createCycleDataService('logistics'); diff --git a/apps/web/src/apps/main/modules/field/cycles/domain/transformers/cycle.remote.transformer.test.ts b/apps/web/src/apps/main/modules/field/cycles/domain/transformers/cycle.remote.transformer.test.ts new file mode 100644 index 0000000..b2a06d1 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/domain/transformers/cycle.remote.transformer.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { CyclesRemoteDataTransformer } from './cycle.remote.transformer'; +import { createEmptyWeekdayRows } from '../constants'; + +const transformer = new CyclesRemoteDataTransformer('sales'); + +const dto = { + id: 'cyc-1', + employeeId: 'emp-1', + purpose: 'sales' as const, + cycleNumber: 2, + weekdays: [ + { + id: 'wd-1', + weekday: 'monday' as const, + startBranchId: 'br-1', + endBranchId: 'br-2', + routeGeometry: { type: 'LineString' as const, coordinates: [[106.8, -6.2]] }, + destinations: [{ id: 'd-1', customerId: 'cus-1', sortOrder: 0 }], + }, + ], + status: 'active' as const, + createdAt: 1, + updatedAt: 2, + createdBy: 'u1', + updatedBy: 'u2', +}; + +describe('CyclesRemoteDataTransformer', () => { + it('expands weekday arrays into seven form rows', () => { + const entity = transformer.transformToEntity(dto as any); + expect(entity.employee).toEqual({ id: 'emp-1' }); + expect(entity.weekdayRows).toHaveLength(7); + const monday = entity.weekdayRows?.find((row) => row.weekday === 'monday'); + expect(monday?.enabled).toBe(true); + expect(monday?.startBranch).toEqual({ id: 'br-1' }); + expect(monday?.customers).toEqual([{ id: 'cus-1' }]); + expect(entity.weekdayRows?.find((row) => row.weekday === 'tuesday')?.enabled).toBe(false); + }); + + it('injects purpose and converts enabled weekday rows to an object on create', () => { + const weekdayRows = createEmptyWeekdayRows().map((row) => + row.weekday === 'monday' + ? { + ...row, + enabled: true, + startBranch: { id: 'br-1' }, + endBranch: { id: 'br-2' }, + customers: [{ id: 'cus-1' }], + } + : row, + ); + + const payload = transformer.transformCreatePayload({ + employee: { id: 'emp-1', code: 'EMP_01', name: 'Ada' }, + cycleNumber: 2, + weekdayRows: weekdayRows as any, + } as any); + + expect(payload.purpose).toBe('sales'); + expect(payload.employeeId).toBe('emp-1'); + expect(payload).not.toHaveProperty('employee'); + expect(payload).not.toHaveProperty('status'); + expect(payload.weekdays).toEqual({ + monday: { startBranchId: 'br-1', endBranchId: 'br-2', customerIds: ['cus-1'] }, + }); + }); + + it('injects purpose into list filters and flattens employee', () => { + const filter = transformer.transformPayloadFilter({ + cycleNumber: 2, + employee: { id: 'emp-1', name: 'Ada' }, + }); + expect(filter).toEqual({ cycleNumber: 2, employeeId: 'emp-1', purpose: 'sales' }); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/cycles/domain/transformers/cycle.remote.transformer.ts b/apps/web/src/apps/main/modules/field/cycles/domain/transformers/cycle.remote.transformer.ts new file mode 100644 index 0000000..bba0e69 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/domain/transformers/cycle.remote.transformer.ts @@ -0,0 +1,100 @@ +import { BaseDataTransformer } from '@repo/core-api/data-services'; +import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators'; +import { WEEKDAYS } from '../../../../../../../core/domain/configuration-field-validators'; +import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose'; +import type { + CycleEntity, + CycleWeekdayEntity, + CycleWeekdayRow, + CycleWeekdayWrite, +} from '../entities'; + +function relationId(value: unknown): string | undefined { + if (value && typeof value === 'object' && 'id' in value) { + const id = (value as { id?: unknown }).id; + return id == null ? undefined : String(id); + } + return undefined; +} + +function toWeekdayRows(weekdays: CycleEntity['weekdays']): CycleWeekdayRow[] { + const byDay = new Map((weekdays ?? []).map((row) => [row.weekday, row])); + return WEEKDAYS.map((weekday) => { + const row = byDay.get(weekday); + return { + weekday, + enabled: Boolean(row), + startBranch: row?.startBranchId ? { id: row.startBranchId } : null, + endBranch: row?.endBranchId ? { id: row.endBranchId } : null, + customers: (row?.destinations ?? []).map((destination) => ({ id: destination.customerId })), + }; + }); +} + +function toWeekdayObject(rows: CycleWeekdayRow[] | undefined): Record { + const next: Record = {}; + for (const row of rows ?? []) { + if (!row.enabled) continue; + const startBranchId = relationId(row.startBranch); + const endBranchId = relationId(row.endBranch); + const customerIds = (row.customers ?? []).map((customer) => String(customer.id)).filter(Boolean); + if (!startBranchId || !endBranchId || customerIds.length === 0) continue; + next[row.weekday] = { startBranchId, endBranchId, customerIds }; + } + return next; +} + +export class CyclesRemoteDataTransformer extends BaseDataTransformer { + constructor(private readonly purpose: FieldPurpose) { + super(); + } + + transformToEntity(dto: CycleEntity): CycleEntity { + const weekdays = Array.isArray(dto.weekdays) ? (dto.weekdays as CycleWeekdayEntity[]) : []; + return { + id: dto.id, + employeeId: dto.employeeId, + employee: dto.employee ?? (dto.employeeId ? { id: dto.employeeId } : null), + purpose: dto.purpose ?? this.purpose, + cycleNumber: dto.cycleNumber, + weekdays, + weekdayRows: toWeekdayRows(weekdays), + status: dto.status, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + createdBy: dto.createdBy, + updatedBy: dto.updatedBy, + }; + } + + transformToDTO(entity: CycleEntity): CycleEntity { + return { ...entity }; + } + + transformCreatePayload(entity: Partial): Partial { + return omitEmptyFields({ + employeeId: relationId(entity.employee) ?? entity.employeeId, + purpose: this.purpose, + cycleNumber: entity.cycleNumber, + weekdays: toWeekdayObject(entity.weekdayRows), + }) as Partial; + } + + transformEditPayload(entity: Partial): Partial { + return { + employeeId: relationId(entity.employee) ?? entity.employeeId, + purpose: this.purpose, + cycleNumber: entity.cycleNumber, + weekdays: toWeekdayObject(entity.weekdayRows), + } as unknown as Partial; + } + + transformPayloadFilter(filter: Record): Record { + const next: Record = { ...filter, purpose: this.purpose }; + if (next.employee && typeof next.employee === 'object') { + next.employeeId = next.employee.id; + delete next.employee; + } + return omitEmptyFields(next); + } +} diff --git a/apps/web/src/apps/main/modules/field/cycles/domain/validators/cycle.validator.test.ts b/apps/web/src/apps/main/modules/field/cycles/domain/validators/cycle.validator.test.ts new file mode 100644 index 0000000..6ae37f8 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/domain/validators/cycle.validator.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { createCycleSchema } from './cycle.validator'; +import { createEmptyWeekdayRows } from '../constants'; + +describe('createCycleSchema', () => { + const t = (key: string) => key; + const schema = createCycleSchema(t); + + const weekdayRows = createEmptyWeekdayRows().map((row) => + row.weekday === 'monday' + ? { + ...row, + enabled: true, + startBranch: { id: 'br-1' }, + endBranch: { id: 'br-2' }, + customers: [{ id: 'cus-1' }], + } + : row, + ); + + it('accepts a complete cycle', () => { + expect( + schema.safeParse({ + employee: { id: 'emp-1', name: 'Ada' }, + cycleNumber: 1, + weekdayRows, + }).success, + ).toBe(true); + }); + + it('rejects a missing employee', () => { + expect(schema.safeParse({ cycleNumber: 1, weekdayRows }).success).toBe(false); + }); + + it('rejects when no weekday is enabled', () => { + expect( + schema.safeParse({ + employee: { id: 'emp-1' }, + cycleNumber: 1, + weekdayRows: createEmptyWeekdayRows(), + }).success, + ).toBe(false); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/cycles/domain/validators/cycle.validator.ts b/apps/web/src/apps/main/modules/field/cycles/domain/validators/cycle.validator.ts new file mode 100644 index 0000000..91432ae --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/domain/validators/cycle.validator.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; +import { WEEKDAYS } from '../../../../../../../core/domain/configuration-field-validators'; + +const relationSchema = z.object({ + id: z.string(), + code: z.string().optional(), + name: z.string().optional(), +}); + +export const createCycleSchema = (t: (key: string) => string) => { + const weekdayRowSchema = z.object({ + weekday: z.enum(WEEKDAYS), + enabled: z.boolean(), + startBranch: relationSchema.nullable().optional(), + endBranch: relationSchema.nullable().optional(), + customers: z.array(relationSchema).optional(), + }); + + return z + .object({ + employee: relationSchema.nullable().optional(), + cycleNumber: z.coerce.number().int().min(1), + weekdayRows: z.array(weekdayRowSchema), + }) + .superRefine((value, ctx) => { + if (!value.employee?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['employee'], + message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.employee') } }), + }); + } + + const enabledRows = value.weekdayRows.filter((row) => row.enabled); + if (enabledRows.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['weekdayRows'], + message: JSON.stringify({ key: 'validation:required', values: { field: t('section_weekdays') } }), + }); + } + + enabledRows.forEach((row) => { + const rowIndex = value.weekdayRows.findIndex((item) => item.weekday === row.weekday); + if (!row.startBranch?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['weekdayRows', rowIndex, 'startBranch'], + message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.startBranch') } }), + }); + } + if (!row.endBranch?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['weekdayRows', rowIndex, 'endBranch'], + message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.endBranch') } }), + }); + } + if (!row.customers?.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['weekdayRows', rowIndex, 'customers'], + message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.customers') } }), + }); + } + }); + }); +}; diff --git a/apps/web/src/apps/main/modules/field/cycles/presentation/components/detail-component/detail-general.tsx b/apps/web/src/apps/main/modules/field/cycles/presentation/components/detail-component/detail-general.tsx new file mode 100644 index 0000000..ba888a6 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/components/detail-component/detail-general.tsx @@ -0,0 +1,40 @@ +import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components'; +import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; +import { relationLabel } from '../../../../shared/relation-label'; +import type { CycleEntity } 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/field/cycles/presentation/components/detail-component/detail-weekdays.tsx b/apps/web/src/apps/main/modules/field/cycles/presentation/components/detail-component/detail-weekdays.tsx new file mode 100644 index 0000000..454b3e9 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/components/detail-component/detail-weekdays.tsx @@ -0,0 +1,39 @@ +import { Box, Paper, Stack, Text } from '@repo/ui/components'; +import { RouteMap } from '@repo/ui/map'; +import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations'; +import type { CycleEntity } from '../../../domain/entities'; + +export function DetailWeekdays() { + const { detailData } = useDetailPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + const weekdays = detailData?.weekdays ?? []; + + if (weekdays.length === 0) { + return ( + + + {t('section_weekdays')} + + {t('empty_route')} + + ); + } + + return ( + + {weekdays.map((row) => ( + + + {t(`weekday_${row.weekday}`)} + + + {(row.destinations ?? []).map((destination, index) => `${index + 1}. ${destination.customerId}`).join(' | ') || t('empty_route')} + + + + + + ))} + + ); +} diff --git a/apps/web/src/apps/main/modules/field/cycles/presentation/components/form-component/form-general.tsx b/apps/web/src/apps/main/modules/field/cycles/presentation/components/form-component/form-general.tsx new file mode 100644 index 0000000..b6e361b --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/components/form-component/form-general.tsx @@ -0,0 +1,44 @@ +import { Box, FieldAsyncSelect, FieldNumberInput, Paper, SimpleGrid, Text } from '@repo/ui/components'; +import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; +import { loadEmployeeOptions } from '../../../../shared/load-employee-options'; +import { relationLabel } from '../../../../shared/relation-label'; +import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities'; + +export function FormGeneral() { + const { formControl } = useFormPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + const employee = formControl.watch('employee') as EmployeeEntity | null | undefined; + + return ( + + + {t('section_general')} + + + + + control={formControl.control} + name="employee" + label={t('common:fields.employee')} + placeholder={t('common:fields.employee')} + valueKey="id" + labelKey="name" + required + searchable + loadOptions={loadEmployeeOptions} + defaultOptions={employee ? [employee] : []} + renderLabel={relationLabel} + /> + + + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/cycles/presentation/components/form-component/form-weekdays.tsx b/apps/web/src/apps/main/modules/field/cycles/presentation/components/form-component/form-weekdays.tsx new file mode 100644 index 0000000..86e91e3 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/components/form-component/form-weekdays.tsx @@ -0,0 +1,82 @@ +import { Box, FieldAsyncSelect, FieldSwitch, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components'; +import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations'; +import { loadBranchOptions } from '../../../../shared/load-branch-options'; +import { loadCustomerOptions } from '../../../../shared/load-customer-options'; +import { relationLabel } from '../../../../shared/relation-label'; +import type { BranchEntity } from '../../../../../configuration/branches/domain/entities'; +import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities'; +import type { CycleWeekdayRow } from '../../../domain/entities'; +import { WEEKDAYS } from '../../../../../../../../core/domain/configuration-field-validators'; + +export function FormWeekdays() { + const { formControl } = useFormPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + const weekdayRows = (formControl.watch('weekdayRows') as CycleWeekdayRow[] | undefined) ?? []; + + return ( + + {t('section_weekdays')} + {WEEKDAYS.map((weekday, index) => { + const row = weekdayRows[index]; + const enabled = Boolean(row?.enabled); + return ( + + + {t(`weekday_${weekday}`)} + + + {enabled ? ( + + + + control={formControl.control} + name={`weekdayRows.${index}.startBranch`} + label={t('common:fields.startBranch')} + valueKey="id" + labelKey="name" + required + searchable + loadOptions={loadBranchOptions} + defaultOptions={(row?.startBranch ? [row.startBranch] : []) as any} + renderLabel={relationLabel} + /> + + control={formControl.control} + name={`weekdayRows.${index}.endBranch`} + label={t('common:fields.endBranch')} + valueKey="id" + labelKey="name" + required + searchable + loadOptions={loadBranchOptions} + defaultOptions={(row?.endBranch ? [row.endBranch] : []) as any} + renderLabel={relationLabel} + /> + + + + control={formControl.control} + name={`weekdayRows.${index}.customers`} + label={t('common:fields.customers')} + valueKey="id" + labelKey="name" + required + searchable + multiple + loadOptions={loadCustomerOptions} + defaultOptions={(row?.customers ?? []) as any} + renderLabel={relationLabel} + /> + + + ) : null} + + ); + })} + + ); +} diff --git a/apps/web/src/apps/main/modules/field/cycles/presentation/components/index-component/filter-content.tsx b/apps/web/src/apps/main/modules/field/cycles/presentation/components/index-component/filter-content.tsx new file mode 100644 index 0000000..f7929a0 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/components/index-component/filter-content.tsx @@ -0,0 +1,40 @@ +import { SimpleGrid } from '@repo/ui/components'; +import { FieldAsyncSelect, FieldNumberInput, FieldSelect } from '@repo/ui/form'; +import { UseFormReturn } from 'react-hook-form'; +import { statusFilterOptions } from '../../../../../configuration/shared/status-filter-options'; +import { loadEmployeeOptions } from '../../../../shared/load-employee-options'; +import { relationLabel } from '../../../../shared/relation-label'; +import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities'; + +export const FilterFormContent = ({ form, t }: { form: UseFormReturn; t: (key: string) => string }) => { + return ( + + + control={form.control} + name="employee" + label={t('common:fields.employee')} + placeholder={t('common:fields.employee')} + valueKey="id" + labelKey="name" + clearable + searchable + loadOptions={loadEmployeeOptions} + renderLabel={relationLabel} + /> + + + + ); +}; diff --git a/apps/web/src/apps/main/modules/field/cycles/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/field/cycles/presentation/factory/index.tsx new file mode 100644 index 0000000..81e7aa0 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/factory/index.tsx @@ -0,0 +1,41 @@ +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 type { FieldPurpose } from '../../../../../../../core/domain/field-purpose'; +import { createCycleModuleConfig } from '../../domain/constants'; +import { logisticsCyclesDataService, salesCyclesDataService } from '../../domain/factories'; +import { CycleEntity } from '../../domain/entities'; +import { logisticsCyclesStore, salesCyclesStore } from '../store'; + +import cyclesId from '../languages/id/cycles.json'; +import cyclesEn from '../languages/en/cycles.json'; + +const IndexPage = lazy(() => import('../pages/cycle.page.index')); +const FormPage = lazy(() => import('../pages/cycle.page.form')); +const DetailPage = lazy(() => import('../pages/cycle.page.detail')); + +registerModuleNamespace('CYCLES', { + id: cyclesId, + en: cyclesEn, +}); + +export default function CyclesModule({ purpose }: { purpose: FieldPurpose }) { + const config = createCycleModuleConfig(purpose); + const dataService = purpose === 'sales' ? salesCyclesDataService : logisticsCyclesDataService; + const store = purpose === 'sales' ? salesCyclesStore : logisticsCyclesStore; + + return ( + config={config} dataServices={dataService} store={store}> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/cycles/presentation/languages/en/cycles.json b/apps/web/src/apps/main/modules/field/cycles/presentation/languages/en/cycles.json new file mode 100644 index 0000000..faefde3 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/languages/en/cycles.json @@ -0,0 +1,28 @@ +{ + "title": "{{purpose}} Cycles", + "detail_page_title": "Cycle Detail", + "create_page_title": "New Cycle", + "edit_page_title": "Edit Cycle", + "duplicate_page_title": "Duplicate Cycle", + "description": "Manage <1>weekly visit cycles for field employees.", + "detail_page_description": "Review the employee cycle, weekday routes, and destinations.", + "create_page_description": "Create a weekly visit cycle with start/end branches and customers.", + "edit_page_description": "Update the weekly visit cycle template.", + "duplicate_page_description": "Copy an existing cycle to create a new one.", + "section_general": "General", + "section_weekdays": "Weekday Routes", + "weekday_enabled": "Active day", + "purpose_sales": "Sales", + "purpose_logistics": "Logistics", + "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", + "empty_route": "No destinations" +} diff --git a/apps/web/src/apps/main/modules/field/cycles/presentation/languages/id/cycles.json b/apps/web/src/apps/main/modules/field/cycles/presentation/languages/id/cycles.json new file mode 100644 index 0000000..2a415fb --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/languages/id/cycles.json @@ -0,0 +1,28 @@ +{ + "title": "Siklus {{purpose}}", + "detail_page_title": "Detail Siklus", + "create_page_title": "Siklus Baru", + "edit_page_title": "Ubah Siklus", + "duplicate_page_title": "Duplikat Siklus", + "description": "Kelola <1>siklus kunjungan mingguan untuk karyawan lapangan.", + "detail_page_description": "Tinjau siklus karyawan, rute harian, dan destinasi.", + "create_page_description": "Buat siklus kunjungan mingguan dengan cabang awal/akhir dan pelanggan.", + "edit_page_description": "Perbarui template siklus kunjungan mingguan.", + "duplicate_page_description": "Salin siklus yang ada untuk membuat data baru.", + "section_general": "Umum", + "section_weekdays": "Rute Harian", + "weekday_enabled": "Hari aktif", + "purpose_sales": "Penjualan", + "purpose_logistics": "Logistik", + "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", + "empty_route": "Tidak ada destinasi" +} diff --git a/apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.detail.tsx b/apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.detail.tsx new file mode 100644 index 0000000..697e01e --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.detail.tsx @@ -0,0 +1,36 @@ +import { Stack } from '@repo/ui/components'; +import { + EnterpriseDetailPageProvider, + useEnterpriseModuleTranslationContext, + useEnterpriseModuleConfigContext, +} from '@repo/ui/foundations'; +import { purposeFromModuleKey } from '../../../../../../../core/domain/field-purpose'; +import { createCycleModuleConfig } from '../../domain/constants'; +import { DetailGeneral } from '../components/detail-component/detail-general'; +import { DetailWeekdays } from '../components/detail-component/detail-weekdays'; + +export default function CyclePageDetail() { + const { t } = useEnterpriseModuleTranslationContext(); + const { config } = useEnterpriseModuleConfigContext(); + const purpose = purposeFromModuleKey(config.moduleKey); + const moduleConfig = createCycleModuleConfig(purpose); + + return ( + + + + + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.form.tsx b/apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.form.tsx new file mode 100644 index 0000000..70b4aa2 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.form.tsx @@ -0,0 +1,64 @@ +import { useMemo } from 'react'; +import { Stack } from '@repo/ui/components'; +import { + useEnterpriseModuleTranslationContext, + useEnterpriseModuleConfigContext, + EnterpriseFormPageProvider, + FormPageType, +} from '@repo/ui/foundations'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { purposeFromModuleKey } from '../../../../../../../core/domain/field-purpose'; +import { createCycleModuleConfig, createEmptyWeekdayRows } from '../../domain/constants'; +import { createCycleSchema } from '../../domain/validators/cycle.validator'; +import { FormGeneral } from '../components/form-component/form-general'; +import { FormWeekdays } from '../components/form-component/form-weekdays'; + +export default function CyclePageForm({ formPageType }: { formPageType: FormPageType }) { + const { t } = useEnterpriseModuleTranslationContext(); + const { config } = useEnterpriseModuleConfigContext(); + const purpose = purposeFromModuleKey(config.moduleKey); + const moduleConfig = createCycleModuleConfig(purpose); + + 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(() => createCycleSchema(t), [t]); + const formControl = useForm({ + resolver: zodResolver(validator), + defaultValues: { cycleNumber: 1, weekdayRows: createEmptyWeekdayRows() }, + }); + + return ( + + + + + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.index.tsx b/apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.index.tsx new file mode 100644 index 0000000..caa1041 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/pages/cycle.page.index.tsx @@ -0,0 +1,63 @@ +import { useMemo } from 'react'; +import { + EnterpriseIndexPageProvider, + useEnterpriseModuleTranslationContext, + useEnterpriseModuleConfigContext, + EnterpriseDataTable, +} from '@repo/ui/foundations'; +import { ColDef, Text } from '@repo/ui/components'; +import { Trans } from '@repo/core-i18n'; +import { Repeat } from 'lucide-react'; +import { purposeFromModuleKey } from '../../../../../../../core/domain/field-purpose'; +import { FilterFormContent } from '../components/index-component/filter-content'; +import type { CycleEntity } from '../../domain/entities'; + +export default function CyclePageIndex() { + const { t } = useEnterpriseModuleTranslationContext(); + const { config } = useEnterpriseModuleConfigContext(); + const purpose = purposeFromModuleKey(config.moduleKey); + const columnDefs: ColDef[] = useMemo(() => { + return [ + { field: 'cycleNumber', headerName: t('common:fields.cycleNumber'), minWidth: 140 }, + { + field: 'employeeId', + headerName: t('common:fields.employee'), + minWidth: 180, + valueGetter: ({ data }) => data?.employee?.name || data?.employeeId, + }, + { + field: 'weekdayRows', + headerName: t('section_weekdays'), + minWidth: 140, + valueGetter: ({ data }) => data?.weekdayRows?.filter((row) => row.enabled).length ?? data?.weekdays?.length ?? 0, + }, + ]; + }, [t]); + + const filterConfig = useMemo(() => { + return { + renderBody: (form: any) => { + if (!form) return null; + return ; + }, + }; + }, [t]); + + return ( + }} /> + ), + icon: Repeat, + breadcrumbs: [ + { label: t(`nav:${purpose}`), type: 'text' }, + { label: t(`nav:${purpose}-cycles`), type: 'text' }, + ], + }} + > + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/cycles/presentation/store/index.ts b/apps/web/src/apps/main/modules/field/cycles/presentation/store/index.ts new file mode 100644 index 0000000..0062f25 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/cycles/presentation/store/index.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand'; +import { EnterpriseModuleState } from '@repo/ui/foundations'; +import { CycleEntity } from '../../domain/entities'; + +export interface CyclesStoreState extends EnterpriseModuleState {} + +export function createCycleStore() { + return 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 }), + })); +} + +export const salesCyclesStore = createCycleStore(); +export const logisticsCyclesStore = createCycleStore(); diff --git a/apps/web/src/apps/main/modules/field/logistics/index.tsx b/apps/web/src/apps/main/modules/field/logistics/index.tsx new file mode 100644 index 0000000..fae816d --- /dev/null +++ b/apps/web/src/apps/main/modules/field/logistics/index.tsx @@ -0,0 +1,15 @@ +import { lazy } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; + +const CyclesModule = lazy(() => import('../cycles/presentation/factory')); +const PlansModule = lazy(() => import('../plans/presentation/factory')); + +export default function LogisticsFieldModule() { + return ( + + } /> + } /> + } /> + + ); +} diff --git a/apps/web/src/apps/main/modules/field/plans/data/plan.remote.service.test.ts b/apps/web/src/apps/main/modules/field/plans/data/plan.remote.service.test.ts new file mode 100644 index 0000000..c323bdb --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/data/plan.remote.service.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AxiosInstance } from '@repo/core-api/http-client'; +import { PlansRemoteDataServices } from './plan.remote.service'; +import { PlansRemoteDataTransformer } from '../domain/transformers/plan.remote.transformer'; + +function createMockHttpClient(): AxiosInstance { + return { + request: vi.fn().mockResolvedValue({ data: { created: 2, skipped: 1 }, 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('PlansRemoteDataServices', () => { + let httpClient: AxiosInstance; + let service: PlansRemoteDataServices; + + beforeEach(() => { + httpClient = createMockHttpClient(); + service = new PlansRemoteDataServices( + httpClient, + { + apiUrl: '/plans', + moduleKey: 'SALES.PLAN', + transformer: new PlansRemoteDataTransformer('sales'), + }, + 'sales', + ); + }); + + it('generates plans with purpose injected', async () => { + await service.generate({ employeeId: 'emp-1', from: '2026-01-12', to: '2026-01-18' }); + expect(httpClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/plans/generate', + method: 'POST', + data: { employeeId: 'emp-1', from: '2026-01-12', to: '2026-01-18', purpose: 'sales' }, + }), + ); + }); + + it('adds a destination via POST /plans/:id/destinations', async () => { + vi.mocked(httpClient.request).mockResolvedValueOnce({ data: { id: 'plan-1' }, status: 200 }); + await service.addDestination('plan-1', { customerId: 'cus-2' }); + expect(httpClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/plans/plan-1/destinations', + method: 'POST', + data: { customerId: 'cus-2' }, + }), + ); + }); + + it('removes a destination via DELETE and expects 200', async () => { + vi.mocked(httpClient.request).mockResolvedValueOnce({ data: { id: 'plan-1' }, status: 200 }); + await service.removeDestination('plan-1', 'dest-1'); + expect(httpClient.request).toHaveBeenCalledWith( + expect.objectContaining({ + url: '/plans/plan-1/destinations/dest-1', + method: 'DELETE', + }), + ); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/plans/data/plan.remote.service.ts b/apps/web/src/apps/main/modules/field/plans/data/plan.remote.service.ts new file mode 100644 index 0000000..782fbaf --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/data/plan.remote.service.ts @@ -0,0 +1,49 @@ +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 { FieldPurpose } from '../../../../../../core/domain/field-purpose'; +import type { GeneratePlansPayload, GeneratePlansResult, PlanEntity } from '../domain/entities'; + +export class PlansRemoteDataServices extends TrackGoRemoteDataServices { + constructor( + httpClient: AxiosInstance, + config: DataServicesConfig, + private readonly purpose: FieldPurpose, + ) { + super(httpClient, { + ...config, + apiUrl: config.apiUrl ?? '/plans', + }); + } + + generate(payload: Omit) { + return this.customRequest({ + url: '/plans/generate', + method: 'POST', + data: { ...payload, purpose: this.purpose }, + }); + } + + async addDestination(planId: string, payload: { customerId: string; afterDestinationId?: string }) { + const result = await this.customRequest({ + url: `/plans/${planId}/destinations`, + method: 'POST', + data: payload, + }); + const entity = this.transformer?.transformToEntity + ? this.transformer.transformToEntity(result.data as PlanEntity) + : (result.data as PlanEntity); + return { ...result, data: entity }; + } + + async removeDestination(planId: string, destinationId: string) { + const result = await this.customRequest({ + url: `/plans/${planId}/destinations/${destinationId}`, + method: 'DELETE', + }); + const entity = this.transformer?.transformToEntity + ? this.transformer.transformToEntity(result.data as PlanEntity) + : (result.data as PlanEntity); + return { ...result, data: entity }; + } +} diff --git a/apps/web/src/apps/main/modules/field/plans/domain/constants/index.ts b/apps/web/src/apps/main/modules/field/plans/domain/constants/index.ts new file mode 100644 index 0000000..85b0326 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/constants/index.ts @@ -0,0 +1 @@ +export * from './plan.constants'; diff --git a/apps/web/src/apps/main/modules/field/plans/domain/constants/plan.constants.ts b/apps/web/src/apps/main/modules/field/plans/domain/constants/plan.constants.ts new file mode 100644 index 0000000..0b47f0d --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/constants/plan.constants.ts @@ -0,0 +1,14 @@ +import { ModuleConfigEntity } from '@repo/ui/foundations'; +import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose'; +import type { PlanEntity } from '../entities'; + +export function createPlanModuleConfig(purpose: FieldPurpose): ModuleConfigEntity { + return { + moduleKey: purpose === 'sales' ? 'SALES.PLAN' : 'LOGISTICS.PLAN', + translationNamespace: 'PLANS', + apiUrl: '/plans', + webUrl: `/app/${purpose}/plans`, + moduleCategory: 'FULL_PAGE', + moduleType: 'MASTER_DATA', + }; +} diff --git a/apps/web/src/apps/main/modules/field/plans/domain/entities/index.ts b/apps/web/src/apps/main/modules/field/plans/domain/entities/index.ts new file mode 100644 index 0000000..1c2e136 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/entities/index.ts @@ -0,0 +1 @@ +export * from './plan.entity'; diff --git a/apps/web/src/apps/main/modules/field/plans/domain/entities/plan.entity.ts b/apps/web/src/apps/main/modules/field/plans/domain/entities/plan.entity.ts new file mode 100644 index 0000000..3b83016 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/entities/plan.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@repo/core-api/data-services'; +import type { ConfigurationStatus } from '../../../../configuration/divisions/domain/entities'; +import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose'; +import type { RelationRef } from '../../../../../../../core/domain/relation-ref'; +import type { RouteGeometry } from '../../../cycles/domain/entities'; + +export interface PlanDestinationEntity { + id?: string; + customerId: string; + sortOrder: number; +} + +export interface PlanEntity extends BaseEntity { + employeeId: string; + employee?: RelationRef | null; + purpose: FieldPurpose; + date: string; + startBranchId: string; + startBranch?: RelationRef | null; + endBranchId: string; + endBranch?: RelationRef | null; + routeGeometry?: RouteGeometry | null; + destinations?: PlanDestinationEntity[]; + customers?: RelationRef[]; + invoiceIds?: string[]; + invoices?: RelationRef[]; + packingSlipIds?: string[]; + packingSlips?: RelationRef[]; + status?: ConfigurationStatus; + createdAt?: number; + updatedAt?: number; + createdBy?: string; + updatedBy?: string; +} + +export interface GeneratePlansPayload { + employeeId: string; + purpose: FieldPurpose; + from: string; + to: string; +} + +export interface GeneratePlansResult { + created: number; + skipped: number; +} diff --git a/apps/web/src/apps/main/modules/field/plans/domain/factories/index.ts b/apps/web/src/apps/main/modules/field/plans/domain/factories/index.ts new file mode 100644 index 0000000..1f7531d --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/factories/index.ts @@ -0,0 +1,22 @@ +import { apiClient } from '../../../../../../../core/lib/api-client'; +import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose'; +import { PlansRemoteDataServices } from '../../data/plan.remote.service'; +import { createPlanModuleConfig } from '../constants/plan.constants'; +import { PlansRemoteDataTransformer } from '../transformers/plan.remote.transformer'; + +export function createPlanDataService(purpose: FieldPurpose) { + const config = createPlanModuleConfig(purpose); + const transformer = new PlansRemoteDataTransformer(purpose); + return new PlansRemoteDataServices( + apiClient, + { + apiUrl: config.apiUrl, + moduleKey: config.moduleKey, + transformer, + }, + purpose, + ); +} + +export const salesPlansDataService = createPlanDataService('sales'); +export const logisticsPlansDataService = createPlanDataService('logistics'); diff --git a/apps/web/src/apps/main/modules/field/plans/domain/transformers/plan.remote.transformer.test.ts b/apps/web/src/apps/main/modules/field/plans/domain/transformers/plan.remote.transformer.test.ts new file mode 100644 index 0000000..04d9456 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/transformers/plan.remote.transformer.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { PlansRemoteDataTransformer } from './plan.remote.transformer'; + +const salesTransformer = new PlansRemoteDataTransformer('sales'); +const logisticsTransformer = new PlansRemoteDataTransformer('logistics'); + +const dto = { + id: 'plan-1', + employeeId: 'emp-1', + purpose: 'sales' as const, + date: Date.UTC(2026, 0, 12), + startBranchId: 'br-1', + endBranchId: 'br-2', + routeGeometry: { type: 'LineString' as const, coordinates: [[106.8, -6.2]] }, + destinations: [{ id: 'd-1', customerId: 'cus-1', sortOrder: 0 }], + invoiceIds: ['inv-1'], + packingSlipIds: ['ps-1'], + status: 'active' as const, + createdAt: 1, + updatedAt: 2, + createdBy: 'u1', + updatedBy: 'u2', +}; + +describe('PlansRemoteDataTransformer', () => { + it('converts unix date values to YYYY-MM-DD', () => { + const entity = salesTransformer.transformToEntity(dto as any); + expect(entity.date).toBe('2026-01-12'); + expect(entity.customers).toEqual([{ id: 'cus-1' }]); + }); + + it('injects purpose and sales invoice attachments on create', () => { + const payload = salesTransformer.transformCreatePayload({ + employee: { id: 'emp-1' }, + date: '2026-01-12', + startBranch: { id: 'br-1' }, + endBranch: { id: 'br-2' }, + customers: [{ id: 'cus-1' }], + invoices: [{ id: 'inv-1' }], + packingSlips: [{ id: 'ps-1' }], + } as any); + + expect(payload.purpose).toBe('sales'); + expect(payload.employeeId).toBe('emp-1'); + expect(payload.date).toBe('2026-01-12'); + expect(payload.invoiceIds).toEqual(['inv-1']); + expect(payload).not.toHaveProperty('packingSlipIds'); + expect(payload).not.toHaveProperty('employee'); + }); + + it('sends packing slips instead of invoices for logistics plans', () => { + const payload = logisticsTransformer.transformCreatePayload({ + employee: { id: 'emp-1' }, + date: '2026-01-12', + startBranch: { id: 'br-1' }, + endBranch: { id: 'br-2' }, + customers: [{ id: 'cus-1' }], + invoices: [{ id: 'inv-1' }], + packingSlips: [{ id: 'ps-1' }], + } as any); + + expect(payload.purpose).toBe('logistics'); + expect(payload.packingSlipIds).toEqual(['ps-1']); + expect(payload).not.toHaveProperty('invoiceIds'); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/plans/domain/transformers/plan.remote.transformer.ts b/apps/web/src/apps/main/modules/field/plans/domain/transformers/plan.remote.transformer.ts new file mode 100644 index 0000000..7c12ead --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/transformers/plan.remote.transformer.ts @@ -0,0 +1,92 @@ +import { BaseDataTransformer } from '@repo/core-api/data-services'; +import { formatDateValue, parseDateValue } from '@repo/ui/form'; +import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators'; +import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose'; +import type { PlanEntity } from '../entities'; + +function relationId(value: unknown): string | undefined { + if (value && typeof value === 'object' && 'id' in value) { + const id = (value as { id?: unknown }).id; + return id == null ? undefined : String(id); + } + return undefined; +} + +function relationIds(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => relationId(item)).filter((id): id is string => Boolean(id)); +} + +export class PlansRemoteDataTransformer extends BaseDataTransformer { + constructor(private readonly purpose: FieldPurpose) { + super(); + } + + transformToEntity(dto: PlanEntity): PlanEntity { + return { + id: dto.id, + employeeId: dto.employeeId, + employee: dto.employee ?? (dto.employeeId ? { id: dto.employeeId } : null), + purpose: dto.purpose ?? this.purpose, + date: formatDateValue(parseDateValue(dto.date) ?? dto.date), + startBranchId: dto.startBranchId, + startBranch: dto.startBranch ?? (dto.startBranchId ? { id: dto.startBranchId } : null), + endBranchId: dto.endBranchId, + endBranch: dto.endBranch ?? (dto.endBranchId ? { id: dto.endBranchId } : null), + routeGeometry: dto.routeGeometry ?? null, + destinations: dto.destinations ?? [], + customers: dto.customers ?? (dto.destinations ?? []).map((destination) => ({ id: destination.customerId })), + invoiceIds: dto.invoiceIds ?? [], + invoices: dto.invoices ?? (dto.invoiceIds ?? []).map((id) => ({ id })), + packingSlipIds: dto.packingSlipIds ?? [], + packingSlips: dto.packingSlips ?? (dto.packingSlipIds ?? []).map((id) => ({ id })), + status: dto.status, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + createdBy: dto.createdBy, + updatedBy: dto.updatedBy, + }; + } + + transformToDTO(entity: PlanEntity): PlanEntity { + return { ...entity }; + } + + private toWritePayload(entity: Partial) { + const customerIds = relationIds(entity.customers); + const payload: Record = { + employeeId: relationId(entity.employee) ?? entity.employeeId, + purpose: this.purpose, + date: formatDateValue(entity.date), + startBranchId: relationId(entity.startBranch) ?? entity.startBranchId, + endBranchId: relationId(entity.endBranch) ?? entity.endBranchId, + customerIds, + }; + if (this.purpose === 'sales') { + payload.invoiceIds = relationIds(entity.invoices).length ? relationIds(entity.invoices) : entity.invoiceIds; + } else { + payload.packingSlipIds = relationIds(entity.packingSlips).length ? relationIds(entity.packingSlips) : entity.packingSlipIds; + } + return payload; + } + + transformCreatePayload(entity: Partial): Partial { + return omitEmptyFields(this.toWritePayload(entity)) as Partial; + } + + transformEditPayload(entity: Partial): Partial { + return this.toWritePayload(entity) as Partial; + } + + transformPayloadFilter(filter: Record): Record { + const next: Record = { ...filter, purpose: this.purpose }; + if (next.employee && typeof next.employee === 'object') { + next.employeeId = next.employee.id; + delete next.employee; + } + if (next.date) { + next.date = formatDateValue(next.date); + } + return omitEmptyFields(next); + } +} diff --git a/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.test.ts b/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.test.ts new file mode 100644 index 0000000..f29778e --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { createPlanSchema } from './plan.validator'; + +describe('createPlanSchema', () => { + const t = (key: string) => key; + const schema = createPlanSchema(t); + const valid = { + employee: { id: 'emp-1' }, + date: '2026-01-12', + startBranch: { id: 'br-1' }, + endBranch: { id: 'br-2' }, + customers: [{ id: 'cus-1' }], + }; + + it('accepts a complete plan', () => { + expect(schema.safeParse(valid).success).toBe(true); + }); + + it('rejects a missing date', () => { + expect(schema.safeParse({ ...valid, date: '' }).success).toBe(false); + }); + + it('rejects empty customers', () => { + expect(schema.safeParse({ ...valid, customers: [] }).success).toBe(false); + }); +}); diff --git a/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.ts b/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.ts new file mode 100644 index 0000000..457c5de --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/domain/validators/plan.validator.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; +const relationSchema = z.object({ + id: z.string(), + code: z.string().optional(), + name: z.string().optional(), +}); + +export const createPlanSchema = (t: (key: string) => string) => { + return z + .object({ + employee: relationSchema.nullable().optional(), + date: z.string().min(1), + startBranch: relationSchema.nullable().optional(), + endBranch: relationSchema.nullable().optional(), + customers: z.array(relationSchema).optional(), + invoices: z.array(relationSchema).optional(), + packingSlips: z.array(relationSchema).optional(), + }) + .superRefine((value, ctx) => { + if (!value.employee?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['employee'], + message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.employee') } }), + }); + } + if (!value.startBranch?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['startBranch'], + message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.startBranch') } }), + }); + } + if (!value.endBranch?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['endBranch'], + message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.endBranch') } }), + }); + } + if (!value.customers?.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['customers'], + message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.customers') } }), + }); + } + }); +}; + +export const createGeneratePlansSchema = (t: (key: string) => string) => { + return z + .object({ + employee: relationSchema.nullable().optional(), + from: z.string().min(1), + to: z.string().min(1), + }) + .superRefine((value, ctx) => { + if (!value.employee?.id) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['employee'], + message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.employee') } }), + }); + } + }); +}; diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/detail-component/detail-general.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/components/detail-component/detail-general.tsx new file mode 100644 index 0000000..a86b337 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/detail-component/detail-general.tsx @@ -0,0 +1,98 @@ +import { ActionIcon, Box, Button, FieldAsyncSelect, FieldValue, Group, Paper, RenderDate, SimpleGrid, Stack, StatusBadge, Text, notifications } from '@repo/ui/components'; +import { RouteMap } from '@repo/ui/map'; +import { useDetailPageContext, useEnterpriseModuleTranslationContext, useEnterpriseModuleDataServiceContext } from '@repo/ui/foundations'; +import { useForm } from 'react-hook-form'; +import { Plus, Trash2 } from 'lucide-react'; +import { relationLabel } from '../../../../shared/relation-label'; +import { loadCustomerOptions } from '../../../../shared/load-customer-options'; +import type { PlansRemoteDataServices } from '../../../data/plan.remote.service'; +import type { PlanEntity } from '../../../domain/entities'; +import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities'; + +export function DetailGeneral() { + const { detailData, reload } = useDetailPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + const { dataServices } = useEnterpriseModuleDataServiceContext(); + const data = detailData; + const destinationForm = useForm<{ customer: CustomerEntity | null }>({ defaultValues: { customer: null } }); + + const handleAdd = destinationForm.handleSubmit(async (values) => { + if (!data?.id || !values.customer?.id) return; + await dataServices.addDestination(String(data.id), { customerId: String(values.customer.id) }); + notifications.show({ color: 'green', message: t('add_destination') }); + destinationForm.reset(); + await reload(); + }); + + const handleRemove = async (destinationId?: string) => { + if (!data?.id || !destinationId || (data.destinations?.length ?? 0) <= 1) return; + await dataServices.removeDestination(String(data.id), destinationId); + notifications.show({ color: 'green', message: t('remove_destination') }); + await reload(); + }; + + return ( + + + + {t('section_general')} + + + + } /> + + + } /> + + + + + + {t('section_route')} + + + + + + + {t('section_destinations')} + + + {(data?.destinations ?? []).map((destination, index) => ( + + + {index + 1}. {destination.customerId} + + handleRemove(destination.id)} + > + + + + ))} + + + + + control={destinationForm.control as any} + name="customer" + label={t('common:fields.customers')} + valueKey="id" + labelKey="name" + searchable + loadOptions={loadCustomerOptions} + renderLabel={relationLabel} + /> + + + + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-general.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-general.tsx new file mode 100644 index 0000000..6aec4b2 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/form-component/form-general.tsx @@ -0,0 +1,118 @@ +import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Text } from '@repo/ui/components'; +import { useEnterpriseModuleTranslationContext, useFormPageContext, useEnterpriseModuleConfigContext } from '@repo/ui/foundations'; +import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose'; +import { loadEmployeeOptions } from '../../../../shared/load-employee-options'; +import { loadBranchOptions } from '../../../../shared/load-branch-options'; +import { loadCustomerOptions } from '../../../../shared/load-customer-options'; +import { loadSalesInvoiceOptions, loadPackingSlipOptions } from '../../../../shared/lookup.factories'; +import { relationLabel } from '../../../../shared/relation-label'; +import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities'; +import type { BranchEntity } from '../../../../../configuration/branches/domain/entities'; +import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities'; +import type { LookupEntity } from '../../../../shared/lookup.entity'; + +export function FormGeneral() { + const { formControl } = useFormPageContext(); + const { t } = useEnterpriseModuleTranslationContext(); + const { config } = useEnterpriseModuleConfigContext(); + const purpose = purposeFromModuleKey(config.moduleKey); + const employee = formControl.watch('employee'); + const startBranch = formControl.watch('startBranch'); + const endBranch = formControl.watch('endBranch'); + const customers = formControl.watch('customers') ?? []; + const invoices = formControl.watch('invoices') ?? []; + const packingSlips = formControl.watch('packingSlips') ?? []; + + return ( + + + {t('section_general')} + + + + + control={formControl.control} + name="employee" + label={t('common:fields.employee')} + valueKey="id" + labelKey="name" + required + searchable + loadOptions={loadEmployeeOptions} + defaultOptions={employee ? [employee] : []} + renderLabel={relationLabel} + /> + + + control={formControl.control} + name="startBranch" + label={t('common:fields.startBranch')} + valueKey="id" + labelKey="name" + required + searchable + loadOptions={loadBranchOptions} + defaultOptions={startBranch ? [startBranch] : []} + renderLabel={relationLabel} + /> + + control={formControl.control} + name="endBranch" + label={t('common:fields.endBranch')} + valueKey="id" + labelKey="name" + required + searchable + loadOptions={loadBranchOptions} + defaultOptions={endBranch ? [endBranch] : []} + renderLabel={relationLabel} + /> + + + + control={formControl.control} + name="customers" + label={t('common:fields.customers')} + valueKey="id" + labelKey="name" + required + searchable + multiple + loadOptions={loadCustomerOptions} + defaultOptions={customers} + renderLabel={relationLabel} + /> + + + {purpose === 'sales' ? ( + + control={formControl.control} + name="invoices" + label={t('common:fields.invoices')} + valueKey="id" + labelKey="code" + searchable + multiple + loadOptions={loadSalesInvoiceOptions} + defaultOptions={invoices} + renderLabel={relationLabel} + /> + ) : ( + + control={formControl.control} + name="packingSlips" + label={t('common:fields.packingSlips')} + valueKey="id" + labelKey="code" + searchable + multiple + loadOptions={loadPackingSlipOptions} + defaultOptions={packingSlips} + renderLabel={relationLabel} + /> + )} + + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/filter-content.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/filter-content.tsx new file mode 100644 index 0000000..39b3cdc --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/filter-content.tsx @@ -0,0 +1,33 @@ +import { SimpleGrid } from '@repo/ui/components'; +import { FieldAsyncSelect, FieldDatePicker, FieldSelect } from '@repo/ui/form'; +import { UseFormReturn } from 'react-hook-form'; +import { statusFilterOptions } from '../../../../../configuration/shared/status-filter-options'; +import { loadEmployeeOptions } from '../../../../shared/load-employee-options'; +import { relationLabel } from '../../../../shared/relation-label'; +import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities'; + +export const FilterFormContent = ({ form, t }: { form: UseFormReturn; t: (key: string) => string }) => { + return ( + + + control={form.control} + name="employee" + label={t('common:fields.employee')} + valueKey="id" + labelKey="name" + clearable + searchable + loadOptions={loadEmployeeOptions} + renderLabel={relationLabel} + /> + + + + ); +}; diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/generate-modal.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/generate-modal.tsx new file mode 100644 index 0000000..15509b0 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/components/index-component/generate-modal.tsx @@ -0,0 +1,63 @@ +import { useMemo } from 'react'; +import { Button, FieldAsyncSelect, FieldDatePicker, Group, Modal, Stack, notifications } from '@repo/ui/components'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useEnterpriseModuleTranslationContext, useEnterpriseModuleDataServiceContext } from '@repo/ui/foundations'; +import { loadEmployeeOptions } from '../../../../shared/load-employee-options'; +import { relationLabel } from '../../../../shared/relation-label'; +import { createGeneratePlansSchema } from '../../../domain/validators/plan.validator'; +import type { PlansRemoteDataServices } from '../../../data/plan.remote.service'; +import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities'; +import type { PlanEntity } from '../../../domain/entities'; + +export function GeneratePlansModal({ opened, onClose }: { opened: boolean; onClose: () => void }) { + const { t } = useEnterpriseModuleTranslationContext(); + const { dataServices } = useEnterpriseModuleDataServiceContext(); + const validator = useMemo(() => createGeneratePlansSchema(t), [t]); + const form = useForm({ resolver: zodResolver(validator) }); + + const handleSubmit = form.handleSubmit(async (values) => { + const result = await dataServices.generate({ + employeeId: values.employee?.id ?? '', + from: values.from, + to: values.to, + }); + notifications.show({ + color: 'green', + message: t('generate_success', { + created: result.data?.created ?? 0, + skipped: result.data?.skipped ?? 0, + }), + }); + onClose(); + form.reset(); + }); + + return ( + +
+ + + control={form.control as any} + name="employee" + label={t('common:fields.employee')} + valueKey="id" + labelKey="name" + required + searchable + loadOptions={loadEmployeeOptions} + renderLabel={relationLabel} + /> + + + + + + + +
+
+ ); +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/factory/index.tsx new file mode 100644 index 0000000..6014843 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/factory/index.tsx @@ -0,0 +1,41 @@ +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 type { FieldPurpose } from '../../../../../../../core/domain/field-purpose'; +import { createPlanModuleConfig } from '../../domain/constants'; +import { logisticsPlansDataService, salesPlansDataService } from '../../domain/factories'; +import { PlanEntity } from '../../domain/entities'; +import { logisticsPlansStore, salesPlansStore } from '../store'; + +import plansId from '../languages/id/plans.json'; +import plansEn from '../languages/en/plans.json'; + +const IndexPage = lazy(() => import('../pages/plan.page.index')); +const FormPage = lazy(() => import('../pages/plan.page.form')); +const DetailPage = lazy(() => import('../pages/plan.page.detail')); + +registerModuleNamespace('PLANS', { + id: plansId, + en: plansEn, +}); + +export default function PlansModule({ purpose }: { purpose: FieldPurpose }) { + const config = createPlanModuleConfig(purpose); + const dataService = purpose === 'sales' ? salesPlansDataService : logisticsPlansDataService; + const store = purpose === 'sales' ? salesPlansStore : logisticsPlansStore; + + return ( + config={config} dataServices={dataService} store={store}> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json b/apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json new file mode 100644 index 0000000..067f995 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/languages/en/plans.json @@ -0,0 +1,27 @@ +{ + "title": "{{purpose}} Plans", + "detail_page_title": "Plan Detail", + "create_page_title": "New Plan", + "edit_page_title": "Edit Plan", + "duplicate_page_title": "Duplicate Plan", + "description": "Manage <1>daily visit plans generated from employee cycles.", + "detail_page_description": "Review the route, destinations, and attached documents.", + "create_page_description": "Create a one-off daily plan for an employee.", + "edit_page_description": "Update plan date, branches, destinations, and attachments.", + "duplicate_page_description": "Copy an existing plan to create a new one.", + "section_general": "General", + "section_route": "Route", + "section_destinations": "Destinations", + "section_attachments": "Attachments", + "generate": "Generate", + "generate_title": "Generate plans", + "generate_success": "Created {{created}} plan(s), skipped {{skipped}}.", + "add_destination": "Add destination", + "remove_destination": "Remove destination", + "empty_route": "No route geometry", + "purpose_sales": "Sales", + "purpose_logistics": "Logistics", + "status_draft": "Draft", + "status_active": "Active", + "status_archived": "Archived" +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json b/apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json new file mode 100644 index 0000000..dc6c689 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/languages/id/plans.json @@ -0,0 +1,27 @@ +{ + "title": "Rencana {{purpose}}", + "detail_page_title": "Detail Rencana", + "create_page_title": "Rencana Baru", + "edit_page_title": "Ubah Rencana", + "duplicate_page_title": "Duplikat Rencana", + "description": "Kelola <1>rencana kunjungan harian yang dibuat dari siklus karyawan.", + "detail_page_description": "Tinjau rute, destinasi, dan dokumen terlampir.", + "create_page_description": "Buat rencana harian sekali jalan untuk karyawan.", + "edit_page_description": "Perbarui tanggal, cabang, destinasi, dan lampiran rencana.", + "duplicate_page_description": "Salin rencana yang ada untuk membuat data baru.", + "section_general": "Umum", + "section_route": "Rute", + "section_destinations": "Destinasi", + "section_attachments": "Lampiran", + "generate": "Generate", + "generate_title": "Generate rencana", + "generate_success": "Berhasil membuat {{created}} rencana, {{skipped}} dilewati.", + "add_destination": "Tambah destinasi", + "remove_destination": "Hapus destinasi", + "empty_route": "Tidak ada geometri rute", + "purpose_sales": "Penjualan", + "purpose_logistics": "Logistik", + "status_draft": "Draft", + "status_active": "Aktif", + "status_archived": "Diarsipkan" +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.detail.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.detail.tsx new file mode 100644 index 0000000..d234423 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.detail.tsx @@ -0,0 +1,31 @@ +import { + EnterpriseDetailPageProvider, + useEnterpriseModuleTranslationContext, + useEnterpriseModuleConfigContext, +} from '@repo/ui/foundations'; +import { purposeFromModuleKey } from '../../../../../../../core/domain/field-purpose'; +import { createPlanModuleConfig } from '../../domain/constants'; +import { DetailGeneral } from '../components/detail-component/detail-general'; + +export default function PlanPageDetail() { + const { t } = useEnterpriseModuleTranslationContext(); + const { config } = useEnterpriseModuleConfigContext(); + const purpose = purposeFromModuleKey(config.moduleKey); + const moduleConfig = createPlanModuleConfig(purpose); + + return ( + + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.form.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.form.tsx new file mode 100644 index 0000000..2e3e7e8 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.form.tsx @@ -0,0 +1,56 @@ +import { useMemo } from 'react'; +import { + useEnterpriseModuleTranslationContext, + useEnterpriseModuleConfigContext, + EnterpriseFormPageProvider, + FormPageType, +} from '@repo/ui/foundations'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { purposeFromModuleKey } from '../../../../../../../core/domain/field-purpose'; +import { createPlanModuleConfig } from '../../domain/constants'; +import { createPlanSchema } from '../../domain/validators/plan.validator'; +import { FormGeneral } from '../components/form-component/form-general'; + +export default function PlanPageForm({ formPageType }: { formPageType: FormPageType }) { + const { t } = useEnterpriseModuleTranslationContext(); + const { config } = useEnterpriseModuleConfigContext(); + const purpose = purposeFromModuleKey(config.moduleKey); + const moduleConfig = createPlanModuleConfig(purpose); + + 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(() => createPlanSchema(t), [t]); + const formControl = useForm({ resolver: zodResolver(validator) }); + + return ( + + + + ); +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.index.tsx b/apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.index.tsx new file mode 100644 index 0000000..b79719c --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/pages/plan.page.index.tsx @@ -0,0 +1,79 @@ +import { useMemo, useState } from 'react'; +import { + EnterpriseIndexPageProvider, + useEnterpriseModuleTranslationContext, + useEnterpriseModuleConfigContext, + EnterpriseDataTable, +} from '@repo/ui/foundations'; +import { ColDef, Text } from '@repo/ui/components'; +import { Trans } from '@repo/core-i18n'; +import { Calendar, CalendarPlus } from 'lucide-react'; +import { purposeFromModuleKey } from '../../../../../../../core/domain/field-purpose'; +import { FilterFormContent } from '../components/index-component/filter-content'; +import { GeneratePlansModal } from '../components/index-component/generate-modal'; +import type { PlanEntity } from '../../domain/entities'; + +export default function PlanPageIndex() { + const { t } = useEnterpriseModuleTranslationContext(); + const { config } = useEnterpriseModuleConfigContext(); + const purpose = purposeFromModuleKey(config.moduleKey); + const [generateOpened, setGenerateOpened] = useState(false); + + const columnDefs: ColDef[] = useMemo(() => { + return [ + { field: 'date', headerName: t('common:fields.date'), minWidth: 140 }, + { + field: 'employeeId', + headerName: t('common:fields.employee'), + minWidth: 180, + valueGetter: ({ data }) => data?.employee?.name || data?.employeeId, + }, + { + field: 'destinations', + headerName: t('section_destinations'), + minWidth: 140, + valueGetter: ({ data }) => data?.destinations?.length ?? 0, + }, + ]; + }, [t]); + + const filterConfig = useMemo(() => { + return { + renderBody: (form: any) => { + if (!form) return null; + return ; + }, + }; + }, [t]); + + return ( + }} /> + ), + icon: Calendar, + breadcrumbs: [ + { label: t(`nav:${purpose}`), type: 'text' }, + { label: t(`nav:${purpose}-plans`), type: 'text' }, + ], + }} + customPageActions={(actions) => [ + { + key: 'generate', + label: t('generate'), + icon: , + intent: 'primary', + variant: 'light', + tooltipLabel: t('generate'), + onClick: () => setGenerateOpened(true), + }, + ...(actions ?? []), + ]} + > + + setGenerateOpened(false)} /> + + ); +} diff --git a/apps/web/src/apps/main/modules/field/plans/presentation/store/index.ts b/apps/web/src/apps/main/modules/field/plans/presentation/store/index.ts new file mode 100644 index 0000000..83e1f75 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/plans/presentation/store/index.ts @@ -0,0 +1,23 @@ +import { create } from 'zustand'; +import { EnterpriseModuleState } from '@repo/ui/foundations'; +import { PlanEntity } from '../../domain/entities'; + +export interface PlansStoreState extends EnterpriseModuleState {} + +export function createPlanStore() { + return 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 }), + })); +} + +export const salesPlansStore = createPlanStore(); +export const logisticsPlansStore = createPlanStore(); diff --git a/apps/web/src/apps/main/modules/field/sales/index.tsx b/apps/web/src/apps/main/modules/field/sales/index.tsx new file mode 100644 index 0000000..f67bd30 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/sales/index.tsx @@ -0,0 +1,15 @@ +import { lazy } from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; + +const CyclesModule = lazy(() => import('../cycles/presentation/factory')); +const PlansModule = lazy(() => import('../plans/presentation/factory')); + +export default function SalesFieldModule() { + return ( + + } /> + } /> + } /> + + ); +} diff --git a/apps/web/src/apps/main/modules/field/shared/create-option-loader.ts b/apps/web/src/apps/main/modules/field/shared/create-option-loader.ts new file mode 100644 index 0000000..88290a6 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/shared/create-option-loader.ts @@ -0,0 +1,14 @@ +import type { LoadOptionsFn } from '@repo/ui/form'; + +export function createOptionLoader( + getMany: (config: { params: Record }) => Promise<{ data?: unknown }>, +): LoadOptionsFn { + return async (search, page) => { + const result = await getMany({ + params: { search, page, limit: 20 }, + }); + const rows = (result.data as { data?: T[]; 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/field/shared/load-branch-options.ts b/apps/web/src/apps/main/modules/field/shared/load-branch-options.ts new file mode 100644 index 0000000..4221cd6 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/shared/load-branch-options.ts @@ -0,0 +1,5 @@ +import { branchesDataService } from '../../configuration/branches/domain/factories'; +import type { BranchEntity } from '../../configuration/branches/domain/entities'; +import { createOptionLoader } from './create-option-loader'; + +export const loadBranchOptions = createOptionLoader((config) => branchesDataService.getMany(config)); diff --git a/apps/web/src/apps/main/modules/field/shared/load-customer-options.ts b/apps/web/src/apps/main/modules/field/shared/load-customer-options.ts new file mode 100644 index 0000000..3b76747 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/shared/load-customer-options.ts @@ -0,0 +1,5 @@ +import { customersDataService } from '../../configuration/customers/domain/factories'; +import type { CustomerEntity } from '../../configuration/customers/domain/entities'; +import { createOptionLoader } from './create-option-loader'; + +export const loadCustomerOptions = createOptionLoader((config) => customersDataService.getMany(config)); diff --git a/apps/web/src/apps/main/modules/field/shared/load-employee-options.ts b/apps/web/src/apps/main/modules/field/shared/load-employee-options.ts new file mode 100644 index 0000000..c20b1a2 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/shared/load-employee-options.ts @@ -0,0 +1,5 @@ +import { employeesDataService } from '../../configuration/employees/domain/factories'; +import type { EmployeeEntity } from '../../configuration/employees/domain/entities'; +import { createOptionLoader } from './create-option-loader'; + +export const loadEmployeeOptions = createOptionLoader((config) => employeesDataService.getMany(config)); diff --git a/apps/web/src/apps/main/modules/field/shared/lookup.entity.ts b/apps/web/src/apps/main/modules/field/shared/lookup.entity.ts new file mode 100644 index 0000000..56115b8 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/shared/lookup.entity.ts @@ -0,0 +1,6 @@ +import { BaseEntity } from '@repo/core-api/data-services'; + +export interface LookupEntity extends BaseEntity { + code?: string; + name?: string; +} diff --git a/apps/web/src/apps/main/modules/field/shared/lookup.factories.ts b/apps/web/src/apps/main/modules/field/shared/lookup.factories.ts new file mode 100644 index 0000000..792e96a --- /dev/null +++ b/apps/web/src/apps/main/modules/field/shared/lookup.factories.ts @@ -0,0 +1,22 @@ +import { apiClient } from '../../../../../core/lib/api-client'; +import { LookupRemoteDataServices } from './lookup.remote.service'; +import { createOptionLoader } from './create-option-loader'; +import type { LookupEntity } from './lookup.entity'; + +export const salesInvoicesDataService = new LookupRemoteDataServices(apiClient, { + apiUrl: '/sales-invoices', + moduleKey: 'SALES.INVOICE', +}); + +export const packingSlipsDataService = new LookupRemoteDataServices(apiClient, { + apiUrl: '/packing-slips', + moduleKey: 'SALES.PACKING_SLIP', +}); + +export const loadSalesInvoiceOptions = createOptionLoader((config) => + salesInvoicesDataService.getMany(config), +); + +export const loadPackingSlipOptions = createOptionLoader((config) => + packingSlipsDataService.getMany(config), +); diff --git a/apps/web/src/apps/main/modules/field/shared/lookup.remote.service.ts b/apps/web/src/apps/main/modules/field/shared/lookup.remote.service.ts new file mode 100644 index 0000000..f264490 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/shared/lookup.remote.service.ts @@ -0,0 +1,10 @@ +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 { LookupEntity } from './lookup.entity'; + +export class LookupRemoteDataServices extends TrackGoRemoteDataServices { + constructor(httpClient: AxiosInstance, config: DataServicesConfig) { + super(httpClient, config); + } +} diff --git a/apps/web/src/apps/main/modules/field/shared/relation-label.ts b/apps/web/src/apps/main/modules/field/shared/relation-label.ts new file mode 100644 index 0000000..5bdf681 --- /dev/null +++ b/apps/web/src/apps/main/modules/field/shared/relation-label.ts @@ -0,0 +1,7 @@ +export function relationLabel( + item: { code?: string | null; name?: string; id?: string | number } | null | undefined, +) { + if (!item) return ''; + if (item.code && item.name) return `${item.code} - ${item.name}`; + return item.name || item.code || (item.id == null ? '' : String(item.id)); +} diff --git a/apps/web/src/core/constants/api-url.ts b/apps/web/src/core/constants/api-url.ts index 0707540..9eeadc5 100644 --- a/apps/web/src/core/constants/api-url.ts +++ b/apps/web/src/core/constants/api-url.ts @@ -7,4 +7,9 @@ export const API_URL = { DIVISIONS: '/divisions', BRANCHES: '/branches', CUSTOMERS: '/customers', + EMPLOYEES: '/employees', + CYCLES: '/cycles', + PLANS: '/plans', + SALES_INVOICES: '/sales-invoices', + PACKING_SLIPS: '/packing-slips', } as const; diff --git a/apps/web/src/core/constants/module-key.ts b/apps/web/src/core/constants/module-key.ts index ada0767..f091968 100644 --- a/apps/web/src/core/constants/module-key.ts +++ b/apps/web/src/core/constants/module-key.ts @@ -3,4 +3,9 @@ export const MODULE_KEY = { CONFIGURATION_DIVISION: 'CONFIGURATION.DIVISION', CONFIGURATION_BRANCH: 'CONFIGURATION.BRANCH', CONFIGURATION_CUSTOMER: 'CONFIGURATION.CUSTOMER', + CONFIGURATION_EMPLOYEE: 'CONFIGURATION.EMPLOYEE', + SALES_CYCLE: 'SALES.CYCLE', + SALES_PLAN: 'SALES.PLAN', + LOGISTICS_CYCLE: 'LOGISTICS.CYCLE', + LOGISTICS_PLAN: 'LOGISTICS.PLAN', } as const; diff --git a/apps/web/src/core/constants/web-url.ts b/apps/web/src/core/constants/web-url.ts index 5a2661d..8f62224 100644 --- a/apps/web/src/core/constants/web-url.ts +++ b/apps/web/src/core/constants/web-url.ts @@ -2,4 +2,9 @@ export const WEB_URL = { DIVISIONS: '/app/configuration/divisions', BRANCHES: '/app/configuration/branches', CUSTOMERS: '/app/configuration/customers', + EMPLOYEES: '/app/configuration/employees', + SALES_CYCLES: '/app/sales/cycles', + SALES_PLANS: '/app/sales/plans', + LOGISTICS_CYCLES: '/app/logistics/cycles', + LOGISTICS_PLANS: '/app/logistics/plans', } as const; diff --git a/apps/web/src/core/domain/field-purpose.ts b/apps/web/src/core/domain/field-purpose.ts new file mode 100644 index 0000000..0a00448 --- /dev/null +++ b/apps/web/src/core/domain/field-purpose.ts @@ -0,0 +1,7 @@ +export type FieldPurpose = 'sales' | 'logistics'; + +export const FIELD_PURPOSES: FieldPurpose[] = ['sales', 'logistics']; + +export function purposeFromModuleKey(moduleKey: string): FieldPurpose { + return moduleKey.startsWith('LOGISTICS') ? 'logistics' : 'sales'; +} diff --git a/apps/web/src/core/domain/relation-ref.ts b/apps/web/src/core/domain/relation-ref.ts new file mode 100644 index 0000000..bf89f9d --- /dev/null +++ b/apps/web/src/core/domain/relation-ref.ts @@ -0,0 +1,5 @@ +export interface RelationRef { + id: string; + code?: string; + name?: string; +} diff --git a/packages/core-i18n/src/languages/en/common.json b/packages/core-i18n/src/languages/en/common.json index fd47a59..4d0c1d0 100644 --- a/packages/core-i18n/src/languages/en/common.json +++ b/packages/core-i18n/src/languages/en/common.json @@ -206,7 +206,19 @@ "startDate": "Start Date", "endDate": "End Date", "attachment": "Attachment", - "reference": "Reference" + "reference": "Reference", + "employee": "Employee", + "position": "Position", + "cycleNumber": "Cycle Number", + "purpose": "Purpose", + "startBranch": "Start Branch", + "endBranch": "End Branch", + "customers": "Customers", + "invoices": "Invoices", + "packingSlips": "Packing Slips", + "destinations": "Destinations", + "from": "From", + "to": "To" }, "systemPages": { "comingSoon": { @@ -235,4 +247,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/core-i18n/src/languages/id/common.json b/packages/core-i18n/src/languages/id/common.json index 5350c49..73792d5 100644 --- a/packages/core-i18n/src/languages/id/common.json +++ b/packages/core-i18n/src/languages/id/common.json @@ -206,7 +206,19 @@ "startDate": "Tanggal Mulai", "endDate": "Tanggal Selesai", "attachment": "Lampiran", - "reference": "Referensi" + "reference": "Referensi", + "employee": "Karyawan", + "position": "Posisi", + "cycleNumber": "Nomor Siklus", + "purpose": "Tujuan", + "startBranch": "Cabang Awal", + "endBranch": "Cabang Akhir", + "customers": "Pelanggan", + "invoices": "Faktur", + "packingSlips": "Surat Jalan", + "destinations": "Destinasi", + "from": "Dari", + "to": "Sampai" }, "systemPages": { "comingSoon": { @@ -235,4 +247,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 99f8acd..a63a8c3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -10,7 +10,8 @@ "./validators": "./src/validators/index.ts", "./foundations": "./src/foundations/index.ts", "./constants": "./src/constants/index.ts", - "./ag-grid": "./src/components/ag-grid/index.ts" + "./ag-grid": "./src/components/ag-grid/index.ts", + "./map": "./src/components/map/index.ts" }, "license": "MIT", "scripts": { @@ -25,6 +26,7 @@ "dependencies": { "@hookform/resolvers": "^5.0.1", "@mantine/core": "^8.3.15", + "@mantine/dates": "8.3.15", "@mantine/hooks": "^8.3.15", "@mantine/modals": "^8.3.15", "@mantine/notifications": "^8.3.15", @@ -43,8 +45,10 @@ "ag-grid-enterprise": "^36.0.1", "ag-grid-react": "^36.0.1", "dayjs": "^1.11.19", + "leaflet": "^1.9.4", "lucide-react": "^1.22.0", "react-hook-form": "^7.56.4", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.11.0", "tailwind-merge": "^3.4.0", "tailwind-variants": "^3.2.2", @@ -59,6 +63,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/leaflet": "^1.9.22", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", diff --git a/packages/ui/src/components/Form/__tests__/date-picker.field.test.tsx b/packages/ui/src/components/Form/__tests__/date-picker.field.test.tsx new file mode 100644 index 0000000..4d06b33 --- /dev/null +++ b/packages/ui/src/components/Form/__tests__/date-picker.field.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { useForm } from 'react-hook-form'; +import { MantineProvider } from '@mantine/core'; +import { FieldDatePicker } from '../fields/date-picker.field'; + +vi.mock('@repo/core-i18n', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { exists: () => false }, + }), +})); + +describe('FieldDatePicker', () => { + it('renders with a label', () => { + function TestForm() { + const { control } = useForm({ defaultValues: { date: '2026-01-12' } }); + return ( + + + + ); + } + + render(); + expect(screen.getByText('Plan date')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/Form/fields/date-picker.field.tsx b/packages/ui/src/components/Form/fields/date-picker.field.tsx new file mode 100644 index 0000000..5d9c584 --- /dev/null +++ b/packages/ui/src/components/Form/fields/date-picker.field.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { DatePickerInput, type DatePickerInputProps } from '@mantine/dates'; +import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form'; +import { useTranslatedError } from '../useTranslatedError'; +import { formatDateValue, parseDateValue } from './date-value'; + +type ManagedProps = 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error'; + +export type FieldDatePickerProps< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = UseControllerProps & Omit; + +function FieldDatePickerInner< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>(props: FieldDatePickerProps) { + const { name, control, rules, shouldUnregister, defaultValue, disabled, ...mantineProps } = props; + const { + field, + fieldState: { error }, + } = useController({ + name, + control, + rules, + shouldUnregister, + defaultValue, + disabled, + }); + const translatedError = useTranslatedError(error?.message); + + return ( + field.onChange(formatDateValue(next as Date | string | null))} + onBlur={field.onBlur} + error={translatedError} + disabled={field.disabled} + /> + ); +} + +export const FieldDatePicker = React.memo(FieldDatePickerInner) as typeof FieldDatePickerInner; +(FieldDatePicker as { displayName?: string }).displayName = 'FieldDatePicker'; diff --git a/packages/ui/src/components/Form/fields/date-value.test.ts b/packages/ui/src/components/Form/fields/date-value.test.ts new file mode 100644 index 0000000..639d2f5 --- /dev/null +++ b/packages/ui/src/components/Form/fields/date-value.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { formatDateValue, parseDateValue } from './date-value'; + +describe('parseDateValue', () => { + it('returns null for empty values', () => { + expect(parseDateValue(null)).toBeNull(); + expect(parseDateValue(undefined)).toBeNull(); + expect(parseDateValue('')).toBeNull(); + }); + + it('parses YYYY-MM-DD strings', () => { + const date = parseDateValue('2026-01-12'); + expect(date).toBeInstanceOf(Date); + expect(formatDateValue(date)).toBe('2026-01-12'); + }); + + it('parses unix milliseconds', () => { + const date = parseDateValue(new Date(2026, 0, 12).getTime()); + expect(formatDateValue(date)).toBe('2026-01-12'); + }); +}); + +describe('formatDateValue', () => { + it('formats a Date as YYYY-MM-DD', () => { + expect(formatDateValue(new Date(2026, 0, 12))).toBe('2026-01-12'); + }); + + it('returns an empty string for empty values', () => { + expect(formatDateValue(null)).toBe(''); + expect(formatDateValue(undefined)).toBe(''); + }); +}); diff --git a/packages/ui/src/components/Form/fields/date-value.ts b/packages/ui/src/components/Form/fields/date-value.ts new file mode 100644 index 0000000..e36f4ad --- /dev/null +++ b/packages/ui/src/components/Form/fields/date-value.ts @@ -0,0 +1,22 @@ +import dayjs from 'dayjs'; + +export const DATE_INPUT_FORMAT = 'YYYY-MM-DD'; + +export function parseDateValue(value: unknown): Date | null { + if (value == null || value === '') { + return null; + } + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value; + } + const parsed = typeof value === 'number' ? dayjs(value) : dayjs(String(value)); + return parsed.isValid() ? parsed.toDate() : null; +} + +export function formatDateValue(value: Date | string | null | undefined): string { + if (value == null || value === '') { + return ''; + } + const parsed = value instanceof Date ? dayjs(value) : dayjs(value); + return parsed.isValid() ? parsed.format(DATE_INPUT_FORMAT) : ''; +} diff --git a/packages/ui/src/components/Form/index.ts b/packages/ui/src/components/Form/index.ts index 7cbba4f..9af17fc 100644 --- a/packages/ui/src/components/Form/index.ts +++ b/packages/ui/src/components/Form/index.ts @@ -83,3 +83,10 @@ export { FieldColorPicker } from './fields/color-picker.field'; // File Fields // --------------------------------------------------------------------------- export { FieldFileInput } from './fields/file-input.field'; + +// --------------------------------------------------------------------------- +// Date Fields +// --------------------------------------------------------------------------- +export { FieldDatePicker } from './fields/date-picker.field'; +export type { FieldDatePickerProps } from './fields/date-picker.field'; +export { parseDateValue, formatDateValue } from './fields/date-value'; diff --git a/packages/ui/src/components/map/index.ts b/packages/ui/src/components/map/index.ts new file mode 100644 index 0000000..7f7b164 --- /dev/null +++ b/packages/ui/src/components/map/index.ts @@ -0,0 +1,4 @@ +export { RouteMap } from './route-map'; +export type { RouteMapProps } from './route-map'; +export { toLeafletLatLngs } from './route-geometry'; +export type { RouteGeometry } from './route-geometry'; diff --git a/packages/ui/src/components/map/route-geometry.test.ts b/packages/ui/src/components/map/route-geometry.test.ts new file mode 100644 index 0000000..2fc99a8 --- /dev/null +++ b/packages/ui/src/components/map/route-geometry.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { toLeafletLatLngs } from './route-geometry'; + +describe('toLeafletLatLngs', () => { + it('converts GeoJSON [lng, lat] pairs to Leaflet [lat, lng]', () => { + expect( + toLeafletLatLngs({ + type: 'LineString', + coordinates: [ + [106.8456, -6.2088], + [107.0, -6.3], + ], + }), + ).toEqual([ + [-6.2088, 106.8456], + [-6.3, 107.0], + ]); + }); + + it('returns an empty array when geometry is missing', () => { + expect(toLeafletLatLngs(null)).toEqual([]); + expect(toLeafletLatLngs(undefined)).toEqual([]); + expect(toLeafletLatLngs({ type: 'LineString', coordinates: [] })).toEqual([]); + }); +}); diff --git a/packages/ui/src/components/map/route-geometry.ts b/packages/ui/src/components/map/route-geometry.ts new file mode 100644 index 0000000..7e3496c --- /dev/null +++ b/packages/ui/src/components/map/route-geometry.ts @@ -0,0 +1,13 @@ +export interface RouteGeometry { + type: 'LineString'; + coordinates: Array<[number, number]>; +} + +export function toLeafletLatLngs(geometry?: RouteGeometry | null): Array<[number, number]> { + if (!geometry?.coordinates?.length) { + return []; + } + return geometry.coordinates + .filter((pair) => Array.isArray(pair) && pair.length >= 2) + .map(([lng, lat]) => [lat, lng]); +} diff --git a/packages/ui/src/components/map/route-map.tsx b/packages/ui/src/components/map/route-map.tsx new file mode 100644 index 0000000..5d9b4a4 --- /dev/null +++ b/packages/ui/src/components/map/route-map.tsx @@ -0,0 +1,53 @@ +import { useEffect } from 'react'; +import { CircleMarker, MapContainer, Polyline, TileLayer, Tooltip, useMap } from 'react-leaflet'; +import { Box, Text } from '@mantine/core'; +import type { RouteGeometry } from './route-geometry'; +import { toLeafletLatLngs } from './route-geometry'; +import 'leaflet/dist/leaflet.css'; + +function FitRouteBounds({ positions }: { positions: Array<[number, number]> }) { + const map = useMap(); + useEffect(() => { + if (positions.length === 0) return; + if (positions.length === 1) { + map.setView(positions[0], 14); + return; + } + map.fitBounds(positions, { padding: [24, 24] }); + }, [map, positions]); + return null; +} + +export interface RouteMapProps { + geometry?: RouteGeometry | null; + height?: number; +} + +export function RouteMap({ geometry, height = 280 }: RouteMapProps) { + const positions = toLeafletLatLngs(geometry); + + if (positions.length === 0) { + return ( + + + No route geometry + + + ); + } + + return ( + + + + + {positions.map((position, index) => ( + + {index + 1} + + ))} + + + + ); +} diff --git a/packages/ui/src/theme.css b/packages/ui/src/theme.css index 30316a4..134f9aa 100644 --- a/packages/ui/src/theme.css +++ b/packages/ui/src/theme.css @@ -5,6 +5,7 @@ @import '@mantine/core/styles.css'; @import '@mantine/tiptap/styles.css'; @import '@mantine/notifications/styles.css'; +@import '@mantine/dates/styles.css'; /* Initialize Tailwind CSS v4 engine */ @import 'tailwindcss'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baad5ee..d84063f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -239,7 +239,7 @@ importers: version: 5.4.17(@types/node@22.19.3) vitest: specifier: ^4.0.17 - version: 4.0.17(jsdom@26.1.0) + version: 4.0.17(@opentelemetry/api@1.9.1) apps/web: dependencies: @@ -333,7 +333,7 @@ importers: version: 5.4.17(@types/node@22.19.3) vitest: specifier: ^4.0.17 - version: 4.0.17(jsdom@26.1.0) + version: 4.0.17(@opentelemetry/api@1.9.1) packages/brand: devDependencies: @@ -551,7 +551,7 @@ importers: version: 5.5.4 vitest: specifier: ^4.0.17 - version: 4.0.17(jsdom@26.1.0) + version: 4.0.17(@opentelemetry/api@1.9.1) packages/ui: dependencies: @@ -561,6 +561,9 @@ importers: '@mantine/core': specifier: ^8.3.15 version: 8.3.15(@mantine/hooks@8.3.15)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3) + '@mantine/dates': + specifier: 8.3.15 + version: 8.3.15(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(dayjs@1.11.19)(react-dom@19.2.3)(react@19.2.3) '@mantine/hooks': specifier: ^8.3.15 version: 8.3.15(react@19.2.3) @@ -615,12 +618,18 @@ importers: dayjs: specifier: ^1.11.19 version: 1.11.19 + leaflet: + specifier: ^1.9.4 + version: 1.9.4 lucide-react: specifier: ^1.22.0 version: 1.22.0(react@19.2.3) react-hook-form: specifier: ^7.56.4 version: 7.79.0(react@19.2.3) + react-leaflet: + specifier: ^5.0.0 + version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.3)(react@19.2.3) react-router-dom: specifier: ^7.11.0 version: 7.11.0(react-dom@19.2.3)(react@19.2.3) @@ -658,6 +667,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) + '@types/leaflet': + specifier: ^1.9.22 + version: 1.9.22 '@types/react': specifier: ^19.2.7 version: 19.2.7 @@ -721,7 +733,7 @@ importers: version: 5.5.4 vitest: specifier: ^4.0.17 - version: 4.0.17(jsdom@26.1.0) + version: 4.0.17(@opentelemetry/api@1.9.1) packages: @@ -2071,6 +2083,23 @@ packages: - '@types/react' dev: false + /@mantine/dates@8.3.15(@mantine/core@8.3.15)(@mantine/hooks@8.3.15)(dayjs@1.11.19)(react-dom@19.2.3)(react@19.2.3): + resolution: {integrity: sha512-4WlGHCOAE4in88rQFNlPVl14e7WFWb+YBqxmx4rvAXLj9xLgUxYJO44fva1eIOwNPlTqwbx+GgsEr/HwlcmDMg==} + peerDependencies: + '@mantine/core': 8.3.15 + '@mantine/hooks': 8.3.15 + dayjs: '>=1.0.0' + react: ^18.x || ^19.x + react-dom: ^18.x || ^19.x + dependencies: + '@mantine/core': 8.3.15(@mantine/hooks@8.3.15)(@types/react@19.2.7)(react-dom@19.2.3)(react@19.2.3) + '@mantine/hooks': 8.3.15(react@19.2.3) + clsx: 2.1.1 + dayjs: 1.11.19 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + dev: false + /@mantine/hooks@8.3.15(react@19.2.3): resolution: {integrity: sha512-AUSnpUlzttHzJht3CJ1YWi16iy6NWRwtyWO5RLGHHsmiW05DyG0qOPKF8+R5dLHuOCnl3XOu4roI2Y1ku9U04Q==} peerDependencies: @@ -2620,6 +2649,18 @@ packages: resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} dev: false + /@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.3)(react@19.2.3): + resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==} + peerDependencies: + leaflet: ^1.9.0 + react: ^19.0.0 + react-dom: ^19.0.0 + dependencies: + leaflet: 1.9.4 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + dev: false + /@rolldown/pluginutils@1.0.0-beta.53: resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} dev: true @@ -3864,6 +3905,12 @@ packages: '@types/node': 22.19.3 dev: true + /@types/leaflet@1.9.22: + resolution: {integrity: sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==} + dependencies: + '@types/geojson': 7946.0.16 + dev: true + /@types/linkify-it@5.0.0: resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} dev: true @@ -8636,6 +8683,10 @@ packages: readable-stream: 2.3.8 dev: true + /leaflet@1.9.4: + resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==} + dev: false + /level-codec@9.0.2: resolution: {integrity: sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==} engines: {node: '>=6'} @@ -10538,6 +10589,19 @@ packages: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} dev: true + /react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.3)(react@19.2.3): + resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==} + peerDependencies: + leaflet: ^1.9.0 + react: ^19.0.0 + react-dom: ^19.0.0 + dependencies: + '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.3)(react@19.2.3) + leaflet: 1.9.4 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + dev: false + /react-number-format@5.4.4(react-dom@19.2.3)(react@19.2.3): resolution: {integrity: sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==} peerDependencies: