Author SHA1 Message Date
shancheas f5c1b7430a refactor: simplify login component imports and remove unused dependencies
- Removed the `Divider` import and unnecessary icons (`GoogleIcon`, `MicrosoftIcon`) from the login component, streamlining the code.
- Improved code readability by consolidating imports, enhancing maintainability.

These changes contribute to a cleaner and more efficient login component, making it easier for developers to work with the codebase.
2026-09-02 08:40:08 +07:00
shancheas e316afa51d refactor: clean up code formatting and improve readability
- Standardized table formatting in `api.md` for privilege keys, enhancing clarity.
- Removed unnecessary line breaks and improved inline formatting in various components, including `right-section.tsx`, `company-settings.remote.service.ts`, and `timeline-helpers.tsx`, to streamline code readability.
- Updated test cases in `privilege-key-hierarchy.test.ts` and `filter-menu-by-view-privilege.test.ts` for better alignment and consistency in assertions.

These changes improve the overall code quality and maintainability, making it easier for developers to navigate and understand the codebase.
2026-09-02 08:14:37 +07:00
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
shancheas 4c643547b6 feat: enhance plan entity and transformer for customer handling
- Added optional `customer` field to `PlanDestinationEntity` to improve customer association.
- Updated `PlansRemoteDataTransformer` to map nested customer data from destinations, ensuring accurate customer representation in transformed entities.
- Enhanced `FormDocumentsPreview` to display customer information alongside documents, improving clarity in document management.
- Introduced new tests for customer mapping in transformers, ensuring reliability and correctness in data handling.

These changes significantly improve the handling of customer data within plans, enhancing user experience and data integrity.
2026-09-01 19:25:29 +07:00
shancheas 6a54aa6c50 feat: update privilege keys and module configurations for enhanced access control
- Refactored privilege keys in `api.md` to use a more structured naming convention, aligning with the new `Group.Parent.Module` format.
- Updated various module configurations across the application to reflect the new privilege key structure, ensuring consistent access control.
- Removed deprecated keys and streamlined the privilege management process, enhancing clarity and maintainability.
- Added new tests for privilege key parsing and grouping functionalities to ensure reliability and correctness.

