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
shancheas 991bf3fe65 feat: implement report engine with sales and logistics modules
- Added a new report engine UI, including configuration-driven report screens for sales and logistics.
- Created `ReportProvider`, `ReportTable`, and related components for rendering reports with server-side data fetching.
- Introduced `logisticsReportsModuleConfig` and `salesReportsModuleConfig` for managing report configurations and API interactions.
- Updated navigation to include sales and logistics reports with proper labels and paths.
- Enhanced language support for report titles in English and Indonesian.
- Implemented bookmark and filter functionalities for reports, improving user interaction and data management.

These changes establish a robust reporting framework, enhancing the application's data visualization capabilities and user experience.
2026-09-01 08:46:08 +07:00
shancheas 90b606f865 feat: add sidebar store and utility functions with tests
- Introduced `useSidebarStore` for managing sidebar state, including opened keys and initialization logic.
- Added utility functions `getAllParentKeys`, `shouldShowMenuChildren`, and `getVisibleMenuKeys` to enhance sidebar functionality.
- Created unit tests for the sidebar store and utility functions to ensure correct behavior and state management.
- Updated `sidebar.tsx` to utilize the new utility functions for improved menu item rendering.

These changes enhance the sidebar's functionality and state management, providing a better user experience in navigating the application.
2026-08-31 16:59:17 +07:00
shancheas 6808a681b7 feat: enhance sales module with decimal formatting and input improvements
- Updated various components to utilize `FieldCurrencyInput` for quantity inputs, improving user experience in financial data entry.
- Introduced `formatDecimal` utility for consistent decimal formatting across the application, ensuring accurate display of numeric values.
- Refactored quantity handling in forms and tables to support decimal values, enhancing data integrity and usability.
- Added unit tests for new decimal formatting functionality to ensure reliability and correctness.

These changes improve the sales module's handling of numeric inputs and displays, providing a more user-friendly experience for managing financial data.
2026-08-31 16:27:55 +07:00
shancheas 9d0cde7252 feat: enhance plan form previews with customer and document displays
- Added `FormCustomersPreview` and `FormDocumentsPreview` components to display customer and document information in the plan form.
- Implemented customer location handling in the validation schema to ensure proper data representation.
- Updated the `FormGeneral` component to integrate the new previews, improving user experience during plan creation.
- Added unit tests for new functionalities to ensure reliability and correctness.
- Enhanced language support for preview sections in both English and Indonesian.

These changes improve the plan form's usability by providing clear previews of customers and documents, streamlining the planning process.
2026-08-31 16:19:48 +07:00
shancheas 532e30672e feat: enhance packing slips module with new action and language support
- Added "Process" action to the packing slips module, allowing users to transition packing slips from draft to processed status.
- Updated English and Indonesian language files to include the new "Process" action, improving user experience and consistency across languages.
- Refactored sales status handling to accommodate the new action, ensuring proper workflow integration.

These changes enhance the packing slips functionality by providing additional actions and improving language support, streamlining user interactions.
2026-08-31 16:02:40 +07:00
shancheas 71046973ae feat: add currency input field and enhance currency formatting utilities
- Introduced `FieldCurrencyInput` component for handling currency input in forms, supporting formatted display while maintaining numeric values.
- Implemented `RenderCurrency` component for displaying currency values with proper formatting.
- Enhanced `CurrencyUtils` to include parsing and formatting functions for Rupiah, ensuring accurate representation in UI.
- Updated various components and forms to utilize the new currency input and rendering capabilities, improving user experience in financial data entry.
- Added unit tests for currency input and formatting functionalities to ensure reliability and correctness.

These changes enhance the application's handling of currency inputs and displays, providing a more user-friendly experience for financial transactions.
2026-08-31 15:36:53 +07:00
shancheas efe321ca2f feat: implement payable invoice functionality and enhance invoice processing
- Introduced `isPayableInvoice` utility to determine the payability of invoices based on status and balance.
- Updated sales invoice detail page to conditionally create payment actions for payable invoices.
- Refactored form allocations to load only payable sales invoices, improving data integrity in payment forms.
- Enhanced sales payment form to validate invoice payability before processing.
- Added unit tests for `isPayableInvoice` to ensure accurate functionality.
- Updated sales status transitions to allow draft invoices to be processed or cancelled, improving workflow flexibility.

These changes enhance the sales invoicing process by ensuring that only valid invoices are processed for payments, improving overall user experience and data management.
2026-08-31 15:14:58 +07:00
shancheas 5a6b109a89 feat: refactor option loading functions to support active status filtering
- Updated `loadDivisionOptions`, `loadBranchOptions`, `loadCustomerOptions`, `loadEmployeeOptionsForPurpose`, and `loadProductOptions` to utilize the new `createOptionLoader` function with active status parameters.
- Introduced `ACTIVE_LOOKUP_PARAMS` to streamline the inclusion of additional parameters in option loading.
- Added unit tests for `createOptionLoader` and new utility functions for managing product options, ensuring robust functionality and reliability.

