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>employees1> 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>karyawan1> 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 cycles1> 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 mingguan1> 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}
+ />
+
+ } onClick={handleAdd}>
+ {t('add_destination')}
+
+
+
+
+ );
+}
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 (
+
+
+
+ );
+}
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 plans1> 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 harian1> 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: