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