Compare commits
3
Commits
05c8459d12
...
87bfe50f0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87bfe50f0e | ||
|
|
4c643547b6 | ||
|
|
6a54aa6c50 |
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -167,9 +167,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 +179,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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import type {
|
||||
CompanySettingsEntity,
|
||||
UpdateCompanySettingsPayload,
|
||||
} from '../domain/entities/company-settings.entity';
|
||||
|
||||
export class CompanySettingsRemoteService {
|
||||
constructor(private readonly client: AxiosInstance) {}
|
||||
|
||||
async get(): Promise<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;
|
||||
}
|
||||
}
|
||||
+13
@@ -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;
|
||||
+19
@@ -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;
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
|
||||
import {
|
||||
companySettingsModuleConfig,
|
||||
type CompanySettingsShellEntity,
|
||||
} from '../constants/company-settings.constants';
|
||||
import { CompanySettingsRemoteService } from '../../data/company-settings.remote.service';
|
||||
|
||||
class CompanySettingsShellTransformer extends BaseDataTransformer<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);
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { companySettingsModuleConfig } from '../../domain/constants/company-settings.constants';
|
||||
import { companySettingsDataService } from '../../domain/factories';
|
||||
import { companySettingsStore } from '../store';
|
||||
|
||||
import companySettingsEn from '../languages/en/company-settings.json';
|
||||
import companySettingsId from '../languages/id/company-settings.json';
|
||||
|
||||
const IndexPage = lazy(() => import('../pages/company-settings.page'));
|
||||
|
||||
registerModuleNamespace(companySettingsModuleConfig.translationNamespace, {
|
||||
en: companySettingsEn,
|
||||
id: companySettingsId,
|
||||
});
|
||||
|
||||
export default function CompanySettingsModule() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+17
@@ -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."
|
||||
}
|
||||
}
|
||||
+17
@@ -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."
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, CorePageContainer, Grid, Stack, Text } from '@repo/ui/components';
|
||||
import { ModulePageHeader } from '@repo/ui/foundations';
|
||||
import { FieldDatePicker, FieldNumberInput } from '@repo/ui/form';
|
||||
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { companySettingsModuleConfig } from '../../domain/constants/company-settings.constants';
|
||||
import { companySettingsRemoteService } from '../../domain/factories';
|
||||
|
||||
import companySettingsEn from '../languages/en/company-settings.json';
|
||||
import companySettingsId from '../languages/id/company-settings.json';
|
||||
|
||||
registerModuleNamespace(companySettingsModuleConfig.translationNamespace, {
|
||||
en: companySettingsEn,
|
||||
id: companySettingsId,
|
||||
});
|
||||
|
||||
type CompanySettingsForm = {
|
||||
cycleStartDate: string;
|
||||
checkInRadiusMeters: number;
|
||||
gpsIntervalSeconds: number;
|
||||
checkoutWarningRadiusMeters: number;
|
||||
};
|
||||
|
||||
function unixDayToIsoDate(unixMs: number): string {
|
||||
const date = new Date(unixMs);
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export default function CompanySettingsPage() {
|
||||
const { t } = useTranslation(companySettingsModuleConfig.translationNamespace);
|
||||
const { t: tNav } = useTranslation('nav');
|
||||
const form = useForm<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>
|
||||
);
|
||||
}
|
||||
+16
@@ -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 }),
|
||||
}));
|
||||
+1
-1
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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`,
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+47
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
+53
-9
@@ -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;
|
||||
}
|
||||
|
||||
+8
-17
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+8
-1
@@ -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();
|
||||
|
||||
+9
-1
@@ -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);
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { Avatar, Badge, Box, Card, Group, Stack, Text, Timeline, UnstyledButton } from '@repo/ui/components';
|
||||
import { formatClock, type TimelineActivityGroup } from './timeline-helpers';
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function TimelineActivityList({
|
||||
groups,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
activityLabel,
|
||||
emptyLabel,
|
||||
ungroupedLabel,
|
||||
}: {
|
||||
groups: TimelineActivityGroup[];
|
||||
selectedKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
activityLabel: (type: string) => string;
|
||||
emptyLabel: string;
|
||||
ungroupedLabel: string;
|
||||
}) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@repo/ui/components';
|
||||
import { Search } from 'lucide-react';
|
||||
import { TimelineActivityList } from './timeline-activity-list';
|
||||
import type { TimelineActivityGroup, TimelineActivityTab } from './timeline-helpers';
|
||||
|
||||
export function TimelineActivityPanel({
|
||||
title,
|
||||
search,
|
||||
searchPlaceholder,
|
||||
onSearchChange,
|
||||
tab,
|
||||
onTabChange,
|
||||
onTheWayLabel,
|
||||
completedLabel,
|
||||
filters,
|
||||
groups,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
activityLabel,
|
||||
emptyLabel,
|
||||
ungroupedLabel,
|
||||
errorMessage,
|
||||
}: {
|
||||
title: string;
|
||||
search: string;
|
||||
searchPlaceholder: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
tab: TimelineActivityTab;
|
||||
onTabChange: (value: TimelineActivityTab) => void;
|
||||
onTheWayLabel: string;
|
||||
completedLabel: string;
|
||||
filters: ReactNode;
|
||||
groups: TimelineActivityGroup[];
|
||||
selectedKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
activityLabel: (type: string) => string;
|
||||
emptyLabel: string;
|
||||
ungroupedLabel: string;
|
||||
errorMessage: string | null;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TimelineActivityEntity, TimelineFootprintEntity } from '../../domain/entities/timeline.entity';
|
||||
import {
|
||||
advancePlaybackTime,
|
||||
filterActivityGroups,
|
||||
filterTimelineByPlayback,
|
||||
groupActivities,
|
||||
playbackStep,
|
||||
resolvePlaybackBounds,
|
||||
resolvePlaybackPositions,
|
||||
shouldRestartPlayback,
|
||||
} from './timeline-helpers';
|
||||
|
||||
const footprints: TimelineFootprintEntity[] = [
|
||||
{
|
||||
id: 'fp-1',
|
||||
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
recordedAt: 1000,
|
||||
},
|
||||
{
|
||||
id: 'fp-2',
|
||||
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
latitude: -6.21,
|
||||
longitude: 106.81,
|
||||
recordedAt: 2000,
|
||||
},
|
||||
];
|
||||
|
||||
function activity(overrides: Partial<TimelineActivityEntity>): 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);
|
||||
});
|
||||
});
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import type {
|
||||
TimelineActivityEntity,
|
||||
TimelineFootprintEntity,
|
||||
} from '../../domain/entities/timeline.entity';
|
||||
|
||||
export type TimelineActivityGroup = {
|
||||
key: string;
|
||||
title: string;
|
||||
employeeName: string;
|
||||
lastType: string;
|
||||
firstRecordedAt: number;
|
||||
lastRecordedAt: number;
|
||||
activities: TimelineActivityEntity[];
|
||||
isVisit: boolean;
|
||||
isOnTheWay: boolean;
|
||||
};
|
||||
|
||||
export type TimelineActivityTab = 'on_the_way' | 'completed';
|
||||
|
||||
function sortByRecordedAt(activities: TimelineActivityEntity[]): TimelineActivityEntity[] {
|
||||
return [...activities].sort((left, right) => left.recordedAt - right.recordedAt);
|
||||
}
|
||||
|
||||
function isOpenVisit(types: string[]): boolean {
|
||||
const hasCheckIn = types.some((type) => type.endsWith('_check_in'));
|
||||
const hasCheckOut = types.some((type) => type.endsWith('_check_out'));
|
||||
return hasCheckIn && !hasCheckOut;
|
||||
}
|
||||
|
||||
export function formatClock(unixMs: number): string {
|
||||
const date = new Date(unixMs);
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export function groupActivities(activities: TimelineActivityEntity[]): TimelineActivityGroup[] {
|
||||
const grouped = new Map<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);
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { Avatar, Badge, Box, Button, Group, Paper, SimpleGrid, Slider, Stack, Text } from '@repo/ui/components';
|
||||
import { Pause, Play } from 'lucide-react';
|
||||
import type { TimelineActivityGroup } from './timeline-helpers';
|
||||
import { formatClock } from './timeline-helpers';
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
function OverlayMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<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,36 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { salesTimelineModuleConfig } from '../../domain/constants/timeline.constants';
|
||||
import { salesTimelineDataService } from '../../domain/factories';
|
||||
import { salesTimelineStore } from '../store';
|
||||
|
||||
import timelineEn from '../languages/en/timeline.json';
|
||||
import timelineId from '../languages/id/timeline.json';
|
||||
|
||||
const IndexPage = lazy(() => import('../pages/timeline.page.index'));
|
||||
|
||||
registerModuleNamespace(salesTimelineModuleConfig.translationNamespace, {
|
||||
en: timelineEn,
|
||||
id: timelineId,
|
||||
});
|
||||
|
||||
export default function SalesTimelineModule() {
|
||||
return (
|
||||
<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."
|
||||
}
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Grid } from '@repo/ui/components';
|
||||
import { FieldAsyncSelect, FieldDatePicker } from '@repo/ui/form';
|
||||
import { TimelineMap } from '@repo/ui/map';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import { FormProvider, useForm, type Control, type FieldValues } from 'react-hook-form';
|
||||
import { salesTimelineModuleConfig } from '../../domain/constants/timeline.constants';
|
||||
import type {
|
||||
TimelineActivityEntity,
|
||||
TimelineDayEntity,
|
||||
TimelineFootprintEntity,
|
||||
} from '../../domain/entities/timeline.entity';
|
||||
import { timelineRemoteService } from '../../domain/factories';
|
||||
import { loadSalesEmployeeOptions } from '../../../shared/load-employee-options';
|
||||
import { relationLabel } from '../../../shared/relation-label';
|
||||
import type { EmployeeEntity } from '../../../../configuration/employees/domain/entities';
|
||||
import { TimelineActivityPanel } from '../components/timeline-activity-panel';
|
||||
import { TimelinePlaybackOverlay } from '../components/timeline-playback-overlay';
|
||||
import {
|
||||
advancePlaybackTime,
|
||||
filterActivityGroups,
|
||||
filterTimelineByPlayback,
|
||||
groupActivities,
|
||||
playbackStep,
|
||||
resolvePlaybackBounds,
|
||||
resolvePlaybackPositions,
|
||||
shouldRestartPlayback,
|
||||
type TimelineActivityTab,
|
||||
} from '../components/timeline-helpers';
|
||||
|
||||
type TimelineFilterForm = {
|
||||
date: string;
|
||||
employee: EmployeeEntity | null;
|
||||
};
|
||||
|
||||
const EMPTY_FOOTPRINTS: TimelineFootprintEntity[] = [];
|
||||
const EMPTY_ACTIVITIES: TimelineActivityEntity[] = [];
|
||||
|
||||
function todayIsoDate(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = `${now.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${now.getDate()}`.padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export default function TimelinePage() {
|
||||
const { t } = useTranslation(salesTimelineModuleConfig.translationNamespace);
|
||||
const form = useForm<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>
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+3
-3
@@ -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,
|
||||
});
|
||||
|
||||
+1
-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',
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
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(' ');
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ describe('buildPrivilegeMatrixRows', () => {
|
||||
details: [
|
||||
{
|
||||
privilegeKeyId: 'key-2',
|
||||
keyCode: 'USERS',
|
||||
keyCode: 'ADMIN.SETTINGS.USER.USERS',
|
||||
keyLabel: 'Users',
|
||||
sortOrder: 2,
|
||||
action: 'view',
|
||||
|
||||
+3
-3
@@ -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,
|
||||
});
|
||||
|
||||
+22
-35
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+18
-34
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+11
@@ -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"
|
||||
|
||||
+11
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,7 @@ 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 +35,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,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, 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(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,238 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
CircleMarker,
|
||||
MapContainer,
|
||||
Polyline,
|
||||
TileLayer,
|
||||
Tooltip,
|
||||
ZoomControl,
|
||||
useMap,
|
||||
} from 'react-leaflet';
|
||||
import { Box, Text } from '@mantine/core';
|
||||
import { OSM_ATTRIBUTION, OSM_TILE_URL } from './osm';
|
||||
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from './location-point';
|
||||
import { toLeafletLatLngs } from './route-geometry';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './leaflet-stacking.css';
|
||||
|
||||
export type TimelineMapFootprint = {
|
||||
employeeId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
recordedAt: number;
|
||||
};
|
||||
|
||||
export type TimelineMapActivity = {
|
||||
id: string;
|
||||
type: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
recordedAt: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type TimelineMapPlaybackPosition = {
|
||||
employeeId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export interface TimelineMapProps {
|
||||
footprints?: TimelineMapFootprint[];
|
||||
activities?: TimelineMapActivity[];
|
||||
playbackPositions?: TimelineMapPlaybackPosition[];
|
||||
focusPositions?: Array<[number, number]>;
|
||||
selectedActivityId?: string;
|
||||
height?: number | string;
|
||||
radius?: string | number;
|
||||
fullBleed?: boolean;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
const TRACK_COLORS = [
|
||||
'var(--mantine-color-blue-6)',
|
||||
'var(--mantine-color-teal-6)',
|
||||
'var(--mantine-color-orange-6)',
|
||||
'var(--mantine-color-grape-6)',
|
||||
'var(--mantine-color-cyan-6)',
|
||||
'var(--mantine-color-pink-6)',
|
||||
];
|
||||
|
||||
function InvalidateSize() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const container = map.getContainer();
|
||||
const observer = new ResizeObserver(() => {
|
||||
map.invalidateSize();
|
||||
});
|
||||
observer.observe(container);
|
||||
const id = window.setTimeout(() => map.invalidateSize(), 0);
|
||||
return () => {
|
||||
window.clearTimeout(id);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function FitTimelineBounds({
|
||||
positions,
|
||||
}: {
|
||||
positions: Array<[number, number]>;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const boundsKey = positions.map(([lat, lng]) => `${lat.toFixed(6)},${lng.toFixed(6)}`).join('|');
|
||||
useEffect(() => {
|
||||
if (positions.length === 0) {
|
||||
map.setView(DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM);
|
||||
return;
|
||||
}
|
||||
if (positions.length === 1) {
|
||||
map.setView(positions[0], 14);
|
||||
return;
|
||||
}
|
||||
map.fitBounds(positions, { padding: [48, 48] });
|
||||
}, [map, boundsKey]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function groupFootprintsByEmployee(
|
||||
footprints: readonly TimelineMapFootprint[],
|
||||
): Map<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user