feat: add sales timeline module and company settings configuration
- Introduced a new sales timeline module with routes and lazy loading for efficient loading. - Updated privilege keys in `api.md` to include `ADMIN.SALES.ACTIVITIES.TIMELINE` for access control. - Enhanced menu data to include the timeline option, improving navigation. - Added company settings module with configuration options for cycle start date and check-in radius. - Implemented remote services and data handling for company settings, ensuring accurate data management. - Enhanced language support for both English and Indonesian in navigation and company settings. These changes significantly improve the application's functionality by adding a timeline feature for sales activities and a comprehensive settings module for company configurations, enhancing user experience and data management.
This commit is contained in:
@@ -258,6 +258,7 @@ Catalog (`GET /privilege-keys`, needs `ADMIN.SETTINGS.USER.PRIVILEGES` `view`).
|
|||||||
| `ADMIN.SALES.ACTIVITIES.INVOICE` | Sales invoices |
|
| `ADMIN.SALES.ACTIVITIES.INVOICE` | Sales invoices |
|
||||||
| `ADMIN.SALES.ACTIVITIES.PAYMENT` | Sales payments |
|
| `ADMIN.SALES.ACTIVITIES.PAYMENT` | Sales payments |
|
||||||
| `ADMIN.SALES.ACTIVITIES.PLAN` | Sales plans |
|
| `ADMIN.SALES.ACTIVITIES.PLAN` | Sales plans |
|
||||||
|
| `ADMIN.SALES.ACTIVITIES.TIMELINE` | Sales timeline |
|
||||||
| `ADMIN.SALES.REPORT` | Sales reports |
|
| `ADMIN.SALES.REPORT` | Sales reports |
|
||||||
| `ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP` | Packing slips |
|
| `ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP` | Packing slips |
|
||||||
| `ADMIN.LOGISTICS.DATA.CYCLE` | Logistics cycles |
|
| `ADMIN.LOGISTICS.DATA.CYCLE` | Logistics cycles |
|
||||||
@@ -266,6 +267,7 @@ Catalog (`GET /privilege-keys`, needs `ADMIN.SETTINGS.USER.PRIVILEGES` `view`).
|
|||||||
| `MOBILE.SALES.PLAN` | Sales plans (mobile) |
|
| `MOBILE.SALES.PLAN` | Sales plans (mobile) |
|
||||||
| `MOBILE.SALES.PLAN.ATTENDANCE` | Branch attendance |
|
| `MOBILE.SALES.PLAN.ATTENDANCE` | Branch attendance |
|
||||||
| `MOBILE.SALES.VISIT` | Customer visits |
|
| `MOBILE.SALES.VISIT` | Customer visits |
|
||||||
|
| `MOBILE.SALES.TIMELINE` | Sales timeline (mobile) |
|
||||||
|
|
||||||
### Field purpose
|
### Field purpose
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const PrivilegesModule = lazy(() => import('./modules/system/privileges/presenta
|
|||||||
const UsersModule = lazy(() => import('./modules/system/users/presentation/factory'));
|
const UsersModule = lazy(() => import('./modules/system/users/presentation/factory'));
|
||||||
const ConfigurationModule = lazy(() => import('./modules/configuration'));
|
const ConfigurationModule = lazy(() => import('./modules/configuration'));
|
||||||
const SalesModule = lazy(() => import('./modules/sales'));
|
const SalesModule = lazy(() => import('./modules/sales'));
|
||||||
|
const TimelineModule = lazy(() => import('./modules/field/timeline/presentation/factory'));
|
||||||
const LogisticsFieldModule = lazy(() => import('./modules/field/logistics'));
|
const LogisticsFieldModule = lazy(() => import('./modules/field/logistics'));
|
||||||
|
|
||||||
export default function AppModule() {
|
export default function AppModule() {
|
||||||
@@ -26,6 +27,7 @@ export default function AppModule() {
|
|||||||
<Route path="/system/users/*" element={<UsersModule />} />
|
<Route path="/system/users/*" element={<UsersModule />} />
|
||||||
<Route path="/configuration/*" element={<ConfigurationModule />} />
|
<Route path="/configuration/*" element={<ConfigurationModule />} />
|
||||||
<Route path="/sales/*" element={<SalesModule />} />
|
<Route path="/sales/*" element={<SalesModule />} />
|
||||||
|
<Route path="/timeline/*" element={<TimelineModule />} />
|
||||||
<Route path="/logistics/*" element={<LogisticsFieldModule />} />
|
<Route path="/logistics/*" element={<LogisticsFieldModule />} />
|
||||||
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
|
|||||||
@@ -13,8 +13,17 @@ const flatten = (items: MenuItemType[]): MenuItemType[] =>
|
|||||||
items.flatMap((item) => [item, ...(item.children ? flatten(item.children) : [])]);
|
items.flatMap((item) => [item, ...(item.children ? flatten(item.children) : [])]);
|
||||||
|
|
||||||
describe('MENU_ITEMS', () => {
|
describe('MENU_ITEMS', () => {
|
||||||
it('orders top-level items as dashboard, sales, logistics, settings', () => {
|
it('orders top-level items as dashboard, timeline, sales, logistics, settings', () => {
|
||||||
expect(MENU_ITEMS.map((item) => item.key)).toEqual(['dashboard', 'sales', 'logistics', 'settings']);
|
expect(MENU_ITEMS.map((item) => item.key)).toEqual(['dashboard', 'timeline', 'sales', 'logistics', 'settings']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places timeline next to dashboard instead of inside sales activities', () => {
|
||||||
|
const timeline = findItem(MENU_ITEMS, 'timeline');
|
||||||
|
const sales = findItem(MENU_ITEMS, 'sales');
|
||||||
|
|
||||||
|
expect(timeline?.path).toBe('/app/timeline/index');
|
||||||
|
expect(timeline?.moduleKey).toBe('ADMIN.SALES.ACTIVITIES.TIMELINE');
|
||||||
|
expect(childKeys(findItem(sales?.children ?? [], 'sales-activities'))).not.toContain('sales-timeline');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nests sales as data, activities, then reports', () => {
|
it('nests sales as data, activities, then reports', () => {
|
||||||
@@ -58,6 +67,7 @@ describe('MENU_ITEMS', () => {
|
|||||||
'configuration-divisions',
|
'configuration-divisions',
|
||||||
'configuration-customers',
|
'configuration-customers',
|
||||||
'configuration-products',
|
'configuration-products',
|
||||||
|
'configuration-company-settings',
|
||||||
]);
|
]);
|
||||||
expect(childKeys(findItem(settings?.children ?? [], 'settings-user'))).toEqual([
|
expect(childKeys(findItem(settings?.children ?? [], 'settings-user'))).toEqual([
|
||||||
'system-users',
|
'system-users',
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ export const MENU_ITEMS: MenuItemType[] = [
|
|||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
path: '/app/dashboard',
|
path: '/app/dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'timeline',
|
||||||
|
label: 'nav:timeline',
|
||||||
|
icon: MapPin,
|
||||||
|
path: '/app/timeline/index',
|
||||||
|
moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'sales',
|
key: 'sales',
|
||||||
label: 'nav:sales',
|
label: 'nav:sales',
|
||||||
@@ -212,6 +219,13 @@ export const MENU_ITEMS: MenuItemType[] = [
|
|||||||
path: '/app/configuration/products/index',
|
path: '/app/configuration/products/index',
|
||||||
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'configuration-company-settings',
|
||||||
|
label: 'nav:configuration-company-settings',
|
||||||
|
icon: Settings,
|
||||||
|
path: '/app/configuration/company-settings/index',
|
||||||
|
moduleKey: 'ADMIN.SETTINGS.DATA.SETTING',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
|
"timeline": "Timeline",
|
||||||
"crm": "CRM",
|
"crm": "CRM",
|
||||||
"crm-leads": "Leads",
|
"crm-leads": "Leads",
|
||||||
"crm-pipelines": "Pipelines",
|
"crm-pipelines": "Pipelines",
|
||||||
@@ -55,5 +56,6 @@
|
|||||||
"logistics-plans": "Logistics Plans",
|
"logistics-plans": "Logistics Plans",
|
||||||
"logistics-packing-slips": "Packing Slips",
|
"logistics-packing-slips": "Packing Slips",
|
||||||
"configuration-employees": "Employees",
|
"configuration-employees": "Employees",
|
||||||
"configuration-products": "Products"
|
"configuration-products": "Products",
|
||||||
|
"configuration-company-settings": "Company settings"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"dashboard": "Dasbor",
|
"dashboard": "Dasbor",
|
||||||
|
"timeline": "Timeline",
|
||||||
"crm": "CRM",
|
"crm": "CRM",
|
||||||
"crm-leads": "Prospek",
|
"crm-leads": "Prospek",
|
||||||
"crm-pipelines": "Alur Penjualan",
|
"crm-pipelines": "Alur Penjualan",
|
||||||
@@ -55,5 +56,6 @@
|
|||||||
"logistics-plans": "Rencana Logistik",
|
"logistics-plans": "Rencana Logistik",
|
||||||
"logistics-packing-slips": "Surat Jalan",
|
"logistics-packing-slips": "Surat Jalan",
|
||||||
"configuration-employees": "Karyawan",
|
"configuration-employees": "Karyawan",
|
||||||
"configuration-products": "Produk"
|
"configuration-products": "Produk",
|
||||||
|
"configuration-company-settings": "Pengaturan perusahaan"
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||||
|
import type {
|
||||||
|
CompanySettingsEntity,
|
||||||
|
UpdateCompanySettingsPayload,
|
||||||
|
} from '../domain/entities/company-settings.entity';
|
||||||
|
|
||||||
|
export class CompanySettingsRemoteService {
|
||||||
|
constructor(private readonly client: AxiosInstance) {}
|
||||||
|
|
||||||
|
async get(): Promise<CompanySettingsEntity> {
|
||||||
|
const { data } = await this.client.get<CompanySettingsEntity>('/settings');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(payload: UpdateCompanySettingsPayload): Promise<CompanySettingsEntity> {
|
||||||
|
const { data } = await this.client.patch<CompanySettingsEntity>('/settings', payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||||
|
import type { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||||
|
|
||||||
|
export type CompanySettingsShellEntity = BaseEntity & { id: string };
|
||||||
|
|
||||||
|
export const companySettingsModuleConfig: ModuleConfigEntity<CompanySettingsShellEntity> = {
|
||||||
|
moduleKey: 'ADMIN.SETTINGS.DATA.SETTING',
|
||||||
|
translationNamespace: 'COMPANY_SETTINGS',
|
||||||
|
apiUrl: '/settings',
|
||||||
|
webUrl: '/app/configuration/company-settings',
|
||||||
|
moduleCategory: 'SINGLE_PAGE',
|
||||||
|
moduleType: 'MASTER_DATA',
|
||||||
|
} as const;
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
export type CompanySettingsEntity = {
|
||||||
|
id: string;
|
||||||
|
cycleStartDate: number;
|
||||||
|
checkInRadiusMeters: number;
|
||||||
|
gpsIntervalSeconds: number;
|
||||||
|
checkoutWarningRadiusMeters: number;
|
||||||
|
status: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
createdBy: string;
|
||||||
|
updatedBy: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateCompanySettingsPayload = {
|
||||||
|
cycleStartDate?: string;
|
||||||
|
checkInRadiusMeters?: number;
|
||||||
|
gpsIntervalSeconds?: number;
|
||||||
|
checkoutWarningRadiusMeters?: number;
|
||||||
|
};
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||||
|
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||||
|
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
|
||||||
|
import {
|
||||||
|
companySettingsModuleConfig,
|
||||||
|
type CompanySettingsShellEntity,
|
||||||
|
} from '../constants/company-settings.constants';
|
||||||
|
import { CompanySettingsRemoteService } from '../../data/company-settings.remote.service';
|
||||||
|
|
||||||
|
class CompanySettingsShellTransformer extends BaseDataTransformer<CompanySettingsShellEntity> {
|
||||||
|
transformToEntity(dto: CompanySettingsShellEntity): CompanySettingsShellEntity {
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
transformToDTO(entity: CompanySettingsShellEntity): CompanySettingsShellEntity {
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const companySettingsDataService = new TrackGoRemoteDataServices(apiClient, {
|
||||||
|
apiUrl: companySettingsModuleConfig.apiUrl,
|
||||||
|
moduleKey: companySettingsModuleConfig.moduleKey,
|
||||||
|
transformer: new CompanySettingsShellTransformer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const companySettingsRemoteService = new CompanySettingsRemoteService(apiClient);
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
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 { companySettingsModuleConfig } from '../../domain/constants/company-settings.constants';
|
||||||
|
import { companySettingsDataService } from '../../domain/factories';
|
||||||
|
import { companySettingsStore } from '../store';
|
||||||
|
|
||||||
|
import companySettingsEn from '../languages/en/company-settings.json';
|
||||||
|
import companySettingsId from '../languages/id/company-settings.json';
|
||||||
|
|
||||||
|
const IndexPage = lazy(() => import('../pages/company-settings.page'));
|
||||||
|
|
||||||
|
registerModuleNamespace(companySettingsModuleConfig.translationNamespace, {
|
||||||
|
en: companySettingsEn,
|
||||||
|
id: companySettingsId,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function CompanySettingsModule() {
|
||||||
|
return (
|
||||||
|
<EnterpriseModuleProvider
|
||||||
|
config={companySettingsModuleConfig}
|
||||||
|
dataServices={companySettingsDataService}
|
||||||
|
store={companySettingsStore}
|
||||||
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/index" element={<IndexPage />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={<Navigate to={`${companySettingsModuleConfig.webUrl}/index`} replace />}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</EnterpriseModuleProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"title": "Company settings",
|
||||||
|
"description": "Configure cycle start date, check-in radius, and timeline tracking.",
|
||||||
|
"fields": {
|
||||||
|
"cycleStartDate": "Cycle start date",
|
||||||
|
"checkInRadiusMeters": "Check-in radius (meters)",
|
||||||
|
"gpsIntervalSeconds": "GPS interval (seconds)",
|
||||||
|
"checkoutWarningRadiusMeters": "Checkout warning radius (meters)"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Save settings"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"saved": "Company settings updated.",
|
||||||
|
"loadFailed": "Could not load company settings."
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"title": "Pengaturan perusahaan",
|
||||||
|
"description": "Atur tanggal awal siklus, radius check-in, dan pelacakan timeline.",
|
||||||
|
"fields": {
|
||||||
|
"cycleStartDate": "Tanggal awal siklus",
|
||||||
|
"checkInRadiusMeters": "Radius check-in (meter)",
|
||||||
|
"gpsIntervalSeconds": "Interval GPS (detik)",
|
||||||
|
"checkoutWarningRadiusMeters": "Radius peringatan checkout (meter)"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Simpan pengaturan"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"saved": "Pengaturan perusahaan diperbarui.",
|
||||||
|
"loadFailed": "Gagal memuat pengaturan perusahaan."
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Button, Card, CorePageContainer, Grid, Stack, Text } from '@repo/ui/components';
|
||||||
|
import { ModulePageHeader } from '@repo/ui/foundations';
|
||||||
|
import { FieldDatePicker, FieldNumberInput } from '@repo/ui/form';
|
||||||
|
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
|
||||||
|
import { Settings } from 'lucide-react';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import { companySettingsModuleConfig } from '../../domain/constants/company-settings.constants';
|
||||||
|
import { companySettingsRemoteService } from '../../domain/factories';
|
||||||
|
|
||||||
|
import companySettingsEn from '../languages/en/company-settings.json';
|
||||||
|
import companySettingsId from '../languages/id/company-settings.json';
|
||||||
|
|
||||||
|
registerModuleNamespace(companySettingsModuleConfig.translationNamespace, {
|
||||||
|
en: companySettingsEn,
|
||||||
|
id: companySettingsId,
|
||||||
|
});
|
||||||
|
|
||||||
|
type CompanySettingsForm = {
|
||||||
|
cycleStartDate: string;
|
||||||
|
checkInRadiusMeters: number;
|
||||||
|
gpsIntervalSeconds: number;
|
||||||
|
checkoutWarningRadiusMeters: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function unixDayToIsoDate(unixMs: number): string {
|
||||||
|
const date = new Date(unixMs);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompanySettingsPage() {
|
||||||
|
const { t } = useTranslation(companySettingsModuleConfig.translationNamespace);
|
||||||
|
const { t: tNav } = useTranslation('nav');
|
||||||
|
const form = useForm<CompanySettingsForm>();
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadSettings = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
try {
|
||||||
|
const settings = await companySettingsRemoteService.get();
|
||||||
|
form.reset({
|
||||||
|
cycleStartDate: unixDayToIsoDate(settings.cycleStartDate),
|
||||||
|
checkInRadiusMeters: settings.checkInRadiusMeters,
|
||||||
|
gpsIntervalSeconds: settings.gpsIntervalSeconds,
|
||||||
|
checkoutWarningRadiusMeters: settings.checkoutWarningRadiusMeters,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setErrorMessage(t('messages.loadFailed'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [form, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSettings();
|
||||||
|
}, [loadSettings]);
|
||||||
|
|
||||||
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
|
setErrorMessage(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
try {
|
||||||
|
await companySettingsRemoteService.update(values);
|
||||||
|
setSuccessMessage(t('messages.saved'));
|
||||||
|
} catch {
|
||||||
|
setErrorMessage(t('messages.loadFailed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CorePageContainer>
|
||||||
|
<ModulePageHeader
|
||||||
|
icon={Settings}
|
||||||
|
title={t('title')}
|
||||||
|
description={t('description')}
|
||||||
|
moduleKey={companySettingsModuleConfig.moduleKey}
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: tNav('settings'), type: 'text' },
|
||||||
|
{ label: tNav('data'), type: 'text' },
|
||||||
|
{ label: t('title'), type: 'text' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<Card withBorder padding="xl" radius="md">
|
||||||
|
<Stack gap="md" component="form" onSubmit={onSubmit}>
|
||||||
|
<Grid gutter="md">
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<FieldDatePicker
|
||||||
|
control={form.control}
|
||||||
|
name="cycleStartDate"
|
||||||
|
label={t('fields.cycleStartDate')}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<FieldNumberInput
|
||||||
|
control={form.control}
|
||||||
|
name="checkInRadiusMeters"
|
||||||
|
label={t('fields.checkInRadiusMeters')}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<FieldNumberInput
|
||||||
|
control={form.control}
|
||||||
|
name="gpsIntervalSeconds"
|
||||||
|
label={t('fields.gpsIntervalSeconds')}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<FieldNumberInput
|
||||||
|
control={form.control}
|
||||||
|
name="checkoutWarningRadiusMeters"
|
||||||
|
label={t('fields.checkoutWarningRadiusMeters')}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{successMessage ? (
|
||||||
|
<Text size="sm" c="teal">
|
||||||
|
{successMessage}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button type="submit" loading={isLoading}>
|
||||||
|
{t('actions.save')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</FormProvider>
|
||||||
|
</CorePageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||||
|
import type { CompanySettingsShellEntity } from '../../domain/constants/company-settings.constants';
|
||||||
|
|
||||||
|
export const companySettingsStore = create<EnterpriseModuleState<CompanySettingsShellEntity>>((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 }),
|
||||||
|
}));
|
||||||
@@ -6,6 +6,7 @@ const DivisionsModule = lazy(() => import('./divisions/presentation/factory'));
|
|||||||
const BranchesModule = lazy(() => import('./branches/presentation/factory'));
|
const BranchesModule = lazy(() => import('./branches/presentation/factory'));
|
||||||
const CustomersModule = lazy(() => import('./customers/presentation/factory'));
|
const CustomersModule = lazy(() => import('./customers/presentation/factory'));
|
||||||
const ProductsModule = lazy(() => import('./products/presentation/factory'));
|
const ProductsModule = lazy(() => import('./products/presentation/factory'));
|
||||||
|
const CompanySettingsModule = lazy(() => import('./company-settings/presentation/factory'));
|
||||||
|
|
||||||
export default function ConfigurationModule() {
|
export default function ConfigurationModule() {
|
||||||
return (
|
return (
|
||||||
@@ -14,6 +15,7 @@ export default function ConfigurationModule() {
|
|||||||
<Route path="/branches/*" element={<BranchesModule />} />
|
<Route path="/branches/*" element={<BranchesModule />} />
|
||||||
<Route path="/customers/*" element={<CustomersModule />} />
|
<Route path="/customers/*" element={<CustomersModule />} />
|
||||||
<Route path="/products/*" element={<ProductsModule />} />
|
<Route path="/products/*" element={<ProductsModule />} />
|
||||||
|
<Route path="/company-settings/*" element={<CompanySettingsModule />} />
|
||||||
<Route path="/employees/*" element={<Navigate to={`${WEB_URL.SALES_EMPLOYEES}/index`} replace={true} />} />
|
<Route path="/employees/*" element={<Navigate to={`${WEB_URL.SALES_EMPLOYEES}/index`} replace={true} />} />
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||||
|
import type { TimelineDayEntity } from '../domain/entities/timeline.entity';
|
||||||
|
|
||||||
|
export type TimelineQuery = {
|
||||||
|
date?: string;
|
||||||
|
employeeId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class TimelineRemoteService {
|
||||||
|
constructor(private readonly client: AxiosInstance) {}
|
||||||
|
|
||||||
|
async getDay(query: TimelineQuery = {}): Promise<TimelineDayEntity> {
|
||||||
|
const { data } = await this.client.get<TimelineDayEntity>('/timeline', {
|
||||||
|
params: query,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||||
|
import type { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||||
|
|
||||||
|
export type TimelineShellEntity = BaseEntity & { id: string };
|
||||||
|
|
||||||
|
export const salesTimelineModuleConfig: ModuleConfigEntity<TimelineShellEntity> = {
|
||||||
|
moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
|
||||||
|
translationNamespace: 'SALES_TIMELINE',
|
||||||
|
apiUrl: '/timeline',
|
||||||
|
webUrl: '/app/timeline',
|
||||||
|
moduleCategory: 'SINGLE_PAGE',
|
||||||
|
moduleType: 'TRANSACTION',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type TimelineRelation = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineFootprintEntity = {
|
||||||
|
id: string;
|
||||||
|
employee: TimelineRelation;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
recordedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineActivityEntity = {
|
||||||
|
id: string;
|
||||||
|
employee: TimelineRelation;
|
||||||
|
customer: TimelineRelation | null;
|
||||||
|
visitId: string | null;
|
||||||
|
type: string;
|
||||||
|
sourceType: string;
|
||||||
|
sourceId: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
recordedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineDayEntity = {
|
||||||
|
date: string;
|
||||||
|
footprints: TimelineFootprintEntity[];
|
||||||
|
activities: TimelineActivityEntity[];
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||||
|
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||||
|
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
|
||||||
|
import { salesTimelineModuleConfig, type TimelineShellEntity } from '../constants/timeline.constants';
|
||||||
|
import { TimelineRemoteService } from '../../data/timeline.remote.service';
|
||||||
|
|
||||||
|
class TimelineShellTransformer extends BaseDataTransformer<TimelineShellEntity> {
|
||||||
|
transformToEntity(dto: TimelineShellEntity): TimelineShellEntity {
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
transformToDTO(entity: TimelineShellEntity): TimelineShellEntity {
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesTimelineDataService = new TrackGoRemoteDataServices(apiClient, {
|
||||||
|
apiUrl: salesTimelineModuleConfig.apiUrl,
|
||||||
|
moduleKey: salesTimelineModuleConfig.moduleKey,
|
||||||
|
transformer: new TimelineShellTransformer(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const timelineRemoteService = new TimelineRemoteService(apiClient);
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
import { Avatar, Badge, Box, Card, Group, Stack, Text, Timeline, UnstyledButton } from '@repo/ui/components';
|
||||||
|
import { formatClock, type TimelineActivityGroup } from './timeline-helpers';
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineActivityList({
|
||||||
|
groups,
|
||||||
|
selectedKey,
|
||||||
|
onSelect,
|
||||||
|
activityLabel,
|
||||||
|
emptyLabel,
|
||||||
|
ungroupedLabel,
|
||||||
|
}: {
|
||||||
|
groups: TimelineActivityGroup[];
|
||||||
|
selectedKey: string | null;
|
||||||
|
onSelect: (key: string) => void;
|
||||||
|
activityLabel: (type: string) => string;
|
||||||
|
emptyLabel: string;
|
||||||
|
ungroupedLabel: string;
|
||||||
|
}) {
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed" py="md">
|
||||||
|
{emptyLabel}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{groups.map((group) => {
|
||||||
|
const selected = group.key === selectedKey;
|
||||||
|
const title = group.key === 'ungrouped' ? ungroupedLabel : group.title;
|
||||||
|
const first = group.activities[0];
|
||||||
|
const last = group.activities[group.activities.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={group.key}
|
||||||
|
withBorder
|
||||||
|
padding="md"
|
||||||
|
radius="md"
|
||||||
|
shadow={selected ? 'sm' : undefined}
|
||||||
|
style={{
|
||||||
|
borderColor: selected
|
||||||
|
? 'var(--mantine-color-blue-filled)'
|
||||||
|
: 'var(--mantine-color-default-border)',
|
||||||
|
borderWidth: selected ? 2 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UnstyledButton w="100%" onClick={() => onSelect(group.key)}>
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
|
||||||
|
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||||
|
<Text fw={600} lineClamp={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatClock(group.firstRecordedAt)}
|
||||||
|
{first && last && first.id !== last.id ? ` → ${formatClock(group.lastRecordedAt)}` : ''}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Badge variant="light" color={group.isOnTheWay ? 'blue' : 'teal'} tt="none">
|
||||||
|
{activityLabel(group.lastType)}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
|
||||||
|
{selected ? (
|
||||||
|
<Stack gap="sm" mt="md">
|
||||||
|
<Group gap="sm">
|
||||||
|
<Avatar radius="xl" size="md" color="blue">
|
||||||
|
{initials(group.employeeName)}
|
||||||
|
</Avatar>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{group.employeeName}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Box bg="var(--mantine-color-blue-light)" p="sm" bdrs="md">
|
||||||
|
<Timeline active={group.activities.length - 1} bulletSize={12} lineWidth={2} color="blue">
|
||||||
|
{group.activities.map((item) => (
|
||||||
|
<Timeline.Item key={item.id} title={activityLabel(item.type)}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatClock(item.recordedAt)}
|
||||||
|
</Text>
|
||||||
|
</Timeline.Item>
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from '@repo/ui/components';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { TimelineActivityList } from './timeline-activity-list';
|
||||||
|
import type { TimelineActivityGroup, TimelineActivityTab } from './timeline-helpers';
|
||||||
|
|
||||||
|
export function TimelineActivityPanel({
|
||||||
|
title,
|
||||||
|
search,
|
||||||
|
searchPlaceholder,
|
||||||
|
onSearchChange,
|
||||||
|
tab,
|
||||||
|
onTabChange,
|
||||||
|
onTheWayLabel,
|
||||||
|
completedLabel,
|
||||||
|
filters,
|
||||||
|
groups,
|
||||||
|
selectedKey,
|
||||||
|
onSelect,
|
||||||
|
activityLabel,
|
||||||
|
emptyLabel,
|
||||||
|
ungroupedLabel,
|
||||||
|
errorMessage,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
search: string;
|
||||||
|
searchPlaceholder: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
tab: TimelineActivityTab;
|
||||||
|
onTabChange: (value: TimelineActivityTab) => void;
|
||||||
|
onTheWayLabel: string;
|
||||||
|
completedLabel: string;
|
||||||
|
filters: ReactNode;
|
||||||
|
groups: TimelineActivityGroup[];
|
||||||
|
selectedKey: string | null;
|
||||||
|
onSelect: (key: string) => void;
|
||||||
|
activityLabel: (type: string) => string;
|
||||||
|
emptyLabel: string;
|
||||||
|
ungroupedLabel: string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
shadow="md"
|
||||||
|
radius="lg"
|
||||||
|
p="md"
|
||||||
|
h="100%"
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md" style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<Text fw={700} size="xl">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => onSearchChange(event.currentTarget.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
leftSection={<Search size={16} />}
|
||||||
|
/>
|
||||||
|
{filters}
|
||||||
|
<SegmentedControl
|
||||||
|
fullWidth
|
||||||
|
value={tab}
|
||||||
|
onChange={(value) => onTabChange(value as TimelineActivityTab)}
|
||||||
|
data={[
|
||||||
|
{ label: onTheWayLabel, value: 'on_the_way' },
|
||||||
|
{ label: completedLabel, value: 'completed' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{errorMessage ? (
|
||||||
|
<Text c="red" size="sm">
|
||||||
|
{errorMessage}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<ScrollArea flex={1} type="scroll" offsetScrollbars>
|
||||||
|
<TimelineActivityList
|
||||||
|
groups={groups}
|
||||||
|
selectedKey={selectedKey}
|
||||||
|
onSelect={onSelect}
|
||||||
|
activityLabel={activityLabel}
|
||||||
|
emptyLabel={emptyLabel}
|
||||||
|
ungroupedLabel={ungroupedLabel}
|
||||||
|
/>
|
||||||
|
</ScrollArea>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { TimelineActivityEntity, TimelineFootprintEntity } from '../../domain/entities/timeline.entity';
|
||||||
|
import {
|
||||||
|
advancePlaybackTime,
|
||||||
|
filterActivityGroups,
|
||||||
|
filterTimelineByPlayback,
|
||||||
|
groupActivities,
|
||||||
|
playbackStep,
|
||||||
|
resolvePlaybackBounds,
|
||||||
|
resolvePlaybackPositions,
|
||||||
|
shouldRestartPlayback,
|
||||||
|
} from './timeline-helpers';
|
||||||
|
|
||||||
|
const footprints: TimelineFootprintEntity[] = [
|
||||||
|
{
|
||||||
|
id: 'fp-1',
|
||||||
|
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||||
|
latitude: -6.2,
|
||||||
|
longitude: 106.8,
|
||||||
|
recordedAt: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fp-2',
|
||||||
|
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||||
|
latitude: -6.21,
|
||||||
|
longitude: 106.81,
|
||||||
|
recordedAt: 2000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function activity(overrides: Partial<TimelineActivityEntity>): TimelineActivityEntity {
|
||||||
|
return {
|
||||||
|
id: 'act-1',
|
||||||
|
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||||
|
customer: { id: 'cus-1', code: 'C1', name: 'Toko Maju' },
|
||||||
|
visitId: 'visit-1',
|
||||||
|
type: 'customer_check_in',
|
||||||
|
sourceType: 'visit',
|
||||||
|
sourceId: 'visit-1',
|
||||||
|
latitude: -6.2,
|
||||||
|
longitude: 106.8,
|
||||||
|
recordedAt: 1000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('timeline helpers', () => {
|
||||||
|
it('resolves playback bounds from footprints', () => {
|
||||||
|
expect(resolvePlaybackBounds(footprints)).toEqual({ min: 1000, max: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters timeline items by playback time', () => {
|
||||||
|
expect(filterTimelineByPlayback(footprints, 1500)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves latest playback positions per employee', () => {
|
||||||
|
expect(resolvePlaybackPositions(footprints, 2000)).toEqual([
|
||||||
|
{
|
||||||
|
employeeId: 'emp-1',
|
||||||
|
latitude: -6.21,
|
||||||
|
longitude: 106.81,
|
||||||
|
label: 'Ada',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups visit activities and marks open visits as on the way', () => {
|
||||||
|
const groups = groupActivities([
|
||||||
|
activity({ id: 'in', type: 'customer_check_in', recordedAt: 1000 }),
|
||||||
|
activity({ id: 'order', type: 'sales_order_created', recordedAt: 1500 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
key: 'visit-1',
|
||||||
|
title: 'Toko Maju',
|
||||||
|
employeeName: 'Ada',
|
||||||
|
lastType: 'sales_order_created',
|
||||||
|
isVisit: true,
|
||||||
|
isOnTheWay: true,
|
||||||
|
});
|
||||||
|
expect(groups[0].activities.map((item) => item.id)).toEqual(['in', 'order']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a visit completed after check-out', () => {
|
||||||
|
const groups = groupActivities([
|
||||||
|
activity({ id: 'in', type: 'customer_check_in', recordedAt: 1000 }),
|
||||||
|
activity({ id: 'out', type: 'customer_check_out', recordedAt: 2000 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(groups[0]?.isOnTheWay).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters groups by tab and search query', () => {
|
||||||
|
const groups = groupActivities([
|
||||||
|
activity({ id: 'open', type: 'customer_check_in', recordedAt: 1000 }),
|
||||||
|
activity({
|
||||||
|
id: 'done',
|
||||||
|
visitId: 'visit-2',
|
||||||
|
customer: { id: 'cus-2', code: 'C2', name: 'Toko Selesai' },
|
||||||
|
type: 'customer_check_out',
|
||||||
|
recordedAt: 2000,
|
||||||
|
sourceId: 'visit-2',
|
||||||
|
}),
|
||||||
|
activity({
|
||||||
|
id: 'branch',
|
||||||
|
visitId: null,
|
||||||
|
customer: null,
|
||||||
|
type: 'branch_check_in',
|
||||||
|
recordedAt: 500,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(filterActivityGroups(groups, { tab: 'on_the_way', query: '' }).map((group) => group.key)).toEqual([
|
||||||
|
'visit-1',
|
||||||
|
'ungrouped',
|
||||||
|
]);
|
||||||
|
expect(filterActivityGroups(groups, { tab: 'completed', query: '' }).map((group) => group.key)).toEqual(['visit-2']);
|
||||||
|
expect(filterActivityGroups(groups, { tab: 'on_the_way', query: 'maju' }).map((group) => group.title)).toEqual([
|
||||||
|
'Toko Maju',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restarts playback from the start once the cursor is at the end', () => {
|
||||||
|
expect(shouldRestartPlayback(2000, { min: 1000, max: 2000 })).toBe(true);
|
||||||
|
expect(shouldRestartPlayback(1500, { min: 1000, max: 2000 })).toBe(false);
|
||||||
|
expect(advancePlaybackTime(1500, { min: 1000, max: 2000 }, 1000)).toBe(2000);
|
||||||
|
expect(playbackStep({ min: 0, max: 240_000 })).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
import type {
|
||||||
|
TimelineActivityEntity,
|
||||||
|
TimelineFootprintEntity,
|
||||||
|
} from '../../domain/entities/timeline.entity';
|
||||||
|
|
||||||
|
export type TimelineActivityGroup = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
employeeName: string;
|
||||||
|
lastType: string;
|
||||||
|
firstRecordedAt: number;
|
||||||
|
lastRecordedAt: number;
|
||||||
|
activities: TimelineActivityEntity[];
|
||||||
|
isVisit: boolean;
|
||||||
|
isOnTheWay: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineActivityTab = 'on_the_way' | 'completed';
|
||||||
|
|
||||||
|
function sortByRecordedAt(activities: TimelineActivityEntity[]): TimelineActivityEntity[] {
|
||||||
|
return [...activities].sort((left, right) => left.recordedAt - right.recordedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenVisit(types: string[]): boolean {
|
||||||
|
const hasCheckIn = types.some((type) => type.endsWith('_check_in'));
|
||||||
|
const hasCheckOut = types.some((type) => type.endsWith('_check_out'));
|
||||||
|
return hasCheckIn && !hasCheckOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatClock(unixMs: number): string {
|
||||||
|
const date = new Date(unixMs);
|
||||||
|
const hours = date.getHours().toString().padStart(2, '0');
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupActivities(activities: TimelineActivityEntity[]): TimelineActivityGroup[] {
|
||||||
|
const grouped = new Map<string, TimelineActivityEntity[]>();
|
||||||
|
|
||||||
|
for (const activity of activities) {
|
||||||
|
const key = activity.visitId ?? 'ungrouped';
|
||||||
|
grouped.set(key, [...(grouped.get(key) ?? []), activity]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...grouped.entries()].map(([key, items]) => {
|
||||||
|
const sorted = sortByRecordedAt(items);
|
||||||
|
const last = sorted[sorted.length - 1];
|
||||||
|
const first = sorted[0];
|
||||||
|
const title =
|
||||||
|
last?.customer?.name ??
|
||||||
|
(key === 'ungrouped' ? 'ungrouped' : (last?.customer?.code ?? key));
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
title,
|
||||||
|
employeeName: last?.employee.name ?? '',
|
||||||
|
lastType: last?.type ?? '',
|
||||||
|
firstRecordedAt: first?.recordedAt ?? 0,
|
||||||
|
lastRecordedAt: last?.recordedAt ?? 0,
|
||||||
|
activities: sorted,
|
||||||
|
isVisit: key !== 'ungrouped',
|
||||||
|
isOnTheWay: isOpenVisit(sorted.map((item) => item.type)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterActivityGroups(
|
||||||
|
groups: TimelineActivityGroup[],
|
||||||
|
options: { tab: TimelineActivityTab; query: string },
|
||||||
|
): TimelineActivityGroup[] {
|
||||||
|
const query = options.query.trim().toLowerCase();
|
||||||
|
|
||||||
|
return groups.filter((group) => {
|
||||||
|
if (options.tab === 'on_the_way' && !group.isOnTheWay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (options.tab === 'completed' && group.isOnTheWay) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (query.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const haystack = [group.title, group.employeeName, group.lastType, ...group.activities.map((item) => item.type)]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
return haystack.includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePlaybackPositions(
|
||||||
|
footprints: TimelineFootprintEntity[],
|
||||||
|
playbackTime: number | null,
|
||||||
|
): Array<{ employeeId: string; latitude: number; longitude: number; label: string }> {
|
||||||
|
if (playbackTime === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestByEmployee = new Map<
|
||||||
|
string,
|
||||||
|
{ latitude: number; longitude: number; label: string }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const footprint of footprints) {
|
||||||
|
if (footprint.recordedAt > playbackTime) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
latestByEmployee.set(footprint.employee.id, {
|
||||||
|
latitude: footprint.latitude,
|
||||||
|
longitude: footprint.longitude,
|
||||||
|
label: footprint.employee.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...latestByEmployee.entries()].map(([employeeId, position]) => ({
|
||||||
|
employeeId,
|
||||||
|
...position,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePlaybackBounds(footprints: TimelineFootprintEntity[]): {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
} | null {
|
||||||
|
if (footprints.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const times = footprints.map((footprint) => footprint.recordedAt);
|
||||||
|
return {
|
||||||
|
min: Math.min(...times),
|
||||||
|
max: Math.max(...times),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterTimelineByPlayback<T extends { recordedAt: number }>(
|
||||||
|
items: T[],
|
||||||
|
playbackTime: number | null,
|
||||||
|
): T[] {
|
||||||
|
if (playbackTime === null) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return items.filter((item) => item.recordedAt <= playbackTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playbackStep(bounds: { min: number; max: number }): number {
|
||||||
|
return Math.max(1000, Math.round((bounds.max - bounds.min) / 240));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRestartPlayback(
|
||||||
|
playbackTime: number | null,
|
||||||
|
bounds: { min: number; max: number } | null,
|
||||||
|
): boolean {
|
||||||
|
if (bounds === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return playbackTime === null || playbackTime >= bounds.max;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advancePlaybackTime(
|
||||||
|
current: number,
|
||||||
|
bounds: { min: number; max: number },
|
||||||
|
step: number,
|
||||||
|
): number {
|
||||||
|
return Math.min(bounds.max, current + step);
|
||||||
|
}
|
||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
import { Avatar, Badge, Box, Button, Group, Paper, SimpleGrid, Slider, Stack, Text } from '@repo/ui/components';
|
||||||
|
import { Pause, Play } from 'lucide-react';
|
||||||
|
import type { TimelineActivityGroup } from './timeline-helpers';
|
||||||
|
import { formatClock } from './timeline-helpers';
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverlayMetric({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} lineClamp={1}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelinePlaybackOverlay({
|
||||||
|
group,
|
||||||
|
ungroupedLabel,
|
||||||
|
activityLabel,
|
||||||
|
fromLabel,
|
||||||
|
toLabel,
|
||||||
|
currentLocationLabel,
|
||||||
|
activitiesLabel,
|
||||||
|
playbackTitle,
|
||||||
|
playLabel,
|
||||||
|
pauseLabel,
|
||||||
|
timeLabel,
|
||||||
|
emptyLabel,
|
||||||
|
currentLocation,
|
||||||
|
playbackBounds,
|
||||||
|
playbackTime,
|
||||||
|
onPlaybackTimeChange,
|
||||||
|
isPlaying,
|
||||||
|
onTogglePlayback,
|
||||||
|
canPlay,
|
||||||
|
}: {
|
||||||
|
group: TimelineActivityGroup | null;
|
||||||
|
ungroupedLabel: string;
|
||||||
|
activityLabel: (type: string) => string;
|
||||||
|
fromLabel: string;
|
||||||
|
toLabel: string;
|
||||||
|
currentLocationLabel: string;
|
||||||
|
activitiesLabel: string;
|
||||||
|
playbackTitle: string;
|
||||||
|
playLabel: string;
|
||||||
|
pauseLabel: string;
|
||||||
|
timeLabel: string;
|
||||||
|
emptyLabel: string;
|
||||||
|
currentLocation: string;
|
||||||
|
playbackBounds: { min: number; max: number } | null;
|
||||||
|
playbackTime: number | null;
|
||||||
|
onPlaybackTimeChange: (value: number) => void;
|
||||||
|
isPlaying: boolean;
|
||||||
|
onTogglePlayback: () => void;
|
||||||
|
canPlay: boolean;
|
||||||
|
}) {
|
||||||
|
const title = group ? (group.key === 'ungrouped' ? ungroupedLabel : group.title) : playbackTitle;
|
||||||
|
const first = group?.activities[0];
|
||||||
|
const last = group?.activities[group.activities.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder shadow="md" radius="lg" p="md">
|
||||||
|
<Group align="flex-start" justify="space-between" wrap="wrap" gap="lg">
|
||||||
|
<Stack gap="sm" style={{ flex: 1, minWidth: 220 }}>
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text fw={700}>{title}</Text>
|
||||||
|
{group ? (
|
||||||
|
<Badge variant="light" color={group.isOnTheWay ? 'blue' : 'teal'} tt="none">
|
||||||
|
{activityLabel(group.lastType)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
{group ? (
|
||||||
|
<Group gap="sm">
|
||||||
|
<Avatar radius="xl" size="sm" color="blue">
|
||||||
|
{initials(group.employeeName)}
|
||||||
|
</Avatar>
|
||||||
|
<Text size="sm">{group.employeeName}</Text>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{emptyLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md" visibleFrom="sm">
|
||||||
|
<OverlayMetric
|
||||||
|
label={fromLabel}
|
||||||
|
value={first ? `${formatClock(first.recordedAt)} · ${activityLabel(first.type)}` : '—'}
|
||||||
|
/>
|
||||||
|
<OverlayMetric
|
||||||
|
label={toLabel}
|
||||||
|
value={last ? `${formatClock(last.recordedAt)} · ${activityLabel(last.type)}` : '—'}
|
||||||
|
/>
|
||||||
|
<OverlayMetric label={currentLocationLabel} value={currentLocation || '—'} />
|
||||||
|
<OverlayMetric
|
||||||
|
label={activitiesLabel}
|
||||||
|
value={group ? String(group.activities.length) : '—'}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Box miw={220} style={{ flex: '0 0 240px' }}>
|
||||||
|
{playbackBounds ? (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Slider
|
||||||
|
min={playbackBounds.min}
|
||||||
|
max={playbackBounds.max}
|
||||||
|
value={playbackTime ?? playbackBounds.max}
|
||||||
|
onChange={onPlaybackTimeChange}
|
||||||
|
label={(value) => formatClock(value)}
|
||||||
|
/>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
leftSection={isPlaying ? <Pause size={16} /> : <Play size={16} />}
|
||||||
|
onClick={onTogglePlayback}
|
||||||
|
disabled={!canPlay}
|
||||||
|
>
|
||||||
|
{isPlaying ? pauseLabel : playLabel}
|
||||||
|
</Button>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{playbackTime === null ? timeLabel : formatClock(playbackTime)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{emptyLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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 { salesTimelineModuleConfig } from '../../domain/constants/timeline.constants';
|
||||||
|
import { salesTimelineDataService } from '../../domain/factories';
|
||||||
|
import { salesTimelineStore } from '../store';
|
||||||
|
|
||||||
|
import timelineEn from '../languages/en/timeline.json';
|
||||||
|
import timelineId from '../languages/id/timeline.json';
|
||||||
|
|
||||||
|
const IndexPage = lazy(() => import('../pages/timeline.page.index'));
|
||||||
|
|
||||||
|
registerModuleNamespace(salesTimelineModuleConfig.translationNamespace, {
|
||||||
|
en: timelineEn,
|
||||||
|
id: timelineId,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function SalesTimelineModule() {
|
||||||
|
return (
|
||||||
|
<EnterpriseModuleProvider
|
||||||
|
config={salesTimelineModuleConfig}
|
||||||
|
dataServices={salesTimelineDataService}
|
||||||
|
store={salesTimelineStore}
|
||||||
|
>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/index" element={<IndexPage />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={<Navigate to={`${salesTimelineModuleConfig.webUrl}/index`} replace />}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</EnterpriseModuleProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"title": "Timeline",
|
||||||
|
"description": "Review field footprints and activity logs on the map.",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search activities"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"onTheWay": "On the way",
|
||||||
|
"completed": "Completed"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"date": "Date",
|
||||||
|
"employee": "Employee",
|
||||||
|
"allEmployees": "All employees"
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"title": "Playback",
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"time": "Time"
|
||||||
|
},
|
||||||
|
"overlay": {
|
||||||
|
"from": "From",
|
||||||
|
"to": "To",
|
||||||
|
"currentLocation": "Current location",
|
||||||
|
"activities": "Activities",
|
||||||
|
"emptyMap": "No timeline data"
|
||||||
|
},
|
||||||
|
"activities": {
|
||||||
|
"title": "Activities",
|
||||||
|
"empty": "No activities for this day.",
|
||||||
|
"ungrouped": "Other activities",
|
||||||
|
"types": {
|
||||||
|
"branch_check_in": "Branch check-in",
|
||||||
|
"branch_check_out": "Branch check-out",
|
||||||
|
"customer_check_in": "Customer check-in",
|
||||||
|
"customer_check_out": "Customer check-out",
|
||||||
|
"sales_order_created": "Sales order created",
|
||||||
|
"sales_request_created": "Sales request created",
|
||||||
|
"sales_payment_created": "Sales payment created",
|
||||||
|
"customer_created": "Customer created"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Could not load timeline data."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"title": "Timeline",
|
||||||
|
"description": "Tinjau jejak lapangan dan log aktivitas di peta.",
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Cari aktivitas"
|
||||||
|
},
|
||||||
|
"tabs": {
|
||||||
|
"onTheWay": "Di perjalanan",
|
||||||
|
"completed": "Selesai"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"date": "Tanggal",
|
||||||
|
"employee": "Karyawan",
|
||||||
|
"allEmployees": "Semua karyawan"
|
||||||
|
},
|
||||||
|
"playback": {
|
||||||
|
"title": "Playback",
|
||||||
|
"play": "Putar",
|
||||||
|
"pause": "Jeda",
|
||||||
|
"time": "Waktu"
|
||||||
|
},
|
||||||
|
"overlay": {
|
||||||
|
"from": "Dari",
|
||||||
|
"to": "Ke",
|
||||||
|
"currentLocation": "Lokasi saat ini",
|
||||||
|
"activities": "Aktivitas",
|
||||||
|
"emptyMap": "Tidak ada data timeline"
|
||||||
|
},
|
||||||
|
"activities": {
|
||||||
|
"title": "Aktivitas",
|
||||||
|
"empty": "Tidak ada aktivitas untuk hari ini.",
|
||||||
|
"ungrouped": "Aktivitas lain",
|
||||||
|
"types": {
|
||||||
|
"branch_check_in": "Check-in cabang",
|
||||||
|
"branch_check_out": "Check-out cabang",
|
||||||
|
"customer_check_in": "Check-in pelanggan",
|
||||||
|
"customer_check_out": "Check-out pelanggan",
|
||||||
|
"sales_order_created": "Sales order dibuat",
|
||||||
|
"sales_request_created": "Sales request dibuat",
|
||||||
|
"sales_payment_created": "Pembayaran dibuat",
|
||||||
|
"customer_created": "Pelanggan dibuat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Gagal memuat data timeline."
|
||||||
|
}
|
||||||
|
}
|
||||||
+316
@@ -0,0 +1,316 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Box, Grid } from '@repo/ui/components';
|
||||||
|
import { FieldAsyncSelect, FieldDatePicker } from '@repo/ui/form';
|
||||||
|
import { TimelineMap } from '@repo/ui/map';
|
||||||
|
import { useTranslation } from '@repo/core-i18n';
|
||||||
|
import { FormProvider, useForm, type Control, type FieldValues } from 'react-hook-form';
|
||||||
|
import { salesTimelineModuleConfig } from '../../domain/constants/timeline.constants';
|
||||||
|
import type {
|
||||||
|
TimelineActivityEntity,
|
||||||
|
TimelineDayEntity,
|
||||||
|
TimelineFootprintEntity,
|
||||||
|
} from '../../domain/entities/timeline.entity';
|
||||||
|
import { timelineRemoteService } from '../../domain/factories';
|
||||||
|
import { loadSalesEmployeeOptions } from '../../../shared/load-employee-options';
|
||||||
|
import { relationLabel } from '../../../shared/relation-label';
|
||||||
|
import type { EmployeeEntity } from '../../../../configuration/employees/domain/entities';
|
||||||
|
import { TimelineActivityPanel } from '../components/timeline-activity-panel';
|
||||||
|
import { TimelinePlaybackOverlay } from '../components/timeline-playback-overlay';
|
||||||
|
import {
|
||||||
|
advancePlaybackTime,
|
||||||
|
filterActivityGroups,
|
||||||
|
filterTimelineByPlayback,
|
||||||
|
groupActivities,
|
||||||
|
playbackStep,
|
||||||
|
resolvePlaybackBounds,
|
||||||
|
resolvePlaybackPositions,
|
||||||
|
shouldRestartPlayback,
|
||||||
|
type TimelineActivityTab,
|
||||||
|
} from '../components/timeline-helpers';
|
||||||
|
|
||||||
|
type TimelineFilterForm = {
|
||||||
|
date: string;
|
||||||
|
employee: EmployeeEntity | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_FOOTPRINTS: TimelineFootprintEntity[] = [];
|
||||||
|
const EMPTY_ACTIVITIES: TimelineActivityEntity[] = [];
|
||||||
|
|
||||||
|
function todayIsoDate(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = `${now.getMonth() + 1}`.padStart(2, '0');
|
||||||
|
const day = `${now.getDate()}`.padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimelinePage() {
|
||||||
|
const { t } = useTranslation(salesTimelineModuleConfig.translationNamespace);
|
||||||
|
const form = useForm<TimelineFilterForm>({
|
||||||
|
defaultValues: {
|
||||||
|
date: todayIsoDate(),
|
||||||
|
employee: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [data, setData] = useState<TimelineDayEntity | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [playbackTime, setPlaybackTime] = useState<number | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [tab, setTab] = useState<TimelineActivityTab>('on_the_way');
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const watchedDate = form.watch('date');
|
||||||
|
const watchedEmployee = form.watch('employee');
|
||||||
|
|
||||||
|
const loadTimeline = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
try {
|
||||||
|
const response = await timelineRemoteService.getDay({
|
||||||
|
date: watchedDate,
|
||||||
|
employeeId: watchedEmployee?.id ? String(watchedEmployee.id) : undefined,
|
||||||
|
});
|
||||||
|
setData(response);
|
||||||
|
const bounds = resolvePlaybackBounds(response.footprints);
|
||||||
|
setPlaybackTime(bounds?.max ?? null);
|
||||||
|
setIsPlaying(false);
|
||||||
|
const hasOpenVisit = groupActivities(response.activities).some((group) => group.isOnTheWay);
|
||||||
|
setTab(hasOpenVisit ? 'on_the_way' : 'completed');
|
||||||
|
} catch {
|
||||||
|
setErrorMessage(t('errors.loadFailed'));
|
||||||
|
setData(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [t, watchedDate, watchedEmployee?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadTimeline();
|
||||||
|
}, [loadTimeline]);
|
||||||
|
|
||||||
|
const footprints = data?.footprints ?? EMPTY_FOOTPRINTS;
|
||||||
|
const activities = data?.activities ?? EMPTY_ACTIVITIES;
|
||||||
|
const playbackBounds = useMemo(() => resolvePlaybackBounds(footprints), [footprints]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || playbackBounds === null) {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = playbackStep(playbackBounds);
|
||||||
|
timerRef.current = window.setInterval(() => {
|
||||||
|
setPlaybackTime((current) => {
|
||||||
|
if (current === null) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return advancePlaybackTime(current, playbackBounds, step);
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPlaying, playbackBounds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPlaying && playbackBounds && playbackTime !== null && playbackTime >= playbackBounds.max) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
}, [isPlaying, playbackBounds, playbackTime]);
|
||||||
|
|
||||||
|
const togglePlayback = useCallback(() => {
|
||||||
|
if (isPlaying) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldRestartPlayback(playbackTime, playbackBounds) && playbackBounds) {
|
||||||
|
setPlaybackTime(playbackBounds.min);
|
||||||
|
}
|
||||||
|
setIsPlaying(true);
|
||||||
|
}, [isPlaying, playbackBounds, playbackTime]);
|
||||||
|
|
||||||
|
const visibleFootprints = useMemo(
|
||||||
|
() => filterTimelineByPlayback<TimelineFootprintEntity>(footprints, playbackTime),
|
||||||
|
[footprints, playbackTime],
|
||||||
|
);
|
||||||
|
const visibleActivities = useMemo(
|
||||||
|
() => filterTimelineByPlayback<TimelineActivityEntity>(activities, playbackTime),
|
||||||
|
[activities, playbackTime],
|
||||||
|
);
|
||||||
|
const playbackPositions = useMemo(
|
||||||
|
() => resolvePlaybackPositions(footprints, playbackTime),
|
||||||
|
[footprints, playbackTime],
|
||||||
|
);
|
||||||
|
const groups = useMemo(() => groupActivities(visibleActivities), [visibleActivities]);
|
||||||
|
const filteredGroups = useMemo(
|
||||||
|
() => filterActivityGroups(groups, { tab, query: search }),
|
||||||
|
[groups, search, tab],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredGroups.length === 0) {
|
||||||
|
setSelectedKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!filteredGroups.some((group) => group.key === selectedKey)) {
|
||||||
|
setSelectedKey(filteredGroups[0]?.key ?? null);
|
||||||
|
}
|
||||||
|
}, [filteredGroups, selectedKey]);
|
||||||
|
|
||||||
|
const selectedGroup = filteredGroups.find((group) => group.key === selectedKey) ?? null;
|
||||||
|
const selectedActivity = selectedGroup?.activities[selectedGroup.activities.length - 1] ?? null;
|
||||||
|
const dayPositions = useMemo(
|
||||||
|
() => [
|
||||||
|
...footprints.map((footprint): [number, number] => [footprint.latitude, footprint.longitude]),
|
||||||
|
...activities.map((activity): [number, number] => [activity.latitude, activity.longitude]),
|
||||||
|
],
|
||||||
|
[activities, footprints],
|
||||||
|
);
|
||||||
|
const focusPositions = useMemo(() => {
|
||||||
|
if (selectedGroup && selectedGroup.activities.length > 0) {
|
||||||
|
return selectedGroup.activities.map(
|
||||||
|
(activity): [number, number] => [activity.latitude, activity.longitude],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dayPositions;
|
||||||
|
}, [dayPositions, selectedGroup]);
|
||||||
|
const mapFootprints = useMemo(
|
||||||
|
() =>
|
||||||
|
visibleFootprints.map((footprint) => ({
|
||||||
|
employeeId: footprint.employee.id,
|
||||||
|
latitude: footprint.latitude,
|
||||||
|
longitude: footprint.longitude,
|
||||||
|
recordedAt: footprint.recordedAt,
|
||||||
|
})),
|
||||||
|
[visibleFootprints],
|
||||||
|
);
|
||||||
|
const activityLabel = useCallback(
|
||||||
|
(type: string) => t(`activities.types.${type}`, { defaultValue: type }),
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
const mapActivities = useMemo(
|
||||||
|
() =>
|
||||||
|
visibleActivities.map((activity) => ({
|
||||||
|
id: activity.id,
|
||||||
|
type: activity.type,
|
||||||
|
latitude: activity.latitude,
|
||||||
|
longitude: activity.longitude,
|
||||||
|
recordedAt: activity.recordedAt,
|
||||||
|
label: activityLabel(activity.type),
|
||||||
|
})),
|
||||||
|
[activityLabel, visibleActivities],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
m="calc(var(--mantine-spacing-md) * -1)"
|
||||||
|
h="calc(100dvh - var(--app-shell-header-offset, 0px))"
|
||||||
|
pos="relative"
|
||||||
|
style={{ overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<TimelineMap
|
||||||
|
footprints={mapFootprints}
|
||||||
|
activities={mapActivities}
|
||||||
|
playbackPositions={playbackPositions}
|
||||||
|
focusPositions={focusPositions}
|
||||||
|
selectedActivityId={selectedActivity?.id}
|
||||||
|
height="100%"
|
||||||
|
fullBleed
|
||||||
|
emptyLabel={t('overlay.emptyMap')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<Box
|
||||||
|
pos="absolute"
|
||||||
|
top={{ base: 12, md: 16 }}
|
||||||
|
left={{ base: 12, md: 16 }}
|
||||||
|
bottom={{ base: undefined, md: 16 }}
|
||||||
|
h={{ base: '38%', md: 'auto' }}
|
||||||
|
w={{ base: 'calc(100% - 24px)', md: 400 }}
|
||||||
|
style={{ zIndex: 2 }}
|
||||||
|
>
|
||||||
|
<TimelineActivityPanel
|
||||||
|
title={t('title')}
|
||||||
|
search={search}
|
||||||
|
searchPlaceholder={t('search.placeholder')}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
tab={tab}
|
||||||
|
onTabChange={setTab}
|
||||||
|
onTheWayLabel={t('tabs.onTheWay')}
|
||||||
|
completedLabel={t('tabs.completed')}
|
||||||
|
groups={filteredGroups}
|
||||||
|
selectedKey={selectedKey}
|
||||||
|
onSelect={setSelectedKey}
|
||||||
|
activityLabel={activityLabel}
|
||||||
|
emptyLabel={t('activities.empty')}
|
||||||
|
ungroupedLabel={t('activities.ungrouped')}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
filters={
|
||||||
|
<Grid gutter="xs">
|
||||||
|
<Grid.Col span={12}>
|
||||||
|
<FieldDatePicker control={form.control} name="date" label={t('filters.date')} size="sm" />
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={12}>
|
||||||
|
<FieldAsyncSelect<EmployeeEntity>
|
||||||
|
control={form.control as unknown as Control<FieldValues>}
|
||||||
|
name="employee"
|
||||||
|
label={t('filters.employee')}
|
||||||
|
placeholder={t('filters.allEmployees')}
|
||||||
|
loadOptions={loadSalesEmployeeOptions}
|
||||||
|
valueKey="id"
|
||||||
|
labelKey="name"
|
||||||
|
clearable
|
||||||
|
searchable
|
||||||
|
size="sm"
|
||||||
|
renderLabel={relationLabel}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</FormProvider>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
pos="absolute"
|
||||||
|
bottom={16}
|
||||||
|
left={{ base: 12, md: 432 }}
|
||||||
|
right={{ base: 12, md: 16 }}
|
||||||
|
style={{ zIndex: 2 }}
|
||||||
|
>
|
||||||
|
<TimelinePlaybackOverlay
|
||||||
|
group={selectedGroup}
|
||||||
|
ungroupedLabel={t('activities.ungrouped')}
|
||||||
|
activityLabel={activityLabel}
|
||||||
|
fromLabel={t('overlay.from')}
|
||||||
|
toLabel={t('overlay.to')}
|
||||||
|
currentLocationLabel={t('overlay.currentLocation')}
|
||||||
|
activitiesLabel={t('overlay.activities')}
|
||||||
|
playbackTitle={t('playback.title')}
|
||||||
|
playLabel={t('playback.play')}
|
||||||
|
pauseLabel={t('playback.pause')}
|
||||||
|
timeLabel={t('playback.time')}
|
||||||
|
emptyLabel={t('activities.empty')}
|
||||||
|
currentLocation={playbackPositions[0]?.label ?? ''}
|
||||||
|
playbackBounds={playbackBounds}
|
||||||
|
playbackTime={playbackTime}
|
||||||
|
onPlaybackTimeChange={setPlaybackTime}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onTogglePlayback={togglePlayback}
|
||||||
|
canPlay={!isLoading && footprints.length > 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||||
|
import type { TimelineShellEntity } from '../../domain/constants/timeline.constants';
|
||||||
|
|
||||||
|
export const salesTimelineStore = create<EnterpriseModuleState<TimelineShellEntity>>((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 }),
|
||||||
|
}));
|
||||||
@@ -9,7 +9,6 @@ const CyclesModule = lazy(() => import('../field/cycles/presentation/factory'));
|
|||||||
const PlansModule = lazy(() => import('../field/plans/presentation/factory'));
|
const PlansModule = lazy(() => import('../field/plans/presentation/factory'));
|
||||||
const InvoicesModule = lazy(() => import('./invoices/presentation/factory'));
|
const InvoicesModule = lazy(() => import('./invoices/presentation/factory'));
|
||||||
const PaymentsModule = lazy(() => import('./payments/presentation/factory'));
|
const PaymentsModule = lazy(() => import('./payments/presentation/factory'));
|
||||||
|
|
||||||
export default function SalesModule() {
|
export default function SalesModule() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -20,6 +19,7 @@ export default function SalesModule() {
|
|||||||
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
|
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
|
||||||
<Route path="/invoices/*" element={<InvoicesModule />} />
|
<Route path="/invoices/*" element={<InvoicesModule />} />
|
||||||
<Route path="/payments/*" element={<PaymentsModule />} />
|
<Route path="/payments/*" element={<PaymentsModule />} />
|
||||||
|
<Route path="/timeline/*" element={<Navigate to="/app/timeline/index" replace />} />
|
||||||
<Route path="/reports/*" element={<ReportsModule />} />
|
<Route path="/reports/*" element={<ReportsModule />} />
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const MODULE_KEY = {
|
|||||||
CONFIGURATION_BRANCH: 'ADMIN.SETTINGS.DATA.BRANCH',
|
CONFIGURATION_BRANCH: 'ADMIN.SETTINGS.DATA.BRANCH',
|
||||||
CONFIGURATION_CUSTOMER: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
CONFIGURATION_CUSTOMER: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||||
CONFIGURATION_PRODUCT: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
CONFIGURATION_PRODUCT: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||||
|
CONFIGURATION_COMPANY_SETTINGS: 'ADMIN.SETTINGS.DATA.SETTING',
|
||||||
CONFIGURATION_EMPLOYEE: 'ADMIN.SALES.DATA.EMPLOYEE',
|
CONFIGURATION_EMPLOYEE: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||||
SALES_CYCLE: 'ADMIN.SALES.DATA.CYCLE',
|
SALES_CYCLE: 'ADMIN.SALES.DATA.CYCLE',
|
||||||
SALES_REQUEST: 'ADMIN.SALES.ACTIVITIES.REQUEST',
|
SALES_REQUEST: 'ADMIN.SALES.ACTIVITIES.REQUEST',
|
||||||
@@ -12,6 +13,7 @@ export const MODULE_KEY = {
|
|||||||
SALES_INVOICE: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
SALES_INVOICE: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||||
SALES_PAYMENT: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
|
SALES_PAYMENT: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
|
||||||
SALES_PLAN: 'ADMIN.SALES.ACTIVITIES.PLAN',
|
SALES_PLAN: 'ADMIN.SALES.ACTIVITIES.PLAN',
|
||||||
|
SALES_TIMELINE: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
|
||||||
SALES_REPORT: 'ADMIN.SALES.REPORT',
|
SALES_REPORT: 'ADMIN.SALES.REPORT',
|
||||||
PACKING_SLIP: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
PACKING_SLIP: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
||||||
LOGISTICS_CYCLE: 'ADMIN.LOGISTICS.DATA.CYCLE',
|
LOGISTICS_CYCLE: 'ADMIN.LOGISTICS.DATA.CYCLE',
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
export { RouteMap } from './route-map';
|
export { RouteMap } from './route-map';
|
||||||
export type { RouteMapProps } from './route-map';
|
export type { RouteMapProps } from './route-map';
|
||||||
|
export { TimelineMap } from './timeline-map';
|
||||||
|
export type {
|
||||||
|
TimelineMapActivity,
|
||||||
|
TimelineMapFootprint,
|
||||||
|
TimelineMapPlaybackPosition,
|
||||||
|
TimelineMapProps,
|
||||||
|
} from './timeline-map';
|
||||||
export { LocationMap } from './location-map';
|
export { LocationMap } from './location-map';
|
||||||
export type { LocationMapProps } from './location-map';
|
export type { LocationMapProps } from './location-map';
|
||||||
export { toLeafletLatLngs } from './route-geometry';
|
export { toLeafletLatLngs } from './route-geometry';
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
CircleMarker,
|
||||||
|
MapContainer,
|
||||||
|
Polyline,
|
||||||
|
TileLayer,
|
||||||
|
Tooltip,
|
||||||
|
ZoomControl,
|
||||||
|
useMap,
|
||||||
|
} from 'react-leaflet';
|
||||||
|
import { Box, Text } from '@mantine/core';
|
||||||
|
import { OSM_ATTRIBUTION, OSM_TILE_URL } from './osm';
|
||||||
|
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from './location-point';
|
||||||
|
import { toLeafletLatLngs } from './route-geometry';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import './leaflet-stacking.css';
|
||||||
|
|
||||||
|
export type TimelineMapFootprint = {
|
||||||
|
employeeId: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
recordedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineMapActivity = {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
recordedAt: number;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineMapPlaybackPosition = {
|
||||||
|
employeeId: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TimelineMapProps {
|
||||||
|
footprints?: TimelineMapFootprint[];
|
||||||
|
activities?: TimelineMapActivity[];
|
||||||
|
playbackPositions?: TimelineMapPlaybackPosition[];
|
||||||
|
focusPositions?: Array<[number, number]>;
|
||||||
|
selectedActivityId?: string;
|
||||||
|
height?: number | string;
|
||||||
|
radius?: string | number;
|
||||||
|
fullBleed?: boolean;
|
||||||
|
emptyLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACK_COLORS = [
|
||||||
|
'var(--mantine-color-blue-6)',
|
||||||
|
'var(--mantine-color-teal-6)',
|
||||||
|
'var(--mantine-color-orange-6)',
|
||||||
|
'var(--mantine-color-grape-6)',
|
||||||
|
'var(--mantine-color-cyan-6)',
|
||||||
|
'var(--mantine-color-pink-6)',
|
||||||
|
];
|
||||||
|
|
||||||
|
function InvalidateSize() {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
const container = map.getContainer();
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
map.invalidateSize();
|
||||||
|
});
|
||||||
|
observer.observe(container);
|
||||||
|
const id = window.setTimeout(() => map.invalidateSize(), 0);
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(id);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FitTimelineBounds({
|
||||||
|
positions,
|
||||||
|
}: {
|
||||||
|
positions: Array<[number, number]>;
|
||||||
|
}) {
|
||||||
|
const map = useMap();
|
||||||
|
const boundsKey = positions.map(([lat, lng]) => `${lat.toFixed(6)},${lng.toFixed(6)}`).join('|');
|
||||||
|
useEffect(() => {
|
||||||
|
if (positions.length === 0) {
|
||||||
|
map.setView(DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (positions.length === 1) {
|
||||||
|
map.setView(positions[0], 14);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
map.fitBounds(positions, { padding: [48, 48] });
|
||||||
|
}, [map, boundsKey]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupFootprintsByEmployee(
|
||||||
|
footprints: readonly TimelineMapFootprint[],
|
||||||
|
): Map<string, TimelineMapFootprint[]> {
|
||||||
|
const grouped = new Map<string, TimelineMapFootprint[]>();
|
||||||
|
for (const point of footprints) {
|
||||||
|
const existing = grouped.get(point.employeeId) ?? [];
|
||||||
|
grouped.set(point.employeeId, [...existing, point]);
|
||||||
|
}
|
||||||
|
for (const [employeeId, points] of grouped) {
|
||||||
|
grouped.set(
|
||||||
|
employeeId,
|
||||||
|
[...points].sort((left, right) => left.recordedAt - right.recordedAt),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineMap({
|
||||||
|
footprints = [],
|
||||||
|
activities = [],
|
||||||
|
playbackPositions = [],
|
||||||
|
focusPositions,
|
||||||
|
selectedActivityId,
|
||||||
|
height = 420,
|
||||||
|
radius = 'md',
|
||||||
|
fullBleed = false,
|
||||||
|
emptyLabel = 'No timeline data',
|
||||||
|
}: TimelineMapProps) {
|
||||||
|
const groupedTracks = useMemo(
|
||||||
|
() => groupFootprintsByEmployee(footprints),
|
||||||
|
[footprints],
|
||||||
|
);
|
||||||
|
|
||||||
|
const positions = useMemo(() => {
|
||||||
|
const points: Array<[number, number]> = [];
|
||||||
|
for (const footprint of footprints) {
|
||||||
|
points.push([footprint.latitude, footprint.longitude]);
|
||||||
|
}
|
||||||
|
for (const activity of activities) {
|
||||||
|
points.push([activity.latitude, activity.longitude]);
|
||||||
|
}
|
||||||
|
for (const position of playbackPositions) {
|
||||||
|
points.push([position.latitude, position.longitude]);
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}, [activities, footprints, playbackPositions]);
|
||||||
|
|
||||||
|
const boundsPositions =
|
||||||
|
focusPositions && focusPositions.length > 0 ? focusPositions : positions;
|
||||||
|
const employeeIds = [...groupedTracks.keys()];
|
||||||
|
const hasData = positions.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
h={height}
|
||||||
|
bdrs={fullBleed ? 0 : radius}
|
||||||
|
className="tg-map-viewport"
|
||||||
|
pos="relative"
|
||||||
|
>
|
||||||
|
<MapContainer
|
||||||
|
center={boundsPositions[0] ?? DEFAULT_MAP_CENTER}
|
||||||
|
zoom={hasData ? 12 : DEFAULT_MAP_ZOOM}
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
scrollWheelZoom
|
||||||
|
zoomControl={false}
|
||||||
|
>
|
||||||
|
<TileLayer attribution={OSM_ATTRIBUTION} url={OSM_TILE_URL} />
|
||||||
|
<ZoomControl position="topright" />
|
||||||
|
{employeeIds.map((employeeId, index) => {
|
||||||
|
const track = groupedTracks.get(employeeId) ?? [];
|
||||||
|
const line = toLeafletLatLngs({
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: track.map((point) => [point.longitude, point.latitude]),
|
||||||
|
});
|
||||||
|
if (line.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Polyline
|
||||||
|
key={employeeId}
|
||||||
|
positions={line}
|
||||||
|
pathOptions={{
|
||||||
|
color: TRACK_COLORS[index % TRACK_COLORS.length],
|
||||||
|
weight: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{activities.map((activity) => {
|
||||||
|
const selected = activity.id === selectedActivityId;
|
||||||
|
return (
|
||||||
|
<CircleMarker
|
||||||
|
key={activity.id}
|
||||||
|
center={[activity.latitude, activity.longitude]}
|
||||||
|
radius={selected ? 12 : 9}
|
||||||
|
pathOptions={{
|
||||||
|
color: selected ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-red-7)',
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
weight: selected ? 3 : 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip permanent={selected}>{activity.label ?? activity.type}</Tooltip>
|
||||||
|
</CircleMarker>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{playbackPositions.map((position) => (
|
||||||
|
<CircleMarker
|
||||||
|
key={`playback-${position.employeeId}`}
|
||||||
|
center={[position.latitude, position.longitude]}
|
||||||
|
radius={11}
|
||||||
|
pathOptions={{
|
||||||
|
color: 'var(--mantine-color-yellow-7)',
|
||||||
|
fillColor: 'var(--mantine-color-yellow-4)',
|
||||||
|
fillOpacity: 1,
|
||||||
|
weight: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip permanent>{position.label ?? 'Playback'}</Tooltip>
|
||||||
|
</CircleMarker>
|
||||||
|
))}
|
||||||
|
<FitTimelineBounds positions={boundsPositions} />
|
||||||
|
<InvalidateSize />
|
||||||
|
</MapContainer>
|
||||||
|
{!hasData ? (
|
||||||
|
<Box
|
||||||
|
pos="absolute"
|
||||||
|
top="50%"
|
||||||
|
left="50%"
|
||||||
|
style={{ zIndex: 1, transform: 'translate(-50%, -50%)' }}
|
||||||
|
p="sm"
|
||||||
|
>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{emptyLabel}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user