diff --git a/api.md b/api.md
index c85309a..ea84556 100644
--- a/api.md
+++ b/api.md
@@ -258,6 +258,7 @@ Catalog (`GET /privilege-keys`, needs `ADMIN.SETTINGS.USER.PRIVILEGES` `view`).
| `ADMIN.SALES.ACTIVITIES.INVOICE` | Sales invoices |
| `ADMIN.SALES.ACTIVITIES.PAYMENT` | Sales payments |
| `ADMIN.SALES.ACTIVITIES.PLAN` | Sales plans |
+| `ADMIN.SALES.ACTIVITIES.TIMELINE` | Sales timeline |
| `ADMIN.SALES.REPORT` | Sales reports |
| `ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP` | Packing slips |
| `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.ATTENDANCE` | Branch attendance |
| `MOBILE.SALES.VISIT` | Customer visits |
+| `MOBILE.SALES.TIMELINE` | Sales timeline (mobile) |
### Field purpose
diff --git a/apps/web/src/apps/main/index.tsx b/apps/web/src/apps/main/index.tsx
index 5bb896b..26bd453 100644
--- a/apps/web/src/apps/main/index.tsx
+++ b/apps/web/src/apps/main/index.tsx
@@ -11,6 +11,7 @@ const PrivilegesModule = lazy(() => import('./modules/system/privileges/presenta
const UsersModule = lazy(() => import('./modules/system/users/presentation/factory'));
const ConfigurationModule = lazy(() => import('./modules/configuration'));
const SalesModule = lazy(() => import('./modules/sales'));
+const TimelineModule = lazy(() => import('./modules/field/timeline/presentation/factory'));
const LogisticsFieldModule = lazy(() => import('./modules/field/logistics'));
export default function AppModule() {
@@ -26,6 +27,7 @@ export default function AppModule() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/web/src/apps/main/layouts/data/menu.data.test.ts b/apps/web/src/apps/main/layouts/data/menu.data.test.ts
index e39e4c4..859b869 100644
--- a/apps/web/src/apps/main/layouts/data/menu.data.test.ts
+++ b/apps/web/src/apps/main/layouts/data/menu.data.test.ts
@@ -13,8 +13,17 @@ const flatten = (items: MenuItemType[]): MenuItemType[] =>
items.flatMap((item) => [item, ...(item.children ? flatten(item.children) : [])]);
describe('MENU_ITEMS', () => {
- it('orders top-level items as dashboard, sales, logistics, settings', () => {
- expect(MENU_ITEMS.map((item) => item.key)).toEqual(['dashboard', 'sales', 'logistics', 'settings']);
+ it('orders top-level items as dashboard, timeline, 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', () => {
@@ -58,6 +67,7 @@ describe('MENU_ITEMS', () => {
'configuration-divisions',
'configuration-customers',
'configuration-products',
+ 'configuration-company-settings',
]);
expect(childKeys(findItem(settings?.children ?? [], 'settings-user'))).toEqual([
'system-users',
diff --git a/apps/web/src/apps/main/layouts/data/menu.data.ts b/apps/web/src/apps/main/layouts/data/menu.data.ts
index bdaea77..be805b2 100644
--- a/apps/web/src/apps/main/layouts/data/menu.data.ts
+++ b/apps/web/src/apps/main/layouts/data/menu.data.ts
@@ -33,6 +33,13 @@ export const MENU_ITEMS: MenuItemType[] = [
icon: LayoutDashboard,
path: '/app/dashboard',
},
+ {
+ key: 'timeline',
+ label: 'nav:timeline',
+ icon: MapPin,
+ path: '/app/timeline/index',
+ moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
+ },
{
key: 'sales',
label: 'nav:sales',
@@ -212,6 +219,13 @@ export const MENU_ITEMS: MenuItemType[] = [
path: '/app/configuration/products/index',
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',
+ },
],
},
{
diff --git a/apps/web/src/apps/main/layouts/languages/en/nav.json b/apps/web/src/apps/main/layouts/languages/en/nav.json
index d0ca598..80b668c 100644
--- a/apps/web/src/apps/main/layouts/languages/en/nav.json
+++ b/apps/web/src/apps/main/layouts/languages/en/nav.json
@@ -1,5 +1,6 @@
{
"dashboard": "Dashboard",
+ "timeline": "Timeline",
"crm": "CRM",
"crm-leads": "Leads",
"crm-pipelines": "Pipelines",
@@ -55,5 +56,6 @@
"logistics-plans": "Logistics Plans",
"logistics-packing-slips": "Packing Slips",
"configuration-employees": "Employees",
- "configuration-products": "Products"
+ "configuration-products": "Products",
+ "configuration-company-settings": "Company settings"
}
diff --git a/apps/web/src/apps/main/layouts/languages/id/nav.json b/apps/web/src/apps/main/layouts/languages/id/nav.json
index c2e5705..3f07d11 100644
--- a/apps/web/src/apps/main/layouts/languages/id/nav.json
+++ b/apps/web/src/apps/main/layouts/languages/id/nav.json
@@ -1,5 +1,6 @@
{
"dashboard": "Dasbor",
+ "timeline": "Timeline",
"crm": "CRM",
"crm-leads": "Prospek",
"crm-pipelines": "Alur Penjualan",
@@ -55,5 +56,6 @@
"logistics-plans": "Rencana Logistik",
"logistics-packing-slips": "Surat Jalan",
"configuration-employees": "Karyawan",
- "configuration-products": "Produk"
+ "configuration-products": "Produk",
+ "configuration-company-settings": "Pengaturan perusahaan"
}
diff --git a/apps/web/src/apps/main/modules/configuration/company-settings/data/company-settings.remote.service.ts b/apps/web/src/apps/main/modules/configuration/company-settings/data/company-settings.remote.service.ts
new file mode 100644
index 0000000..3ece50d
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/company-settings/data/company-settings.remote.service.ts
@@ -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 {
+ const { data } = await this.client.get('/settings');
+ return data;
+ }
+
+ async update(payload: UpdateCompanySettingsPayload): Promise {
+ const { data } = await this.client.patch('/settings', payload);
+ return data;
+ }
+}
diff --git a/apps/web/src/apps/main/modules/configuration/company-settings/domain/constants/company-settings.constants.ts b/apps/web/src/apps/main/modules/configuration/company-settings/domain/constants/company-settings.constants.ts
new file mode 100644
index 0000000..fcada65
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/company-settings/domain/constants/company-settings.constants.ts
@@ -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 = {
+ moduleKey: 'ADMIN.SETTINGS.DATA.SETTING',
+ translationNamespace: 'COMPANY_SETTINGS',
+ apiUrl: '/settings',
+ webUrl: '/app/configuration/company-settings',
+ moduleCategory: 'SINGLE_PAGE',
+ moduleType: 'MASTER_DATA',
+} as const;
diff --git a/apps/web/src/apps/main/modules/configuration/company-settings/domain/entities/company-settings.entity.ts b/apps/web/src/apps/main/modules/configuration/company-settings/domain/entities/company-settings.entity.ts
new file mode 100644
index 0000000..fdd73db
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/company-settings/domain/entities/company-settings.entity.ts
@@ -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;
+};
diff --git a/apps/web/src/apps/main/modules/configuration/company-settings/domain/factories/index.ts b/apps/web/src/apps/main/modules/configuration/company-settings/domain/factories/index.ts
new file mode 100644
index 0000000..fc3d0b1
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/company-settings/domain/factories/index.ts
@@ -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 {
+ 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);
diff --git a/apps/web/src/apps/main/modules/configuration/company-settings/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/configuration/company-settings/presentation/factory/index.tsx
new file mode 100644
index 0000000..0662b8c
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/company-settings/presentation/factory/index.tsx
@@ -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 (
+
+
+ } />
+ }
+ />
+ } />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/company-settings/presentation/languages/en/company-settings.json b/apps/web/src/apps/main/modules/configuration/company-settings/presentation/languages/en/company-settings.json
new file mode 100644
index 0000000..01a7200
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/company-settings/presentation/languages/en/company-settings.json
@@ -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."
+ }
+}
diff --git a/apps/web/src/apps/main/modules/configuration/company-settings/presentation/languages/id/company-settings.json b/apps/web/src/apps/main/modules/configuration/company-settings/presentation/languages/id/company-settings.json
new file mode 100644
index 0000000..444a991
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/company-settings/presentation/languages/id/company-settings.json
@@ -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."
+ }
+}
diff --git a/apps/web/src/apps/main/modules/configuration/company-settings/presentation/pages/company-settings.page.tsx b/apps/web/src/apps/main/modules/configuration/company-settings/presentation/pages/company-settings.page.tsx
new file mode 100644
index 0000000..f9dbda4
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/company-settings/presentation/pages/company-settings.page.tsx
@@ -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();
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [successMessage, setSuccessMessage] = useState(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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+ {successMessage ? (
+
+ {successMessage}
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/configuration/company-settings/presentation/store/index.ts b/apps/web/src/apps/main/modules/configuration/company-settings/presentation/store/index.ts
new file mode 100644
index 0000000..ee37435
--- /dev/null
+++ b/apps/web/src/apps/main/modules/configuration/company-settings/presentation/store/index.ts
@@ -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>((set) => ({
+ metaData: { limit: 15 },
+ setMetaData: (data) => set({ metaData: data }),
+ filterData: {},
+ setFilterData: (data) => set({ filterData: data }),
+ selectedRows: [],
+ setSelectedRows: (rows) => set({ selectedRows: rows }),
+ privileges: [],
+ setPrivileges: (privileges) => set({ privileges }),
+ tableConfig: null,
+ setTableConfig: (config) => set({ tableConfig: config }),
+}));
diff --git a/apps/web/src/apps/main/modules/configuration/index.tsx b/apps/web/src/apps/main/modules/configuration/index.tsx
index 97f3680..d0d846e 100644
--- a/apps/web/src/apps/main/modules/configuration/index.tsx
+++ b/apps/web/src/apps/main/modules/configuration/index.tsx
@@ -6,6 +6,7 @@ const DivisionsModule = lazy(() => import('./divisions/presentation/factory'));
const BranchesModule = lazy(() => import('./branches/presentation/factory'));
const CustomersModule = lazy(() => import('./customers/presentation/factory'));
const ProductsModule = lazy(() => import('./products/presentation/factory'));
+const CompanySettingsModule = lazy(() => import('./company-settings/presentation/factory'));
export default function ConfigurationModule() {
return (
@@ -14,6 +15,7 @@ export default function ConfigurationModule() {
} />
} />
} />
+ } />
} />
} />
diff --git a/apps/web/src/apps/main/modules/field/timeline/data/timeline.remote.service.ts b/apps/web/src/apps/main/modules/field/timeline/data/timeline.remote.service.ts
new file mode 100644
index 0000000..d79a2cc
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/data/timeline.remote.service.ts
@@ -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 {
+ const { data } = await this.client.get('/timeline', {
+ params: query,
+ });
+ return data;
+ }
+}
diff --git a/apps/web/src/apps/main/modules/field/timeline/domain/constants/timeline.constants.ts b/apps/web/src/apps/main/modules/field/timeline/domain/constants/timeline.constants.ts
new file mode 100644
index 0000000..3bd4b6a
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/domain/constants/timeline.constants.ts
@@ -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 = {
+ moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
+ translationNamespace: 'SALES_TIMELINE',
+ apiUrl: '/timeline',
+ webUrl: '/app/timeline',
+ moduleCategory: 'SINGLE_PAGE',
+ moduleType: 'TRANSACTION',
+} as const;
diff --git a/apps/web/src/apps/main/modules/field/timeline/domain/entities/timeline.entity.ts b/apps/web/src/apps/main/modules/field/timeline/domain/entities/timeline.entity.ts
new file mode 100644
index 0000000..77d0521
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/domain/entities/timeline.entity.ts
@@ -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[];
+};
diff --git a/apps/web/src/apps/main/modules/field/timeline/domain/factories/index.ts b/apps/web/src/apps/main/modules/field/timeline/domain/factories/index.ts
new file mode 100644
index 0000000..95b7004
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/domain/factories/index.ts
@@ -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 {
+ 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);
diff --git a/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-activity-list.tsx b/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-activity-list.tsx
new file mode 100644
index 0000000..bdd800d
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-activity-list.tsx
@@ -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 (
+
+ {emptyLabel}
+
+ );
+ }
+
+ return (
+
+ {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 (
+
+ onSelect(group.key)}>
+
+
+
+ {title}
+
+
+ {formatClock(group.firstRecordedAt)}
+ {first && last && first.id !== last.id ? ` → ${formatClock(group.lastRecordedAt)}` : ''}
+
+
+
+ {activityLabel(group.lastType)}
+
+
+
+
+ {selected ? (
+
+
+
+ {initials(group.employeeName)}
+
+
+ {group.employeeName}
+
+
+
+
+
+ {group.activities.map((item) => (
+
+
+ {formatClock(item.recordedAt)}
+
+
+ ))}
+
+
+
+ ) : null}
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-activity-panel.tsx b/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-activity-panel.tsx
new file mode 100644
index 0000000..6c01baa
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-activity-panel.tsx
@@ -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 (
+
+
+
+ {title}
+
+ onSearchChange(event.currentTarget.value)}
+ placeholder={searchPlaceholder}
+ leftSection={}
+ />
+ {filters}
+ onTabChange(value as TimelineActivityTab)}
+ data={[
+ { label: onTheWayLabel, value: 'on_the_way' },
+ { label: completedLabel, value: 'completed' },
+ ]}
+ />
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-helpers.test.ts b/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-helpers.test.ts
new file mode 100644
index 0000000..6794bc9
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-helpers.test.ts
@@ -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 {
+ 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);
+ });
+});
diff --git a/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-helpers.tsx b/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-helpers.tsx
new file mode 100644
index 0000000..e94dd3a
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-helpers.tsx
@@ -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();
+
+ 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(
+ 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);
+}
diff --git a/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-playback-overlay.tsx b/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-playback-overlay.tsx
new file mode 100644
index 0000000..b2d54ac
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-playback-overlay.tsx
@@ -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 (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+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 (
+
+
+
+
+ {title}
+ {group ? (
+
+ {activityLabel(group.lastType)}
+
+ ) : null}
+
+ {group ? (
+
+
+ {initials(group.employeeName)}
+
+ {group.employeeName}
+
+ ) : (
+
+ {emptyLabel}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {playbackBounds ? (
+
+ formatClock(value)}
+ />
+
+ : }
+ onClick={onTogglePlayback}
+ disabled={!canPlay}
+ >
+ {isPlaying ? pauseLabel : playLabel}
+
+
+ {playbackTime === null ? timeLabel : formatClock(playbackTime)}
+
+
+
+ ) : (
+
+ {emptyLabel}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/field/timeline/presentation/factory/index.tsx b/apps/web/src/apps/main/modules/field/timeline/presentation/factory/index.tsx
new file mode 100644
index 0000000..89ee69b
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/presentation/factory/index.tsx
@@ -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 (
+
+
+ } />
+ }
+ />
+ } />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/field/timeline/presentation/languages/en/timeline.json b/apps/web/src/apps/main/modules/field/timeline/presentation/languages/en/timeline.json
new file mode 100644
index 0000000..783ba3d
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/presentation/languages/en/timeline.json
@@ -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."
+ }
+}
diff --git a/apps/web/src/apps/main/modules/field/timeline/presentation/languages/id/timeline.json b/apps/web/src/apps/main/modules/field/timeline/presentation/languages/id/timeline.json
new file mode 100644
index 0000000..6dd5585
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/presentation/languages/id/timeline.json
@@ -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."
+ }
+}
diff --git a/apps/web/src/apps/main/modules/field/timeline/presentation/pages/timeline.page.index.tsx b/apps/web/src/apps/main/modules/field/timeline/presentation/pages/timeline.page.index.tsx
new file mode 100644
index 0000000..1d2bd3e
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/presentation/pages/timeline.page.index.tsx
@@ -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({
+ defaultValues: {
+ date: todayIsoDate(),
+ employee: null,
+ },
+ });
+
+ const [data, setData] = useState(null);
+ const [errorMessage, setErrorMessage] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [playbackTime, setPlaybackTime] = useState(null);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [search, setSearch] = useState('');
+ const [tab, setTab] = useState('on_the_way');
+ const [selectedKey, setSelectedKey] = useState(null);
+ const timerRef = useRef(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(footprints, playbackTime),
+ [footprints, playbackTime],
+ );
+ const visibleActivities = useMemo(
+ () => filterTimelineByPlayback(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 (
+
+
+
+
+
+
+
+
+
+
+
+ control={form.control as unknown as Control}
+ name="employee"
+ label={t('filters.employee')}
+ placeholder={t('filters.allEmployees')}
+ loadOptions={loadSalesEmployeeOptions}
+ valueKey="id"
+ labelKey="name"
+ clearable
+ searchable
+ size="sm"
+ renderLabel={relationLabel}
+ />
+
+
+ }
+ />
+
+
+
+
+ 0}
+ />
+
+
+ );
+}
diff --git a/apps/web/src/apps/main/modules/field/timeline/presentation/store/index.ts b/apps/web/src/apps/main/modules/field/timeline/presentation/store/index.ts
new file mode 100644
index 0000000..afbe015
--- /dev/null
+++ b/apps/web/src/apps/main/modules/field/timeline/presentation/store/index.ts
@@ -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>((set) => ({
+ metaData: { limit: 15 },
+ setMetaData: (data) => set({ metaData: data }),
+ filterData: {},
+ setFilterData: (data) => set({ filterData: data }),
+ selectedRows: [],
+ setSelectedRows: (rows) => set({ selectedRows: rows }),
+ privileges: [],
+ setPrivileges: (privileges) => set({ privileges }),
+ tableConfig: null,
+ setTableConfig: (config) => set({ tableConfig: config }),
+}));
diff --git a/apps/web/src/apps/main/modules/sales/index.tsx b/apps/web/src/apps/main/modules/sales/index.tsx
index a5a202d..ea80aa2 100644
--- a/apps/web/src/apps/main/modules/sales/index.tsx
+++ b/apps/web/src/apps/main/modules/sales/index.tsx
@@ -9,7 +9,6 @@ const CyclesModule = lazy(() => import('../field/cycles/presentation/factory'));
const PlansModule = lazy(() => import('../field/plans/presentation/factory'));
const InvoicesModule = lazy(() => import('./invoices/presentation/factory'));
const PaymentsModule = lazy(() => import('./payments/presentation/factory'));
-
export default function SalesModule() {
return (
@@ -20,6 +19,7 @@ export default function SalesModule() {
} />
} />
} />
+ } />
} />
} />
diff --git a/apps/web/src/core/constants/module-key.ts b/apps/web/src/core/constants/module-key.ts
index b5db688..a0e0a0c 100644
--- a/apps/web/src/core/constants/module-key.ts
+++ b/apps/web/src/core/constants/module-key.ts
@@ -5,6 +5,7 @@ export const MODULE_KEY = {
CONFIGURATION_BRANCH: 'ADMIN.SETTINGS.DATA.BRANCH',
CONFIGURATION_CUSTOMER: 'ADMIN.SETTINGS.DATA.CUSTOMER',
CONFIGURATION_PRODUCT: 'ADMIN.SETTINGS.DATA.PRODUCT',
+ CONFIGURATION_COMPANY_SETTINGS: 'ADMIN.SETTINGS.DATA.SETTING',
CONFIGURATION_EMPLOYEE: 'ADMIN.SALES.DATA.EMPLOYEE',
SALES_CYCLE: 'ADMIN.SALES.DATA.CYCLE',
SALES_REQUEST: 'ADMIN.SALES.ACTIVITIES.REQUEST',
@@ -12,6 +13,7 @@ export const MODULE_KEY = {
SALES_INVOICE: 'ADMIN.SALES.ACTIVITIES.INVOICE',
SALES_PAYMENT: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
SALES_PLAN: 'ADMIN.SALES.ACTIVITIES.PLAN',
+ SALES_TIMELINE: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
SALES_REPORT: 'ADMIN.SALES.REPORT',
PACKING_SLIP: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
LOGISTICS_CYCLE: 'ADMIN.LOGISTICS.DATA.CYCLE',
diff --git a/packages/ui/src/components/map/index.ts b/packages/ui/src/components/map/index.ts
index c23bfe2..7fb6fc7 100644
--- a/packages/ui/src/components/map/index.ts
+++ b/packages/ui/src/components/map/index.ts
@@ -1,5 +1,12 @@
export { RouteMap } 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 type { LocationMapProps } from './location-map';
export { toLeafletLatLngs } from './route-geometry';
diff --git a/packages/ui/src/components/map/timeline-map.tsx b/packages/ui/src/components/map/timeline-map.tsx
new file mode 100644
index 0000000..124781a
--- /dev/null
+++ b/packages/ui/src/components/map/timeline-map.tsx
@@ -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 {
+ const grouped = new Map();
+ 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 (
+
+
+
+
+ {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 (
+
+ );
+ })}
+ {activities.map((activity) => {
+ const selected = activity.id === selectedActivityId;
+ return (
+
+ {activity.label ?? activity.type}
+
+ );
+ })}
+ {playbackPositions.map((position) => (
+
+ {position.label ?? 'Playback'}
+
+ ))}
+
+
+
+ {!hasData ? (
+
+
+ {emptyLabel}
+
+
+ ) : null}
+
+ );
+}