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:
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user