Files
trackgo-fe/packages/ui/src/components/map/timeline-map.tsx
T
shancheas 87bfe50f0e 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.
2026-09-01 20:36:57 +07:00

239 lines
6.5 KiB
TypeScript

import { useEffect, useMemo } from 'react';
import {
CircleMarker,
MapContainer,
Polyline,
TileLayer,
Tooltip,
ZoomControl,
useMap,
} from 'react-leaflet';
import { Box, Text } from '@mantine/core';
import { OSM_ATTRIBUTION, OSM_TILE_URL } from './osm';
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from './location-point';
import { toLeafletLatLngs } from './route-geometry';
import 'leaflet/dist/leaflet.css';
import './leaflet-stacking.css';
export type TimelineMapFootprint = {
employeeId: string;
latitude: number;
longitude: number;
recordedAt: number;
};
export type TimelineMapActivity = {
id: string;
type: string;
latitude: number;
longitude: number;
recordedAt: number;
label?: string;
};
export type TimelineMapPlaybackPosition = {
employeeId: string;
latitude: number;
longitude: number;
label?: string;
};
export interface TimelineMapProps {
footprints?: TimelineMapFootprint[];
activities?: TimelineMapActivity[];
playbackPositions?: TimelineMapPlaybackPosition[];
focusPositions?: Array<[number, number]>;
selectedActivityId?: string;
height?: number | string;
radius?: string | number;
fullBleed?: boolean;
emptyLabel?: string;
}
const TRACK_COLORS = [
'var(--mantine-color-blue-6)',
'var(--mantine-color-teal-6)',
'var(--mantine-color-orange-6)',
'var(--mantine-color-grape-6)',
'var(--mantine-color-cyan-6)',
'var(--mantine-color-pink-6)',
];
function InvalidateSize() {
const map = useMap();
useEffect(() => {
const container = map.getContainer();
const observer = new ResizeObserver(() => {
map.invalidateSize();
});
observer.observe(container);
const id = window.setTimeout(() => map.invalidateSize(), 0);
return () => {
window.clearTimeout(id);
observer.disconnect();
};
}, [map]);
return null;
}
function FitTimelineBounds({
positions,
}: {
positions: Array<[number, number]>;
}) {
const map = useMap();
const boundsKey = positions.map(([lat, lng]) => `${lat.toFixed(6)},${lng.toFixed(6)}`).join('|');
useEffect(() => {
if (positions.length === 0) {
map.setView(DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM);
return;
}
if (positions.length === 1) {
map.setView(positions[0], 14);
return;
}
map.fitBounds(positions, { padding: [48, 48] });
}, [map, boundsKey]);
return null;
}
function groupFootprintsByEmployee(
footprints: readonly TimelineMapFootprint[],
): Map<string, TimelineMapFootprint[]> {
const grouped = new Map<string, TimelineMapFootprint[]>();
for (const point of footprints) {
const existing = grouped.get(point.employeeId) ?? [];
grouped.set(point.employeeId, [...existing, point]);
}
for (const [employeeId, points] of grouped) {
grouped.set(
employeeId,
[...points].sort((left, right) => left.recordedAt - right.recordedAt),
);
}
return grouped;
}
export function TimelineMap({
footprints = [],
activities = [],
playbackPositions = [],
focusPositions,
selectedActivityId,
height = 420,
radius = 'md',
fullBleed = false,
emptyLabel = 'No timeline data',
}: TimelineMapProps) {
const groupedTracks = useMemo(
() => groupFootprintsByEmployee(footprints),
[footprints],
);
const positions = useMemo(() => {
const points: Array<[number, number]> = [];
for (const footprint of footprints) {
points.push([footprint.latitude, footprint.longitude]);
}
for (const activity of activities) {
points.push([activity.latitude, activity.longitude]);
}
for (const position of playbackPositions) {
points.push([position.latitude, position.longitude]);
}
return points;
}, [activities, footprints, playbackPositions]);
const boundsPositions =
focusPositions && focusPositions.length > 0 ? focusPositions : positions;
const employeeIds = [...groupedTracks.keys()];
const hasData = positions.length > 0;
return (
<Box
h={height}
bdrs={fullBleed ? 0 : radius}
className="tg-map-viewport"
pos="relative"
>
<MapContainer
center={boundsPositions[0] ?? DEFAULT_MAP_CENTER}
zoom={hasData ? 12 : DEFAULT_MAP_ZOOM}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom
zoomControl={false}
>
<TileLayer attribution={OSM_ATTRIBUTION} url={OSM_TILE_URL} />
<ZoomControl position="topright" />
{employeeIds.map((employeeId, index) => {
const track = groupedTracks.get(employeeId) ?? [];
const line = toLeafletLatLngs({
type: 'LineString',
coordinates: track.map((point) => [point.longitude, point.latitude]),
});
if (line.length < 2) {
return null;
}
return (
<Polyline
key={employeeId}
positions={line}
pathOptions={{
color: TRACK_COLORS[index % TRACK_COLORS.length],
weight: 4,
}}
/>
);
})}
{activities.map((activity) => {
const selected = activity.id === selectedActivityId;
return (
<CircleMarker
key={activity.id}
center={[activity.latitude, activity.longitude]}
radius={selected ? 12 : 9}
pathOptions={{
color: selected ? 'var(--mantine-color-blue-8)' : 'var(--mantine-color-red-7)',
fillOpacity: 0.9,
weight: selected ? 3 : 2,
}}
>
<Tooltip permanent={selected}>{activity.label ?? activity.type}</Tooltip>
</CircleMarker>
);
})}
{playbackPositions.map((position) => (
<CircleMarker
key={`playback-${position.employeeId}`}
center={[position.latitude, position.longitude]}
radius={11}
pathOptions={{
color: 'var(--mantine-color-yellow-7)',
fillColor: 'var(--mantine-color-yellow-4)',
fillOpacity: 1,
weight: 3,
}}
>
<Tooltip permanent>{position.label ?? 'Playback'}</Tooltip>
</CircleMarker>
))}
<FitTimelineBounds positions={boundsPositions} />
<InvalidateSize />
</MapContainer>
{!hasData ? (
<Box
pos="absolute"
top="50%"
left="50%"
style={{ zIndex: 1, transform: 'translate(-50%, -50%)' }}
p="sm"
>
<Text size="sm" c="dimmed">
{emptyLabel}
</Text>
</Box>
) : null}
</Box>
);
}