Author SHA1 Message Date
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
117 changed files with 3301 additions and 409 deletions
@@ -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.
@@ -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;
}
@@ -106,10 +106,10 @@ export const MENU_ITEMS: MenuItemType[] = [
},
{
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: 'SALES.REPORT',
},
],
},
@@ -165,10 +165,10 @@ export const MENU_ITEMS: MenuItemType[] = [
},
{
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: 'LOGISTICS.REPORT',
},
],
},
@@ -31,6 +31,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",
@@ -31,6 +31,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",
@@ -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,
);
@@ -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: '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>
);
@@ -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",
@@ -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(',');
}
@@ -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'),
});
}
});
@@ -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,202 @@
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));
}
export function FormDocumentsPreview({
kind,
items,
customers = [],
}: {
kind: 'invoice' | 'packingSlip';
items: Array<SalesInvoiceEntity | PackingSlipEntity>;
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,59 @@
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 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;
});
});
return () => {
cancelled = true;
};
}, [idsKey]);
const pending = items.some((item) => {
const id = item.id == null ? '' : String(item.id);
return Boolean(id) && shouldFetch(item) && !details[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');
@@ -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 ReportsModule = lazy(() => import('./reports/presentation/factory'));
const RequestsModule = lazy(() => import('./requests/presentation/factory'));
const OrdersModule = lazy(() => import('./orders/presentation/factory'));
@@ -20,7 +20,7 @@ export default function SalesModule() {
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
<Route path="/invoices/*" element={<InvoicesModule />} />
<Route path="/payments/*" element={<PaymentsModule />} />
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
<Route path="/reports/*" element={<ReportsModule />} />
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
</Routes>
);
@@ -23,6 +23,7 @@
"import_success": "CSV imported.",
"status_updated": "Status updated.",
"create_sales_payment": "Create Sales Payment",
"action_process": "Process",
"action_cancel": "Cancel",
"status_draft": "Draft",
"status_processed": "Processed",
@@ -23,6 +23,7 @@
"import_success": "CSV berhasil diimpor.",
"status_updated": "Status diperbarui.",
"create_sales_payment": "Buat Pembayaran Penjualan",
"action_process": "Proses",
"action_cancel": "Batalkan",
"status_draft": "Draft",
"status_processed": "Diproses",
@@ -6,6 +6,7 @@ import { DetailGeneral } from '../../../shared/detail-general';
import { DetailLocation } from '../../../shared/detail-location';
import { DetailProducts } from '../../../shared/detail-products';
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
import { isPayableInvoice } from '../../../shared/payable-invoice';
import { salesOrdersModuleConfig } from '../../../orders/domain/constants';
import { salesPaymentsModuleConfig } from '../../../payments/domain/constants';
import type { SalesInvoiceEntity } from '../../domain/entities';
@@ -27,10 +28,13 @@ export default function SalesInvoicePageDetail() {
],
}}
customPageActions={(data, pageActions) => {
const createPayment = actions.createPaymentAction(data as SalesInvoiceEntity, () => {
navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`);
});
const withStatus = actions.detailStatusActions(data as SalesInvoiceEntity, pageActions ?? []);
const invoice = data as SalesInvoiceEntity;
const createPayment = isPayableInvoice(invoice)
? actions.createPaymentAction(invoice, () => {
navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`);
})
: null;
const withStatus = actions.detailStatusActions(invoice, pageActions ?? []);
return createPayment ? [createPayment, ...withStatus] : withStatus;
}}
>
@@ -7,6 +7,7 @@ import {
import { ColDef, Text } from '@repo/ui/components';
import { Trans } from '@repo/core-i18n';
import { Receipt } from 'lucide-react';
import { formatRupiah } from '@repo/utils';
import { SalesFilterFormContent } from '../../../shared/filter-content';
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
import { relationLabel } from '../../../../field/shared/relation-label';
@@ -38,7 +39,12 @@ export default function SalesInvoicePageIndex() {
minWidth: 160,
valueGetter: ({ data }) => relationLabel(data?.branch) || data?.branchId,
},
{ field: 'balance', headerName: t('common:fields.balance'), minWidth: 140 },
{
field: 'balance',
headerName: t('common:fields.balance'),
minWidth: 140,
valueFormatter: ({ value }) => formatRupiah(value) || '-',
},
],
[t],
);
@@ -1,4 +1,3 @@
import { useNavigate } from 'react-router-dom';
import { Stack } from '@repo/ui/components';
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { salesOrdersModuleConfig } from '../../domain/constants';
@@ -9,12 +8,10 @@ import { DetailImages } from '../../../shared/detail-images';
import { DetailRelated } from '../components/detail-component/detail-related';
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
import { salesRequestsModuleConfig } from '../../../requests/domain/constants';
import { salesInvoicesModuleConfig } from '../../../invoices/domain/constants';
import type { SalesOrderEntity } from '../../domain/entities';
export default function SalesOrderPageDetail() {
const { t } = useEnterpriseModuleTranslationContext();
const navigate = useNavigate();
const actions = useSalesDocumentActions('order');
return (
@@ -28,13 +25,9 @@ export default function SalesOrderPageDetail() {
{ label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` },
],
}}
customPageActions={(data, pageActions) => {
const createInvoice = actions.createInvoiceAction(data as SalesOrderEntity, () => {
navigate(`${salesInvoicesModuleConfig.webUrl}/create?salesOrderId=${data.id}`);
});
const withStatus = actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? []);
return createInvoice ? [createInvoice, ...withStatus] : withStatus;
}}
customPageActions={(data, pageActions) =>
actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? [])
}
>
<Stack gap="md">
<DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
@@ -1,6 +1,7 @@
import { BaseDataTransformer } from '@repo/core-api/data-services';
import { formatDateValue, parseDateValue } from '@repo/ui/form';
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
import { toDecimalStringValue } from '../../../../../../../core/domain/decimal-string.schema';
import { mapSalesImagesFromDto, relationId, toLookup } from '../../../shared/sales-document.mapper';
import type { SalesPaymentDto, SalesPaymentEntity } from '../entities';
@@ -51,7 +52,7 @@ export class SalesPaymentsRemoteDataTransformer extends BaseDataTransformer<Sale
.map((row) => {
const invoiceId = relationId(row.invoice) ?? row.invoiceId;
if (!invoiceId) return null;
return omitEmptyFields({ invoiceId, amount: row.amount });
return omitEmptyFields({ invoiceId, amount: toDecimalStringValue(row.amount) });
})
.filter(Boolean);
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { compose, maxLength, required } from '@repo/ui/validators';
import { decimalStringSchema } from '../../../../../../../core/domain/decimal-string.schema';
import { decimalStringSchema, PRICE_DECIMAL_SCALE } from '../../../../../../../core/domain/decimal-string.schema';
import { salesImageSchema } from '../../../shared/sales-document.validator';
const NOTES_MAX = 1024;
@@ -40,7 +40,7 @@ export function createSalesPaymentSchema(t: (key: string) => string) {
z.object({
id: z.string().optional(),
invoice: relationSchema,
amount: decimalStringSchema(t, 'common:fields.amount'),
amount: decimalStringSchema(t, 'common:fields.amount', PRICE_DECIMAL_SCALE),
}),
)
.min(1),
@@ -1,5 +1,6 @@
import { Box, Paper, Table, Text } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { formatRupiah } from '@repo/utils';
import type { SalesPaymentEntity } from '../../../domain/entities';
import { relationLabel } from '../../../../../field/shared/relation-label';
@@ -34,7 +35,7 @@ export function DetailAllocations() {
rows.map((row, index) => (
<Table.Tr key={row.id ?? `${row.invoiceId}-${index}`}>
<Table.Td>{relationLabel(row.invoice) || row.invoiceId}</Table.Td>
<Table.Td ta="right">{row.amount}</Table.Td>
<Table.Td ta="right">{formatRupiah(row.amount)}</Table.Td>
</Table.Tr>
))
)}
@@ -3,7 +3,7 @@ import {
Box,
Button,
FieldAsyncSelect,
FieldTextInput,
FieldCurrencyInput,
Group,
Paper,
Table,
@@ -12,9 +12,10 @@ import {
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { useFieldArray, useWatch } from '@repo/ui/form';
import { Plus, Trash2 } from 'lucide-react';
import { loadSalesInvoiceOptions } from '../../../../../field/shared/lookup.factories';
import { loadPayableSalesInvoiceOptions } from '../../../../shared/load-payable-sales-invoice-options';
import { isPayableInvoice } from '../../../../shared/payable-invoice';
import { relationLabel } from '../../../../../field/shared/relation-label';
import type { LookupEntity } from '../../../../../field/shared/lookup.entity';
import type { SalesInvoiceEntity } from '../../../../invoices/domain/entities';
export function FormAllocations() {
const { formControl } = useFormPageContext();
@@ -45,19 +46,20 @@ export function FormAllocations() {
return (
<Table.Tr key={field.id}>
<Table.Td miw={240}>
<FieldAsyncSelect<LookupEntity>
<FieldAsyncSelect<SalesInvoiceEntity>
control={formControl.control}
name={`invoices.${index}.invoice`}
valueKey="id"
labelKey="code"
searchable
loadOptions={loadSalesInvoiceOptions}
loadOptions={loadPayableSalesInvoiceOptions}
filterOption={(item) => isPayableInvoice(item)}
defaultOptions={line?.invoice ? [line.invoice] : []}
renderLabel={relationLabel}
/>
</Table.Td>
<Table.Td miw={140}>
<FieldTextInput control={formControl.control} name={`invoices.${index}.amount`} radius="md" />
<FieldCurrencyInput control={formControl.control} name={`invoices.${index}.amount`} radius="md" />
</Table.Td>
<Table.Td>
<ActionIcon
@@ -11,6 +11,7 @@ import { FormAllocations } from '../components/form-component/form-allocations';
import { FormImages } from '../../../shared/form-images';
import { FormNotes } from '../../../shared/form-notes';
import { salesInvoicesDataService } from '../../../invoices/domain/factories';
import { isPayableInvoice } from '../../../shared/payable-invoice';
import type { SalesInvoiceEntity } from '../../../invoices/domain/entities';
export default function SalesPaymentPageForm({ formPageType }: { formPageType: FormPageType }) {
@@ -46,7 +47,7 @@ export default function SalesPaymentPageForm({ formPageType }: { formPageType: F
prefilled.current = true;
void salesInvoicesDataService.getOne(invoiceId).then((result) => {
const entity = (result.data as { data?: SalesInvoiceEntity })?.data;
if (!entity) return;
if (!entity || !isPayableInvoice(entity)) return;
formControl.reset({
invoices: [
{
@@ -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 salesReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
moduleKey: 'SALES.REPORT',
translationNamespace: 'SALES_REPORTS',
apiUrl: '/reports',
webUrl: '/app/sales/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 { salesReportsModuleConfig, 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 salesReportsDataService = new TrackGoRemoteDataServices(apiClient, {
apiUrl: salesReportsModuleConfig.apiUrl,
moduleKey: salesReportsModuleConfig.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 { salesReportsModuleConfig, type ReportShellEntity } from '../../domain/constants/reports.constants';
import { salesReportsDataService } from '../../domain/factories';
import { salesReportsStore } 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(salesReportsModuleConfig.translationNamespace, {
en: reportsEn,
id: reportsId,
});
export default function SalesReportsModule() {
return (
<EnterpriseModuleProvider<ReportShellEntity>
config={salesReportsModuleConfig}
dataServices={salesReportsDataService}
store={salesReportsStore}
>
<Routes>
<Route path="/index" element={<IndexPage />} />
<Route path="/" element={<Navigate to={`${salesReportsModuleConfig.webUrl}/index`} replace />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</EnterpriseModuleProvider>
);
}
@@ -0,0 +1,4 @@
{
"title": "Sales Reports",
"description": "Config-driven sales report tables"
}
@@ -0,0 +1,4 @@
{
"title": "Laporan Penjualan",
"description": "Tabel laporan penjualan 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 { salesReportsModuleConfig } from '../../domain/constants/reports.constants';
export default function SalesReportsPage() {
const { t } = useTranslation(salesReportsModuleConfig.translationNamespace);
const { t: tNav } = useTranslation('nav');
return (
<CorePageContainer>
<ModulePageHeader
icon={FileText}
title={t('title')}
description={t('description')}
moduleKey={salesReportsModuleConfig.moduleKey}
breadcrumbs={[
{ label: tNav('sales'), type: 'text' },
{ label: t('title'), type: 'text' },
]}
/>
<ReportProvider groupName={REPORT_GROUP.SALES_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 salesReportsStore = 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,50 +0,0 @@
import { Button, FieldSelect, Group, Modal, Stack } from '@repo/ui/components';
import { useForm } from 'react-hook-form';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { allowedTransitions, type SalesDocumentType } from './sales-status';
export function ChangeStatusModal({
opened,
onClose,
documentType,
currentStatus,
onSubmit,
}: {
opened: boolean;
onClose: () => void;
documentType: SalesDocumentType;
currentStatus?: string | null;
onSubmit: (status: string) => Promise<void> | void;
}) {
const { t } = useEnterpriseModuleTranslationContext();
const form = useForm<{ status: string }>();
const options = allowedTransitions(documentType, currentStatus);
const handleSubmit = form.handleSubmit(async (values) => {
await onSubmit(values.status);
form.reset();
onClose();
});
return (
<Modal opened={opened} onClose={onClose} title={t('change_status')}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<FieldSelect
control={form.control as any}
name="status"
label={t('common:fields.status')}
required
data={options.map((value) => ({ value, label: t(`status_${value}`) }))}
/>
<Group justify="flex-end">
<Button variant="default" type="button" onClick={onClose}>
{t('common:cancel')}
</Button>
<Button type="submit">{t('change_status')}</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -1,9 +1,10 @@
import { Button, FieldTextInput, Group, Modal, Stack, Text } from '@repo/ui/components';
import { Button, FieldCurrencyInput, Group, Modal, Stack, Text } from '@repo/ui/components';
import { useEffect } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { relationLabel } from '../../field/shared/relation-label';
import type { SalesLineEntity } from './sales-document.entity';
import { toDecimalStringValue } from '../../../../../core/domain/decimal-string.schema';
export function CompletePackingModal({
opened,
@@ -17,7 +18,7 @@ export function CompletePackingModal({
onSubmit: (products: Array<{ productId: string; quantity: string }>) => Promise<void> | void;
}) {
const { t } = useEnterpriseModuleTranslationContext();
const form = useForm<{ products: Array<{ productId: string; quantity: string; label: string }> }>({
const form = useForm<{ products: Array<{ productId: string; quantity: string | number; label: string }> }>({
defaultValues: { products: [] },
});
const { fields } = useFieldArray({ control: form.control, name: 'products' });
@@ -37,7 +38,7 @@ export function CompletePackingModal({
await onSubmit(
values.products.map((line) => ({
productId: line.productId,
quantity: line.quantity,
quantity: toDecimalStringValue(line.quantity) ?? '',
})),
);
onClose();
@@ -49,10 +50,11 @@ export function CompletePackingModal({
<Stack gap="md">
<Text size="sm">{t('complete_packing_help')}</Text>
{fields.map((field, index) => (
<FieldTextInput
<FieldCurrencyInput
key={field.id}
control={form.control as any}
name={`products.${index}.quantity`}
prefix=""
label={`${t('delivered_quantity')} — ${form.getValues(`products.${index}.label`) || field.label}`}
required
/>
@@ -1,4 +1,14 @@
import { Anchor, Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
import {
Anchor,
Box,
Paper,
SimpleGrid,
FieldValue,
RenderCurrency,
RenderDate,
Text,
StatusBadge,
} from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import type { SalesDocumentEntity } from './sales-document.entity';
import { relationLabel } from '../../field/shared/relation-label';
@@ -83,7 +93,13 @@ export function DetailGeneral({
}
/>
)}
{showBalance ? <FieldValue label={t('common:fields.balance')} value={data?.balance} /> : null}
{showBalance ? (
<FieldValue
label={t('common:fields.balance')}
value={data?.balance}
render={(val) => <RenderCurrency value={val as string | number | null} />}
/>
) : null}
{salesRequestHref && (
<FieldValue
label={t('common:fields.salesRequest')}
@@ -1,11 +1,9 @@
import { Box, Paper, Table, Text } from '@repo/ui/components';
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
import { CurrencyUtils } from '@repo/utils';
import { formatDecimal, formatRupiah } from '@repo/utils';
import type { SalesDocumentEntity } from './sales-document.entity';
import { relationLabel } from '../../field/shared/relation-label';
const currency = new CurrencyUtils({ decimalScale: 4 });
export function DetailProducts() {
const { detailData } = useDetailPageContext<SalesDocumentEntity>();
const { t } = useEnterpriseModuleTranslationContext();
@@ -49,9 +47,9 @@ export function DetailProducts() {
return (
<Table.Tr key={line.id ?? `${line.productId}-${index}`}>
<Table.Td>{relationLabel(line.product) || line.productId}</Table.Td>
<Table.Td ta="right">{line.quantity}</Table.Td>
<Table.Td ta="right">{line.price ? currency.format(line.price) : '-'}</Table.Td>
<Table.Td ta="right">{currency.format(total)}</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(total)}</Table.Td>
</Table.Tr>
);
})
@@ -60,7 +58,7 @@ export function DetailProducts() {
</Table>
</Box>
<Text fw={600} ta="right" mt="md">
{t('common:fields.total')}: {currency.format(grandTotal)}
{t('common:fields.total')}: {formatRupiah(grandTotal)}
</Text>
</Paper>
);
@@ -5,6 +5,7 @@ import { loadBranchOptions } from '../../field/shared/load-branch-options';
import { loadCustomerOptions } from '../../field/shared/load-customer-options';
import { loadDivisionOptions } from '../../configuration/shared/load-division-options';
import { relationLabel } from '../../field/shared/relation-label';
import { customerLocationFromSelection } from './sales-document.mapper';
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
import type { BranchEntity } from '../../configuration/branches/domain/entities';
import type { DivisionEntity } from '../../configuration/divisions/domain/entities';
@@ -86,6 +87,13 @@ export function FormGeneral() {
loadOptions={loadCustomerOptions}
defaultOptions={customer ? [customer] : []}
renderLabel={relationLabel}
onSelect={(value) => {
const location = customerLocationFromSelection(Array.isArray(value) ? value[0] : value);
if (!location) return;
formControl.setValue('address', location.address, { shouldDirty: true, shouldValidate: true });
formControl.setValue('latitude', location.latitude, { shouldDirty: true, shouldValidate: true });
formControl.setValue('longitude', location.longitude, { shouldDirty: true, shouldValidate: true });
}}
/>
</SimpleGrid>
</Box>
@@ -3,7 +3,7 @@ import {
Box,
Button,
FieldAsyncSelect,
FieldTextInput,
FieldCurrencyInput,
Group,
Paper,
Table,
@@ -12,14 +12,13 @@ import {
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
import { useFieldArray, useWatch } from '@repo/ui/form';
import { Plus, Trash2 } from 'lucide-react';
import { CurrencyUtils } from '@repo/utils';
import { formatRupiah } from '@repo/utils';
import { loadProductOptions } from './load-product-options';
import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options';
import { relationLabel } from '../../field/shared/relation-label';
import type { ProductEntity } from '../../configuration/products/domain/entities';
const currency = new CurrencyUtils({ decimalScale: 4 });
function lineTotal(quantity?: string, price?: string) {
function lineTotal(quantity?: string | number, price?: string | number) {
const qty = Number(quantity);
const unitPrice = Number(price);
if (!Number.isFinite(qty) || !Number.isFinite(unitPrice)) return 0;
@@ -35,7 +34,8 @@ export function FormProducts() {
});
const products = useWatch({ control: formControl.control, name: 'products' }) ?? [];
const grandTotal = products.reduce(
(sum: number, line: { quantity?: string; price?: string }) => sum + lineTotal(line?.quantity, line.price),
(sum: number, line: { quantity?: string | number; price?: string | number }) =>
sum + lineTotal(line?.quantity, line.price),
0,
);
@@ -58,6 +58,7 @@ export function FormProducts() {
<Table.Tbody>
{fields.map((field, index) => {
const line = products[index];
const excludedIds = new Set(selectedProductIdsExceptLine(products, index));
return (
<Table.Tr key={field.id}>
<Table.Td miw={240}>
@@ -67,18 +68,27 @@ export function FormProducts() {
valueKey="id"
labelKey="name"
searchable
loadOptions={loadProductOptions}
loadOptions={async (search, page, prevOptions) => {
const result = await loadProductOptions(search, page, prevOptions);
return { ...result, options: excludeProductIds(result.options, excludedIds) };
}}
filterOption={(item) => item.id == null || !excludedIds.has(String(item.id))}
defaultOptions={line?.product ? [line.product] : []}
renderLabel={relationLabel}
/>
</Table.Td>
<Table.Td miw={120}>
<FieldTextInput control={formControl.control} name={`products.${index}.quantity`} radius="md" />
<FieldCurrencyInput
control={formControl.control}
name={`products.${index}.quantity`}
prefix=""
radius="md"
/>
</Table.Td>
<Table.Td miw={140}>
<FieldTextInput control={formControl.control} name={`products.${index}.price`} radius="md" />
<FieldCurrencyInput control={formControl.control} name={`products.${index}.price`} radius="md" />
</Table.Td>
<Table.Td ta="right">{currency.format(lineTotal(line?.quantity, line?.price))}</Table.Td>
<Table.Td ta="right">{formatRupiah(lineTotal(line?.quantity, line?.price))}</Table.Td>
<Table.Td>
<ActionIcon
variant="subtle"
@@ -105,7 +115,7 @@ export function FormProducts() {
{t('add_line')}
</Button>
<Text fw={600}>
{t('common:fields.total')}: {currency.format(grandTotal)}
{t('common:fields.total')}: {formatRupiah(grandTotal)}
</Text>
</Group>
</Paper>
@@ -0,0 +1,8 @@
import { createOptionLoader } from '../../field/shared/create-option-loader';
import { salesInvoicesDataService } from '../invoices/domain/factories';
import type { SalesInvoiceEntity } from '../invoices/domain/entities';
export const loadPayableSalesInvoiceOptions = createOptionLoader<SalesInvoiceEntity>(
(config) => salesInvoicesDataService.getMany(config),
{ payable: true },
);
@@ -1,5 +1,8 @@
import { productsDataService } from '../../configuration/products/domain/factories';
import type { ProductEntity } from '../../configuration/products/domain/entities';
import { createOptionLoader } from '../../field/shared/create-option-loader';
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from '../../field/shared/create-option-loader';
export const loadProductOptions = createOptionLoader<ProductEntity>((config) => productsDataService.getMany(config));
export const loadProductOptions = createOptionLoader<ProductEntity>(
(config) => productsDataService.getMany(config),
ACTIVE_LOOKUP_PARAMS,
);
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { isPayableInvoice } from './payable-invoice';
describe('isPayableInvoice', () => {
it('accepts processed and partial invoices with remaining balance', () => {
expect(isPayableInvoice({ status: 'processed', balance: '25000.0000' })).toBe(true);
expect(isPayableInvoice({ status: 'partial', balance: '1' })).toBe(true);
});
it('rejects draft invoices and invoices with no remaining balance', () => {
expect(isPayableInvoice({ status: 'draft', balance: '25000.0000' })).toBe(false);
expect(isPayableInvoice({ status: 'processed', balance: '0.0000' })).toBe(false);
expect(isPayableInvoice({ status: 'completed', balance: '0' })).toBe(false);
expect(isPayableInvoice({ status: 'cancelled', balance: '10000' })).toBe(false);
});
});
@@ -0,0 +1,10 @@
export const PAYABLE_INVOICE_STATUSES = ['processed', 'partial'] as const;
export function isPayableInvoice(invoice: { status?: string | null; balance?: string | number | null }): boolean {
const payable: readonly string[] = PAYABLE_INVOICE_STATUSES;
if (!invoice.status || !payable.includes(invoice.status)) {
return false;
}
const balance = Number(invoice.balance);
return Number.isFinite(balance) && balance > 0;
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
customerLocationFromSelection,
mapSalesDocumentFromDto,
salesRequestToFormValues,
toSalesFilterPayload,
@@ -111,6 +112,29 @@ describe('sales document mapper', () => {
});
});
it('copies customer address and coordinates when a customer is picked', () => {
expect(
customerLocationFromSelection({
address: 'Jl Gatot Subroto No 8',
latitude: -6.2,
longitude: 106.8,
}),
).toEqual({
address: 'Jl Gatot Subroto No 8',
latitude: -6.2,
longitude: 106.8,
});
});
it('defaults missing customer location fields instead of leaving the previous customer', () => {
expect(customerLocationFromSelection({ address: 'Depot A' })).toEqual({
address: 'Depot A',
latitude: null,
longitude: null,
});
expect(customerLocationFromSelection(null)).toBeNull();
});
it('copies nested lookups onto form values for edit', () => {
const formValues = salesRequestToFormValues(mapSalesDocumentFromDto(nestedResponse));
@@ -1,5 +1,6 @@
import { formatDateValue, parseDateValue } from '@repo/ui/form';
import { emptyToNull, omitEmptyFields } from '../../../../../core/domain/configuration-field-validators';
import { toDecimalStringValue } from '../../../../../core/domain/decimal-string.schema';
import type {
LookupStub,
SalesDocumentDto,
@@ -8,6 +9,24 @@ import type {
SalesLineEntity,
} from './sales-document.entity';
export function customerLocationFromSelection(
customer:
| {
address?: string | null;
latitude?: number | null;
longitude?: number | null;
}
| null
| undefined,
) {
if (!customer) return null;
return {
address: customer.address ?? '',
latitude: customer.latitude ?? null,
longitude: customer.longitude ?? null,
};
}
export function relationId(value: unknown): string | undefined {
if (value && typeof value === 'object' && 'id' in value) {
const id = (value as { id?: unknown }).id;
@@ -108,8 +127,8 @@ export function toSalesWritePayload(
if (!productId) return null;
return omitEmptyFields({
productId,
quantity: line.quantity,
price: line.price,
quantity: toDecimalStringValue(line.quantity),
price: toDecimalStringValue(line.price),
});
})
.filter(Boolean);
@@ -5,7 +5,11 @@ import {
optionalLatitudeSchema,
optionalLongitudeSchema,
} from '../../../../../core/domain/configuration-field-validators';
import { decimalStringSchema } from '../../../../../core/domain/decimal-string.schema';
import {
decimalStringSchema,
optionalDecimalStringSchema,
PRICE_DECIMAL_SCALE,
} from '../../../../../core/domain/decimal-string.schema';
const NOTES_MAX = 1024;
const IMAGE_URL_MAX = 2048;
@@ -30,8 +34,8 @@ export function salesLineSchema(t: (key: string) => string) {
return z.object({
id: z.string().optional(),
product: relationSchema,
quantity: decimalStringSchema(t, 'common:fields.quantity'),
price: z.preprocess(emptyToUndefined, decimalStringSchema(t, 'common:fields.price').optional()),
quantity: decimalStringSchema(t, 'common:fields.quantity', PRICE_DECIMAL_SCALE),
price: optionalDecimalStringSchema(t, 'common:fields.price'),
});
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options';
describe('sales line product options', () => {
const lines = [{ product: { id: 'prd-1' } }, { product: { id: 'prd-2' } }, { product: null }];
it('collects product ids from other lines only', () => {
expect(selectedProductIdsExceptLine(lines, 0)).toEqual(['prd-2']);
expect(selectedProductIdsExceptLine(lines, 2)).toEqual(['prd-1', 'prd-2']);
});
it('hides products already used on other lines', () => {
const options = [{ id: 'prd-1' }, { id: 'prd-2' }, { id: 'prd-3' }];
expect(excludeProductIds(options, new Set(selectedProductIdsExceptLine(lines, 2)))).toEqual([{ id: 'prd-3' }]);
});
it('keeps the current line product available', () => {
const options = [{ id: 'prd-1' }, { id: 'prd-3' }];
expect(excludeProductIds(options, new Set(selectedProductIdsExceptLine(lines, 0)))).toEqual([
{ id: 'prd-1' },
{ id: 'prd-3' },
]);
});
});
@@ -0,0 +1,18 @@
export function selectedProductIdsExceptLine(
lines: Array<{ product?: { id?: string | number } | null } | null | undefined>,
lineIndex: number,
): string[] {
return lines.flatMap((line, index) => {
const id = line?.product?.id;
if (index === lineIndex || id == null || id === '') return [];
return [String(id)];
});
}
export function excludeProductIds<T extends { id?: string | number }>(
options: T[],
excludeIds: ReadonlySet<string>,
): T[] {
if (excludeIds.size === 0) return options;
return options.filter((option) => option.id == null || !excludeIds.has(String(option.id)));
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { allowedTransitions, commonTransitions, namedActionsFor } from './sales-status';
import { ModuleAction } from '@repo/ui/foundations';
import { allowedTransitions, commonTransitions, keepSalesChromeActions, namedActionsFor } from './sales-status';
describe('sales status transitions', () => {
it('allows request draft to pending or rejected', () => {
@@ -26,10 +27,14 @@ describe('sales status transitions', () => {
expect(namedActionsFor('order', 'processed').map((action) => action.key)).toEqual(['cancel']);
});
it('limits invoice user transitions to cancelled', () => {
it('allows invoice draft to processed or cancelled', () => {
expect(allowedTransitions('invoice', 'draft')).toEqual(['processed', 'cancelled']);
expect(allowedTransitions('invoice', 'processed')).toEqual(['cancelled']);
expect(allowedTransitions('invoice', 'partial')).toEqual(['cancelled']);
expect(allowedTransitions('invoice', 'completed')).toEqual(['cancelled']);
expect(namedActionsFor('invoice', 'draft').map((action) => action.key)).toEqual(['process', 'cancel']);
expect(namedActionsFor('invoice', 'processed').map((action) => action.key)).toEqual(['cancel']);
expect(namedActionsFor('invoice', 'completed').map((action) => action.key)).toEqual(['cancel']);
});
it('adds payment rollback from pending to draft', () => {
@@ -41,8 +46,33 @@ describe('sales status transitions', () => {
]);
});
it('allows packing draft to processed or cancelled', () => {
expect(allowedTransitions('packing', 'draft')).toEqual(['processed', 'cancelled']);
expect(namedActionsFor('packing', 'draft').map((action) => action.key)).toEqual(['process', 'cancel']);
});
it('allows packing processed to completed or cancelled', () => {
expect(allowedTransitions('packing', 'processed')).toEqual(['completed', 'cancelled']);
expect(namedActionsFor('packing', 'processed').map((action) => action.key)).toEqual(['complete', 'cancel']);
});
it('strips generic transaction status chrome from default actions', () => {
const defaultActions = [
{ key: 'VIEW', label: 'View' },
{ key: ModuleAction.EDIT, label: 'Edit' },
{ key: ModuleAction.HOLD, label: 'Hold' },
{ key: ModuleAction.ROLLBACK, label: 'Rollback' },
{ key: ModuleAction.CANCEL, label: 'Cancel' },
{ key: ModuleAction.CONFIRM, label: 'Confirm' },
{ key: 'change-status', label: 'Change status' },
{ key: 'bulk-change-status', label: 'Change status' },
{ key: ModuleAction.DELETE, label: 'Delete' },
];
expect(keepSalesChromeActions(defaultActions).map((action) => action.key)).toEqual([
'VIEW',
ModuleAction.EDIT,
ModuleAction.DELETE,
]);
});
});
@@ -37,7 +37,7 @@ const PACKING_TRANSITIONS: Record<PackingSlipStatus, PackingSlipStatus[]> = {
};
const INVOICE_TRANSITIONS: Record<SalesInvoiceStatus, SalesInvoiceStatus[]> = {
draft: ['cancelled'],
draft: ['processed', 'cancelled'],
processed: ['cancelled'],
partial: ['cancelled'],
completed: ['cancelled'],
@@ -56,12 +56,18 @@ export const SALES_ORDER_NAMED_ACTIONS = [
] as const;
export const PACKING_SLIP_NAMED_ACTIONS = [
{ key: 'process', target: 'processed' as const, from: ['draft'] as const },
{ key: 'complete', target: 'completed' as const, from: ['processed'] as const },
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
] as const;
export const SALES_INVOICE_NAMED_ACTIONS = [
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed', 'partial'] as const },
{ key: 'process', target: 'processed' as const, from: ['draft'] as const },
{
key: 'cancel',
target: 'cancelled' as const,
from: ['draft', 'processed', 'partial', 'completed'] as const,
},
] as const;
export const SALES_PAYMENT_NAMED_ACTIONS = [
@@ -119,3 +125,16 @@ export function documentStatusFilterOptions(t: (key: string) => string, type: Sa
label: t(`status_${value}`),
}));
}
export const SALES_GENERIC_STATUS_ACTION_KEYS = new Set([
'HOLD',
'CONFIRM',
'CANCEL',
'ROLLBACK',
'change-status',
'bulk-change-status',
]);
export function keepSalesChromeActions<T extends { key?: string }>(actions: T[]): T[] {
return actions.filter((action) => !action.key || !SALES_GENERIC_STATUS_ACTION_KEYS.has(action.key));
}
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import { Check, FileUp, ShoppingCart, XCircle, Play, Ban, Receipt, CreditCard, Undo2 } from 'lucide-react';
import { Check, FileUp, ShoppingCart, XCircle, Play, Ban, CreditCard, Undo2 } from 'lucide-react';
import { notifications } from '@repo/ui/components';
import {
useDetailPageContext,
@@ -7,12 +7,11 @@ import {
useEnterpriseModuleDataServiceContext,
useEnterpriseModuleTranslationContext,
} from '@repo/ui/foundations';
import { namedActionsFor, type SalesDocumentType } from './sales-status';
import { keepSalesChromeActions, namedActionsFor, type SalesDocumentType } from './sales-status';
import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service';
import type { SalesDocumentEntity, SalesLineEntity } from './sales-document.entity';
type SalesActionEntity = { id?: string | number; status?: string; products?: SalesLineEntity[] };
import { ChangeStatusModal } from './change-status-modal';
import { ImportCsvModal } from './import-csv-modal';
import { ProcessOrderModal } from './process-order-modal';
import { CompletePackingModal } from './complete-packing-modal';
@@ -34,12 +33,10 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
SalesDocumentEntity,
SalesDocumentRemoteDataServices<SalesDocumentEntity>
>();
const [statusOpened, setStatusOpened] = useState(false);
const [importOpened, setImportOpened] = useState(false);
const [processOpened, setProcessOpened] = useState(false);
const [completeOpened, setCompleteOpened] = useState(false);
const [pendingIds, setPendingIds] = useState<string[]>([]);
const [pendingStatus, setPendingStatus] = useState<string | undefined>();
const [completeProducts, setCompleteProducts] = useState<SalesLineEntity[]>([]);
const canEdit = privileges.ALLOW_EDIT;
const canImport = privileges.ALLOW_IMPORT;
@@ -72,7 +69,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
};
const namedRowActions = (data: SalesActionEntity, defaultActions: any[]) => {
if (!canEdit) return defaultActions;
const chromeActions = keepSalesChromeActions(defaultActions);
if (!canEdit) return chromeActions;
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
const Icon = ACTION_ICONS[action.key] ?? Check;
return {
@@ -84,23 +82,12 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
},
};
});
if (documentType !== 'invoice') {
extras.push({
key: 'change-status',
label: t('change_status'),
icon: <Play size={15} />,
onClick: () => {
setPendingIds([String(data.id)]);
setPendingStatus(data.status);
setStatusOpened(true);
},
});
}
return [...extras, ...defaultActions];
return [...extras, ...chromeActions];
};
const namedBulkActions = (selectedRows: SalesActionEntity[], defaultActions: any[]) => {
if (!canEdit || selectedRows.length === 0) return defaultActions;
const chromeActions = keepSalesChromeActions(defaultActions);
if (!canEdit || selectedRows.length === 0) return chromeActions;
const statuses = selectedRows.map((row) => row.status);
const extras: any[] = namedActionsFor(documentType, statuses[0])
.filter((action) => statuses.every((status) => (action.from as readonly string[]).includes(status ?? '')))
@@ -121,20 +108,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
},
};
});
if (documentType !== 'invoice') {
extras.push({
key: 'bulk-change-status',
label: t('change_status'),
icon: <Play size={16} />,
variant: 'light' as const,
onClick: () => {
setPendingIds(selectedRows.map((row) => String(row.id)));
setPendingStatus(selectedRows[0]?.status);
setStatusOpened(true);
},
});
}
return [...extras, ...defaultActions];
return [...extras, ...chromeActions];
};
const importPageAction = canImport
@@ -161,18 +135,6 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
}
: null;
const createInvoiceAction = (_data: SalesActionEntity, onClick: () => void) =>
canEdit
? {
key: 'create-invoice',
label: t('create_sales_invoice'),
icon: <Receipt size={16} />,
intent: 'primary' as const,
variant: 'light' as const,
onClick,
}
: null;
const createPaymentAction = (_data: SalesActionEntity, onClick: () => void) =>
canEdit
? {
@@ -186,7 +148,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
: null;
const detailStatusActions = (data: SalesActionEntity, defaultActions: any[]) => {
if (!canEdit) return defaultActions;
const chromeActions = keepSalesChromeActions(defaultActions);
if (!canEdit) return chromeActions;
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
const Icon = ACTION_ICONS[action.key] ?? Check;
return {
@@ -200,34 +163,11 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
},
};
});
if (documentType !== 'invoice') {
extras.push({
key: 'change-status',
label: t('change_status'),
icon: <Play size={16} />,
intent: 'primary' as const,
variant: 'light' as const,
onClick: () => {
setPendingIds([String(data.id)]);
setPendingStatus(data.status);
setStatusOpened(true);
},
});
}
return [...extras, ...defaultActions];
return [...extras, ...chromeActions];
};
const modals = (
<>
<ChangeStatusModal
opened={statusOpened}
onClose={() => setStatusOpened(false)}
documentType={documentType}
currentStatus={pendingStatus}
onSubmit={async (status) => {
await applyStatus(pendingIds, status);
}}
/>
<ProcessOrderModal
opened={processOpened}
onClose={() => setProcessOpened(false)}
@@ -260,7 +200,6 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
namedBulkActions,
importPageAction,
createOrderAction,
createInvoiceAction,
createPaymentAction,
detailStatusActions,
applyStatus,
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { decimalStringSchema, optionalDecimalStringSchema, toDecimalStringValue } from './decimal-string.schema';
const t = (key: string) => key;
describe('decimalStringSchema', () => {
const schema = decimalStringSchema(t, 'common:fields.quantity');
it('accepts a decimal string within four places', () => {
expect(schema.safeParse('2.0000').success).toBe(true);
});
it('coerces a number into a decimal string', () => {
const result = schema.safeParse(12.5);
expect(result.success).toBe(true);
if (result.success) expect(result.data).toBe('12.5');
});
it('rejects more than four decimal places', () => {
expect(schema.safeParse('12.34567').success).toBe(false);
});
});
describe('optionalDecimalStringSchema', () => {
const schema = optionalDecimalStringSchema(t, 'common:fields.price');
it('accepts five decimal places and numbers', () => {
expect(schema.safeParse('12.34567').success).toBe(true);
expect(schema.safeParse(12500.12345).success).toBe(true);
});
it('accepts empty values', () => {
expect(schema.safeParse('').success).toBe(true);
expect(schema.safeParse(undefined).success).toBe(true);
});
it('rejects six decimal places', () => {
expect(schema.safeParse('12.345678').success).toBe(false);
});
});
describe('toDecimalStringValue', () => {
it('keeps five decimal places when stringifying a number', () => {
expect(toDecimalStringValue(12500.12345)).toBe('12500.12345');
expect(toDecimalStringValue('12500.12345')).toBe('12500.12345');
expect(toDecimalStringValue('')).toBeUndefined();
});
});
@@ -1,20 +1,53 @@
import { z } from 'zod';
import { CURRENCY_DATA_SCALE } from '@repo/utils';
const DECIMAL_PATTERN = /^\d+(\.\d{1,4})?$/;
export const PRICE_DECIMAL_SCALE = CURRENCY_DATA_SCALE;
export const QUANTITY_DECIMAL_SCALE = 4;
function emptyToUndefined(value: unknown) {
function coerceDecimalInput(value: unknown) {
if (value === '' || value === null || value === undefined) {
return undefined;
}
if (typeof value === 'number') {
return Number.isFinite(value) ? String(value) : value;
}
return value;
}
export function decimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') {
return z.string().regex(DECIMAL_PATTERN, {
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }),
});
export function toDecimalStringValue(value: unknown): string | undefined {
const next = coerceDecimalInput(value);
return typeof next === 'string' ? next : undefined;
}
export function optionalDecimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') {
return z.preprocess(emptyToUndefined, decimalStringSchema(t, fieldKey).optional());
function decimalPattern(maxDecimals: number) {
return new RegExp(`^\\d+(\\.\\d{1,${maxDecimals}})?$`);
}
export function decimalStringSchema(
t: (key: string) => string,
fieldKey = 'common:fields.price',
maxDecimals = QUANTITY_DECIMAL_SCALE,
) {
return z.preprocess(
coerceDecimalInput,
z.string().regex(decimalPattern(maxDecimals), {
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }),
}),
);
}
export function optionalDecimalStringSchema(
t: (key: string) => string,
fieldKey = 'common:fields.price',
maxDecimals = PRICE_DECIMAL_SCALE,
) {
return z.preprocess(
coerceDecimalInput,
z
.string()
.regex(decimalPattern(maxDecimals), {
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }),
})
.optional(),
);
}
@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import { Button, Drawer, Group, Stack, Text } from '@repo/ui/components';
import { REPORT_BOOKMARK_TYPE } from '../constants';
import type { ReportBookmark, ReportConfig } from '../entities';
import { reportRemoteService } from '../data/report.remote.service';
export interface ReportBookmarkListProps {
opened: boolean;
onClose: () => void;
config: ReportConfig;
onApplied: () => void;
}
export function ReportBookmarkList({ opened, onClose, config, onApplied }: ReportBookmarkListProps) {
const [bookmarks, setBookmarks] = useState<ReportBookmark[]>([]);
useEffect(() => {
if (!opened) {
return;
}
reportRemoteService
.listBookmarks({
groupName: config.groupName,
uniqueName: config.uniqueName,
limit: 50,
page: 1,
})
.then((res) => setBookmarks(res.data ?? []))
.catch(() => setBookmarks([]));
}, [opened, config.groupName, config.uniqueName]);
const apply = async (id: string) => {
await reportRemoteService.applyBookmark(id);
onApplied();
};
const unapply = async (id: string) => {
await reportRemoteService.unapplyBookmark(id);
onApplied();
};
const remove = async (id: string) => {
await reportRemoteService.deleteBookmark(id);
setBookmarks((prev) => prev.filter((b) => b.id !== id));
};
return (
<Drawer opened={opened} onClose={onClose} title="Report bookmarks" position="right" size="md">
<Stack gap="md">
{bookmarks.length === 0 && <Text size="sm">No bookmarks yet.</Text>}
{bookmarks.map((bookmark) => (
<Group key={bookmark.id} justify="space-between" align="flex-start">
<Stack gap={2}>
<Text fw={600}>{bookmark.label}</Text>
<Text size="xs" c="dimmed">
{bookmark.type}
</Text>
</Stack>
<Group gap="xs">
{bookmark.type === REPORT_BOOKMARK_TYPE.FILTER_TABLE && (
<Button size="xs" onClick={() => apply(bookmark.id)}>
Apply
</Button>
)}
{bookmark.applied && (
<Button size="xs" variant="light" onClick={() => unapply(bookmark.id)}>
Unapply
</Button>
)}
<Button size="xs" variant="subtle" color="red" onClick={() => remove(bookmark.id)}>
Delete
</Button>
</Group>
</Group>
))}
</Stack>
</Drawer>
);
}
@@ -0,0 +1,98 @@
import { useEffect } from 'react';
import { Button, Drawer, Stack } from '@repo/ui/components';
import { FieldDatePicker, FieldSelect, FieldTagsInput, FieldTextInput, useForm, FormProvider } from '@repo/ui/form';
import { FILTER_FIELD_TYPE } from '../constants';
import type { ReportConfig } from '../entities';
export interface ReportFilterDrawerProps {
opened: boolean;
onClose: () => void;
config: ReportConfig;
initialValues: Record<string, unknown>;
onSubmit: (values: Record<string, unknown>) => void;
onSubmitAndBookmark: (values: Record<string, unknown>, label: string) => Promise<void>;
}
export function ReportFilterDrawer({
opened,
onClose,
config,
initialValues,
onSubmit,
onSubmitAndBookmark,
}: ReportFilterDrawerProps) {
const form = useForm({
defaultValues: initialValues,
});
useEffect(() => {
if (opened) {
form.reset(initialValues);
}
}, [opened, initialValues, form]);
const handleSubmit = form.handleSubmit((values) => {
onSubmit(values);
});
const handleBookmark = form.handleSubmit(async (values) => {
const label = (values.bookmarkLabel as string) || `${config.label} filter ${new Date().toISOString()}`;
await onSubmitAndBookmark(values, label);
});
return (
<Drawer opened={opened} onClose={onClose} title="Report filters" position="right" size="md">
<FormProvider {...form}>
<Stack gap="md">
{config.filterConfigs?.map((filterConfig) => {
if (filterConfig.hideField) {
return null;
}
const name = filterConfig.filterColumn;
switch (filterConfig.fieldType) {
case FILTER_FIELD_TYPE.SELECT:
return (
<FieldSelect
key={name}
name={name}
label={filterConfig.fieldLabel}
data={
filterConfig.selectCustomOptions?.map((opt) => ({
value: opt,
label: opt,
})) ?? []
}
clearable
/>
);
case FILTER_FIELD_TYPE.INPUT_TAG:
return <FieldTagsInput key={name} name={name} label={filterConfig.fieldLabel} />;
case FILTER_FIELD_TYPE.INPUT_TEXT:
return <FieldTextInput key={name} name={name} label={filterConfig.fieldLabel} />;
case FILTER_FIELD_TYPE.DATE_RANGE_PICKER:
return (
<Stack key={name} gap="xs">
<FieldDatePicker name={`${name}.from`} label={`${filterConfig.fieldLabel} (from)`} />
<FieldDatePicker name={`${name}.to`} label={`${filterConfig.fieldLabel} (to)`} />
</Stack>
);
default:
return null;
}
})}
<FieldTextInput name="bookmarkLabel" label="Bookmark label (optional)" />
<Stack gap="sm">
<Button onClick={handleSubmit}>Apply filter</Button>
<Button variant="light" onClick={handleBookmark}>
Submit & bookmark
</Button>
</Stack>
</Stack>
</FormProvider>
</Drawer>
);
}
@@ -0,0 +1,81 @@
import { useEffect, useMemo, useState } from 'react';
import { Tabs } from '@repo/ui/components';
import type { ReportConfig } from '../entities';
import { reportRemoteService } from '../data/report.remote.service';
import { ReportTable } from './report-table';
export interface ReportProviderProps {
groupName: string;
commonDefaultFilter?: Record<string, unknown>;
defaultFilterPerItem?: Record<string, Record<string, unknown>>;
}
export function ReportProvider({ groupName, commonDefaultFilter, defaultFilterPerItem }: ReportProviderProps) {
const [configs, setConfigs] = useState<ReportConfig[]>([]);
const [activeTab, setActiveTab] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
reportRemoteService
.getConfigs([groupName])
.then((data) => {
if (!cancelled) {
setConfigs(data);
setActiveTab(data[0]?.uniqueName ?? null);
setError(null);
}
})
.catch(() => {
if (!cancelled) {
setError('Failed to load report configs');
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [groupName]);
const activeConfig = useMemo(() => configs.find((c) => c.uniqueName === activeTab), [configs, activeTab]);
if (loading) {
return <div>Loading reports...</div>;
}
if (error) {
return <div>{error}</div>;
}
if (configs.length === 0) {
return <div>No reports available</div>;
}
return (
<Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List>
{configs.map((config) => (
<Tabs.Tab key={config.uniqueName} value={config.uniqueName}>
{config.label}
</Tabs.Tab>
))}
</Tabs.List>
{activeConfig && (
<Tabs.Panel value={activeConfig.uniqueName} pt="md">
<ReportTable
config={activeConfig}
commonDefaultFilter={commonDefaultFilter}
additionalDefaultFilter={defaultFilterPerItem?.[activeConfig.uniqueName]}
/>
</Tabs.Panel>
)}
</Tabs>
);
}
@@ -0,0 +1,222 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Group, Stack, Box } from '@repo/ui/components';
import { DataGrid } from '@repo/ui/ag-grid';
import type { ColDef, ColumnState, GridApi, IServerSideDatasource, IServerSideGetRowsParams } from '@repo/ui/ag-grid';
import { COUNT_CHILD_GROUP_COLUMN } from '../constants';
import type { ReportConfig, ReportQueryPayload } from '../entities';
import { reportRemoteService } from '../data/report.remote.service';
import { buildColumnDefs } from '../utils/column.helper';
import { restoreFilterFormValues, transformFilterValue } from '../utils/filter.helper';
import { ReportFilterDrawer } from './report-filter-drawer';
import { ReportBookmarkList } from './report-bookmark-list';
const CACHE_BLOCK_SIZE = 100;
export interface ReportTableProps {
config: ReportConfig;
commonDefaultFilter?: Record<string, unknown>;
additionalDefaultFilter?: Record<string, unknown>;
}
export function ReportTable({ config, commonDefaultFilter, additionalDefaultFilter }: ReportTableProps) {
const gridApiRef = useRef<GridApi | null>(null);
const filterValuesRef = useRef<Record<string, unknown>>({});
const [filterOpen, setFilterOpen] = useState(false);
const [bookmarkOpen, setBookmarkOpen] = useState(false);
const [filterFormValues, setFilterFormValues] = useState<Record<string, unknown>>({});
const columnDefs = useMemo<ColDef[]>(() => buildColumnDefs(config.columnConfigs), [config.columnConfigs]);
const defaultColDef = useMemo<ColDef>(
() => ({
flex: 1,
minWidth: 120,
sortable: true,
filter: true,
}),
[],
);
const sideBar = useMemo(
() => ({
toolPanels: [
{
id: 'columns',
labelDefault: 'Columns',
labelKey: 'columns',
iconKey: 'columns',
toolPanel: 'agColumnsToolPanel',
},
],
defaultToolPanel: 'columns',
}),
[],
);
const applyBookmarkState = useCallback(() => {
const api = gridApiRef.current;
if (!api) {
return;
}
if (config.activeTableConfig?.configuration) {
const tableState = config.activeTableConfig.configuration as {
columnState?: unknown;
columnGroupState?: unknown;
isPivotMode?: boolean;
};
if (tableState.columnState) {
api.applyColumnState({
state: tableState.columnState as ColumnState[],
applyOrder: true,
});
}
if (tableState.isPivotMode) {
api.setGridOption('pivotMode', true);
}
}
if (config.activeFilter?.configuration) {
const restored = restoreFilterFormValues(
config.activeFilter.configuration as Record<string, unknown>,
config.filterConfigs,
);
setFilterFormValues(restored);
filterValuesRef.current = restored;
}
}, [config]);
const createDatasource = useCallback((): IServerSideDatasource => {
return {
getRows: async (params: IServerSideGetRowsParams) => {
const drawerFilter = transformFilterValue(config.filterConfigs, filterValuesRef.current);
const mergedFilterModel = {
...params.request.filterModel,
...transformFilterValue(config.filterConfigs, commonDefaultFilter ?? {}),
...transformFilterValue(config.filterConfigs, additionalDefaultFilter ?? {}),
...drawerFilter,
};
const payload: ReportQueryPayload = {
groupName: config.groupName,
uniqueName: config.uniqueName,
queryModel: {
...params.request,
startRow: params.request.startRow ?? 0,
endRow: params.request.endRow ?? CACHE_BLOCK_SIZE,
filterModel: mergedFilterModel,
} as ReportQueryPayload['queryModel'],
};
try {
const isFirstBlock = params.request.startRow === 0;
let rowCount: number | undefined;
if (isFirstBlock) {
const meta = await reportRemoteService.getMeta(payload);
rowCount = meta.totalRow;
}
const rows = await reportRemoteService.getData(payload);
params.success({
rowData: rows,
rowCount,
});
} catch {
params.fail();
}
},
};
}, [config, commonDefaultFilter, additionalDefaultFilter]);
const onGridReady = useCallback(
(params: { api: GridApi }) => {
gridApiRef.current = params.api;
params.api.setGridOption('serverSideDatasource', createDatasource());
applyBookmarkState();
},
[applyBookmarkState, createDatasource],
);
useEffect(() => {
const api = gridApiRef.current;
if (api) {
api.setGridOption('serverSideDatasource', createDatasource());
api.refreshServerSide({ purge: true });
}
}, [createDatasource]);
const refreshGrid = () => {
gridApiRef.current?.refreshServerSide({ purge: true });
};
const handleApplyFilter = (values: Record<string, unknown>) => {
filterValuesRef.current = values;
setFilterFormValues(values);
setFilterOpen(false);
refreshGrid();
};
return (
<Stack gap="md">
<Group justify="space-between">
<Group>
<Button variant="default" onClick={() => setFilterOpen(true)}>
Filter
</Button>
<Button variant="light" onClick={() => setBookmarkOpen(true)}>
Bookmarks
</Button>
<Button variant="subtle" onClick={refreshGrid}>
Reload
</Button>
</Group>
</Group>
<Box style={{ height: 600, width: '100%' }}>
<DataGrid
columnDefs={columnDefs}
defaultColDef={defaultColDef}
rowModelType="serverSide"
cacheBlockSize={CACHE_BLOCK_SIZE}
maxBlocksInCache={2}
rowGroupPanelShow="always"
pivotPanelShow="always"
sideBar={sideBar}
animateRows
suppressAggFuncInHeader
getChildCount={(data) => data?.[COUNT_CHILD_GROUP_COLUMN]}
onGridReady={onGridReady}
/>
</Box>
<ReportFilterDrawer
opened={filterOpen}
onClose={() => setFilterOpen(false)}
config={config}
initialValues={filterFormValues}
onSubmit={handleApplyFilter}
onSubmitAndBookmark={async (values, label) => {
await reportRemoteService.createBookmark({
groupName: config.groupName,
uniqueName: config.uniqueName,
label,
type: 'FILTER_TABLE',
applied: true,
configuration: values,
});
handleApplyFilter(values);
}}
/>
<ReportBookmarkList
opened={bookmarkOpen}
onClose={() => setBookmarkOpen(false)}
config={config}
onApplied={() => {
refreshGrid();
setBookmarkOpen(false);
}}
/>
</Stack>
);
}
@@ -0,0 +1,68 @@
export const FILTER_TYPE = {
TEXT_EQUALS: 'text_equals',
TEXT_NOT_EQUAL: 'text_not_equal',
TEXT_CONTAINS: 'text_contains',
TEXT_NOT_CONTAINS: 'text_not_contains',
TEXT_MULTIPLE_CONTAINS: 'text_multiple_contains',
TEXT_IN_MEMBER_TEXT: 'text_inMemberText',
NUMBER_EQUALS: 'number_equals',
NUMBER_NOT_EQUAL: 'number_not_equal',
NUMBER_GREATER_THAN: 'number_greater_than',
NUMBER_LESS_THAN: 'number_less_than',
NUMBER_IN_RANGE: 'number_in_range',
TEXT_IN_DATE_RANGE_EPOCH: 'text_inDateRange_epoch',
TEXT_IN_DATE_RANGE_TIMESTAMP: 'text_inDateRange_timestamp',
} as const;
export type FilterType = (typeof FILTER_TYPE)[keyof typeof FILTER_TYPE];
export const DATA_FORMAT = {
TEXT: 'text',
TEXT_UPPERCASE: 'text_uppercase',
TEXT_LOWERCASE: 'text_lowercase',
NUMBER: 'number',
CURRENCY: 'currency',
MINUS_CURRENCY: 'minus_currency',
PERCENTAGE: 'percentage',
BOOLEAN: 'boolean',
STATUS: 'status',
DATE_EPOCH: 'date_epoch',
DATE_TIMESTAMP: 'date_timestamp',
} as const;
export type DataFormat = (typeof DATA_FORMAT)[keyof typeof DATA_FORMAT];
export const DATA_TYPE = {
DIMENSION: 'dimension',
MEASURE: 'measure',
} as const;
export type DataType = (typeof DATA_TYPE)[keyof typeof DATA_TYPE];
export const FILTER_FIELD_TYPE = {
SELECT: 'select',
INPUT_TEXT: 'input_text',
INPUT_NUMBER: 'input_number',
INPUT_TAG: 'input_tag',
DATE_PICKER: 'date_picker',
DATE_RANGE_PICKER: 'date_range_picker',
MONTH_RANGE_PICKER: 'month_range_picker',
} as const;
export type FilterFieldType = (typeof FILTER_FIELD_TYPE)[keyof typeof FILTER_FIELD_TYPE];
export const REPORT_BOOKMARK_TYPE = {
TABLE_CONFIG: 'TABLE_CONFIG',
FILTER_TABLE: 'FILTER_TABLE',
} as const;
export type ReportBookmarkType = (typeof REPORT_BOOKMARK_TYPE)[keyof typeof REPORT_BOOKMARK_TYPE];
export const REPORT_GROUP = {
SALES_REPORT: 'sales_report',
LOGISTICS_REPORT: 'logistics_report',
} as const;
export type ReportGroupName = (typeof REPORT_GROUP)[keyof typeof REPORT_GROUP];
export const COUNT_CHILD_GROUP_COLUMN = 'countChildGroup';
@@ -0,0 +1,64 @@
import { apiClient } from '../../lib/api-client';
import type { ReportBookmark, ReportConfig, ReportMeta, ReportQueryPayload } from '../entities';
export class ReportRemoteService {
async getConfigs(groupNames: string[]): Promise<ReportConfig[]> {
const response = await apiClient.get<ReportConfig[]>('/reports/config', {
params: { groupNames },
paramsSerializer: {
indexes: null,
},
});
return response.data;
}
async getData(payload: ReportQueryPayload): Promise<Record<string, unknown>[]> {
const response = await apiClient.post<Record<string, unknown>[]>('/reports/data', payload);
return response.data;
}
async getMeta(payload: ReportQueryPayload): Promise<ReportMeta> {
const response = await apiClient.post<ReportMeta>('/reports/meta', payload);
return response.data;
}
async listBookmarks(params: Record<string, unknown>) {
const response = await apiClient.get<{ data: ReportBookmark[] }>('/report-bookmarks', { params });
return response.data;
}
async createBookmark(body: {
groupName: string;
uniqueName: string;
label: string;
type: string;
applied?: boolean;
configuration: unknown;
}): Promise<ReportBookmark> {
const response = await apiClient.post<ReportBookmark>('/report-bookmarks', body);
return response.data;
}
async applyBookmark(id: string): Promise<ReportBookmark> {
const response = await apiClient.put<ReportBookmark>(`/report-bookmarks/applied/${id}`);
return response.data;
}
async unapplyBookmark(id: string): Promise<ReportBookmark> {
const response = await apiClient.put<ReportBookmark>(`/report-bookmarks/unapplied/${id}`);
return response.data;
}
async deleteBookmark(id: string): Promise<void> {
await apiClient.delete(`/report-bookmarks/${id}`);
}
async labelHistory(label?: string): Promise<string[]> {
const response = await apiClient.get<string[]>('/report-bookmarks/label-history', {
params: label ? { label } : undefined,
});
return response.data;
}
}
export const reportRemoteService = new ReportRemoteService();
+107
View File
@@ -0,0 +1,107 @@
import type {
DataFormat,
DataType,
FilterFieldType,
FilterType,
ReportBookmarkType,
ReportGroupName,
} from '../constants';
export interface ReportColumnConfig {
column: string;
query: string;
label: string;
type: DataType;
format: DataFormat;
dateFormat?: string;
}
export interface FilterConfig {
filterColumn: string;
filterType: FilterType;
fieldType: FilterFieldType;
fieldLabel: string;
hideField?: boolean;
selectDataSourceUrl?: string;
selectCustomOptions?: string[];
selectValueKey?: string;
selectLabelKey?: string;
dateFormat?: string;
}
export interface FilterModelEntry {
type: FilterType;
filter: unknown;
}
export interface ReportConfig {
groupName: ReportGroupName;
uniqueName: string;
privilegeKey: string;
label: string;
tableSchema: string;
mainTableAlias?: string;
columnConfigs: ReportColumnConfig[];
filterConfigs?: FilterConfig[];
filterPeriodConfig?: { hidden?: boolean };
whereDefaultConditions?: string[];
ignoreFilterKeys?: string[];
defaultOrderBy?: string[];
lowLevelOrderBy?: string[];
activeFilter?: ReportBookmark | null;
activeTableConfig?: ReportBookmark | null;
}
export interface ReportBookmark {
id: string;
groupName: string;
uniqueName: string;
label: string;
type: ReportBookmarkType;
applied: boolean;
configuration: unknown;
status?: string;
createdAt?: number;
updatedAt?: number;
}
export interface RowGroupCol {
id: string;
displayName: string;
field: string;
}
export interface ValueCol {
id: string;
field: string;
aggFunc: string;
}
export interface SortModelEntry {
colId: string;
sort: 'asc' | 'desc';
}
export interface QueryModel {
startRow: number;
endRow: number;
rowGroupCols: RowGroupCol[];
valueCols: ValueCol[];
pivotCols: unknown[];
pivotMode: boolean;
groupKeys: unknown[];
filterModel: Record<string, FilterModelEntry>;
sortModel: SortModelEntry[];
}
export interface ReportQueryPayload {
groupName: string;
uniqueName: string;
queryModel: QueryModel;
}
export interface ReportMeta {
totalRow: number;
limit: number;
offset: number;
}
+9
View File
@@ -0,0 +1,9 @@
export { ReportProvider } from './components/report-provider';
export { ReportTable } from './components/report-table';
export { ReportFilterDrawer } from './components/report-filter-drawer';
export { ReportBookmarkList } from './components/report-bookmark-list';
export * from './constants';
export * from './entities';
export { reportRemoteService } from './data/report.remote.service';
export { buildColumnDefs, formatReportCellDisplay } from './utils/column.helper';
export { transformFilterValue, restoreFilterFormValues } from './utils/filter.helper';
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { DATA_FORMAT, DATA_TYPE } from '../constants';
import { buildColumnDefs, formatReportCellDisplay } from '../utils/column.helper';
describe('buildColumnDefs', () => {
it('maps dimension and measure columns', () => {
const defs = buildColumnDefs([
{
column: 'main__code',
query: 'main.code',
label: 'Code',
type: DATA_TYPE.DIMENSION,
format: 'text',
},
{
column: 'main__amount',
query: 'main.amount',
label: 'Amount',
type: DATA_TYPE.MEASURE,
format: 'currency',
},
]);
expect(defs[0]?.enableRowGroup).toBe(true);
expect(defs[1]?.enableValue).toBe(true);
expect(defs[1]?.aggFunc).toBe('sum');
expect(defs[1]?.cellDataType).toBe(false);
});
});
describe('formatReportCellDisplay', () => {
it('preserves decimal scale from API strings', () => {
expect(formatReportCellDisplay('10.50000', DATA_FORMAT.CURRENCY)).toBe('10.50000');
expect(formatReportCellDisplay('1234.5000', DATA_FORMAT.NUMBER)).toBe('1234.5000');
});
});
@@ -0,0 +1,52 @@
import type { ColDef, ValueFormatterParams } from '@repo/ui/ag-grid';
import { DATA_FORMAT, DATA_TYPE } from '../constants';
import type { ReportColumnConfig } from '../entities';
const NUMERIC_FORMATS = new Set<string>([DATA_FORMAT.NUMBER, DATA_FORMAT.CURRENCY, DATA_FORMAT.MINUS_CURRENCY]);
export function formatReportCellDisplay(value: unknown, format: string): string {
if (value === null || value === undefined || value === '') {
return '';
}
if (NUMERIC_FORMATS.has(format)) {
return formatDecimalDisplay(value);
}
return String(value);
}
function formatDecimalDisplay(value: unknown): string {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed === '') {
return '';
}
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return trimmed;
}
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return String(value);
}
return String(value);
}
export function buildColumnDefs(columnConfigs: ReportColumnConfig[]): ColDef[] {
return columnConfigs.map((col) => {
const isNumeric = NUMERIC_FORMATS.has(col.format);
return {
field: col.column,
headerName: col.label,
enableRowGroup: col.type === DATA_TYPE.DIMENSION,
enableValue: col.type === DATA_TYPE.MEASURE,
aggFunc: col.type === DATA_TYPE.MEASURE ? 'sum' : undefined,
cellDataType: isNumeric ? false : undefined,
valueFormatter: (params: ValueFormatterParams) => formatReportCellDisplay(params.value, col.format),
};
});
}

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