From 87bfe50f0eaae4e299ae881f7c1ea15d7cedce0b Mon Sep 17 00:00:00 2001 From: shancheas Date: Tue, 1 Sep 2026 20:36:57 +0700 Subject: [PATCH] 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. --- api.md | 2 + apps/web/src/apps/main/index.tsx | 2 + .../apps/main/layouts/data/menu.data.test.ts | 14 +- .../src/apps/main/layouts/data/menu.data.ts | 14 + .../apps/main/layouts/languages/en/nav.json | 4 +- .../apps/main/layouts/languages/id/nav.json | 4 +- .../data/company-settings.remote.service.ts | 19 ++ .../constants/company-settings.constants.ts | 13 + .../entities/company-settings.entity.ts | 19 ++ .../domain/factories/index.ts | 26 ++ .../presentation/factory/index.tsx | 36 ++ .../languages/en/company-settings.json | 17 + .../languages/id/company-settings.json | 17 + .../pages/company-settings.page.tsx | 142 ++++++++ .../presentation/store/index.ts | 16 + .../apps/main/modules/configuration/index.tsx | 2 + .../timeline/data/timeline.remote.service.ts | 18 + .../domain/constants/timeline.constants.ts | 13 + .../domain/entities/timeline.entity.ts | 32 ++ .../field/timeline/domain/factories/index.ts | 23 ++ .../components/timeline-activity-list.tsx | 104 ++++++ .../components/timeline-activity-panel.tsx | 96 ++++++ .../components/timeline-helpers.test.ts | 130 +++++++ .../components/timeline-helpers.tsx | 164 +++++++++ .../components/timeline-playback-overlay.tsx | 146 ++++++++ .../timeline/presentation/factory/index.tsx | 36 ++ .../presentation/languages/en/timeline.json | 47 +++ .../presentation/languages/id/timeline.json | 47 +++ .../pages/timeline.page.index.tsx | 316 ++++++++++++++++++ .../timeline/presentation/store/index.ts | 16 + .../web/src/apps/main/modules/sales/index.tsx | 2 +- apps/web/src/core/constants/module-key.ts | 2 + packages/ui/src/components/map/index.ts | 7 + .../ui/src/components/map/timeline-map.tsx | 238 +++++++++++++ 34 files changed, 1779 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/apps/main/modules/configuration/company-settings/data/company-settings.remote.service.ts create mode 100644 apps/web/src/apps/main/modules/configuration/company-settings/domain/constants/company-settings.constants.ts create mode 100644 apps/web/src/apps/main/modules/configuration/company-settings/domain/entities/company-settings.entity.ts create mode 100644 apps/web/src/apps/main/modules/configuration/company-settings/domain/factories/index.ts create mode 100644 apps/web/src/apps/main/modules/configuration/company-settings/presentation/factory/index.tsx create mode 100644 apps/web/src/apps/main/modules/configuration/company-settings/presentation/languages/en/company-settings.json create mode 100644 apps/web/src/apps/main/modules/configuration/company-settings/presentation/languages/id/company-settings.json create mode 100644 apps/web/src/apps/main/modules/configuration/company-settings/presentation/pages/company-settings.page.tsx create mode 100644 apps/web/src/apps/main/modules/configuration/company-settings/presentation/store/index.ts create mode 100644 apps/web/src/apps/main/modules/field/timeline/data/timeline.remote.service.ts create mode 100644 apps/web/src/apps/main/modules/field/timeline/domain/constants/timeline.constants.ts create mode 100644 apps/web/src/apps/main/modules/field/timeline/domain/entities/timeline.entity.ts create mode 100644 apps/web/src/apps/main/modules/field/timeline/domain/factories/index.ts create mode 100644 apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-activity-list.tsx create mode 100644 apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-activity-panel.tsx create mode 100644 apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-helpers.test.ts create mode 100644 apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-helpers.tsx create mode 100644 apps/web/src/apps/main/modules/field/timeline/presentation/components/timeline-playback-overlay.tsx create mode 100644 apps/web/src/apps/main/modules/field/timeline/presentation/factory/index.tsx create mode 100644 apps/web/src/apps/main/modules/field/timeline/presentation/languages/en/timeline.json create mode 100644 apps/web/src/apps/main/modules/field/timeline/presentation/languages/id/timeline.json create mode 100644 apps/web/src/apps/main/modules/field/timeline/presentation/pages/timeline.page.index.tsx create mode 100644 apps/web/src/apps/main/modules/field/timeline/presentation/store/index.ts create mode 100644 packages/ui/src/components/map/timeline-map.tsx 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)} + /> + + + + {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} + + ); +}