- 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.
239 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|