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

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

These changes improve the overall code quality and maintainability, making it easier for developers to navigate and understand the codebase.
2026-09-02 08:14:37 +07:00
shancheas 87bfe50f0e feat: add sales timeline module and company settings configuration
- Introduced a new sales timeline module with routes and lazy loading for efficient loading.
- Updated privilege keys in `api.md` to include `ADMIN.SALES.ACTIVITIES.TIMELINE` for access control.
- Enhanced menu data to include the timeline option, improving navigation.
- Added company settings module with configuration options for cycle start date and check-in radius.
- Implemented remote services and data handling for company settings, ensuring accurate data management.
- Enhanced language support for both English and Indonesian in navigation and company settings.

These changes significantly improve the application's functionality by adding a timeline feature for sales activities and a comprehensive settings module for company configurations, enhancing user experience and data management.
2026-09-01 20:36:57 +07:00
shancheas 4c643547b6 feat: enhance plan entity and transformer for customer handling
- Added optional `customer` field to `PlanDestinationEntity` to improve customer association.
- Updated `PlansRemoteDataTransformer` to map nested customer data from destinations, ensuring accurate customer representation in transformed entities.
- Enhanced `FormDocumentsPreview` to display customer information alongside documents, improving clarity in document management.
- Introduced new tests for customer mapping in transformers, ensuring reliability and correctness in data handling.

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

These changes significantly improve the application's privilege management system, providing a clearer structure for access control and enhancing overall security.
2026-09-01 13:15:12 +07:00
shancheas 05c8459d12 feat: enhance plan document handling and validation in forms
- Introduced new functions for loading and managing plan document options, improving user experience in selecting documents based on customer context.
- Implemented grouping of documents by customer in the `FormDocumentsPreview` component, enhancing clarity in document displays.
- Updated validation schemas to ensure proper handling of document associations with selected customers, preventing invalid selections.
- Added unit tests for new functionalities, ensuring reliability and correctness in document management.
- Enhanced language support for user prompts related to document selection, improving overall usability.

