feat: introduce employees and logistics management modules
- Added new modules for managing employees and logistics, including routes for creating, editing, and viewing employee details and logistics cycles. - Implemented UI components for employee forms and detail views, with validation schemas for employee data. - Integrated language support for English and Indonesian in the new modules. - Developed unit tests for employee remote data services and transformers to ensure functionality and reliability. This commit enhances the application by providing structured management for employees and logistics, improving user experience and data handling.
This commit is contained in:
@@ -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() {
|
||||
<Route path="/system/notifications" element={<SystemNotification />} />
|
||||
<Route path="/system/privileges/*" element={<PrivilegesModule />} />
|
||||
<Route path="/configuration/*" element={<ConfigurationModule />} />
|
||||
<Route path="/sales/*" element={<SalesFieldModule />} />
|
||||
<Route path="/logistics/*" element={<LogisticsFieldModule />} />
|
||||
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+54
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
+13
@@ -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<EmployeeEntity> {
|
||||
constructor(httpClient: AxiosInstance, config: DataServicesConfig<EmployeeEntity>) {
|
||||
super(httpClient, {
|
||||
...config,
|
||||
apiUrl: config.apiUrl ?? '/employees',
|
||||
});
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { EmployeeEntity } from '../entities';
|
||||
|
||||
export const employeesModuleConfig: ModuleConfigEntity<EmployeeEntity> = {
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
translationNamespace: 'EMPLOYEES',
|
||||
apiUrl: '/employees',
|
||||
webUrl: '/app/configuration/employees',
|
||||
moduleCategory: 'FULL_PAGE',
|
||||
moduleType: 'MASTER_DATA',
|
||||
} as const;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './employee.constants';
|
||||
+30
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './employee.entity';
|
||||
@@ -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,
|
||||
});
|
||||
+55
@@ -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');
|
||||
});
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import type { EmployeeDto, EmployeeEntity } from '../entities';
|
||||
|
||||
export class EmployeesRemoteDataTransformer extends BaseDataTransformer<EmployeeEntity> {
|
||||
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<EmployeeEntity>): Partial<EmployeeEntity> {
|
||||
return {
|
||||
code: entity.code,
|
||||
name: entity.name,
|
||||
phone: entity.phone,
|
||||
position: entity.position,
|
||||
};
|
||||
}
|
||||
|
||||
transformEditPayload(entity: Partial<EmployeeEntity>): Partial<EmployeeEntity> {
|
||||
return {
|
||||
code: entity.code,
|
||||
name: entity.name,
|
||||
phone: entity.phone,
|
||||
position: entity.position,
|
||||
};
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
});
|
||||
});
|
||||
+18
@@ -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') } }),
|
||||
}),
|
||||
});
|
||||
};
|
||||
+44
@@ -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<EmployeeEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const data = detailData;
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
|
||||
<FieldValue label={t('common:fields.code')} value={data?.code} />
|
||||
<FieldValue label={t('common:fields.name')} value={data?.name} />
|
||||
<FieldValue label={t('common:fields.phone')} value={data?.phone} />
|
||||
<FieldValue
|
||||
label={t('common:fields.position')}
|
||||
value={data?.position}
|
||||
render={(val) => t(`position_${String(val ?? '')}`)}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.status')}
|
||||
value={data?.status}
|
||||
render={(val) => <StatusBadge status={String(val ?? '')} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.createdAt')}
|
||||
value={data?.createdAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.updatedAt')}
|
||||
value={data?.updatedAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+52
@@ -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 (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name="code"
|
||||
label={t('common:fields.code')}
|
||||
placeholder="e.g. EMP_01"
|
||||
required
|
||||
radius="md"
|
||||
/>
|
||||
<FieldTextInput
|
||||
name="name"
|
||||
control={formControl.control}
|
||||
label={t('common:fields.name')}
|
||||
placeholder="e.g. Ada Lovelace"
|
||||
required
|
||||
radius="md"
|
||||
/>
|
||||
<FieldTextInput
|
||||
control={formControl.control}
|
||||
name="phone"
|
||||
label={t('common:fields.phone')}
|
||||
placeholder="+6281234567890"
|
||||
required
|
||||
radius="md"
|
||||
/>
|
||||
<FieldSelect
|
||||
control={formControl.control}
|
||||
name="position"
|
||||
label={t('common:fields.position')}
|
||||
data={EMPLOYEE_POSITIONS.map((value) => ({ value, label: t(`position_${value}`) }))}
|
||||
required
|
||||
radius="md"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+44
@@ -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<any>; t: (key: string) => string }) => {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldTextInput
|
||||
control={form.control}
|
||||
name="code"
|
||||
label={t('common:fields.code')}
|
||||
placeholder={`Enter ${t('common:fields.code')}`}
|
||||
/>
|
||||
<FieldTextInput
|
||||
control={form.control}
|
||||
name="name"
|
||||
label={t('common:fields.name')}
|
||||
placeholder={`Enter ${t('common:fields.name')}`}
|
||||
/>
|
||||
<FieldTextInput
|
||||
control={form.control}
|
||||
name="phone"
|
||||
label={t('common:fields.phone')}
|
||||
placeholder={`Enter ${t('common:fields.phone')}`}
|
||||
/>
|
||||
<FieldSelect
|
||||
control={form.control}
|
||||
name="position"
|
||||
label={t('common:fields.position')}
|
||||
clearable
|
||||
data={EMPLOYEE_POSITIONS.map((value) => ({ value, label: t(`position_${value}`) }))}
|
||||
/>
|
||||
<FieldSelect
|
||||
control={form.control}
|
||||
name="status"
|
||||
label={t('common:fields.status')}
|
||||
clearable
|
||||
data={statusFilterOptions(t)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<EnterpriseModuleProvider<EmployeeEntity>
|
||||
config={employeesModuleConfig}
|
||||
dataServices={employeesDataService}
|
||||
store={employeesStore}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/detail/:dataId" element={<DetailPage />} />
|
||||
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
|
||||
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
|
||||
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
|
||||
<Route path="/" element={<Navigate to={`${employeesModuleConfig.webUrl}/index`} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
);
|
||||
}
|
||||
+19
@@ -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</1> 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"
|
||||
}
|
||||
+19
@@ -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</1> 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"
|
||||
}
|
||||
+23
@@ -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 (
|
||||
<EnterpriseDetailPageProvider
|
||||
highlightDataKey="code"
|
||||
pageHeaderProps={{
|
||||
title: t('detail_page_title'),
|
||||
description: t('detail_page_description'),
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:configuration'), type: 'text' },
|
||||
{ label: t('nav:configuration-employees'), type: 'link', href: `${employeesModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<DetailGeneral />
|
||||
</EnterpriseDetailPageProvider>
|
||||
);
|
||||
}
|
||||
+47
@@ -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 (
|
||||
<EnterpriseFormPageProvider
|
||||
formControl={formControl}
|
||||
formPageType={formPageType}
|
||||
ignoreKeyDuplicate={['code']}
|
||||
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id']}
|
||||
highlightDataKey="code"
|
||||
pageHeaderProps={{
|
||||
title: title?.title,
|
||||
description: title?.description,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:configuration'), type: 'text' },
|
||||
{ label: t('nav:configuration-employees'), type: 'link', href: `${employeesModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<FormGeneral />
|
||||
</EnterpriseFormPageProvider>
|
||||
);
|
||||
}
|
||||
+56
@@ -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<EmployeeEntity>[] = 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 <FilterFormContent form={form} t={t} />;
|
||||
},
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<EnterpriseIndexPageProvider
|
||||
pageHeaderProps={{
|
||||
title: t('title'),
|
||||
description: (
|
||||
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
|
||||
),
|
||||
icon: Users,
|
||||
breadcrumbs: [
|
||||
{ label: t('nav:configuration'), type: 'text' },
|
||||
{ label: t('nav:configuration-employees'), type: 'text' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
|
||||
</EnterpriseIndexPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||
import { EmployeeEntity } from '../../domain/entities';
|
||||
|
||||
export interface EmployeesStoreState extends EnterpriseModuleState<EmployeeEntity> {}
|
||||
|
||||
export const employeesStore = create<EmployeesStoreState>((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 }),
|
||||
}));
|
||||
@@ -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() {
|
||||
<Route path="/divisions/*" element={<DivisionsModule />} />
|
||||
<Route path="/branches/*" element={<BranchesModule />} />
|
||||
<Route path="/customers/*" element={<CustomersModule />} />
|
||||
<Route path="/employees/*" element={<EmployeesModule />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -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' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<CycleEntity> {
|
||||
constructor(httpClient: AxiosInstance, config: DataServicesConfig<CycleEntity>) {
|
||||
super(httpClient, {
|
||||
...config,
|
||||
apiUrl: config.apiUrl ?? '/cycles',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<CycleEntity> {
|
||||
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: [],
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './cycle.constants';
|
||||
@@ -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<string, { startBranchId: string; endBranchId: string; customerIds: string[] }>;
|
||||
status?: ConfigurationStatus;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
createdBy?: string;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface CycleWeekdayWrite {
|
||||
startBranchId: string;
|
||||
endBranchId: string;
|
||||
customerIds: string[];
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './cycle.entity';
|
||||
@@ -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');
|
||||
+76
@@ -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' });
|
||||
});
|
||||
});
|
||||
+100
@@ -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<string, CycleWeekdayWrite> {
|
||||
const next: Record<string, CycleWeekdayWrite> = {};
|
||||
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<CycleEntity> {
|
||||
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<CycleEntity>): Partial<CycleEntity> {
|
||||
return omitEmptyFields({
|
||||
employeeId: relationId(entity.employee) ?? entity.employeeId,
|
||||
purpose: this.purpose,
|
||||
cycleNumber: entity.cycleNumber,
|
||||
weekdays: toWeekdayObject(entity.weekdayRows),
|
||||
}) as Partial<CycleEntity>;
|
||||
}
|
||||
|
||||
transformEditPayload(entity: Partial<CycleEntity>): Partial<CycleEntity> {
|
||||
return {
|
||||
employeeId: relationId(entity.employee) ?? entity.employeeId,
|
||||
purpose: this.purpose,
|
||||
cycleNumber: entity.cycleNumber,
|
||||
weekdays: toWeekdayObject(entity.weekdayRows),
|
||||
} as unknown as Partial<CycleEntity>;
|
||||
}
|
||||
|
||||
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
|
||||
const next: Record<string, any> = { ...filter, purpose: this.purpose };
|
||||
if (next.employee && typeof next.employee === 'object') {
|
||||
next.employeeId = next.employee.id;
|
||||
delete next.employee;
|
||||
}
|
||||
return omitEmptyFields(next);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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') } }),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
+40
@@ -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<CycleEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const data = detailData;
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
|
||||
<FieldValue label={t('common:fields.employee')} value={relationLabel(data?.employee) || data?.employeeId} />
|
||||
<FieldValue label={t('common:fields.cycleNumber')} value={data?.cycleNumber} />
|
||||
<FieldValue label={t('common:fields.purpose')} value={t(`purpose_${data?.purpose ?? ''}`)} />
|
||||
<FieldValue
|
||||
label={t('common:fields.status')}
|
||||
value={data?.status}
|
||||
render={(val) => <StatusBadge status={String(val ?? '')} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.createdAt')}
|
||||
value={data?.createdAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.updatedAt')}
|
||||
value={data?.updatedAt}
|
||||
render={(val) => <RenderDate value={val as any} />}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+39
@@ -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<CycleEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const weekdays = detailData?.weekdays ?? [];
|
||||
|
||||
if (weekdays.length === 0) {
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_weekdays')}
|
||||
</Text>
|
||||
<Text c="dimmed">{t('empty_route')}</Text>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{weekdays.map((row) => (
|
||||
<Paper key={row.id ?? row.weekday} withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t(`weekday_${row.weekday}`)}
|
||||
</Text>
|
||||
<Text size="sm" mb="md">
|
||||
{(row.destinations ?? []).map((destination, index) => `${index + 1}. ${destination.customerId}`).join(' | ') || t('empty_route')}
|
||||
</Text>
|
||||
<Box>
|
||||
<RouteMap geometry={row.routeGeometry} />
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+44
@@ -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 (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldAsyncSelect<EmployeeEntity>
|
||||
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}
|
||||
/>
|
||||
<FieldNumberInput
|
||||
control={formControl.control}
|
||||
name="cycleNumber"
|
||||
label={t('common:fields.cycleNumber')}
|
||||
min={1}
|
||||
required
|
||||
hideControls
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+82
@@ -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 (
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>{t('section_weekdays')}</Text>
|
||||
{WEEKDAYS.map((weekday, index) => {
|
||||
const row = weekdayRows[index];
|
||||
const enabled = Boolean(row?.enabled);
|
||||
return (
|
||||
<Paper key={weekday} withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t(`weekday_${weekday}`)}
|
||||
</Text>
|
||||
<FieldSwitch
|
||||
control={formControl.control}
|
||||
name={`weekdayRows.${index}.enabled`}
|
||||
label={t('weekday_enabled')}
|
||||
/>
|
||||
{enabled ? (
|
||||
<Box mt="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
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}
|
||||
/>
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
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}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Box mt="md">
|
||||
<FieldAsyncSelect<CustomerEntity>
|
||||
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}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+40
@@ -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<any>; t: (key: string) => string }) => {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldAsyncSelect<EmployeeEntity>
|
||||
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}
|
||||
/>
|
||||
<FieldNumberInput
|
||||
control={form.control}
|
||||
name="cycleNumber"
|
||||
label={t('common:fields.cycleNumber')}
|
||||
min={1}
|
||||
hideControls
|
||||
/>
|
||||
<FieldSelect
|
||||
control={form.control}
|
||||
name="status"
|
||||
label={t('common:fields.status')}
|
||||
clearable
|
||||
data={statusFilterOptions(t)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<EnterpriseModuleProvider<CycleEntity> config={config} dataServices={dataService} store={store}>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/detail/:dataId" element={<DetailPage />} />
|
||||
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
|
||||
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
|
||||
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
|
||||
<Route path="/" element={<Navigate to={`${config.webUrl}/index`} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
);
|
||||
}
|
||||
@@ -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</1> 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"
|
||||
}
|
||||
@@ -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</1> 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"
|
||||
}
|
||||
@@ -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 (
|
||||
<EnterpriseDetailPageProvider
|
||||
highlightDataKey="cycleNumber"
|
||||
pageHeaderProps={{
|
||||
title: t('detail_page_title'),
|
||||
description: t('detail_page_description'),
|
||||
breadcrumbs: [
|
||||
{ label: t(`nav:${purpose}`), type: 'text' },
|
||||
{ label: t(`nav:${purpose}-cycles`), type: 'link', href: `${moduleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<DetailGeneral />
|
||||
<DetailWeekdays />
|
||||
</Stack>
|
||||
</EnterpriseDetailPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<EnterpriseFormPageProvider
|
||||
formControl={formControl}
|
||||
formPageType={formPageType}
|
||||
ignoreKeyDuplicate={['id']}
|
||||
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id', 'purpose', 'employeeId', 'weekdays', 'routeGeometry']}
|
||||
highlightDataKey="cycleNumber"
|
||||
pageHeaderProps={{
|
||||
title: title?.title,
|
||||
description: title?.description,
|
||||
breadcrumbs: [
|
||||
{ label: t(`nav:${purpose}`), type: 'text' },
|
||||
{ label: t(`nav:${purpose}-cycles`), type: 'link', href: `${moduleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<FormGeneral />
|
||||
<FormWeekdays />
|
||||
</Stack>
|
||||
</EnterpriseFormPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -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<CycleEntity>[] = 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 <FilterFormContent form={form} t={t} />;
|
||||
},
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<EnterpriseIndexPageProvider
|
||||
pageHeaderProps={{
|
||||
title: t('title', { purpose: t(`purpose_${purpose}`) }),
|
||||
description: (
|
||||
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
|
||||
),
|
||||
icon: Repeat,
|
||||
breadcrumbs: [
|
||||
{ label: t(`nav:${purpose}`), type: 'text' },
|
||||
{ label: t(`nav:${purpose}-cycles`), type: 'text' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
|
||||
</EnterpriseIndexPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand';
|
||||
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||
import { CycleEntity } from '../../domain/entities';
|
||||
|
||||
export interface CyclesStoreState extends EnterpriseModuleState<CycleEntity> {}
|
||||
|
||||
export function createCycleStore() {
|
||||
return create<CyclesStoreState>((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();
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/cycles/*" element={<CyclesModule purpose="logistics" />} />
|
||||
<Route path="/plans/*" element={<PlansModule purpose="logistics" />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<PlanEntity> {
|
||||
constructor(
|
||||
httpClient: AxiosInstance,
|
||||
config: DataServicesConfig<PlanEntity>,
|
||||
private readonly purpose: FieldPurpose,
|
||||
) {
|
||||
super(httpClient, {
|
||||
...config,
|
||||
apiUrl: config.apiUrl ?? '/plans',
|
||||
});
|
||||
}
|
||||
|
||||
generate(payload: Omit<GeneratePlansPayload, 'purpose'>) {
|
||||
return this.customRequest<GeneratePlansResult>({
|
||||
url: '/plans/generate',
|
||||
method: 'POST',
|
||||
data: { ...payload, purpose: this.purpose },
|
||||
});
|
||||
}
|
||||
|
||||
async addDestination(planId: string, payload: { customerId: string; afterDestinationId?: string }) {
|
||||
const result = await this.customRequest<PlanEntity>({
|
||||
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<PlanEntity>({
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './plan.constants';
|
||||
@@ -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<PlanEntity> {
|
||||
return {
|
||||
moduleKey: purpose === 'sales' ? 'SALES.PLAN' : 'LOGISTICS.PLAN',
|
||||
translationNamespace: 'PLANS',
|
||||
apiUrl: '/plans',
|
||||
webUrl: `/app/${purpose}/plans`,
|
||||
moduleCategory: 'FULL_PAGE',
|
||||
moduleType: 'MASTER_DATA',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './plan.entity';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
+66
@@ -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');
|
||||
});
|
||||
});
|
||||
+92
@@ -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<PlanEntity> {
|
||||
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<PlanEntity>) {
|
||||
const customerIds = relationIds(entity.customers);
|
||||
const payload: Record<string, unknown> = {
|
||||
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<PlanEntity>): Partial<PlanEntity> {
|
||||
return omitEmptyFields(this.toWritePayload(entity)) as Partial<PlanEntity>;
|
||||
}
|
||||
|
||||
transformEditPayload(entity: Partial<PlanEntity>): Partial<PlanEntity> {
|
||||
return this.toWritePayload(entity) as Partial<PlanEntity>;
|
||||
}
|
||||
|
||||
transformPayloadFilter(filter: Record<string, any>): Record<string, any> {
|
||||
const next: Record<string, any> = { ...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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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') } }),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
+98
@@ -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<PlanEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const { dataServices } = useEnterpriseModuleDataServiceContext<PlanEntity, PlansRemoteDataServices>();
|
||||
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 (
|
||||
<Stack gap="md">
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" verticalSpacing="xl">
|
||||
<FieldValue label={t('common:fields.employee')} value={relationLabel(data?.employee) || data?.employeeId} />
|
||||
<FieldValue label={t('common:fields.date')} value={data?.date} render={(val) => <RenderDate value={val as any} />} />
|
||||
<FieldValue label={t('common:fields.startBranch')} value={relationLabel(data?.startBranch) || data?.startBranchId} />
|
||||
<FieldValue label={t('common:fields.endBranch')} value={relationLabel(data?.endBranch) || data?.endBranchId} />
|
||||
<FieldValue label={t('common:fields.status')} value={data?.status} render={(val) => <StatusBadge status={String(val ?? '')} />} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_route')}
|
||||
</Text>
|
||||
<RouteMap geometry={data?.routeGeometry} />
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_destinations')}
|
||||
</Text>
|
||||
<Stack gap="sm" mb="md">
|
||||
{(data?.destinations ?? []).map((destination, index) => (
|
||||
<Group key={destination.id ?? destination.customerId} justify="space-between">
|
||||
<Text size="sm">
|
||||
{index + 1}. {destination.customerId}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t('remove_destination')}
|
||||
disabled={(data?.destinations?.length ?? 0) <= 1}
|
||||
onClick={() => handleRemove(destination.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
<Group align="flex-end">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<FieldAsyncSelect<CustomerEntity>
|
||||
control={destinationForm.control as any}
|
||||
name="customer"
|
||||
label={t('common:fields.customers')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
searchable
|
||||
loadOptions={loadCustomerOptions}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</Box>
|
||||
<Button leftSection={<Plus size={16} />} onClick={handleAdd}>
|
||||
{t('add_destination')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+118
@@ -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 (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldAsyncSelect<EmployeeEntity>
|
||||
control={formControl.control}
|
||||
name="employee"
|
||||
label={t('common:fields.employee')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadEmployeeOptions}
|
||||
defaultOptions={employee ? [employee] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldDatePicker control={formControl.control} name="date" label={t('common:fields.date')} required />
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={formControl.control}
|
||||
name="startBranch"
|
||||
label={t('common:fields.startBranch')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadBranchOptions}
|
||||
defaultOptions={startBranch ? [startBranch] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={formControl.control}
|
||||
name="endBranch"
|
||||
label={t('common:fields.endBranch')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadBranchOptions}
|
||||
defaultOptions={endBranch ? [endBranch] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Box mt="md">
|
||||
<FieldAsyncSelect<CustomerEntity>
|
||||
control={formControl.control}
|
||||
name="customers"
|
||||
label={t('common:fields.customers')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadCustomerOptions}
|
||||
defaultOptions={customers}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</Box>
|
||||
<Box mt="md">
|
||||
{purpose === 'sales' ? (
|
||||
<FieldAsyncSelect<LookupEntity>
|
||||
control={formControl.control}
|
||||
name="invoices"
|
||||
label={t('common:fields.invoices')}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadSalesInvoiceOptions}
|
||||
defaultOptions={invoices}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
) : (
|
||||
<FieldAsyncSelect<LookupEntity>
|
||||
control={formControl.control}
|
||||
name="packingSlips"
|
||||
label={t('common:fields.packingSlips')}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadPackingSlipOptions}
|
||||
defaultOptions={packingSlips}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
+33
@@ -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<any>; t: (key: string) => string }) => {
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldAsyncSelect<EmployeeEntity>
|
||||
control={form.control}
|
||||
name="employee"
|
||||
label={t('common:fields.employee')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
clearable
|
||||
searchable
|
||||
loadOptions={loadEmployeeOptions}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldDatePicker control={form.control} name="date" label={t('common:fields.date')} clearable />
|
||||
<FieldSelect
|
||||
control={form.control}
|
||||
name="status"
|
||||
label={t('common:fields.status')}
|
||||
clearable
|
||||
data={statusFilterOptions(t)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
);
|
||||
};
|
||||
+63
@@ -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<PlanEntity, PlansRemoteDataServices>();
|
||||
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 (
|
||||
<Modal opened={opened} onClose={onClose} title={t('generate_title')}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<FieldAsyncSelect<EmployeeEntity>
|
||||
control={form.control as any}
|
||||
name="employee"
|
||||
label={t('common:fields.employee')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadEmployeeOptions}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldDatePicker control={form.control as any} name="from" label={t('common:fields.from')} required />
|
||||
<FieldDatePicker control={form.control as any} name="to" label={t('common:fields.to')} required />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" type="button" onClick={onClose}>
|
||||
{t('common:cancel')}
|
||||
</Button>
|
||||
<Button type="submit">{t('generate')}</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<EnterpriseModuleProvider<PlanEntity> config={config} dataServices={dataService} store={store}>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/detail/:dataId" element={<DetailPage />} />
|
||||
<Route path="/edit/:dataId" element={<FormPage formPageType="EDIT" />} />
|
||||
<Route path="/duplicate/:dataId" element={<FormPage formPageType="DUPLICATE" />} />
|
||||
<Route path="/create" element={<FormPage formPageType="CREATE" />} />
|
||||
<Route path="/" element={<Navigate to={`${config.webUrl}/index`} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
);
|
||||
}
|
||||
@@ -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</1> 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"
|
||||
}
|
||||
@@ -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</1> 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"
|
||||
}
|
||||
@@ -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 (
|
||||
<EnterpriseDetailPageProvider
|
||||
highlightDataKey="date"
|
||||
pageHeaderProps={{
|
||||
title: t('detail_page_title'),
|
||||
description: t('detail_page_description'),
|
||||
breadcrumbs: [
|
||||
{ label: t(`nav:${purpose}`), type: 'text' },
|
||||
{ label: t(`nav:${purpose}-plans`), type: 'link', href: `${moduleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<DetailGeneral />
|
||||
</EnterpriseDetailPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<EnterpriseFormPageProvider
|
||||
formControl={formControl}
|
||||
formPageType={formPageType}
|
||||
ignoreKeyDuplicate={['id']}
|
||||
ignoreKeyUpdate={['status', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'id', 'purpose', 'employeeId', 'destinations', 'routeGeometry', 'invoiceIds', 'packingSlipIds']}
|
||||
highlightDataKey="date"
|
||||
pageHeaderProps={{
|
||||
title: title?.title,
|
||||
description: title?.description,
|
||||
breadcrumbs: [
|
||||
{ label: t(`nav:${purpose}`), type: 'text' },
|
||||
{ label: t(`nav:${purpose}-plans`), type: 'link', href: `${moduleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<FormGeneral />
|
||||
</EnterpriseFormPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -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<PlanEntity>[] = 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 <FilterFormContent form={form} t={t} />;
|
||||
},
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<EnterpriseIndexPageProvider
|
||||
pageHeaderProps={{
|
||||
title: t('title', { purpose: t(`purpose_${purpose}`) }),
|
||||
description: (
|
||||
<Trans t={t} i18nKey="description" components={{ 1: <Text span fw={500} c="var(--mantine-color-text)" /> }} />
|
||||
),
|
||||
icon: Calendar,
|
||||
breadcrumbs: [
|
||||
{ label: t(`nav:${purpose}`), type: 'text' },
|
||||
{ label: t(`nav:${purpose}-plans`), type: 'text' },
|
||||
],
|
||||
}}
|
||||
customPageActions={(actions) => [
|
||||
{
|
||||
key: 'generate',
|
||||
label: t('generate'),
|
||||
icon: <CalendarPlus size={16} />,
|
||||
intent: 'primary',
|
||||
variant: 'light',
|
||||
tooltipLabel: t('generate'),
|
||||
onClick: () => setGenerateOpened(true),
|
||||
},
|
||||
...(actions ?? []),
|
||||
]}
|
||||
>
|
||||
<EnterpriseDataTable columnDefs={columnDefs} filterConfig={filterConfig} />
|
||||
<GeneratePlansModal opened={generateOpened} onClose={() => setGenerateOpened(false)} />
|
||||
</EnterpriseIndexPageProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { create } from 'zustand';
|
||||
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||
import { PlanEntity } from '../../domain/entities';
|
||||
|
||||
export interface PlansStoreState extends EnterpriseModuleState<PlanEntity> {}
|
||||
|
||||
export function createPlanStore() {
|
||||
return create<PlansStoreState>((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();
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/cycles/*" element={<CyclesModule purpose="sales" />} />
|
||||
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
|
||||
export function createOptionLoader<T>(
|
||||
getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>,
|
||||
): LoadOptionsFn<T> {
|
||||
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 };
|
||||
};
|
||||
}
|
||||
@@ -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<BranchEntity>((config) => branchesDataService.getMany(config));
|
||||
@@ -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<CustomerEntity>((config) => customersDataService.getMany(config));
|
||||
@@ -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<EmployeeEntity>((config) => employeesDataService.getMany(config));
|
||||
@@ -0,0 +1,6 @@
|
||||
import { BaseEntity } from '@repo/core-api/data-services';
|
||||
|
||||
export interface LookupEntity extends BaseEntity {
|
||||
code?: string;
|
||||
name?: string;
|
||||
}
|
||||
@@ -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<LookupEntity>((config) =>
|
||||
salesInvoicesDataService.getMany(config),
|
||||
);
|
||||
|
||||
export const loadPackingSlipOptions = createOptionLoader<LookupEntity>((config) =>
|
||||
packingSlipsDataService.getMany(config),
|
||||
);
|
||||
@@ -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<LookupEntity> {
|
||||
constructor(httpClient: AxiosInstance, config: DataServicesConfig<LookupEntity>) {
|
||||
super(httpClient, config);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface RelationRef {
|
||||
id: string;
|
||||
code?: string;
|
||||
name?: string;
|
||||
}
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<MantineProvider>
|
||||
<FieldDatePicker name="date" control={control} label="Plan date" />
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestForm />);
|
||||
expect(screen.getByText('Plan date')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = UseControllerProps<TFieldValues, TName> & Omit<DatePickerInputProps, ManagedProps>;
|
||||
|
||||
function FieldDatePickerInner<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>(props: FieldDatePickerProps<TFieldValues, TName>) {
|
||||
const { name, control, rules, shouldUnregister, defaultValue, disabled, ...mantineProps } = props;
|
||||
const {
|
||||
field,
|
||||
fieldState: { error },
|
||||
} = useController<TFieldValues, TName>({
|
||||
name,
|
||||
control,
|
||||
rules,
|
||||
shouldUnregister,
|
||||
defaultValue,
|
||||
disabled,
|
||||
});
|
||||
const translatedError = useTranslatedError(error?.message);
|
||||
|
||||
return (
|
||||
<DatePickerInput
|
||||
{...mantineProps}
|
||||
valueFormat="YYYY-MM-DD"
|
||||
value={parseDateValue(field.value)}
|
||||
onChange={(next) => 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';
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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) : '';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 (
|
||||
<Box h={height} bdrs="md" bd="1px solid var(--mantine-color-default-border)" p="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
No route geometry
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h={height} bdrs="md" style={{ overflow: 'hidden' }}>
|
||||
<MapContainer center={positions[0]} zoom={12} style={{ height: '100%', width: '100%' }} scrollWheelZoom>
|
||||
<TileLayer attribution="© OpenStreetMap contributors" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<Polyline positions={positions} pathOptions={{ color: 'var(--mantine-color-blue-6)', weight: 4 }} />
|
||||
{positions.map((position, index) => (
|
||||
<CircleMarker key={`${position[0]}-${position[1]}-${index}`} center={position} radius={8} pathOptions={{ color: 'var(--mantine-color-blue-8)' }}>
|
||||
<Tooltip permanent>{index + 1}</Tooltip>
|
||||
</CircleMarker>
|
||||
))}
|
||||
<FitRouteBounds positions={positions} />
|
||||
</MapContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Generated
+68
-4
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user