Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0090bc15f | ||
|
|
f5c1b7430a | ||
|
|
e316afa51d |
@@ -242,40 +242,40 @@ HTTP mapping:
|
||||
|
||||
Catalog (`GET /privilege-keys`, needs `ADMIN.SETTINGS.USER.PRIVILEGES` `view`). Keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule`:
|
||||
|
||||
| code | label |
|
||||
| ---- | ----- |
|
||||
| `ADMIN.SETTINGS.USER.PRIVILEGES` | Privileges |
|
||||
| `ADMIN.SETTINGS.USER.USERS` | Users |
|
||||
| `ADMIN.SETTINGS.DATA.DIVISION` | Divisions |
|
||||
| `ADMIN.SETTINGS.DATA.BRANCH` | Branches |
|
||||
| `ADMIN.SETTINGS.DATA.CUSTOMER` | Customers |
|
||||
| `ADMIN.SETTINGS.DATA.PRODUCT` | Products |
|
||||
| `ADMIN.SETTINGS.DATA.SETTING` | Company settings |
|
||||
| `ADMIN.SALES.DATA.EMPLOYEE` | Employees |
|
||||
| `ADMIN.SALES.DATA.CYCLE` | Sales cycles |
|
||||
| `ADMIN.SALES.ACTIVITIES.REQUEST` | Sales requests |
|
||||
| `ADMIN.SALES.ACTIVITIES.ORDER` | Sales orders |
|
||||
| `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 |
|
||||
| `ADMIN.LOGISTICS.ACTIVITIES.PLAN` | Logistics plans |
|
||||
| `ADMIN.LOGISTICS.REPORT` | Logistics reports |
|
||||
| `MOBILE.SALES.PLAN` | Sales plans (mobile) |
|
||||
| `MOBILE.SALES.PLAN.ATTENDANCE` | Branch attendance |
|
||||
| `MOBILE.SALES.VISIT` | Customer visits |
|
||||
| `MOBILE.SALES.TIMELINE` | Sales timeline (mobile) |
|
||||
| code | label |
|
||||
| ----------------------------------------- | ----------------------- |
|
||||
| `ADMIN.SETTINGS.USER.PRIVILEGES` | Privileges |
|
||||
| `ADMIN.SETTINGS.USER.USERS` | Users |
|
||||
| `ADMIN.SETTINGS.DATA.DIVISION` | Divisions |
|
||||
| `ADMIN.SETTINGS.DATA.BRANCH` | Branches |
|
||||
| `ADMIN.SETTINGS.DATA.CUSTOMER` | Customers |
|
||||
| `ADMIN.SETTINGS.DATA.PRODUCT` | Products |
|
||||
| `ADMIN.SETTINGS.DATA.SETTING` | Company settings |
|
||||
| `ADMIN.SALES.DATA.EMPLOYEE` | Employees |
|
||||
| `ADMIN.SALES.DATA.CYCLE` | Sales cycles |
|
||||
| `ADMIN.SALES.ACTIVITIES.REQUEST` | Sales requests |
|
||||
| `ADMIN.SALES.ACTIVITIES.ORDER` | Sales orders |
|
||||
| `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 |
|
||||
| `ADMIN.LOGISTICS.ACTIVITIES.PLAN` | Logistics plans |
|
||||
| `ADMIN.LOGISTICS.REPORT` | Logistics reports |
|
||||
| `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
|
||||
|
||||
Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`:
|
||||
|
||||
| purpose | cycle keys | plan keys |
|
||||
| ----------- | ---------- | --------- |
|
||||
| `sales` | `ADMIN.SALES.DATA.CYCLE` | `ADMIN.SALES.ACTIVITIES.PLAN`, `MOBILE.SALES.PLAN` |
|
||||
| purpose | cycle keys | plan keys |
|
||||
| ----------- | ---------------------------- | ---------------------------------------------------------- |
|
||||
| `sales` | `ADMIN.SALES.DATA.CYCLE` | `ADMIN.SALES.ACTIVITIES.PLAN`, `MOBILE.SALES.PLAN` |
|
||||
| `logistics` | `ADMIN.LOGISTICS.DATA.CYCLE` | `ADMIN.LOGISTICS.ACTIVITIES.PLAN`, `MOBILE.LOGISTICS.PLAN` |
|
||||
|
||||
`purpose` is read from **body** (writes) or **query** (lists). If omitted, the user may proceed if they have the action on **either** purpose; list results are filtered to purposes they can view. Superadmin bypasses.
|
||||
|
||||
@@ -21,8 +21,8 @@ test.describe('login', () => {
|
||||
await page.getByLabel('Password').fill(E2E_LOGIN.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/app(\/|$)/);
|
||||
await expect(page.getByRole('heading', { name: 'Full Page' })).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/app\/timeline\/index/);
|
||||
await expect(page.getByPlaceholder('Search activities')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows an error and stays on login when credentials are rejected', async ({ page }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { WEB_URL } from '../../core/constants/web-url';
|
||||
import { AuthPageGuard } from '../../core/lib/auth-guard';
|
||||
|
||||
const LoginPage = lazy(() => import('./login'));
|
||||
@@ -9,7 +10,7 @@ export default function AuthModule() {
|
||||
<AuthPageGuard>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<Navigate to="/auth/login" replace={true} />} />
|
||||
<Route path="/" element={<Navigate to={WEB_URL.LOGIN} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</AuthPageGuard>
|
||||
|
||||
@@ -104,12 +104,6 @@ export function RightSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-center text-gray-600 mt-6 max-w-[85%] z-10 leading-[1.6]">
|
||||
Compatible with{' '}
|
||||
<span className="font-bold italic text-gray-900">Gmail, Outlook Web, LinkedIn and most web editors</span> for a
|
||||
smooth writing experience anywhere online.
|
||||
</p>
|
||||
|
||||
{/* Pagination Indicators */}
|
||||
<div className="flex gap-1.5 mt-8 z-10 mb-2">
|
||||
<div className="w-6 h-1 rounded-full bg-brand-500"></div>
|
||||
|
||||
@@ -3,14 +3,11 @@ import {
|
||||
FieldPasswordInput,
|
||||
Checkbox,
|
||||
Button,
|
||||
Divider,
|
||||
Select,
|
||||
Image,
|
||||
notifications,
|
||||
} from '@repo/ui/components';
|
||||
import { Lock, Apple, LogInIcon, UserRound, Globe } from 'lucide-react';
|
||||
import { GoogleIcon } from './components/google-icon';
|
||||
import { MicrosoftIcon } from './components/microsoft-icon';
|
||||
import { Lock, LogInIcon, UserRound, Globe } from 'lucide-react';
|
||||
import { RightSection } from './components/right-section';
|
||||
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { AppStorageKey, appStorage } from '../../../core/storage/local';
|
||||
|
||||
@@ -5,8 +5,10 @@ import { StatusPage, AgGridProvider } from '@repo/ui/components';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import { LoadingScreen } from '../core/components/loading-screen';
|
||||
import { ComingSoonPage } from '../core/components/coming-soon-page';
|
||||
import { WEB_URL } from '../core/constants/web-url';
|
||||
import { registerClientNavigate, registerSessionLifecycle } from '../core/lib/client-navigation';
|
||||
import { useThemeStore } from '../core/stores/theme.store';
|
||||
import { initializeAndPurgeHistoryBackground } from './main/layouts/hooks/useHistoryTracker';
|
||||
import { initializeAndPurgeHistoryBackground, resetHistoryCache } from './main/layouts/hooks/useHistoryTracker';
|
||||
|
||||
const AuthModule = lazy(() => import('./auth'));
|
||||
const AppModule = lazy(() => import('./main'));
|
||||
@@ -20,7 +22,7 @@ function NotFoundPage() {
|
||||
description={t('common:systemPages.notFound.description')}
|
||||
showActionsBack
|
||||
showActionsHome
|
||||
homeUrl="/app"
|
||||
homeUrl={WEB_URL.APP}
|
||||
backButtonLabel={t('common:systemPages.actions.goBack')}
|
||||
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
||||
/>
|
||||
@@ -36,7 +38,7 @@ function ForbiddenPage() {
|
||||
description={t('common:systemPages.forbidden.description')}
|
||||
showActionsBack
|
||||
showActionsHome
|
||||
homeUrl="/app"
|
||||
homeUrl={WEB_URL.APP}
|
||||
backButtonLabel={t('common:systemPages.actions.goBack')}
|
||||
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
||||
/>
|
||||
@@ -61,10 +63,16 @@ const router = createBrowserRouter([
|
||||
{ path: '/403', element: <ForbiddenPage /> },
|
||||
{ path: '/maintenance', element: <MaintenancePage /> },
|
||||
{ path: '/coming-soon', element: <ComingSoonPage /> },
|
||||
{ path: '/', element: <Navigate to="/app" /> },
|
||||
{ path: '/', element: <Navigate to={WEB_URL.APP} /> },
|
||||
{ path: '*', element: <Navigate to="/404" /> },
|
||||
]);
|
||||
|
||||
registerClientNavigate((to, options) => router.navigate(to, options));
|
||||
registerSessionLifecycle({
|
||||
onEnd: resetHistoryCache,
|
||||
onStart: () => initializeAndPurgeHistoryBackground(),
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import type { PrivilegeEntity } from '@repo/ui/foundations';
|
||||
import { WEB_URL } from '../../core/constants/web-url';
|
||||
import { filterMenuByViewPrivilege } from '../../core/lib/filter-menu-by-view-privilege';
|
||||
import { firstProductPath } from '../../core/lib/first-product-path';
|
||||
import { appDatabase, AppDatabaseKey } from '../../core/storage/local';
|
||||
import { MENU_ITEMS } from './layouts/data/menu.data';
|
||||
|
||||
export function AppHomeRedirect() {
|
||||
const [to, setTo] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
|
||||
if (!token || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await appDatabase.getItem<{ isSuperadmin?: boolean }>(AppDatabaseKey.USER_PROFILE);
|
||||
const privileges = await appDatabase.getItem<Record<string, PrivilegeEntity>>(AppDatabaseKey.USER_PRIVILEGE);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = filterMenuByViewPrivilege(MENU_ITEMS, privileges ?? {}, Boolean(profile?.isSuperadmin));
|
||||
setTo(firstProductPath(filtered, WEB_URL.APP_HOME));
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Navigate to={to} replace />;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import ModuleLayout from './layouts/module.layout';
|
||||
import { EmbeddedComingSoonPage } from '../../core/components/coming-soon-page';
|
||||
import { GlobalCredentialChecker } from '../../core/lib/auth-guard';
|
||||
import { AppHomeRedirect } from './app-home-redirect';
|
||||
import ModuleLayout from './layouts/module.layout';
|
||||
|
||||
const ExampleModule = lazy(() => import('./modules/example'));
|
||||
const SystemSetting = lazy(() => import('./modules/system/setting'));
|
||||
@@ -29,7 +31,8 @@ export default function AppModule() {
|
||||
<Route path="/sales/*" element={<SalesModule />} />
|
||||
<Route path="/timeline/*" element={<TimelineModule />} />
|
||||
<Route path="/logistics/*" element={<LogisticsFieldModule />} />
|
||||
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
||||
<Route path="/dashboard" element={<EmbeddedComingSoonPage />} />
|
||||
<Route path="/" element={<AppHomeRedirect />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</ModuleLayout>
|
||||
|
||||
@@ -29,6 +29,11 @@ if (historyChannel) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Background Task: Initialization & Purge (Remain the same)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function resetHistoryCache(): void {
|
||||
memoryHistoryCache = [];
|
||||
isHistoryReady = false;
|
||||
}
|
||||
|
||||
export async function initializeAndPurgeHistoryBackground() {
|
||||
if (isHistoryReady) return;
|
||||
|
||||
|
||||
+1
-4
@@ -1,8 +1,5 @@
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import type {
|
||||
CompanySettingsEntity,
|
||||
UpdateCompanySettingsPayload,
|
||||
} from '../domain/entities/company-settings.entity';
|
||||
import type { CompanySettingsEntity, UpdateCompanySettingsPayload } from '../domain/entities/company-settings.entity';
|
||||
|
||||
export class CompanySettingsRemoteService {
|
||||
constructor(private readonly client: AxiosInstance) {}
|
||||
|
||||
+1
-4
@@ -1,10 +1,7 @@
|
||||
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 { companySettingsModuleConfig, type CompanySettingsShellEntity } from '../constants/company-settings.constants';
|
||||
import { CompanySettingsRemoteService } from '../../data/company-settings.remote.service';
|
||||
|
||||
class CompanySettingsShellTransformer extends BaseDataTransformer<CompanySettingsShellEntity> {
|
||||
|
||||
+1
-4
@@ -25,10 +25,7 @@ export default function CompanySettingsModule() {
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={<Navigate to={`${companySettingsModuleConfig.webUrl}/index`} replace />}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`${companySettingsModuleConfig.webUrl}/index`} replace />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
|
||||
+1
-5
@@ -91,11 +91,7 @@ export default function CompanySettingsPage() {
|
||||
<Stack gap="md" component="form" onSubmit={onSubmit}>
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<FieldDatePicker
|
||||
control={form.control}
|
||||
name="cycleStartDate"
|
||||
label={t('fields.cycleStartDate')}
|
||||
/>
|
||||
<FieldDatePicker control={form.control} name="cycleStartDate" label={t('fields.cycleStartDate')} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<FieldNumberInput
|
||||
|
||||
+1
-3
@@ -49,9 +49,7 @@ export function TimelineActivityList({
|
||||
radius="md"
|
||||
shadow={selected ? 'sm' : undefined}
|
||||
style={{
|
||||
borderColor: selected
|
||||
? 'var(--mantine-color-blue-filled)'
|
||||
: 'var(--mantine-color-default-border)',
|
||||
borderColor: selected ? 'var(--mantine-color-blue-filled)' : 'var(--mantine-color-default-border)',
|
||||
borderWidth: selected ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
|
||||
+1
-8
@@ -1,12 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@repo/ui/components';
|
||||
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';
|
||||
|
||||
+3
-1
@@ -115,7 +115,9 @@ describe('timeline helpers', () => {
|
||||
'visit-1',
|
||||
'ungrouped',
|
||||
]);
|
||||
expect(filterActivityGroups(groups, { tab: 'completed', query: '' }).map((group) => group.key)).toEqual(['visit-2']);
|
||||
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',
|
||||
]);
|
||||
|
||||
+4
-16
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
TimelineActivityEntity,
|
||||
TimelineFootprintEntity,
|
||||
} from '../../domain/entities/timeline.entity';
|
||||
import type { TimelineActivityEntity, TimelineFootprintEntity } from '../../domain/entities/timeline.entity';
|
||||
|
||||
export type TimelineActivityGroup = {
|
||||
key: string;
|
||||
@@ -46,9 +43,7 @@ export function groupActivities(activities: TimelineActivityEntity[]): TimelineA
|
||||
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));
|
||||
const title = last?.customer?.name ?? (key === 'ungrouped' ? 'ungrouped' : (last?.customer?.code ?? key));
|
||||
|
||||
return {
|
||||
key,
|
||||
@@ -95,10 +90,7 @@ export function resolvePlaybackPositions(
|
||||
return [];
|
||||
}
|
||||
|
||||
const latestByEmployee = new Map<
|
||||
string,
|
||||
{ latitude: number; longitude: number; label: string }
|
||||
>();
|
||||
const latestByEmployee = new Map<string, { latitude: number; longitude: number; label: string }>();
|
||||
|
||||
for (const footprint of footprints) {
|
||||
if (footprint.recordedAt > playbackTime) {
|
||||
@@ -155,10 +147,6 @@ export function shouldRestartPlayback(
|
||||
return playbackTime === null || playbackTime >= bounds.max;
|
||||
}
|
||||
|
||||
export function advancePlaybackTime(
|
||||
current: number,
|
||||
bounds: { min: number; max: number },
|
||||
step: number,
|
||||
): number {
|
||||
export function advancePlaybackTime(current: number, bounds: { min: number; max: number }, step: number): number {
|
||||
return Math.min(bounds.max, current + step);
|
||||
}
|
||||
|
||||
+1
-4
@@ -104,10 +104,7 @@ export function TimelinePlaybackOverlay({
|
||||
value={last ? `${formatClock(last.recordedAt)} · ${activityLabel(last.type)}` : '—'}
|
||||
/>
|
||||
<OverlayMetric label={currentLocationLabel} value={currentLocation || '—'} />
|
||||
<OverlayMetric
|
||||
label={activitiesLabel}
|
||||
value={group ? String(group.activities.length) : '—'}
|
||||
/>
|
||||
<OverlayMetric label={activitiesLabel} value={group ? String(group.activities.length) : '—'} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -25,10 +25,7 @@ export default function SalesTimelineModule() {
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={<Navigate to={`${salesTimelineModuleConfig.webUrl}/index`} replace />}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`${salesTimelineModuleConfig.webUrl}/index`} replace />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
|
||||
+4
-18
@@ -153,10 +153,7 @@ export default function TimelinePage() {
|
||||
[footprints, playbackTime],
|
||||
);
|
||||
const groups = useMemo(() => groupActivities(visibleActivities), [visibleActivities]);
|
||||
const filteredGroups = useMemo(
|
||||
() => filterActivityGroups(groups, { tab, query: search }),
|
||||
[groups, search, tab],
|
||||
);
|
||||
const filteredGroups = useMemo(() => filterActivityGroups(groups, { tab, query: search }), [groups, search, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredGroups.length === 0) {
|
||||
@@ -179,9 +176,7 @@ export default function TimelinePage() {
|
||||
);
|
||||
const focusPositions = useMemo(() => {
|
||||
if (selectedGroup && selectedGroup.activities.length > 0) {
|
||||
return selectedGroup.activities.map(
|
||||
(activity): [number, number] => [activity.latitude, activity.longitude],
|
||||
);
|
||||
return selectedGroup.activities.map((activity): [number, number] => [activity.latitude, activity.longitude]);
|
||||
}
|
||||
return dayPositions;
|
||||
}, [dayPositions, selectedGroup]);
|
||||
@@ -195,10 +190,7 @@ export default function TimelinePage() {
|
||||
})),
|
||||
[visibleFootprints],
|
||||
);
|
||||
const activityLabel = useCallback(
|
||||
(type: string) => t(`activities.types.${type}`, { defaultValue: type }),
|
||||
[t],
|
||||
);
|
||||
const activityLabel = useCallback((type: string) => t(`activities.types.${type}`, { defaultValue: type }), [t]);
|
||||
const mapActivities = useMemo(
|
||||
() =>
|
||||
visibleActivities.map((activity) => ({
|
||||
@@ -282,13 +274,7 @@ export default function TimelinePage() {
|
||||
</Box>
|
||||
</FormProvider>
|
||||
|
||||
<Box
|
||||
pos="absolute"
|
||||
bottom={16}
|
||||
left={{ base: 12, md: 432 }}
|
||||
right={{ base: 12, md: 16 }}
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<Box pos="absolute" bottom={16} left={{ base: 12, md: 432 }} right={{ base: 12, md: 16 }} style={{ zIndex: 2 }}>
|
||||
<TimelinePlaybackOverlay
|
||||
group={selectedGroup}
|
||||
ungroupedLabel={t('activities.ungrouped')}
|
||||
|
||||
+3
-11
@@ -52,14 +52,9 @@ describe('groupPrivilegeKeys', () => {
|
||||
|
||||
expect(grouped.map((tab) => tab.group)).toEqual(['ADMIN', 'MOBILE']);
|
||||
expect(grouped[0]?.parents[0]?.parent).toBe('SALES');
|
||||
expect(grouped[0]?.parents[0]?.modules.map((m) => m.module)).toEqual([
|
||||
'ACTIVITIES',
|
||||
'DATA',
|
||||
]);
|
||||
expect(grouped[0]?.parents[0]?.modules.map((m) => m.module)).toEqual(['ACTIVITIES', 'DATA']);
|
||||
expect(grouped[1]?.parents[0]?.modules[0]?.directRow?.code).toBe('MOBILE.SALES.PLAN');
|
||||
expect(grouped[1]?.parents[0]?.modules[0]?.submodules[0]?.code).toBe(
|
||||
'MOBILE.SALES.PLAN.ATTENDANCE',
|
||||
);
|
||||
expect(grouped[1]?.parents[0]?.modules[0]?.submodules[0]?.code).toBe('MOBILE.SALES.PLAN.ATTENDANCE');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,10 +67,7 @@ describe('flattenParentSectionRows', () => {
|
||||
expect(salesParent).toBeDefined();
|
||||
const tableRows = flattenParentSectionRows(salesParent!);
|
||||
|
||||
expect(tableRows.map((r) => r.code)).toEqual([
|
||||
'ADMIN.SALES.DATA.CYCLE',
|
||||
'ADMIN.SALES.ACTIVITIES.PLAN',
|
||||
]);
|
||||
expect(tableRows.map((r) => r.code)).toEqual(['ADMIN.SALES.DATA.CYCLE', 'ADMIN.SALES.ACTIVITIES.PLAN']);
|
||||
expect(tableRows[0]).toMatchObject({ module: 'DATA', submodule: 'CYCLE' });
|
||||
expect(tableRows[1]).toMatchObject({ module: 'ACTIVITIES', submodule: 'PLAN' });
|
||||
});
|
||||
|
||||
+3
-15
@@ -28,10 +28,7 @@ function segmentLabel(t: (key: string) => string, kind: string, value: string) {
|
||||
return translated === key ? formatPrivilegeSegment(value) : translated;
|
||||
}
|
||||
|
||||
function submoduleCellLabel(
|
||||
t: (key: string) => string,
|
||||
row: PrivilegeTableRow,
|
||||
) {
|
||||
function submoduleCellLabel(t: (key: string) => string, row: PrivilegeTableRow) {
|
||||
if (row.submodule) {
|
||||
return row.label || segmentLabel(t, 'submodule', row.submodule);
|
||||
}
|
||||
@@ -53,11 +50,7 @@ function MatrixActionCells({
|
||||
<Table.Td key={action} ta="center" w={ACTION_COL_WIDTH} miw={ACTION_COL_WIDTH} maw={ACTION_COL_WIDTH}>
|
||||
<Box display="flex" style={{ justifyContent: 'center', alignItems: 'center' }}>
|
||||
{mode === 'edit' && formControl ? (
|
||||
<FieldCheckbox
|
||||
control={formControl.control}
|
||||
name={`matrix.${row.keyId}.${action}`}
|
||||
label=""
|
||||
/>
|
||||
<FieldCheckbox control={formControl.control} name={`matrix.${row.keyId}.${action}`} label="" />
|
||||
) : row.cell[action] ? (
|
||||
<Check size={16} />
|
||||
) : (
|
||||
@@ -137,12 +130,7 @@ function ParentMatrixTable({
|
||||
);
|
||||
}
|
||||
|
||||
export function PrivilegeMatrixLayout({
|
||||
groups,
|
||||
t,
|
||||
mode,
|
||||
formControl,
|
||||
}: PrivilegeMatrixLayoutProps) {
|
||||
export function PrivilegeMatrixLayout({ groups, t, mode, formControl }: PrivilegeMatrixLayoutProps) {
|
||||
if (groups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StatusPage } from '@repo/ui/components';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import { WEB_URL } from '../constants/web-url';
|
||||
|
||||
interface ComingSoonPageProps {
|
||||
/** Use inside ModuleLayout so the page fits under the app chrome */
|
||||
@@ -16,7 +17,7 @@ export function ComingSoonPage({ embedded = false }: ComingSoonPageProps) {
|
||||
description={t('common:systemPages.comingSoon.description')}
|
||||
showActionsBack={!embedded}
|
||||
showActionsHome
|
||||
homeUrl="/app"
|
||||
homeUrl={WEB_URL.APP}
|
||||
backButtonLabel={t('common:systemPages.actions.goBack')}
|
||||
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
||||
height={embedded ? 500 : undefined}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { WEB_URL } from './web-url';
|
||||
|
||||
describe('WEB_URL', () => {
|
||||
it('uses an ungated fallback home instead of the example module', () => {
|
||||
expect(WEB_URL.APP_HOME).toBe('/app/dashboard');
|
||||
expect(WEB_URL.APP_HOME).not.toContain('/example/');
|
||||
expect(WEB_URL.LOGIN).toBe('/auth/login');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
export const WEB_URL = {
|
||||
LOGIN: '/auth/login',
|
||||
APP: '/app',
|
||||
APP_HOME: '/app/dashboard',
|
||||
DASHBOARD: '/app/dashboard',
|
||||
TIMELINE: '/app/timeline',
|
||||
USERS: '/app/system/users',
|
||||
DIVISIONS: '/app/configuration/divisions',
|
||||
BRANCHES: '/app/configuration/branches',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { appDatabase, AppDatabaseKey } from '../storage/local';
|
||||
import { WEB_URL } from '../constants/web-url';
|
||||
import { refreshAuthSession, terminateAuthSession } from './auth.helper';
|
||||
|
||||
/**
|
||||
@@ -46,12 +47,12 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
|
||||
if (token && !isTokenExpired(token)) {
|
||||
navigate('/app', { replace: true });
|
||||
navigate(WEB_URL.APP, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (await restoreSessionWithRefresh()) {
|
||||
navigate('/app', { replace: true });
|
||||
navigate(WEB_URL.APP, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -80,8 +81,6 @@ export function AuthPageGuard({ children }: { children: ReactNode }) {
|
||||
*/
|
||||
export function GlobalCredentialChecker({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
let intervalId: ReturnType<typeof setInterval>;
|
||||
|
||||
async function checkCredential() {
|
||||
try {
|
||||
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
|
||||
@@ -102,7 +101,7 @@ export function GlobalCredentialChecker({ children }: { children: ReactNode }) {
|
||||
|
||||
checkCredential();
|
||||
|
||||
intervalId = setInterval(checkCredential, 60000);
|
||||
const intervalId = setInterval(checkCredential, 60000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock('./api-client', () => ({
|
||||
import { appDatabase, appStorage } from '../storage/local';
|
||||
import { apiClient } from './api-client';
|
||||
import { initiateAuthSession, logoutAuthSession, persistTokenPair, terminateAuthSession } from './auth.helper';
|
||||
import { resetClientNavigate } from './client-navigation';
|
||||
|
||||
const tokens = { accessToken: 'access-1', refreshToken: 'refresh-1' };
|
||||
const me = {
|
||||
@@ -45,7 +46,7 @@ const me = {
|
||||
function stubLocation(pathname: string, search = '') {
|
||||
const replace = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
location: { pathname, search, replace },
|
||||
location: { pathname, search, replace, origin: 'https://admin.trackgo.eigen.co.id' },
|
||||
});
|
||||
return replace;
|
||||
}
|
||||
@@ -53,6 +54,7 @@ function stubLocation(pathname: string, search = '') {
|
||||
describe('auth.helper', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetClientNavigate();
|
||||
});
|
||||
|
||||
it('persists the token pair', async () => {
|
||||
@@ -89,6 +91,18 @@ describe('auth.helper', () => {
|
||||
expect(replace).toHaveBeenCalledWith('/app');
|
||||
});
|
||||
|
||||
it('uses the SPA navigator after login so nginx is not asked for a missing file', async () => {
|
||||
const { registerClientNavigate } = await import('./client-navigation');
|
||||
const navigate = vi.fn();
|
||||
const replace = stubLocation('/auth/login', '');
|
||||
registerClientNavigate(navigate);
|
||||
|
||||
await initiateAuthSession(tokens, me);
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith('/app', { replace: true });
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honours the redirect query param after login', async () => {
|
||||
const replace = stubLocation('/auth/login', '?redirect=%2Fapp%2Fcustomers');
|
||||
|
||||
@@ -97,6 +111,14 @@ describe('auth.helper', () => {
|
||||
expect(replace).toHaveBeenCalledWith('/app/customers');
|
||||
});
|
||||
|
||||
it('ignores an external redirect query param', async () => {
|
||||
const replace = stubLocation('/auth/login', '?redirect=https%3A%2F%2Fevil.example');
|
||||
|
||||
await initiateAuthSession(tokens, me);
|
||||
|
||||
expect(replace).toHaveBeenCalledWith('/app');
|
||||
});
|
||||
|
||||
it('clears the refresh token when terminating a session', async () => {
|
||||
const replace = stubLocation('/app/customers');
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { appDatabase, AppDatabaseKey, appStorage, AppStorageKey } from '../storage/local';
|
||||
import { API_URL } from '../constants/api-url';
|
||||
import { WEB_URL } from '../constants/web-url';
|
||||
import { clientReplace, notifySessionEnd, notifySessionStart } from './client-navigation';
|
||||
import { mapUserPrivileges } from './map-user-privileges';
|
||||
import { safeInternalPath } from './safe-internal-path';
|
||||
import type { AuthUser, TokenPair } from './auth.types';
|
||||
|
||||
interface TerminateOptions {
|
||||
@@ -39,16 +42,17 @@ export async function terminateAuthSession(options: TerminateOptions = {}): Prom
|
||||
await appDatabase.removeItem(AppDatabaseKey.REFRESH_TOKEN);
|
||||
await appStorage.removeItem(AppStorageKey.USER_ID);
|
||||
|
||||
let loginUrl = '/auth/login';
|
||||
const isNotLoginPage = !window.location.pathname.includes('/auth/login');
|
||||
let loginUrl = WEB_URL.LOGIN;
|
||||
const isLoginPage =
|
||||
window.location.pathname === WEB_URL.LOGIN || window.location.pathname.startsWith(`${WEB_URL.LOGIN}/`);
|
||||
|
||||
if (isNotLoginPage) {
|
||||
if (!isLoginPage) {
|
||||
if (preserveRedirect) {
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
loginUrl += `?redirect=${encodeURIComponent(currentPath)}`;
|
||||
loginUrl += `?redirect=${encodeURIComponent(window.location.pathname)}`;
|
||||
}
|
||||
|
||||
window.location.replace(loginUrl);
|
||||
notifySessionEnd();
|
||||
clientReplace(loginUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,10 +63,10 @@ export async function initiateAuthSession(tokens: TokenPair, me: AuthUser): Prom
|
||||
|
||||
await persistTokenPair(tokens);
|
||||
await persistUserSession(me);
|
||||
notifySessionStart();
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const redirectTo = params.get('redirect');
|
||||
window.location.replace(redirectTo ? decodeURIComponent(redirectTo) : '/app');
|
||||
clientReplace(safeInternalPath(params.get('redirect'), WEB_URL.APP));
|
||||
}
|
||||
|
||||
let refreshInFlight: Promise<TokenPair> | null = null;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
clientReplace,
|
||||
notifySessionEnd,
|
||||
registerClientNavigate,
|
||||
registerSessionLifecycle,
|
||||
resetClientNavigate,
|
||||
} from './client-navigation';
|
||||
|
||||
describe('clientReplace', () => {
|
||||
afterEach(() => {
|
||||
resetClientNavigate();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uses the registered SPA navigator instead of a full page load', () => {
|
||||
const navigate = vi.fn();
|
||||
const replace = vi.fn();
|
||||
vi.stubGlobal('window', { location: { replace } });
|
||||
registerClientNavigate(navigate);
|
||||
|
||||
clientReplace('/auth/login');
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith('/auth/login', { replace: true });
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to location.replace when no navigator is registered', () => {
|
||||
const replace = vi.fn();
|
||||
vi.stubGlobal('window', { location: { replace } });
|
||||
|
||||
clientReplace('/auth/login');
|
||||
|
||||
expect(replace).toHaveBeenCalledWith('/auth/login');
|
||||
});
|
||||
|
||||
it('runs onEnd when the session is torn down', () => {
|
||||
const onEnd = vi.fn();
|
||||
registerSessionLifecycle({ onEnd });
|
||||
|
||||
notifySessionEnd();
|
||||
|
||||
expect(onEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
type ClientNavigate = (to: string, options: { replace: boolean }) => void | Promise<void>;
|
||||
|
||||
interface SessionLifecycle {
|
||||
onStart?: () => void | Promise<void>;
|
||||
onEnd?: () => void;
|
||||
}
|
||||
|
||||
let navigateFn: ClientNavigate | null = null;
|
||||
let sessionLifecycle: SessionLifecycle = {};
|
||||
|
||||
export function registerClientNavigate(fn: ClientNavigate): void {
|
||||
navigateFn = fn;
|
||||
}
|
||||
|
||||
export function registerSessionLifecycle(lifecycle: SessionLifecycle): void {
|
||||
sessionLifecycle = lifecycle;
|
||||
}
|
||||
|
||||
export function resetClientNavigate(): void {
|
||||
navigateFn = null;
|
||||
sessionLifecycle = {};
|
||||
}
|
||||
|
||||
export function notifySessionStart(): void {
|
||||
void sessionLifecycle.onStart?.();
|
||||
}
|
||||
|
||||
export function notifySessionEnd(): void {
|
||||
sessionLifecycle.onEnd?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stay inside the already-loaded SPA when possible.
|
||||
* A full `location.replace` makes nginx look up a real file (e.g. `/auth/login`) and 404.
|
||||
*/
|
||||
export function clientReplace(to: string): void {
|
||||
if (!navigateFn) {
|
||||
window.location.replace(to);
|
||||
return;
|
||||
}
|
||||
|
||||
void Promise.resolve(navigateFn(to, { replace: true })).catch(() => {
|
||||
window.location.replace(to);
|
||||
});
|
||||
}
|
||||
@@ -27,7 +27,9 @@ describe('enterpriseStorageAdapter', () => {
|
||||
return null;
|
||||
});
|
||||
|
||||
await expect(enterpriseStorageAdapter.getPrivileges('ADMIN.SETTINGS.USER.PRIVILEGES')).resolves.toEqual(defaultPrivileges);
|
||||
await expect(enterpriseStorageAdapter.getPrivileges('ADMIN.SETTINGS.USER.PRIVILEGES')).resolves.toEqual(
|
||||
defaultPrivileges,
|
||||
);
|
||||
await expect(enterpriseStorageAdapter.getPrivileges('UNKNOWN')).resolves.toEqual(defaultPrivileges);
|
||||
});
|
||||
|
||||
|
||||
@@ -94,7 +94,11 @@ describe('filterMenuByViewPrivilege', () => {
|
||||
},
|
||||
];
|
||||
|
||||
const filtered = filterMenuByViewPrivilege(menu, { 'ADMIN.SALES.ACTIVITIES.ORDER': { ...noPrivileges, ALLOW_VIEW: true } }, false);
|
||||
const filtered = filterMenuByViewPrivilege(
|
||||
menu,
|
||||
{ 'ADMIN.SALES.ACTIVITIES.ORDER': { ...noPrivileges, ALLOW_VIEW: true } },
|
||||
false,
|
||||
);
|
||||
|
||||
expect(filtered.find((item) => item.key === 'sales')?.children?.map((child) => child.key)).toEqual([
|
||||
'orders',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { firstProductPath } from './first-product-path';
|
||||
|
||||
describe('firstProductPath', () => {
|
||||
it('skips ungated placeholders and returns the first privileged leaf', () => {
|
||||
const path = firstProductPath(
|
||||
[
|
||||
{ path: '/app/dashboard' },
|
||||
{ path: '/app/timeline/index', moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE' },
|
||||
{
|
||||
path: '/app/sales',
|
||||
children: [{ path: '/app/sales/orders/index', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER' }],
|
||||
},
|
||||
],
|
||||
'/app/dashboard',
|
||||
);
|
||||
|
||||
expect(path).toBe('/app/timeline/index');
|
||||
});
|
||||
|
||||
it('walks nested children when the first privileged item is nested', () => {
|
||||
const path = firstProductPath(
|
||||
[
|
||||
{ path: '/app/dashboard' },
|
||||
{
|
||||
path: '/app/logistics',
|
||||
children: [{ path: '/app/logistics/plans/index', moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN' }],
|
||||
},
|
||||
],
|
||||
'/app/dashboard',
|
||||
);
|
||||
|
||||
expect(path).toBe('/app/logistics/plans/index');
|
||||
});
|
||||
|
||||
it('returns the fallback when no privileged leaf remains', () => {
|
||||
expect(firstProductPath([{ path: '/app/dashboard' }], '/app/dashboard')).toBe('/app/dashboard');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
export function firstProductPath<T extends { path?: string; moduleKey?: string; children?: T[] }>(
|
||||
items: T[],
|
||||
fallback: string,
|
||||
): string {
|
||||
for (const item of items) {
|
||||
if (item.children?.length) {
|
||||
const nested = firstProductPath(item.children, '');
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.moduleKey && item.path) {
|
||||
return item.path;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { safeInternalPath } from './safe-internal-path';
|
||||
|
||||
const ORIGIN = 'https://admin.trackgo.eigen.co.id';
|
||||
|
||||
describe('safeInternalPath', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', { location: { origin: ORIGIN } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns the fallback when the value is missing', () => {
|
||||
expect(safeInternalPath(null, '/app')).toBe('/app');
|
||||
expect(safeInternalPath(undefined, '/app')).toBe('/app');
|
||||
expect(safeInternalPath('', '/app')).toBe('/app');
|
||||
});
|
||||
|
||||
it('accepts an in-app absolute path', () => {
|
||||
expect(safeInternalPath('/app/timeline/index', '/app')).toBe('/app/timeline/index');
|
||||
expect(safeInternalPath('/app/timeline/index?tab=map', '/app')).toBe('/app/timeline/index?tab=map');
|
||||
});
|
||||
|
||||
it('rejects open redirects', () => {
|
||||
expect(safeInternalPath('https://evil.example', '/app')).toBe('/app');
|
||||
expect(safeInternalPath('//evil.example/phish', '/app')).toBe('/app');
|
||||
expect(safeInternalPath('auth/login', '/app')).toBe('/app');
|
||||
expect(safeInternalPath('/auth/login', '/app')).toBe('/app');
|
||||
expect(safeInternalPath('/\\evil.example', '/app')).toBe('/app');
|
||||
expect(safeInternalPath('/\tevil.example', '/app')).toBe('/app');
|
||||
expect(safeInternalPath('/\n/evil.example', '/app')).toBe('/app');
|
||||
expect(safeInternalPath('/\r/evil.example', '/app')).toBe('/app');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { WEB_URL } from '../constants/web-url';
|
||||
|
||||
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/;
|
||||
|
||||
function resolveOrigin(): string {
|
||||
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
}
|
||||
return 'http://localhost';
|
||||
}
|
||||
|
||||
/** Allows in-app paths only (`/app` or `/app/...`). Rejects open redirects. */
|
||||
export function safeInternalPath(raw: string | null | undefined, fallback: string): string {
|
||||
if (!raw || raw.includes('\\') || CONTROL_CHARS.test(raw)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let candidate: URL;
|
||||
try {
|
||||
candidate = new URL(raw, resolveOrigin());
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (candidate.origin !== resolveOrigin()) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const path = `${candidate.pathname}${candidate.search}${candidate.hash}`;
|
||||
if (path !== WEB_URL.APP && !path.startsWith(`${WEB_URL.APP}/`)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
Polyline,
|
||||
TileLayer,
|
||||
Tooltip,
|
||||
ZoomControl,
|
||||
useMap,
|
||||
} from 'react-leaflet';
|
||||
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';
|
||||
@@ -76,11 +68,7 @@ function InvalidateSize() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function FitTimelineBounds({
|
||||
positions,
|
||||
}: {
|
||||
positions: Array<[number, number]>;
|
||||
}) {
|
||||
function FitTimelineBounds({ positions }: { positions: Array<[number, number]> }) {
|
||||
const map = useMap();
|
||||
const boundsKey = positions.map(([lat, lng]) => `${lat.toFixed(6)},${lng.toFixed(6)}`).join('|');
|
||||
useEffect(() => {
|
||||
@@ -97,9 +85,7 @@ function FitTimelineBounds({
|
||||
return null;
|
||||
}
|
||||
|
||||
function groupFootprintsByEmployee(
|
||||
footprints: readonly TimelineMapFootprint[],
|
||||
): Map<string, TimelineMapFootprint[]> {
|
||||
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) ?? [];
|
||||
@@ -125,10 +111,7 @@ export function TimelineMap({
|
||||
fullBleed = false,
|
||||
emptyLabel = 'No timeline data',
|
||||
}: TimelineMapProps) {
|
||||
const groupedTracks = useMemo(
|
||||
() => groupFootprintsByEmployee(footprints),
|
||||
[footprints],
|
||||
);
|
||||
const groupedTracks = useMemo(() => groupFootprintsByEmployee(footprints), [footprints]);
|
||||
|
||||
const positions = useMemo(() => {
|
||||
const points: Array<[number, number]> = [];
|
||||
@@ -144,18 +127,12 @@ export function TimelineMap({
|
||||
return points;
|
||||
}, [activities, footprints, playbackPositions]);
|
||||
|
||||
const boundsPositions =
|
||||
focusPositions && focusPositions.length > 0 ? focusPositions : positions;
|
||||
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"
|
||||
>
|
||||
<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}
|
||||
@@ -221,13 +198,7 @@ export function TimelineMap({
|
||||
<InvalidateSize />
|
||||
</MapContainer>
|
||||
{!hasData ? (
|
||||
<Box
|
||||
pos="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
style={{ zIndex: 1, transform: 'translate(-50%, -50%)' }}
|
||||
p="sm"
|
||||
>
|
||||
<Box pos="absolute" top="50%" left="50%" style={{ zIndex: 1, transform: 'translate(-50%, -50%)' }} p="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{emptyLabel}
|
||||
</Text>
|
||||
|
||||
Reference in New Issue
Block a user