These changes significantly improve the handling of plan documents within the application, streamlining user interactions and ensuring data integrity.
2026-09-01 09:50:53 +07:00
123 changed files with 2955 additions and 505 deletions
+5 -5
View File
@@ -8,11 +8,11 @@ alwaysApply: false
Every authenticated product module in `apps/web` must be gated by `GET /auth/me` permissions. Copy Privileges (`system/privileges`) — do not invent a second RBAC path.
Catalog keys live in `api.md` §4 (`PRIVILEGES`, `CONFIGURATION.BRANCH`, `SALES.ORDER`, …). `isSuperadmin` bypasses the matrix (adapter returns `defaultPrivileges`).
Catalog keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule` (e.g. `ADMIN.SETTINGS.DATA.BRANCH`, `ADMIN.SALES.ACTIVITIES.ORDER`). `isSuperadmin` bypasses the matrix (adapter returns `defaultPrivileges`).
## Required wiring (do all four)
1. **`moduleKey`** on `ModuleConfigEntity` equals the catalog `code` (e.g. `CONFIGURATION.BRANCH`).
1. **`moduleKey`** on `ModuleConfigEntity` equals the Admin catalog `code` (e.g. `ADMIN.SETTINGS.DATA.BRANCH`).
2. **Menu leaf** in `layouts/data/menu.data.ts` sets the same `moduleKey`. `filterMenuByViewPrivilege` hides the item when `ALLOW_VIEW` is false.
3. **Routes** wrap in `EnterpriseModuleProvider` so missing `ALLOW_VIEW` shows forbidden (no all-true flash).
4. **Do not** re-check create/edit/delete in page JSX. Foundations already hide actions from `PrivilegeEntity`.
@@ -23,8 +23,8 @@ if (!user.permissions.BRANCHES?.create) return null;
{ key: 'branches', path: '/app/system/branches/index' }
// GOOD
export const branchesModuleConfig = { moduleKey: 'CONFIGURATION.BRANCH', /* ... */ };
{ key: 'system-branches', path: '/app/system/branches/index', moduleKey: 'CONFIGURATION.BRANCH' }
export const branchesModuleConfig = { moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH', /* ... */ };
{ key: 'system-branches', path: '/app/system/branches/index', moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH' }
```
## Flag map (`mapUserPrivileges`)
@@ -37,6 +37,6 @@ export const branchesModuleConfig = { moduleKey: 'CONFIGURATION.BRANCH', /* ...
| `delete` | `ALLOW_DELETE` |
| `import` | `ALLOW_IMPORT` |
Missing flag → `false`. Cycles/plans: key follows `purpose` (`SALES.CYCLE` / `LOGISTICS.PLAN`), not a generic `CYCLES` key.
Missing flag → `false`. Cycles/plans: key follows `purpose` (`ADMIN.SALES.DATA.CYCLE` / `ADMIN.LOGISTICS.ACTIVITIES.PLAN`), not a generic `CYCLES` key.
Reference: [apps/web/src/core/lib/map-user-privileges.ts](apps/web/src/core/lib/map-user-privileges.ts), [filter-menu-by-view-privilege.ts](apps/web/src/core/lib/filter-menu-by-view-privilege.ts).
+31 -24
View File
@@ -240,36 +240,43 @@ HTTP mapping:
| `DELETE /:id`, `POST /bulk-delete` | `delete` |
| `POST /import` | `import` |
Catalog (`GET /privilege-keys`, needs `PRIVILEGES` `view`):
Catalog (`GET /privilege-keys`, needs `ADMIN.SETTINGS.USER.PRIVILEGES` `view`). Keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule`:
| code | label |
| ------------------------ | ---------------- |
| `PRIVILEGES` | Privileges |
| `USERS` | Users |
| `CONFIGURATION.DIVISION` | Divisions |
| `CONFIGURATION.BRANCH` | Branches |
| `CONFIGURATION.CUSTOMER` | Customers |
| `CONFIGURATION.EMPLOYEE` | Employees |
| `CONFIGURATION.PRODUCT` | Products |
| `SALES.REQUEST` | Sales requests |
| `SALES.ORDER` | Sales orders |
| `SALES.PACKING_SLIP` | Packing slips |
| `SALES.INVOICE` | Sales invoices |
| `SALES.PAYMENT` | Sales payments |
| `CONFIGURATION.SETTING` | Company settings |
| `SALES.CYCLE` | Sales cycles |
| `SALES.PLAN` | Sales plans |
| `LOGISTICS.CYCLE` | Logistics cycles |
| `LOGISTICS.PLAN` | Logistics plans |
| code | label |
| ----------------------------------------- | ----------------------- |
| `ADMIN.SETTINGS.USER.PRIVILEGES` | Privileges |
| `ADMIN.SETTINGS.USER.USERS` | Users |
| `ADMIN.SETTINGS.DATA.DIVISION` | Divisions |
| `ADMIN.SETTINGS.DATA.BRANCH` | Branches |
| `ADMIN.SETTINGS.DATA.CUSTOMER` | Customers |
| `ADMIN.SETTINGS.DATA.PRODUCT` | Products |
| `ADMIN.SETTINGS.DATA.SETTING` | Company settings |
| `ADMIN.SALES.DATA.EMPLOYEE` | Employees |
| `ADMIN.SALES.DATA.CYCLE` | Sales cycles |
| `ADMIN.SALES.ACTIVITIES.REQUEST` | Sales requests |
| `ADMIN.SALES.ACTIVITIES.ORDER` | Sales orders |
| `ADMIN.SALES.ACTIVITIES.INVOICE` | Sales invoices |
| `ADMIN.SALES.ACTIVITIES.PAYMENT` | Sales payments |
| `ADMIN.SALES.ACTIVITIES.PLAN` | Sales plans |
| `ADMIN.SALES.ACTIVITIES.TIMELINE` | Sales timeline |
| `ADMIN.SALES.REPORT` | Sales reports |
| `ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP` | Packing slips |
| `ADMIN.LOGISTICS.DATA.CYCLE` | Logistics cycles |
| `ADMIN.LOGISTICS.ACTIVITIES.PLAN` | Logistics plans |
| `ADMIN.LOGISTICS.REPORT` | Logistics reports |
| `MOBILE.SALES.PLAN` | Sales plans (mobile) |
| `MOBILE.SALES.PLAN.ATTENDANCE` | Branch attendance |
| `MOBILE.SALES.VISIT` | Customer visits |
| `MOBILE.SALES.TIMELINE` | Sales timeline (mobile) |
### Field purpose
Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`:
| purpose | cycle key | plan key |
| ----------- | ----------------- | ---------------- |
| `sales` | `SALES.CYCLE` | `SALES.PLAN` |
| `logistics` | `LOGISTICS.CYCLE` | `LOGISTICS.PLAN` |
| purpose | cycle keys | plan keys |
| ----------- | ---------------------------- | ---------------------------------------------------------- |
| `sales` | `ADMIN.SALES.DATA.CYCLE` | `ADMIN.SALES.ACTIVITIES.PLAN`, `MOBILE.SALES.PLAN` |
| `logistics` | `ADMIN.LOGISTICS.DATA.CYCLE` | `ADMIN.LOGISTICS.ACTIVITIES.PLAN`, `MOBILE.LOGISTICS.PLAN` |
`purpose` is read from **body** (writes) or **query** (lists). If omitted, the user may proceed if they have the action on **either** purpose; list results are filtered to purposes they can view. Superadmin bypasses.
+14 -14
View File
@@ -6,23 +6,23 @@ Architecture and APIs: [trackgo-be/docs/report-engine.md](../../../../../trackgo
## Layout
| Path | Role |
| --- | --- |
| `constants/` | `FILTER_TYPE`, `DATA_FORMAT`, `REPORT_GROUP` — keep in sync with backend |
| `entities/` | Frontend mirror of config / query contracts |
| `data/report.remote.service.ts` | HTTP client (`apiClient`) |
| `utils/filter.helper.ts` | Form values → `filterModel` |
| `utils/column.helper.ts` | `columnConfigs` → AG Grid `columnDefs` |
| `components/report-provider.tsx` | Load configs → Mantine tabs |
| `components/report-table.tsx` | AG Grid SSRM + filter/bookmark actions |
| `components/report-filter-drawer.tsx` | Config-driven filter form |
| `components/report-bookmark-list.tsx` | Bookmark apply / delete |
| Path | Role |
| ------------------------------------- | ------------------------------------------------------------------------ |
| `constants/` | `FILTER_TYPE`, `DATA_FORMAT`, `REPORT_GROUP` — keep in sync with backend |
| `entities/` | Frontend mirror of config / query contracts |
| `data/report.remote.service.ts` | HTTP client (`apiClient`) |
| `utils/filter.helper.ts` | Form values → `filterModel` |
| `utils/column.helper.ts` | `columnConfigs` → AG Grid `columnDefs` |
| `components/report-provider.tsx` | Load configs → Mantine tabs |
| `components/report-table.tsx` | AG Grid SSRM + filter/bookmark actions |
| `components/report-filter-drawer.tsx` | Config-driven filter form |
| `components/report-bookmark-list.tsx` | Bookmark apply / delete |
## Product modules
| Module | Path | `moduleKey` |
| --- | --- | --- |
| Sales reports | `apps/main/modules/sales/reports/` | `SALES.REPORT` |
| Module | Path | `moduleKey` |
| ----------------- | -------------------------------------------- | ------------------ |
| Sales reports | `apps/main/modules/sales/reports/` | `SALES.REPORT` |
| Logistics reports | `apps/main/modules/field/logistics-reports/` | `LOGISTICS.REPORT` |
Each module wraps `ReportProvider` with `groupName` `sales_report` or `logistics_report` inside `EnterpriseModuleProvider` for RBAC.
@@ -104,12 +104,6 @@ export function RightSection() {
</div>
</div>
<p className="text-[13px] text-center text-gray-600 mt-6 max-w-[85%] z-10 leading-[1.6]">
Compatible with{' '}
<span className="font-bold italic text-gray-900">Gmail, Outlook Web, LinkedIn and most web editors</span> for a
smooth writing experience anywhere online.
</p>
{/* Pagination Indicators */}
<div className="flex gap-1.5 mt-8 z-10 mb-2">
<div className="w-6 h-1 rounded-full bg-brand-500"></div>
+1 -44
View File
@@ -3,14 +3,11 @@ import {
FieldPasswordInput,
Checkbox,
Button,
Divider,
Select,
Image,
notifications,
} from '@repo/ui/components';
import { Lock, Apple, LogInIcon, UserRound, Globe } from 'lucide-react';
import { GoogleIcon } from './components/google-icon';
import { MicrosoftIcon } from './components/microsoft-icon';
import { Lock, LogInIcon, UserRound, Globe } from 'lucide-react';
import { RightSection } from './components/right-section';
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
import { AppStorageKey, appStorage } from '../../../core/storage/local';
@@ -167,9 +164,6 @@ export default function LoginPage() {
label: { color: '#64748b', fontWeight: 500 },
}}
/>
<a href="#" className="text-[13px] text-brand-600 font-semibold hover:underline">
{t('forgot_password')}
</a>
</div>
<Button
@@ -182,43 +176,6 @@ export default function LoginPage() {
>
{t('login_button')}
</Button>
<Divider
label={t('or_login_with')}
labelPosition="center"
my="lg"
styles={{ label: { color: '#94a3b8', fontSize: '12px' } }}
/>
<div className="grid grid-cols-3 gap-4">
<Button
type="button"
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<GoogleIcon className="w-5 h-5" />
</Button>
<Button
type="button"
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<Apple className="w-5.5 h-5.5 text-black" fill="black" />
</Button>
<Button
type="button"
variant="default"
size="md"
className="border-gray-200 hover:bg-gray-50 transition-colors"
styles={{ root: { borderRadius: '10px', height: '44px' } }}
>
<MicrosoftIcon className="w-5 h-5" />
</Button>
</div>
</form>
</div>
</div>
+2
View File
@@ -11,6 +11,7 @@ const PrivilegesModule = lazy(() => import('./modules/system/privileges/presenta
const UsersModule = lazy(() => import('./modules/system/users/presentation/factory'));
const ConfigurationModule = lazy(() => import('./modules/configuration'));
const SalesModule = lazy(() => import('./modules/sales'));
const TimelineModule = lazy(() => import('./modules/field/timeline/presentation/factory'));
const LogisticsFieldModule = lazy(() => import('./modules/field/logistics'));
export default function AppModule() {
@@ -26,6 +27,7 @@ export default function AppModule() {
<Route path="/system/users/*" element={<UsersModule />} />
<Route path="/configuration/*" element={<ConfigurationModule />} />
<Route path="/sales/*" element={<SalesModule />} />
<Route path="/timeline/*" element={<TimelineModule />} />
<Route path="/logistics/*" element={<LogisticsFieldModule />} />
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
@@ -98,7 +98,9 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
styles={{
root: {
borderRadius: 'var(--mantine-radius-md)',
color: isParentActive ? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))' : undefined,
color: isParentActive
? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))'
: undefined,
},
label: {
overflow: 'hidden',
@@ -21,11 +21,7 @@ export function shouldShowMenuChildren(isOpened: boolean, isSearching: boolean):
* Keys that must stay in the accessible tree when a branch is open.
* Nested Collapse height bugs clip these siblings after refresh; this list is the contract.
*/
export function getVisibleMenuKeys(
items: MenuItemType[],
openedKeys: Set<string>,
isSearching = false,
): string[] {
export function getVisibleMenuKeys(items: MenuItemType[], openedKeys: Set<string>, isSearching = false): string[] {
const keys: string[] = [];
const walk = (nodes: MenuItemType[]) => {
@@ -13,8 +13,17 @@ const flatten = (items: MenuItemType[]): MenuItemType[] =>
items.flatMap((item) => [item, ...(item.children ? flatten(item.children) : [])]);
describe('MENU_ITEMS', () => {
it('orders top-level items as dashboard, sales, logistics, settings', () => {
expect(MENU_ITEMS.map((item) => item.key)).toEqual(['dashboard', 'sales', 'logistics', 'settings']);
it('orders top-level items as dashboard, timeline, sales, logistics, settings', () => {
expect(MENU_ITEMS.map((item) => item.key)).toEqual(['dashboard', 'timeline', 'sales', 'logistics', 'settings']);
});
it('places timeline next to dashboard instead of inside sales activities', () => {
const timeline = findItem(MENU_ITEMS, 'timeline');
const sales = findItem(MENU_ITEMS, 'sales');
expect(timeline?.path).toBe('/app/timeline/index');
expect(timeline?.moduleKey).toBe('ADMIN.SALES.ACTIVITIES.TIMELINE');
expect(childKeys(findItem(sales?.children ?? [], 'sales-activities'))).not.toContain('sales-timeline');
});
it('nests sales as data, activities, then reports', () => {
@@ -58,6 +67,7 @@ describe('MENU_ITEMS', () => {
'configuration-divisions',
'configuration-customers',
'configuration-products',
'configuration-company-settings',
]);
expect(childKeys(findItem(settings?.children ?? [], 'settings-user'))).toEqual([
'system-users',
@@ -33,6 +33,13 @@ export const MENU_ITEMS: MenuItemType[] = [
icon: LayoutDashboard,
path: '/app/dashboard',
},
{
key: 'timeline',
label: 'nav:timeline',
icon: MapPin,
path: '/app/timeline/index',
moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
},
{
key: 'sales',
label: 'nav:sales',
@@ -50,14 +57,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:configuration-employees',
icon: Users,
path: '/app/sales/employees/index',
moduleKey: 'CONFIGURATION.EMPLOYEE',
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
},
{
key: 'sales-cycles',
label: 'nav:sales-cycles',
icon: Repeat,
path: '/app/sales/cycles/index',
moduleKey: 'SALES.CYCLE',
moduleKey: 'ADMIN.SALES.DATA.CYCLE',
},
],
},
@@ -72,35 +79,35 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:sales-requests',
icon: ClipboardList,
path: '/app/sales/requests/index',
moduleKey: 'SALES.REQUEST',
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
},
{
key: 'sales-orders',
label: 'nav:sales-orders',
icon: Box,
path: '/app/sales/orders/index',
moduleKey: 'SALES.ORDER',
moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
},
{
key: 'sales-invoices',
label: 'nav:sales-invoices',
icon: Receipt,
path: '/app/sales/invoices/index',
moduleKey: 'SALES.INVOICE',
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
},
{
key: 'sales-payments',
label: 'nav:sales-payments',
icon: CreditCard,
path: '/app/sales/payments/index',
moduleKey: 'SALES.PAYMENT',
moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
},
{
key: 'sales-plans',
label: 'nav:sales-plans',
icon: Calendar,
path: '/app/sales/plans/index',
moduleKey: 'SALES.PLAN',
moduleKey: 'ADMIN.SALES.ACTIVITIES.PLAN',
},
],
},
@@ -109,7 +116,7 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:sales-reports',
icon: FileText,
path: '/app/sales/reports/index',
moduleKey: 'SALES.REPORT',
moduleKey: 'ADMIN.SALES.REPORT',
},
],
},
@@ -130,14 +137,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:configuration-employees',
icon: Users,
path: '/app/logistics/employees/index',
moduleKey: 'CONFIGURATION.EMPLOYEE',
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
},
{
key: 'logistics-cycles',
label: 'nav:logistics-cycles',
icon: Repeat,
path: '/app/logistics/cycles/index',
moduleKey: 'LOGISTICS.CYCLE',
moduleKey: 'ADMIN.LOGISTICS.DATA.CYCLE',
},
],
},
@@ -152,14 +159,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:logistics-packing-slips',
icon: Package,
path: '/app/logistics/packing-slips/index',
moduleKey: 'SALES.PACKING_SLIP',
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
},
{
key: 'logistics-plans',
label: 'nav:logistics-plans',
icon: Calendar,
path: '/app/logistics/plans/index',
moduleKey: 'LOGISTICS.PLAN',
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
},
],
},
@@ -168,7 +175,7 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:logistics-reports',
icon: FileText,
path: '/app/logistics/reports/index',
moduleKey: 'LOGISTICS.REPORT',
moduleKey: 'ADMIN.LOGISTICS.REPORT',
},
],
},
@@ -189,28 +196,35 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:configuration-branches',
icon: MapPin,
path: '/app/configuration/branches/index',
moduleKey: 'CONFIGURATION.BRANCH',
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
},
{
key: 'configuration-divisions',
label: 'nav:configuration-divisions',
icon: Layers,
path: '/app/configuration/divisions/index',
moduleKey: 'CONFIGURATION.DIVISION',
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
},
{
key: 'configuration-customers',
label: 'nav:configuration-customers',
icon: Users,
path: '/app/configuration/customers/index',
moduleKey: 'CONFIGURATION.CUSTOMER',
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
},
{
key: 'configuration-products',
label: 'nav:configuration-products',
icon: Package,
path: '/app/configuration/products/index',
moduleKey: 'CONFIGURATION.PRODUCT',
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
},
{
key: 'configuration-company-settings',
label: 'nav:configuration-company-settings',
icon: Settings,
path: '/app/configuration/company-settings/index',
moduleKey: 'ADMIN.SETTINGS.DATA.SETTING',
},
],
},
@@ -225,14 +239,14 @@ export const MENU_ITEMS: MenuItemType[] = [
label: 'nav:system-users',
icon: Users,
path: '/app/system/users/index',
moduleKey: 'USERS',
moduleKey: 'ADMIN.SETTINGS.USER.USERS',
},
{
key: 'system-privileges',
label: 'nav:system-privileges',
icon: Shield,
path: '/app/system/privileges/index',
moduleKey: 'PRIVILEGES',
moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
},
],
},
@@ -1,5 +1,6 @@
{
"dashboard": "Dashboard",
"timeline": "Timeline",
"crm": "CRM",
"crm-leads": "Leads",
"crm-pipelines": "Pipelines",
@@ -55,5 +56,6 @@
"logistics-plans": "Logistics Plans",
"logistics-packing-slips": "Packing Slips",
"configuration-employees": "Employees",
"configuration-products": "Products"
"configuration-products": "Products",
"configuration-company-settings": "Company settings"
}
@@ -1,5 +1,6 @@
{
"dashboard": "Dasbor",
"timeline": "Timeline",
"crm": "CRM",
"crm-leads": "Prospek",
"crm-pipelines": "Alur Penjualan",
@@ -55,5 +56,6 @@
"logistics-plans": "Rencana Logistik",
"logistics-packing-slips": "Surat Jalan",
"configuration-employees": "Karyawan",
"configuration-products": "Produk"
"configuration-products": "Produk",
"configuration-company-settings": "Pengaturan perusahaan"
}
@@ -33,7 +33,7 @@ describe('BranchesRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new BranchesRemoteDataServices(httpClient, {
apiUrl: '/branches',
moduleKey: 'CONFIGURATION.BRANCH',
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
transformer: new BranchesRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { BranchEntity } from '../entities';
export const branchesModuleConfig: ModuleConfigEntity<BranchEntity> = {
moduleKey: 'CONFIGURATION.BRANCH',
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
translationNamespace: 'BRANCHES',
apiUrl: '/branches',
webUrl: '/app/configuration/branches',
@@ -0,0 +1,16 @@
import type { AxiosInstance } from '@repo/core-api/http-client';
import type { CompanySettingsEntity, UpdateCompanySettingsPayload } from '../domain/entities/company-settings.entity';
export class CompanySettingsRemoteService {
constructor(private readonly client: AxiosInstance) {}
async get(): Promise<CompanySettingsEntity> {
const { data } = await this.client.get<CompanySettingsEntity>('/settings');
return data;
}
async update(payload: UpdateCompanySettingsPayload): Promise<CompanySettingsEntity> {
const { data } = await this.client.patch<CompanySettingsEntity>('/settings', payload);
return data;
}
}
@@ -0,0 +1,13 @@
import type { BaseEntity } from '@repo/core-api/data-services';
import type { ModuleConfigEntity } from '@repo/ui/foundations';
export type CompanySettingsShellEntity = BaseEntity & { id: string };
export const companySettingsModuleConfig: ModuleConfigEntity<CompanySettingsShellEntity> = {
moduleKey: 'ADMIN.SETTINGS.DATA.SETTING',
translationNamespace: 'COMPANY_SETTINGS',
apiUrl: '/settings',
webUrl: '/app/configuration/company-settings',
moduleCategory: 'SINGLE_PAGE',
moduleType: 'MASTER_DATA',
} as const;
@@ -0,0 +1,19 @@
export type CompanySettingsEntity = {
id: string;
cycleStartDate: number;
checkInRadiusMeters: number;
gpsIntervalSeconds: number;
checkoutWarningRadiusMeters: number;
status: string;
createdAt: number;
updatedAt: number;
createdBy: string;
updatedBy: string;
};
export type UpdateCompanySettingsPayload = {
cycleStartDate?: string;
checkInRadiusMeters?: number;
gpsIntervalSeconds?: number;
checkoutWarningRadiusMeters?: number;
};
@@ -0,0 +1,23 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { apiClient } from '../../../../../../../core/lib/api-client';
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
import { companySettingsModuleConfig, type CompanySettingsShellEntity } from '../constants/company-settings.constants';
import { CompanySettingsRemoteService } from '../../data/company-settings.remote.service';
class CompanySettingsShellTransformer extends BaseDataTransformer<CompanySettingsShellEntity> {
transformToEntity(dto: CompanySettingsShellEntity): CompanySettingsShellEntity {
return dto;
}
transformToDTO(entity: CompanySettingsShellEntity): CompanySettingsShellEntity {
return entity;
}
}
export const companySettingsDataService = new TrackGoRemoteDataServices(apiClient, {
apiUrl: companySettingsModuleConfig.apiUrl,
moduleKey: companySettingsModuleConfig.moduleKey,
transformer: new CompanySettingsShellTransformer(),
});
export const companySettingsRemoteService = new CompanySettingsRemoteService(apiClient);
@@ -0,0 +1,33 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
import { registerModuleNamespace } from '@repo/core-i18n';
import { companySettingsModuleConfig } from '../../domain/constants/company-settings.constants';
import { companySettingsDataService } from '../../domain/factories';
import { companySettingsStore } from '../store';
import companySettingsEn from '../languages/en/company-settings.json';
import companySettingsId from '../languages/id/company-settings.json';
const IndexPage = lazy(() => import('../pages/company-settings.page'));
registerModuleNamespace(companySettingsModuleConfig.translationNamespace, {
en: companySettingsEn,
id: companySettingsId,
});
export default function CompanySettingsModule() {
return (
<EnterpriseModuleProvider
config={companySettingsModuleConfig}
dataServices={companySettingsDataService}
store={companySettingsStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/" element={<Navigate to={`${companySettingsModuleConfig.webUrl}/index`} replace />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,17 @@
{
"title": "Company settings",
"description": "Configure cycle start date, check-in radius, and timeline tracking.",
"fields": {
"cycleStartDate": "Cycle start date",
"checkInRadiusMeters": "Check-in radius (meters)",
"gpsIntervalSeconds": "GPS interval (seconds)",
"checkoutWarningRadiusMeters": "Checkout warning radius (meters)"
},
"actions": {
"save": "Save settings"
},
"messages": {
"saved": "Company settings updated.",
"loadFailed": "Could not load company settings."
}
}
@@ -0,0 +1,17 @@
{
"title": "Pengaturan perusahaan",
"description": "Atur tanggal awal siklus, radius check-in, dan pelacakan timeline.",
"fields": {
"cycleStartDate": "Tanggal awal siklus",
"checkInRadiusMeters": "Radius check-in (meter)",
"gpsIntervalSeconds": "Interval GPS (detik)",
"checkoutWarningRadiusMeters": "Radius peringatan checkout (meter)"
},
"actions": {
"save": "Simpan pengaturan"
},
"messages": {
"saved": "Pengaturan perusahaan diperbarui.",
"loadFailed": "Gagal memuat pengaturan perusahaan."
}
}
@@ -0,0 +1,138 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, Card, CorePageContainer, Grid, Stack, Text } from '@repo/ui/components';
import { ModulePageHeader } from '@repo/ui/foundations';
import { FieldDatePicker, FieldNumberInput } from '@repo/ui/form';
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
import { Settings } from 'lucide-react';
import { FormProvider, useForm } from 'react-hook-form';
import { companySettingsModuleConfig } from '../../domain/constants/company-settings.constants';
import { companySettingsRemoteService } from '../../domain/factories';
import companySettingsEn from '../languages/en/company-settings.json';
import companySettingsId from '../languages/id/company-settings.json';
registerModuleNamespace(companySettingsModuleConfig.translationNamespace, {
en: companySettingsEn,
id: companySettingsId,
});
type CompanySettingsForm = {
cycleStartDate: string;
checkInRadiusMeters: number;
gpsIntervalSeconds: number;
checkoutWarningRadiusMeters: number;
};
function unixDayToIsoDate(unixMs: number): string {
const date = new Date(unixMs);
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}
export default function CompanySettingsPage() {
const { t } = useTranslation(companySettingsModuleConfig.translationNamespace);
const { t: tNav } = useTranslation('nav');
const form = useForm<CompanySettingsForm>();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const loadSettings = useCallback(async () => {
setIsLoading(true);
setErrorMessage(null);
try {
const settings = await companySettingsRemoteService.get();
form.reset({
cycleStartDate: unixDayToIsoDate(settings.cycleStartDate),
checkInRadiusMeters: settings.checkInRadiusMeters,
gpsIntervalSeconds: settings.gpsIntervalSeconds,
checkoutWarningRadiusMeters: settings.checkoutWarningRadiusMeters,
});
} catch {
setErrorMessage(t('messages.loadFailed'));
} finally {
setIsLoading(false);
}
}, [form, t]);
useEffect(() => {
void loadSettings();
}, [loadSettings]);
const onSubmit = form.handleSubmit(async (values) => {
setErrorMessage(null);
setSuccessMessage(null);
try {
await companySettingsRemoteService.update(values);
setSuccessMessage(t('messages.saved'));
} catch {
setErrorMessage(t('messages.loadFailed'));
}
});
return (
<CorePageContainer>
<ModulePageHeader
icon={Settings}
title={t('title')}
description={t('description')}
moduleKey={companySettingsModuleConfig.moduleKey}
breadcrumbs={[
{ label: tNav('settings'), type: 'text' },
{ label: tNav('data'), type: 'text' },
{ label: t('title'), type: 'text' },
]}
/>
<FormProvider {...form}>
<Card withBorder padding="xl" radius="md">
<Stack gap="md" component="form" onSubmit={onSubmit}>
<Grid gutter="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<FieldDatePicker control={form.control} name="cycleStartDate" label={t('fields.cycleStartDate')} />
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<FieldNumberInput
control={form.control}
name="checkInRadiusMeters"
label={t('fields.checkInRadiusMeters')}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<FieldNumberInput
control={form.control}
name="gpsIntervalSeconds"
label={t('fields.gpsIntervalSeconds')}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<FieldNumberInput
control={form.control}
name="checkoutWarningRadiusMeters"
label={t('fields.checkoutWarningRadiusMeters')}
/>
</Grid.Col>
</Grid>
{errorMessage ? (
<Text size="sm" c="red">
{errorMessage}
</Text>
) : null}
{successMessage ? (
<Text size="sm" c="teal">
{successMessage}
</Text>
) : null}
<Button type="submit" loading={isLoading}>
{t('actions.save')}
</Button>
</Stack>
</Card>
</FormProvider>
</CorePageContainer>
);
}
@@ -0,0 +1,16 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import type { CompanySettingsShellEntity } from '../../domain/constants/company-settings.constants';
export const companySettingsStore = create<EnterpriseModuleState<CompanySettingsShellEntity>>((set) => ({
metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }),
filterData: {},
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
tableConfig: null,
setTableConfig: (config) => set({ tableConfig: config }),
}));
@@ -42,7 +42,7 @@ describe('CustomersRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new CustomersRemoteDataServices(httpClient, {
apiUrl: '/customers',
moduleKey: 'CONFIGURATION.CUSTOMER',
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
transformer: new CustomersRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { CustomerEntity } from '../entities';
export const customersModuleConfig: ModuleConfigEntity<CustomerEntity> = {
moduleKey: 'CONFIGURATION.CUSTOMER',
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
translationNamespace: 'CUSTOMERS',
apiUrl: '/customers',
webUrl: '/app/configuration/customers',
@@ -44,7 +44,7 @@ describe('DivisionsRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new DivisionsRemoteDataServices(httpClient, {
apiUrl: '/divisions',
moduleKey: 'CONFIGURATION.DIVISION',
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
transformer: new DivisionsRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { DivisionEntity } from '../entities';
export const divisionsModuleConfig: ModuleConfigEntity<DivisionEntity> = {
moduleKey: 'CONFIGURATION.DIVISION',
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
translationNamespace: 'DIVISIONS',
apiUrl: '/divisions',
webUrl: '/app/configuration/divisions',
@@ -33,7 +33,7 @@ describe('EmployeesRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new EmployeesRemoteDataServices(httpClient, {
apiUrl: '/employees',
moduleKey: 'CONFIGURATION.EMPLOYEE',
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
transformer: new EmployeesRemoteDataTransformer(),
});
});
@@ -14,7 +14,7 @@ describe('employee purpose helpers', () => {
});
it('keeps a shared employee privilege key', () => {
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
});
});
@@ -4,7 +4,7 @@ import type { EmployeeEntity } from '../entities';
export function createEmployeeModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<EmployeeEntity> {
return {
moduleKey: 'CONFIGURATION.EMPLOYEE',
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
translationNamespace: 'EMPLOYEES',
apiUrl: '/employees',
webUrl: `/app/${purpose}/employees`,
@@ -6,6 +6,7 @@ const DivisionsModule = lazy(() => import('./divisions/presentation/factory'));
const BranchesModule = lazy(() => import('./branches/presentation/factory'));
const CustomersModule = lazy(() => import('./customers/presentation/factory'));
const ProductsModule = lazy(() => import('./products/presentation/factory'));
const CompanySettingsModule = lazy(() => import('./company-settings/presentation/factory'));
export default function ConfigurationModule() {
return (
@@ -14,6 +15,7 @@ export default function ConfigurationModule() {
<Route path="/branches/*" element={<BranchesModule />} />
<Route path="/customers/*" element={<CustomersModule />} />
<Route path="/products/*" element={<ProductsModule />} />
<Route path="/company-settings/*" element={<CompanySettingsModule />} />
<Route path="/employees/*" element={<Navigate to={`${WEB_URL.SALES_EMPLOYEES}/index`} replace={true} />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
@@ -33,7 +33,7 @@ describe('ProductsRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new ProductsRemoteDataServices(httpClient, {
apiUrl: '/products',
moduleKey: 'CONFIGURATION.PRODUCT',
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
transformer: new ProductsRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { ProductEntity } from '../entities';
export const productsModuleConfig: ModuleConfigEntity<ProductEntity> = {
moduleKey: 'CONFIGURATION.PRODUCT',
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
translationNamespace: 'PRODUCTS',
apiUrl: '/products',
webUrl: '/app/configuration/products',
@@ -33,7 +33,7 @@ describe('CyclesRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new CyclesRemoteDataServices(httpClient, {
apiUrl: '/cycles',
moduleKey: 'SALES.CYCLE',
moduleKey: 'ADMIN.SALES.DATA.CYCLE',
transformer: new CyclesRemoteDataTransformer('sales'),
});
});
@@ -5,7 +5,7 @@ import type { CycleEntity, CycleWeekdayRow } from '../entities';
export function createCycleModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<CycleEntity> {
return {
moduleKey: purpose === 'sales' ? 'SALES.CYCLE' : 'LOGISTICS.CYCLE',
moduleKey: purpose === 'sales' ? 'ADMIN.SALES.DATA.CYCLE' : 'ADMIN.LOGISTICS.DATA.CYCLE',
translationNamespace: 'CYCLES',
apiUrl: '/cycles',
webUrl: `/app/${purpose}/cycles`,
@@ -4,7 +4,7 @@ import type { ModuleConfigEntity } from '@repo/ui/foundations';
export type ReportShellEntity = BaseEntity & { id: string };
export const logisticsReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
moduleKey: 'LOGISTICS.REPORT',
moduleKey: 'ADMIN.LOGISTICS.REPORT',
translationNamespace: 'LOGISTICS_REPORTS',
apiUrl: '/reports',
webUrl: '/app/logistics/reports',
@@ -1,10 +1,7 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { apiClient } from '../../../../../../../core/lib/api-client';
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
import {
logisticsReportsModuleConfig,
type ReportShellEntity,
} from '../constants/reports.constants';
import { logisticsReportsModuleConfig, type ReportShellEntity } from '../constants/reports.constants';
class ReportShellTransformer extends BaseDataTransformer<ReportShellEntity> {
transformToEntity(dto: ReportShellEntity): ReportShellEntity {
@@ -2,10 +2,7 @@ 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 {
logisticsReportsModuleConfig,
type ReportShellEntity,
} from '../../domain/constants/reports.constants';
import { logisticsReportsModuleConfig, type ReportShellEntity } from '../../domain/constants/reports.constants';
import { logisticsReportsDataService } from '../../domain/factories';
import { logisticsReportsStore } from '../store';
@@ -1,8 +1,6 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
const LogisticsReportsModule = lazy(
() => import('../logistics-reports/presentation/factory'),
);
const LogisticsReportsModule = lazy(() => import('../logistics-reports/presentation/factory'));
const EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory'));
const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
@@ -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;
}
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from 'vitest';
import { loadPlanDocumentOptions } from './load-plan-document-options';
describe('loadPlanDocumentOptions', () => {
it('returns no options until customers are selected', async () => {
const getMany = vi.fn();
const load = loadPlanDocumentOptions(getMany, []);
await expect(load('', 1, [])).resolves.toEqual({ options: [], hasMore: false });
expect(getMany).not.toHaveBeenCalled();
});
it('requests documents for the selected customers', async () => {
const getMany = vi.fn().mockResolvedValue({
data: { data: [{ id: 'inv-1' }], meta: { totalPages: 1 } },
});
const load = loadPlanDocumentOptions(getMany, ['cus-1', 'cus-2']);
const result = await load('INV', 1, []);
expect(getMany).toHaveBeenCalledWith({
params: { search: 'INV', page: 1, limit: 20, customerIds: 'cus-1,cus-2' },
});
expect(result).toEqual({ options: [{ id: 'inv-1' }], hasMore: false });
});
});
@@ -0,0 +1,12 @@
import type { LoadOptionsFn } from '@repo/ui/form';
import { createOptionLoader } from '../../shared/create-option-loader';
export function loadPlanDocumentOptions<T>(
getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>,
customerIds: string[],
): LoadOptionsFn<T> {
if (customerIds.length === 0) {
return async () => ({ options: [], hasMore: false });
}
return createOptionLoader<T>(getMany, { customerIds: customerIds.join(',') });
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { calendarDateToday, isCalendarDateBefore } from './plan-date';
describe('calendarDateToday', () => {
it('formats a local calendar date as YYYY-MM-DD', () => {
expect(calendarDateToday(new Date(2026, 8, 1, 22, 15))).toBe('2026-09-01');
});
});
describe('isCalendarDateBefore', () => {
it('compares ISO calendar dates lexicographically', () => {
expect(isCalendarDateBefore('2026-08-31', '2026-09-01')).toBe(true);
expect(isCalendarDateBefore('2026-09-01', '2026-09-01')).toBe(false);
expect(isCalendarDateBefore('2026-09-02', '2026-09-01')).toBe(false);
});
});
@@ -0,0 +1,10 @@
export function calendarDateToday(now = new Date()): string {
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function isCalendarDateBefore(date: string, minDate: string): boolean {
return date < minDate;
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
documentCustomerId,
documentsBelongToCustomers,
keepDocumentsForCustomers,
needsDocumentHydration,
} from './plan-documents';
describe('documentCustomerId', () => {
it('prefers customerId then nested customer.id', () => {
expect(documentCustomerId({ customerId: 'cus-1', customer: { id: 'cus-2' } })).toBe('cus-1');
expect(documentCustomerId({ customer: { id: 'cus-2' } })).toBe('cus-2');
expect(documentCustomerId({})).toBe('');
});
});
describe('documentsBelongToCustomers', () => {
it('allows stubs without a customer and rejects other customers', () => {
expect(documentsBelongToCustomers([{ id: 'inv-1' } as never, { customerId: 'cus-1' }], ['cus-1'])).toBe(true);
expect(documentsBelongToCustomers([{ customerId: 'cus-2' }], ['cus-1'])).toBe(false);
});
});
describe('keepDocumentsForCustomers', () => {
it('drops documents whose customer is not selected and keeps stubs without a customer', () => {
expect(
keepDocumentsForCustomers(
[{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-2', customerId: 'cus-2' }, { id: 'inv-3' }],
['cus-1'],
),
).toEqual([{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-3' }]);
});
});
describe('needsDocumentHydration', () => {
it('is true when only an id is present', () => {
expect(needsDocumentHydration({})).toBe(true);
expect(needsDocumentHydration({ id: 'inv-1' })).toBe(true);
});
it('is false when a customer is already present', () => {
expect(needsDocumentHydration({ customerId: 'cus-1' })).toBe(false);
});
});
@@ -0,0 +1,50 @@
export type PlanDocumentCustomer = {
id?: string | number;
code?: string | null;
name?: string;
};
export type PlanDocumentRef = {
id?: string | number;
customerId?: string | null;
customer?: PlanDocumentCustomer | null;
};
export function documentCustomerId(doc: PlanDocumentRef): string {
if (doc.customerId) return String(doc.customerId);
if (doc.customer?.id != null && doc.customer.id !== '') return String(doc.customer.id);
return '';
}
export function documentsBelongToCustomers(
documents: ReadonlyArray<PlanDocumentRef> | undefined,
customerIds: Array<string | undefined>,
): boolean {
const allowed = new Set(customerIds.filter((id): id is string => Boolean(id)));
return (documents ?? []).every((doc) => {
const customerId = documentCustomerId(doc);
return !customerId || allowed.has(customerId);
});
}
export function keepDocumentsForCustomers<T extends PlanDocumentRef>(
documents: T[] | undefined,
customerIds: Array<string | undefined>,
): T[] {
const allowed = new Set(customerIds.filter((id): id is string => Boolean(id)));
return (documents ?? []).filter((doc) => {
const customerId = documentCustomerId(doc);
return !customerId || allowed.has(customerId);
});
}
export function needsDocumentHydration(doc: PlanDocumentRef): boolean {
return !documentCustomerId(doc);
}
export function documentIdsKey(documents: Array<{ id?: string | number }> | undefined): string {
return (documents ?? [])
.map((item) => (item.id == null ? '' : String(item.id)))
.filter(Boolean)
.join(',');
}
@@ -29,6 +29,39 @@ describe('PlansRemoteDataTransformer', () => {
expect(entity.customers).toEqual([{ id: 'cus-1' }]);
});
it('maps nested destination.customer onto customers for the edit form', () => {
const entity = salesTransformer.transformToEntity({
id: 'plan-1',
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
purpose: 'sales',
date: Date.UTC(2026, 0, 12),
startBranch: { id: 'br-1', code: 'BDG', name: 'Bandung' },
endBranch: { id: 'br-2', code: 'BDG', name: 'Bandung' },
destinations: [
{
id: 'd-1',
customer: { id: 'cus-1', code: 'C1', name: 'Acme Corp' },
sortOrder: 0,
},
],
invoices: [{ id: 'inv-1', code: 'SI-001' }],
packingSlips: [],
status: 'active',
} as any);
expect(entity.customers).toEqual([{ id: 'cus-1', code: 'C1', name: 'Acme Corp' }]);
expect(entity.destinations).toEqual([
{
id: 'd-1',
customerId: 'cus-1',
customer: { id: 'cus-1', code: 'C1', name: 'Acme Corp' },
sortOrder: 0,
},
]);
expect(entity.invoices).toEqual([{ id: 'inv-1', code: 'SI-001' }]);
expect(entity.invoiceIds).toEqual(['inv-1']);
});
it('injects purpose and sales invoice attachments on create', () => {
const payload = salesTransformer.transformCreatePayload({
employee: { id: 'emp-1' },
@@ -63,4 +96,18 @@ describe('PlansRemoteDataTransformer', () => {
expect(payload.packingSlipIds).toEqual(['ps-1']);
expect(payload).not.toHaveProperty('invoiceIds');
});
it('does not resurrect invoices when the form list is emptied', () => {
const payload = salesTransformer.transformEditPayload({
employee: { id: 'emp-1' },
date: '2026-01-12',
startBranch: { id: 'br-1' },
endBranch: { id: 'br-2' },
customers: [{ id: 'cus-1' }],
invoices: [],
invoiceIds: ['inv-1'],
} as any);
expect(payload.invoiceIds).toEqual([]);
});
});
@@ -2,7 +2,8 @@ import { BaseDataTransformer } from '@repo/core-api/data-services';
import { formatDateValue, parseDateValue } from '@repo/ui/form';
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
import type { PlanEntity } from '../entities';
import type { RelationRef } from '../../../../../../../core/domain/relation-ref';
import type { PlanDestinationEntity, PlanEntity } from '../entities';
function relationId(value: unknown): string | undefined {
if (value && typeof value === 'object' && 'id' in value) {
@@ -17,12 +18,55 @@ function relationIds(value: unknown): string[] {
return value.map((item) => relationId(item)).filter((id): id is string => Boolean(id));
}
function relationRef(value: unknown): RelationRef | undefined {
const id = relationId(value);
if (!id || !value || typeof value !== 'object') return undefined;
const row = value as { code?: unknown; name?: unknown };
return {
id,
...(typeof row.code === 'string' ? { code: row.code } : {}),
...(typeof row.name === 'string' ? { name: row.name } : {}),
};
}
function customerFromDestination(destination: unknown): RelationRef | undefined {
if (!destination || typeof destination !== 'object') return undefined;
const row = destination as { customer?: unknown; customerId?: unknown };
if (row.customerId != null && row.customerId !== '') {
return relationRef(row.customer) ?? { id: String(row.customerId) };
}
return relationRef(row.customer);
}
function mapDestinations(destinations: unknown): PlanDestinationEntity[] {
if (!Array.isArray(destinations)) return [];
return destinations.map((destination, index) => {
const row = destination && typeof destination === 'object' ? (destination as Record<string, unknown>) : {};
const customer = customerFromDestination(destination);
return {
id: typeof row.id === 'string' ? row.id : undefined,
customerId: customer?.id ?? '',
customer: customer ?? null,
sortOrder: typeof row.sortOrder === 'number' ? row.sortOrder : index,
};
});
}
export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity> {
constructor(private readonly purpose: FieldPurpose) {
super();
}
transformToEntity(dto: PlanEntity): PlanEntity {
const destinations = mapDestinations(dto.destinations);
const customers =
Array.isArray(dto.customers) && dto.customers.length > 0
? dto.customers
: destinations
.map((destination) => destination.customer)
.filter((customer): customer is RelationRef => Boolean(customer?.id));
const invoices = dto.invoices ?? (dto.invoiceIds ?? []).map((id) => ({ id }));
const packingSlips = dto.packingSlips ?? (dto.packingSlipIds ?? []).map((id) => ({ id }));
return {
id: dto.id,
employeeId: dto.employeeId,
@@ -34,12 +78,12 @@ export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity>
endBranchId: dto.endBranchId,
endBranch: dto.endBranch ?? (dto.endBranchId ? { id: dto.endBranchId } : null),
routeGeometry: dto.routeGeometry ?? null,
destinations: dto.destinations ?? [],
customers: dto.customers ?? (dto.destinations ?? []).map((destination) => ({ id: destination.customerId })),
invoiceIds: dto.invoiceIds ?? [],
invoices: dto.invoices ?? (dto.invoiceIds ?? []).map((id) => ({ id })),
packingSlipIds: dto.packingSlipIds ?? [],
packingSlips: dto.packingSlips ?? (dto.packingSlipIds ?? []).map((id) => ({ id })),
destinations,
customers,
invoiceIds: dto.invoiceIds ?? relationIds(invoices),
invoices,
packingSlipIds: dto.packingSlipIds ?? relationIds(packingSlips),
packingSlips,
status: dto.status,
createdAt: dto.createdAt,
updatedAt: dto.updatedAt,
@@ -63,9 +107,9 @@ export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity>
customerIds,
};
if (this.purpose === 'sales') {
payload.invoiceIds = relationIds(entity.invoices).length ? relationIds(entity.invoices) : entity.invoiceIds;
payload.invoiceIds = Array.isArray(entity.invoices) ? relationIds(entity.invoices) : entity.invoiceIds;
} else {
payload.packingSlipIds = relationIds(entity.packingSlips).length
payload.packingSlipIds = Array.isArray(entity.packingSlips)
? relationIds(entity.packingSlips)
: entity.packingSlipIds;
}
@@ -1,12 +1,12 @@
import { describe, expect, it } from 'vitest';
import { createPlanSchema } from './plan.validator';
import { createGeneratePlansSchema, createPlanSchema } from './plan.validator';
describe('createPlanSchema', () => {
const t = (key: string) => key;
const schema = createPlanSchema(t);
const schema = createPlanSchema(t, { today: '2026-09-01' });
const valid = {
employee: { id: 'emp-1' },
date: '2026-01-12',
date: '2026-09-01',
startBranch: { id: 'br-1' },
endBranch: { id: 'br-2' },
customers: [{ id: 'cus-1' }],
@@ -20,10 +20,37 @@ describe('createPlanSchema', () => {
expect(schema.safeParse({ ...valid, date: '' }).success).toBe(false);
});
it('rejects a date before today', () => {
expect(schema.safeParse({ ...valid, date: '2026-08-31' }).success).toBe(false);
});
it('allows a past date when editing an existing plan', () => {
const editSchema = createPlanSchema(t, { today: '2026-09-01', allowPast: true });
expect(editSchema.safeParse({ ...valid, date: '2026-08-31' }).success).toBe(true);
});
it('rejects empty customers', () => {
expect(schema.safeParse({ ...valid, customers: [] }).success).toBe(false);
});
it('rejects invoices that belong to other customers', () => {
expect(
schema.safeParse({
...valid,
invoices: [{ id: 'inv-1', customerId: 'cus-2' }],
}).success,
).toBe(false);
});
it('keeps invoices for selected customers', () => {
expect(
schema.safeParse({
...valid,
invoices: [{ id: 'inv-1', customerId: 'cus-1', customer: { id: 'cus-1' } }],
}).success,
).toBe(true);
});
it('keeps customer location fields for the form preview', () => {
const result = schema.safeParse({
...valid,
@@ -38,3 +65,17 @@ describe('createPlanSchema', () => {
});
});
});
describe('createGeneratePlansSchema', () => {
const t = (key: string) => key;
const schema = createGeneratePlansSchema(t, { today: '2026-09-01' });
const valid = { employee: { id: 'emp-1' }, from: '2026-09-01', to: '2026-09-08' };
it('accepts a range starting today', () => {
expect(schema.safeParse(valid).success).toBe(true);
});
it('rejects a from date before today', () => {
expect(schema.safeParse({ ...valid, from: '2026-08-31' }).success).toBe(false);
});
});
@@ -1,4 +1,6 @@
import { z } from 'zod';
import { calendarDateToday, isCalendarDateBefore } from '../plan-date';
import { documentsBelongToCustomers, type PlanDocumentRef } from '../plan-documents';
const relationSchema = z
.object({
@@ -8,7 +10,21 @@ const relationSchema = z
})
.passthrough();
export const createPlanSchema = (t: (key: string) => string) => {
export type PlanSchemaOptions = {
today?: string;
allowPast?: boolean;
};
function requiredIssue(t: (key: string) => string, fieldKey: string) {
return JSON.stringify({ key: 'validation:required', values: { field: t(fieldKey) } });
}
function notPastIssue(t: (key: string) => string, fieldKey: string) {
return JSON.stringify({ key: 'validation:not_past', values: { field: t(fieldKey) } });
}
export const createPlanSchema = (t: (key: string) => string, options?: PlanSchemaOptions) => {
const today = options?.today ?? calendarDateToday();
return z
.object({
employee: relationSchema.nullable().optional(),
@@ -24,34 +40,63 @@ export const createPlanSchema = (t: (key: string) => string) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['employee'],
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.employee') } }),
message: requiredIssue(t, 'common:fields.employee'),
});
}
if (value.date && isCalendarDateBefore(value.date, today) && !options?.allowPast) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['date'],
message: notPastIssue(t, 'common:fields.date'),
});
}
if (!value.startBranch?.id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['startBranch'],
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.startBranch') } }),
message: requiredIssue(t, 'common:fields.startBranch'),
});
}
if (!value.endBranch?.id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['endBranch'],
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.endBranch') } }),
message: requiredIssue(t, 'common:fields.endBranch'),
});
}
if (!value.customers?.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['customers'],
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.customers') } }),
message: requiredIssue(t, 'common:fields.customers'),
});
}
const customerIds = (value.customers ?? []).map((customer) => customer.id);
if (!documentsBelongToCustomers(value.invoices as PlanDocumentRef[] | undefined, customerIds)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['invoices'],
message: JSON.stringify({
key: 'validation:not_in_selection',
values: { field: t('common:fields.invoices') },
}),
});
}
if (!documentsBelongToCustomers(value.packingSlips as PlanDocumentRef[] | undefined, customerIds)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['packingSlips'],
message: JSON.stringify({
key: 'validation:not_in_selection',
values: { field: t('common:fields.packingSlips') },
}),
});
}
});
};
export const createGeneratePlansSchema = (t: (key: string) => string) => {
export const createGeneratePlansSchema = (t: (key: string) => string, options?: PlanSchemaOptions) => {
const today = options?.today ?? calendarDateToday();
return z
.object({
employee: relationSchema.nullable().optional(),
@@ -63,7 +108,21 @@ export const createGeneratePlansSchema = (t: (key: string) => string) => {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['employee'],
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.employee') } }),
message: requiredIssue(t, 'common:fields.employee'),
});
}
if (value.from && isCalendarDateBefore(value.from, today)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['from'],
message: notPastIssue(t, 'common:fields.from'),
});
}
if (value.to && isCalendarDateBefore(value.to, today)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['to'],
message: notPastIssue(t, 'common:fields.to'),
});
}
});
@@ -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>
);
}
@@ -18,7 +18,7 @@ import { relationLabel } from '../../../../shared/relation-label';
import type { SalesInvoiceEntity } from '../../../../../sales/invoices/domain/entities';
import type { PackingSlipEntity } from '../../../../packing-slips/domain/entities';
import type { SalesDocumentEntity, SalesLineEntity } from '../../../../../sales/shared/sales-document.entity';
import { salesLinesTotal, unwrapEntity } from './plan-form-preview';
import { salesLinesTotal, unwrapEntity, groupDocumentsByCustomer } from './plan-form-preview';
import { useHydratedRecords } from './use-hydrated-records';
type PreviewDocument = SalesDocumentEntity & {
@@ -35,12 +35,21 @@ 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();
const { records: hydratedInvoices, pending: invoicesPending } = useHydratedRecords(
@@ -53,6 +62,7 @@ export function FormDocumentsPreview({
);
const hydrated = kind === 'invoice' ? hydratedInvoices : hydratedSlips;
const pending = kind === 'invoice' ? invoicesPending : slipsPending;
const groups = groupDocumentsByCustomer(hydrated, customers);
if (hydrated.length === 0) return null;
return (
@@ -60,14 +70,23 @@ export function FormDocumentsPreview({
<Text fw={600} mb="md">
{kind === 'invoice' ? t('section_preview_invoices') : t('section_preview_packing_slips')}
</Text>
<Stack gap="lg">
{hydrated.map((document, index) => (
<DocumentPreviewCard
key={String(document.id ?? index)}
document={document}
showBalance={kind === 'invoice'}
pending={pending}
/>
<Stack gap="xl">
{groups.map((group) => (
<Box key={group.customerId || 'unassigned'}>
<Text fw={600} mb="md">
{group.label || t('unassigned_customer')}
</Text>
<Stack gap="lg">
{group.documents.map((document, index) => (
<DocumentPreviewCard
key={String(document.id ?? index)}
document={document}
showBalance={kind === 'invoice'}
pending={pending}
/>
))}
</Stack>
</Box>
))}
</Stack>
</Paper>
@@ -1,14 +1,15 @@
import { useEffect, useMemo } from 'react';
import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components';
import {
useEnterpriseModuleTranslationContext,
useFormPageContext,
useEnterpriseModuleConfigContext,
} from '@repo/ui/foundations';
import { parseDateValue } from '@repo/ui/form';
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
import { loadBranchOptions } from '../../../../shared/load-branch-options';
import { loadCustomerOptions } from '../../../../shared/load-customer-options';
import { createOptionLoader } from '../../../../shared/create-option-loader';
import { relationLabel } from '../../../../shared/relation-label';
import { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories';
import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories';
@@ -17,14 +18,26 @@ import type { BranchEntity } from '../../../../../configuration/branches/domain/
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
import type { SalesInvoiceEntity } from '../../../../../sales/invoices/domain/entities';
import type { PackingSlipEntity } from '../../../../packing-slips/domain/entities';
import { documentOptionLabel } from './plan-form-preview';
import { calendarDateToday } from '../../../domain/plan-date';
import {
documentCustomerId,
documentIdsKey,
keepDocumentsForCustomers,
needsDocumentHydration,
} from '../../../domain/plan-documents';
import { loadPlanDocumentOptions } from '../../../domain/load-plan-document-options';
import { documentOptionLabel, unwrapEntity } from './plan-form-preview';
import { FormCustomersPreview } from './form-customers-preview';
import { FormDocumentsPreview } from './form-documents-preview';
import { useHydratedRecords } from './use-hydrated-records';
const loadInvoiceOptions = createOptionLoader<SalesInvoiceEntity>((config) =>
salesInvoicesDataService.getMany(config),
);
const loadSlipOptions = createOptionLoader<PackingSlipEntity>((config) => packingSlipsModuleDataService.getMany(config));
async function fetchInvoice(id: string): Promise<SalesInvoiceEntity | null> {
return unwrapEntity<SalesInvoiceEntity>(await salesInvoicesDataService.getOne(id));
}
async function fetchPackingSlip(id: string): Promise<PackingSlipEntity | null> {
return unwrapEntity<PackingSlipEntity>(await packingSlipsModuleDataService.getOne(id));
}
export function FormGeneral() {
const { formControl } = useFormPageContext();
@@ -37,6 +50,41 @@ export function FormGeneral() {
const customers = (formControl.watch('customers') ?? []) as CustomerEntity[];
const invoices = (formControl.watch('invoices') ?? []) as SalesInvoiceEntity[];
const packingSlips = (formControl.watch('packingSlips') ?? []) as PackingSlipEntity[];
const customerIds = useMemo(
() => customers.map((customer) => String(customer.id ?? '')).filter(Boolean),
[customers],
);
const customerIdsKey = customerIds.join(',');
const minDate = parseDateValue(calendarDateToday());
const loadInvoiceOptions = useMemo(
() =>
loadPlanDocumentOptions<SalesInvoiceEntity>((config) => salesInvoicesDataService.getMany(config), customerIds),
[customerIdsKey],
);
const loadSlipOptions = useMemo(
() =>
loadPlanDocumentOptions<PackingSlipEntity>(
(config) => packingSlipsModuleDataService.getMany(config),
customerIds,
),
[customerIdsKey],
);
const { records: hydratedInvoices } = useHydratedRecords(invoices, fetchInvoice, needsDocumentHydration);
const { records: hydratedSlips } = useHydratedRecords(packingSlips, fetchPackingSlip, needsDocumentHydration);
const invoiceHydrationKey = hydratedInvoices.map((item) => `${item.id ?? ''}:${documentCustomerId(item)}`).join(',');
const slipHydrationKey = hydratedSlips.map((item) => `${item.id ?? ''}:${documentCustomerId(item)}`).join(',');
const documentsDisabled = customerIds.length === 0;
useEffect(() => {
const nextInvoices = keepDocumentsForCustomers(hydratedInvoices, customerIds);
if (documentIdsKey(nextInvoices) !== documentIdsKey(invoices)) {
formControl.setValue('invoices', nextInvoices, { shouldDirty: true, shouldValidate: true });
}
const nextSlips = keepDocumentsForCustomers(hydratedSlips, customerIds);
if (documentIdsKey(nextSlips) !== documentIdsKey(packingSlips)) {
formControl.setValue('packingSlips', nextSlips, { shouldDirty: true, shouldValidate: true });
}
}, [customerIdsKey, invoiceHydrationKey, slipHydrationKey]);
return (
<Stack gap="md">
@@ -58,7 +106,13 @@ export function FormGeneral() {
defaultOptions={employee ? [employee] : []}
renderLabel={relationLabel}
/>
<FieldDatePicker control={formControl.control} name="date" label={t('common:fields.date')} required />
<FieldDatePicker
control={formControl.control}
name="date"
label={t('common:fields.date')}
required
minDate={minDate ?? undefined}
/>
<FieldAsyncSelect<BranchEntity>
control={formControl.control}
name="startBranch"
@@ -102,6 +156,7 @@ export function FormGeneral() {
<Box mt="md">
{purpose === 'sales' ? (
<FieldAsyncSelect<SalesInvoiceEntity>
key={`invoices-${customerIdsKey}`}
control={formControl.control}
name="invoices"
label={t('common:fields.invoices')}
@@ -109,12 +164,15 @@ export function FormGeneral() {
labelKey="code"
searchable
multiple
disabled={documentsDisabled}
description={documentsDisabled ? t('select_customers_first') : undefined}
loadOptions={loadInvoiceOptions}
defaultOptions={invoices}
renderLabel={documentOptionLabel}
/>
) : (
<FieldAsyncSelect<PackingSlipEntity>
key={`packing-slips-${customerIdsKey}`}
control={formControl.control}
name="packingSlips"
label={t('common:fields.packingSlips')}
@@ -122,6 +180,8 @@ export function FormGeneral() {
labelKey="code"
searchable
multiple
disabled={documentsDisabled}
description={documentsDisabled ? t('select_customers_first') : undefined}
loadOptions={loadSlipOptions}
defaultOptions={packingSlips}
renderLabel={documentOptionLabel}
@@ -133,9 +193,9 @@ export function FormGeneral() {
<FormCustomersPreview customers={customers} startBranch={startBranch} endBranch={endBranch} />
{purpose === 'sales' ? (
<FormDocumentsPreview kind="invoice" items={invoices} />
<FormDocumentsPreview kind="invoice" items={hydratedInvoices} customers={customers} />
) : (
<FormDocumentsPreview kind="packingSlip" items={packingSlips} />
<FormDocumentsPreview kind="packingSlip" items={hydratedSlips} customers={customers} />
)}
</Stack>
);
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import {
documentCustomerId,
documentOptionLabel,
groupDocumentsByCustomer,
keepDocumentsForCustomers,
mergeHydrated,
needsCustomerHydration,
salesLinesTotal,
@@ -18,7 +21,10 @@ describe('selectionIds', () => {
describe('mergeHydrated', () => {
it('replaces selected stubs with fetched details by id', () => {
const merged = mergeHydrated(
[{ id: 'cus-1', name: 'Stub' }, { id: 'cus-2', name: 'Keep' }],
[
{ id: 'cus-1', name: 'Stub' },
{ id: 'cus-2', name: 'Keep' },
],
{ 'cus-1': { id: 'cus-1', name: 'Acme', address: 'Jl Sudirman' } },
);
expect(merged).toEqual([
@@ -106,3 +112,60 @@ describe('documentOptionLabel', () => {
).toBe('INV-1 · C1 - Acme');
});
});
describe('documentCustomerId', () => {
it('prefers customerId then nested customer.id', () => {
expect(documentCustomerId({ customerId: 'cus-1', customer: { id: 'cus-2' } })).toBe('cus-1');
expect(documentCustomerId({ customer: { id: 'cus-2' } })).toBe('cus-2');
expect(documentCustomerId({})).toBe('');
});
});
describe('keepDocumentsForCustomers', () => {
it('drops documents whose customer is not selected and keeps stubs without a customer', () => {
expect(
keepDocumentsForCustomers(
[{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-2', customerId: 'cus-2' }, { id: 'inv-3' }],
['cus-1'],
),
).toEqual([{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-3' }]);
});
});
describe('groupDocumentsByCustomer', () => {
it('groups documents in customer order and keeps unknown customers last', () => {
expect(
groupDocumentsByCustomer(
[
{ id: 'inv-2', customerId: 'cus-2', customer: { id: 'cus-2', code: 'C2', name: 'Beta' } },
{ id: 'inv-1', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
{ id: 'inv-3', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
{ id: 'inv-4' },
],
[
{ id: 'cus-1', code: 'C1', name: 'Acme' },
{ id: 'cus-2', code: 'C2', name: 'Beta' },
],
),
).toEqual([
{
customerId: 'cus-1',
label: 'C1 - Acme',
documents: [
{ id: 'inv-1', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
{ id: 'inv-3', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
],
},
{
customerId: 'cus-2',
label: 'C2 - Beta',
documents: [{ id: 'inv-2', customerId: 'cus-2', customer: { id: 'cus-2', code: 'C2', name: 'Beta' } }],
},
{
customerId: '',
label: '',
documents: [{ id: 'inv-4' }],
},
]);
});
});
@@ -1,15 +1,16 @@
import type { RouteGeometry } from '../../../../cycles/domain/entities';
import { documentCustomerId, type PlanDocumentRef } from '../../../domain/plan-documents';
import { relationLabel } from '../../../../shared/relation-label';
export { documentCustomerId, keepDocumentsForCustomers } from '../../../domain/plan-documents';
export type GeoPoint = {
latitude?: number | null;
longitude?: number | null;
};
export function selectionIds(selected: Array<{ id?: string | number }> | undefined): string[] {
return (selected ?? [])
.map((item) => (item.id == null ? '' : String(item.id)))
.filter(Boolean);
return (selected ?? []).map((item) => (item.id == null ? '' : String(item.id))).filter(Boolean);
}
export function mergeHydrated<T extends { id?: string | number }>(selected: T[], details: Record<string, T>): T[] {
@@ -80,3 +81,41 @@ export function documentOptionLabel(item: {
if (base && customer) return `${base} · ${customer}`;
return base || customer;
}
export function groupDocumentsByCustomer<T extends PlanDocumentRef>(
documents: T[],
customers: Array<{ id?: string | number; code?: string | null; name?: string }> = [],
): Array<{ customerId: string; label: string; documents: T[] }> {
const buckets = new Map<string, T[]>();
for (const document of documents) {
const customerId = documentCustomerId(document);
buckets.set(customerId, [...(buckets.get(customerId) ?? []), document]);
}
const groups: Array<{ customerId: string; label: string; documents: T[] }> = [];
const seen = new Set<string>();
for (const customer of customers) {
const customerId = customer.id == null ? '' : String(customer.id);
if (!customerId || seen.has(customerId)) continue;
const grouped = buckets.get(customerId);
if (!grouped?.length) continue;
seen.add(customerId);
groups.push({
customerId,
label: relationLabel(customer) || customerId,
documents: grouped,
});
}
for (const [customerId, grouped] of buckets) {
if (seen.has(customerId) || grouped.length === 0) continue;
groups.push({
customerId,
label: relationLabel(grouped[0]?.customer) || customerId,
documents: grouped,
});
}
return groups;
}
@@ -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 };
@@ -7,9 +7,11 @@ import {
useEnterpriseModuleDataServiceContext,
useEnterpriseModuleTranslationContext,
} from '@repo/ui/foundations';
import { parseDateValue } from '@repo/ui/form';
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
import { relationLabel } from '../../../../shared/relation-label';
import { calendarDateToday } from '../../../domain/plan-date';
import { createGeneratePlansSchema } from '../../../domain/validators/plan.validator';
import type { PlansRemoteDataServices } from '../../../data/plan.remote.service';
import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
@@ -22,6 +24,7 @@ export function GeneratePlansModal({ opened, onClose }: { opened: boolean; onClo
const { dataServices } = useEnterpriseModuleDataServiceContext<PlanEntity, PlansRemoteDataServices>();
const validator = useMemo(() => createGeneratePlansSchema(t), [t]);
const form = useForm({ resolver: zodResolver(validator) });
const minDate = parseDateValue(calendarDateToday());
const handleSubmit = form.handleSubmit(async (values) => {
const result = await dataServices.generate({
@@ -55,8 +58,20 @@ export function GeneratePlansModal({ opened, onClose }: { opened: boolean; onClo
loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
renderLabel={relationLabel}
/>
<FieldDatePicker control={form.control as any} name="from" label={t('common:fields.from')} required />
<FieldDatePicker control={form.control as any} name="to" label={t('common:fields.to')} required />
<FieldDatePicker
control={form.control as any}
name="from"
label={t('common:fields.from')}
required
minDate={minDate ?? undefined}
/>
<FieldDatePicker
control={form.control as any}
name="to"
label={t('common:fields.to')}
required
minDate={minDate ?? undefined}
/>
<Group justify="flex-end">
<Button variant="default" type="button" onClick={onClose}>
{t('common:cancel')}
@@ -25,6 +25,8 @@
"empty_route": "No route geometry",
"empty_products": "No products on this document",
"preview_loading": "Loading document details…",
"select_customers_first": "Select customers first",
"unassigned_customer": "Unassigned",
"purpose_sales": "Sales",
"purpose_logistics": "Logistics",
"status_draft": "Draft",
@@ -25,6 +25,8 @@
"empty_route": "Tidak ada geometri rute",
"empty_products": "Tidak ada produk pada dokumen ini",
"preview_loading": "Memuat detail dokumen…",
"select_customers_first": "Pilih pelanggan terlebih dahulu",
"unassigned_customer": "Belum ditetapkan",
"purpose_sales": "Penjualan",
"purpose_logistics": "Logistik",
"status_draft": "Draft",
@@ -31,7 +31,7 @@ export default function PlanPageForm({ formPageType }: { formPageType: FormPageT
return { title: '', description: '' };
}, [formPageType, t]);
const validator = useMemo(() => createPlanSchema(t), [t]);
const validator = useMemo(() => createPlanSchema(t, { allowPast: formPageType === 'EDIT' }), [t, formPageType]);
const formControl = useForm({ resolver: zodResolver(validator) });
return (
@@ -5,12 +5,12 @@ import type { LookupEntity } from './lookup.entity';
export const salesInvoicesDataService = new LookupRemoteDataServices(apiClient, {
apiUrl: '/sales-invoices',
moduleKey: 'SALES.INVOICE',
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
});
export const packingSlipsDataService = new LookupRemoteDataServices(apiClient, {
apiUrl: '/packing-slips',
moduleKey: 'SALES.PACKING_SLIP',
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
});
export const loadSalesInvoiceOptions = createOptionLoader<LookupEntity>((config) =>
@@ -0,0 +1,18 @@
import type { AxiosInstance } from '@repo/core-api/http-client';
import type { TimelineDayEntity } from '../domain/entities/timeline.entity';
export type TimelineQuery = {
date?: string;
employeeId?: string;
};
export class TimelineRemoteService {
constructor(private readonly client: AxiosInstance) {}
async getDay(query: TimelineQuery = {}): Promise<TimelineDayEntity> {
const { data } = await this.client.get<TimelineDayEntity>('/timeline', {
params: query,
});
return data;
}
}
@@ -0,0 +1,13 @@
import type { BaseEntity } from '@repo/core-api/data-services';
import type { ModuleConfigEntity } from '@repo/ui/foundations';
export type TimelineShellEntity = BaseEntity & { id: string };
export const salesTimelineModuleConfig: ModuleConfigEntity<TimelineShellEntity> = {
moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
translationNamespace: 'SALES_TIMELINE',
apiUrl: '/timeline',
webUrl: '/app/timeline',
moduleCategory: 'SINGLE_PAGE',
moduleType: 'TRANSACTION',
} as const;
@@ -0,0 +1,32 @@
export type TimelineRelation = {
id: string;
code: string;
name: string;
};
export type TimelineFootprintEntity = {
id: string;
employee: TimelineRelation;
latitude: number;
longitude: number;
recordedAt: number;
};
export type TimelineActivityEntity = {
id: string;
employee: TimelineRelation;
customer: TimelineRelation | null;
visitId: string | null;
type: string;
sourceType: string;
sourceId: string;
latitude: number;
longitude: number;
recordedAt: number;
};
export type TimelineDayEntity = {
date: string;
footprints: TimelineFootprintEntity[];
activities: TimelineActivityEntity[];
};
@@ -0,0 +1,23 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { apiClient } from '../../../../../../../core/lib/api-client';
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
import { salesTimelineModuleConfig, type TimelineShellEntity } from '../constants/timeline.constants';
import { TimelineRemoteService } from '../../data/timeline.remote.service';
class TimelineShellTransformer extends BaseDataTransformer<TimelineShellEntity> {
transformToEntity(dto: TimelineShellEntity): TimelineShellEntity {
return dto;
}
transformToDTO(entity: TimelineShellEntity): TimelineShellEntity {
return entity;
}
}
export const salesTimelineDataService = new TrackGoRemoteDataServices(apiClient, {
apiUrl: salesTimelineModuleConfig.apiUrl,
moduleKey: salesTimelineModuleConfig.moduleKey,
transformer: new TimelineShellTransformer(),
});
export const timelineRemoteService = new TimelineRemoteService(apiClient);
@@ -0,0 +1,102 @@
import { Avatar, Badge, Box, Card, Group, Stack, Text, Timeline, UnstyledButton } from '@repo/ui/components';
import { formatClock, type TimelineActivityGroup } from './timeline-helpers';
function initials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('');
}
export function TimelineActivityList({
groups,
selectedKey,
onSelect,
activityLabel,
emptyLabel,
ungroupedLabel,
}: {
groups: TimelineActivityGroup[];
selectedKey: string | null;
onSelect: (key: string) => void;
activityLabel: (type: string) => string;
emptyLabel: string;
ungroupedLabel: string;
}) {
if (groups.length === 0) {
return (
<Text size="sm" c="dimmed" py="md">
{emptyLabel}
</Text>
);
}
return (
<Stack gap="sm">
{groups.map((group) => {
const selected = group.key === selectedKey;
const title = group.key === 'ungrouped' ? ungroupedLabel : group.title;
const first = group.activities[0];
const last = group.activities[group.activities.length - 1];
return (
<Card
key={group.key}
withBorder
padding="md"
radius="md"
shadow={selected ? 'sm' : undefined}
style={{
borderColor: selected ? 'var(--mantine-color-blue-filled)' : 'var(--mantine-color-default-border)',
borderWidth: selected ? 2 : 1,
}}
>
<UnstyledButton w="100%" onClick={() => onSelect(group.key)}>
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
<Stack gap={4} style={{ minWidth: 0 }}>
<Text fw={600} lineClamp={1}>
{title}
</Text>
<Text size="sm" c="dimmed">
{formatClock(group.firstRecordedAt)}
{first && last && first.id !== last.id ? ` → ${formatClock(group.lastRecordedAt)}` : ''}
</Text>
</Stack>
<Badge variant="light" color={group.isOnTheWay ? 'blue' : 'teal'} tt="none">
{activityLabel(group.lastType)}
</Badge>
</Group>
</UnstyledButton>
{selected ? (
<Stack gap="sm" mt="md">
<Group gap="sm">
<Avatar radius="xl" size="md" color="blue">
{initials(group.employeeName)}
</Avatar>
<Text size="sm" fw={500}>
{group.employeeName}
</Text>
</Group>
<Box bg="var(--mantine-color-blue-light)" p="sm" bdrs="md">
<Timeline active={group.activities.length - 1} bulletSize={12} lineWidth={2} color="blue">
{group.activities.map((item) => (
<Timeline.Item key={item.id} title={activityLabel(item.type)}>
<Text size="xs" c="dimmed">
{formatClock(item.recordedAt)}
</Text>
</Timeline.Item>
))}
</Timeline>
</Box>
</Stack>
) : null}
</Card>
);
})}
</Stack>
);
}
@@ -0,0 +1,89 @@
import type { ReactNode } from 'react';
import { Paper, ScrollArea, SegmentedControl, Stack, Text, TextInput } from '@repo/ui/components';
import { Search } from 'lucide-react';
import { TimelineActivityList } from './timeline-activity-list';
import type { TimelineActivityGroup, TimelineActivityTab } from './timeline-helpers';
export function TimelineActivityPanel({
title,
search,
searchPlaceholder,
onSearchChange,
tab,
onTabChange,
onTheWayLabel,
completedLabel,
filters,
groups,
selectedKey,
onSelect,
activityLabel,
emptyLabel,
ungroupedLabel,
errorMessage,
}: {
title: string;
search: string;
searchPlaceholder: string;
onSearchChange: (value: string) => void;
tab: TimelineActivityTab;
onTabChange: (value: TimelineActivityTab) => void;
onTheWayLabel: string;
completedLabel: string;
filters: ReactNode;
groups: TimelineActivityGroup[];
selectedKey: string | null;
onSelect: (key: string) => void;
activityLabel: (type: string) => string;
emptyLabel: string;
ungroupedLabel: string;
errorMessage: string | null;
}) {
return (
<Paper
withBorder
shadow="md"
radius="lg"
p="md"
h="100%"
style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}
>
<Stack gap="md" style={{ flex: 1, minHeight: 0 }}>
<Text fw={700} size="xl">
{title}
</Text>
<TextInput
value={search}
onChange={(event) => onSearchChange(event.currentTarget.value)}
placeholder={searchPlaceholder}
leftSection={<Search size={16} />}
/>
{filters}
<SegmentedControl
fullWidth
value={tab}
onChange={(value) => onTabChange(value as TimelineActivityTab)}
data={[
{ label: onTheWayLabel, value: 'on_the_way' },
{ label: completedLabel, value: 'completed' },
]}
/>
{errorMessage ? (
<Text c="red" size="sm">
{errorMessage}
</Text>
) : null}
<ScrollArea flex={1} type="scroll" offsetScrollbars>
<TimelineActivityList
groups={groups}
selectedKey={selectedKey}
onSelect={onSelect}
activityLabel={activityLabel}
emptyLabel={emptyLabel}
ungroupedLabel={ungroupedLabel}
/>
</ScrollArea>
</Stack>
</Paper>
);
}
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import type { TimelineActivityEntity, TimelineFootprintEntity } from '../../domain/entities/timeline.entity';
import {
advancePlaybackTime,
filterActivityGroups,
filterTimelineByPlayback,
groupActivities,
playbackStep,
resolvePlaybackBounds,
resolvePlaybackPositions,
shouldRestartPlayback,
} from './timeline-helpers';
const footprints: TimelineFootprintEntity[] = [
{
id: 'fp-1',
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
latitude: -6.2,
longitude: 106.8,
recordedAt: 1000,
},
{
id: 'fp-2',
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
latitude: -6.21,
longitude: 106.81,
recordedAt: 2000,
},
];
function activity(overrides: Partial<TimelineActivityEntity>): TimelineActivityEntity {
return {
id: 'act-1',
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
customer: { id: 'cus-1', code: 'C1', name: 'Toko Maju' },
visitId: 'visit-1',
type: 'customer_check_in',
sourceType: 'visit',
sourceId: 'visit-1',
latitude: -6.2,
longitude: 106.8,
recordedAt: 1000,
...overrides,
};
}
describe('timeline helpers', () => {
it('resolves playback bounds from footprints', () => {
expect(resolvePlaybackBounds(footprints)).toEqual({ min: 1000, max: 2000 });
});
it('filters timeline items by playback time', () => {
expect(filterTimelineByPlayback(footprints, 1500)).toHaveLength(1);
});
it('resolves latest playback positions per employee', () => {
expect(resolvePlaybackPositions(footprints, 2000)).toEqual([
{
employeeId: 'emp-1',
latitude: -6.21,
longitude: 106.81,
label: 'Ada',
},
]);
});
it('groups visit activities and marks open visits as on the way', () => {
const groups = groupActivities([
activity({ id: 'in', type: 'customer_check_in', recordedAt: 1000 }),
activity({ id: 'order', type: 'sales_order_created', recordedAt: 1500 }),
]);
expect(groups).toHaveLength(1);
expect(groups[0]).toMatchObject({
key: 'visit-1',
title: 'Toko Maju',
employeeName: 'Ada',
lastType: 'sales_order_created',
isVisit: true,
isOnTheWay: true,
});
expect(groups[0].activities.map((item) => item.id)).toEqual(['in', 'order']);
});
it('marks a visit completed after check-out', () => {
const groups = groupActivities([
activity({ id: 'in', type: 'customer_check_in', recordedAt: 1000 }),
activity({ id: 'out', type: 'customer_check_out', recordedAt: 2000 }),
]);
expect(groups[0]?.isOnTheWay).toBe(false);
});
it('filters groups by tab and search query', () => {
const groups = groupActivities([
activity({ id: 'open', type: 'customer_check_in', recordedAt: 1000 }),
activity({
id: 'done',
visitId: 'visit-2',
customer: { id: 'cus-2', code: 'C2', name: 'Toko Selesai' },
type: 'customer_check_out',
recordedAt: 2000,
sourceId: 'visit-2',
}),
activity({
id: 'branch',
visitId: null,
customer: null,
type: 'branch_check_in',
recordedAt: 500,
}),
]);
expect(filterActivityGroups(groups, { tab: 'on_the_way', query: '' }).map((group) => group.key)).toEqual([
'visit-1',
'ungrouped',
]);
expect(filterActivityGroups(groups, { tab: 'completed', query: '' }).map((group) => group.key)).toEqual([
'visit-2',
]);
expect(filterActivityGroups(groups, { tab: 'on_the_way', query: 'maju' }).map((group) => group.title)).toEqual([
'Toko Maju',
]);
});
it('restarts playback from the start once the cursor is at the end', () => {
expect(shouldRestartPlayback(2000, { min: 1000, max: 2000 })).toBe(true);
expect(shouldRestartPlayback(1500, { min: 1000, max: 2000 })).toBe(false);
expect(advancePlaybackTime(1500, { min: 1000, max: 2000 }, 1000)).toBe(2000);
expect(playbackStep({ min: 0, max: 240_000 })).toBe(1000);
});
});
@@ -0,0 +1,152 @@
import type { TimelineActivityEntity, TimelineFootprintEntity } from '../../domain/entities/timeline.entity';
export type TimelineActivityGroup = {
key: string;
title: string;
employeeName: string;
lastType: string;
firstRecordedAt: number;
lastRecordedAt: number;
activities: TimelineActivityEntity[];
isVisit: boolean;
isOnTheWay: boolean;
};
export type TimelineActivityTab = 'on_the_way' | 'completed';
function sortByRecordedAt(activities: TimelineActivityEntity[]): TimelineActivityEntity[] {
return [...activities].sort((left, right) => left.recordedAt - right.recordedAt);
}
function isOpenVisit(types: string[]): boolean {
const hasCheckIn = types.some((type) => type.endsWith('_check_in'));
const hasCheckOut = types.some((type) => type.endsWith('_check_out'));
return hasCheckIn && !hasCheckOut;
}
export function formatClock(unixMs: number): string {
const date = new Date(unixMs);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
}
export function groupActivities(activities: TimelineActivityEntity[]): TimelineActivityGroup[] {
const grouped = new Map<string, TimelineActivityEntity[]>();
for (const activity of activities) {
const key = activity.visitId ?? 'ungrouped';
grouped.set(key, [...(grouped.get(key) ?? []), activity]);
}
return [...grouped.entries()].map(([key, items]) => {
const sorted = sortByRecordedAt(items);
const last = sorted[sorted.length - 1];
const first = sorted[0];
const title = last?.customer?.name ?? (key === 'ungrouped' ? 'ungrouped' : (last?.customer?.code ?? key));
return {
key,
title,
employeeName: last?.employee.name ?? '',
lastType: last?.type ?? '',
firstRecordedAt: first?.recordedAt ?? 0,
lastRecordedAt: last?.recordedAt ?? 0,
activities: sorted,
isVisit: key !== 'ungrouped',
isOnTheWay: isOpenVisit(sorted.map((item) => item.type)),
};
});
}
export function filterActivityGroups(
groups: TimelineActivityGroup[],
options: { tab: TimelineActivityTab; query: string },
): TimelineActivityGroup[] {
const query = options.query.trim().toLowerCase();
return groups.filter((group) => {
if (options.tab === 'on_the_way' && !group.isOnTheWay) {
return false;
}
if (options.tab === 'completed' && group.isOnTheWay) {
return false;
}
if (query.length === 0) {
return true;
}
const haystack = [group.title, group.employeeName, group.lastType, ...group.activities.map((item) => item.type)]
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}
export function resolvePlaybackPositions(
footprints: TimelineFootprintEntity[],
playbackTime: number | null,
): Array<{ employeeId: string; latitude: number; longitude: number; label: string }> {
if (playbackTime === null) {
return [];
}
const latestByEmployee = new Map<string, { latitude: number; longitude: number; label: string }>();
for (const footprint of footprints) {
if (footprint.recordedAt > playbackTime) {
continue;
}
latestByEmployee.set(footprint.employee.id, {
latitude: footprint.latitude,
longitude: footprint.longitude,
label: footprint.employee.name,
});
}
return [...latestByEmployee.entries()].map(([employeeId, position]) => ({
employeeId,
...position,
}));
}
export function resolvePlaybackBounds(footprints: TimelineFootprintEntity[]): {
min: number;
max: number;
} | null {
if (footprints.length === 0) {
return null;
}
const times = footprints.map((footprint) => footprint.recordedAt);
return {
min: Math.min(...times),
max: Math.max(...times),
};
}
export function filterTimelineByPlayback<T extends { recordedAt: number }>(
items: T[],
playbackTime: number | null,
): T[] {
if (playbackTime === null) {
return items;
}
return items.filter((item) => item.recordedAt <= playbackTime);
}
export function playbackStep(bounds: { min: number; max: number }): number {
return Math.max(1000, Math.round((bounds.max - bounds.min) / 240));
}
export function shouldRestartPlayback(
playbackTime: number | null,
bounds: { min: number; max: number } | null,
): boolean {
if (bounds === null) {
return false;
}
return playbackTime === null || playbackTime >= bounds.max;
}
export function advancePlaybackTime(current: number, bounds: { min: number; max: number }, step: number): number {
return Math.min(bounds.max, current + step);
}
@@ -0,0 +1,143 @@
import { Avatar, Badge, Box, Button, Group, Paper, SimpleGrid, Slider, Stack, Text } from '@repo/ui/components';
import { Pause, Play } from 'lucide-react';
import type { TimelineActivityGroup } from './timeline-helpers';
import { formatClock } from './timeline-helpers';
function initials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('');
}
function OverlayMetric({ label, value }: { label: string; value: string }) {
return (
<Stack gap={2}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600} lineClamp={1}>
{value}
</Text>
</Stack>
);
}
export function TimelinePlaybackOverlay({
group,
ungroupedLabel,
activityLabel,
fromLabel,
toLabel,
currentLocationLabel,
activitiesLabel,
playbackTitle,
playLabel,
pauseLabel,
timeLabel,
emptyLabel,
currentLocation,
playbackBounds,
playbackTime,
onPlaybackTimeChange,
isPlaying,
onTogglePlayback,
canPlay,
}: {
group: TimelineActivityGroup | null;
ungroupedLabel: string;
activityLabel: (type: string) => string;
fromLabel: string;
toLabel: string;
currentLocationLabel: string;
activitiesLabel: string;
playbackTitle: string;
playLabel: string;
pauseLabel: string;
timeLabel: string;
emptyLabel: string;
currentLocation: string;
playbackBounds: { min: number; max: number } | null;
playbackTime: number | null;
onPlaybackTimeChange: (value: number) => void;
isPlaying: boolean;
onTogglePlayback: () => void;
canPlay: boolean;
}) {
const title = group ? (group.key === 'ungrouped' ? ungroupedLabel : group.title) : playbackTitle;
const first = group?.activities[0];
const last = group?.activities[group.activities.length - 1];
return (
<Paper withBorder shadow="md" radius="lg" p="md">
<Group align="flex-start" justify="space-between" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 220 }}>
<Group gap="sm">
<Text fw={700}>{title}</Text>
{group ? (
<Badge variant="light" color={group.isOnTheWay ? 'blue' : 'teal'} tt="none">
{activityLabel(group.lastType)}
</Badge>
) : null}
</Group>
{group ? (
<Group gap="sm">
<Avatar radius="xl" size="sm" color="blue">
{initials(group.employeeName)}
</Avatar>
<Text size="sm">{group.employeeName}</Text>
</Group>
) : (
<Text size="sm" c="dimmed">
{emptyLabel}
</Text>
)}
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md" visibleFrom="sm">
<OverlayMetric
label={fromLabel}
value={first ? `${formatClock(first.recordedAt)} · ${activityLabel(first.type)}` : '—'}
/>
<OverlayMetric
label={toLabel}
value={last ? `${formatClock(last.recordedAt)} · ${activityLabel(last.type)}` : '—'}
/>
<OverlayMetric label={currentLocationLabel} value={currentLocation || '—'} />
<OverlayMetric label={activitiesLabel} value={group ? String(group.activities.length) : '—'} />
</SimpleGrid>
</Stack>
<Box miw={220} style={{ flex: '0 0 240px' }}>
{playbackBounds ? (
<Stack gap="sm">
<Slider
min={playbackBounds.min}
max={playbackBounds.max}
value={playbackTime ?? playbackBounds.max}
onChange={onPlaybackTimeChange}
label={(value) => formatClock(value)}
/>
<Group>
<Button
leftSection={isPlaying ? <Pause size={16} /> : <Play size={16} />}
onClick={onTogglePlayback}
disabled={!canPlay}
>
{isPlaying ? pauseLabel : playLabel}
</Button>
<Text size="sm" c="dimmed">
{playbackTime === null ? timeLabel : formatClock(playbackTime)}
</Text>
</Group>
</Stack>
) : (
<Text size="sm" c="dimmed">
{emptyLabel}
</Text>
)}
</Box>
</Group>
</Paper>
);
}
@@ -0,0 +1,33 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
import { registerModuleNamespace } from '@repo/core-i18n';
import { salesTimelineModuleConfig } from '../../domain/constants/timeline.constants';
import { salesTimelineDataService } from '../../domain/factories';
import { salesTimelineStore } from '../store';
import timelineEn from '../languages/en/timeline.json';
import timelineId from '../languages/id/timeline.json';
const IndexPage = lazy(() => import('../pages/timeline.page.index'));
registerModuleNamespace(salesTimelineModuleConfig.translationNamespace, {
en: timelineEn,
id: timelineId,
});
export default function SalesTimelineModule() {
return (
<EnterpriseModuleProvider
config={salesTimelineModuleConfig}
dataServices={salesTimelineDataService}
store={salesTimelineStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/" element={<Navigate to={`${salesTimelineModuleConfig.webUrl}/index`} replace />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,47 @@
{
"title": "Timeline",
"description": "Review field footprints and activity logs on the map.",
"search": {
"placeholder": "Search activities"
},
"tabs": {
"onTheWay": "On the way",
"completed": "Completed"
},
"filters": {
"date": "Date",
"employee": "Employee",
"allEmployees": "All employees"
},
"playback": {
"title": "Playback",
"play": "Play",
"pause": "Pause",
"time": "Time"
},
"overlay": {
"from": "From",
"to": "To",
"currentLocation": "Current location",
"activities": "Activities",
"emptyMap": "No timeline data"
},
"activities": {
"title": "Activities",
"empty": "No activities for this day.",
"ungrouped": "Other activities",
"types": {
"branch_check_in": "Branch check-in",
"branch_check_out": "Branch check-out",
"customer_check_in": "Customer check-in",
"customer_check_out": "Customer check-out",
"sales_order_created": "Sales order created",
"sales_request_created": "Sales request created",
"sales_payment_created": "Sales payment created",
"customer_created": "Customer created"
}
},
"errors": {
"loadFailed": "Could not load timeline data."
}
}
@@ -0,0 +1,47 @@
{
"title": "Timeline",
"description": "Tinjau jejak lapangan dan log aktivitas di peta.",
"search": {
"placeholder": "Cari aktivitas"
},
"tabs": {
"onTheWay": "Di perjalanan",
"completed": "Selesai"
},
"filters": {
"date": "Tanggal",
"employee": "Karyawan",
"allEmployees": "Semua karyawan"
},
"playback": {
"title": "Playback",
"play": "Putar",
"pause": "Jeda",
"time": "Waktu"
},
"overlay": {
"from": "Dari",
"to": "Ke",
"currentLocation": "Lokasi saat ini",
"activities": "Aktivitas",
"emptyMap": "Tidak ada data timeline"
},
"activities": {
"title": "Aktivitas",
"empty": "Tidak ada aktivitas untuk hari ini.",
"ungrouped": "Aktivitas lain",
"types": {
"branch_check_in": "Check-in cabang",
"branch_check_out": "Check-out cabang",
"customer_check_in": "Check-in pelanggan",
"customer_check_out": "Check-out pelanggan",
"sales_order_created": "Sales order dibuat",
"sales_request_created": "Sales request dibuat",
"sales_payment_created": "Pembayaran dibuat",
"customer_created": "Pelanggan dibuat"
}
},
"errors": {
"loadFailed": "Gagal memuat data timeline."
}
}
@@ -0,0 +1,302 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Grid } from '@repo/ui/components';
import { FieldAsyncSelect, FieldDatePicker } from '@repo/ui/form';
import { TimelineMap } from '@repo/ui/map';
import { useTranslation } from '@repo/core-i18n';
import { FormProvider, useForm, type Control, type FieldValues } from 'react-hook-form';
import { salesTimelineModuleConfig } from '../../domain/constants/timeline.constants';
import type {
TimelineActivityEntity,
TimelineDayEntity,
TimelineFootprintEntity,
} from '../../domain/entities/timeline.entity';
import { timelineRemoteService } from '../../domain/factories';
import { loadSalesEmployeeOptions } from '../../../shared/load-employee-options';
import { relationLabel } from '../../../shared/relation-label';
import type { EmployeeEntity } from '../../../../configuration/employees/domain/entities';
import { TimelineActivityPanel } from '../components/timeline-activity-panel';
import { TimelinePlaybackOverlay } from '../components/timeline-playback-overlay';
import {
advancePlaybackTime,
filterActivityGroups,
filterTimelineByPlayback,
groupActivities,
playbackStep,
resolvePlaybackBounds,
resolvePlaybackPositions,
shouldRestartPlayback,
type TimelineActivityTab,
} from '../components/timeline-helpers';
type TimelineFilterForm = {
date: string;
employee: EmployeeEntity | null;
};
const EMPTY_FOOTPRINTS: TimelineFootprintEntity[] = [];
const EMPTY_ACTIVITIES: TimelineActivityEntity[] = [];
function todayIsoDate(): string {
const now = new Date();
const year = now.getFullYear();
const month = `${now.getMonth() + 1}`.padStart(2, '0');
const day = `${now.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}
export default function TimelinePage() {
const { t } = useTranslation(salesTimelineModuleConfig.translationNamespace);
const form = useForm<TimelineFilterForm>({
defaultValues: {
date: todayIsoDate(),
employee: null,
},
});
const [data, setData] = useState<TimelineDayEntity | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [playbackTime, setPlaybackTime] = useState<number | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [search, setSearch] = useState('');
const [tab, setTab] = useState<TimelineActivityTab>('on_the_way');
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const timerRef = useRef<number | null>(null);
const watchedDate = form.watch('date');
const watchedEmployee = form.watch('employee');
const loadTimeline = useCallback(async () => {
setIsLoading(true);
setErrorMessage(null);
try {
const response = await timelineRemoteService.getDay({
date: watchedDate,
employeeId: watchedEmployee?.id ? String(watchedEmployee.id) : undefined,
});
setData(response);
const bounds = resolvePlaybackBounds(response.footprints);
setPlaybackTime(bounds?.max ?? null);
setIsPlaying(false);
const hasOpenVisit = groupActivities(response.activities).some((group) => group.isOnTheWay);
setTab(hasOpenVisit ? 'on_the_way' : 'completed');
} catch {
setErrorMessage(t('errors.loadFailed'));
setData(null);
} finally {
setIsLoading(false);
}
}, [t, watchedDate, watchedEmployee?.id]);
useEffect(() => {
void loadTimeline();
}, [loadTimeline]);
const footprints = data?.footprints ?? EMPTY_FOOTPRINTS;
const activities = data?.activities ?? EMPTY_ACTIVITIES;
const playbackBounds = useMemo(() => resolvePlaybackBounds(footprints), [footprints]);
useEffect(() => {
if (!isPlaying || playbackBounds === null) {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
return;
}
const step = playbackStep(playbackBounds);
timerRef.current = window.setInterval(() => {
setPlaybackTime((current) => {
if (current === null) {
return current;
}
return advancePlaybackTime(current, playbackBounds, step);
});
}, 250);
return () => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [isPlaying, playbackBounds]);
useEffect(() => {
if (isPlaying && playbackBounds && playbackTime !== null && playbackTime >= playbackBounds.max) {
setIsPlaying(false);
}
}, [isPlaying, playbackBounds, playbackTime]);
const togglePlayback = useCallback(() => {
if (isPlaying) {
setIsPlaying(false);
return;
}
if (shouldRestartPlayback(playbackTime, playbackBounds) && playbackBounds) {
setPlaybackTime(playbackBounds.min);
}
setIsPlaying(true);
}, [isPlaying, playbackBounds, playbackTime]);
const visibleFootprints = useMemo(
() => filterTimelineByPlayback<TimelineFootprintEntity>(footprints, playbackTime),
[footprints, playbackTime],
);
const visibleActivities = useMemo(
() => filterTimelineByPlayback<TimelineActivityEntity>(activities, playbackTime),
[activities, playbackTime],
);
const playbackPositions = useMemo(
() => resolvePlaybackPositions(footprints, playbackTime),
[footprints, playbackTime],
);
const groups = useMemo(() => groupActivities(visibleActivities), [visibleActivities]);
const filteredGroups = useMemo(() => filterActivityGroups(groups, { tab, query: search }), [groups, search, tab]);
useEffect(() => {
if (filteredGroups.length === 0) {
setSelectedKey(null);
return;
}
if (!filteredGroups.some((group) => group.key === selectedKey)) {
setSelectedKey(filteredGroups[0]?.key ?? null);
}
}, [filteredGroups, selectedKey]);
const selectedGroup = filteredGroups.find((group) => group.key === selectedKey) ?? null;
const selectedActivity = selectedGroup?.activities[selectedGroup.activities.length - 1] ?? null;
const dayPositions = useMemo(
() => [
...footprints.map((footprint): [number, number] => [footprint.latitude, footprint.longitude]),
...activities.map((activity): [number, number] => [activity.latitude, activity.longitude]),
],
[activities, footprints],
);
const focusPositions = useMemo(() => {
if (selectedGroup && selectedGroup.activities.length > 0) {
return selectedGroup.activities.map((activity): [number, number] => [activity.latitude, activity.longitude]);
}
return dayPositions;
}, [dayPositions, selectedGroup]);
const mapFootprints = useMemo(
() =>
visibleFootprints.map((footprint) => ({
employeeId: footprint.employee.id,
latitude: footprint.latitude,
longitude: footprint.longitude,
recordedAt: footprint.recordedAt,
})),
[visibleFootprints],
);
const activityLabel = useCallback((type: string) => t(`activities.types.${type}`, { defaultValue: type }), [t]);
const mapActivities = useMemo(
() =>
visibleActivities.map((activity) => ({
id: activity.id,
type: activity.type,
latitude: activity.latitude,
longitude: activity.longitude,
recordedAt: activity.recordedAt,
label: activityLabel(activity.type),
})),
[activityLabel, visibleActivities],
);
return (
<Box
m="calc(var(--mantine-spacing-md) * -1)"
h="calc(100dvh - var(--app-shell-header-offset, 0px))"
pos="relative"
style={{ overflow: 'hidden' }}
>
<TimelineMap
footprints={mapFootprints}
activities={mapActivities}
playbackPositions={playbackPositions}
focusPositions={focusPositions}
selectedActivityId={selectedActivity?.id}
height="100%"
fullBleed
emptyLabel={t('overlay.emptyMap')}
/>
<FormProvider {...form}>
<Box
pos="absolute"
top={{ base: 12, md: 16 }}
left={{ base: 12, md: 16 }}
bottom={{ base: undefined, md: 16 }}
h={{ base: '38%', md: 'auto' }}
w={{ base: 'calc(100% - 24px)', md: 400 }}
style={{ zIndex: 2 }}
>
<TimelineActivityPanel
title={t('title')}
search={search}
searchPlaceholder={t('search.placeholder')}
onSearchChange={setSearch}
tab={tab}
onTabChange={setTab}
onTheWayLabel={t('tabs.onTheWay')}
completedLabel={t('tabs.completed')}
groups={filteredGroups}
selectedKey={selectedKey}
onSelect={setSelectedKey}
activityLabel={activityLabel}
emptyLabel={t('activities.empty')}
ungroupedLabel={t('activities.ungrouped')}
errorMessage={errorMessage}
filters={
<Grid gutter="xs">
<Grid.Col span={12}>
<FieldDatePicker control={form.control} name="date" label={t('filters.date')} size="sm" />
</Grid.Col>
<Grid.Col span={12}>
<FieldAsyncSelect<EmployeeEntity>
control={form.control as unknown as Control<FieldValues>}
name="employee"
label={t('filters.employee')}
placeholder={t('filters.allEmployees')}
loadOptions={loadSalesEmployeeOptions}
valueKey="id"
labelKey="name"
clearable
searchable
size="sm"
renderLabel={relationLabel}
/>
</Grid.Col>
</Grid>
}
/>
</Box>
</FormProvider>
<Box pos="absolute" bottom={16} left={{ base: 12, md: 432 }} right={{ base: 12, md: 16 }} style={{ zIndex: 2 }}>
<TimelinePlaybackOverlay
group={selectedGroup}
ungroupedLabel={t('activities.ungrouped')}
activityLabel={activityLabel}
fromLabel={t('overlay.from')}
toLabel={t('overlay.to')}
currentLocationLabel={t('overlay.currentLocation')}
activitiesLabel={t('overlay.activities')}
playbackTitle={t('playback.title')}
playLabel={t('playback.play')}
pauseLabel={t('playback.pause')}
timeLabel={t('playback.time')}
emptyLabel={t('activities.empty')}
currentLocation={playbackPositions[0]?.label ?? ''}
playbackBounds={playbackBounds}
playbackTime={playbackTime}
onPlaybackTimeChange={setPlaybackTime}
isPlaying={isPlaying}
onTogglePlayback={togglePlayback}
canPlay={!isLoading && footprints.length > 0}
/>
</Box>
</Box>
);
}
@@ -0,0 +1,16 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import type { TimelineShellEntity } from '../../domain/constants/timeline.constants';
export const salesTimelineStore = create<EnterpriseModuleState<TimelineShellEntity>>((set) => ({
metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }),
filterData: {},
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
tableConfig: null,
setTableConfig: (config) => set({ tableConfig: config }),
}));
@@ -9,7 +9,6 @@ const CyclesModule = lazy(() => import('../field/cycles/presentation/factory'));
const PlansModule = lazy(() => import('../field/plans/presentation/factory'));
const InvoicesModule = lazy(() => import('./invoices/presentation/factory'));
const PaymentsModule = lazy(() => import('./payments/presentation/factory'));
export default function SalesModule() {
return (
<Routes>
@@ -20,6 +19,7 @@ export default function SalesModule() {
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
<Route path="/invoices/*" element={<InvoicesModule />} />
<Route path="/payments/*" element={<PaymentsModule />} />
<Route path="/timeline/*" element={<Navigate to="/app/timeline/index" replace />} />
<Route path="/reports/*" element={<ReportsModule />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
@@ -33,7 +33,7 @@ describe('SalesInvoicesRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new SalesInvoicesRemoteDataServices(httpClient, {
apiUrl: '/sales-invoices',
moduleKey: 'SALES.INVOICE',
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
transformer: new SalesInvoicesRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesInvoiceEntity } from '../entities';
export const salesInvoicesModuleConfig: ModuleConfigEntity<SalesInvoiceEntity> = {
moduleKey: 'SALES.INVOICE',
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
translationNamespace: 'SALES_INVOICES',
apiUrl: '/sales-invoices',
webUrl: '/app/sales/invoices',
@@ -33,7 +33,7 @@ describe('SalesOrdersRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new SalesOrdersRemoteDataServices(httpClient, {
apiUrl: '/sales-orders',
moduleKey: 'SALES.ORDER',
moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
transformer: new SalesOrdersRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesOrderEntity } from '../entities';
export const salesOrdersModuleConfig: ModuleConfigEntity<SalesOrderEntity> = {
moduleKey: 'SALES.ORDER',
moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
translationNamespace: 'SALES_ORDERS',
apiUrl: '/sales-orders',
webUrl: '/app/sales/orders',
@@ -33,7 +33,7 @@ describe('SalesPaymentsRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new SalesPaymentsRemoteDataServices(httpClient, {
apiUrl: '/sales-payments',
moduleKey: 'SALES.PAYMENT',
moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
transformer: new SalesPaymentsRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesPaymentEntity } from '../entities';
export const salesPaymentsModuleConfig: ModuleConfigEntity<SalesPaymentEntity> = {
moduleKey: 'SALES.PAYMENT',
moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
translationNamespace: 'SALES_PAYMENTS',
apiUrl: '/sales-payments',
webUrl: '/app/sales/payments',
@@ -4,7 +4,7 @@ import type { ModuleConfigEntity } from '@repo/ui/foundations';
export type ReportShellEntity = BaseEntity & { id: string };
export const salesReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
moduleKey: 'SALES.REPORT',
moduleKey: 'ADMIN.SALES.REPORT',
translationNamespace: 'SALES_REPORTS',
apiUrl: '/reports',
webUrl: '/app/sales/reports',
@@ -1,10 +1,7 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { apiClient } from '../../../../../../../core/lib/api-client';
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
import {
salesReportsModuleConfig,
type ReportShellEntity,
} from '../constants/reports.constants';
import { salesReportsModuleConfig, type ReportShellEntity } from '../constants/reports.constants';
class ReportShellTransformer extends BaseDataTransformer<ReportShellEntity> {
transformToEntity(dto: ReportShellEntity): ReportShellEntity {
@@ -33,7 +33,7 @@ describe('SalesRequestsRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new SalesRequestsRemoteDataServices(httpClient, {
apiUrl: '/sales-requests',
moduleKey: 'SALES.REQUEST',
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
transformer: new SalesRequestsRemoteDataTransformer(),
});
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { SalesRequestEntity } from '../entities';
export const salesRequestsModuleConfig: ModuleConfigEntity<SalesRequestEntity> = {
moduleKey: 'SALES.REQUEST',
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
translationNamespace: 'SALES_REQUESTS',
apiUrl: '/sales-requests',
webUrl: '/app/sales/requests',
@@ -32,7 +32,7 @@ describe('SalesDocumentRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new SalesDocumentRemoteDataServices(httpClient, {
apiUrl: '/sales-requests',
moduleKey: 'SALES.REQUEST',
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
});
});
@@ -45,7 +45,7 @@ describe('PrivilegesRemoteDataServices', () => {
httpClient = createMockHttpClient();
service = new PrivilegesRemoteDataServices(httpClient, {
apiUrl: '/privileges',
moduleKey: 'PRIVILEGES',
moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
transformer: new PrivilegesRemoteDataTransformer(),
});
});
@@ -125,7 +125,7 @@ describe('PrivilegesRemoteDataServices', () => {
it('lists privilege keys from /privilege-keys', async () => {
vi.mocked(httpClient.request).mockResolvedValueOnce({
data: {
data: [{ id: 'key-1', code: 'SALES.INVOICE', label: 'Sales Invoice', sortOrder: 1 }],
data: [{ id: 'key-1', code: 'ADMIN.SALES.ACTIVITIES.INVOICE', label: 'Sales Invoice', sortOrder: 1 }],
meta: { currentPage: 1, itemsPerPage: 200, totalItems: 1, totalPages: 1 },
},
status: 200,
@@ -142,7 +142,7 @@ describe('PrivilegesRemoteDataServices', () => {
);
expect(result.data[0]).toEqual({
id: 'key-1',
code: 'SALES.INVOICE',
code: 'ADMIN.SALES.ACTIVITIES.INVOICE',
label: 'Sales Invoice',
sortOrder: 1,
});
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
import type { PrivilegeEntity } from '../entities';
export const privilegesModuleConfig: ModuleConfigEntity<PrivilegeEntity> = {
moduleKey: 'PRIVILEGES',
moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
translationNamespace: 'PRIVILEGES',
apiUrl: '/privileges',
webUrl: '/app/system/privileges',
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { EMPTY_MATRIX_CELL } from './entities';
import type { PrivilegeKeyEntity } from './entities';
import { groupPrivilegeKeys, flattenParentSectionRows, parsePrivilegeKey } from './privilege-key-hierarchy';
const keys: PrivilegeKeyEntity[] = [
{ id: 'k1', code: 'ADMIN.SALES.DATA.CYCLE', label: 'Sales cycles', sortOrder: 1 },
{ id: 'k2', code: 'ADMIN.SALES.ACTIVITIES.PLAN', label: 'Sales plans', sortOrder: 2 },
{ id: 'k3', code: 'MOBILE.SALES.PLAN', label: 'Sales plans (mobile)', sortOrder: 3 },
{ id: 'k4', code: 'MOBILE.SALES.PLAN.ATTENDANCE', label: 'Branch attendance', sortOrder: 4 },
];
function row(key: PrivilegeKeyEntity, view = false) {
return {
keyId: key.id,
code: key.code,
label: key.label,
sortOrder: key.sortOrder,
cell: { ...EMPTY_MATRIX_CELL, view },
};
}
describe('parsePrivilegeKey', () => {
it('parses 3-part keys', () => {
expect(parsePrivilegeKey('MOBILE.SALES.PLAN')).toEqual({
group: 'MOBILE',
parent: 'SALES',
module: 'PLAN',
submodule: null,
});
});
it('parses 4-part keys', () => {
expect(parsePrivilegeKey('MOBILE.SALES.PLAN.ATTENDANCE')).toEqual({
group: 'MOBILE',
parent: 'SALES',
module: 'PLAN',
submodule: 'ATTENDANCE',
});
});
it('returns null for legacy keys', () => {
expect(parsePrivilegeKey('PRIVILEGES')).toBeNull();
});
});
describe('groupPrivilegeKeys', () => {
it('groups keys into tabs, parents, modules, and submodule rows', () => {
const rowsByKeyId = Object.fromEntries(keys.map((key) => [key.id, row(key)]));
const grouped = groupPrivilegeKeys(keys, rowsByKeyId);
expect(grouped.map((tab) => tab.group)).toEqual(['ADMIN', 'MOBILE']);
expect(grouped[0]?.parents[0]?.parent).toBe('SALES');
expect(grouped[0]?.parents[0]?.modules.map((m) => m.module)).toEqual(['ACTIVITIES', 'DATA']);
expect(grouped[1]?.parents[0]?.modules[0]?.directRow?.code).toBe('MOBILE.SALES.PLAN');
expect(grouped[1]?.parents[0]?.modules[0]?.submodules[0]?.code).toBe('MOBILE.SALES.PLAN.ATTENDANCE');
});
});
describe('flattenParentSectionRows', () => {
it('flattens modules and submodules into sortable table rows', () => {
const rowsByKeyId = Object.fromEntries(keys.map((key) => [key.id, row(key)]));
const grouped = groupPrivilegeKeys(keys, rowsByKeyId);
const salesParent = grouped[0]?.parents[0];
expect(salesParent).toBeDefined();
const tableRows = flattenParentSectionRows(salesParent!);
expect(tableRows.map((r) => r.code)).toEqual(['ADMIN.SALES.DATA.CYCLE', 'ADMIN.SALES.ACTIVITIES.PLAN']);
expect(tableRows[0]).toMatchObject({ module: 'DATA', submodule: 'CYCLE' });
expect(tableRows[1]).toMatchObject({ module: 'ACTIVITIES', submodule: 'PLAN' });
});
});
@@ -0,0 +1,140 @@
import type { PrivilegeKeyEntity, PrivilegeMatrixCell } from './entities';
export type ParsedPrivilegeKey = {
group: string;
parent: string;
module: string;
submodule: string | null;
};
export type PrivilegeMatrixRow = {
keyId: string;
code: string;
label: string;
sortOrder: number;
cell: PrivilegeMatrixCell;
};
export type PrivilegeModuleGroup = {
module: string;
directRow?: PrivilegeMatrixRow;
submodules: PrivilegeMatrixRow[];
};
export type PrivilegeParentSection = {
parent: string;
modules: PrivilegeModuleGroup[];
};
export type PrivilegeGroupTab = {
group: string;
parents: PrivilegeParentSection[];
};
export function parsePrivilegeKey(code: string): ParsedPrivilegeKey | null {
const parts = code.split('.');
if (parts.length === 3) {
const [group, parent, module] = parts;
return { group, parent, module, submodule: null };
}
if (parts.length === 4) {
const [group, parent, module, submodule] = parts;
return { group, parent, module, submodule };
}
return null;
}
export function groupPrivilegeKeys(
keys: PrivilegeKeyEntity[],
rowsByKeyId: Record<string, PrivilegeMatrixRow>,
): PrivilegeGroupTab[] {
const groupMap = new Map<string, Map<string, Map<string, PrivilegeModuleGroup>>>();
const sortedKeys = [...keys].sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
for (const key of sortedKeys) {
const parsed = parsePrivilegeKey(key.code);
if (!parsed) {
continue;
}
const row = rowsByKeyId[key.id];
if (!row) {
continue;
}
if (!groupMap.has(parsed.group)) {
groupMap.set(parsed.group, new Map());
}
const parentMap = groupMap.get(parsed.group)!;
if (!parentMap.has(parsed.parent)) {
parentMap.set(parsed.parent, new Map());
}
const moduleMap = parentMap.get(parsed.parent)!;
if (!moduleMap.has(parsed.module)) {
moduleMap.set(parsed.module, { module: parsed.module, submodules: [] });
}
const moduleGroup = moduleMap.get(parsed.module)!;
if (parsed.submodule === null) {
moduleGroup.directRow = row;
} else {
moduleGroup.submodules.push(row);
}
}
return [...groupMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, parentMap]) => ({
group,
parents: [...parentMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([parent, moduleMap]) => ({
parent,
modules: [...moduleMap.values()]
.map((moduleGroup) => ({
...moduleGroup,
submodules: [...moduleGroup.submodules].sort(
(a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code),
),
}))
.sort((a, b) => a.module.localeCompare(b.module)),
})),
}));
}
export type PrivilegeTableRow = PrivilegeMatrixRow & {
module: string;
submodule: string | null;
};
export function flattenParentSectionRows(parent: PrivilegeParentSection): PrivilegeTableRow[] {
const rows: PrivilegeTableRow[] = [];
for (const moduleGroup of parent.modules) {
if (moduleGroup.directRow) {
rows.push({
...moduleGroup.directRow,
module: moduleGroup.module,
submodule: null,
});
}
for (const subRow of moduleGroup.submodules) {
const parsed = parsePrivilegeKey(subRow.code);
rows.push({
...subRow,
module: moduleGroup.module,
submodule: parsed?.submodule ?? null,
});
}
}
return rows.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
}
export function formatPrivilegeSegment(segment: string): string {
return segment
.split('_')
.map((part) => part.charAt(0) + part.slice(1).toLowerCase())
.join(' ');
}
@@ -17,7 +17,7 @@ describe('buildPrivilegeMatrixRows', () => {
details: [
{
privilegeKeyId: 'key-2',
keyCode: 'USERS',
keyCode: 'ADMIN.SETTINGS.USER.USERS',
keyLabel: 'Users',
sortOrder: 2,
action: 'view',
@@ -17,7 +17,7 @@ const detailDto: PrivilegeDetailResponseDto = {
{
id: 'd1',
privilegeKeyId: 'key-1',
keyCode: 'SALES.INVOICE',
keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
keyLabel: 'Sales Invoice',
sortOrder: 1,
action: 'view',
@@ -26,7 +26,7 @@ const detailDto: PrivilegeDetailResponseDto = {
{
id: 'd2',
privilegeKeyId: 'key-1',
keyCode: 'SALES.INVOICE',
keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
keyLabel: 'Sales Invoice',
sortOrder: 1,
action: 'create',
@@ -48,7 +48,7 @@ describe('PrivilegesRemoteDataTransformer', () => {
});
expect(entity.details?.[0]).toMatchObject({
privilegeKeyId: 'key-1',
keyCode: 'SALES.INVOICE',
keyCode: 'ADMIN.SALES.ACTIVITIES.INVOICE',
keyLabel: 'Sales Invoice',
sortOrder: 1,
});
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
import { Box, Paper, Table, Text } from '@repo/ui/components';
import { FieldCheckbox } from '@repo/ui/form';
import { useEffect, useMemo, useState } from 'react';
import { Paper, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { privilegesDataService } from '../../../domain/factories';
import { EMPTY_MATRIX_CELL, PRIVILEGE_ACTIONS, type PrivilegeKeyEntity } from '../../../domain/entities';
import { EMPTY_MATRIX_CELL, type PrivilegeKeyEntity } from '../../../domain/entities';
import { groupPrivilegeKeys } from '../../../domain/privilege-key-hierarchy';
import { PrivilegeMatrixLayout } from '../privilege-matrix-layout';
export function FormPermissions() {
const { formControl } = useFormPageContext();
@@ -28,42 +29,28 @@ export function FormPermissions() {
};
}, [formControl]);
const grouped = useMemo(() => {
const rowsByKeyId = Object.fromEntries(
keys.map((key) => [
key.id,
{
keyId: key.id,
code: key.code,
label: key.label,
sortOrder: key.sortOrder,
cell: formControl.getValues(`matrix.${key.id}`) ?? { ...EMPTY_MATRIX_CELL },
},
]),
);
return groupPrivilegeKeys(keys, rowsByKeyId);
}, [keys, formControl]);
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_permissions')}
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('matrix_module')}</Table.Th>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Th key={action} ta="center">
{t(`matrix_${action}`)}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{keys.map((key) => (
<Table.Tr key={key.id}>
<Table.Td>
<Text size="sm">{key.label || key.code}</Text>
<Text size="xs" c="dimmed">
{key.code}
</Text>
</Table.Td>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Td key={action} ta="center">
<FieldCheckbox control={formControl.control} name={`matrix.${key.id}.${action}`} label="" />
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<PrivilegeMatrixLayout groups={grouped} t={t} mode="edit" formControl={formControl} />
</Paper>
);
}
@@ -0,0 +1,167 @@
import { Box, Stack, Table, Tabs, Text } from '@repo/ui/components';
import { FieldCheckbox } from '@repo/ui/form';
import type { Control } from 'react-hook-form';
import { Check, Minus } from 'lucide-react';
import { PRIVILEGE_ACTIONS } from '../../domain/entities';
import {
flattenParentSectionRows,
formatPrivilegeSegment,
type PrivilegeGroupTab,
type PrivilegeMatrixRow,
type PrivilegeTableRow,
} from '../../domain/privilege-key-hierarchy';
const MODULE_COL_WIDTH = '22%';
const SUBMODULE_COL_WIDTH = '30%';
const ACTION_COL_WIDTH = 88;
type PrivilegeMatrixLayoutProps = {
groups: PrivilegeGroupTab[];
t: (key: string) => string;
mode: 'edit' | 'read';
formControl?: { control: Control<Record<string, unknown>> };
};
function segmentLabel(t: (key: string) => string, kind: string, value: string) {
const key = `hierarchy_${kind}_${value.toLowerCase()}`;
const translated = t(key);
return translated === key ? formatPrivilegeSegment(value) : translated;
}
function submoduleCellLabel(t: (key: string) => string, row: PrivilegeTableRow) {
if (row.submodule) {
return row.label || segmentLabel(t, 'submodule', row.submodule);
}
return row.label || segmentLabel(t, 'module', row.module);
}
function MatrixActionCells({
row,
mode,
formControl,
}: {
row: PrivilegeMatrixRow;
mode: 'edit' | 'read';
formControl?: { control: Control<Record<string, unknown>> };
}) {
return (
<>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Td key={action} ta="center" w={ACTION_COL_WIDTH} miw={ACTION_COL_WIDTH} maw={ACTION_COL_WIDTH}>
<Box display="flex" style={{ justifyContent: 'center', alignItems: 'center' }}>
{mode === 'edit' && formControl ? (
<FieldCheckbox control={formControl.control} name={`matrix.${row.keyId}.${action}`} label="" />
) : row.cell[action] ? (
<Check size={16} />
) : (
<Minus size={16} />
)}
</Box>
</Table.Td>
))}
</>
);
}
function ParentMatrixTable({
rows,
mode,
formControl,
t,
}: {
rows: PrivilegeTableRow[];
mode: 'edit' | 'read';
formControl?: { control: Control<Record<string, unknown>> };
t: (key: string) => string;
}) {
if (rows.length === 0) {
return null;
}
return (
<Box style={{ overflowX: 'auto' }}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
layout="fixed"
w="100%"
style={{ tableLayout: 'fixed' }}
>
<colgroup>
<col style={{ width: MODULE_COL_WIDTH }} />
<col style={{ width: SUBMODULE_COL_WIDTH }} />
{PRIVILEGE_ACTIONS.map((action) => (
<col key={action} style={{ width: ACTION_COL_WIDTH }} />
))}
</colgroup>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('matrix_module')}</Table.Th>
<Table.Th>{t('matrix_submodule')}</Table.Th>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Th key={action} ta="center" w={ACTION_COL_WIDTH}>
{t(`matrix_${action}`)}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={row.keyId}>
<Table.Td valign="middle">
<Text size="sm" fw={500}>
{segmentLabel(t, 'module', row.module)}
</Text>
</Table.Td>
<Table.Td valign="middle">
<Text size="sm">{submoduleCellLabel(t, row)}</Text>
<Text size="xs" c="dimmed">
{row.code}
</Text>
</Table.Td>
<MatrixActionCells row={row} mode={mode} formControl={formControl} />
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
);
}
export function PrivilegeMatrixLayout({ groups, t, mode, formControl }: PrivilegeMatrixLayoutProps) {
if (groups.length === 0) {
return null;
}
return (
<Tabs defaultValue={groups[0]?.group} variant="outline" radius="md">
<Tabs.List mb="md">
{groups.map((groupTab) => (
<Tabs.Tab key={groupTab.group} value={groupTab.group}>
{segmentLabel(t, 'group', groupTab.group)}
</Tabs.Tab>
))}
</Tabs.List>
{groups.map((groupTab) => (
<Tabs.Panel key={groupTab.group} value={groupTab.group} pt="md">
<Stack gap="lg">
{groupTab.parents.map((parentSection) => (
<Stack key={parentSection.parent} gap="sm">
<Text fw={600}>{segmentLabel(t, 'parent', parentSection.parent)}</Text>
<ParentMatrixTable
rows={flattenParentSectionRows(parentSection)}
mode={mode}
formControl={formControl}
t={t}
/>
</Stack>
))}
</Stack>
</Tabs.Panel>
))}
</Tabs>
);
}
@@ -1,7 +1,9 @@
import { Box, Paper, Table, Text } from '@repo/ui/components';
import { Check, Minus } from 'lucide-react';
import { useMemo } from 'react';
import { Paper, Text } from '@repo/ui/components';
import { PRIVILEGE_ACTIONS, type PrivilegeEntity } from '../../domain/entities';
import { buildPrivilegeMatrixRows } from '../../domain/privilege.matrix-rows';
import { groupPrivilegeKeys } from '../../domain/privilege-key-hierarchy';
import { PrivilegeMatrixLayout } from './privilege-matrix-layout';
export function PrivilegePermissionsTable({
privilege,
@@ -10,44 +12,26 @@ export function PrivilegePermissionsTable({
privilege: Pick<PrivilegeEntity, 'matrix' | 'details'> | null | undefined;
t: (key: string) => string;
}) {
const rows = buildPrivilegeMatrixRows(privilege);
const grouped = useMemo(() => {
const rows = buildPrivilegeMatrixRows(privilege);
const keys = rows.map((row) => ({
id: row.keyId,
code: row.code,
label: row.label,
sortOrder: row.sortOrder,
}));
const rowsByKeyId = Object.fromEntries(rows.map((row) => [row.keyId, row]));
return groupPrivilegeKeys(keys, rowsByKeyId);
}, [privilege]);
void PRIVILEGE_ACTIONS;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_permissions')}
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('matrix_module')}</Table.Th>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Th key={action} ta="center">
{t(`matrix_${action}`)}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={row.keyId}>
<Table.Td>
<Text size="sm">{row.label}</Text>
<Text size="xs" c="dimmed">
{row.code}
</Text>
</Table.Td>
{PRIVILEGE_ACTIONS.map((action) => (
<Table.Td key={action} ta="center">
{row.cell[action] ? <Check size={16} /> : <Minus size={16} />}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<PrivilegeMatrixLayout groups={grouped} t={t} mode="read" />
</Paper>
);
}
@@ -12,11 +12,22 @@
"section_general": "General",
"section_permissions": "Permissions",
"matrix_module": "Module",
"matrix_submodule": "Submodule",
"matrix_view": "View",
"matrix_create": "Create",
"matrix_update": "Update",
"matrix_delete": "Delete",
"matrix_import": "Import",
"hierarchy_group_admin": "Admin",
"hierarchy_group_mobile": "Mobile",
"hierarchy_parent_sales": "Sales",
"hierarchy_parent_logistics": "Logistics",
"hierarchy_parent_settings": "Settings",
"hierarchy_module_data": "Data",
"hierarchy_module_activities": "Activities",
"hierarchy_module_report": "Report",
"hierarchy_module_user": "User",
"hierarchy_module_plan": "Plan",
"status_draft": "Draft",
"status_active": "Active",
"status_archived": "Archived"

Some files were not shown because too many files have changed in this diff Show More