Compare commits
8
Commits
25f1b79042
...
991bf3fe65
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
991bf3fe65 | ||
|
|
90b606f865 | ||
|
|
6808a681b7 | ||
|
|
9d0cde7252 | ||
|
|
532e30672e | ||
|
|
71046973ae | ||
|
|
efe321ca2f | ||
|
|
5a6b109a89 |
@@ -150,8 +150,7 @@ interface CoreAppShellFeatures {
|
|||||||
| `zIndex` | `number` | `200` | Base z-index passed to Mantine's `AppShell`. |
|
| `zIndex` | `number` | `200` | Base z-index passed to Mantine's `AppShell`. |
|
||||||
| `disabled` | `boolean` | `false` | Disables the AppShell layout entirely (renders children without structural chrome). |
|
| `disabled` | `boolean` | `false` | Disables the AppShell layout entirely (renders children without structural chrome). |
|
||||||
|
|
||||||
> [!TIP]
|
> [!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.
|
||||||
> **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 |
|
| `toggleNavbarPanel()` | `() => void` | Toggle the double-sidebar panel open/closed |
|
||||||
| `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` |
|
| `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` |
|
||||||
|
|
||||||
> [!WARNING]
|
> [!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.
|
||||||
> `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,
|
FieldPasswordInput,
|
||||||
FieldTextarea,
|
FieldTextarea,
|
||||||
FieldNumberInput,
|
FieldNumberInput,
|
||||||
|
FieldCurrencyInput,
|
||||||
FieldJsonInput,
|
FieldJsonInput,
|
||||||
FieldPinInput,
|
FieldPinInput,
|
||||||
FieldAutocomplete,
|
FieldAutocomplete,
|
||||||
@@ -82,6 +83,7 @@ export default function AllFieldsDemo() {
|
|||||||
password: '',
|
password: '',
|
||||||
description: '',
|
description: '',
|
||||||
age: undefined,
|
age: undefined,
|
||||||
|
price: 12500.12345,
|
||||||
jsonConfig: '',
|
jsonConfig: '',
|
||||||
pin: '',
|
pin: '',
|
||||||
country: '',
|
country: '',
|
||||||
@@ -142,6 +144,9 @@ export default function AllFieldsDemo() {
|
|||||||
<FieldPasswordInput name="password" control={control} label={t.fields.password} />
|
<FieldPasswordInput name="password" control={control} label={t.fields.password} />
|
||||||
<FieldNumberInput name="age" control={control} label={t.fields.age} />
|
<FieldNumberInput name="age" control={control} label={t.fields.age} />
|
||||||
</Group>
|
</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">
|
<Group grow align="flex-start" mb="md">
|
||||||
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
|
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
|
||||||
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
|
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"password": "Password",
|
"password": "Password",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"age": "Age",
|
"age": "Age",
|
||||||
|
"price": "Price",
|
||||||
"jsonConfig": "JSON Config",
|
"jsonConfig": "JSON Config",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"terms": "I agree to the terms and conditions",
|
"terms": "I agree to the terms and conditions",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"password": "Kata Sandi",
|
"password": "Kata Sandi",
|
||||||
"description": "Deskripsi",
|
"description": "Deskripsi",
|
||||||
"age": "Usia",
|
"age": "Usia",
|
||||||
|
"price": "Harga",
|
||||||
"jsonConfig": "Konfigurasi JSON",
|
"jsonConfig": "Konfigurasi JSON",
|
||||||
"tags": "Label (Tags)",
|
"tags": "Label (Tags)",
|
||||||
"terms": "Saya setuju dengan syarat dan ketentuan",
|
"terms": "Saya setuju dengan syarat dan ketentuan",
|
||||||
|
|||||||
@@ -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 { shortcutsData } from '@repo/ui/constants';
|
||||||
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
|
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
|
||||||
import { useSidebarStore } from './sidebar.store';
|
import { useSidebarStore } from './sidebar.store';
|
||||||
|
import { getAllParentKeys, shouldShowMenuChildren } from './sidebar.utils';
|
||||||
import { useDebouncedValue } from '@repo/ui/hooks';
|
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 isOpened = useSidebarStore((state) => state.openedKeys.has(item.key));
|
||||||
const toggleMenu = useSidebarStore((state) => state.toggleMenu);
|
const toggleMenu = useSidebarStore((state) => state.toggleMenu);
|
||||||
|
|
||||||
const effectivelyOpened = isSearching || isOpened;
|
const effectivelyOpened = shouldShowMenuChildren(isOpened, isSearching);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(newOpened: boolean) => {
|
(newOpened: boolean) => {
|
||||||
@@ -84,6 +85,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<NavLink
|
<NavLink
|
||||||
component={hasChildren ? 'button' : (Link as any)}
|
component={hasChildren ? 'button' : (Link as any)}
|
||||||
to={hasChildren ? undefined : item.path}
|
to={hasChildren ? undefined : item.path}
|
||||||
@@ -106,8 +108,12 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasChildren &&
|
{/* Keep the chevron without nesting real items in Collapse (nested height clips siblings on refresh). */}
|
||||||
item.children!.map((child) => (
|
{hasChildren ? <></> : undefined}
|
||||||
|
</NavLink>
|
||||||
|
{hasChildren && effectivelyOpened ? (
|
||||||
|
<Box ps="lg">
|
||||||
|
{item.children!.map((child) => (
|
||||||
<MenuItemExpanded
|
<MenuItemExpanded
|
||||||
key={child.key}
|
key={child.key}
|
||||||
item={child}
|
item={child}
|
||||||
@@ -116,7 +122,9 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
|||||||
allParentKeys={allParentKeys}
|
allParentKeys={allParentKeys}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</NavLink>
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,17 +244,6 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot =
|
|||||||
// SidebarMenu Component
|
// 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({
|
export const SidebarMenu = memo(function SidebarMenu({
|
||||||
items,
|
items,
|
||||||
variantOverride,
|
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,42 @@
|
|||||||
|
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',
|
key: 'sales-reports',
|
||||||
label: 'nav:reports-coming-soon',
|
label: 'nav:sales-reports',
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
path: '/app/sales/reports',
|
path: '/app/sales/reports/index',
|
||||||
isPlaceholder: true,
|
moduleKey: 'SALES.REPORT',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -165,10 +165,10 @@ export const MENU_ITEMS: MenuItemType[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'logistics-reports',
|
key: 'logistics-reports',
|
||||||
label: 'nav:reports-coming-soon',
|
label: 'nav:logistics-reports',
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
path: '/app/logistics/reports',
|
path: '/app/logistics/reports/index',
|
||||||
isPlaceholder: true,
|
moduleKey: 'LOGISTICS.REPORT',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
"data": "Data",
|
"data": "Data",
|
||||||
"activities": "Activities",
|
"activities": "Activities",
|
||||||
"reports-coming-soon": "Reports (Coming Soon)",
|
"reports-coming-soon": "Reports (Coming Soon)",
|
||||||
|
"sales-reports": "Sales Reports",
|
||||||
|
"logistics-reports": "Logistics Reports",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"settings-general": "General Settings",
|
"settings-general": "General Settings",
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
"data": "Data",
|
"data": "Data",
|
||||||
"activities": "Aktivitas",
|
"activities": "Aktivitas",
|
||||||
"reports-coming-soon": "Laporan (Segera Hadir)",
|
"reports-coming-soon": "Laporan (Segera Hadir)",
|
||||||
|
"sales-reports": "Laporan Penjualan",
|
||||||
|
"logistics-reports": "Laporan Logistik",
|
||||||
"user": "Pengguna",
|
"user": "Pengguna",
|
||||||
"settings": "Pengaturan",
|
"settings": "Pengaturan",
|
||||||
"settings-general": "Pengaturan Umum",
|
"settings-general": "Pengaturan Umum",
|
||||||
|
|||||||
+9
@@ -64,4 +64,13 @@ describe('ProductsRemoteDataTransformer', () => {
|
|||||||
expect(payload).not.toHaveProperty('status');
|
expect(payload).not.toHaveProperty('status');
|
||||||
expect(payload).not.toHaveProperty('id');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||||
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
||||||
|
import { toDecimalStringValue } from '../../../../../../../core/domain/decimal-string.schema';
|
||||||
import type { ProductDto, ProductEntity } from '../entities';
|
import type { ProductDto, ProductEntity } from '../entities';
|
||||||
|
|
||||||
export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEntity> {
|
export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEntity> {
|
||||||
@@ -28,7 +29,7 @@ export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEn
|
|||||||
code: entity.code,
|
code: entity.code,
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
unit: entity.unit,
|
unit: entity.unit,
|
||||||
price: entity.price,
|
price: toDecimalStringValue(entity.price),
|
||||||
brand: entity.brand,
|
brand: entity.brand,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -38,7 +39,7 @@ export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEn
|
|||||||
code: entity.code,
|
code: entity.code,
|
||||||
name: entity.name,
|
name: entity.name,
|
||||||
unit: emptyToNull(entity.unit) as string | null,
|
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,
|
brand: emptyToNull(entity.brand) as string | null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -29,6 +29,11 @@ describe('createProductSchema', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a non-decimal price', () => {
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-2
@@ -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 { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import type { ProductEntity } from '../../../domain/entities';
|
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.code')} value={data?.code} />
|
||||||
<FieldValue label={t('common:fields.name')} value={data?.name} />
|
<FieldValue label={t('common:fields.name')} value={data?.name} />
|
||||||
<FieldValue label={t('common:fields.unit')} value={data?.unit} />
|
<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.brand')} value={data?.brand} />
|
||||||
<FieldValue
|
<FieldValue
|
||||||
label={t('common:fields.status')}
|
label={t('common:fields.status')}
|
||||||
|
|||||||
+3
-3
@@ -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';
|
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||||
|
|
||||||
export function FormGeneral() {
|
export function FormGeneral() {
|
||||||
@@ -35,11 +35,11 @@ export function FormGeneral() {
|
|||||||
placeholder="e.g. PCS"
|
placeholder="e.g. PCS"
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
<FieldTextInput
|
<FieldCurrencyInput
|
||||||
control={formControl.control}
|
control={formControl.control}
|
||||||
name="price"
|
name="price"
|
||||||
label={t('common:fields.price')}
|
label={t('common:fields.price')}
|
||||||
placeholder="12500.0000"
|
placeholder="Rp 12.500,00"
|
||||||
radius="md"
|
radius="md"
|
||||||
/>
|
/>
|
||||||
<FieldTextInput
|
<FieldTextInput
|
||||||
|
|||||||
+7
-1
@@ -7,6 +7,7 @@ import {
|
|||||||
import { ColDef, Text } from '@repo/ui/components';
|
import { ColDef, Text } from '@repo/ui/components';
|
||||||
import { Trans } from '@repo/core-i18n';
|
import { Trans } from '@repo/core-i18n';
|
||||||
import { Package } from 'lucide-react';
|
import { Package } from 'lucide-react';
|
||||||
|
import { formatRupiah } from '@repo/utils';
|
||||||
import { FilterFormContent } from '../components/index-component/filter-content';
|
import { FilterFormContent } from '../components/index-component/filter-content';
|
||||||
import type { ProductEntity } from '../../domain/entities';
|
import type { ProductEntity } from '../../domain/entities';
|
||||||
|
|
||||||
@@ -18,7 +19,12 @@ export default function ProductPageIndex() {
|
|||||||
{ field: 'code', headerName: t('common:fields.code'), minWidth: 140 },
|
{ field: 'code', headerName: t('common:fields.code'), minWidth: 140 },
|
||||||
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
|
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
|
||||||
{ field: 'unit', headerName: t('common:fields.unit'), minWidth: 100 },
|
{ 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 },
|
{ field: 'brand', headerName: t('common:fields.brand'), minWidth: 140 },
|
||||||
];
|
];
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
|
||||||
import { divisionsDataService } from '../divisions/domain/factories';
|
import { divisionsDataService } from '../divisions/domain/factories';
|
||||||
import type { DivisionEntity } from '../divisions/domain/entities';
|
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) => {
|
export const loadDivisionOptions = createOptionLoader<DivisionEntity>(
|
||||||
const result = await divisionsDataService.getMany({
|
(config) => divisionsDataService.getMany(config),
|
||||||
params: { search, page, limit: 20 },
|
ACTIVE_LOOKUP_PARAMS,
|
||||||
});
|
);
|
||||||
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 };
|
|
||||||
};
|
|
||||||
|
|||||||
+13
@@ -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,23 @@
|
|||||||
|
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||||
|
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||||
|
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
|
||||||
|
import {
|
||||||
|
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,36 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||||
|
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||||
|
import {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"title": "Logistics Reports",
|
||||||
|
"description": "Config-driven logistics report tables"
|
||||||
|
}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"title": "Laporan Logistik",
|
||||||
|
"description": "Tabel laporan logistik berbasis konfigurasi"
|
||||||
|
}
|
||||||
+27
@@ -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,8 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
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 EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory'));
|
||||||
const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
|
const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
|
||||||
@@ -14,7 +16,7 @@ export default function LogisticsFieldModule() {
|
|||||||
<Route path="/cycles/*" element={<CyclesModule purpose="logistics" />} />
|
<Route path="/cycles/*" element={<CyclesModule purpose="logistics" />} />
|
||||||
<Route path="/plans/*" element={<PlansModule purpose="logistics" />} />
|
<Route path="/plans/*" element={<PlansModule purpose="logistics" />} />
|
||||||
<Route path="/packing-slips/*" element={<PackingSlipsModule />} />
|
<Route path="/packing-slips/*" element={<PackingSlipsModule />} />
|
||||||
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
|
<Route path="/reports/*" element={<LogisticsReportsModule />} />
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
@@ -23,6 +23,7 @@
|
|||||||
"status_updated": "Status updated.",
|
"status_updated": "Status updated.",
|
||||||
"action_complete": "Complete",
|
"action_complete": "Complete",
|
||||||
"action_cancel": "Cancel",
|
"action_cancel": "Cancel",
|
||||||
|
"action_process": "Process",
|
||||||
"complete_packing": "Complete packing",
|
"complete_packing": "Complete packing",
|
||||||
"complete_packing_help": "Enter delivered quantity for each line. Remaining quantity opens a new packing slip.",
|
"complete_packing_help": "Enter delivered quantity for each line. Remaining quantity opens a new packing slip.",
|
||||||
"delivered_quantity": "Delivered quantity",
|
"delivered_quantity": "Delivered quantity",
|
||||||
|
|||||||
+1
@@ -23,6 +23,7 @@
|
|||||||
"status_updated": "Status diperbarui.",
|
"status_updated": "Status diperbarui.",
|
||||||
"action_complete": "Selesaikan",
|
"action_complete": "Selesaikan",
|
||||||
"action_cancel": "Batalkan",
|
"action_cancel": "Batalkan",
|
||||||
|
"action_process": "Proses",
|
||||||
"complete_packing": "Selesaikan packing",
|
"complete_packing": "Selesaikan packing",
|
||||||
"complete_packing_help": "Masukkan kuantitas terkirim untuk setiap baris. Sisa kuantitas akan membuka surat jalan baru.",
|
"complete_packing_help": "Masukkan kuantitas terkirim untuk setiap baris. Sisa kuantitas akan membuka surat jalan baru.",
|
||||||
"delivered_quantity": "Kuantitas terkirim",
|
"delivered_quantity": "Kuantitas terkirim",
|
||||||
|
|||||||
@@ -23,4 +23,18 @@ describe('createPlanSchema', () => {
|
|||||||
it('rejects empty customers', () => {
|
it('rejects empty customers', () => {
|
||||||
expect(schema.safeParse({ ...valid, customers: [] }).success).toBe(false);
|
expect(schema.safeParse({ ...valid, customers: [] }).success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
const relationSchema = z.object({
|
|
||||||
|
const relationSchema = z
|
||||||
|
.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
code: z.string().optional(),
|
code: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
});
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
export const createPlanSchema = (t: (key: string) => string) => {
|
export const createPlanSchema = (t: (key: string) => string) => {
|
||||||
return z
|
return z
|
||||||
|
|||||||
+73
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
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 } 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,
|
||||||
|
}: {
|
||||||
|
kind: 'invoice' | 'packingSlip';
|
||||||
|
items: Array<SalesInvoiceEntity | PackingSlipEntity>;
|
||||||
|
}) {
|
||||||
|
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;
|
||||||
|
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="lg">
|
||||||
|
{hydrated.map((document, index) => (
|
||||||
|
<DocumentPreviewCard
|
||||||
|
key={String(document.id ?? index)}
|
||||||
|
document={document}
|
||||||
|
showBalance={kind === 'invoice'}
|
||||||
|
pending={pending}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+34
-14
@@ -1,4 +1,4 @@
|
|||||||
import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Text } from '@repo/ui/components';
|
import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components';
|
||||||
import {
|
import {
|
||||||
useEnterpriseModuleTranslationContext,
|
useEnterpriseModuleTranslationContext,
|
||||||
useFormPageContext,
|
useFormPageContext,
|
||||||
@@ -8,12 +8,23 @@ import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-
|
|||||||
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
|
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
|
||||||
import { loadBranchOptions } from '../../../../shared/load-branch-options';
|
import { loadBranchOptions } from '../../../../shared/load-branch-options';
|
||||||
import { loadCustomerOptions } from '../../../../shared/load-customer-options';
|
import { loadCustomerOptions } from '../../../../shared/load-customer-options';
|
||||||
import { loadSalesInvoiceOptions, loadPackingSlipOptions } from '../../../../shared/lookup.factories';
|
import { createOptionLoader } from '../../../../shared/create-option-loader';
|
||||||
import { relationLabel } from '../../../../shared/relation-label';
|
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 { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
|
||||||
import type { BranchEntity } from '../../../../../configuration/branches/domain/entities';
|
import type { BranchEntity } from '../../../../../configuration/branches/domain/entities';
|
||||||
import type { CustomerEntity } from '../../../../../configuration/customers/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 { documentOptionLabel } from './plan-form-preview';
|
||||||
|
import { FormCustomersPreview } from './form-customers-preview';
|
||||||
|
import { FormDocumentsPreview } from './form-documents-preview';
|
||||||
|
|
||||||
|
const loadInvoiceOptions = createOptionLoader<SalesInvoiceEntity>((config) =>
|
||||||
|
salesInvoicesDataService.getMany(config),
|
||||||
|
);
|
||||||
|
const loadSlipOptions = createOptionLoader<PackingSlipEntity>((config) => packingSlipsModuleDataService.getMany(config));
|
||||||
|
|
||||||
export function FormGeneral() {
|
export function FormGeneral() {
|
||||||
const { formControl } = useFormPageContext();
|
const { formControl } = useFormPageContext();
|
||||||
@@ -21,13 +32,14 @@ export function FormGeneral() {
|
|||||||
const { config } = useEnterpriseModuleConfigContext();
|
const { config } = useEnterpriseModuleConfigContext();
|
||||||
const purpose = purposeFromModuleKey(config.moduleKey);
|
const purpose = purposeFromModuleKey(config.moduleKey);
|
||||||
const employee = formControl.watch('employee');
|
const employee = formControl.watch('employee');
|
||||||
const startBranch = formControl.watch('startBranch');
|
const startBranch = formControl.watch('startBranch') as BranchEntity | null | undefined;
|
||||||
const endBranch = formControl.watch('endBranch');
|
const endBranch = formControl.watch('endBranch') as BranchEntity | null | undefined;
|
||||||
const customers = formControl.watch('customers') ?? [];
|
const customers = (formControl.watch('customers') ?? []) as CustomerEntity[];
|
||||||
const invoices = formControl.watch('invoices') ?? [];
|
const invoices = (formControl.watch('invoices') ?? []) as SalesInvoiceEntity[];
|
||||||
const packingSlips = formControl.watch('packingSlips') ?? [];
|
const packingSlips = (formControl.watch('packingSlips') ?? []) as PackingSlipEntity[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||||
<Text fw={600} mb="md">
|
<Text fw={600} mb="md">
|
||||||
{t('section_general')}
|
{t('section_general')}
|
||||||
@@ -89,7 +101,7 @@ export function FormGeneral() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box mt="md">
|
<Box mt="md">
|
||||||
{purpose === 'sales' ? (
|
{purpose === 'sales' ? (
|
||||||
<FieldAsyncSelect<LookupEntity>
|
<FieldAsyncSelect<SalesInvoiceEntity>
|
||||||
control={formControl.control}
|
control={formControl.control}
|
||||||
name="invoices"
|
name="invoices"
|
||||||
label={t('common:fields.invoices')}
|
label={t('common:fields.invoices')}
|
||||||
@@ -97,12 +109,12 @@ export function FormGeneral() {
|
|||||||
labelKey="code"
|
labelKey="code"
|
||||||
searchable
|
searchable
|
||||||
multiple
|
multiple
|
||||||
loadOptions={loadSalesInvoiceOptions}
|
loadOptions={loadInvoiceOptions}
|
||||||
defaultOptions={invoices}
|
defaultOptions={invoices}
|
||||||
renderLabel={relationLabel}
|
renderLabel={documentOptionLabel}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<FieldAsyncSelect<LookupEntity>
|
<FieldAsyncSelect<PackingSlipEntity>
|
||||||
control={formControl.control}
|
control={formControl.control}
|
||||||
name="packingSlips"
|
name="packingSlips"
|
||||||
label={t('common:fields.packingSlips')}
|
label={t('common:fields.packingSlips')}
|
||||||
@@ -110,13 +122,21 @@ export function FormGeneral() {
|
|||||||
labelKey="code"
|
labelKey="code"
|
||||||
searchable
|
searchable
|
||||||
multiple
|
multiple
|
||||||
loadOptions={loadPackingSlipOptions}
|
loadOptions={loadSlipOptions}
|
||||||
defaultOptions={packingSlips}
|
defaultOptions={packingSlips}
|
||||||
renderLabel={relationLabel}
|
renderLabel={documentOptionLabel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<FormCustomersPreview customers={customers} startBranch={startBranch} endBranch={endBranch} />
|
||||||
|
{purpose === 'sales' ? (
|
||||||
|
<FormDocumentsPreview kind="invoice" items={invoices} />
|
||||||
|
) : (
|
||||||
|
<FormDocumentsPreview kind="packingSlip" items={packingSlips} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
documentOptionLabel,
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
import type { RouteGeometry } from '../../../../cycles/domain/entities';
|
||||||
|
import { relationLabel } from '../../../../shared/relation-label';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
+59
@@ -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 };
|
||||||
|
}
|
||||||
@@ -13,13 +13,18 @@
|
|||||||
"section_route": "Route",
|
"section_route": "Route",
|
||||||
"section_destinations": "Destinations",
|
"section_destinations": "Destinations",
|
||||||
"section_attachments": "Attachments",
|
"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": "Generate",
|
||||||
"generate_title": "Generate plans",
|
"generate_title": "Generate plans",
|
||||||
"generate_success": "Created {{created}} plan(s), skipped {{skipped}}.",
|
"generate_success": "Created {{created}} plan(s), skipped {{skipped}}.",
|
||||||
"add_destination": "Add destination",
|
"add_destination": "Add destination",
|
||||||
"remove_destination": "Remove destination",
|
"remove_destination": "Remove destination",
|
||||||
"empty_route": "No route geometry",
|
"empty_route": "No route geometry",
|
||||||
"section_attachments": "Attachments",
|
"empty_products": "No products on this document",
|
||||||
|
"preview_loading": "Loading document details…",
|
||||||
"purpose_sales": "Sales",
|
"purpose_sales": "Sales",
|
||||||
"purpose_logistics": "Logistics",
|
"purpose_logistics": "Logistics",
|
||||||
"status_draft": "Draft",
|
"status_draft": "Draft",
|
||||||
|
|||||||
@@ -13,13 +13,18 @@
|
|||||||
"section_route": "Rute",
|
"section_route": "Rute",
|
||||||
"section_destinations": "Destinasi",
|
"section_destinations": "Destinasi",
|
||||||
"section_attachments": "Lampiran",
|
"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": "Generate",
|
||||||
"generate_title": "Generate rencana",
|
"generate_title": "Generate rencana",
|
||||||
"generate_success": "Berhasil membuat {{created}} rencana, {{skipped}} dilewati.",
|
"generate_success": "Berhasil membuat {{created}} rencana, {{skipped}} dilewati.",
|
||||||
"add_destination": "Tambah destinasi",
|
"add_destination": "Tambah destinasi",
|
||||||
"remove_destination": "Hapus destinasi",
|
"remove_destination": "Hapus destinasi",
|
||||||
"empty_route": "Tidak ada geometri rute",
|
"empty_route": "Tidak ada geometri rute",
|
||||||
"section_attachments": "Lampiran",
|
"empty_products": "Tidak ada produk pada dokumen ini",
|
||||||
|
"preview_loading": "Memuat detail dokumen…",
|
||||||
"purpose_sales": "Penjualan",
|
"purpose_sales": "Penjualan",
|
||||||
"purpose_logistics": "Logistik",
|
"purpose_logistics": "Logistik",
|
||||||
"status_draft": "Draft",
|
"status_draft": "Draft",
|
||||||
|
|||||||
@@ -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';
|
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||||
|
|
||||||
|
export const ACTIVE_LOOKUP_PARAMS = { status: 'active' } as const;
|
||||||
|
|
||||||
export function createOptionLoader<T>(
|
export function createOptionLoader<T>(
|
||||||
getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>,
|
getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>,
|
||||||
|
extraParams?: Record<string, unknown>,
|
||||||
): LoadOptionsFn<T> {
|
): LoadOptionsFn<T> {
|
||||||
return async (search, page) => {
|
return async (search, page) => {
|
||||||
const result = await getMany({
|
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 rows = (result.data as { data?: T[]; meta?: { totalPages?: number } })?.data ?? [];
|
||||||
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
|
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { branchesDataService } from '../../configuration/branches/domain/factories';
|
import { branchesDataService } from '../../configuration/branches/domain/factories';
|
||||||
import type { BranchEntity } from '../../configuration/branches/domain/entities';
|
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 { customersDataService } from '../../configuration/customers/domain/factories';
|
||||||
import type { CustomerEntity } from '../../configuration/customers/domain/entities';
|
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,
|
salesEmployeesDataService,
|
||||||
} from '../../configuration/employees/domain/factories';
|
} from '../../configuration/employees/domain/factories';
|
||||||
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
|
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) {
|
function employeeServiceForPurpose(purpose?: FieldPurpose) {
|
||||||
if (purpose === 'sales') {
|
if (purpose === 'sales') {
|
||||||
@@ -20,7 +20,7 @@ function employeeServiceForPurpose(purpose?: FieldPurpose) {
|
|||||||
|
|
||||||
export function loadEmployeeOptionsForPurpose(purpose?: FieldPurpose): LoadOptionsFn<EmployeeEntity> {
|
export function loadEmployeeOptionsForPurpose(purpose?: FieldPurpose): LoadOptionsFn<EmployeeEntity> {
|
||||||
const service = employeeServiceForPurpose(purpose);
|
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');
|
export const loadSalesEmployeeOptions = loadEmployeeOptionsForPurpose('sales');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
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 RequestsModule = lazy(() => import('./requests/presentation/factory'));
|
||||||
const OrdersModule = lazy(() => import('./orders/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="/plans/*" element={<PlansModule purpose="sales" />} />
|
||||||
<Route path="/invoices/*" element={<InvoicesModule />} />
|
<Route path="/invoices/*" element={<InvoicesModule />} />
|
||||||
<Route path="/payments/*" element={<PaymentsModule />} />
|
<Route path="/payments/*" element={<PaymentsModule />} />
|
||||||
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
|
<Route path="/reports/*" element={<ReportsModule />} />
|
||||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
+1
@@ -23,6 +23,7 @@
|
|||||||
"import_success": "CSV imported.",
|
"import_success": "CSV imported.",
|
||||||
"status_updated": "Status updated.",
|
"status_updated": "Status updated.",
|
||||||
"create_sales_payment": "Create Sales Payment",
|
"create_sales_payment": "Create Sales Payment",
|
||||||
|
"action_process": "Process",
|
||||||
"action_cancel": "Cancel",
|
"action_cancel": "Cancel",
|
||||||
"status_draft": "Draft",
|
"status_draft": "Draft",
|
||||||
"status_processed": "Processed",
|
"status_processed": "Processed",
|
||||||
|
|||||||
+1
@@ -23,6 +23,7 @@
|
|||||||
"import_success": "CSV berhasil diimpor.",
|
"import_success": "CSV berhasil diimpor.",
|
||||||
"status_updated": "Status diperbarui.",
|
"status_updated": "Status diperbarui.",
|
||||||
"create_sales_payment": "Buat Pembayaran Penjualan",
|
"create_sales_payment": "Buat Pembayaran Penjualan",
|
||||||
|
"action_process": "Proses",
|
||||||
"action_cancel": "Batalkan",
|
"action_cancel": "Batalkan",
|
||||||
"status_draft": "Draft",
|
"status_draft": "Draft",
|
||||||
"status_processed": "Diproses",
|
"status_processed": "Diproses",
|
||||||
|
|||||||
+7
-3
@@ -6,6 +6,7 @@ import { DetailGeneral } from '../../../shared/detail-general';
|
|||||||
import { DetailLocation } from '../../../shared/detail-location';
|
import { DetailLocation } from '../../../shared/detail-location';
|
||||||
import { DetailProducts } from '../../../shared/detail-products';
|
import { DetailProducts } from '../../../shared/detail-products';
|
||||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||||
|
import { isPayableInvoice } from '../../../shared/payable-invoice';
|
||||||
import { salesOrdersModuleConfig } from '../../../orders/domain/constants';
|
import { salesOrdersModuleConfig } from '../../../orders/domain/constants';
|
||||||
import { salesPaymentsModuleConfig } from '../../../payments/domain/constants';
|
import { salesPaymentsModuleConfig } from '../../../payments/domain/constants';
|
||||||
import type { SalesInvoiceEntity } from '../../domain/entities';
|
import type { SalesInvoiceEntity } from '../../domain/entities';
|
||||||
@@ -27,10 +28,13 @@ export default function SalesInvoicePageDetail() {
|
|||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
customPageActions={(data, pageActions) => {
|
customPageActions={(data, pageActions) => {
|
||||||
const createPayment = actions.createPaymentAction(data as SalesInvoiceEntity, () => {
|
const invoice = data as SalesInvoiceEntity;
|
||||||
|
const createPayment = isPayableInvoice(invoice)
|
||||||
|
? actions.createPaymentAction(invoice, () => {
|
||||||
navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`);
|
navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`);
|
||||||
});
|
})
|
||||||
const withStatus = actions.detailStatusActions(data as SalesInvoiceEntity, pageActions ?? []);
|
: null;
|
||||||
|
const withStatus = actions.detailStatusActions(invoice, pageActions ?? []);
|
||||||
return createPayment ? [createPayment, ...withStatus] : withStatus;
|
return createPayment ? [createPayment, ...withStatus] : withStatus;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
+7
-1
@@ -7,6 +7,7 @@ import {
|
|||||||
import { ColDef, Text } from '@repo/ui/components';
|
import { ColDef, Text } from '@repo/ui/components';
|
||||||
import { Trans } from '@repo/core-i18n';
|
import { Trans } from '@repo/core-i18n';
|
||||||
import { Receipt } from 'lucide-react';
|
import { Receipt } from 'lucide-react';
|
||||||
|
import { formatRupiah } from '@repo/utils';
|
||||||
import { SalesFilterFormContent } from '../../../shared/filter-content';
|
import { SalesFilterFormContent } from '../../../shared/filter-content';
|
||||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||||
import { relationLabel } from '../../../../field/shared/relation-label';
|
import { relationLabel } from '../../../../field/shared/relation-label';
|
||||||
@@ -38,7 +39,12 @@ export default function SalesInvoicePageIndex() {
|
|||||||
minWidth: 160,
|
minWidth: 160,
|
||||||
valueGetter: ({ data }) => relationLabel(data?.branch) || data?.branchId,
|
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],
|
[t],
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-10
@@ -1,4 +1,3 @@
|
|||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Stack } from '@repo/ui/components';
|
import { Stack } from '@repo/ui/components';
|
||||||
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import { salesOrdersModuleConfig } from '../../domain/constants';
|
import { salesOrdersModuleConfig } from '../../domain/constants';
|
||||||
@@ -9,12 +8,10 @@ import { DetailImages } from '../../../shared/detail-images';
|
|||||||
import { DetailRelated } from '../components/detail-component/detail-related';
|
import { DetailRelated } from '../components/detail-component/detail-related';
|
||||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||||
import { salesRequestsModuleConfig } from '../../../requests/domain/constants';
|
import { salesRequestsModuleConfig } from '../../../requests/domain/constants';
|
||||||
import { salesInvoicesModuleConfig } from '../../../invoices/domain/constants';
|
|
||||||
import type { SalesOrderEntity } from '../../domain/entities';
|
import type { SalesOrderEntity } from '../../domain/entities';
|
||||||
|
|
||||||
export default function SalesOrderPageDetail() {
|
export default function SalesOrderPageDetail() {
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
const navigate = useNavigate();
|
|
||||||
const actions = useSalesDocumentActions('order');
|
const actions = useSalesDocumentActions('order');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -28,13 +25,9 @@ export default function SalesOrderPageDetail() {
|
|||||||
{ label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` },
|
{ label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` },
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
customPageActions={(data, pageActions) => {
|
customPageActions={(data, pageActions) =>
|
||||||
const createInvoice = actions.createInvoiceAction(data as SalesOrderEntity, () => {
|
actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? [])
|
||||||
navigate(`${salesInvoicesModuleConfig.webUrl}/create?salesOrderId=${data.id}`);
|
}
|
||||||
});
|
|
||||||
const withStatus = actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? []);
|
|
||||||
return createInvoice ? [createInvoice, ...withStatus] : withStatus;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
|
<DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||||
import { formatDateValue, parseDateValue } from '@repo/ui/form';
|
import { formatDateValue, parseDateValue } from '@repo/ui/form';
|
||||||
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
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 { mapSalesImagesFromDto, relationId, toLookup } from '../../../shared/sales-document.mapper';
|
||||||
import type { SalesPaymentDto, SalesPaymentEntity } from '../entities';
|
import type { SalesPaymentDto, SalesPaymentEntity } from '../entities';
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ export class SalesPaymentsRemoteDataTransformer extends BaseDataTransformer<Sale
|
|||||||
.map((row) => {
|
.map((row) => {
|
||||||
const invoiceId = relationId(row.invoice) ?? row.invoiceId;
|
const invoiceId = relationId(row.invoice) ?? row.invoiceId;
|
||||||
if (!invoiceId) return null;
|
if (!invoiceId) return null;
|
||||||
return omitEmptyFields({ invoiceId, amount: row.amount });
|
return omitEmptyFields({ invoiceId, amount: toDecimalStringValue(row.amount) });
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { compose, maxLength, required } from '@repo/ui/validators';
|
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';
|
import { salesImageSchema } from '../../../shared/sales-document.validator';
|
||||||
|
|
||||||
const NOTES_MAX = 1024;
|
const NOTES_MAX = 1024;
|
||||||
@@ -40,7 +40,7 @@ export function createSalesPaymentSchema(t: (key: string) => string) {
|
|||||||
z.object({
|
z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
invoice: relationSchema,
|
invoice: relationSchema,
|
||||||
amount: decimalStringSchema(t, 'common:fields.amount'),
|
amount: decimalStringSchema(t, 'common:fields.amount', PRICE_DECIMAL_SCALE),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.min(1),
|
.min(1),
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
|
import { formatRupiah } from '@repo/utils';
|
||||||
import type { SalesPaymentEntity } from '../../../domain/entities';
|
import type { SalesPaymentEntity } from '../../../domain/entities';
|
||||||
import { relationLabel } from '../../../../../field/shared/relation-label';
|
import { relationLabel } from '../../../../../field/shared/relation-label';
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export function DetailAllocations() {
|
|||||||
rows.map((row, index) => (
|
rows.map((row, index) => (
|
||||||
<Table.Tr key={row.id ?? `${row.invoiceId}-${index}`}>
|
<Table.Tr key={row.id ?? `${row.invoiceId}-${index}`}>
|
||||||
<Table.Td>{relationLabel(row.invoice) || row.invoiceId}</Table.Td>
|
<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>
|
</Table.Tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
+8
-6
@@ -3,7 +3,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
FieldAsyncSelect,
|
FieldAsyncSelect,
|
||||||
FieldTextInput,
|
FieldCurrencyInput,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
Table,
|
Table,
|
||||||
@@ -12,9 +12,10 @@ import {
|
|||||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||||
import { useFieldArray, useWatch } from '@repo/ui/form';
|
import { useFieldArray, useWatch } from '@repo/ui/form';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
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 { relationLabel } from '../../../../../field/shared/relation-label';
|
||||||
import type { LookupEntity } from '../../../../../field/shared/lookup.entity';
|
import type { SalesInvoiceEntity } from '../../../../invoices/domain/entities';
|
||||||
|
|
||||||
export function FormAllocations() {
|
export function FormAllocations() {
|
||||||
const { formControl } = useFormPageContext();
|
const { formControl } = useFormPageContext();
|
||||||
@@ -45,19 +46,20 @@ export function FormAllocations() {
|
|||||||
return (
|
return (
|
||||||
<Table.Tr key={field.id}>
|
<Table.Tr key={field.id}>
|
||||||
<Table.Td miw={240}>
|
<Table.Td miw={240}>
|
||||||
<FieldAsyncSelect<LookupEntity>
|
<FieldAsyncSelect<SalesInvoiceEntity>
|
||||||
control={formControl.control}
|
control={formControl.control}
|
||||||
name={`invoices.${index}.invoice`}
|
name={`invoices.${index}.invoice`}
|
||||||
valueKey="id"
|
valueKey="id"
|
||||||
labelKey="code"
|
labelKey="code"
|
||||||
searchable
|
searchable
|
||||||
loadOptions={loadSalesInvoiceOptions}
|
loadOptions={loadPayableSalesInvoiceOptions}
|
||||||
|
filterOption={(item) => isPayableInvoice(item)}
|
||||||
defaultOptions={line?.invoice ? [line.invoice] : []}
|
defaultOptions={line?.invoice ? [line.invoice] : []}
|
||||||
renderLabel={relationLabel}
|
renderLabel={relationLabel}
|
||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td miw={140}>
|
<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>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@ import { FormAllocations } from '../components/form-component/form-allocations';
|
|||||||
import { FormImages } from '../../../shared/form-images';
|
import { FormImages } from '../../../shared/form-images';
|
||||||
import { FormNotes } from '../../../shared/form-notes';
|
import { FormNotes } from '../../../shared/form-notes';
|
||||||
import { salesInvoicesDataService } from '../../../invoices/domain/factories';
|
import { salesInvoicesDataService } from '../../../invoices/domain/factories';
|
||||||
|
import { isPayableInvoice } from '../../../shared/payable-invoice';
|
||||||
import type { SalesInvoiceEntity } from '../../../invoices/domain/entities';
|
import type { SalesInvoiceEntity } from '../../../invoices/domain/entities';
|
||||||
|
|
||||||
export default function SalesPaymentPageForm({ formPageType }: { formPageType: FormPageType }) {
|
export default function SalesPaymentPageForm({ formPageType }: { formPageType: FormPageType }) {
|
||||||
@@ -46,7 +47,7 @@ export default function SalesPaymentPageForm({ formPageType }: { formPageType: F
|
|||||||
prefilled.current = true;
|
prefilled.current = true;
|
||||||
void salesInvoicesDataService.getOne(invoiceId).then((result) => {
|
void salesInvoicesDataService.getOne(invoiceId).then((result) => {
|
||||||
const entity = (result.data as { data?: SalesInvoiceEntity })?.data;
|
const entity = (result.data as { data?: SalesInvoiceEntity })?.data;
|
||||||
if (!entity) return;
|
if (!entity || !isPayableInvoice(entity)) return;
|
||||||
formControl.reset({
|
formControl.reset({
|
||||||
invoices: [
|
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,23 @@
|
|||||||
|
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||||
|
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||||
|
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
|
||||||
|
import {
|
||||||
|
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"
|
||||||
|
}
|
||||||
+27
@@ -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 { useEffect } from 'react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import { relationLabel } from '../../field/shared/relation-label';
|
import { relationLabel } from '../../field/shared/relation-label';
|
||||||
import type { SalesLineEntity } from './sales-document.entity';
|
import type { SalesLineEntity } from './sales-document.entity';
|
||||||
|
import { toDecimalStringValue } from '../../../../../core/domain/decimal-string.schema';
|
||||||
|
|
||||||
export function CompletePackingModal({
|
export function CompletePackingModal({
|
||||||
opened,
|
opened,
|
||||||
@@ -17,7 +18,7 @@ export function CompletePackingModal({
|
|||||||
onSubmit: (products: Array<{ productId: string; quantity: string }>) => Promise<void> | void;
|
onSubmit: (products: Array<{ productId: string; quantity: string }>) => Promise<void> | void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
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: [] },
|
defaultValues: { products: [] },
|
||||||
});
|
});
|
||||||
const { fields } = useFieldArray({ control: form.control, name: 'products' });
|
const { fields } = useFieldArray({ control: form.control, name: 'products' });
|
||||||
@@ -37,7 +38,7 @@ export function CompletePackingModal({
|
|||||||
await onSubmit(
|
await onSubmit(
|
||||||
values.products.map((line) => ({
|
values.products.map((line) => ({
|
||||||
productId: line.productId,
|
productId: line.productId,
|
||||||
quantity: line.quantity,
|
quantity: toDecimalStringValue(line.quantity) ?? '',
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -49,10 +50,11 @@ export function CompletePackingModal({
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm">{t('complete_packing_help')}</Text>
|
<Text size="sm">{t('complete_packing_help')}</Text>
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<FieldTextInput
|
<FieldCurrencyInput
|
||||||
key={field.id}
|
key={field.id}
|
||||||
control={form.control as any}
|
control={form.control as any}
|
||||||
name={`products.${index}.quantity`}
|
name={`products.${index}.quantity`}
|
||||||
|
prefix=""
|
||||||
label={`${t('delivered_quantity')} — ${form.getValues(`products.${index}.label`) || field.label}`}
|
label={`${t('delivered_quantity')} — ${form.getValues(`products.${index}.label`) || field.label}`}
|
||||||
required
|
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 { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||||
import type { SalesDocumentEntity } from './sales-document.entity';
|
import type { SalesDocumentEntity } from './sales-document.entity';
|
||||||
import { relationLabel } from '../../field/shared/relation-label';
|
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 && (
|
{salesRequestHref && (
|
||||||
<FieldValue
|
<FieldValue
|
||||||
label={t('common:fields.salesRequest')}
|
label={t('common:fields.salesRequest')}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
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 type { SalesDocumentEntity } from './sales-document.entity';
|
||||||
import { relationLabel } from '../../field/shared/relation-label';
|
import { relationLabel } from '../../field/shared/relation-label';
|
||||||
|
|
||||||
const currency = new CurrencyUtils({ decimalScale: 4 });
|
|
||||||
|
|
||||||
export function DetailProducts() {
|
export function DetailProducts() {
|
||||||
const { detailData } = useDetailPageContext<SalesDocumentEntity>();
|
const { detailData } = useDetailPageContext<SalesDocumentEntity>();
|
||||||
const { t } = useEnterpriseModuleTranslationContext();
|
const { t } = useEnterpriseModuleTranslationContext();
|
||||||
@@ -49,9 +47,9 @@ export function DetailProducts() {
|
|||||||
return (
|
return (
|
||||||
<Table.Tr key={line.id ?? `${line.productId}-${index}`}>
|
<Table.Tr key={line.id ?? `${line.productId}-${index}`}>
|
||||||
<Table.Td>{relationLabel(line.product) || line.productId}</Table.Td>
|
<Table.Td>{relationLabel(line.product) || line.productId}</Table.Td>
|
||||||
<Table.Td ta="right">{line.quantity}</Table.Td>
|
<Table.Td ta="right">{formatDecimal(line.quantity) || '-'}</Table.Td>
|
||||||
<Table.Td ta="right">{line.price ? currency.format(line.price) : '-'}</Table.Td>
|
<Table.Td ta="right">{line.price ? formatRupiah(line.price) : '-'}</Table.Td>
|
||||||
<Table.Td ta="right">{currency.format(total)}</Table.Td>
|
<Table.Td ta="right">{formatRupiah(total)}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -60,7 +58,7 @@ export function DetailProducts() {
|
|||||||
</Table>
|
</Table>
|
||||||
</Box>
|
</Box>
|
||||||
<Text fw={600} ta="right" mt="md">
|
<Text fw={600} ta="right" mt="md">
|
||||||
{t('common:fields.total')}: {currency.format(grandTotal)}
|
{t('common:fields.total')}: {formatRupiah(grandTotal)}
|
||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { loadBranchOptions } from '../../field/shared/load-branch-options';
|
|||||||
import { loadCustomerOptions } from '../../field/shared/load-customer-options';
|
import { loadCustomerOptions } from '../../field/shared/load-customer-options';
|
||||||
import { loadDivisionOptions } from '../../configuration/shared/load-division-options';
|
import { loadDivisionOptions } from '../../configuration/shared/load-division-options';
|
||||||
import { relationLabel } from '../../field/shared/relation-label';
|
import { relationLabel } from '../../field/shared/relation-label';
|
||||||
|
import { customerLocationFromSelection } from './sales-document.mapper';
|
||||||
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
|
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
|
||||||
import type { BranchEntity } from '../../configuration/branches/domain/entities';
|
import type { BranchEntity } from '../../configuration/branches/domain/entities';
|
||||||
import type { DivisionEntity } from '../../configuration/divisions/domain/entities';
|
import type { DivisionEntity } from '../../configuration/divisions/domain/entities';
|
||||||
@@ -86,6 +87,13 @@ export function FormGeneral() {
|
|||||||
loadOptions={loadCustomerOptions}
|
loadOptions={loadCustomerOptions}
|
||||||
defaultOptions={customer ? [customer] : []}
|
defaultOptions={customer ? [customer] : []}
|
||||||
renderLabel={relationLabel}
|
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>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
FieldAsyncSelect,
|
FieldAsyncSelect,
|
||||||
FieldTextInput,
|
FieldCurrencyInput,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
Table,
|
Table,
|
||||||
@@ -12,14 +12,13 @@ import {
|
|||||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||||
import { useFieldArray, useWatch } from '@repo/ui/form';
|
import { useFieldArray, useWatch } from '@repo/ui/form';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { CurrencyUtils } from '@repo/utils';
|
import { formatRupiah } from '@repo/utils';
|
||||||
import { loadProductOptions } from './load-product-options';
|
import { loadProductOptions } from './load-product-options';
|
||||||
|
import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options';
|
||||||
import { relationLabel } from '../../field/shared/relation-label';
|
import { relationLabel } from '../../field/shared/relation-label';
|
||||||
import type { ProductEntity } from '../../configuration/products/domain/entities';
|
import type { ProductEntity } from '../../configuration/products/domain/entities';
|
||||||
|
|
||||||
const currency = new CurrencyUtils({ decimalScale: 4 });
|
function lineTotal(quantity?: string | number, price?: string | number) {
|
||||||
|
|
||||||
function lineTotal(quantity?: string, price?: string) {
|
|
||||||
const qty = Number(quantity);
|
const qty = Number(quantity);
|
||||||
const unitPrice = Number(price);
|
const unitPrice = Number(price);
|
||||||
if (!Number.isFinite(qty) || !Number.isFinite(unitPrice)) return 0;
|
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 products = useWatch({ control: formControl.control, name: 'products' }) ?? [];
|
||||||
const grandTotal = products.reduce(
|
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,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@ export function FormProducts() {
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{fields.map((field, index) => {
|
{fields.map((field, index) => {
|
||||||
const line = products[index];
|
const line = products[index];
|
||||||
|
const excludedIds = new Set(selectedProductIdsExceptLine(products, index));
|
||||||
return (
|
return (
|
||||||
<Table.Tr key={field.id}>
|
<Table.Tr key={field.id}>
|
||||||
<Table.Td miw={240}>
|
<Table.Td miw={240}>
|
||||||
@@ -67,18 +68,27 @@ export function FormProducts() {
|
|||||||
valueKey="id"
|
valueKey="id"
|
||||||
labelKey="name"
|
labelKey="name"
|
||||||
searchable
|
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] : []}
|
defaultOptions={line?.product ? [line.product] : []}
|
||||||
renderLabel={relationLabel}
|
renderLabel={relationLabel}
|
||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td miw={120}>
|
<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>
|
||||||
<Table.Td miw={140}>
|
<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>
|
||||||
<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>
|
<Table.Td>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -105,7 +115,7 @@ export function FormProducts() {
|
|||||||
{t('add_line')}
|
{t('add_line')}
|
||||||
</Button>
|
</Button>
|
||||||
<Text fw={600}>
|
<Text fw={600}>
|
||||||
{t('common:fields.total')}: {currency.format(grandTotal)}
|
{t('common:fields.total')}: {formatRupiah(grandTotal)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</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 { productsDataService } from '../../configuration/products/domain/factories';
|
||||||
import type { ProductEntity } from '../../configuration/products/domain/entities';
|
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 { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
customerLocationFromSelection,
|
||||||
mapSalesDocumentFromDto,
|
mapSalesDocumentFromDto,
|
||||||
salesRequestToFormValues,
|
salesRequestToFormValues,
|
||||||
toSalesFilterPayload,
|
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', () => {
|
it('copies nested lookups onto form values for edit', () => {
|
||||||
const formValues = salesRequestToFormValues(mapSalesDocumentFromDto(nestedResponse));
|
const formValues = salesRequestToFormValues(mapSalesDocumentFromDto(nestedResponse));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { formatDateValue, parseDateValue } from '@repo/ui/form';
|
import { formatDateValue, parseDateValue } from '@repo/ui/form';
|
||||||
import { emptyToNull, omitEmptyFields } from '../../../../../core/domain/configuration-field-validators';
|
import { emptyToNull, omitEmptyFields } from '../../../../../core/domain/configuration-field-validators';
|
||||||
|
import { toDecimalStringValue } from '../../../../../core/domain/decimal-string.schema';
|
||||||
import type {
|
import type {
|
||||||
LookupStub,
|
LookupStub,
|
||||||
SalesDocumentDto,
|
SalesDocumentDto,
|
||||||
@@ -8,6 +9,24 @@ import type {
|
|||||||
SalesLineEntity,
|
SalesLineEntity,
|
||||||
} from './sales-document.entity';
|
} 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 {
|
export function relationId(value: unknown): string | undefined {
|
||||||
if (value && typeof value === 'object' && 'id' in value) {
|
if (value && typeof value === 'object' && 'id' in value) {
|
||||||
const id = (value as { id?: unknown }).id;
|
const id = (value as { id?: unknown }).id;
|
||||||
@@ -108,8 +127,8 @@ export function toSalesWritePayload(
|
|||||||
if (!productId) return null;
|
if (!productId) return null;
|
||||||
return omitEmptyFields({
|
return omitEmptyFields({
|
||||||
productId,
|
productId,
|
||||||
quantity: line.quantity,
|
quantity: toDecimalStringValue(line.quantity),
|
||||||
price: line.price,
|
price: toDecimalStringValue(line.price),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
optionalLatitudeSchema,
|
optionalLatitudeSchema,
|
||||||
optionalLongitudeSchema,
|
optionalLongitudeSchema,
|
||||||
} from '../../../../../core/domain/configuration-field-validators';
|
} 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 NOTES_MAX = 1024;
|
||||||
const IMAGE_URL_MAX = 2048;
|
const IMAGE_URL_MAX = 2048;
|
||||||
@@ -30,8 +34,8 @@ export function salesLineSchema(t: (key: string) => string) {
|
|||||||
return z.object({
|
return z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
product: relationSchema,
|
product: relationSchema,
|
||||||
quantity: decimalStringSchema(t, 'common:fields.quantity'),
|
quantity: decimalStringSchema(t, 'common:fields.quantity', PRICE_DECIMAL_SCALE),
|
||||||
price: z.preprocess(emptyToUndefined, decimalStringSchema(t, 'common:fields.price').optional()),
|
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 { 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', () => {
|
describe('sales status transitions', () => {
|
||||||
it('allows request draft to pending or rejected', () => {
|
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']);
|
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', 'processed')).toEqual(['cancelled']);
|
||||||
expect(allowedTransitions('invoice', 'partial')).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', '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', () => {
|
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', () => {
|
it('allows packing processed to completed or cancelled', () => {
|
||||||
expect(allowedTransitions('packing', 'processed')).toEqual(['completed', 'cancelled']);
|
expect(allowedTransitions('packing', 'processed')).toEqual(['completed', 'cancelled']);
|
||||||
expect(namedActionsFor('packing', 'processed').map((action) => action.key)).toEqual(['complete', 'cancel']);
|
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[]> = {
|
const INVOICE_TRANSITIONS: Record<SalesInvoiceStatus, SalesInvoiceStatus[]> = {
|
||||||
draft: ['cancelled'],
|
draft: ['processed', 'cancelled'],
|
||||||
processed: ['cancelled'],
|
processed: ['cancelled'],
|
||||||
partial: ['cancelled'],
|
partial: ['cancelled'],
|
||||||
completed: ['cancelled'],
|
completed: ['cancelled'],
|
||||||
@@ -56,12 +56,18 @@ export const SALES_ORDER_NAMED_ACTIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const PACKING_SLIP_NAMED_ACTIONS = [
|
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: 'complete', target: 'completed' as const, from: ['processed'] as const },
|
||||||
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
|
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const SALES_INVOICE_NAMED_ACTIONS = [
|
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;
|
] as const;
|
||||||
|
|
||||||
export const SALES_PAYMENT_NAMED_ACTIONS = [
|
export const SALES_PAYMENT_NAMED_ACTIONS = [
|
||||||
@@ -119,3 +125,16 @@ export function documentStatusFilterOptions(t: (key: string) => string, type: Sa
|
|||||||
label: t(`status_${value}`),
|
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 { 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 { notifications } from '@repo/ui/components';
|
||||||
import {
|
import {
|
||||||
useDetailPageContext,
|
useDetailPageContext,
|
||||||
@@ -7,12 +7,11 @@ import {
|
|||||||
useEnterpriseModuleDataServiceContext,
|
useEnterpriseModuleDataServiceContext,
|
||||||
useEnterpriseModuleTranslationContext,
|
useEnterpriseModuleTranslationContext,
|
||||||
} from '@repo/ui/foundations';
|
} 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 { SalesDocumentRemoteDataServices } from './sales-document.remote.service';
|
||||||
import type { SalesDocumentEntity, SalesLineEntity } from './sales-document.entity';
|
import type { SalesDocumentEntity, SalesLineEntity } from './sales-document.entity';
|
||||||
|
|
||||||
type SalesActionEntity = { id?: string | number; status?: string; products?: SalesLineEntity[] };
|
type SalesActionEntity = { id?: string | number; status?: string; products?: SalesLineEntity[] };
|
||||||
import { ChangeStatusModal } from './change-status-modal';
|
|
||||||
import { ImportCsvModal } from './import-csv-modal';
|
import { ImportCsvModal } from './import-csv-modal';
|
||||||
import { ProcessOrderModal } from './process-order-modal';
|
import { ProcessOrderModal } from './process-order-modal';
|
||||||
import { CompletePackingModal } from './complete-packing-modal';
|
import { CompletePackingModal } from './complete-packing-modal';
|
||||||
@@ -34,12 +33,10 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
|||||||
SalesDocumentEntity,
|
SalesDocumentEntity,
|
||||||
SalesDocumentRemoteDataServices<SalesDocumentEntity>
|
SalesDocumentRemoteDataServices<SalesDocumentEntity>
|
||||||
>();
|
>();
|
||||||
const [statusOpened, setStatusOpened] = useState(false);
|
|
||||||
const [importOpened, setImportOpened] = useState(false);
|
const [importOpened, setImportOpened] = useState(false);
|
||||||
const [processOpened, setProcessOpened] = useState(false);
|
const [processOpened, setProcessOpened] = useState(false);
|
||||||
const [completeOpened, setCompleteOpened] = useState(false);
|
const [completeOpened, setCompleteOpened] = useState(false);
|
||||||
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
||||||
const [pendingStatus, setPendingStatus] = useState<string | undefined>();
|
|
||||||
const [completeProducts, setCompleteProducts] = useState<SalesLineEntity[]>([]);
|
const [completeProducts, setCompleteProducts] = useState<SalesLineEntity[]>([]);
|
||||||
const canEdit = privileges.ALLOW_EDIT;
|
const canEdit = privileges.ALLOW_EDIT;
|
||||||
const canImport = privileges.ALLOW_IMPORT;
|
const canImport = privileges.ALLOW_IMPORT;
|
||||||
@@ -72,7 +69,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const namedRowActions = (data: SalesActionEntity, defaultActions: any[]) => {
|
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 extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
|
||||||
const Icon = ACTION_ICONS[action.key] ?? Check;
|
const Icon = ACTION_ICONS[action.key] ?? Check;
|
||||||
return {
|
return {
|
||||||
@@ -84,23 +82,12 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (documentType !== 'invoice') {
|
return [...extras, ...chromeActions];
|
||||||
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];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const namedBulkActions = (selectedRows: SalesActionEntity[], defaultActions: any[]) => {
|
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 statuses = selectedRows.map((row) => row.status);
|
||||||
const extras: any[] = namedActionsFor(documentType, statuses[0])
|
const extras: any[] = namedActionsFor(documentType, statuses[0])
|
||||||
.filter((action) => statuses.every((status) => (action.from as readonly string[]).includes(status ?? '')))
|
.filter((action) => statuses.every((status) => (action.from as readonly string[]).includes(status ?? '')))
|
||||||
@@ -121,20 +108,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (documentType !== 'invoice') {
|
return [...extras, ...chromeActions];
|
||||||
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];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const importPageAction = canImport
|
const importPageAction = canImport
|
||||||
@@ -161,18 +135,6 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
|||||||
}
|
}
|
||||||
: null;
|
: 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) =>
|
const createPaymentAction = (_data: SalesActionEntity, onClick: () => void) =>
|
||||||
canEdit
|
canEdit
|
||||||
? {
|
? {
|
||||||
@@ -186,7 +148,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const detailStatusActions = (data: SalesActionEntity, defaultActions: any[]) => {
|
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 extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
|
||||||
const Icon = ACTION_ICONS[action.key] ?? Check;
|
const Icon = ACTION_ICONS[action.key] ?? Check;
|
||||||
return {
|
return {
|
||||||
@@ -200,34 +163,11 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (documentType !== 'invoice') {
|
return [...extras, ...chromeActions];
|
||||||
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];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const modals = (
|
const modals = (
|
||||||
<>
|
<>
|
||||||
<ChangeStatusModal
|
|
||||||
opened={statusOpened}
|
|
||||||
onClose={() => setStatusOpened(false)}
|
|
||||||
documentType={documentType}
|
|
||||||
currentStatus={pendingStatus}
|
|
||||||
onSubmit={async (status) => {
|
|
||||||
await applyStatus(pendingIds, status);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ProcessOrderModal
|
<ProcessOrderModal
|
||||||
opened={processOpened}
|
opened={processOpened}
|
||||||
onClose={() => setProcessOpened(false)}
|
onClose={() => setProcessOpened(false)}
|
||||||
@@ -260,7 +200,6 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
|||||||
namedBulkActions,
|
namedBulkActions,
|
||||||
importPageAction,
|
importPageAction,
|
||||||
createOrderAction,
|
createOrderAction,
|
||||||
createInvoiceAction,
|
|
||||||
createPaymentAction,
|
createPaymentAction,
|
||||||
detailStatusActions,
|
detailStatusActions,
|
||||||
applyStatus,
|
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 { 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) {
|
if (value === '' || value === null || value === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) ? String(value) : value;
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') {
|
export function toDecimalStringValue(value: unknown): string | undefined {
|
||||||
return z.string().regex(DECIMAL_PATTERN, {
|
const next = coerceDecimalInput(value);
|
||||||
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }),
|
return typeof next === 'string' ? next : undefined;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optionalDecimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') {
|
function decimalPattern(maxDecimals: number) {
|
||||||
return z.preprocess(emptyToUndefined, decimalStringSchema(t, fieldKey).optional());
|
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,88 @@
|
|||||||
|
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,132 @@
|
|||||||
|
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,88 @@
|
|||||||
|
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,249 @@
|
|||||||
|
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,71 @@
|
|||||||
|
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,82 @@
|
|||||||
|
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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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,43 @@
|
|||||||
|
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,62 @@
|
|||||||
|
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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { FILTER_FIELD_TYPE, FILTER_TYPE } from '../constants';
|
||||||
|
import { transformFilterValue } from '../utils/filter.helper';
|
||||||
|
|
||||||
|
describe('transformFilterValue', () => {
|
||||||
|
it('maps text equals filter', () => {
|
||||||
|
const result = transformFilterValue(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
filterColumn: 'main__status',
|
||||||
|
filterType: FILTER_TYPE.TEXT_EQUALS,
|
||||||
|
fieldType: FILTER_FIELD_TYPE.INPUT_TEXT,
|
||||||
|
fieldLabel: 'Status',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ main__status: 'active' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.main__status).toEqual({
|
||||||
|
type: FILTER_TYPE.TEXT_EQUALS,
|
||||||
|
filter: 'active',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps date range to epoch milliseconds', () => {
|
||||||
|
const result = transformFilterValue(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
filterColumn: 'main__date',
|
||||||
|
filterType: FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH,
|
||||||
|
fieldType: FILTER_FIELD_TYPE.DATE_RANGE_PICKER,
|
||||||
|
fieldLabel: 'Date',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
main__date: { from: '2026-01-01', to: '2026-01-31' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.main__date?.type).toBe(FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH);
|
||||||
|
expect((result.main__date?.filter as { from: number }).from).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { FILTER_FIELD_TYPE, FILTER_TYPE } from '../constants';
|
||||||
|
import type { FilterConfig, FilterModelEntry } from '../entities';
|
||||||
|
|
||||||
|
export function transformFilterValue(
|
||||||
|
filterConfigs: FilterConfig[] | undefined,
|
||||||
|
formValues: Record<string, unknown>,
|
||||||
|
): Record<string, FilterModelEntry> {
|
||||||
|
const result: Record<string, FilterModelEntry> = {};
|
||||||
|
if (!filterConfigs) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of filterConfigs) {
|
||||||
|
const raw = formValues[config.filterColumn];
|
||||||
|
if (raw === undefined || raw === null || raw === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter: unknown = raw;
|
||||||
|
if (
|
||||||
|
config.fieldType === FILTER_FIELD_TYPE.DATE_RANGE_PICKER &&
|
||||||
|
config.filterType === FILTER_TYPE.TEXT_IN_DATE_RANGE_EPOCH
|
||||||
|
) {
|
||||||
|
const range = raw as { from?: string; to?: string };
|
||||||
|
if (!range.from && !range.to) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filter = {
|
||||||
|
from: range.from
|
||||||
|
? dayjs(range.from).startOf('day').valueOf()
|
||||||
|
: undefined,
|
||||||
|
to: range.to ? dayjs(range.to).endOf('day').valueOf() : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
result[config.filterColumn] = {
|
||||||
|
type: config.filterType,
|
||||||
|
filter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreFilterFormValues(
|
||||||
|
configuration: Record<string, unknown>,
|
||||||
|
filterConfigs: FilterConfig[] | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const restored: Record<string, unknown> = { ...configuration };
|
||||||
|
if (!filterConfigs) {
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const config of filterConfigs) {
|
||||||
|
const value = configuration[config.filterColumn];
|
||||||
|
if (
|
||||||
|
config.fieldType === FILTER_FIELD_TYPE.DATE_RANGE_PICKER &&
|
||||||
|
value &&
|
||||||
|
typeof value === 'object'
|
||||||
|
) {
|
||||||
|
const range = value as { from?: number; to?: number };
|
||||||
|
restored[config.filterColumn] = {
|
||||||
|
from: range.from ? dayjs(range.from).format('YYYY-MM-DD') : undefined,
|
||||||
|
to: range.to ? dayjs(range.to).format('YYYY-MM-DD') : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return restored;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { MantineProvider } from '@mantine/core';
|
||||||
|
import { FieldCurrencyInput } from '../fields/currency-input.field';
|
||||||
|
|
||||||
|
vi.mock('@repo/core-i18n', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
i18n: { exists: () => false },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('FieldCurrencyInput', () => {
|
||||||
|
it('renders a formatted Rupiah value while keeping the form value as a number', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control, handleSubmit, getValues } = useForm({
|
||||||
|
defaultValues: { price: 12500.12345 },
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<FieldCurrencyInput name="price" control={control} label="Price" />
|
||||||
|
<div data-testid="stored">{String(getValues('price'))}</div>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Price')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('stored')).toHaveTextContent('12500.12345');
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Save'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({ price: 12500.12345 }, expect.anything());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes a number on change, not a formatted string', async () => {
|
||||||
|
const onSubmit = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
function TestForm() {
|
||||||
|
const { control, handleSubmit } = useForm({
|
||||||
|
defaultValues: { price: '' as number | '' },
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<FieldCurrencyInput name="price" control={control} label="Price" />
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
</MantineProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<TestForm />);
|
||||||
|
const input = screen.getByLabelText('Price');
|
||||||
|
await user.type(input, '15000,12345');
|
||||||
|
await user.click(screen.getByText('Save'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(onSubmit.mock.calls[0][0].price).toBe(15000.12345);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NumberInput, type NumberInputProps } from '@mantine/core';
|
||||||
|
import { useController, type FieldPath, type FieldValues, type UseControllerProps } from 'react-hook-form';
|
||||||
|
import { CURRENCY_DATA_SCALE, CurrencyUtils, toCurrencyNumber } from '@repo/utils';
|
||||||
|
import { useTranslatedError } from '../useTranslatedError';
|
||||||
|
|
||||||
|
type ManagedProps = 'value' | 'defaultValue' | 'onChange' | 'onBlur' | 'error';
|
||||||
|
|
||||||
|
export type FieldCurrencyInputProps<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = UseControllerProps<TFieldValues, TName> & Omit<NumberInputProps, ManagedProps>;
|
||||||
|
|
||||||
|
function toFieldNumber(next: string | number): number | '' {
|
||||||
|
if (next === '') return '';
|
||||||
|
const numeric = typeof next === 'number' ? next : Number(next);
|
||||||
|
return Number.isFinite(numeric) ? numeric : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldCurrencyInputInner<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>(props: FieldCurrencyInputProps<TFieldValues, TName>) {
|
||||||
|
const { name, control, rules, shouldUnregister, defaultValue, disabled, ...mantineProps } = props;
|
||||||
|
const {
|
||||||
|
field,
|
||||||
|
fieldState: { error },
|
||||||
|
} = useController<TFieldValues, TName>({
|
||||||
|
name,
|
||||||
|
control,
|
||||||
|
rules,
|
||||||
|
shouldUnregister,
|
||||||
|
defaultValue,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
const translatedError = useTranslatedError(error?.message);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
hideControls
|
||||||
|
allowNegative={false}
|
||||||
|
min={0}
|
||||||
|
thousandSeparator="."
|
||||||
|
decimalSeparator=","
|
||||||
|
prefix={CurrencyUtils.getGlobalPrefix()}
|
||||||
|
{...mantineProps}
|
||||||
|
decimalScale={CURRENCY_DATA_SCALE}
|
||||||
|
value={toCurrencyNumber(field.value)}
|
||||||
|
onChange={(next) => field.onChange(toFieldNumber(next))}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
error={translatedError}
|
||||||
|
disabled={field.disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FieldCurrencyInput = React.memo(FieldCurrencyInputInner) as typeof FieldCurrencyInputInner;
|
||||||
|
(FieldCurrencyInput as { displayName?: string }).displayName = 'FieldCurrencyInput';
|
||||||
@@ -25,6 +25,8 @@ export { FieldTextInput } from './fields/text-input.field';
|
|||||||
export { FieldPasswordInput } from './fields/password-input.field';
|
export { FieldPasswordInput } from './fields/password-input.field';
|
||||||
export { FieldTextarea } from './fields/textarea.field';
|
export { FieldTextarea } from './fields/textarea.field';
|
||||||
export { FieldNumberInput } from './fields/number-input.field';
|
export { FieldNumberInput } from './fields/number-input.field';
|
||||||
|
export { FieldCurrencyInput } from './fields/currency-input.field';
|
||||||
|
export type { FieldCurrencyInputProps } from './fields/currency-input.field';
|
||||||
export { FieldJsonInput } from './fields/json-input.field';
|
export { FieldJsonInput } from './fields/json-input.field';
|
||||||
export { FieldPinInput } from './fields/pin-input.field';
|
export { FieldPinInput } from './fields/pin-input.field';
|
||||||
export { FieldAutocomplete } from './fields/autocomplete.field';
|
export { FieldAutocomplete } from './fields/autocomplete.field';
|
||||||
|
|||||||
@@ -11,5 +11,14 @@ export { initAgGrid } from './ag-grid-setup';
|
|||||||
export type { AgGridInitOptions } from './ag-grid-setup';
|
export type { AgGridInitOptions } from './ag-grid-setup';
|
||||||
|
|
||||||
/* ── Re-export commonly used AG Grid types ───────── */
|
/* ── Re-export commonly used AG Grid types ───────── */
|
||||||
export type { ColDef, GridReadyEvent, GridOptions, ValueFormatterParams } from 'ag-grid-community';
|
export type {
|
||||||
|
ColDef,
|
||||||
|
ColumnState,
|
||||||
|
GridApi,
|
||||||
|
GridReadyEvent,
|
||||||
|
GridOptions,
|
||||||
|
IServerSideDatasource,
|
||||||
|
IServerSideGetRowsParams,
|
||||||
|
ValueFormatterParams,
|
||||||
|
} from 'ag-grid-community';
|
||||||
export type { AgGridReactProps } from 'ag-grid-react';
|
export type { AgGridReactProps } from 'ag-grid-react';
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
export * from './field-value';
|
export * from './field-value';
|
||||||
export * from './render-date';
|
export * from './render-date';
|
||||||
|
export * from './render-currency';
|
||||||
|
export * from './render-decimal';
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { RenderCurrency } from './render-currency';
|
||||||
|
|
||||||
|
describe('RenderCurrency', () => {
|
||||||
|
it('renders Rupiah with two display decimals', () => {
|
||||||
|
render(<RenderCurrency value="12500.12345" />);
|
||||||
|
expect(screen.getByText('Rp 12.500,12')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the fallback when empty', () => {
|
||||||
|
render(<RenderCurrency value={null} />);
|
||||||
|
expect(screen.getByText('-')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user