These changes enhance the option loading mechanism across various modules, improving data retrieval and filtering capabilities.
2026-08-31 15:05:48 +07:00
198 changed files with 5698 additions and 671 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.
@@ -150,8 +150,7 @@ interface CoreAppShellFeatures {
| `zIndex` | `number` | `200` | Base z-index passed to Mantine's `AppShell`. |
| `disabled` | `boolean` | `false` | Disables the AppShell layout entirely (renders children without structural chrome). |
> [!TIP]
> **Smart defaults**: You rarely need to set `withUtilityBar`, `withAside`, or `withFooter` explicitly. The engine auto-detects presence by checking if the corresponding slot is provided and truthy. Only set them to `false` when you want to **suppress** a slot that is being passed.
> [!TIP] > **Smart defaults**: You rarely need to set `withUtilityBar`, `withAside`, or `withFooter` explicitly. The engine auto-detects presence by checking if the corresponding slot is provided and truthy. Only set them to `false` when you want to **suppress** a slot that is being passed.
---
@@ -234,8 +233,7 @@ import { useCoreAppShell } from '@repo/ui/components';
| `toggleNavbarPanel()` | `() => void` | Toggle the double-sidebar panel open/closed |
| `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` |
> [!WARNING]
> `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
> [!WARNING] > `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
---
@@ -5,6 +5,7 @@ import {
FieldPasswordInput,
FieldTextarea,
FieldNumberInput,
FieldCurrencyInput,
FieldJsonInput,
FieldPinInput,
FieldAutocomplete,
@@ -82,6 +83,7 @@ export default function AllFieldsDemo() {
password: '',
description: '',
age: undefined,
price: 12500.12345,
jsonConfig: '',
pin: '',
country: '',
@@ -142,6 +144,9 @@ export default function AllFieldsDemo() {
<FieldPasswordInput name="password" control={control} label={t.fields.password} />
<FieldNumberInput name="age" control={control} label={t.fields.age} />
</Group>
<Group grow align="flex-start" mb="md">
<FieldCurrencyInput name="price" control={control} label={t.fields.price} />
</Group>
<Group grow align="flex-start" mb="md">
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
@@ -33,6 +33,7 @@
"password": "Password",
"description": "Description",
"age": "Age",
"price": "Price",
"jsonConfig": "JSON Config",
"tags": "Tags",
"terms": "I agree to the terms and conditions",
@@ -33,6 +33,7 @@
"password": "Kata Sandi",
"description": "Deskripsi",
"age": "Usia",
"price": "Harga",
"jsonConfig": "Konfigurasi JSON",
"tags": "Label (Tags)",
"terms": "Saya setuju dengan syarat dan ketentuan",
+42
View File
@@ -0,0 +1,42 @@
# Reports UI
Generic, config-driven report screens live in `apps/web/src/core/report/`. Report definitions are backend `ReportConfigEntity` objects.
Architecture and APIs: [trackgo-be/docs/report-engine.md](../../../../../trackgo-be/docs/report-engine.md)
## 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 |
## Product modules
| 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.
## Grid contract
Every server-side block sends:
```ts
{
groupName,
uniqueName,
queryModel: { /* AG Grid IServerSideGetRowsRequest + merged filterModel */ }
}
```
Column defs, filters, and formats come from the config payload. Adding a report is a backend config change only.
@@ -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} />} />
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../../core/storage/local', () => ({
appStorage: {
getItem: vi.fn(),
setItem: vi.fn(),
},
AppStorageKey: {
SIDEBAR_OPEN_MENUS: 'sidebar_open_menus',
},
}));
import { appStorage } from '../../../../core/storage/local';
import { useSidebarStore } from './sidebar.store';
const allParentKeys = ['sales', 'sales-data', 'sales-activities'];
describe('useSidebarStore', () => {
beforeEach(() => {
useSidebarStore.setState({
openedKeys: new Set(),
isInitialized: false,
isAllExpanded: false,
searchQuery: '',
});
vi.mocked(appStorage.getItem).mockReset();
vi.mocked(appStorage.setItem).mockReset();
});
it('restores saved open keys so nested sales branches can render together', async () => {
vi.mocked(appStorage.getItem).mockResolvedValue(['sales', 'sales-data']);
await useSidebarStore.getState().initializeStorage(['sales'], allParentKeys);
expect([...useSidebarStore.getState().openedKeys]).toEqual(['sales', 'sales-data']);
expect(useSidebarStore.getState().isInitialized).toBe(true);
expect(useSidebarStore.getState().isAllExpanded).toBe(false);
});
it('opens the active path on first visit when nothing is saved', async () => {
vi.mocked(appStorage.getItem).mockResolvedValue(null);
await useSidebarStore.getState().initializeStorage(['sales', 'sales-data'], allParentKeys);
expect([...useSidebarStore.getState().openedKeys]).toEqual(['sales', 'sales-data']);
expect(appStorage.setItem).toHaveBeenCalledWith('sidebar_open_menus', ['sales', 'sales-data']);
});
});
@@ -22,6 +22,7 @@ import { useTranslation } from '@repo/core-i18n';
import { shortcutsData } from '@repo/ui/constants';
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
import { useSidebarStore } from './sidebar.store';
import { getAllParentKeys, shouldShowMenuChildren } from './sidebar.utils';
import { useDebouncedValue } from '@repo/ui/hooks';
// ---------------------------------------------------------------------------
@@ -74,7 +75,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
const isOpened = useSidebarStore((state) => state.openedKeys.has(item.key));
const toggleMenu = useSidebarStore((state) => state.toggleMenu);
const effectivelyOpened = isSearching || isOpened;
const effectivelyOpened = shouldShowMenuChildren(isOpened, isSearching);
const handleChange = useCallback(
(newOpened: boolean) => {
@@ -84,39 +85,48 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
);
return (
<NavLink
component={hasChildren ? 'button' : (Link as any)}
to={hasChildren ? undefined : item.path}
label={t(item.label)}
leftSection={<Icon size={18} strokeWidth={1.8} />}
active={isExactActive}
opened={effectivelyOpened}
onChange={handleChange}
variant="light"
styles={{
root: {
borderRadius: 'var(--mantine-radius-md)',
color: isParentActive ? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))' : undefined,
},
label: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 500,
},
}}
>
{hasChildren &&
item.children!.map((child) => (
<MenuItemExpanded
key={child.key}
item={child}
activeKeys={activeKeys}
isSearching={isSearching}
allParentKeys={allParentKeys}
/>
))}
</NavLink>
<>
<NavLink
component={hasChildren ? 'button' : (Link as any)}
to={hasChildren ? undefined : item.path}
label={t(item.label)}
leftSection={<Icon size={18} strokeWidth={1.8} />}
active={isExactActive}
opened={effectivelyOpened}
onChange={handleChange}
variant="light"
styles={{
root: {
borderRadius: 'var(--mantine-radius-md)',
color: isParentActive
? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))'
: undefined,
},
label: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 500,
},
}}
>
{/* Keep the chevron without nesting real items in Collapse (nested height clips siblings on refresh). */}
{hasChildren ? <></> : undefined}
</NavLink>
{hasChildren && effectivelyOpened ? (
<Box ps="lg">
{item.children!.map((child) => (
<MenuItemExpanded
key={child.key}
item={child}
activeKeys={activeKeys}
isSearching={isSearching}
allParentKeys={allParentKeys}
/>
))}
</Box>
) : null}
</>
);
});
@@ -236,17 +246,6 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot =
// SidebarMenu Component
// ---------------------------------------------------------------------------
const getAllParentKeys = (items: MenuItemType[]): string[] => {
let keys: string[] = [];
for (const item of items) {
if (item.children && item.children.length > 0) {
keys.push(item.key);
keys = keys.concat(getAllParentKeys(item.children));
}
}
return keys;
};
export const SidebarMenu = memo(function SidebarMenu({
items,
variantOverride,
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { MENU_ITEMS } from '../data/menu.data';
import { getAllParentKeys, getVisibleMenuKeys, shouldShowMenuChildren } from './sidebar.utils';
describe('shouldShowMenuChildren', () => {
it('shows children when the branch is open or the user is searching', () => {
expect(shouldShowMenuChildren(true, false)).toBe(true);
expect(shouldShowMenuChildren(false, true)).toBe(true);
expect(shouldShowMenuChildren(false, false)).toBe(false);
});
});
describe('getAllParentKeys', () => {
it('includes nested groups under sales, logistics, and settings', () => {
expect(getAllParentKeys(MENU_ITEMS)).toEqual(
expect.arrayContaining([
'sales',
'sales-data',
'sales-activities',
'logistics',
'logistics-data',
'settings',
'settings-data',
]),
);
});
});
describe('getVisibleMenuKeys', () => {
it('keeps all siblings of an expanded nested branch visible after restore', () => {
const openedKeys = new Set(['sales', 'sales-data']);
const keys = getVisibleMenuKeys(MENU_ITEMS, openedKeys);
expect(keys).toContain('sales-employees');
expect(keys).toContain('sales-cycles');
expect(keys).toContain('sales-activities');
expect(keys).toContain('sales-reports');
expect(keys).not.toContain('sales-requests');
});
it('reveals every nested item while searching', () => {
const keys = getVisibleMenuKeys(MENU_ITEMS, new Set(), true);
expect(keys).toContain('sales-requests');
expect(keys).toContain('logistics-packing-slips');
expect(keys).toContain('system-users');
});
});
@@ -0,0 +1,38 @@
import type { MenuItemType } from '../types/menu.types';
export function getAllParentKeys(items: MenuItemType[]): string[] {
const keys: string[] = [];
for (const item of items) {
if (item.children && item.children.length > 0) {
keys.push(item.key);
keys.push(...getAllParentKeys(item.children));
}
}
return keys;
}
export function shouldShowMenuChildren(isOpened: boolean, isSearching: boolean): boolean {
return isSearching || isOpened;
}
/**
* 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[] {
const keys: string[] = [];
const walk = (nodes: MenuItemType[]) => {
for (const node of nodes) {
keys.push(node.key);
if (node.children?.length && shouldShowMenuChildren(openedKeys.has(node.key), isSearching)) {
walk(node.children);
}
}
};
walk(items);
return keys;
}
@@ -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,44 +79,44 @@ 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',
},
],
},
{
key: 'sales-reports',
label: 'nav:reports-coming-soon',
label: 'nav:sales-reports',
icon: FileText,
path: '/app/sales/reports',
isPlaceholder: true,
path: '/app/sales/reports/index',
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,23 +159,23 @@ 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',
},
],
},
{
key: 'logistics-reports',
label: 'nav:reports-coming-soon',
label: 'nav:logistics-reports',
icon: FileText,
path: '/app/logistics/reports',
isPlaceholder: true,
path: '/app/logistics/reports/index',
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",
@@ -31,6 +32,8 @@
"data": "Data",
"activities": "Activities",
"reports-coming-soon": "Reports (Coming Soon)",
"sales-reports": "Sales Reports",
"logistics-reports": "Logistics Reports",
"user": "User",
"settings": "Settings",
"settings-general": "General Settings",
@@ -53,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",
@@ -31,6 +32,8 @@
"data": "Data",
"activities": "Aktivitas",
"reports-coming-soon": "Laporan (Segera Hadir)",
"sales-reports": "Laporan Penjualan",
"logistics-reports": "Laporan Logistik",
"user": "Pengguna",
"settings": "Pengaturan",
"settings-general": "Pengaturan Umum",
@@ -53,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',
@@ -64,4 +64,13 @@ describe('ProductsRemoteDataTransformer', () => {
expect(payload).not.toHaveProperty('status');
expect(payload).not.toHaveProperty('id');
});
it('stringifies a numeric price on write without rounding', () => {
const payload = transformer.transformCreatePayload({
code: 'SKU_003',
name: 'Priced Widget',
price: 12500.12345 as unknown as string,
});
expect(payload.price).toBe('12500.12345');
});
});
@@ -1,5 +1,6 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import { toDecimalStringValue } from '../../../../../../../core/domain/decimal-string.schema';
import type { ProductDto, ProductEntity } from '../entities';
export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEntity> {
@@ -28,7 +29,7 @@ export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEn
code: entity.code,
name: entity.name,
unit: entity.unit,
price: entity.price,
price: toDecimalStringValue(entity.price),
brand: entity.brand,
});
}
@@ -38,7 +39,7 @@ export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEn
code: entity.code,
name: entity.name,
unit: emptyToNull(entity.unit) as string | null,
price: emptyToNull(entity.price) as string | null,
price: emptyToNull(toDecimalStringValue(entity.price)) as string | null,
brand: emptyToNull(entity.brand) as string | null,
};
}
@@ -29,6 +29,11 @@ describe('createProductSchema', () => {
});
it('rejects a non-decimal price', () => {
expect(schema.safeParse({ ...valid, price: '12.34567' }).success).toBe(false);
expect(schema.safeParse({ ...valid, price: 'abc' }).success).toBe(false);
});
it('accepts a numeric price with five decimal places', () => {
expect(schema.safeParse({ ...valid, price: 12.34567 }).success).toBe(true);
expect(schema.safeParse({ ...valid, price: '12.34567' }).success).toBe(true);
});
});
@@ -1,4 +1,4 @@
import { Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
import { Box, Paper, SimpleGrid, FieldValue, RenderCurrency, RenderDate, Text, StatusBadge } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { ProductEntity } from '../../../domain/entities';
@@ -17,7 +17,11 @@ export function DetailGeneral() {
<FieldValue label={t('common:fields.code')} value={data?.code} />
<FieldValue label={t('common:fields.name')} value={data?.name} />
<FieldValue label={t('common:fields.unit')} value={data?.unit} />
<FieldValue label={t('common:fields.price')} value={data?.price} />
<FieldValue
label={t('common:fields.price')}
value={data?.price}
render={(val) => <RenderCurrency value={val as string | number | null} />}
/>
<FieldValue label={t('common:fields.brand')} value={data?.brand} />
<FieldValue
label={t('common:fields.status')}
@@ -1,4 +1,4 @@
import { Box, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { Box, FieldCurrencyInput, FieldTextInput, Paper, SimpleGrid, Text } from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
export function FormGeneral() {
@@ -35,11 +35,11 @@ export function FormGeneral() {
placeholder="e.g. PCS"
radius="md"
/>
<FieldTextInput
<FieldCurrencyInput
control={formControl.control}
name="price"
label={t('common:fields.price')}
placeholder="12500.0000"
placeholder="Rp 12.500,00"
radius="md"
/>
<FieldTextInput
@@ -7,6 +7,7 @@ import {
import { ColDef, Text } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n';
import { Package } from 'lucide-react';
import { formatRupiah } from '@repo/utils';
import { FilterFormContent } from '../components/index-component/filter-content';
import type { ProductEntity } from '../../domain/entities';
@@ -18,7 +19,12 @@ export default function ProductPageIndex() {
{ field: 'code', headerName: t('common:fields.code'), minWidth: 140 },
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
{ field: 'unit', headerName: t('common:fields.unit'), minWidth: 100 },
{ field: 'price', headerName: t('common:fields.price'), minWidth: 140 },
{
field: 'price',
headerName: t('common:fields.price'),
minWidth: 140,
valueFormatter: ({ value }) => formatRupiah(value) || '-',
},
{ field: 'brand', headerName: t('common:fields.brand'), minWidth: 140 },
];
}, [t]);
@@ -1,12 +1,8 @@
import type { LoadOptionsFn } from '@repo/ui/form';
import { divisionsDataService } from '../divisions/domain/factories';
import type { DivisionEntity } from '../divisions/domain/entities';
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from '../../field/shared/create-option-loader';
export const loadDivisionOptions: LoadOptionsFn<DivisionEntity> = async (search, page) => {
const result = await divisionsDataService.getMany({
params: { search, page, limit: 20 },
});
const rows = (result.data as { data?: DivisionEntity[]; meta?: { totalPages?: number } })?.data ?? [];
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
return { options: rows, hasMore: page < totalPages };
};
export const loadDivisionOptions = createOptionLoader<DivisionEntity>(
(config) => divisionsDataService.getMany(config),
ACTIVE_LOOKUP_PARAMS,
);
@@ -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`,
@@ -0,0 +1,13 @@
import type { BaseEntity } from '@repo/core-api/data-services';
import type { ModuleConfigEntity } from '@repo/ui/foundations';
export type ReportShellEntity = BaseEntity & { id: string };
export const logisticsReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
moduleKey: 'ADMIN.LOGISTICS.REPORT',
translationNamespace: 'LOGISTICS_REPORTS',
apiUrl: '/reports',
webUrl: '/app/logistics/reports',
moduleCategory: 'SINGLE_PAGE',
moduleType: 'TRANSACTION',
} as const;
@@ -0,0 +1,20 @@
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';
class ReportShellTransformer extends BaseDataTransformer<ReportShellEntity> {
transformToEntity(dto: ReportShellEntity): ReportShellEntity {
return dto;
}
transformToDTO(entity: ReportShellEntity): ReportShellEntity {
return entity;
}
}
export const logisticsReportsDataService = new TrackGoRemoteDataServices(apiClient, {
apiUrl: logisticsReportsModuleConfig.apiUrl,
moduleKey: logisticsReportsModuleConfig.moduleKey,
transformer: new ReportShellTransformer(),
});
@@ -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 { logisticsReportsModuleConfig, type ReportShellEntity } from '../../domain/constants/reports.constants';
import { logisticsReportsDataService } from '../../domain/factories';
import { logisticsReportsStore } from '../store';
import reportsEn from '../languages/en/reports.json';
import reportsId from '../languages/id/reports.json';
const IndexPage = lazy(() => import('../pages/reports.page.index'));
registerModuleNamespace(logisticsReportsModuleConfig.translationNamespace, {
en: reportsEn,
id: reportsId,
});
export default function LogisticsReportsModule() {
return (
<EnterpriseModuleProvider<ReportShellEntity>
config={logisticsReportsModuleConfig}
dataServices={logisticsReportsDataService}
store={logisticsReportsStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/" element={<Navigate to={`${logisticsReportsModuleConfig.webUrl}/index`} replace />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,4 @@
{
"title": "Logistics Reports",
"description": "Config-driven logistics report tables"
}
@@ -0,0 +1,4 @@
{
"title": "Laporan Logistik",
"description": "Tabel laporan logistik berbasis konfigurasi"
}
@@ -0,0 +1,27 @@
import { FileText } from 'lucide-react';
import { CorePageContainer } from '@repo/ui/components';
import { ModulePageHeader } from '@repo/ui/foundations';
import { useTranslation } from '@repo/core-i18n';
import { REPORT_GROUP, ReportProvider } from '../../../../../../../core/report';
import { logisticsReportsModuleConfig } from '../../domain/constants/reports.constants';
export default function LogisticsReportsPage() {
const { t } = useTranslation(logisticsReportsModuleConfig.translationNamespace);
const { t: tNav } = useTranslation('nav');
return (
<CorePageContainer>
<ModulePageHeader
icon={FileText}
title={t('title')}
description={t('description')}
moduleKey={logisticsReportsModuleConfig.moduleKey}
breadcrumbs={[
{ label: tNav('logistics'), type: 'text' },
{ label: t('title'), type: 'text' },
]}
/>
<ReportProvider groupName={REPORT_GROUP.LOGISTICS_REPORT} />
</CorePageContainer>
);
}
@@ -0,0 +1,16 @@
import { create } from 'zustand';
import { EnterpriseModuleState } from '@repo/ui/foundations';
import type { ReportShellEntity } from '../../domain/constants/reports.constants';
export const logisticsReportsStore = create<EnterpriseModuleState<ReportShellEntity>>((set) => ({
metaData: { limit: 15 },
setMetaData: (data) => set({ metaData: data }),
filterData: {},
setFilterData: (data) => set({ filterData: data }),
selectedRows: [],
setSelectedRows: (rows) => set({ selectedRows: rows }),
privileges: [],
setPrivileges: (privileges) => set({ privileges }),
tableConfig: null,
setTableConfig: (config) => set({ tableConfig: config }),
}));
@@ -1,6 +1,6 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { EmbeddedComingSoonPage } from '../../../../../core/components/coming-soon-page';
const LogisticsReportsModule = lazy(() => import('../logistics-reports/presentation/factory'));
const EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory'));
const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
@@ -14,7 +14,7 @@ export default function LogisticsFieldModule() {
<Route path="/cycles/*" element={<CyclesModule purpose="logistics" />} />
<Route path="/plans/*" element={<PlansModule purpose="logistics" />} />
<Route path="/packing-slips/*" element={<PackingSlipsModule />} />
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
<Route path="/reports/*" element={<LogisticsReportsModule />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
);
@@ -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',
@@ -23,6 +23,7 @@
"status_updated": "Status updated.",
"action_complete": "Complete",
"action_cancel": "Cancel",
"action_process": "Process",
"complete_packing": "Complete packing",
"complete_packing_help": "Enter delivered quantity for each line. Remaining quantity opens a new packing slip.",
"delivered_quantity": "Delivered quantity",
@@ -23,6 +23,7 @@
"status_updated": "Status diperbarui.",
"action_complete": "Selesaikan",
"action_cancel": "Batalkan",
"action_process": "Proses",
"complete_packing": "Selesaikan packing",
"complete_packing_help": "Masukkan kuantitas terkirim untuk setiap baris. Sisa kuantitas akan membuka surat jalan baru.",
"delivered_quantity": "Kuantitas terkirim",
@@ -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,7 +20,62 @@ 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,
customers: [{ id: 'cus-1', name: 'Acme', address: 'Jl Sudirman', latitude: -6.2, longitude: 106.8 }],
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.customers?.[0]).toMatchObject({
address: 'Jl Sudirman',
latitude: -6.2,
longitude: 106.8,
});
});
});
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,11 +1,30 @@
import { z } from 'zod';
const relationSchema = z.object({
id: z.string(),
code: z.string().optional(),
name: z.string().optional(),
});
import { calendarDateToday, isCalendarDateBefore } from '../plan-date';
import { documentsBelongToCustomers, type PlanDocumentRef } from '../plan-documents';
export const createPlanSchema = (t: (key: string) => string) => {
const relationSchema = z
.object({
id: z.string(),
code: z.string().optional(),
name: z.string().optional(),
})
.passthrough();
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(),
@@ -21,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(),
@@ -60,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>
);
}
@@ -0,0 +1,73 @@
import { Box, Paper, Stack, Table, Text } from '@repo/ui/components';
import { RouteMap } from '@repo/ui/map';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { customersDataService } from '../../../../../configuration/customers/domain/factories';
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
import type { BranchEntity } from '../../../../../configuration/branches/domain/entities';
import { needsCustomerHydration, toPlanTrackGeometry, unwrapEntity } from './plan-form-preview';
import { useHydratedRecords } from './use-hydrated-records';
async function fetchCustomer(id: string): Promise<CustomerEntity | null> {
return unwrapEntity<CustomerEntity>(await customersDataService.getOne(id));
}
export function FormCustomersPreview({
customers,
startBranch,
endBranch,
}: {
customers: CustomerEntity[];
startBranch?: BranchEntity | null;
endBranch?: BranchEntity | null;
}) {
const { t } = useEnterpriseModuleTranslationContext();
const { records: hydrated } = useHydratedRecords(customers, fetchCustomer, needsCustomerHydration);
const geometry = toPlanTrackGeometry(startBranch, hydrated, endBranch);
if (hydrated.length === 0) return null;
return (
<Stack gap="md">
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_preview_customers')}
</Text>
<Box style={{ overflowX: 'auto' }}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('common:fields.code')}</Table.Th>
<Table.Th>{t('common:fields.name')}</Table.Th>
<Table.Th>{t('common:fields.phone')}</Table.Th>
<Table.Th>{t('common:fields.address')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{hydrated.map((customer, index) => (
<Table.Tr key={String(customer.id ?? index)}>
<Table.Td>{customer.code || '-'}</Table.Td>
<Table.Td>{customer.name || '-'}</Table.Td>
<Table.Td>{customer.phone || '-'}</Table.Td>
<Table.Td>{customer.address || '-'}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</Paper>
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_preview_track')}
</Text>
{geometry ? (
<RouteMap geometry={geometry} />
) : (
<Text size="sm" c="dimmed">
{t('empty_route')}
</Text>
)}
</Paper>
</Stack>
);
}
@@ -0,0 +1,209 @@
import {
Box,
FieldValue,
Paper,
RenderCurrency,
RenderDate,
SimpleGrid,
Stack,
StatusBadge,
Table,
Text,
} from '@repo/ui/components';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { formatDecimal, formatRupiah } from '@repo/utils';
import { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories';
import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories';
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, groupDocumentsByCustomer } from './plan-form-preview';
import { useHydratedRecords } from './use-hydrated-records';
type PreviewDocument = SalesDocumentEntity & {
balance?: string | null;
salesOrder?: { id: string; code?: string; name?: string } | null;
packingSlip?: { id: string; code?: string; name?: string } | null;
};
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));
}
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: PlanDocumentPreviewItem[];
customers?: Array<{ id?: string | number; code?: string | null; name?: string }>;
}) {
const { t } = useEnterpriseModuleTranslationContext();
const { records: hydratedInvoices, pending: invoicesPending } = useHydratedRecords(
kind === 'invoice' ? (items as SalesInvoiceEntity[]) : [],
fetchInvoice,
);
const { records: hydratedSlips, pending: slipsPending } = useHydratedRecords(
kind === 'packingSlip' ? (items as PackingSlipEntity[]) : [],
fetchPackingSlip,
);
const hydrated = kind === 'invoice' ? hydratedInvoices : hydratedSlips;
const pending = kind === 'invoice' ? invoicesPending : slipsPending;
const groups = groupDocumentsByCustomer(hydrated, customers);
if (hydrated.length === 0) return null;
return (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{kind === 'invoice' ? t('section_preview_invoices') : t('section_preview_packing_slips')}
</Text>
<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>
);
}
function DocumentPreviewCard({
document,
showBalance,
pending,
}: {
document: PreviewDocument;
showBalance: boolean;
pending: boolean;
}) {
const { t } = useEnterpriseModuleTranslationContext();
const lines = document.products ?? [];
const total = salesLinesTotal(lines);
return (
<Box>
<Text fw={600} mb="sm">
{document.code || relationLabel(document) || document.id}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" mb="md">
<FieldValue
label={t('common:fields.date')}
value={document.date}
render={(val) => <RenderDate value={typeof val === 'string' || typeof val === 'number' ? val : null} />}
/>
<FieldValue
label={t('common:fields.customer')}
value={relationLabel(document.customer) || document.customerId}
/>
<FieldValue
label={t('common:fields.status')}
value={document.status}
render={(val) => <StatusBadge status={String(val ?? '')} />}
/>
{showBalance ? (
<FieldValue
label={t('common:fields.balance')}
value={document.balance}
render={(val) => <RenderCurrency value={val as string | number | null} />}
/>
) : (
<FieldValue
label={t('common:fields.salesOrder')}
value={relationLabel(document.salesOrder) || document.salesOrder?.id}
/>
)}
</SimpleGrid>
{showBalance && document.salesOrder ? (
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" mb="md">
<FieldValue label={t('common:fields.salesOrder')} value={relationLabel(document.salesOrder)} />
{document.packingSlip ? (
<FieldValue label={t('common:fields.packingSlip')} value={relationLabel(document.packingSlip)} />
) : null}
</SimpleGrid>
) : null}
<DocumentProductsTable lines={lines} total={total} pending={pending} />
</Box>
);
}
function DocumentProductsTable({
lines,
total,
pending,
}: {
lines: SalesLineEntity[];
total: number;
pending: boolean;
}) {
const { t } = useEnterpriseModuleTranslationContext();
return (
<Box style={{ overflowX: 'auto' }}>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{t('common:fields.product')}</Table.Th>
<Table.Th ta="right">{t('common:fields.quantity')}</Table.Th>
<Table.Th ta="right">{t('common:fields.price')}</Table.Th>
<Table.Th ta="right">{t('common:fields.lineTotal')}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={4}>
<Text size="sm" c="dimmed">
{pending ? t('preview_loading') : t('empty_products')}
</Text>
</Table.Td>
</Table.Tr>
) : (
lines.map((line, index) => {
const qty = Number(line.quantity);
const price = Number(line.price);
const lineTotal = Number.isFinite(qty) && Number.isFinite(price) ? qty * price : 0;
return (
<Table.Tr key={line.id ?? `${line.productId}-${index}`}>
<Table.Td>{relationLabel(line.product) || line.productId}</Table.Td>
<Table.Td ta="right">{formatDecimal(line.quantity) || '-'}</Table.Td>
<Table.Td ta="right">{line.price ? formatRupiah(line.price) : '-'}</Table.Td>
<Table.Td ta="right">{formatRupiah(lineTotal)}</Table.Td>
</Table.Tr>
);
})
)}
</Table.Tbody>
</Table>
{!pending || lines.length > 0 ? (
<Text fw={600} ta="right" mt="md">
{t('common:fields.total')}: {formatRupiah(total)}
</Text>
) : null}
</Box>
);
}
@@ -1,19 +1,43 @@
import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Text } from '@repo/ui/components';
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 { loadSalesInvoiceOptions, loadPackingSlipOptions } from '../../../../shared/lookup.factories';
import { relationLabel } from '../../../../shared/relation-label';
import { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories';
import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories';
import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
import type { BranchEntity } from '../../../../../configuration/branches/domain/entities';
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
import type { LookupEntity } from '../../../../shared/lookup.entity';
import type { SalesInvoiceEntity } from '../../../../../sales/invoices/domain/entities';
import type { PackingSlipEntity } from '../../../../packing-slips/domain/entities';
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';
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();
@@ -21,102 +45,158 @@ export function FormGeneral() {
const { config } = useEnterpriseModuleConfigContext();
const purpose = purposeFromModuleKey(config.moduleKey);
const employee = formControl.watch('employee');
const startBranch = formControl.watch('startBranch');
const endBranch = formControl.watch('endBranch');
const customers = formControl.watch('customers') ?? [];
const invoices = formControl.watch('invoices') ?? [];
const packingSlips = formControl.watch('packingSlips') ?? [];
const startBranch = formControl.watch('startBranch') as BranchEntity | null | undefined;
const endBranch = formControl.watch('endBranch') as BranchEntity | null | undefined;
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 (
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldAsyncSelect<EmployeeEntity>
control={formControl.control}
name="employee"
label={t('common:fields.employee')}
valueKey="id"
labelKey="name"
required
searchable
loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
defaultOptions={employee ? [employee] : []}
renderLabel={relationLabel}
/>
<FieldDatePicker control={formControl.control} name="date" label={t('common:fields.date')} required />
<FieldAsyncSelect<BranchEntity>
control={formControl.control}
name="startBranch"
label={t('common:fields.startBranch')}
valueKey="id"
labelKey="name"
required
searchable
loadOptions={loadBranchOptions}
defaultOptions={startBranch ? [startBranch] : []}
renderLabel={relationLabel}
/>
<FieldAsyncSelect<BranchEntity>
control={formControl.control}
name="endBranch"
label={t('common:fields.endBranch')}
valueKey="id"
labelKey="name"
required
searchable
loadOptions={loadBranchOptions}
defaultOptions={endBranch ? [endBranch] : []}
renderLabel={relationLabel}
/>
</SimpleGrid>
<Box mt="md">
<FieldAsyncSelect<CustomerEntity>
control={formControl.control}
name="customers"
label={t('common:fields.customers')}
valueKey="id"
labelKey="name"
required
searchable
multiple
loadOptions={loadCustomerOptions}
defaultOptions={customers}
renderLabel={relationLabel}
/>
</Box>
<Box mt="md">
{purpose === 'sales' ? (
<FieldAsyncSelect<LookupEntity>
<Stack gap="md">
<Paper withBorder shadow="sm" radius="md" p="xl">
<Text fw={600} mb="md">
{t('section_general')}
</Text>
<Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FieldAsyncSelect<EmployeeEntity>
control={formControl.control}
name="invoices"
label={t('common:fields.invoices')}
name="employee"
label={t('common:fields.employee')}
valueKey="id"
labelKey="code"
labelKey="name"
required
searchable
multiple
loadOptions={loadSalesInvoiceOptions}
defaultOptions={invoices}
loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
defaultOptions={employee ? [employee] : []}
renderLabel={relationLabel}
/>
) : (
<FieldAsyncSelect<LookupEntity>
<FieldDatePicker
control={formControl.control}
name="packingSlips"
label={t('common:fields.packingSlips')}
name="date"
label={t('common:fields.date')}
required
minDate={minDate ?? undefined}
/>
<FieldAsyncSelect<BranchEntity>
control={formControl.control}
name="startBranch"
label={t('common:fields.startBranch')}
valueKey="id"
labelKey="code"
labelKey="name"
required
searchable
multiple
loadOptions={loadPackingSlipOptions}
defaultOptions={packingSlips}
loadOptions={loadBranchOptions}
defaultOptions={startBranch ? [startBranch] : []}
renderLabel={relationLabel}
/>
)}
<FieldAsyncSelect<BranchEntity>
control={formControl.control}
name="endBranch"
label={t('common:fields.endBranch')}
valueKey="id"
labelKey="name"
required
searchable
loadOptions={loadBranchOptions}
defaultOptions={endBranch ? [endBranch] : []}
renderLabel={relationLabel}
/>
</SimpleGrid>
<Box mt="md">
<FieldAsyncSelect<CustomerEntity>
control={formControl.control}
name="customers"
label={t('common:fields.customers')}
valueKey="id"
labelKey="name"
required
searchable
multiple
loadOptions={loadCustomerOptions}
defaultOptions={customers}
renderLabel={relationLabel}
/>
</Box>
<Box mt="md">
{purpose === 'sales' ? (
<FieldAsyncSelect<SalesInvoiceEntity>
key={`invoices-${customerIdsKey}`}
control={formControl.control}
name="invoices"
label={t('common:fields.invoices')}
valueKey="id"
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')}
valueKey="id"
labelKey="code"
searchable
multiple
disabled={documentsDisabled}
description={documentsDisabled ? t('select_customers_first') : undefined}
loadOptions={loadSlipOptions}
defaultOptions={packingSlips}
renderLabel={documentOptionLabel}
/>
)}
</Box>
</Box>
</Box>
</Paper>
</Paper>
<FormCustomersPreview customers={customers} startBranch={startBranch} endBranch={endBranch} />
{purpose === 'sales' ? (
<FormDocumentsPreview kind="invoice" items={hydratedInvoices} customers={customers} />
) : (
<FormDocumentsPreview kind="packingSlip" items={hydratedSlips} customers={customers} />
)}
</Stack>
);
}
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest';
import {
documentCustomerId,
documentOptionLabel,
groupDocumentsByCustomer,
keepDocumentsForCustomers,
mergeHydrated,
needsCustomerHydration,
salesLinesTotal,
selectionIds,
toPlanTrackGeometry,
unwrapEntity,
} from './plan-form-preview';
describe('selectionIds', () => {
it('returns string ids and drops empty values', () => {
expect(selectionIds([{ id: 'a' }, { id: 2 }, {}, { id: '' }])).toEqual(['a', '2']);
});
});
describe('mergeHydrated', () => {
it('replaces selected stubs with fetched details by id', () => {
const merged = mergeHydrated(
[
{ id: 'cus-1', name: 'Stub' },
{ id: 'cus-2', name: 'Keep' },
],
{ 'cus-1': { id: 'cus-1', name: 'Acme', address: 'Jl Sudirman' } },
);
expect(merged).toEqual([
{ id: 'cus-1', name: 'Acme', address: 'Jl Sudirman' },
{ id: 'cus-2', name: 'Keep' },
]);
});
});
describe('needsCustomerHydration', () => {
it('is true when only an id is present', () => {
expect(needsCustomerHydration({})).toBe(true);
expect(needsCustomerHydration({ latitude: null, longitude: null })).toBe(true);
});
it('is false when address, phone, or coordinates are present', () => {
expect(needsCustomerHydration({ address: 'Jl Sudirman' })).toBe(false);
expect(needsCustomerHydration({ phone: '+62811' })).toBe(false);
expect(needsCustomerHydration({ latitude: -6.2, longitude: 106.8 })).toBe(false);
});
});
describe('unwrapEntity', () => {
it('reads nested data envelopes from getOne', () => {
expect(unwrapEntity({ data: { data: { id: 'inv-1' } } })).toEqual({ id: 'inv-1' });
});
it('returns null when the envelope is empty', () => {
expect(unwrapEntity({ data: { data: undefined } })).toBeNull();
expect(unwrapEntity(null)).toBeNull();
});
});
describe('salesLinesTotal', () => {
it('sums quantity times price and ignores invalid lines', () => {
expect(
salesLinesTotal([
{ quantity: '2', price: '1000' },
{ quantity: '1', price: '500.5' },
{ quantity: 'x', price: '10' },
]),
).toBe(2500.5);
});
});
describe('toPlanTrackGeometry', () => {
it('builds a LineString from start branch, customers, and end branch', () => {
expect(
toPlanTrackGeometry(
{ latitude: -6.1, longitude: 106.7 },
[
{ latitude: -6.2, longitude: 106.8 },
{ latitude: -6.3, longitude: 106.9 },
],
{ latitude: -6.4, longitude: 107.0 },
),
).toEqual({
type: 'LineString',
coordinates: [
[106.7, -6.1],
[106.8, -6.2],
[106.9, -6.3],
[107.0, -6.4],
],
});
});
it('skips missing coordinates and returns null when nothing is plottable', () => {
expect(toPlanTrackGeometry(null, [{ latitude: null, longitude: null }], undefined)).toBeNull();
expect(toPlanTrackGeometry(null, [{ latitude: -6.2, longitude: 106.8 }])).toEqual({
type: 'LineString',
coordinates: [[106.8, -6.2]],
});
});
});
describe('documentOptionLabel', () => {
it('joins document code with customer when both exist', () => {
expect(
documentOptionLabel({
id: 'inv-1',
code: 'INV-1',
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
}),
).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' }],
},
]);
});
});
@@ -0,0 +1,121 @@
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);
}
export function mergeHydrated<T extends { id?: string | number }>(selected: T[], details: Record<string, T>): T[] {
return selected.map((item) => {
const id = item.id == null ? '' : String(item.id);
const hydrated = details[id];
return hydrated ? { ...item, ...hydrated } : item;
});
}
export function needsCustomerHydration(customer: {
address?: string | null;
phone?: string | null;
latitude?: number | null;
longitude?: number | null;
}): boolean {
const hasLocation =
customer.latitude != null &&
customer.longitude != null &&
Number.isFinite(Number(customer.latitude)) &&
Number.isFinite(Number(customer.longitude));
return !customer.address && !customer.phone && !hasLocation;
}
export function unwrapEntity<T>(result: { data?: { data?: T } | T } | null | undefined): T | null {
if (!result?.data) return null;
const payload = result.data;
if (typeof payload === 'object' && payload !== null && 'data' in payload) {
return (payload as { data?: T }).data ?? null;
}
return payload as T;
}
export function salesLinesTotal(lines: Array<{ quantity?: string; price?: string | null }> | undefined): number {
return (lines ?? []).reduce((sum, line) => {
const qty = Number(line.quantity);
const price = Number(line.price);
if (!Number.isFinite(qty) || !Number.isFinite(price)) return sum;
return sum + qty * price;
}, 0);
}
export function toPlanTrackGeometry(
startBranch?: GeoPoint | null,
customers: GeoPoint[] = [],
endBranch?: GeoPoint | null,
): RouteGeometry | null {
const points: Array<[number, number]> = [];
for (const point of [startBranch, ...customers, endBranch]) {
if (point?.latitude == null || point?.longitude == null) continue;
const lat = Number(point.latitude);
const lng = Number(point.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
points.push([lng, lat]);
}
if (points.length === 0) return null;
return { type: 'LineString', coordinates: points };
}
export function documentOptionLabel(item: {
code?: string | null;
name?: string;
id?: string | number;
customer?: { code?: string | null; name?: string; id?: string | number } | null;
}) {
const base = relationLabel(item);
const customer = relationLabel(item.customer);
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;
}
@@ -0,0 +1,67 @@
import { useEffect, useRef, useState } from 'react';
import { mergeHydrated, selectionIds } from './plan-form-preview';
export function useHydratedRecords<T extends { id?: string | number }>(
selected: T[] | undefined,
fetchOne: (id: string) => Promise<T | null>,
shouldFetch: (item: T) => boolean = () => true,
): { records: T[]; pending: boolean } {
const items = selected ?? [];
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);
itemsRef.current = items;
fetchOneRef.current = fetchOne;
shouldFetchRef.current = shouldFetch;
useEffect(() => {
let cancelled = false;
const toLoad = itemsRef.current.filter((item) => {
const id = item.id == null ? '' : String(item.id);
return Boolean(id) && shouldFetchRef.current(item);
});
if (toLoad.length === 0) return undefined;
void Promise.all(
toLoad.map(async (item) => {
const id = String(item.id);
try {
return { id, entity: await fetchOneRef.current(id) };
} catch {
return { id, entity: null };
}
}),
).then((rows) => {
if (cancelled) return;
setDetails((prev) => {
const next = { ...prev };
for (const row of rows) {
if (row.entity) next[row.id] = row.entity;
}
return next;
});
setSettled((prev) => {
const next = { ...prev };
for (const row of rows) {
next[row.id] = true;
}
return next;
});
});
return () => {
cancelled = true;
};
}, [idsKey]);
const pending = items.some((item) => {
const id = item.id == null ? '' : String(item.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')}
@@ -13,13 +13,20 @@
"section_route": "Route",
"section_destinations": "Destinations",
"section_attachments": "Attachments",
"section_preview_customers": "Customer preview",
"section_preview_track": "Track preview",
"section_preview_invoices": "Invoice preview",
"section_preview_packing_slips": "Packing slip preview",
"generate": "Generate",
"generate_title": "Generate plans",
"generate_success": "Created {{created}} plan(s), skipped {{skipped}}.",
"add_destination": "Add destination",
"remove_destination": "Remove destination",
"empty_route": "No route geometry",
"section_attachments": "Attachments",
"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",
@@ -13,13 +13,20 @@
"section_route": "Rute",
"section_destinations": "Destinasi",
"section_attachments": "Lampiran",
"section_preview_customers": "Pratinjau pelanggan",
"section_preview_track": "Pratinjau rute",
"section_preview_invoices": "Pratinjau faktur",
"section_preview_packing_slips": "Pratinjau surat jalan",
"generate": "Generate",
"generate_title": "Generate rencana",
"generate_success": "Berhasil membuat {{created}} rencana, {{skipped}} dilewati.",
"add_destination": "Tambah destinasi",
"remove_destination": "Hapus destinasi",
"empty_route": "Tidak ada geometri rute",
"section_attachments": "Lampiran",
"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 (
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from 'vitest';
import { createOptionLoader } from './create-option-loader';
describe('createOptionLoader', () => {
it('requests the first page with search and limit', async () => {
const getMany = vi.fn().mockResolvedValue({
data: { data: [{ id: 'a' }], meta: { totalPages: 1 } },
});
const load = createOptionLoader(getMany);
const result = await load('ada', 1, []);
expect(getMany).toHaveBeenCalledWith({
params: { search: 'ada', page: 1, limit: 20 },
});
expect(result).toEqual({ options: [{ id: 'a' }], hasMore: false });
});
it('merges extra params so lookups can require active status', async () => {
const getMany = vi.fn().mockResolvedValue({
data: { data: [{ id: 'a', status: 'active' }], meta: { totalPages: 3 } },
});
const load = createOptionLoader(getMany, { status: 'active' });
const result = await load('', 2, []);
expect(getMany).toHaveBeenCalledWith({
params: { search: '', page: 2, limit: 20, status: 'active' },
});
expect(result.hasMore).toBe(true);
});
});
@@ -1,11 +1,14 @@
import type { LoadOptionsFn } from '@repo/ui/form';
export const ACTIVE_LOOKUP_PARAMS = { status: 'active' } as const;
export function createOptionLoader<T>(
getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>,
extraParams?: Record<string, unknown>,
): LoadOptionsFn<T> {
return async (search, page) => {
const result = await getMany({
params: { search, page, limit: 20 },
params: { search, page, limit: 20, ...extraParams },
});
const rows = (result.data as { data?: T[]; meta?: { totalPages?: number } })?.data ?? [];
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
@@ -1,5 +1,8 @@
import { branchesDataService } from '../../configuration/branches/domain/factories';
import type { BranchEntity } from '../../configuration/branches/domain/entities';
import { createOptionLoader } from './create-option-loader';
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
export const loadBranchOptions = createOptionLoader<BranchEntity>((config) => branchesDataService.getMany(config));
export const loadBranchOptions = createOptionLoader<BranchEntity>(
(config) => branchesDataService.getMany(config),
ACTIVE_LOOKUP_PARAMS,
);
@@ -1,5 +1,8 @@
import { customersDataService } from '../../configuration/customers/domain/factories';
import type { CustomerEntity } from '../../configuration/customers/domain/entities';
import { createOptionLoader } from './create-option-loader';
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
export const loadCustomerOptions = createOptionLoader<CustomerEntity>((config) => customersDataService.getMany(config));
export const loadCustomerOptions = createOptionLoader<CustomerEntity>(
(config) => customersDataService.getMany(config),
ACTIVE_LOOKUP_PARAMS,
);
@@ -6,7 +6,7 @@ import {
salesEmployeesDataService,
} from '../../configuration/employees/domain/factories';
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
import { createOptionLoader } from './create-option-loader';
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
function employeeServiceForPurpose(purpose?: FieldPurpose) {
if (purpose === 'sales') {
@@ -20,7 +20,7 @@ function employeeServiceForPurpose(purpose?: FieldPurpose) {
export function loadEmployeeOptionsForPurpose(purpose?: FieldPurpose): LoadOptionsFn<EmployeeEntity> {
const service = employeeServiceForPurpose(purpose);
return createOptionLoader<EmployeeEntity>((config) => service.getMany(config));
return createOptionLoader<EmployeeEntity>((config) => service.getMany(config), ACTIVE_LOOKUP_PARAMS);
}
export const loadSalesEmployeeOptions = loadEmployeeOptionsForPurpose('sales');
@@ -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."
}
}

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