Compare commits
8
Commits
| 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`. |
|
||||
| `disabled` | `boolean` | `false` | Disables the AppShell layout entirely (renders children without structural chrome). |
|
||||
|
||||
> [!TIP]
|
||||
> **Smart defaults**: You rarely need to set `withUtilityBar`, `withAside`, or `withFooter` explicitly. The engine auto-detects presence by checking if the corresponding slot is provided and truthy. Only set them to `false` when you want to **suppress** a slot that is being passed.
|
||||
> [!TIP] > **Smart defaults**: You rarely need to set `withUtilityBar`, `withAside`, or `withFooter` explicitly. The engine auto-detects presence by checking if the corresponding slot is provided and truthy. Only set them to `false` when you want to **suppress** a slot that is being passed.
|
||||
|
||||
---
|
||||
|
||||
@@ -234,8 +233,7 @@ import { useCoreAppShell } from '@repo/ui/components';
|
||||
| `toggleNavbarPanel()` | `() => void` | Toggle the double-sidebar panel open/closed |
|
||||
| `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` |
|
||||
|
||||
> [!WARNING]
|
||||
> `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
|
||||
> [!WARNING] > `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FieldPasswordInput,
|
||||
FieldTextarea,
|
||||
FieldNumberInput,
|
||||
FieldCurrencyInput,
|
||||
FieldJsonInput,
|
||||
FieldPinInput,
|
||||
FieldAutocomplete,
|
||||
@@ -82,6 +83,7 @@ export default function AllFieldsDemo() {
|
||||
password: '',
|
||||
description: '',
|
||||
age: undefined,
|
||||
price: 12500.12345,
|
||||
jsonConfig: '',
|
||||
pin: '',
|
||||
country: '',
|
||||
@@ -142,6 +144,9 @@ export default function AllFieldsDemo() {
|
||||
<FieldPasswordInput name="password" control={control} label={t.fields.password} />
|
||||
<FieldNumberInput name="age" control={control} label={t.fields.age} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldCurrencyInput name="price" control={control} label={t.fields.price} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
|
||||
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"password": "Password",
|
||||
"description": "Description",
|
||||
"age": "Age",
|
||||
"price": "Price",
|
||||
"jsonConfig": "JSON Config",
|
||||
"tags": "Tags",
|
||||
"terms": "I agree to the terms and conditions",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"password": "Kata Sandi",
|
||||
"description": "Deskripsi",
|
||||
"age": "Usia",
|
||||
"price": "Harga",
|
||||
"jsonConfig": "Konfigurasi JSON",
|
||||
"tags": "Label (Tags)",
|
||||
"terms": "Saya setuju dengan syarat dan ketentuan",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Reports UI
|
||||
|
||||
Generic, config-driven report screens live in `apps/web/src/core/report/`. Report definitions are backend `ReportConfigEntity` objects.
|
||||
|
||||
Architecture and APIs: [trackgo-be/docs/report-engine.md](../../../../../trackgo-be/docs/report-engine.md)
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Role |
|
||||
| --- | --- |
|
||||
| `constants/` | `FILTER_TYPE`, `DATA_FORMAT`, `REPORT_GROUP` — keep in sync with backend |
|
||||
| `entities/` | Frontend mirror of config / query contracts |
|
||||
| `data/report.remote.service.ts` | HTTP client (`apiClient`) |
|
||||
| `utils/filter.helper.ts` | Form values → `filterModel` |
|
||||
| `utils/column.helper.ts` | `columnConfigs` → AG Grid `columnDefs` |
|
||||
| `components/report-provider.tsx` | Load configs → Mantine tabs |
|
||||
| `components/report-table.tsx` | AG Grid SSRM + filter/bookmark actions |
|
||||
| `components/report-filter-drawer.tsx` | Config-driven filter form |
|
||||
| `components/report-bookmark-list.tsx` | Bookmark apply / delete |
|
||||
|
||||
## Product modules
|
||||
|
||||
| Module | Path | `moduleKey` |
|
||||
| --- | --- | --- |
|
||||
| Sales reports | `apps/main/modules/sales/reports/` | `SALES.REPORT` |
|
||||
| Logistics reports | `apps/main/modules/field/logistics-reports/` | `LOGISTICS.REPORT` |
|
||||
|
||||
Each module wraps `ReportProvider` with `groupName` `sales_report` or `logistics_report` inside `EnterpriseModuleProvider` for RBAC.
|
||||
|
||||
## Grid contract
|
||||
|
||||
Every server-side block sends:
|
||||
|
||||
```ts
|
||||
{
|
||||
groupName,
|
||||
uniqueName,
|
||||
queryModel: { /* AG Grid IServerSideGetRowsRequest + merged filterModel */ }
|
||||
}
|
||||
```
|
||||
|
||||
Column defs, filters, and formats come from the config payload. Adding a report is a backend config change only.
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../core/storage/local', () => ({
|
||||
appStorage: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
},
|
||||
AppStorageKey: {
|
||||
SIDEBAR_OPEN_MENUS: 'sidebar_open_menus',
|
||||
},
|
||||
}));
|
||||
|
||||
import { appStorage } from '../../../../core/storage/local';
|
||||
import { useSidebarStore } from './sidebar.store';
|
||||
|
||||
const allParentKeys = ['sales', 'sales-data', 'sales-activities'];
|
||||
|
||||
describe('useSidebarStore', () => {
|
||||
beforeEach(() => {
|
||||
useSidebarStore.setState({
|
||||
openedKeys: new Set(),
|
||||
isInitialized: false,
|
||||
isAllExpanded: false,
|
||||
searchQuery: '',
|
||||
});
|
||||
vi.mocked(appStorage.getItem).mockReset();
|
||||
vi.mocked(appStorage.setItem).mockReset();
|
||||
});
|
||||
|
||||
it('restores saved open keys so nested sales branches can render together', async () => {
|
||||
vi.mocked(appStorage.getItem).mockResolvedValue(['sales', 'sales-data']);
|
||||
|
||||
await useSidebarStore.getState().initializeStorage(['sales'], allParentKeys);
|
||||
|
||||
expect([...useSidebarStore.getState().openedKeys]).toEqual(['sales', 'sales-data']);
|
||||
expect(useSidebarStore.getState().isInitialized).toBe(true);
|
||||
expect(useSidebarStore.getState().isAllExpanded).toBe(false);
|
||||
});
|
||||
|
||||
it('opens the active path on first visit when nothing is saved', async () => {
|
||||
vi.mocked(appStorage.getItem).mockResolvedValue(null);
|
||||
|
||||
await useSidebarStore.getState().initializeStorage(['sales', 'sales-data'], allParentKeys);
|
||||
|
||||
expect([...useSidebarStore.getState().openedKeys]).toEqual(['sales', 'sales-data']);
|
||||
expect(appStorage.setItem).toHaveBeenCalledWith('sidebar_open_menus', ['sales', 'sales-data']);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { useTranslation } from '@repo/core-i18n';
|
||||
import { shortcutsData } from '@repo/ui/constants';
|
||||
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
|
||||
import { useSidebarStore } from './sidebar.store';
|
||||
import { getAllParentKeys, shouldShowMenuChildren } from './sidebar.utils';
|
||||
import { useDebouncedValue } from '@repo/ui/hooks';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,7 +75,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
||||
const isOpened = useSidebarStore((state) => state.openedKeys.has(item.key));
|
||||
const toggleMenu = useSidebarStore((state) => state.toggleMenu);
|
||||
|
||||
const effectivelyOpened = isSearching || isOpened;
|
||||
const effectivelyOpened = shouldShowMenuChildren(isOpened, isSearching);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newOpened: boolean) => {
|
||||
@@ -84,39 +85,46 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
||||
);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
component={hasChildren ? 'button' : (Link as any)}
|
||||
to={hasChildren ? undefined : item.path}
|
||||
label={t(item.label)}
|
||||
leftSection={<Icon size={18} strokeWidth={1.8} />}
|
||||
active={isExactActive}
|
||||
opened={effectivelyOpened}
|
||||
onChange={handleChange}
|
||||
variant="light"
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
color: isParentActive ? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))' : undefined,
|
||||
},
|
||||
label: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{hasChildren &&
|
||||
item.children!.map((child) => (
|
||||
<MenuItemExpanded
|
||||
key={child.key}
|
||||
item={child}
|
||||
activeKeys={activeKeys}
|
||||
isSearching={isSearching}
|
||||
allParentKeys={allParentKeys}
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
<>
|
||||
<NavLink
|
||||
component={hasChildren ? 'button' : (Link as any)}
|
||||
to={hasChildren ? undefined : item.path}
|
||||
label={t(item.label)}
|
||||
leftSection={<Icon size={18} strokeWidth={1.8} />}
|
||||
active={isExactActive}
|
||||
opened={effectivelyOpened}
|
||||
onChange={handleChange}
|
||||
variant="light"
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
color: isParentActive ? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))' : undefined,
|
||||
},
|
||||
label: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Keep the chevron without nesting real items in Collapse (nested height clips siblings on refresh). */}
|
||||
{hasChildren ? <></> : undefined}
|
||||
</NavLink>
|
||||
{hasChildren && effectivelyOpened ? (
|
||||
<Box ps="lg">
|
||||
{item.children!.map((child) => (
|
||||
<MenuItemExpanded
|
||||
key={child.key}
|
||||
item={child}
|
||||
activeKeys={activeKeys}
|
||||
isSearching={isSearching}
|
||||
allParentKeys={allParentKeys}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -236,17 +244,6 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot =
|
||||
// SidebarMenu Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getAllParentKeys = (items: MenuItemType[]): string[] => {
|
||||
let keys: string[] = [];
|
||||
for (const item of items) {
|
||||
if (item.children && item.children.length > 0) {
|
||||
keys.push(item.key);
|
||||
keys = keys.concat(getAllParentKeys(item.children));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
export const SidebarMenu = memo(function SidebarMenu({
|
||||
items,
|
||||
variantOverride,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MENU_ITEMS } from '../data/menu.data';
|
||||
import { getAllParentKeys, getVisibleMenuKeys, shouldShowMenuChildren } from './sidebar.utils';
|
||||
|
||||
describe('shouldShowMenuChildren', () => {
|
||||
it('shows children when the branch is open or the user is searching', () => {
|
||||
expect(shouldShowMenuChildren(true, false)).toBe(true);
|
||||
expect(shouldShowMenuChildren(false, true)).toBe(true);
|
||||
expect(shouldShowMenuChildren(false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllParentKeys', () => {
|
||||
it('includes nested groups under sales, logistics, and settings', () => {
|
||||
expect(getAllParentKeys(MENU_ITEMS)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'sales',
|
||||
'sales-data',
|
||||
'sales-activities',
|
||||
'logistics',
|
||||
'logistics-data',
|
||||
'settings',
|
||||
'settings-data',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisibleMenuKeys', () => {
|
||||
it('keeps all siblings of an expanded nested branch visible after restore', () => {
|
||||
const openedKeys = new Set(['sales', 'sales-data']);
|
||||
|
||||
const keys = getVisibleMenuKeys(MENU_ITEMS, openedKeys);
|
||||
|
||||
expect(keys).toContain('sales-employees');
|
||||
expect(keys).toContain('sales-cycles');
|
||||
expect(keys).toContain('sales-activities');
|
||||
expect(keys).toContain('sales-reports');
|
||||
expect(keys).not.toContain('sales-requests');
|
||||
});
|
||||
|
||||
it('reveals every nested item while searching', () => {
|
||||
const keys = getVisibleMenuKeys(MENU_ITEMS, new Set(), true);
|
||||
|
||||
expect(keys).toContain('sales-requests');
|
||||
expect(keys).toContain('logistics-packing-slips');
|
||||
expect(keys).toContain('system-users');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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',
|
||||
label: 'nav:reports-coming-soon',
|
||||
label: 'nav:sales-reports',
|
||||
icon: FileText,
|
||||
path: '/app/sales/reports',
|
||||
isPlaceholder: true,
|
||||
path: '/app/sales/reports/index',
|
||||
moduleKey: 'SALES.REPORT',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -165,10 +165,10 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
},
|
||||
{
|
||||
key: 'logistics-reports',
|
||||
label: 'nav:reports-coming-soon',
|
||||
label: 'nav:logistics-reports',
|
||||
icon: FileText,
|
||||
path: '/app/logistics/reports',
|
||||
isPlaceholder: true,
|
||||
path: '/app/logistics/reports/index',
|
||||
moduleKey: 'LOGISTICS.REPORT',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
"data": "Data",
|
||||
"activities": "Activities",
|
||||
"reports-coming-soon": "Reports (Coming Soon)",
|
||||
"sales-reports": "Sales Reports",
|
||||
"logistics-reports": "Logistics Reports",
|
||||
"user": "User",
|
||||
"settings": "Settings",
|
||||
"settings-general": "General Settings",
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
"data": "Data",
|
||||
"activities": "Aktivitas",
|
||||
"reports-coming-soon": "Laporan (Segera Hadir)",
|
||||
"sales-reports": "Laporan Penjualan",
|
||||
"logistics-reports": "Laporan Logistik",
|
||||
"user": "Pengguna",
|
||||
"settings": "Pengaturan",
|
||||
"settings-general": "Pengaturan Umum",
|
||||
|
||||
+9
@@ -64,4 +64,13 @@ describe('ProductsRemoteDataTransformer', () => {
|
||||
expect(payload).not.toHaveProperty('status');
|
||||
expect(payload).not.toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('stringifies a numeric price on write without rounding', () => {
|
||||
const payload = transformer.transformCreatePayload({
|
||||
code: 'SKU_003',
|
||||
name: 'Priced Widget',
|
||||
price: 12500.12345 as unknown as string,
|
||||
});
|
||||
expect(payload.price).toBe('12500.12345');
|
||||
});
|
||||
});
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
||||
import { toDecimalStringValue } from '../../../../../../../core/domain/decimal-string.schema';
|
||||
import type { ProductDto, ProductEntity } from '../entities';
|
||||
|
||||
export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEntity> {
|
||||
@@ -28,7 +29,7 @@ export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEn
|
||||
code: entity.code,
|
||||
name: entity.name,
|
||||
unit: entity.unit,
|
||||
price: entity.price,
|
||||
price: toDecimalStringValue(entity.price),
|
||||
brand: entity.brand,
|
||||
});
|
||||
}
|
||||
@@ -38,7 +39,7 @@ export class ProductsRemoteDataTransformer extends BaseDataTransformer<ProductEn
|
||||
code: entity.code,
|
||||
name: entity.name,
|
||||
unit: emptyToNull(entity.unit) as string | null,
|
||||
price: emptyToNull(entity.price) as string | null,
|
||||
price: emptyToNull(toDecimalStringValue(entity.price)) as string | null,
|
||||
brand: emptyToNull(entity.brand) as string | null,
|
||||
};
|
||||
}
|
||||
|
||||
+6
-1
@@ -29,6 +29,11 @@ describe('createProductSchema', () => {
|
||||
});
|
||||
|
||||
it('rejects a non-decimal price', () => {
|
||||
expect(schema.safeParse({ ...valid, price: '12.34567' }).success).toBe(false);
|
||||
expect(schema.safeParse({ ...valid, price: 'abc' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a numeric price with five decimal places', () => {
|
||||
expect(schema.safeParse({ ...valid, price: 12.34567 }).success).toBe(true);
|
||||
expect(schema.safeParse({ ...valid, price: '12.34567' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+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 type { ProductEntity } from '../../../domain/entities';
|
||||
|
||||
@@ -17,7 +17,11 @@ export function DetailGeneral() {
|
||||
<FieldValue label={t('common:fields.code')} value={data?.code} />
|
||||
<FieldValue label={t('common:fields.name')} value={data?.name} />
|
||||
<FieldValue label={t('common:fields.unit')} value={data?.unit} />
|
||||
<FieldValue label={t('common:fields.price')} value={data?.price} />
|
||||
<FieldValue
|
||||
label={t('common:fields.price')}
|
||||
value={data?.price}
|
||||
render={(val) => <RenderCurrency value={val as string | number | null} />}
|
||||
/>
|
||||
<FieldValue label={t('common:fields.brand')} value={data?.brand} />
|
||||
<FieldValue
|
||||
label={t('common:fields.status')}
|
||||
|
||||
+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';
|
||||
|
||||
export function FormGeneral() {
|
||||
@@ -35,11 +35,11 @@ export function FormGeneral() {
|
||||
placeholder="e.g. PCS"
|
||||
radius="md"
|
||||
/>
|
||||
<FieldTextInput
|
||||
<FieldCurrencyInput
|
||||
control={formControl.control}
|
||||
name="price"
|
||||
label={t('common:fields.price')}
|
||||
placeholder="12500.0000"
|
||||
placeholder="Rp 12.500,00"
|
||||
radius="md"
|
||||
/>
|
||||
<FieldTextInput
|
||||
|
||||
+7
-1
@@ -7,6 +7,7 @@ import {
|
||||
import { ColDef, Text } from '@repo/ui/components';
|
||||
import { Trans } from '@repo/core-i18n';
|
||||
import { Package } from 'lucide-react';
|
||||
import { formatRupiah } from '@repo/utils';
|
||||
import { FilterFormContent } from '../components/index-component/filter-content';
|
||||
import type { ProductEntity } from '../../domain/entities';
|
||||
|
||||
@@ -18,7 +19,12 @@ export default function ProductPageIndex() {
|
||||
{ field: 'code', headerName: t('common:fields.code'), minWidth: 140 },
|
||||
{ field: 'name', headerName: t('common:fields.name'), minWidth: 180 },
|
||||
{ field: 'unit', headerName: t('common:fields.unit'), minWidth: 100 },
|
||||
{ field: 'price', headerName: t('common:fields.price'), minWidth: 140 },
|
||||
{
|
||||
field: 'price',
|
||||
headerName: t('common:fields.price'),
|
||||
minWidth: 140,
|
||||
valueFormatter: ({ value }) => formatRupiah(value) || '-',
|
||||
},
|
||||
{ field: 'brand', headerName: t('common:fields.brand'), minWidth: 140 },
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
import { divisionsDataService } from '../divisions/domain/factories';
|
||||
import type { DivisionEntity } from '../divisions/domain/entities';
|
||||
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from '../../field/shared/create-option-loader';
|
||||
|
||||
export const loadDivisionOptions: LoadOptionsFn<DivisionEntity> = async (search, page) => {
|
||||
const result = await divisionsDataService.getMany({
|
||||
params: { search, page, limit: 20 },
|
||||
});
|
||||
const rows = (result.data as { data?: DivisionEntity[]; meta?: { totalPages?: number } })?.data ?? [];
|
||||
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
|
||||
return { options: rows, hasMore: page < totalPages };
|
||||
};
|
||||
export const loadDivisionOptions = createOptionLoader<DivisionEntity>(
|
||||
(config) => divisionsDataService.getMany(config),
|
||||
ACTIVE_LOOKUP_PARAMS,
|
||||
);
|
||||
|
||||
+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 { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EmbeddedComingSoonPage } from '../../../../../core/components/coming-soon-page';
|
||||
const LogisticsReportsModule = lazy(
|
||||
() => import('../logistics-reports/presentation/factory'),
|
||||
);
|
||||
|
||||
const EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory'));
|
||||
const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
|
||||
@@ -14,7 +16,7 @@ export default function LogisticsFieldModule() {
|
||||
<Route path="/cycles/*" element={<CyclesModule purpose="logistics" />} />
|
||||
<Route path="/plans/*" element={<PlansModule purpose="logistics" />} />
|
||||
<Route path="/packing-slips/*" element={<PackingSlipsModule />} />
|
||||
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
|
||||
<Route path="/reports/*" element={<LogisticsReportsModule />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"status_updated": "Status updated.",
|
||||
"action_complete": "Complete",
|
||||
"action_cancel": "Cancel",
|
||||
"action_process": "Process",
|
||||
"complete_packing": "Complete packing",
|
||||
"complete_packing_help": "Enter delivered quantity for each line. Remaining quantity opens a new packing slip.",
|
||||
"delivered_quantity": "Delivered quantity",
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"status_updated": "Status diperbarui.",
|
||||
"action_complete": "Selesaikan",
|
||||
"action_cancel": "Batalkan",
|
||||
"action_process": "Proses",
|
||||
"complete_packing": "Selesaikan packing",
|
||||
"complete_packing_help": "Masukkan kuantitas terkirim untuk setiap baris. Sisa kuantitas akan membuka surat jalan baru.",
|
||||
"delivered_quantity": "Kuantitas terkirim",
|
||||
|
||||
@@ -23,4 +23,18 @@ describe('createPlanSchema', () => {
|
||||
it('rejects empty customers', () => {
|
||||
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';
|
||||
const relationSchema = z.object({
|
||||
id: z.string(),
|
||||
code: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
const relationSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
code: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const createPlanSchema = (t: (key: string) => string) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
+107
-87
@@ -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 {
|
||||
useEnterpriseModuleTranslationContext,
|
||||
useFormPageContext,
|
||||
@@ -8,12 +8,23 @@ import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-
|
||||
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
|
||||
import { loadBranchOptions } from '../../../../shared/load-branch-options';
|
||||
import { loadCustomerOptions } from '../../../../shared/load-customer-options';
|
||||
import { loadSalesInvoiceOptions, loadPackingSlipOptions } from '../../../../shared/lookup.factories';
|
||||
import { createOptionLoader } from '../../../../shared/create-option-loader';
|
||||
import { relationLabel } from '../../../../shared/relation-label';
|
||||
import { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories';
|
||||
import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories';
|
||||
import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
|
||||
import type { BranchEntity } from '../../../../../configuration/branches/domain/entities';
|
||||
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
|
||||
import type { LookupEntity } from '../../../../shared/lookup.entity';
|
||||
import type { SalesInvoiceEntity } from '../../../../../sales/invoices/domain/entities';
|
||||
import type { PackingSlipEntity } from '../../../../packing-slips/domain/entities';
|
||||
import { 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() {
|
||||
const { formControl } = useFormPageContext();
|
||||
@@ -21,102 +32,111 @@ export function FormGeneral() {
|
||||
const { config } = useEnterpriseModuleConfigContext();
|
||||
const purpose = purposeFromModuleKey(config.moduleKey);
|
||||
const employee = formControl.watch('employee');
|
||||
const startBranch = formControl.watch('startBranch');
|
||||
const endBranch = formControl.watch('endBranch');
|
||||
const customers = formControl.watch('customers') ?? [];
|
||||
const invoices = formControl.watch('invoices') ?? [];
|
||||
const packingSlips = formControl.watch('packingSlips') ?? [];
|
||||
const startBranch = formControl.watch('startBranch') as BranchEntity | null | undefined;
|
||||
const endBranch = formControl.watch('endBranch') as BranchEntity | null | undefined;
|
||||
const customers = (formControl.watch('customers') ?? []) as CustomerEntity[];
|
||||
const invoices = (formControl.watch('invoices') ?? []) as SalesInvoiceEntity[];
|
||||
const packingSlips = (formControl.watch('packingSlips') ?? []) as PackingSlipEntity[];
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldAsyncSelect<EmployeeEntity>
|
||||
control={formControl.control}
|
||||
name="employee"
|
||||
label={t('common:fields.employee')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
|
||||
defaultOptions={employee ? [employee] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldDatePicker control={formControl.control} name="date" label={t('common:fields.date')} required />
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={formControl.control}
|
||||
name="startBranch"
|
||||
label={t('common:fields.startBranch')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadBranchOptions}
|
||||
defaultOptions={startBranch ? [startBranch] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={formControl.control}
|
||||
name="endBranch"
|
||||
label={t('common:fields.endBranch')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadBranchOptions}
|
||||
defaultOptions={endBranch ? [endBranch] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Box mt="md">
|
||||
<FieldAsyncSelect<CustomerEntity>
|
||||
control={formControl.control}
|
||||
name="customers"
|
||||
label={t('common:fields.customers')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadCustomerOptions}
|
||||
defaultOptions={customers}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</Box>
|
||||
<Box mt="md">
|
||||
{purpose === 'sales' ? (
|
||||
<FieldAsyncSelect<LookupEntity>
|
||||
<Stack gap="md">
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldAsyncSelect<EmployeeEntity>
|
||||
control={formControl.control}
|
||||
name="invoices"
|
||||
label={t('common:fields.invoices')}
|
||||
name="employee"
|
||||
label={t('common:fields.employee')}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadSalesInvoiceOptions}
|
||||
defaultOptions={invoices}
|
||||
loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
|
||||
defaultOptions={employee ? [employee] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
) : (
|
||||
<FieldAsyncSelect<LookupEntity>
|
||||
<FieldDatePicker control={formControl.control} name="date" label={t('common:fields.date')} required />
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={formControl.control}
|
||||
name="packingSlips"
|
||||
label={t('common:fields.packingSlips')}
|
||||
name="startBranch"
|
||||
label={t('common:fields.startBranch')}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadPackingSlipOptions}
|
||||
defaultOptions={packingSlips}
|
||||
loadOptions={loadBranchOptions}
|
||||
defaultOptions={startBranch ? [startBranch] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
)}
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={formControl.control}
|
||||
name="endBranch"
|
||||
label={t('common:fields.endBranch')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadBranchOptions}
|
||||
defaultOptions={endBranch ? [endBranch] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Box mt="md">
|
||||
<FieldAsyncSelect<CustomerEntity>
|
||||
control={formControl.control}
|
||||
name="customers"
|
||||
label={t('common:fields.customers')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadCustomerOptions}
|
||||
defaultOptions={customers}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</Box>
|
||||
<Box mt="md">
|
||||
{purpose === 'sales' ? (
|
||||
<FieldAsyncSelect<SalesInvoiceEntity>
|
||||
control={formControl.control}
|
||||
name="invoices"
|
||||
label={t('common:fields.invoices')}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadInvoiceOptions}
|
||||
defaultOptions={invoices}
|
||||
renderLabel={documentOptionLabel}
|
||||
/>
|
||||
) : (
|
||||
<FieldAsyncSelect<PackingSlipEntity>
|
||||
control={formControl.control}
|
||||
name="packingSlips"
|
||||
label={t('common:fields.packingSlips')}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadSlipOptions}
|
||||
defaultOptions={packingSlips}
|
||||
renderLabel={documentOptionLabel}
|
||||
/>
|
||||
)}
|
||||
</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_destinations": "Destinations",
|
||||
"section_attachments": "Attachments",
|
||||
"section_preview_customers": "Customer preview",
|
||||
"section_preview_track": "Track preview",
|
||||
"section_preview_invoices": "Invoice preview",
|
||||
"section_preview_packing_slips": "Packing slip preview",
|
||||
"generate": "Generate",
|
||||
"generate_title": "Generate plans",
|
||||
"generate_success": "Created {{created}} plan(s), skipped {{skipped}}.",
|
||||
"add_destination": "Add destination",
|
||||
"remove_destination": "Remove destination",
|
||||
"empty_route": "No route geometry",
|
||||
"section_attachments": "Attachments",
|
||||
"empty_products": "No products on this document",
|
||||
"preview_loading": "Loading document details…",
|
||||
"purpose_sales": "Sales",
|
||||
"purpose_logistics": "Logistics",
|
||||
"status_draft": "Draft",
|
||||
|
||||
@@ -13,13 +13,18 @@
|
||||
"section_route": "Rute",
|
||||
"section_destinations": "Destinasi",
|
||||
"section_attachments": "Lampiran",
|
||||
"section_preview_customers": "Pratinjau pelanggan",
|
||||
"section_preview_track": "Pratinjau rute",
|
||||
"section_preview_invoices": "Pratinjau faktur",
|
||||
"section_preview_packing_slips": "Pratinjau surat jalan",
|
||||
"generate": "Generate",
|
||||
"generate_title": "Generate rencana",
|
||||
"generate_success": "Berhasil membuat {{created}} rencana, {{skipped}} dilewati.",
|
||||
"add_destination": "Tambah destinasi",
|
||||
"remove_destination": "Hapus destinasi",
|
||||
"empty_route": "Tidak ada geometri rute",
|
||||
"section_attachments": "Lampiran",
|
||||
"empty_products": "Tidak ada produk pada dokumen ini",
|
||||
"preview_loading": "Memuat detail dokumen…",
|
||||
"purpose_sales": "Penjualan",
|
||||
"purpose_logistics": "Logistik",
|
||||
"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';
|
||||
|
||||
export const ACTIVE_LOOKUP_PARAMS = { status: 'active' } as const;
|
||||
|
||||
export function createOptionLoader<T>(
|
||||
getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>,
|
||||
extraParams?: Record<string, unknown>,
|
||||
): LoadOptionsFn<T> {
|
||||
return async (search, page) => {
|
||||
const result = await getMany({
|
||||
params: { search, page, limit: 20 },
|
||||
params: { search, page, limit: 20, ...extraParams },
|
||||
});
|
||||
const rows = (result.data as { data?: T[]; meta?: { totalPages?: number } })?.data ?? [];
|
||||
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { branchesDataService } from '../../configuration/branches/domain/factories';
|
||||
import type { BranchEntity } from '../../configuration/branches/domain/entities';
|
||||
import { createOptionLoader } from './create-option-loader';
|
||||
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
|
||||
|
||||
export const loadBranchOptions = createOptionLoader<BranchEntity>((config) => branchesDataService.getMany(config));
|
||||
export const loadBranchOptions = createOptionLoader<BranchEntity>(
|
||||
(config) => branchesDataService.getMany(config),
|
||||
ACTIVE_LOOKUP_PARAMS,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { customersDataService } from '../../configuration/customers/domain/factories';
|
||||
import type { CustomerEntity } from '../../configuration/customers/domain/entities';
|
||||
import { createOptionLoader } from './create-option-loader';
|
||||
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
|
||||
|
||||
export const loadCustomerOptions = createOptionLoader<CustomerEntity>((config) => customersDataService.getMany(config));
|
||||
export const loadCustomerOptions = createOptionLoader<CustomerEntity>(
|
||||
(config) => customersDataService.getMany(config),
|
||||
ACTIVE_LOOKUP_PARAMS,
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
salesEmployeesDataService,
|
||||
} from '../../configuration/employees/domain/factories';
|
||||
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
|
||||
import { createOptionLoader } from './create-option-loader';
|
||||
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
|
||||
|
||||
function employeeServiceForPurpose(purpose?: FieldPurpose) {
|
||||
if (purpose === 'sales') {
|
||||
@@ -20,7 +20,7 @@ function employeeServiceForPurpose(purpose?: FieldPurpose) {
|
||||
|
||||
export function loadEmployeeOptionsForPurpose(purpose?: FieldPurpose): LoadOptionsFn<EmployeeEntity> {
|
||||
const service = employeeServiceForPurpose(purpose);
|
||||
return createOptionLoader<EmployeeEntity>((config) => service.getMany(config));
|
||||
return createOptionLoader<EmployeeEntity>((config) => service.getMany(config), ACTIVE_LOOKUP_PARAMS);
|
||||
}
|
||||
|
||||
export const loadSalesEmployeeOptions = loadEmployeeOptionsForPurpose('sales');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EmbeddedComingSoonPage } from '../../../../core/components/coming-soon-page';
|
||||
const ReportsModule = lazy(() => import('./reports/presentation/factory'));
|
||||
|
||||
const RequestsModule = lazy(() => import('./requests/presentation/factory'));
|
||||
const OrdersModule = lazy(() => import('./orders/presentation/factory'));
|
||||
@@ -20,7 +20,7 @@ export default function SalesModule() {
|
||||
<Route path="/plans/*" element={<PlansModule purpose="sales" />} />
|
||||
<Route path="/invoices/*" element={<InvoicesModule />} />
|
||||
<Route path="/payments/*" element={<PaymentsModule />} />
|
||||
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
|
||||
<Route path="/reports/*" element={<ReportsModule />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"import_success": "CSV imported.",
|
||||
"status_updated": "Status updated.",
|
||||
"create_sales_payment": "Create Sales Payment",
|
||||
"action_process": "Process",
|
||||
"action_cancel": "Cancel",
|
||||
"status_draft": "Draft",
|
||||
"status_processed": "Processed",
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"import_success": "CSV berhasil diimpor.",
|
||||
"status_updated": "Status diperbarui.",
|
||||
"create_sales_payment": "Buat Pembayaran Penjualan",
|
||||
"action_process": "Proses",
|
||||
"action_cancel": "Batalkan",
|
||||
"status_draft": "Draft",
|
||||
"status_processed": "Diproses",
|
||||
|
||||
+8
-4
@@ -6,6 +6,7 @@ import { DetailGeneral } from '../../../shared/detail-general';
|
||||
import { DetailLocation } from '../../../shared/detail-location';
|
||||
import { DetailProducts } from '../../../shared/detail-products';
|
||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||
import { isPayableInvoice } from '../../../shared/payable-invoice';
|
||||
import { salesOrdersModuleConfig } from '../../../orders/domain/constants';
|
||||
import { salesPaymentsModuleConfig } from '../../../payments/domain/constants';
|
||||
import type { SalesInvoiceEntity } from '../../domain/entities';
|
||||
@@ -27,10 +28,13 @@ export default function SalesInvoicePageDetail() {
|
||||
],
|
||||
}}
|
||||
customPageActions={(data, pageActions) => {
|
||||
const createPayment = actions.createPaymentAction(data as SalesInvoiceEntity, () => {
|
||||
navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`);
|
||||
});
|
||||
const withStatus = actions.detailStatusActions(data as SalesInvoiceEntity, pageActions ?? []);
|
||||
const invoice = data as SalesInvoiceEntity;
|
||||
const createPayment = isPayableInvoice(invoice)
|
||||
? actions.createPaymentAction(invoice, () => {
|
||||
navigate(`${salesPaymentsModuleConfig.webUrl}/create?invoiceId=${data.id}`);
|
||||
})
|
||||
: null;
|
||||
const withStatus = actions.detailStatusActions(invoice, pageActions ?? []);
|
||||
return createPayment ? [createPayment, ...withStatus] : withStatus;
|
||||
}}
|
||||
>
|
||||
|
||||
+7
-1
@@ -7,6 +7,7 @@ import {
|
||||
import { ColDef, Text } from '@repo/ui/components';
|
||||
import { Trans } from '@repo/core-i18n';
|
||||
import { Receipt } from 'lucide-react';
|
||||
import { formatRupiah } from '@repo/utils';
|
||||
import { SalesFilterFormContent } from '../../../shared/filter-content';
|
||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||
import { relationLabel } from '../../../../field/shared/relation-label';
|
||||
@@ -38,7 +39,12 @@ export default function SalesInvoicePageIndex() {
|
||||
minWidth: 160,
|
||||
valueGetter: ({ data }) => relationLabel(data?.branch) || data?.branchId,
|
||||
},
|
||||
{ field: 'balance', headerName: t('common:fields.balance'), minWidth: 140 },
|
||||
{
|
||||
field: 'balance',
|
||||
headerName: t('common:fields.balance'),
|
||||
minWidth: 140,
|
||||
valueFormatter: ({ value }) => formatRupiah(value) || '-',
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
+3
-10
@@ -1,4 +1,3 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Stack } from '@repo/ui/components';
|
||||
import { EnterpriseDetailPageProvider, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { salesOrdersModuleConfig } from '../../domain/constants';
|
||||
@@ -9,12 +8,10 @@ import { DetailImages } from '../../../shared/detail-images';
|
||||
import { DetailRelated } from '../components/detail-component/detail-related';
|
||||
import { useSalesDocumentActions } from '../../../shared/use-sales-document-actions';
|
||||
import { salesRequestsModuleConfig } from '../../../requests/domain/constants';
|
||||
import { salesInvoicesModuleConfig } from '../../../invoices/domain/constants';
|
||||
import type { SalesOrderEntity } from '../../domain/entities';
|
||||
|
||||
export default function SalesOrderPageDetail() {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const navigate = useNavigate();
|
||||
const actions = useSalesDocumentActions('order');
|
||||
|
||||
return (
|
||||
@@ -28,13 +25,9 @@ export default function SalesOrderPageDetail() {
|
||||
{ label: t('nav:sales-orders'), type: 'link', href: `${salesOrdersModuleConfig.webUrl}/index` },
|
||||
],
|
||||
}}
|
||||
customPageActions={(data, pageActions) => {
|
||||
const createInvoice = actions.createInvoiceAction(data as SalesOrderEntity, () => {
|
||||
navigate(`${salesInvoicesModuleConfig.webUrl}/create?salesOrderId=${data.id}`);
|
||||
});
|
||||
const withStatus = actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? []);
|
||||
return createInvoice ? [createInvoice, ...withStatus] : withStatus;
|
||||
}}
|
||||
customPageActions={(data, pageActions) =>
|
||||
actions.detailStatusActions(data as SalesOrderEntity, pageActions ?? [])
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<DetailGeneral salesRequestHref={(id) => `${salesRequestsModuleConfig.webUrl}/detail/${id}`} />
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { formatDateValue, parseDateValue } from '@repo/ui/form';
|
||||
import { emptyToNull, omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
||||
import { toDecimalStringValue } from '../../../../../../../core/domain/decimal-string.schema';
|
||||
import { mapSalesImagesFromDto, relationId, toLookup } from '../../../shared/sales-document.mapper';
|
||||
import type { SalesPaymentDto, SalesPaymentEntity } from '../entities';
|
||||
|
||||
@@ -51,7 +52,7 @@ export class SalesPaymentsRemoteDataTransformer extends BaseDataTransformer<Sale
|
||||
.map((row) => {
|
||||
const invoiceId = relationId(row.invoice) ?? row.invoiceId;
|
||||
if (!invoiceId) return null;
|
||||
return omitEmptyFields({ invoiceId, amount: row.amount });
|
||||
return omitEmptyFields({ invoiceId, amount: toDecimalStringValue(row.amount) });
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
import { compose, maxLength, required } from '@repo/ui/validators';
|
||||
import { decimalStringSchema } from '../../../../../../../core/domain/decimal-string.schema';
|
||||
import { decimalStringSchema, PRICE_DECIMAL_SCALE } from '../../../../../../../core/domain/decimal-string.schema';
|
||||
import { salesImageSchema } from '../../../shared/sales-document.validator';
|
||||
|
||||
const NOTES_MAX = 1024;
|
||||
@@ -40,7 +40,7 @@ export function createSalesPaymentSchema(t: (key: string) => string) {
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
invoice: relationSchema,
|
||||
amount: decimalStringSchema(t, 'common:fields.amount'),
|
||||
amount: decimalStringSchema(t, 'common:fields.amount', PRICE_DECIMAL_SCALE),
|
||||
}),
|
||||
)
|
||||
.min(1),
|
||||
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { formatRupiah } from '@repo/utils';
|
||||
import type { SalesPaymentEntity } from '../../../domain/entities';
|
||||
import { relationLabel } from '../../../../../field/shared/relation-label';
|
||||
|
||||
@@ -34,7 +35,7 @@ export function DetailAllocations() {
|
||||
rows.map((row, index) => (
|
||||
<Table.Tr key={row.id ?? `${row.invoiceId}-${index}`}>
|
||||
<Table.Td>{relationLabel(row.invoice) || row.invoiceId}</Table.Td>
|
||||
<Table.Td ta="right">{row.amount}</Table.Td>
|
||||
<Table.Td ta="right">{formatRupiah(row.amount)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))
|
||||
)}
|
||||
|
||||
+8
-6
@@ -3,7 +3,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
FieldAsyncSelect,
|
||||
FieldTextInput,
|
||||
FieldCurrencyInput,
|
||||
Group,
|
||||
Paper,
|
||||
Table,
|
||||
@@ -12,9 +12,10 @@ import {
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
import { useFieldArray, useWatch } from '@repo/ui/form';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { loadSalesInvoiceOptions } from '../../../../../field/shared/lookup.factories';
|
||||
import { loadPayableSalesInvoiceOptions } from '../../../../shared/load-payable-sales-invoice-options';
|
||||
import { isPayableInvoice } from '../../../../shared/payable-invoice';
|
||||
import { relationLabel } from '../../../../../field/shared/relation-label';
|
||||
import type { LookupEntity } from '../../../../../field/shared/lookup.entity';
|
||||
import type { SalesInvoiceEntity } from '../../../../invoices/domain/entities';
|
||||
|
||||
export function FormAllocations() {
|
||||
const { formControl } = useFormPageContext();
|
||||
@@ -45,19 +46,20 @@ export function FormAllocations() {
|
||||
return (
|
||||
<Table.Tr key={field.id}>
|
||||
<Table.Td miw={240}>
|
||||
<FieldAsyncSelect<LookupEntity>
|
||||
<FieldAsyncSelect<SalesInvoiceEntity>
|
||||
control={formControl.control}
|
||||
name={`invoices.${index}.invoice`}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
searchable
|
||||
loadOptions={loadSalesInvoiceOptions}
|
||||
loadOptions={loadPayableSalesInvoiceOptions}
|
||||
filterOption={(item) => isPayableInvoice(item)}
|
||||
defaultOptions={line?.invoice ? [line.invoice] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td miw={140}>
|
||||
<FieldTextInput control={formControl.control} name={`invoices.${index}.amount`} radius="md" />
|
||||
<FieldCurrencyInput control={formControl.control} name={`invoices.${index}.amount`} radius="md" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
|
||||
+2
-1
@@ -11,6 +11,7 @@ import { FormAllocations } from '../components/form-component/form-allocations';
|
||||
import { FormImages } from '../../../shared/form-images';
|
||||
import { FormNotes } from '../../../shared/form-notes';
|
||||
import { salesInvoicesDataService } from '../../../invoices/domain/factories';
|
||||
import { isPayableInvoice } from '../../../shared/payable-invoice';
|
||||
import type { SalesInvoiceEntity } from '../../../invoices/domain/entities';
|
||||
|
||||
export default function SalesPaymentPageForm({ formPageType }: { formPageType: FormPageType }) {
|
||||
@@ -46,7 +47,7 @@ export default function SalesPaymentPageForm({ formPageType }: { formPageType: F
|
||||
prefilled.current = true;
|
||||
void salesInvoicesDataService.getOne(invoiceId).then((result) => {
|
||||
const entity = (result.data as { data?: SalesInvoiceEntity })?.data;
|
||||
if (!entity) return;
|
||||
if (!entity || !isPayableInvoice(entity)) return;
|
||||
formControl.reset({
|
||||
invoices: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
import type { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
|
||||
export type ReportShellEntity = BaseEntity & { id: string };
|
||||
|
||||
export const salesReportsModuleConfig: ModuleConfigEntity<ReportShellEntity> = {
|
||||
moduleKey: 'SALES.REPORT',
|
||||
translationNamespace: 'SALES_REPORTS',
|
||||
apiUrl: '/reports',
|
||||
webUrl: '/app/sales/reports',
|
||||
moduleCategory: 'SINGLE_PAGE',
|
||||
moduleType: 'TRANSACTION',
|
||||
} as const;
|
||||
@@ -0,0 +1,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 { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { relationLabel } from '../../field/shared/relation-label';
|
||||
import type { SalesLineEntity } from './sales-document.entity';
|
||||
import { toDecimalStringValue } from '../../../../../core/domain/decimal-string.schema';
|
||||
|
||||
export function CompletePackingModal({
|
||||
opened,
|
||||
@@ -17,7 +18,7 @@ export function CompletePackingModal({
|
||||
onSubmit: (products: Array<{ productId: string; quantity: string }>) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const form = useForm<{ products: Array<{ productId: string; quantity: string; label: string }> }>({
|
||||
const form = useForm<{ products: Array<{ productId: string; quantity: string | number; label: string }> }>({
|
||||
defaultValues: { products: [] },
|
||||
});
|
||||
const { fields } = useFieldArray({ control: form.control, name: 'products' });
|
||||
@@ -37,7 +38,7 @@ export function CompletePackingModal({
|
||||
await onSubmit(
|
||||
values.products.map((line) => ({
|
||||
productId: line.productId,
|
||||
quantity: line.quantity,
|
||||
quantity: toDecimalStringValue(line.quantity) ?? '',
|
||||
})),
|
||||
);
|
||||
onClose();
|
||||
@@ -49,10 +50,11 @@ export function CompletePackingModal({
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{t('complete_packing_help')}</Text>
|
||||
{fields.map((field, index) => (
|
||||
<FieldTextInput
|
||||
<FieldCurrencyInput
|
||||
key={field.id}
|
||||
control={form.control as any}
|
||||
name={`products.${index}.quantity`}
|
||||
prefix=""
|
||||
label={`${t('delivered_quantity')} — ${form.getValues(`products.${index}.label`) || field.label}`}
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { Anchor, Box, Paper, SimpleGrid, FieldValue, RenderDate, Text, StatusBadge } from '@repo/ui/components';
|
||||
import {
|
||||
Anchor,
|
||||
Box,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
FieldValue,
|
||||
RenderCurrency,
|
||||
RenderDate,
|
||||
Text,
|
||||
StatusBadge,
|
||||
} from '@repo/ui/components';
|
||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import type { SalesDocumentEntity } from './sales-document.entity';
|
||||
import { relationLabel } from '../../field/shared/relation-label';
|
||||
@@ -83,7 +93,13 @@ export function DetailGeneral({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{showBalance ? <FieldValue label={t('common:fields.balance')} value={data?.balance} /> : null}
|
||||
{showBalance ? (
|
||||
<FieldValue
|
||||
label={t('common:fields.balance')}
|
||||
value={data?.balance}
|
||||
render={(val) => <RenderCurrency value={val as string | number | null} />}
|
||||
/>
|
||||
) : null}
|
||||
{salesRequestHref && (
|
||||
<FieldValue
|
||||
label={t('common:fields.salesRequest')}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Box, Paper, Table, Text } from '@repo/ui/components';
|
||||
import { useDetailPageContext, useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { CurrencyUtils } from '@repo/utils';
|
||||
import { formatDecimal, formatRupiah } from '@repo/utils';
|
||||
import type { SalesDocumentEntity } from './sales-document.entity';
|
||||
import { relationLabel } from '../../field/shared/relation-label';
|
||||
|
||||
const currency = new CurrencyUtils({ decimalScale: 4 });
|
||||
|
||||
export function DetailProducts() {
|
||||
const { detailData } = useDetailPageContext<SalesDocumentEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
@@ -49,9 +47,9 @@ export function DetailProducts() {
|
||||
return (
|
||||
<Table.Tr key={line.id ?? `${line.productId}-${index}`}>
|
||||
<Table.Td>{relationLabel(line.product) || line.productId}</Table.Td>
|
||||
<Table.Td ta="right">{line.quantity}</Table.Td>
|
||||
<Table.Td ta="right">{line.price ? currency.format(line.price) : '-'}</Table.Td>
|
||||
<Table.Td ta="right">{currency.format(total)}</Table.Td>
|
||||
<Table.Td ta="right">{formatDecimal(line.quantity) || '-'}</Table.Td>
|
||||
<Table.Td ta="right">{line.price ? formatRupiah(line.price) : '-'}</Table.Td>
|
||||
<Table.Td ta="right">{formatRupiah(total)}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})
|
||||
@@ -60,7 +58,7 @@ export function DetailProducts() {
|
||||
</Table>
|
||||
</Box>
|
||||
<Text fw={600} ta="right" mt="md">
|
||||
{t('common:fields.total')}: {currency.format(grandTotal)}
|
||||
{t('common:fields.total')}: {formatRupiah(grandTotal)}
|
||||
</Text>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { loadBranchOptions } from '../../field/shared/load-branch-options';
|
||||
import { loadCustomerOptions } from '../../field/shared/load-customer-options';
|
||||
import { loadDivisionOptions } from '../../configuration/shared/load-division-options';
|
||||
import { relationLabel } from '../../field/shared/relation-label';
|
||||
import { customerLocationFromSelection } from './sales-document.mapper';
|
||||
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
|
||||
import type { BranchEntity } from '../../configuration/branches/domain/entities';
|
||||
import type { DivisionEntity } from '../../configuration/divisions/domain/entities';
|
||||
@@ -86,6 +87,13 @@ export function FormGeneral() {
|
||||
loadOptions={loadCustomerOptions}
|
||||
defaultOptions={customer ? [customer] : []}
|
||||
renderLabel={relationLabel}
|
||||
onSelect={(value) => {
|
||||
const location = customerLocationFromSelection(Array.isArray(value) ? value[0] : value);
|
||||
if (!location) return;
|
||||
formControl.setValue('address', location.address, { shouldDirty: true, shouldValidate: true });
|
||||
formControl.setValue('latitude', location.latitude, { shouldDirty: true, shouldValidate: true });
|
||||
formControl.setValue('longitude', location.longitude, { shouldDirty: true, shouldValidate: true });
|
||||
}}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
FieldAsyncSelect,
|
||||
FieldTextInput,
|
||||
FieldCurrencyInput,
|
||||
Group,
|
||||
Paper,
|
||||
Table,
|
||||
@@ -12,14 +12,13 @@ import {
|
||||
import { useEnterpriseModuleTranslationContext, useFormPageContext } from '@repo/ui/foundations';
|
||||
import { useFieldArray, useWatch } from '@repo/ui/form';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { CurrencyUtils } from '@repo/utils';
|
||||
import { formatRupiah } from '@repo/utils';
|
||||
import { loadProductOptions } from './load-product-options';
|
||||
import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options';
|
||||
import { relationLabel } from '../../field/shared/relation-label';
|
||||
import type { ProductEntity } from '../../configuration/products/domain/entities';
|
||||
|
||||
const currency = new CurrencyUtils({ decimalScale: 4 });
|
||||
|
||||
function lineTotal(quantity?: string, price?: string) {
|
||||
function lineTotal(quantity?: string | number, price?: string | number) {
|
||||
const qty = Number(quantity);
|
||||
const unitPrice = Number(price);
|
||||
if (!Number.isFinite(qty) || !Number.isFinite(unitPrice)) return 0;
|
||||
@@ -35,7 +34,8 @@ export function FormProducts() {
|
||||
});
|
||||
const products = useWatch({ control: formControl.control, name: 'products' }) ?? [];
|
||||
const grandTotal = products.reduce(
|
||||
(sum: number, line: { quantity?: string; price?: string }) => sum + lineTotal(line?.quantity, line.price),
|
||||
(sum: number, line: { quantity?: string | number; price?: string | number }) =>
|
||||
sum + lineTotal(line?.quantity, line.price),
|
||||
0,
|
||||
);
|
||||
|
||||
@@ -58,6 +58,7 @@ export function FormProducts() {
|
||||
<Table.Tbody>
|
||||
{fields.map((field, index) => {
|
||||
const line = products[index];
|
||||
const excludedIds = new Set(selectedProductIdsExceptLine(products, index));
|
||||
return (
|
||||
<Table.Tr key={field.id}>
|
||||
<Table.Td miw={240}>
|
||||
@@ -67,18 +68,27 @@ export function FormProducts() {
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
searchable
|
||||
loadOptions={loadProductOptions}
|
||||
loadOptions={async (search, page, prevOptions) => {
|
||||
const result = await loadProductOptions(search, page, prevOptions);
|
||||
return { ...result, options: excludeProductIds(result.options, excludedIds) };
|
||||
}}
|
||||
filterOption={(item) => item.id == null || !excludedIds.has(String(item.id))}
|
||||
defaultOptions={line?.product ? [line.product] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td miw={120}>
|
||||
<FieldTextInput control={formControl.control} name={`products.${index}.quantity`} radius="md" />
|
||||
<FieldCurrencyInput
|
||||
control={formControl.control}
|
||||
name={`products.${index}.quantity`}
|
||||
prefix=""
|
||||
radius="md"
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td miw={140}>
|
||||
<FieldTextInput control={formControl.control} name={`products.${index}.price`} radius="md" />
|
||||
<FieldCurrencyInput control={formControl.control} name={`products.${index}.price`} radius="md" />
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">{currency.format(lineTotal(line?.quantity, line?.price))}</Table.Td>
|
||||
<Table.Td ta="right">{formatRupiah(lineTotal(line?.quantity, line?.price))}</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
@@ -105,7 +115,7 @@ export function FormProducts() {
|
||||
{t('add_line')}
|
||||
</Button>
|
||||
<Text fw={600}>
|
||||
{t('common:fields.total')}: {currency.format(grandTotal)}
|
||||
{t('common:fields.total')}: {formatRupiah(grandTotal)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createOptionLoader } from '../../field/shared/create-option-loader';
|
||||
import { salesInvoicesDataService } from '../invoices/domain/factories';
|
||||
import type { SalesInvoiceEntity } from '../invoices/domain/entities';
|
||||
|
||||
export const loadPayableSalesInvoiceOptions = createOptionLoader<SalesInvoiceEntity>(
|
||||
(config) => salesInvoicesDataService.getMany(config),
|
||||
{ payable: true },
|
||||
);
|
||||
@@ -1,5 +1,8 @@
|
||||
import { productsDataService } from '../../configuration/products/domain/factories';
|
||||
import type { ProductEntity } from '../../configuration/products/domain/entities';
|
||||
import { createOptionLoader } from '../../field/shared/create-option-loader';
|
||||
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from '../../field/shared/create-option-loader';
|
||||
|
||||
export const loadProductOptions = createOptionLoader<ProductEntity>((config) => productsDataService.getMany(config));
|
||||
export const loadProductOptions = createOptionLoader<ProductEntity>(
|
||||
(config) => productsDataService.getMany(config),
|
||||
ACTIVE_LOOKUP_PARAMS,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isPayableInvoice } from './payable-invoice';
|
||||
|
||||
describe('isPayableInvoice', () => {
|
||||
it('accepts processed and partial invoices with remaining balance', () => {
|
||||
expect(isPayableInvoice({ status: 'processed', balance: '25000.0000' })).toBe(true);
|
||||
expect(isPayableInvoice({ status: 'partial', balance: '1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects draft invoices and invoices with no remaining balance', () => {
|
||||
expect(isPayableInvoice({ status: 'draft', balance: '25000.0000' })).toBe(false);
|
||||
expect(isPayableInvoice({ status: 'processed', balance: '0.0000' })).toBe(false);
|
||||
expect(isPayableInvoice({ status: 'completed', balance: '0' })).toBe(false);
|
||||
expect(isPayableInvoice({ status: 'cancelled', balance: '10000' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export const PAYABLE_INVOICE_STATUSES = ['processed', 'partial'] as const;
|
||||
|
||||
export function isPayableInvoice(invoice: { status?: string | null; balance?: string | number | null }): boolean {
|
||||
const payable: readonly string[] = PAYABLE_INVOICE_STATUSES;
|
||||
if (!invoice.status || !payable.includes(invoice.status)) {
|
||||
return false;
|
||||
}
|
||||
const balance = Number(invoice.balance);
|
||||
return Number.isFinite(balance) && balance > 0;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
customerLocationFromSelection,
|
||||
mapSalesDocumentFromDto,
|
||||
salesRequestToFormValues,
|
||||
toSalesFilterPayload,
|
||||
@@ -111,6 +112,29 @@ describe('sales document mapper', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('copies customer address and coordinates when a customer is picked', () => {
|
||||
expect(
|
||||
customerLocationFromSelection({
|
||||
address: 'Jl Gatot Subroto No 8',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
}),
|
||||
).toEqual({
|
||||
address: 'Jl Gatot Subroto No 8',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults missing customer location fields instead of leaving the previous customer', () => {
|
||||
expect(customerLocationFromSelection({ address: 'Depot A' })).toEqual({
|
||||
address: 'Depot A',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
});
|
||||
expect(customerLocationFromSelection(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('copies nested lookups onto form values for edit', () => {
|
||||
const formValues = salesRequestToFormValues(mapSalesDocumentFromDto(nestedResponse));
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { formatDateValue, parseDateValue } from '@repo/ui/form';
|
||||
import { emptyToNull, omitEmptyFields } from '../../../../../core/domain/configuration-field-validators';
|
||||
import { toDecimalStringValue } from '../../../../../core/domain/decimal-string.schema';
|
||||
import type {
|
||||
LookupStub,
|
||||
SalesDocumentDto,
|
||||
@@ -8,6 +9,24 @@ import type {
|
||||
SalesLineEntity,
|
||||
} from './sales-document.entity';
|
||||
|
||||
export function customerLocationFromSelection(
|
||||
customer:
|
||||
| {
|
||||
address?: string | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
if (!customer) return null;
|
||||
return {
|
||||
address: customer.address ?? '',
|
||||
latitude: customer.latitude ?? null,
|
||||
longitude: customer.longitude ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function relationId(value: unknown): string | undefined {
|
||||
if (value && typeof value === 'object' && 'id' in value) {
|
||||
const id = (value as { id?: unknown }).id;
|
||||
@@ -108,8 +127,8 @@ export function toSalesWritePayload(
|
||||
if (!productId) return null;
|
||||
return omitEmptyFields({
|
||||
productId,
|
||||
quantity: line.quantity,
|
||||
price: line.price,
|
||||
quantity: toDecimalStringValue(line.quantity),
|
||||
price: toDecimalStringValue(line.price),
|
||||
});
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
optionalLatitudeSchema,
|
||||
optionalLongitudeSchema,
|
||||
} from '../../../../../core/domain/configuration-field-validators';
|
||||
import { decimalStringSchema } from '../../../../../core/domain/decimal-string.schema';
|
||||
import {
|
||||
decimalStringSchema,
|
||||
optionalDecimalStringSchema,
|
||||
PRICE_DECIMAL_SCALE,
|
||||
} from '../../../../../core/domain/decimal-string.schema';
|
||||
|
||||
const NOTES_MAX = 1024;
|
||||
const IMAGE_URL_MAX = 2048;
|
||||
@@ -30,8 +34,8 @@ export function salesLineSchema(t: (key: string) => string) {
|
||||
return z.object({
|
||||
id: z.string().optional(),
|
||||
product: relationSchema,
|
||||
quantity: decimalStringSchema(t, 'common:fields.quantity'),
|
||||
price: z.preprocess(emptyToUndefined, decimalStringSchema(t, 'common:fields.price').optional()),
|
||||
quantity: decimalStringSchema(t, 'common:fields.quantity', PRICE_DECIMAL_SCALE),
|
||||
price: optionalDecimalStringSchema(t, 'common:fields.price'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { excludeProductIds, selectedProductIdsExceptLine } from './sales-line-options';
|
||||
|
||||
describe('sales line product options', () => {
|
||||
const lines = [{ product: { id: 'prd-1' } }, { product: { id: 'prd-2' } }, { product: null }];
|
||||
|
||||
it('collects product ids from other lines only', () => {
|
||||
expect(selectedProductIdsExceptLine(lines, 0)).toEqual(['prd-2']);
|
||||
expect(selectedProductIdsExceptLine(lines, 2)).toEqual(['prd-1', 'prd-2']);
|
||||
});
|
||||
|
||||
it('hides products already used on other lines', () => {
|
||||
const options = [{ id: 'prd-1' }, { id: 'prd-2' }, { id: 'prd-3' }];
|
||||
expect(excludeProductIds(options, new Set(selectedProductIdsExceptLine(lines, 2)))).toEqual([{ id: 'prd-3' }]);
|
||||
});
|
||||
|
||||
it('keeps the current line product available', () => {
|
||||
const options = [{ id: 'prd-1' }, { id: 'prd-3' }];
|
||||
expect(excludeProductIds(options, new Set(selectedProductIdsExceptLine(lines, 0)))).toEqual([
|
||||
{ id: 'prd-1' },
|
||||
{ id: 'prd-3' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export function selectedProductIdsExceptLine(
|
||||
lines: Array<{ product?: { id?: string | number } | null } | null | undefined>,
|
||||
lineIndex: number,
|
||||
): string[] {
|
||||
return lines.flatMap((line, index) => {
|
||||
const id = line?.product?.id;
|
||||
if (index === lineIndex || id == null || id === '') return [];
|
||||
return [String(id)];
|
||||
});
|
||||
}
|
||||
|
||||
export function excludeProductIds<T extends { id?: string | number }>(
|
||||
options: T[],
|
||||
excludeIds: ReadonlySet<string>,
|
||||
): T[] {
|
||||
if (excludeIds.size === 0) return options;
|
||||
return options.filter((option) => option.id == null || !excludeIds.has(String(option.id)));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { allowedTransitions, commonTransitions, namedActionsFor } from './sales-status';
|
||||
import { ModuleAction } from '@repo/ui/foundations';
|
||||
import { allowedTransitions, commonTransitions, keepSalesChromeActions, namedActionsFor } from './sales-status';
|
||||
|
||||
describe('sales status transitions', () => {
|
||||
it('allows request draft to pending or rejected', () => {
|
||||
@@ -26,10 +27,14 @@ describe('sales status transitions', () => {
|
||||
expect(namedActionsFor('order', 'processed').map((action) => action.key)).toEqual(['cancel']);
|
||||
});
|
||||
|
||||
it('limits invoice user transitions to cancelled', () => {
|
||||
it('allows invoice draft to processed or cancelled', () => {
|
||||
expect(allowedTransitions('invoice', 'draft')).toEqual(['processed', 'cancelled']);
|
||||
expect(allowedTransitions('invoice', 'processed')).toEqual(['cancelled']);
|
||||
expect(allowedTransitions('invoice', 'partial')).toEqual(['cancelled']);
|
||||
expect(allowedTransitions('invoice', 'completed')).toEqual(['cancelled']);
|
||||
expect(namedActionsFor('invoice', 'draft').map((action) => action.key)).toEqual(['process', 'cancel']);
|
||||
expect(namedActionsFor('invoice', 'processed').map((action) => action.key)).toEqual(['cancel']);
|
||||
expect(namedActionsFor('invoice', 'completed').map((action) => action.key)).toEqual(['cancel']);
|
||||
});
|
||||
|
||||
it('adds payment rollback from pending to draft', () => {
|
||||
@@ -41,8 +46,33 @@ describe('sales status transitions', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows packing draft to processed or cancelled', () => {
|
||||
expect(allowedTransitions('packing', 'draft')).toEqual(['processed', 'cancelled']);
|
||||
expect(namedActionsFor('packing', 'draft').map((action) => action.key)).toEqual(['process', 'cancel']);
|
||||
});
|
||||
|
||||
it('allows packing processed to completed or cancelled', () => {
|
||||
expect(allowedTransitions('packing', 'processed')).toEqual(['completed', 'cancelled']);
|
||||
expect(namedActionsFor('packing', 'processed').map((action) => action.key)).toEqual(['complete', 'cancel']);
|
||||
});
|
||||
|
||||
it('strips generic transaction status chrome from default actions', () => {
|
||||
const defaultActions = [
|
||||
{ key: 'VIEW', label: 'View' },
|
||||
{ key: ModuleAction.EDIT, label: 'Edit' },
|
||||
{ key: ModuleAction.HOLD, label: 'Hold' },
|
||||
{ key: ModuleAction.ROLLBACK, label: 'Rollback' },
|
||||
{ key: ModuleAction.CANCEL, label: 'Cancel' },
|
||||
{ key: ModuleAction.CONFIRM, label: 'Confirm' },
|
||||
{ key: 'change-status', label: 'Change status' },
|
||||
{ key: 'bulk-change-status', label: 'Change status' },
|
||||
{ key: ModuleAction.DELETE, label: 'Delete' },
|
||||
];
|
||||
|
||||
expect(keepSalesChromeActions(defaultActions).map((action) => action.key)).toEqual([
|
||||
'VIEW',
|
||||
ModuleAction.EDIT,
|
||||
ModuleAction.DELETE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ const PACKING_TRANSITIONS: Record<PackingSlipStatus, PackingSlipStatus[]> = {
|
||||
};
|
||||
|
||||
const INVOICE_TRANSITIONS: Record<SalesInvoiceStatus, SalesInvoiceStatus[]> = {
|
||||
draft: ['cancelled'],
|
||||
draft: ['processed', 'cancelled'],
|
||||
processed: ['cancelled'],
|
||||
partial: ['cancelled'],
|
||||
completed: ['cancelled'],
|
||||
@@ -56,12 +56,18 @@ export const SALES_ORDER_NAMED_ACTIONS = [
|
||||
] as const;
|
||||
|
||||
export const PACKING_SLIP_NAMED_ACTIONS = [
|
||||
{ key: 'process', target: 'processed' as const, from: ['draft'] as const },
|
||||
{ key: 'complete', target: 'completed' as const, from: ['processed'] as const },
|
||||
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed'] as const },
|
||||
] as const;
|
||||
|
||||
export const SALES_INVOICE_NAMED_ACTIONS = [
|
||||
{ key: 'cancel', target: 'cancelled' as const, from: ['draft', 'processed', 'partial'] as const },
|
||||
{ key: 'process', target: 'processed' as const, from: ['draft'] as const },
|
||||
{
|
||||
key: 'cancel',
|
||||
target: 'cancelled' as const,
|
||||
from: ['draft', 'processed', 'partial', 'completed'] as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const SALES_PAYMENT_NAMED_ACTIONS = [
|
||||
@@ -119,3 +125,16 @@ export function documentStatusFilterOptions(t: (key: string) => string, type: Sa
|
||||
label: t(`status_${value}`),
|
||||
}));
|
||||
}
|
||||
|
||||
export const SALES_GENERIC_STATUS_ACTION_KEYS = new Set([
|
||||
'HOLD',
|
||||
'CONFIRM',
|
||||
'CANCEL',
|
||||
'ROLLBACK',
|
||||
'change-status',
|
||||
'bulk-change-status',
|
||||
]);
|
||||
|
||||
export function keepSalesChromeActions<T extends { key?: string }>(actions: T[]): T[] {
|
||||
return actions.filter((action) => !action.key || !SALES_GENERIC_STATUS_ACTION_KEYS.has(action.key));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, FileUp, ShoppingCart, XCircle, Play, Ban, Receipt, CreditCard, Undo2 } from 'lucide-react';
|
||||
import { Check, FileUp, ShoppingCart, XCircle, Play, Ban, CreditCard, Undo2 } from 'lucide-react';
|
||||
import { notifications } from '@repo/ui/components';
|
||||
import {
|
||||
useDetailPageContext,
|
||||
@@ -7,12 +7,11 @@ import {
|
||||
useEnterpriseModuleDataServiceContext,
|
||||
useEnterpriseModuleTranslationContext,
|
||||
} from '@repo/ui/foundations';
|
||||
import { namedActionsFor, type SalesDocumentType } from './sales-status';
|
||||
import { keepSalesChromeActions, namedActionsFor, type SalesDocumentType } from './sales-status';
|
||||
import type { SalesDocumentRemoteDataServices } from './sales-document.remote.service';
|
||||
import type { SalesDocumentEntity, SalesLineEntity } from './sales-document.entity';
|
||||
|
||||
type SalesActionEntity = { id?: string | number; status?: string; products?: SalesLineEntity[] };
|
||||
import { ChangeStatusModal } from './change-status-modal';
|
||||
import { ImportCsvModal } from './import-csv-modal';
|
||||
import { ProcessOrderModal } from './process-order-modal';
|
||||
import { CompletePackingModal } from './complete-packing-modal';
|
||||
@@ -34,12 +33,10 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
SalesDocumentEntity,
|
||||
SalesDocumentRemoteDataServices<SalesDocumentEntity>
|
||||
>();
|
||||
const [statusOpened, setStatusOpened] = useState(false);
|
||||
const [importOpened, setImportOpened] = useState(false);
|
||||
const [processOpened, setProcessOpened] = useState(false);
|
||||
const [completeOpened, setCompleteOpened] = useState(false);
|
||||
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
||||
const [pendingStatus, setPendingStatus] = useState<string | undefined>();
|
||||
const [completeProducts, setCompleteProducts] = useState<SalesLineEntity[]>([]);
|
||||
const canEdit = privileges.ALLOW_EDIT;
|
||||
const canImport = privileges.ALLOW_IMPORT;
|
||||
@@ -72,7 +69,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
};
|
||||
|
||||
const namedRowActions = (data: SalesActionEntity, defaultActions: any[]) => {
|
||||
if (!canEdit) return defaultActions;
|
||||
const chromeActions = keepSalesChromeActions(defaultActions);
|
||||
if (!canEdit) return chromeActions;
|
||||
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
|
||||
const Icon = ACTION_ICONS[action.key] ?? Check;
|
||||
return {
|
||||
@@ -84,23 +82,12 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
},
|
||||
};
|
||||
});
|
||||
if (documentType !== 'invoice') {
|
||||
extras.push({
|
||||
key: 'change-status',
|
||||
label: t('change_status'),
|
||||
icon: <Play size={15} />,
|
||||
onClick: () => {
|
||||
setPendingIds([String(data.id)]);
|
||||
setPendingStatus(data.status);
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...extras, ...defaultActions];
|
||||
return [...extras, ...chromeActions];
|
||||
};
|
||||
|
||||
const namedBulkActions = (selectedRows: SalesActionEntity[], defaultActions: any[]) => {
|
||||
if (!canEdit || selectedRows.length === 0) return defaultActions;
|
||||
const chromeActions = keepSalesChromeActions(defaultActions);
|
||||
if (!canEdit || selectedRows.length === 0) return chromeActions;
|
||||
const statuses = selectedRows.map((row) => row.status);
|
||||
const extras: any[] = namedActionsFor(documentType, statuses[0])
|
||||
.filter((action) => statuses.every((status) => (action.from as readonly string[]).includes(status ?? '')))
|
||||
@@ -121,20 +108,7 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
},
|
||||
};
|
||||
});
|
||||
if (documentType !== 'invoice') {
|
||||
extras.push({
|
||||
key: 'bulk-change-status',
|
||||
label: t('change_status'),
|
||||
icon: <Play size={16} />,
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
setPendingIds(selectedRows.map((row) => String(row.id)));
|
||||
setPendingStatus(selectedRows[0]?.status);
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...extras, ...defaultActions];
|
||||
return [...extras, ...chromeActions];
|
||||
};
|
||||
|
||||
const importPageAction = canImport
|
||||
@@ -161,18 +135,6 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
}
|
||||
: null;
|
||||
|
||||
const createInvoiceAction = (_data: SalesActionEntity, onClick: () => void) =>
|
||||
canEdit
|
||||
? {
|
||||
key: 'create-invoice',
|
||||
label: t('create_sales_invoice'),
|
||||
icon: <Receipt size={16} />,
|
||||
intent: 'primary' as const,
|
||||
variant: 'light' as const,
|
||||
onClick,
|
||||
}
|
||||
: null;
|
||||
|
||||
const createPaymentAction = (_data: SalesActionEntity, onClick: () => void) =>
|
||||
canEdit
|
||||
? {
|
||||
@@ -186,7 +148,8 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
: null;
|
||||
|
||||
const detailStatusActions = (data: SalesActionEntity, defaultActions: any[]) => {
|
||||
if (!canEdit) return defaultActions;
|
||||
const chromeActions = keepSalesChromeActions(defaultActions);
|
||||
if (!canEdit) return chromeActions;
|
||||
const extras: any[] = namedActionsFor(documentType, data.status).map((action) => {
|
||||
const Icon = ACTION_ICONS[action.key] ?? Check;
|
||||
return {
|
||||
@@ -200,34 +163,11 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
},
|
||||
};
|
||||
});
|
||||
if (documentType !== 'invoice') {
|
||||
extras.push({
|
||||
key: 'change-status',
|
||||
label: t('change_status'),
|
||||
icon: <Play size={16} />,
|
||||
intent: 'primary' as const,
|
||||
variant: 'light' as const,
|
||||
onClick: () => {
|
||||
setPendingIds([String(data.id)]);
|
||||
setPendingStatus(data.status);
|
||||
setStatusOpened(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
return [...extras, ...defaultActions];
|
||||
return [...extras, ...chromeActions];
|
||||
};
|
||||
|
||||
const modals = (
|
||||
<>
|
||||
<ChangeStatusModal
|
||||
opened={statusOpened}
|
||||
onClose={() => setStatusOpened(false)}
|
||||
documentType={documentType}
|
||||
currentStatus={pendingStatus}
|
||||
onSubmit={async (status) => {
|
||||
await applyStatus(pendingIds, status);
|
||||
}}
|
||||
/>
|
||||
<ProcessOrderModal
|
||||
opened={processOpened}
|
||||
onClose={() => setProcessOpened(false)}
|
||||
@@ -260,7 +200,6 @@ export function useSalesDocumentActions(documentType: SalesDocumentType) {
|
||||
namedBulkActions,
|
||||
importPageAction,
|
||||
createOrderAction,
|
||||
createInvoiceAction,
|
||||
createPaymentAction,
|
||||
detailStatusActions,
|
||||
applyStatus,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decimalStringSchema, optionalDecimalStringSchema, toDecimalStringValue } from './decimal-string.schema';
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
describe('decimalStringSchema', () => {
|
||||
const schema = decimalStringSchema(t, 'common:fields.quantity');
|
||||
|
||||
it('accepts a decimal string within four places', () => {
|
||||
expect(schema.safeParse('2.0000').success).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces a number into a decimal string', () => {
|
||||
const result = schema.safeParse(12.5);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data).toBe('12.5');
|
||||
});
|
||||
|
||||
it('rejects more than four decimal places', () => {
|
||||
expect(schema.safeParse('12.34567').success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('optionalDecimalStringSchema', () => {
|
||||
const schema = optionalDecimalStringSchema(t, 'common:fields.price');
|
||||
|
||||
it('accepts five decimal places and numbers', () => {
|
||||
expect(schema.safeParse('12.34567').success).toBe(true);
|
||||
expect(schema.safeParse(12500.12345).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts empty values', () => {
|
||||
expect(schema.safeParse('').success).toBe(true);
|
||||
expect(schema.safeParse(undefined).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects six decimal places', () => {
|
||||
expect(schema.safeParse('12.345678').success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDecimalStringValue', () => {
|
||||
it('keeps five decimal places when stringifying a number', () => {
|
||||
expect(toDecimalStringValue(12500.12345)).toBe('12500.12345');
|
||||
expect(toDecimalStringValue('12500.12345')).toBe('12500.12345');
|
||||
expect(toDecimalStringValue('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,53 @@
|
||||
import { z } from 'zod';
|
||||
import { CURRENCY_DATA_SCALE } from '@repo/utils';
|
||||
|
||||
const DECIMAL_PATTERN = /^\d+(\.\d{1,4})?$/;
|
||||
export const PRICE_DECIMAL_SCALE = CURRENCY_DATA_SCALE;
|
||||
export const QUANTITY_DECIMAL_SCALE = 4;
|
||||
|
||||
function emptyToUndefined(value: unknown) {
|
||||
function coerceDecimalInput(value: unknown) {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? String(value) : value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function decimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') {
|
||||
return z.string().regex(DECIMAL_PATTERN, {
|
||||
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }),
|
||||
});
|
||||
export function toDecimalStringValue(value: unknown): string | undefined {
|
||||
const next = coerceDecimalInput(value);
|
||||
return typeof next === 'string' ? next : undefined;
|
||||
}
|
||||
|
||||
export function optionalDecimalStringSchema(t: (key: string) => string, fieldKey = 'common:fields.price') {
|
||||
return z.preprocess(emptyToUndefined, decimalStringSchema(t, fieldKey).optional());
|
||||
function decimalPattern(maxDecimals: number) {
|
||||
return new RegExp(`^\\d+(\\.\\d{1,${maxDecimals}})?$`);
|
||||
}
|
||||
|
||||
export function decimalStringSchema(
|
||||
t: (key: string) => string,
|
||||
fieldKey = 'common:fields.price',
|
||||
maxDecimals = QUANTITY_DECIMAL_SCALE,
|
||||
) {
|
||||
return z.preprocess(
|
||||
coerceDecimalInput,
|
||||
z.string().regex(decimalPattern(maxDecimals), {
|
||||
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function optionalDecimalStringSchema(
|
||||
t: (key: string) => string,
|
||||
fieldKey = 'common:fields.price',
|
||||
maxDecimals = PRICE_DECIMAL_SCALE,
|
||||
) {
|
||||
return z.preprocess(
|
||||
coerceDecimalInput,
|
||||
z
|
||||
.string()
|
||||
.regex(decimalPattern(maxDecimals), {
|
||||
message: JSON.stringify({ key: 'validation:invalid_format', values: { field: t(fieldKey) } }),
|
||||
})
|
||||
.optional(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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 { FieldTextarea } from './fields/textarea.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 { FieldPinInput } from './fields/pin-input.field';
|
||||
export { FieldAutocomplete } from './fields/autocomplete.field';
|
||||
|
||||
@@ -11,5 +11,14 @@ export { initAgGrid } from './ag-grid-setup';
|
||||
export type { AgGridInitOptions } from './ag-grid-setup';
|
||||
|
||||
/* ── 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';
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './field-value';
|
||||
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