These changes significantly improve the application's privilege management system, providing a clearer structure for access control and enhancing overall security.
2026-09-01 13:15:12 +07:00
89 changed files with 2403 additions and 268 deletions
+5 -5
View File
@@ -8,11 +8,11 @@ alwaysApply: false
Every authenticated product module in `apps/web` must be gated by `GET /auth/me` permissions. Copy Privileges (`system/privileges`) — do not invent a second RBAC path.
Catalog keys live in `api.md` §4 (`PRIVILEGES`, `CONFIGURATION.BRANCH`, `SALES.ORDER`, …). `isSuperadmin` bypasses the matrix (adapter returns `defaultPrivileges`).
Catalog keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule` (e.g. `ADMIN.SETTINGS.DATA.BRANCH`, `ADMIN.SALES.ACTIVITIES.ORDER`). `isSuperadmin` bypasses the matrix (adapter returns `defaultPrivileges`).
## Required wiring (do all four)
1. **`moduleKey`** on `ModuleConfigEntity` equals the catalog `code` (e.g. `CONFIGURATION.BRANCH`).
1. **`moduleKey`** on `ModuleConfigEntity` equals the Admin catalog `code` (e.g. `ADMIN.SETTINGS.DATA.BRANCH`).
2. **Menu leaf** in `layouts/data/menu.data.ts` sets the same `moduleKey`. `filterMenuByViewPrivilege` hides the item when `ALLOW_VIEW` is false.
3. **Routes** wrap in `EnterpriseModuleProvider` so missing `ALLOW_VIEW` shows forbidden (no all-true flash).
4. **Do not** re-check create/edit/delete in page JSX. Foundations already hide actions from `PrivilegeEntity`.
@@ -23,8 +23,8 @@ if (!user.permissions.BRANCHES?.create) return null;
{ key: 'branches', path: '/app/system/branches/index' }
// GOOD
export const branchesModuleConfig = { moduleKey: 'CONFIGURATION.BRANCH', /* ... */ };
{ key: 'system-branches', path: '/app/system/branches/index', moduleKey: 'CONFIGURATION.BRANCH' }
export const branchesModuleConfig = { moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH', /* ... */ };
{ key: 'system-branches', path: '/app/system/branches/index', moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH' }
```
## Flag map (`mapUserPrivileges`)
@@ -37,6 +37,6 @@ export const branchesModuleConfig = { moduleKey: 'CONFIGURATION.BRANCH', /* ...
| `delete` | `ALLOW_DELETE` |
| `import` | `ALLOW_IMPORT` |
Missing flag → `false`. Cycles/plans: key follows `purpose` (`SALES.CYCLE` / `LOGISTICS.PLAN`), not a generic `CYCLES` key.
Missing flag → `false`. Cycles/plans: key follows `purpose` (`ADMIN.SALES.DATA.CYCLE` / `ADMIN.LOGISTICS.ACTIVITIES.PLAN`), not a generic `CYCLES` key.
Reference: [apps/web/src/core/lib/map-user-privileges.ts](apps/web/src/core/lib/map-user-privileges.ts), [filter-menu-by-view-privilege.ts](apps/web/src/core/lib/filter-menu-by-view-privilege.ts).
+31 -24
View File
@@ -240,36 +240,43 @@ HTTP mapping:
| `DELETE /:id`, `POST /bulk-delete` | `delete` |
| `POST /import` | `import` |
Catalog (`GET /privilege-keys`, needs `PRIVILEGES` `view`):
Catalog (`GET /privilege-keys`, needs `ADMIN.SETTINGS.USER.PRIVILEGES` `view`). Keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule`:
| code | label |
| ------------------------ | ---------------- |
| `PRIVILEGES` | Privileges |
| `USERS` | Users |
| `CONFIGURATION.DIVISION` | Divisions |
| `CONFIGURATION.BRANCH` | Branches |
| `CONFIGURATION.CUSTOMER` | Customers |
| `CONFIGURATION.EMPLOYEE` | Employees |
| `CONFIGURATION.PRODUCT` | Products |
| `SALES.REQUEST` | Sales requests |
| `SALES.ORDER` | Sales orders |
| `SALES.PACKING_SLIP` | Packing slips |
| `SALES.INVOICE` | Sales invoices |
| `SALES.PAYMENT` | Sales payments |
| `CONFIGURATION.SETTING` | Company settings |
| `SALES.CYCLE` | Sales cycles |
| `SALES.PLAN` | Sales plans |
| `LOGISTICS.CYCLE` | Logistics cycles |
| `LOGISTICS.PLAN` | Logistics plans |
| 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 key | plan key |
| ----------- | ----------------- | ---------------- |
| `sales` | `SALES.CYCLE` | `SALES.PLAN` |
| `logistics` | `LOGISTICS.CYCLE` | `LOGISTICS.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.
@@ -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>
+1 -44
View File
@@ -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';
@@ -167,9 +164,6 @@ export default function LoginPage() {
label: { color: '#64748b', fontWeight: 500 },
}}
/>
<a href="#" className="text-[13px] text-brand-600 font-semibold hover:underline">
{t('forgot_password')}
</a>
</div>
<Button
@@ -182,43 +176,6 @@ export default function LoginPage() {
>
{t('login_button')}
</Button>
<Divider
label={t('or_login_with')}
labelPosition="center"
my="lg"
styles={{ label: { color: '#94a3b8', fontSize: '12px' } }}
/>
<div className="grid grid-cols-3 gap-4">
<Button
type="button"
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<GoogleIcon className="w-5 h-5" />
</Button>
<Button
type="button"
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<Apple className="w-5.5 h-5.5 text-black" fill="black" />
</Button>
<Button
type="button"
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<MicrosoftIcon className="w-5 h-5" />
</Button>
</div>
</form>
</div>
</div>
+2
View File
@@ -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() {
<Route path="/system/users/*" element={<UsersModule />} />
<Route path="/configuration/*" element={<ConfigurationModule />} />
<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="*" element={<Navigate to={'/404'} replace={true} />} />
@@ -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',
@@ -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',
@@ -50,14 +57,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:configuration-employees',
icon: Users,
path: '/app/sales/employees/index',
moduleKey: 'CONFIGURATION.EMPLOYEE',
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
},
{
key: 'sales-cycles',
label: 'nav:sales-cycles',
icon: Repeat,
path: '/app/sales/cycles/index',
moduleKey: 'SALES.CYCLE',
moduleKey: 'ADMIN.SALES.DATA.CYCLE',
},
],
},
@@ -72,35 +79,35 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:sales-requests',
icon: ClipboardList,
path: '/app/sales/requests/index',
moduleKey: 'SALES.REQUEST',
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
},
{
key: 'sales-orders',
label: 'nav:sales-orders',
icon: Box,
path: '/app/sales/orders/index',
moduleKey: 'SALES.ORDER',
moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
},
{
key: 'sales-invoices',
label: 'nav:sales-invoices',
icon: Receipt,
path: '/app/sales/invoices/index',
moduleKey: 'SALES.INVOICE',
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
},
{
key: 'sales-payments',
label: 'nav:sales-payments',
icon: CreditCard,
path: '/app/sales/payments/index',
moduleKey: 'SALES.PAYMENT',
moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
},
{
key: 'sales-plans',
label: 'nav:sales-plans',
icon: Calendar,
path: '/app/sales/plans/index',
moduleKey: 'SALES.PLAN',
moduleKey: 'ADMIN.SALES.ACTIVITIES.PLAN',
},
],
},
@@ -109,7 +116,7 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:sales-reports',
icon: FileText,
path: '/app/sales/reports/index',
moduleKey: 'SALES.REPORT',
moduleKey: 'ADMIN.SALES.REPORT',
},
],
},
@@ -130,14 +137,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:configuration-employees',
icon: Users,
path: '/app/logistics/employees/index',
moduleKey: 'CONFIGURATION.EMPLOYEE',
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
},
{
key: 'logistics-cycles',
label: 'nav:logistics-cycles',
icon: Repeat,
path: '/app/logistics/cycles/index',
moduleKey: 'LOGISTICS.CYCLE',
moduleKey: 'ADMIN.LOGISTICS.DATA.CYCLE',
},
],
},
@@ -152,14 +159,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:logistics-packing-slips',
icon: Package,
path: '/app/logistics/packing-slips/index',
moduleKey: 'SALES.PACKING_SLIP',
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
},
{
key: 'logistics-plans',
label: 'nav:logistics-plans',
icon: Calendar,
path: '/app/logistics/plans/index',
moduleKey: 'LOGISTICS.PLAN',
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
},
],
},
@@ -168,7 +175,7 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:logistics-reports',
icon: FileText,
path: '/app/logistics/reports/index',
moduleKey: 'LOGISTICS.REPORT',
moduleKey: 'ADMIN.LOGISTICS.REPORT',
},
],
},
@@ -189,28 +196,35 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:configuration-branches',
icon: MapPin,
path: '/app/configuration/branches/index',
moduleKey: 'CONFIGURATION.BRANCH',
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
},
{
key: 'configuration-divisions',
label: 'nav:configuration-divisions',
icon: Layers,
path: '/app/configuration/divisions/index',
moduleKey: 'CONFIGURATION.DIVISION',
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
},
{
key: 'configuration-customers',
label: 'nav:configuration-customers',
icon: Users,
path: '/app/configuration/customers/index',
moduleKey: 'CONFIGURATION.CUSTOMER',
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
},
{
key: 'configuration-products',
label: 'nav:configuration-products',
icon: Package,
path: '/app/configuration/products/index',
moduleKey: 'CONFIGURATION.PRODUCT',
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',
},
],
},
@@ -225,14 +239,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:system-users',
icon: Users,
path: '/app/system/users/index',
moduleKey: 'USERS',
moduleKey: 'ADMIN.SETTINGS.USER.USERS',
},
{
key: 'system-privileges',
label: 'nav:system-privileges',
icon: Shield,
path: '/app/system/privileges/index',
moduleKey: 'PRIVILEGES',
moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
},
],
},
@@ -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"
}
@@ -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"
}
@@ -33,7 +33,7 @@ describe('BranchesRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new BranchesRemoteDataServices(httpClient, {
apiUrl: '/branches',
moduleKey: 'CONFIGURATION.BRANCH',
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
transformer: new BranchesRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { BranchEntity } from '../entities';
export const branchesModuleConfig: ModuleConfigEntity<BranchEntity> = {
moduleKey: 'CONFIGURATION.BRANCH',
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
translationNamespace: 'BRANCHES',
apiUrl: '/branches',
webUrl: '/app/configuration/branches',
@@ -0,0 +1,16 @@
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<CompanySettingsEntity> {
const { data } = await this.client.get<CompanySettingsEntity>('/settings');
return data;
}
async update(payload: UpdateCompanySettingsPayload): Promise<CompanySettingsEntity> {
const { data } = await this.client.patch<CompanySettingsEntity>('/settings', payload);
return data;
}
}
@@ -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<CompanySettingsShellEntity> = {
moduleKey: 'ADMIN.SETTINGS.DATA.SETTING',
translationNamespace: 'COMPANY_SETTINGS',
apiUrl: '/settings',
webUrl: '/app/configuration/company-settings',
moduleCategory: 'SINGLE_PAGE',
moduleType: 'MASTER_DATA',
} as const;
@@ -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;
};
@@ -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 { companySettingsModuleConfig, type CompanySettingsShellEntity } from '../constants/company-settings.constants';
import { CompanySettingsRemoteService } from '../../data/company-settings.remote.service';
class CompanySettingsShellTransformer extends BaseDataTransformer<CompanySettingsShellEntity> {
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);
@@ -0,0 +1,33 @@
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 (
<EnterpriseModuleProvider
config={companySettingsModuleConfig}
dataServices={companySettingsDataService}
store={companySettingsStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/" element={<Navigate to={`${companySettingsModuleConfig.webUrl}/index`} replace />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -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."
}
}
@@ -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."
}
}
@@ -0,0 +1,138 @@
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<CompanySettingsForm>();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(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 (
<CorePageContainer>
<ModulePageHeader
icon={Settings}
title={t('title')}
description={t('description')}
moduleKey={companySettingsModuleConfig.moduleKey}
breadcrumbs={[
{ label: tNav('settings'), type: 'text' },
{ label: tNav('data'), type: 'text' },
{ label: t('title'), type: 'text' },
]}
/>
<FormProvider {...form}>
<Card withBorder padding="xl" radius="md">
<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')} />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<FieldNumberInput
control={form.control}
name="checkInRadiusMeters"
label={t('fields.checkInRadiusMeters')}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<FieldNumberInput
control={form.control}
name="gpsIntervalSeconds"
label={t('fields.gpsIntervalSeconds')}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<FieldNumberInput
control={form.control}
name="checkoutWarningRadiusMeters"
label={t('fields.checkoutWarningRadiusMeters')}
/>
</Grid.Col>
</Grid>
{errorMessage ? (
<Text size="sm" c="red">
{errorMessage}
</Text>
) : null}
{successMessage ? (
<Text size="sm" c="teal">
{successMessage}
</Text>
) : null}
<Button type="submit" loading={isLoading}>
{t('actions.save')}
</Button>
</Stack>
</Card>
</FormProvider>
</CorePageContainer>
);
}
@@ -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<EnterpriseModuleState<CompanySettingsShellEntity>>((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 }),
}));
@@ -42,7 +42,7 @@ describe('CustomersRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new CustomersRemoteDataServices(httpClient, {
apiUrl: '/customers',
moduleKey: 'CONFIGURATION.CUSTOMER',
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
transformer: new CustomersRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { CustomerEntity } from '../entities';
export const customersModuleConfig: ModuleConfigEntity<CustomerEntity> = {
moduleKey: 'CONFIGURATION.CUSTOMER',
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
translationNamespace: 'CUSTOMERS',
apiUrl: '/customers',
webUrl: '/app/configuration/customers',
@@ -44,7 +44,7 @@ describe('DivisionsRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new DivisionsRemoteDataServices(httpClient, {
apiUrl: '/divisions',
moduleKey: 'CONFIGURATION.DIVISION',
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
transformer: new DivisionsRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { DivisionEntity } from '../entities';
export const divisionsModuleConfig: ModuleConfigEntity<DivisionEntity> = {
moduleKey: 'CONFIGURATION.DIVISION',
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
translationNamespace: 'DIVISIONS',
apiUrl: '/divisions',
webUrl: '/app/configuration/divisions',
@@ -33,7 +33,7 @@ describe('EmployeesRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new EmployeesRemoteDataServices(httpClient, {
apiUrl: '/employees',
moduleKey: 'CONFIGURATION.EMPLOYEE',
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
transformer: new EmployeesRemoteDataTransformer(),
});
});
@@ -14,7 +14,7 @@ describe('employee purpose helpers', () => {
});
it('keeps a shared employee privilege key', () => {
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
});
});
@@ -4,7 +4,7 @@ import type { EmployeeEntity } from '../entities';
export function createEmployeeModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<EmployeeEntity> {
return {
moduleKey: 'CONFIGURATION.EMPLOYEE',
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
translationNamespace: 'EMPLOYEES',
apiUrl: '/employees',
webUrl: `/app/${purpose}/employees`,
@@ -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() {
<Route path="/branches/*" element={<BranchesModule />} />
<Route path="/customers/*" element={<CustomersModule />} />
<Route path="/products/*" element={<ProductsModule />} />
<Route path="/company-settings/*" element={<CompanySettingsModule />} />
<Route path="/employees/*" element={<Navigate to={`${WEB_URL.SALES_EMPLOYEES}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
@@ -33,7 +33,7 @@ describe('ProductsRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new ProductsRemoteDataServices(httpClient, {
apiUrl: '/products',
moduleKey: 'CONFIGURATION.PRODUCT',
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
transformer: new ProductsRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { ProductEntity } from '../entities';
export const productsModuleConfig: ModuleConfigEntity<ProductEntity> = {
moduleKey: 'CONFIGURATION.PRODUCT',
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
translationNamespace: 'PRODUCTS',
apiUrl: '/products',
webUrl: '/app/configuration/products',
@@ -33,7 +33,7 @@ describe('CyclesRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new CyclesRemoteDataServices(httpClient, {
apiUrl: '/cycles',
moduleKey: 'SALES.CYCLE',
moduleKey: 'ADMIN.SALES.DATA.CYCLE',
transformer: new CyclesRemoteDataTransformer('sales'),
});
});
@@ -5,7 +5,7 @@ import type { CycleEntity, CycleWeekdayRow } from '../entities';
export function createCycleModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<CycleEntity> {
return {
moduleKey: purpose === 'sales' ? 'SALES.CYCLE' : 'LOGISTICS.CYCLE',
moduleKey: purpose === 'sales' ? 'ADMIN.SALES.DATA.CYCLE' : 'ADMIN.LOGISTICS.DATA.CYCLE',
translationNamespace: 'CYCLES',
apiUrl: '/cycles',
webUrl: `/app/${purpose}/cycles`,
@@ -4,7 +4,7 @@ import type { ModuleConfigEntity } from '@repo/ui/foundations';
export type ReportShellEntity = BaseEntity & { id: string };
export const logisticsReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
moduleKey: 'LOGISTICS.REPORT',
moduleKey: 'ADMIN.LOGISTICS.REPORT',
translationNamespace: 'LOGISTICS_REPORTS',
apiUrl: '/reports',
webUrl: '/app/logistics/reports',
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { PackingSlipEntity } from '../entities';
export const packingSlipsModuleConfig: ModuleConfigEntity<PackingSlipEntity> = {
moduleKey: 'SALES.PACKING_SLIP',
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
translationNamespace: 'PACKING_SLIPS',
apiUrl: '/packing-slips',
webUrl: '/app/logistics/packing-slips',
@@ -35,7 +35,7 @@ describe('PlansRemoteDataServices', () => {
httpClient,
{
apiUrl: '/plans',
moduleKey: 'SALES.PLAN',
moduleKey: 'ADMIN.SALES.ACTIVITIES.PLAN',
transformer: new PlansRemoteDataTransformer('sales'),
},
'sales',
@@ -4,7 +4,7 @@ import type { PlanEntity } from '../entities';
export function createPlanModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<PlanEntity> {
return {
moduleKey: purpose === 'sales' ? 'SALES.PLAN' : 'LOGISTICS.PLAN',
moduleKey: purpose === 'sales' ? 'ADMIN.SALES.ACTIVITIES.PLAN' : 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
translationNamespace: 'PLANS',
apiUrl: '/plans',
webUrl: `/app/${purpose}/plans`,
@@ -7,6 +7,7 @@ import type { RouteGeometry } from '../../../cycles/domain/entities';
export interface PlanDestinationEntity {
id?: string;
customerId: string;
customer?: RelationRef | null;
sortOrder: number;
}
@@ -29,6 +29,39 @@ describe('PlansRemoteDataTransformer', () => {
expect(entity.customers).toEqual([{ id: 'cus-1' }]);
});
it('maps nested destination.customer onto customers for the edit form', () => {
const entity = salesTransformer.transformToEntity({
id: 'plan-1',
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
purpose: 'sales',
date: Date.UTC(2026, 0, 12),
startBranch: { id: 'br-1', code: 'BDG', name: 'Bandung' },
endBranch: { id: 'br-2', code: 'BDG', name: 'Bandung' },
destinations: [
{
id: 'd-1',
customer: { id: 'cus-1', code: 'C1', name: 'Acme Corp' },
sortOrder: 0,
},
],
invoices: [{ id: 'inv-1', code: 'SI-001' }],
packingSlips: [],
status: 'active',
} as any);
expect(entity.customers).toEqual([{ id: 'cus-1', code: 'C1', name: 'Acme Corp' }]);
expect(entity.destinations).toEqual([
{
id: 'd-1',
customerId: 'cus-1',
customer: { id: 'cus-1', code: 'C1', name: 'Acme Corp' },
sortOrder: 0,
},
]);
expect(entity.invoices).toEqual([{ id: 'inv-1', code: 'SI-001' }]);
expect(entity.invoiceIds).toEqual(['inv-1']);
});
it('injects purpose and sales invoice attachments on create', () => {
const payload = salesTransformer.transformCreatePayload({
employee: { id: 'emp-1' },
@@ -63,4 +96,18 @@ describe('PlansRemoteDataTransformer', () => {
expect(payload.packingSlipIds).toEqual(['ps-1']);
expect(payload).not.toHaveProperty('invoiceIds');
});
it('does not resurrect invoices when the form list is emptied', () => {
const payload = salesTransformer.transformEditPayload({
employee: { id: 'emp-1' },
date: '2026-01-12',
startBranch: { id: 'br-1' },
endBranch: { id: 'br-2' },
customers: [{ id: 'cus-1' }],
invoices: [],
invoiceIds: ['inv-1'],
} as any);
expect(payload.invoiceIds).toEqual([]);
});
});
@@ -2,7 +2,8 @@ import { BaseDataTransformer } from '@repo/core-api/data-services';
import { formatDateValue, parseDateValue } from '@repo/ui/form';
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
import type { PlanEntity } from '../entities';
import type { RelationRef } from '../../../../../../../core/domain/relation-ref';
import type { PlanDestinationEntity, PlanEntity } from '../entities';
function relationId(value: unknown): string | undefined {
if (value && typeof value === 'object' && 'id' in value) {
@@ -17,12 +18,55 @@ function relationIds(value: unknown): string[] {
return value.map((item) => relationId(item)).filter((id): id is string => Boolean(id));
}
function relationRef(value: unknown): RelationRef | undefined {
const id = relationId(value);
if (!id || !value || typeof value !== 'object') return undefined;
const row = value as { code?: unknown; name?: unknown };
return {
id,
...(typeof row.code === 'string' ? { code: row.code } : {}),
...(typeof row.name === 'string' ? { name: row.name } : {}),
};
}
function customerFromDestination(destination: unknown): RelationRef | undefined {
if (!destination || typeof destination !== 'object') return undefined;
const row = destination as { customer?: unknown; customerId?: unknown };
if (row.customerId != null && row.customerId !== '') {
return relationRef(row.customer) ?? { id: String(row.customerId) };
}
return relationRef(row.customer);
}
function mapDestinations(destinations: unknown): PlanDestinationEntity[] {
if (!Array.isArray(destinations)) return [];
return destinations.map((destination, index) => {
const row = destination && typeof destination === 'object' ? (destination as Record<string, unknown>) : {};
const customer = customerFromDestination(destination);
return {
id: typeof row.id === 'string' ? row.id : undefined,
customerId: customer?.id ?? '',
customer: customer ?? null,
sortOrder: typeof row.sortOrder === 'number' ? row.sortOrder : index,
};
});
}
export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity> {
constructor(private readonly purpose: FieldPurpose) {
super();
}
transformToEntity(dto: PlanEntity): PlanEntity {
const destinations = mapDestinations(dto.destinations);
const customers =
Array.isArray(dto.customers) && dto.customers.length > 0
? dto.customers
: destinations
.map((destination) => destination.customer)
.filter((customer): customer is RelationRef => Boolean(customer?.id));
const invoices = dto.invoices ?? (dto.invoiceIds ?? []).map((id) => ({ id }));
const packingSlips = dto.packingSlips ?? (dto.packingSlipIds ?? []).map((id) => ({ id }));
return {
id: dto.id,
employeeId: dto.employeeId,
@@ -34,12 +78,12 @@ export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity>
endBranchId: dto.endBranchId,
endBranch: dto.endBranch ?? (dto.endBranchId ? { id: dto.endBranchId } : null),
routeGeometry: dto.routeGeometry ?? null,
destinations: dto.destinations ?? [],
customers: dto.customers ?? (dto.destinations ?? []).map((destination) => ({ id: destination.customerId })),
invoiceIds: dto.invoiceIds ?? [],
invoices: dto.invoices ?? (dto.invoiceIds ?? []).map((id) => ({ id })),
packingSlipIds: dto.packingSlipIds ?? [],
packingSlips: dto.packingSlips ?? (dto.packingSlipIds ?? []).map((id) => ({ id })),
destinations,
customers,
invoiceIds: dto.invoiceIds ?? relationIds(invoices),
invoices,
packingSlipIds: dto.packingSlipIds ?? relationIds(packingSlips),
packingSlips,
status: dto.status,
createdAt: dto.createdAt,
updatedAt: dto.updatedAt,
@@ -63,9 +107,9 @@ export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity>
customerIds,
};
if (this.purpose === 'sales') {
payload.invoiceIds = relationIds(entity.invoices).length ? relationIds(entity.invoices) : entity.invoiceIds;
payload.invoiceIds = Array.isArray(entity.invoices) ? relationIds(entity.invoices) : entity.invoiceIds;
} else {
payload.packingSlipIds = relationIds(entity.packingSlips).length
payload.packingSlipIds = Array.isArray(entity.packingSlips)
? relationIds(entity.packingSlips)
: entity.packingSlipIds;
}
@@ -26,13 +26,13 @@ import { loadCustomerOptions } from '../../../../shared/load-customer-options';
import type { PlansRemoteDataServices } from '../../../data/plan.remote.service';
import type { PlanEntity } from '../../../domain/entities';
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
import { FormDocumentsPreview } from '../form-component/form-documents-preview';
export function DetailGeneral() {
const { detailData, reload } = useDetailPageContext<PlanEntity>();
const { t } = useEnterpriseModuleTranslationContext();
const { dataServices } = useEnterpriseModuleDataServiceContext<PlanEntity, PlansRemoteDataServices>();
const data = detailData;
const attachments = data?.purpose === 'sales' ? data?.invoices : data?.packingSlips;
const destinationForm = useForm<{ customer: CustomerEntity | null }>({ defaultValues: { customer: null } });
const handleAdd = destinationForm.handleSubmit(async (values) => {
@@ -92,9 +92,9 @@ export function DetailGeneral() {
</Text>
<Stack gap="sm" mb="md">
{(data?.destinations ?? []).map((destination, index) => (
<Group key={destination.id ?? destination.customerId} justify="space-between">
<Group key={destination.id ?? `${destination.customerId}-${index}`} justify="space-between">
<Text size="sm">
{index + 1}. {destination.customerId}
{index + 1}. {relationLabel(destination.customer) || destination.customerId}
</Text>
<ActionIcon
variant="subtle"
@@ -127,20 +127,11 @@ export function DetailGeneral() {
</Group>
</Paper>
{attachments?.length ? (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_attachments')}
</Text>
<Stack gap="xs">
{attachments.map((item) => (
<Text key={item.id} size="sm">
{relationLabel(item) || item.id}
</Text>
))}
</Stack>
</Paper>
) : null}
{data?.purpose === 'sales' ? (
<FormDocumentsPreview kind="invoice" items={data.invoices ?? []} customers={data.customers} />
) : (
<FormDocumentsPreview kind="packingSlip" items={data?.packingSlips ?? []} customers={data?.customers} />
)}
</Stack>
);
}
@@ -35,13 +35,20 @@ async function fetchPackingSlip(id: string): Promise<PackingSlipEntity | null> {
return unwrapEntity<PackingSlipEntity>(await packingSlipsModuleDataService.getOne(id));
}
type PlanDocumentPreviewItem = {
id?: string | number;
code?: string | null;
name?: string;
customer?: { id?: string | number; code?: string | null; name?: string } | null;
};
export function FormDocumentsPreview({
kind,
items,
customers = [],
}: {
kind: 'invoice' | 'packingSlip';
items: Array<SalesInvoiceEntity | PackingSlipEntity>;
items: PlanDocumentPreviewItem[];
customers?: Array<{ id?: string | number; code?: string | null; name?: string }>;
}) {
const { t } = useEnterpriseModuleTranslationContext();
@@ -10,6 +10,7 @@ export function useHydratedRecords<T extends { id?: string | number }>(
const ids = selectionIds(items);
const idsKey = ids.join(',');
const [details, setDetails] = useState<Record<string, T>>({});
const [settled, setSettled] = useState<Record<string, true>>({});
const itemsRef = useRef(items);
const fetchOneRef = useRef(fetchOne);
const shouldFetchRef = useRef(shouldFetch);
@@ -43,6 +44,13 @@ export function useHydratedRecords<T extends { id?: string | number }>(
}
return next;
});
setSettled((prev) => {
const next = { ...prev };
for (const row of rows) {
next[row.id] = true;
}
return next;
});
});
return () => {
@@ -52,7 +60,7 @@ export function useHydratedRecords<T extends { id?: string | number }>(
const pending = items.some((item) => {
const id = item.id == null ? '' : String(item.id);
return Boolean(id) && shouldFetch(item) && !details[id];
return Boolean(id) && shouldFetch(item) && !details[id] && !settled[id];
});
return { records: mergeHydrated(items, details), pending };
@@ -5,12 +5,12 @@ import type { LookupEntity } from './lookup.entity';
export const salesInvoicesDataService = new LookupRemoteDataServices(apiClient, {
apiUrl: '/sales-invoices',
moduleKey: 'SALES.INVOICE',
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
});
export const packingSlipsDataService = new LookupRemoteDataServices(apiClient, {
apiUrl: '/packing-slips',
moduleKey: 'SALES.PACKING_SLIP',
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
});
export const loadSalesInvoiceOptions = createOptionLoader<LookupEntity>((config) =>
@@ -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<TimelineDayEntity> {
const { data } = await this.client.get<TimelineDayEntity>('/timeline', {
params: query,
});
return data;
}
}
@@ -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<TimelineShellEntity> = {
moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
translationNamespace: 'SALES_TIMELINE',
apiUrl: '/timeline',
webUrl: '/app/timeline',
moduleCategory: 'SINGLE_PAGE',
moduleType: 'TRANSACTION',
} as const;
@@ -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[];
};
@@ -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<TimelineShellEntity> {
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);
@@ -0,0 +1,102 @@
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 (
<Text size="sm" c="dimmed" py="md">
{emptyLabel}
</Text>
);
}
return (
<Stack gap="sm">
{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 (
<Card
key={group.key}
withBorder
padding="md"
radius="md"
shadow={selected ? 'sm' : undefined}
style={{
borderColor: selected ? 'var(--mantine-color-blue-filled)' : 'var(--mantine-color-default-border)',
borderWidth: selected ? 2 : 1,
}}
>
<UnstyledButton w="100%" onClick={() => onSelect(group.key)}>
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
<Stack gap={4} style={{ minWidth: 0 }}>
<Text fw={600} lineClamp={1}>
{title}
</Text>
<Text size="sm" c="dimmed">
{formatClock(group.firstRecordedAt)}
{first && last && first.id !== last.id ? ` → ${formatClock(group.lastRecordedAt)}` : ''}
</Text>
</Stack>
<Badge variant="light" color={group.isOnTheWay ? 'blue' : 'teal'} tt="none">
{activityLabel(group.lastType)}
</Badge>
</Group>
</UnstyledButton>
{selected ? (
<Stack gap="sm" mt="md">
<Group gap="sm">
<Avatar radius="xl" size="md" color="blue">
{initials(group.employeeName)}
</Avatar>
<Text size="sm" fw={500}>
{group.employeeName}
</Text>
</Group>
<Box bg="var(--mantine-color-blue-light)" p="sm" bdrs="md">
<Timeline active={group.activities.length - 1} bulletSize={12} lineWidth={2} color="blue">
{group.activities.map((item) => (
<Timeline.Item key={item.id} title={activityLabel(item.type)}>
<Text size="xs" c="dimmed">
{formatClock(item.recordedAt)}
</Text>
</Timeline.Item>
))}
</Timeline>
</Box>
</Stack>
) : null}
</Card>
);
})}
</Stack>
);
}
@@ -0,0 +1,89 @@
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 (
<Paper
withBorder
shadow="md"
radius="lg"
p="md"
h="100%"
style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}
>
<Stack gap="md" style={{ flex: 1, minHeight: 0 }}>
<Text fw={700} size="xl">
{title}
</Text>
<TextInput
value={search}
onChange={(event) => onSearchChange(event.currentTarget.value)}
placeholder={searchPlaceholder}
leftSection={<Search size={16} />}
/>
{filters}
<SegmentedControl
fullWidth
value={tab}
onChange={(value) => onTabChange(value as TimelineActivityTab)}
data={[
{ label: onTheWayLabel, value: 'on_the_way' },
{ label: completedLabel, value: 'completed' },
]}
/>
{errorMessage ? (
<Text c="red" size="sm">
{errorMessage}
</Text>
) : null}
<ScrollArea flex={1} type="scroll" offsetScrollbars>
<TimelineActivityList
groups={groups}
selectedKey={selectedKey}
onSelect={onSelect}
activityLabel={activityLabel}
emptyLabel={emptyLabel}
ungroupedLabel={ungroupedLabel}
/>
</ScrollArea>
</Stack>
</Paper>
);
}
@@ -0,0 +1,132 @@
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>): 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);
});
});
@@ -0,0 +1,152 @@
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<string, TimelineActivityEntity[]>();
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<T extends { recordedAt: number }>(
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);
}
@@ -0,0 +1,143 @@
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 (
<Stack gap={2}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600} lineClamp={1}>
{value}
</Text>
</Stack>
);
}
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 (
<Paper withBorder shadow="md" radius="lg" p="md">
<Group align="flex-start" justify="space-between" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 220 }}>
<Group gap="sm">
<Text fw={700}>{title}</Text>
{group ? (
<Badge variant="light" color={group.isOnTheWay ? 'blue' : 'teal'} tt="none">
{activityLabel(group.lastType)}
</Badge>
) : null}
</Group>
{group ? (
<Group gap="sm">
<Avatar radius="xl" size="sm" color="blue">
{initials(group.employeeName)}
</Avatar>
<Text size="sm">{group.employeeName}</Text>
</Group>
) : (
<Text size="sm" c="dimmed">
{emptyLabel}
</Text>
)}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md" visibleFrom="sm">
<OverlayMetric
label={fromLabel}
value={first ? `${formatClock(first.recordedAt)} · ${activityLabel(first.type)}` : '—'}
/>
<OverlayMetric
label={toLabel}
value={last ? `${formatClock(last.recordedAt)} · ${activityLabel(last.type)}` : '—'}
/>
<OverlayMetric label={currentLocationLabel} value={currentLocation || '—'} />
<OverlayMetric label={activitiesLabel} value={group ? String(group.activities.length) : '—'} />
</SimpleGrid>
</Stack>
<Box miw={220} style={{ flex: '0 0 240px' }}>
{playbackBounds ? (
<Stack gap="sm">
<Slider
min={playbackBounds.min}
max={playbackBounds.max}
value={playbackTime ?? playbackBounds.max}
onChange={onPlaybackTimeChange}
label={(value) => formatClock(value)}
/>
<Group>
<Button
leftSection={isPlaying ? <Pause size={16} /> : <Play size={16} />}
onClick={onTogglePlayback}
disabled={!canPlay}
>
{isPlaying ? pauseLabel : playLabel}
</Button>
<Text size="sm" c="dimmed">
{playbackTime === null ? timeLabel : formatClock(playbackTime)}
</Text>
</Group>
</Stack>
) : (
<Text size="sm" c="dimmed">
{emptyLabel}
</Text>
)}
</Box>
</Group>
</Paper>
);
}
@@ -0,0 +1,33 @@
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 (
<EnterpriseModuleProvider
config={salesTimelineModuleConfig}
dataServices={salesTimelineDataService}
store={salesTimelineStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/" element={<Navigate to={`${salesTimelineModuleConfig.webUrl}/index`} replace />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -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."
}
}
@@ -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."
}
}
@@ -0,0 +1,302 @@
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<TimelineFilterForm>({
defaultValues: {
date: todayIsoDate(),
employee: null,
},
});
const [data, setData] = useState<TimelineDayEntity | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [playbackTime, setPlaybackTime] = useState<number | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [search, setSearch] = useState('');
const [tab, setTab] = useState<TimelineActivityTab>('on_the_way');
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const timerRef = useRef<number | null>(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<TimelineFootprintEntity>(footprints, playbackTime),
[footprints, playbackTime],
);
const visibleActivities = useMemo(
() => filterTimelineByPlayback<TimelineActivityEntity>(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 (
<Box
m="calc(var(--mantine-spacing-md) * -1)"
h="calc(100dvh - var(--app-shell-header-offset, 0px))"
pos="relative"
style={{ overflow: 'hidden' }}
>
<TimelineMap
footprints={mapFootprints}
activities={mapActivities}
playbackPositions={playbackPositions}
focusPositions={focusPositions}
selectedActivityId={selectedActivity?.id}
height="100%"
fullBleed
emptyLabel={t('overlay.emptyMap')}
/>
<FormProvider {...form}>
<Box
pos="absolute"
top={{ base: 12, md: 16 }}
left={{ base: 12, md: 16 }}
bottom={{ base: undefined, md: 16 }}
h={{ base: '38%', md: 'auto' }}
w={{ base: 'calc(100% - 24px)', md: 400 }}
style={{ zIndex: 2 }}
>
<TimelineActivityPanel
title={t('title')}
search={search}
searchPlaceholder={t('search.placeholder')}
onSearchChange={setSearch}
tab={tab}
onTabChange={setTab}
onTheWayLabel={t('tabs.onTheWay')}
completedLabel={t('tabs.completed')}
groups={filteredGroups}
selectedKey={selectedKey}
onSelect={setSelectedKey}
activityLabel={activityLabel}
emptyLabel={t('activities.empty')}
ungroupedLabel={t('activities.ungrouped')}
errorMessage={errorMessage}
filters={
<Grid gutter="xs">
<Grid.Col span={12}>
<FieldDatePicker control={form.control} name="date" label={t('filters.date')} size="sm" />
</Grid.Col>
<Grid.Col span={12}>
<FieldAsyncSelect<EmployeeEntity>
control={form.control as unknown as Control<FieldValues>}
name="employee"
label={t('filters.employee')}
placeholder={t('filters.allEmployees')}
loadOptions={loadSalesEmployeeOptions}
valueKey="id"
labelKey="name"
clearable
searchable
size="sm"
renderLabel={relationLabel}
/>
</Grid.Col>
</Grid>
}
/>
</Box>
</FormProvider>
<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')}
activityLabel={activityLabel}
fromLabel={t('overlay.from')}
toLabel={t('overlay.to')}
currentLocationLabel={t('overlay.currentLocation')}
activitiesLabel={t('overlay.activities')}
playbackTitle={t('playback.title')}
playLabel={t('playback.play')}
pauseLabel={t('playback.pause')}
timeLabel={t('playback.time')}
emptyLabel={t('activities.empty')}
currentLocation={playbackPositions[0]?.label ?? ''}
playbackBounds={playbackBounds}
playbackTime={playbackTime}
onPlaybackTimeChange={setPlaybackTime}
isPlaying={isPlaying}
onTogglePlayback={togglePlayback}
canPlay={!isLoading && footprints.length > 0}
/>
</Box>
</Box>
);
}
@@ -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<EnterpriseModuleState<TimelineShellEntity>>((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 }),
}));
@@ -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 (
<Routes>
@@ -20,6 +19,7 @@ export default function SalesModule() {
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
<Route path="/invoices/*" element={<InvoicesModule />} />
<Route path="/payments/*" element={<PaymentsModule />} />
<Route path="/timeline/*" element={<Navigate to="/app/timeline/index" replace />} />
<Route path="/reports/*" element={<ReportsModule />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
@@ -33,7 +33,7 @@ describe('SalesInvoicesRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new SalesInvoicesRemoteDataServices(httpClient, {
apiUrl: '/sales-invoices',
moduleKey: 'SALES.INVOICE',
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
transformer: new SalesInvoicesRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesInvoiceEntity } from '../entities';
export const salesInvoicesModuleConfig: ModuleConfigEntity<SalesInvoiceEntity> = {
moduleKey: 'SALES.INVOICE',
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
translationNamespace: 'SALES_INVOICES',
apiUrl: '/sales-invoices',
webUrl: '/app/sales/invoices',
@@ -33,7 +33,7 @@ describe('SalesOrdersRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new SalesOrdersRemoteDataServices(httpClient, {
apiUrl: '/sales-orders',
moduleKey: 'SALES.ORDER',
moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
transformer: new SalesOrdersRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesOrderEntity } from '../entities';
export const salesOrdersModuleConfig: ModuleConfigEntity<SalesOrderEntity> = {
moduleKey: 'SALES.ORDER',
moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
translationNamespace: 'SALES_ORDERS',
apiUrl: '/sales-orders',
webUrl: '/app/sales/orders',
@@ -33,7 +33,7 @@ describe('SalesPaymentsRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new SalesPaymentsRemoteDataServices(httpClient, {
apiUrl: '/sales-payments',
moduleKey: 'SALES.PAYMENT',
moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
transformer: new SalesPaymentsRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesPaymentEntity } from '../entities';
export const salesPaymentsModuleConfig: ModuleConfigEntity<SalesPaymentEntity> = {
moduleKey: 'SALES.PAYMENT',
moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
translationNamespace: 'SALES_PAYMENTS',
apiUrl: '/sales-payments',
webUrl: '/app/sales/payments',
@@ -4,7 +4,7 @@ import type { ModuleConfigEntity } from '@repo/ui/foundations';
export type ReportShellEntity = BaseEntity & { id: string };
export const salesReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
moduleKey: 'SALES.REPORT',
moduleKey: 'ADMIN.SALES.REPORT',
translationNamespace: 'SALES_REPORTS',
apiUrl: '/reports',
webUrl: '/app/sales/reports',
@@ -33,7 +33,7 @@ describe('SalesRequestsRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new SalesRequestsRemoteDataServices(httpClient, {
apiUrl: '/sales-requests',
moduleKey: 'SALES.REQUEST',
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
transformer: new SalesRequestsRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesRequestEntity } from '../entities';
export const salesRequestsModuleConfig: ModuleConfigEntity<SalesRequestEntity> = {
moduleKey: 'SALES.REQUEST',
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
translationNamespace: 'SALES_REQUESTS',
apiUrl: '/sales-requests',
webUrl: '/app/sales/requests',
@@ -32,7 +32,7 @@ describe('SalesDocumentRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new SalesDocumentRemoteDataServices(httpClient, {
apiUrl: '/sales-requests',
moduleKey: 'SALES.REQUEST',
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
});
});
@@ -45,7 +45,7 @@ describe('PrivilegesRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new PrivilegesRemoteDataServices(httpClient, {
apiUrl: '/privileges',
moduleKey: 'PRIVILEGES',
moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
transformer: new PrivilegesRemoteDataTransformer(),
});
});
@@ -125,7 +125,7 @@ describe('PrivilegesRemoteDataServices', () => {
it('lists privilege keys from /privilege-keys', async () => {
vi.mocked(httpClient.request).mockResolvedValueOnce({
data: {
data: [{ id: 'key-1', code: 'SALES.INVOICE', label: 'Sales Invoice', sortOrder: 1 }],
data: [{ id: 'key-1', code: 'ADMIN.SALES.ACTIVITIES.INVOICE', label: 'Sales Invoice', sortOrder: 1 }],
meta: { currentPage: 1, itemsPerPage: 200, totalItems: 1, totalPages: 1 },
},
status: 200,
@@ -142,7 +142,7 @@ describe('PrivilegesRemoteDataServices', () => {
);
expect(result.data[0]).toEqual({
id: 'key-1',
code: 'SALES.INVOICE',
code: 'ADMIN.SALES.ACTIVITIES.INVOICE',
label: 'Sales Invoice',
sortOrder: 1,
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { PrivilegeEntity } from '../entities';
export const privilegesModuleConfig: ModuleConfigEntity<PrivilegeEntity> = {
moduleKey: 'PRIVILEGES',
moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
translationNamespace: 'PRIVILEGES',
apiUrl: '/privileges',
webUrl: '/app/system/privileges',
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { EMPTY_MATRIX_CELL } from './entities';
import type { PrivilegeKeyEntity } from './entities';
import { groupPrivilegeKeys, flattenParentSectionRows, parsePrivilegeKey } from './privilege-key-hierarchy';
const keys: PrivilegeKeyEntity[] = [
{ id: 'k1', code: 'ADMIN.SALES.DATA.CYCLE', label: 'Sales cycles', sortOrder: 1 },
{ id: 'k2', code: 'ADMIN.SALES.ACTIVITIES.PLAN', label: 'Sales plans', sortOrder: 2 },
{ id: 'k3', code: 'MOBILE.SALES.PLAN', label: 'Sales plans (mobile)', sortOrder: 3 },
{ id: 'k4', code: 'MOBILE.SALES.PLAN.ATTENDANCE', label: 'Branch attendance', sortOrder: 4 },
];
function row(key: PrivilegeKeyEntity, view = false) {
return {
keyId: key.id,
code: key.code,
label: key.label,
sortOrder: key.sortOrder,
cell: { ...EMPTY_MATRIX_CELL, view },
};
}
describe('parsePrivilegeKey', () => {
it('parses 3-part keys', () => {
expect(parsePrivilegeKey('MOBILE.SALES.PLAN')).toEqual({
group: 'MOBILE',
parent: 'SALES',
module: 'PLAN',
submodule: null,
});
});
it('parses 4-part keys', () => {
expect(parsePrivilegeKey('MOBILE.SALES.PLAN.ATTENDANCE')).toEqual({
group: 'MOBILE',
parent: 'SALES',
module: 'PLAN',
submodule: 'ATTENDANCE',
});
});
it('returns null for legacy keys', () => {
expect(parsePrivilegeKey('PRIVILEGES')).toBeNull();
});
});
describe('groupPrivilegeKeys', () => {
it('groups keys into tabs, parents, modules, and submodule rows', () => {
const rowsByKeyId = Object.fromEntries(keys.map((key) => [key.id, row(key)]));
const grouped = groupPrivilegeKeys(keys, rowsByKeyId);
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[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');
});
});
describe('flattenParentSectionRows', () => {
it('flattens modules and submodules into sortable table rows', () => {
const rowsByKeyId = Object.fromEntries(keys.map((key) => [key.id, row(key)]));
const grouped = groupPrivilegeKeys(keys, rowsByKeyId);
const salesParent = grouped[0]?.parents[0];
expect(salesParent).toBeDefined();
const tableRows = flattenParentSectionRows(salesParent!);
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' });
});
});
@@ -0,0 +1,140 @@
import type { PrivilegeKeyEntity, PrivilegeMatrixCell } from './entities';
export type ParsedPrivilegeKey = {
group: string;
parent: string;
module: string;
submodule: string | null;
};
export type PrivilegeMatrixRow = {
keyId: string;
code: string;
label: string;
sortOrder: number;
cell: PrivilegeMatrixCell;
};
export type PrivilegeModuleGroup = {
module: string;
directRow?: PrivilegeMatrixRow;
submodules: PrivilegeMatrixRow[];
};
export type PrivilegeParentSection = {
parent: string;
modules: PrivilegeModuleGroup[];
};
export type PrivilegeGroupTab = {
group: string;
parents: PrivilegeParentSection[];
};
export function parsePrivilegeKey(code: string): ParsedPrivilegeKey | null {
const parts = code.split('.');
if (parts.length === 3) {
const [group, parent, module] = parts;
return { group, parent, module, submodule: null };
}
if (parts.length === 4) {
const [group, parent, module, submodule] = parts;
return { group, parent, module, submodule };
}
return null;
}
export function groupPrivilegeKeys(
keys: PrivilegeKeyEntity[],
rowsByKeyId: Record<string, PrivilegeMatrixRow>,
): PrivilegeGroupTab[] {
const groupMap = new Map<string, Map<string, Map<string, PrivilegeModuleGroup>>>();
const sortedKeys = [...keys].sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
for (const key of sortedKeys) {
const parsed = parsePrivilegeKey(key.code);
if (!parsed) {
continue;
}
const row = rowsByKeyId[key.id];
if (!row) {
continue;
}
if (!groupMap.has(parsed.group)) {
groupMap.set(parsed.group, new Map());
}
const parentMap = groupMap.get(parsed.group)!;
if (!parentMap.has(parsed.parent)) {
parentMap.set(parsed.parent, new Map());
}
const moduleMap = parentMap.get(parsed.parent)!;
if (!moduleMap.has(parsed.module)) {
moduleMap.set(parsed.module, { module: parsed.module, submodules: [] });
}
const moduleGroup = moduleMap.get(parsed.module)!;
if (parsed.submodule === null) {
moduleGroup.directRow = row;
} else {
moduleGroup.submodules.push(row);
}
}
return [...groupMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, parentMap]) => ({
group,
parents: [...parentMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([parent, moduleMap]) => ({
parent,
modules: [...moduleMap.values()]
.map((moduleGroup) => ({
...moduleGroup,
submodules: [...moduleGroup.submodules].sort(
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
),
}))
.sort((a, b) => a.module.localeCompare(b.module)),
})),
}));
}
export type PrivilegeTableRow = PrivilegeMatrixRow & {
module: string;
submodule: string | null;
};
export function flattenParentSectionRows(parent: PrivilegeParentSection): PrivilegeTableRow[] {
const rows: PrivilegeTableRow[] = [];
for (const moduleGroup of parent.modules) {
if (moduleGroup.directRow) {
rows.push({
...moduleGroup.directRow,
module: moduleGroup.module,
submodule: null,
});
}
for (const subRow of moduleGroup.submodules) {
const parsed = parsePrivilegeKey(subRow.code);
rows.push({
...subRow,
module: moduleGroup.module,
submodule: parsed?.submodule ?? null,
});
}
}
return rows.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
}
export function formatPrivilegeSegment(segment: string): string {
return segment
.split('_')
.map((part) => part.charAt(0) + part.slice(1).toLowerCase())
.join(' ');
}
@@ -17,7 +17,7 @@ describe('buildPrivilegeMatrixRows', () => {
details: [
{
privilegeKeyId: 'key-2',
keyCode: 'USERS',
keyCode: 'ADMIN.SETTINGS.USER.USERS',
keyLabel: 'Users',
sortOrder: 2,
action: 'view',
@@ -17,7 +17,7 @@ const detailDto: PrivilegeDetailResponseDto = {
{
id: 'd1',
privilegeKeyId: 'key-1',
keyCode: 'SALES.INVOICE',
keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
keyLabel: 'Sales Invoice',
sortOrder: 1,
action: 'view',
@@ -26,7 +26,7 @@ const detailDto: PrivilegeDetailResponseDto = {
{
id: 'd2',
privilegeKeyId: 'key-1',
keyCode: 'SALES.INVOICE',
keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
keyLabel: 'Sales Invoice',
sortOrder: 1,
action: 'create',
@@ -48,7 +48,7 @@ describe('PrivilegesRemoteDataTransformer', () => {
});
expect(entity.details?.[0]).toMatchObject({
privilegeKeyId: 'key-1',
keyCode: 'SALES.INVOICE',
keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
keyLabel: 'Sales Invoice',
sortOrder: 1,
});
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { Box, Paper, Table, Text } from '@repo/ui/components';
import { FieldCheckbox } from '@repo/ui/form';
import { useEffect, useMemo, useState } from 'react';
import { Paper, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { privilegesDataService } from '../../../domain/factories';
import { EMPTY_MATRIX_CELL, PRIVILEGE_ACTIONS, type PrivilegeKeyEntity } from '../../../domain/entities';
import { EMPTY_MATRIX_CELL, type PrivilegeKeyEntity } from '../../../domain/entities';
import { groupPrivilegeKeys } from '../../../domain/privilege-key-hierarchy';
import { PrivilegeMatrixLayout } from '../privilege-matrix-layout';
export function FormPermissions() {
const { formControl } = useFormPageContext();
@@ -28,42 +29,28 @@ export function FormPermissions() {
};
}, [formControl]);
const grouped = useMemo(() => {
const rowsByKeyId = Object.fromEntries(
keys.map((key) => [
key.id,
{
keyId: key.id,
code: key.code,
label: key.label,
sortOrder: key.sortOrder,
cell: formControl.getValues(`matrix.${key.id}`) ?? { ...EMPTY_MATRIX_CELL },
},
]),
);
return groupPrivilegeKeys(keys, rowsByKeyId);
}, [keys, formControl]);
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_permissions')}
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('matrix_module')}</Table.Th>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Th key={action} ta="center">
{t(`matrix_${action}`)}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{keys.map((key) => (
<Table.Tr key={key.id}>
<Table.Td>
<Text size="sm">{key.label || key.code}</Text>
<Text size="xs" c="dimmed">
{key.code}
</Text>
</Table.Td>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Td key={action} ta="center">
<FieldCheckbox control={formControl.control} name={`matrix.${key.id}.${action}`} label="" />
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<PrivilegeMatrixLayout groups={grouped} t={t} mode="edit" formControl={formControl} />
</Paper>
);
}
@@ -0,0 +1,167 @@
import { Box, Stack, Table, Tabs, Text } from '@repo/ui/components';
import { FieldCheckbox } from '@repo/ui/form';
import type { Control } from 'react-hook-form';
import { Check, Minus } from 'lucide-react';
import { PRIVILEGE_ACTIONS } from '../../domain/entities';
import {
flattenParentSectionRows,
formatPrivilegeSegment,
type PrivilegeGroupTab,
type PrivilegeMatrixRow,
type PrivilegeTableRow,
} from '../../domain/privilege-key-hierarchy';
const MODULE_COL_WIDTH = '22%';
const SUBMODULE_COL_WIDTH = '30%';
const ACTION_COL_WIDTH = 88;
type PrivilegeMatrixLayoutProps = {
groups: PrivilegeGroupTab[];
t: (key: string) => string;
mode: 'edit' | 'read';
formControl?: { control: Control<Record<string, unknown>> };
};
function segmentLabel(t: (key: string) => string, kind: string, value: string) {
const key = `hierarchy_${kind}_${value.toLowerCase()}`;
const translated = t(key);
return translated === key ? formatPrivilegeSegment(value) : translated;
}
function submoduleCellLabel(t: (key: string) => string, row: PrivilegeTableRow) {
if (row.submodule) {
return row.label || segmentLabel(t, 'submodule', row.submodule);
}
return row.label || segmentLabel(t, 'module', row.module);
}
function MatrixActionCells({
row,
mode,
formControl,
}: {
row: PrivilegeMatrixRow;
mode: 'edit' | 'read';
formControl?: { control: Control<Record<string, unknown>> };
}) {
return (
<>
{PRIVILEGE_ACTIONS.map((action) => (
<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="" />
) : row.cell[action] ? (
<Check size={16} />
) : (
<Minus size={16} />
)}
</Box>
</Table.Td>
))}
</>
);
}
function ParentMatrixTable({
rows,
mode,
formControl,
t,
}: {
rows: PrivilegeTableRow[];
mode: 'edit' | 'read';
formControl?: { control: Control<Record<string, unknown>> };
t: (key: string) => string;
}) {
if (rows.length === 0) {
return null;
}
return (
<Box style={{ overflowX: 'auto' }}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
layout="fixed"
w="100%"
style={{ tableLayout: 'fixed' }}
>
<colgroup>
<col style={{ width: MODULE_COL_WIDTH }} />
<col style={{ width: SUBMODULE_COL_WIDTH }} />
{PRIVILEGE_ACTIONS.map((action) => (
<col key={action} style={{ width: ACTION_COL_WIDTH }} />
))}
</colgroup>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('matrix_module')}</Table.Th>
<Table.Th>{t('matrix_submodule')}</Table.Th>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Th key={action} ta="center" w={ACTION_COL_WIDTH}>
{t(`matrix_${action}`)}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={row.keyId}>
<Table.Td valign="middle">
<Text size="sm" fw={500}>
{segmentLabel(t, 'module', row.module)}
</Text>
</Table.Td>
<Table.Td valign="middle">
<Text size="sm">{submoduleCellLabel(t, row)}</Text>
<Text size="xs" c="dimmed">
{row.code}
</Text>
</Table.Td>
<MatrixActionCells row={row} mode={mode} formControl={formControl} />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
);
}
export function PrivilegeMatrixLayout({ groups, t, mode, formControl }: PrivilegeMatrixLayoutProps) {
if (groups.length === 0) {
return null;
}
return (
<Tabs defaultValue={groups[0]?.group} variant="outline" radius="md">
<Tabs.List mb="md">
{groups.map((groupTab) => (
<Tabs.Tab key={groupTab.group} value={groupTab.group}>
{segmentLabel(t, 'group', groupTab.group)}
</Tabs.Tab>
))}
</Tabs.List>
{groups.map((groupTab) => (
<Tabs.Panel key={groupTab.group} value={groupTab.group} pt="md">
<Stack gap="lg">
{groupTab.parents.map((parentSection) => (
<Stack key={parentSection.parent} gap="sm">
<Text fw={600}>{segmentLabel(t, 'parent', parentSection.parent)}</Text>
<ParentMatrixTable
rows={flattenParentSectionRows(parentSection)}
mode={mode}
formControl={formControl}
t={t}
/>
</Stack>
))}
</Stack>
</Tabs.Panel>
))}
</Tabs>
);
}
@@ -1,7 +1,9 @@
import { Box, Paper, Table, Text } from '@repo/ui/components';
import { Check, Minus } from 'lucide-react';
import { useMemo } from 'react';
import { Paper, Text } from '@repo/ui/components';
import { PRIVILEGE_ACTIONS, type PrivilegeEntity } from '../../domain/entities';
import { buildPrivilegeMatrixRows } from '../../domain/privilege.matrix-rows';
import { groupPrivilegeKeys } from '../../domain/privilege-key-hierarchy';
import { PrivilegeMatrixLayout } from './privilege-matrix-layout';
export function PrivilegePermissionsTable({
privilege,
@@ -10,44 +12,26 @@ export function PrivilegePermissionsTable({
privilege: Pick<PrivilegeEntity, 'matrix' | 'details'> | null | undefined;
t: (key: string) => string;
}) {
const rows = buildPrivilegeMatrixRows(privilege);
const grouped = useMemo(() => {
const rows = buildPrivilegeMatrixRows(privilege);
const keys = rows.map((row) => ({
id: row.keyId,
code: row.code,
label: row.label,
sortOrder: row.sortOrder,
}));
const rowsByKeyId = Object.fromEntries(rows.map((row) => [row.keyId, row]));
return groupPrivilegeKeys(keys, rowsByKeyId);
}, [privilege]);
void PRIVILEGE_ACTIONS;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_permissions')}
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('matrix_module')}</Table.Th>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Th key={action} ta="center">
{t(`matrix_${action}`)}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={row.keyId}>
<Table.Td>
<Text size="sm">{row.label}</Text>
<Text size="xs" c="dimmed">
{row.code}
</Text>
</Table.Td>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Td key={action} ta="center">
{row.cell[action] ? <Check size={16} /> : <Minus size={16} />}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<PrivilegeMatrixLayout groups={grouped} t={t} mode="read" />
</Paper>
);
}
@@ -12,11 +12,22 @@
"section_general": "General",
"section_permissions": "Permissions",
"matrix_module": "Module",
"matrix_submodule": "Submodule",
"matrix_view": "View",
"matrix_create": "Create",
"matrix_update": "Update",
"matrix_delete": "Delete",
"matrix_import": "Import",
"hierarchy_group_admin": "Admin",
"hierarchy_group_mobile": "Mobile",
"hierarchy_parent_sales": "Sales",
"hierarchy_parent_logistics": "Logistics",
"hierarchy_parent_settings": "Settings",
"hierarchy_module_data": "Data",
"hierarchy_module_activities": "Activities",
"hierarchy_module_report": "Report",
"hierarchy_module_user": "User",
"hierarchy_module_plan": "Plan",
"status_draft": "Draft",
"status_active": "Active",
"status_archived": "Archived"
@@ -12,11 +12,22 @@
"section_general": "Umum",
"section_permissions": "Izin",
"matrix_module": "Modul",
"matrix_submodule": "Submodul",
"matrix_view": "Lihat",
"matrix_create": "Buat",
"matrix_update": "Ubah",
"matrix_delete": "Hapus",
"matrix_import": "Impor",
"hierarchy_group_admin": "Admin",
"hierarchy_group_mobile": "Mobile",
"hierarchy_parent_sales": "Penjualan",
"hierarchy_parent_logistics": "Logistik",
"hierarchy_parent_settings": "Pengaturan",
"hierarchy_module_data": "Data",
"hierarchy_module_activities": "Aktivitas",
"hierarchy_module_report": "Laporan",
"hierarchy_module_user": "Pengguna",
"hierarchy_module_plan": "Rencana",
"status_draft": "Draft",
"status_active": "Aktif",
"status_archived": "Diarsipkan"
@@ -33,7 +33,7 @@ describe('UsersRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new UsersRemoteDataServices(httpClient, {
apiUrl: '/users',
moduleKey: 'USERS',
moduleKey: 'ADMIN.SETTINGS.USER.USERS',
transformer: new UsersRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { UserEntity } from '../entities';
export const usersModuleConfig: ModuleConfigEntity<UserEntity> = {
moduleKey: 'USERS',
moduleKey: 'ADMIN.SETTINGS.USER.USERS',
translationNamespace: 'USERS',
apiUrl: '/users',
webUrl: '/app/system/users',
+20 -10
View File
@@ -1,12 +1,22 @@
export const MODULE_KEY = {
PRIVILEGES: 'PRIVILEGES',
USERS: 'USERS',
CONFIGURATION_DIVISION: 'CONFIGURATION.DIVISION',
CONFIGURATION_BRANCH: 'CONFIGURATION.BRANCH',
CONFIGURATION_CUSTOMER: 'CONFIGURATION.CUSTOMER',
CONFIGURATION_EMPLOYEE: 'CONFIGURATION.EMPLOYEE',
SALES_CYCLE: 'SALES.CYCLE',
SALES_PLAN: 'SALES.PLAN',
LOGISTICS_CYCLE: 'LOGISTICS.CYCLE',
LOGISTICS_PLAN: 'LOGISTICS.PLAN',
PRIVILEGES: 'ADMIN.SETTINGS.USER.PRIVILEGES',
USERS: 'ADMIN.SETTINGS.USER.USERS',
CONFIGURATION_DIVISION: 'ADMIN.SETTINGS.DATA.DIVISION',
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',
SALES_ORDER: 'ADMIN.SALES.ACTIVITIES.ORDER',
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',
LOGISTICS_PLAN: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
LOGISTICS_REPORT: 'ADMIN.LOGISTICS.REPORT',
} as const;
+2 -2
View File
@@ -38,7 +38,7 @@ const me = {
isSuperadmin: false,
privilege: { id: 'priv-1', name: 'Admin', code: 'ADMIN' },
permissions: {
PRIVILEGES: { view: true, create: false, update: false, delete: false },
'ADMIN.SETTINGS.USER.PRIVILEGES': { view: true, create: false, update: false, delete: false },
},
};
@@ -83,7 +83,7 @@ describe('auth.helper', () => {
expect(appDatabase.setItem).toHaveBeenCalledWith(
'user_privilege',
expect.objectContaining({
PRIVILEGES: expect.objectContaining({ ALLOW_VIEW: true, ALLOW_CREATE: false }),
'ADMIN.SETTINGS.USER.PRIVILEGES': expect.objectContaining({ ALLOW_VIEW: true, ALLOW_CREATE: false }),
}),
);
expect(replace).toHaveBeenCalledWith('/app');
@@ -27,7 +27,9 @@ describe('enterpriseStorageAdapter', () => {
return null;
});
await expect(enterpriseStorageAdapter.getPrivileges('PRIVILEGES')).resolves.toEqual(defaultPrivileges);
await expect(enterpriseStorageAdapter.getPrivileges('ADMIN.SETTINGS.USER.PRIVILEGES')).resolves.toEqual(
defaultPrivileges,
);
await expect(enterpriseStorageAdapter.getPrivileges('UNKNOWN')).resolves.toEqual(defaultPrivileges);
});
@@ -35,12 +37,12 @@ describe('enterpriseStorageAdapter', () => {
vi.mocked(appDatabase.getItem).mockImplementation(async (key) => {
if (key === 'user_profile') return { isSuperadmin: false };
if (key === 'user_privilege') {
return { PRIVILEGES: { ...noPrivileges, ALLOW_VIEW: true } };
return { 'ADMIN.SETTINGS.USER.PRIVILEGES': { ...noPrivileges, ALLOW_VIEW: true } };
}
return null;
});
await expect(enterpriseStorageAdapter.getPrivileges('PRIVILEGES')).resolves.toEqual({
await expect(enterpriseStorageAdapter.getPrivileges('ADMIN.SETTINGS.USER.PRIVILEGES')).resolves.toEqual({
...noPrivileges,
ALLOW_VIEW: true,
});
@@ -14,7 +14,7 @@ const items: TestMenuItem[] = [
{
key: 'system',
children: [
{ key: 'privileges', moduleKey: 'PRIVILEGES', path: '/app/system/privileges/index' },
{ key: 'privileges', moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES', path: '/app/system/privileges/index' },
{ key: 'placeholder', path: '/app/system/other' },
],
},
@@ -34,7 +34,7 @@ describe('filterMenuByViewPrivilege', () => {
const filtered = filterMenuByViewPrivilege(
items,
{
PRIVILEGES: { ...noPrivileges, ALLOW_VIEW: false },
'ADMIN.SETTINGS.USER.PRIVILEGES': { ...noPrivileges, ALLOW_VIEW: false },
EXAMPLE_FULL_PAGE: { ...noPrivileges, ALLOW_VIEW: true },
},
false,
@@ -50,7 +50,7 @@ describe('filterMenuByViewPrivilege', () => {
const filtered = filterMenuByViewPrivilege(
items,
{
PRIVILEGES: { ...noPrivileges },
'ADMIN.SETTINGS.USER.PRIVILEGES': { ...noPrivileges },
EXAMPLE_FULL_PAGE: { ...noPrivileges },
},
false,
@@ -72,13 +72,13 @@ describe('filterMenuByViewPrivilege', () => {
{
key: 'sales',
children: [
{ key: 'orders', moduleKey: 'SALES.ORDER', path: '/app/sales/orders/index' },
{ key: 'orders', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER', path: '/app/sales/orders/index' },
{ key: 'reports', path: '/app/sales/reports', isPlaceholder: true },
],
},
];
const filtered = filterMenuByViewPrivilege(menu, { 'SALES.ORDER': { ...noPrivileges } }, false);
const filtered = filterMenuByViewPrivilege(menu, { 'ADMIN.SALES.ACTIVITIES.ORDER': { ...noPrivileges } }, false);
expect(filtered.find((item) => item.key === 'sales')).toBeUndefined();
});
@@ -88,13 +88,17 @@ describe('filterMenuByViewPrivilege', () => {
{
key: 'sales',
children: [
{ key: 'orders', moduleKey: 'SALES.ORDER', path: '/app/sales/orders/index' },
{ key: 'orders', moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER', path: '/app/sales/orders/index' },
{ key: 'reports', path: '/app/sales/reports', isPlaceholder: true },
],
},
];
const filtered = filterMenuByViewPrivilege(menu, { 'SALES.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',
@@ -6,7 +6,7 @@ describe('mapUserPrivileges', () => {
it('maps view/create/update/delete/import flags', () => {
const result = mapUserPrivileges(
{
PRIVILEGES: {
'ADMIN.SETTINGS.USER.PRIVILEGES': {
view: true,
create: true,
update: false,
@@ -17,7 +17,7 @@ describe('mapUserPrivileges', () => {
false,
);
expect(result.PRIVILEGES).toEqual({
expect(result['ADMIN.SETTINGS.USER.PRIVILEGES']).toEqual({
...noPrivileges,
ALLOW_VIEW: true,
ALLOW_CREATE: true,
@@ -28,9 +28,9 @@ describe('mapUserPrivileges', () => {
});
it('defaults missing ALLOW flags to false', () => {
const result = mapUserPrivileges({ CUSTOMERS: { view: true } }, false);
const result = mapUserPrivileges({ 'ADMIN.SETTINGS.DATA.CUSTOMER': { view: true } }, false);
expect(result.CUSTOMERS).toEqual({
expect(result['ADMIN.SETTINGS.DATA.CUSTOMER']).toEqual({
...noPrivileges,
ALLOW_VIEW: true,
ALLOW_CREATE: false,
@@ -42,7 +42,7 @@ describe('mapUserPrivileges', () => {
it('returns an empty map for superadmin (adapter grants full privileges)', () => {
const result = mapUserPrivileges(
{
PRIVILEGES: { view: true, create: true, update: true, delete: true },
'ADMIN.SETTINGS.USER.PRIVILEGES': { view: true, create: true, update: true, delete: true },
},
true,
);
@@ -53,7 +53,7 @@ describe('TrackGoRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new SampleRemoteDataServices(httpClient, {
apiUrl: '/divisions',
moduleKey: 'CONFIGURATION.DIVISION',
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
transformer: new SampleTransformer(),
});
});
+7
View File
@@ -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';
@@ -0,0 +1,209 @@
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>
);
}