Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0090bc15f | ||
|
|
f5c1b7430a | ||
|
|
e316afa51d | ||
|
|
87bfe50f0e | ||
|
|
4c643547b6 | ||
|
|
6a54aa6c50 | ||
|
|
05c8459d12 | ||
|
|
991bf3fe65 | ||
|
|
90b606f865 | ||
|
|
6808a681b7 | ||
|
|
9d0cde7252 | ||
|
|
532e30672e | ||
|
|
71046973ae | ||
|
|
efe321ca2f | ||
|
|
5a6b109a89 |
@@ -8,11 +8,11 @@ alwaysApply: false
|
||||
|
||||
Every authenticated product module in `apps/web` must be gated by `GET /auth/me` permissions. Copy Privileges (`system/privileges`) — do not invent a second RBAC path.
|
||||
|
||||
Catalog keys live in `api.md` §4 (`PRIVILEGES`, `CONFIGURATION.BRANCH`, `SALES.ORDER`, …). `isSuperadmin` bypasses the matrix (adapter returns `defaultPrivileges`).
|
||||
Catalog keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule` (e.g. `ADMIN.SETTINGS.DATA.BRANCH`, `ADMIN.SALES.ACTIVITIES.ORDER`). `isSuperadmin` bypasses the matrix (adapter returns `defaultPrivileges`).
|
||||
|
||||
## Required wiring (do all four)
|
||||
|
||||
1. **`moduleKey`** on `ModuleConfigEntity` equals the catalog `code` (e.g. `CONFIGURATION.BRANCH`).
|
||||
1. **`moduleKey`** on `ModuleConfigEntity` equals the Admin catalog `code` (e.g. `ADMIN.SETTINGS.DATA.BRANCH`).
|
||||
2. **Menu leaf** in `layouts/data/menu.data.ts` sets the same `moduleKey`. `filterMenuByViewPrivilege` hides the item when `ALLOW_VIEW` is false.
|
||||
3. **Routes** wrap in `EnterpriseModuleProvider` so missing `ALLOW_VIEW` shows forbidden (no all-true flash).
|
||||
4. **Do not** re-check create/edit/delete in page JSX. Foundations already hide actions from `PrivilegeEntity`.
|
||||
@@ -23,8 +23,8 @@ if (!user.permissions.BRANCHES?.create) return null;
|
||||
{ key: 'branches', path: '/app/system/branches/index' }
|
||||
|
||||
// GOOD
|
||||
export const branchesModuleConfig = { moduleKey: 'CONFIGURATION.BRANCH', /* ... */ };
|
||||
{ key: 'system-branches', path: '/app/system/branches/index', moduleKey: 'CONFIGURATION.BRANCH' }
|
||||
export const branchesModuleConfig = { moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH', /* ... */ };
|
||||
{ key: 'system-branches', path: '/app/system/branches/index', moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH' }
|
||||
```
|
||||
|
||||
## Flag map (`mapUserPrivileges`)
|
||||
@@ -37,6 +37,6 @@ export const branchesModuleConfig = { moduleKey: 'CONFIGURATION.BRANCH', /* ...
|
||||
| `delete` | `ALLOW_DELETE` |
|
||||
| `import` | `ALLOW_IMPORT` |
|
||||
|
||||
Missing flag → `false`. Cycles/plans: key follows `purpose` (`SALES.CYCLE` / `LOGISTICS.PLAN`), not a generic `CYCLES` key.
|
||||
Missing flag → `false`. Cycles/plans: key follows `purpose` (`ADMIN.SALES.DATA.CYCLE` / `ADMIN.LOGISTICS.ACTIVITIES.PLAN`), not a generic `CYCLES` key.
|
||||
|
||||
Reference: [apps/web/src/core/lib/map-user-privileges.ts](apps/web/src/core/lib/map-user-privileges.ts), [filter-menu-by-view-privilege.ts](apps/web/src/core/lib/filter-menu-by-view-privilege.ts).
|
||||
|
||||
@@ -240,36 +240,43 @@ HTTP mapping:
|
||||
| `DELETE /:id`, `POST /bulk-delete` | `delete` |
|
||||
| `POST /import` | `import` |
|
||||
|
||||
Catalog (`GET /privilege-keys`, needs `PRIVILEGES` `view`):
|
||||
Catalog (`GET /privilege-keys`, needs `ADMIN.SETTINGS.USER.PRIVILEGES` `view`). Keys use `Group.Parent.Module` or `Group.Parent.Module.Submodule`:
|
||||
|
||||
| code | label |
|
||||
| ------------------------ | ---------------- |
|
||||
| `PRIVILEGES` | Privileges |
|
||||
| `USERS` | Users |
|
||||
| `CONFIGURATION.DIVISION` | Divisions |
|
||||
| `CONFIGURATION.BRANCH` | Branches |
|
||||
| `CONFIGURATION.CUSTOMER` | Customers |
|
||||
| `CONFIGURATION.EMPLOYEE` | Employees |
|
||||
| `CONFIGURATION.PRODUCT` | Products |
|
||||
| `SALES.REQUEST` | Sales requests |
|
||||
| `SALES.ORDER` | Sales orders |
|
||||
| `SALES.PACKING_SLIP` | Packing slips |
|
||||
| `SALES.INVOICE` | Sales invoices |
|
||||
| `SALES.PAYMENT` | Sales payments |
|
||||
| `CONFIGURATION.SETTING` | Company settings |
|
||||
| `SALES.CYCLE` | Sales cycles |
|
||||
| `SALES.PLAN` | Sales plans |
|
||||
| `LOGISTICS.CYCLE` | Logistics cycles |
|
||||
| `LOGISTICS.PLAN` | Logistics plans |
|
||||
| code | label |
|
||||
| ----------------------------------------- | ----------------------- |
|
||||
| `ADMIN.SETTINGS.USER.PRIVILEGES` | Privileges |
|
||||
| `ADMIN.SETTINGS.USER.USERS` | Users |
|
||||
| `ADMIN.SETTINGS.DATA.DIVISION` | Divisions |
|
||||
| `ADMIN.SETTINGS.DATA.BRANCH` | Branches |
|
||||
| `ADMIN.SETTINGS.DATA.CUSTOMER` | Customers |
|
||||
| `ADMIN.SETTINGS.DATA.PRODUCT` | Products |
|
||||
| `ADMIN.SETTINGS.DATA.SETTING` | Company settings |
|
||||
| `ADMIN.SALES.DATA.EMPLOYEE` | Employees |
|
||||
| `ADMIN.SALES.DATA.CYCLE` | Sales cycles |
|
||||
| `ADMIN.SALES.ACTIVITIES.REQUEST` | Sales requests |
|
||||
| `ADMIN.SALES.ACTIVITIES.ORDER` | Sales orders |
|
||||
| `ADMIN.SALES.ACTIVITIES.INVOICE` | Sales invoices |
|
||||
| `ADMIN.SALES.ACTIVITIES.PAYMENT` | Sales payments |
|
||||
| `ADMIN.SALES.ACTIVITIES.PLAN` | Sales plans |
|
||||
| `ADMIN.SALES.ACTIVITIES.TIMELINE` | Sales timeline |
|
||||
| `ADMIN.SALES.REPORT` | Sales reports |
|
||||
| `ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP` | Packing slips |
|
||||
| `ADMIN.LOGISTICS.DATA.CYCLE` | Logistics cycles |
|
||||
| `ADMIN.LOGISTICS.ACTIVITIES.PLAN` | Logistics plans |
|
||||
| `ADMIN.LOGISTICS.REPORT` | Logistics reports |
|
||||
| `MOBILE.SALES.PLAN` | Sales plans (mobile) |
|
||||
| `MOBILE.SALES.PLAN.ATTENDANCE` | Branch attendance |
|
||||
| `MOBILE.SALES.VISIT` | Customer visits |
|
||||
| `MOBILE.SALES.TIMELINE` | Sales timeline (mobile) |
|
||||
|
||||
### Field purpose
|
||||
|
||||
Cycles and plans do **not** use a single key. Privilege is resolved from `purpose`:
|
||||
|
||||
| purpose | cycle key | plan key |
|
||||
| ----------- | ----------------- | ---------------- |
|
||||
| `sales` | `SALES.CYCLE` | `SALES.PLAN` |
|
||||
| `logistics` | `LOGISTICS.CYCLE` | `LOGISTICS.PLAN` |
|
||||
| purpose | cycle keys | plan keys |
|
||||
| ----------- | ---------------------------- | ---------------------------------------------------------- |
|
||||
| `sales` | `ADMIN.SALES.DATA.CYCLE` | `ADMIN.SALES.ACTIVITIES.PLAN`, `MOBILE.SALES.PLAN` |
|
||||
| `logistics` | `ADMIN.LOGISTICS.DATA.CYCLE` | `ADMIN.LOGISTICS.ACTIVITIES.PLAN`, `MOBILE.LOGISTICS.PLAN` |
|
||||
|
||||
`purpose` is read from **body** (writes) or **query** (lists). If omitted, the user may proceed if they have the action on **either** purpose; list results are filtered to purposes they can view. Superadmin bypasses.
|
||||
|
||||
|
||||
@@ -150,8 +150,7 @@ interface CoreAppShellFeatures {
|
||||
| `zIndex` | `number` | `200` | Base z-index passed to Mantine's `AppShell`. |
|
||||
| `disabled` | `boolean` | `false` | Disables the AppShell layout entirely (renders children without structural chrome). |
|
||||
|
||||
> [!TIP]
|
||||
> **Smart defaults**: You rarely need to set `withUtilityBar`, `withAside`, or `withFooter` explicitly. The engine auto-detects presence by checking if the corresponding slot is provided and truthy. Only set them to `false` when you want to **suppress** a slot that is being passed.
|
||||
> [!TIP] > **Smart defaults**: You rarely need to set `withUtilityBar`, `withAside`, or `withFooter` explicitly. The engine auto-detects presence by checking if the corresponding slot is provided and truthy. Only set them to `false` when you want to **suppress** a slot that is being passed.
|
||||
|
||||
---
|
||||
|
||||
@@ -234,8 +233,7 @@ import { useCoreAppShell } from '@repo/ui/components';
|
||||
| `toggleNavbarPanel()` | `() => void` | Toggle the double-sidebar panel open/closed |
|
||||
| `setSidebarVariant()` | `(variant: SidebarVariant) => void` | Programmatically set the sidebar to `'expanded'`, `'mini'`, or `'hidden'` |
|
||||
|
||||
> [!WARNING]
|
||||
> `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
|
||||
> [!WARNING] > `useCoreAppShell()` **must** be called from within a `<CoreAppShell>` subtree. Calling it outside the provider will throw: `"useCoreAppShell must be used within CoreAppShellProvider"`. If you need context access in the header slot, pass a component (not inline JSX) so it mounts inside the provider tree.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FieldPasswordInput,
|
||||
FieldTextarea,
|
||||
FieldNumberInput,
|
||||
FieldCurrencyInput,
|
||||
FieldJsonInput,
|
||||
FieldPinInput,
|
||||
FieldAutocomplete,
|
||||
@@ -82,6 +83,7 @@ export default function AllFieldsDemo() {
|
||||
password: '',
|
||||
description: '',
|
||||
age: undefined,
|
||||
price: 12500.12345,
|
||||
jsonConfig: '',
|
||||
pin: '',
|
||||
country: '',
|
||||
@@ -142,6 +144,9 @@ export default function AllFieldsDemo() {
|
||||
<FieldPasswordInput name="password" control={control} label={t.fields.password} />
|
||||
<FieldNumberInput name="age" control={control} label={t.fields.age} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldCurrencyInput name="price" control={control} label={t.fields.price} />
|
||||
</Group>
|
||||
<Group grow align="flex-start" mb="md">
|
||||
<FieldTextarea name="description" control={control} label={t.fields.description} minRows={3} />
|
||||
<FieldJsonInput name="jsonConfig" control={control} label={t.fields.jsonConfig} formatOnBlur />
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"password": "Password",
|
||||
"description": "Description",
|
||||
"age": "Age",
|
||||
"price": "Price",
|
||||
"jsonConfig": "JSON Config",
|
||||
"tags": "Tags",
|
||||
"terms": "I agree to the terms and conditions",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"password": "Kata Sandi",
|
||||
"description": "Deskripsi",
|
||||
"age": "Usia",
|
||||
"price": "Harga",
|
||||
"jsonConfig": "Konfigurasi JSON",
|
||||
"tags": "Label (Tags)",
|
||||
"terms": "Saya setuju dengan syarat dan ketentuan",
|
||||
|
||||
@@ -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.
|
||||
@@ -21,8 +21,8 @@ test.describe('login', () => {
|
||||
await page.getByLabel('Password').fill(E2E_LOGIN.password);
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/app(\/|$)/);
|
||||
await expect(page.getByRole('heading', { name: 'Full Page' })).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/app\/timeline\/index/);
|
||||
await expect(page.getByPlaceholder('Search activities')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows an error and stays on login when credentials are rejected', async ({ page }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { WEB_URL } from '../../core/constants/web-url';
|
||||
import { AuthPageGuard } from '../../core/lib/auth-guard';
|
||||
|
||||
const LoginPage = lazy(() => import('./login'));
|
||||
@@ -9,7 +10,7 @@ export default function AuthModule() {
|
||||
<AuthPageGuard>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<Navigate to="/auth/login" replace={true} />} />
|
||||
<Route path="/" element={<Navigate to={WEB_URL.LOGIN} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</AuthPageGuard>
|
||||
|
||||
@@ -104,12 +104,6 @@ export function RightSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] text-center text-gray-600 mt-6 max-w-[85%] z-10 leading-[1.6]">
|
||||
Compatible with{' '}
|
||||
<span className="font-bold italic text-gray-900">Gmail, Outlook Web, LinkedIn and most web editors</span> for a
|
||||
smooth writing experience anywhere online.
|
||||
</p>
|
||||
|
||||
{/* Pagination Indicators */}
|
||||
<div className="flex gap-1.5 mt-8 z-10 mb-2">
|
||||
<div className="w-6 h-1 rounded-full bg-brand-500"></div>
|
||||
|
||||
@@ -3,14 +3,11 @@ import {
|
||||
FieldPasswordInput,
|
||||
Checkbox,
|
||||
Button,
|
||||
Divider,
|
||||
Select,
|
||||
Image,
|
||||
notifications,
|
||||
} from '@repo/ui/components';
|
||||
import { Lock, Apple, LogInIcon, UserRound, Globe } from 'lucide-react';
|
||||
import { GoogleIcon } from './components/google-icon';
|
||||
import { MicrosoftIcon } from './components/microsoft-icon';
|
||||
import { Lock, LogInIcon, UserRound, Globe } from 'lucide-react';
|
||||
import { RightSection } from './components/right-section';
|
||||
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { AppStorageKey, appStorage } from '../../../core/storage/local';
|
||||
@@ -167,9 +164,6 @@ export default function LoginPage() {
|
||||
label: { color: '#64748b', fontWeight: 500 },
|
||||
}}
|
||||
/>
|
||||
<a href="#" className="text-[13px] text-brand-600 font-semibold hover:underline">
|
||||
{t('forgot_password')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -182,43 +176,6 @@ export default function LoginPage() {
|
||||
>
|
||||
{t('login_button')}
|
||||
</Button>
|
||||
|
||||
<Divider
|
||||
label={t('or_login_with')}
|
||||
labelPosition="center"
|
||||
my="lg"
|
||||
styles={{ label: { color: '#94a3b8', fontSize: '12px' } }}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="md"
|
||||
className="border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
styles={{ root: { borderRadius: '10px', height: '44px' } }}
|
||||
>
|
||||
<GoogleIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="md"
|
||||
className="border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
styles={{ root: { borderRadius: '10px', height: '44px' } }}
|
||||
>
|
||||
<Apple className="w-5.5 h-5.5 text-black" fill="black" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="md"
|
||||
className="border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
styles={{ root: { borderRadius: '10px', height: '44px' } }}
|
||||
>
|
||||
<MicrosoftIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,8 +5,10 @@ import { StatusPage, AgGridProvider } from '@repo/ui/components';
|
||||
import { useTranslation } from '@repo/core-i18n';
|
||||
import { LoadingScreen } from '../core/components/loading-screen';
|
||||
import { ComingSoonPage } from '../core/components/coming-soon-page';
|
||||
import { WEB_URL } from '../core/constants/web-url';
|
||||
import { registerClientNavigate, registerSessionLifecycle } from '../core/lib/client-navigation';
|
||||
import { useThemeStore } from '../core/stores/theme.store';
|
||||
import { initializeAndPurgeHistoryBackground } from './main/layouts/hooks/useHistoryTracker';
|
||||
import { initializeAndPurgeHistoryBackground, resetHistoryCache } from './main/layouts/hooks/useHistoryTracker';
|
||||
|
||||
const AuthModule = lazy(() => import('./auth'));
|
||||
const AppModule = lazy(() => import('./main'));
|
||||
@@ -20,7 +22,7 @@ function NotFoundPage() {
|
||||
description={t('common:systemPages.notFound.description')}
|
||||
showActionsBack
|
||||
showActionsHome
|
||||
homeUrl="/app"
|
||||
homeUrl={WEB_URL.APP}
|
||||
backButtonLabel={t('common:systemPages.actions.goBack')}
|
||||
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
||||
/>
|
||||
@@ -36,7 +38,7 @@ function ForbiddenPage() {
|
||||
description={t('common:systemPages.forbidden.description')}
|
||||
showActionsBack
|
||||
showActionsHome
|
||||
homeUrl="/app"
|
||||
homeUrl={WEB_URL.APP}
|
||||
backButtonLabel={t('common:systemPages.actions.goBack')}
|
||||
homeButtonLabel={t('common:systemPages.actions.backToHome')}
|
||||
/>
|
||||
@@ -61,10 +63,16 @@ const router = createBrowserRouter([
|
||||
{ path: '/403', element: <ForbiddenPage /> },
|
||||
{ path: '/maintenance', element: <MaintenancePage /> },
|
||||
{ path: '/coming-soon', element: <ComingSoonPage /> },
|
||||
{ path: '/', element: <Navigate to="/app" /> },
|
||||
{ path: '/', element: <Navigate to={WEB_URL.APP} /> },
|
||||
{ path: '*', element: <Navigate to="/404" /> },
|
||||
]);
|
||||
|
||||
registerClientNavigate((to, options) => router.navigate(to, options));
|
||||
registerSessionLifecycle({
|
||||
onEnd: resetHistoryCache,
|
||||
onStart: () => initializeAndPurgeHistoryBackground(),
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const colorScheme = useThemeStore((s) => s.colorScheme);
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import type { PrivilegeEntity } from '@repo/ui/foundations';
|
||||
import { WEB_URL } from '../../core/constants/web-url';
|
||||
import { filterMenuByViewPrivilege } from '../../core/lib/filter-menu-by-view-privilege';
|
||||
import { firstProductPath } from '../../core/lib/first-product-path';
|
||||
import { appDatabase, AppDatabaseKey } from '../../core/storage/local';
|
||||
import { MENU_ITEMS } from './layouts/data/menu.data';
|
||||
|
||||
export function AppHomeRedirect() {
|
||||
const [to, setTo] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
const token = await appDatabase.getItem<string>(AppDatabaseKey.ACCESS_TOKEN);
|
||||
if (!token || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await appDatabase.getItem<{ isSuperadmin?: boolean }>(AppDatabaseKey.USER_PROFILE);
|
||||
const privileges = await appDatabase.getItem<Record<string, PrivilegeEntity>>(AppDatabaseKey.USER_PRIVILEGE);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filtered = filterMenuByViewPrivilege(MENU_ITEMS, privileges ?? {}, Boolean(profile?.isSuperadmin));
|
||||
setTo(firstProductPath(filtered, WEB_URL.APP_HOME));
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Navigate to={to} replace />;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import ModuleLayout from './layouts/module.layout';
|
||||
import { EmbeddedComingSoonPage } from '../../core/components/coming-soon-page';
|
||||
import { GlobalCredentialChecker } from '../../core/lib/auth-guard';
|
||||
import { AppHomeRedirect } from './app-home-redirect';
|
||||
import ModuleLayout from './layouts/module.layout';
|
||||
|
||||
const ExampleModule = lazy(() => import('./modules/example'));
|
||||
const SystemSetting = lazy(() => import('./modules/system/setting'));
|
||||
@@ -11,6 +13,7 @@ const PrivilegesModule = lazy(() => import('./modules/system/privileges/presenta
|
||||
const UsersModule = lazy(() => import('./modules/system/users/presentation/factory'));
|
||||
const ConfigurationModule = lazy(() => import('./modules/configuration'));
|
||||
const SalesModule = lazy(() => import('./modules/sales'));
|
||||
const TimelineModule = lazy(() => import('./modules/field/timeline/presentation/factory'));
|
||||
const LogisticsFieldModule = lazy(() => import('./modules/field/logistics'));
|
||||
|
||||
export default function AppModule() {
|
||||
@@ -26,8 +29,10 @@ export default function AppModule() {
|
||||
<Route path="/system/users/*" element={<UsersModule />} />
|
||||
<Route path="/configuration/*" element={<ConfigurationModule />} />
|
||||
<Route path="/sales/*" element={<SalesModule />} />
|
||||
<Route path="/timeline/*" element={<TimelineModule />} />
|
||||
<Route path="/logistics/*" element={<LogisticsFieldModule />} />
|
||||
<Route path="/" element={<Navigate to="/app/example/full-page" replace={true} />} />
|
||||
<Route path="/dashboard" element={<EmbeddedComingSoonPage />} />
|
||||
<Route path="/" element={<AppHomeRedirect />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
</ModuleLayout>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../core/storage/local', () => ({
|
||||
appStorage: {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
},
|
||||
AppStorageKey: {
|
||||
SIDEBAR_OPEN_MENUS: 'sidebar_open_menus',
|
||||
},
|
||||
}));
|
||||
|
||||
import { appStorage } from '../../../../core/storage/local';
|
||||
import { useSidebarStore } from './sidebar.store';
|
||||
|
||||
const allParentKeys = ['sales', 'sales-data', 'sales-activities'];
|
||||
|
||||
describe('useSidebarStore', () => {
|
||||
beforeEach(() => {
|
||||
useSidebarStore.setState({
|
||||
openedKeys: new Set(),
|
||||
isInitialized: false,
|
||||
isAllExpanded: false,
|
||||
searchQuery: '',
|
||||
});
|
||||
vi.mocked(appStorage.getItem).mockReset();
|
||||
vi.mocked(appStorage.setItem).mockReset();
|
||||
});
|
||||
|
||||
it('restores saved open keys so nested sales branches can render together', async () => {
|
||||
vi.mocked(appStorage.getItem).mockResolvedValue(['sales', 'sales-data']);
|
||||
|
||||
await useSidebarStore.getState().initializeStorage(['sales'], allParentKeys);
|
||||
|
||||
expect([...useSidebarStore.getState().openedKeys]).toEqual(['sales', 'sales-data']);
|
||||
expect(useSidebarStore.getState().isInitialized).toBe(true);
|
||||
expect(useSidebarStore.getState().isAllExpanded).toBe(false);
|
||||
});
|
||||
|
||||
it('opens the active path on first visit when nothing is saved', async () => {
|
||||
vi.mocked(appStorage.getItem).mockResolvedValue(null);
|
||||
|
||||
await useSidebarStore.getState().initializeStorage(['sales', 'sales-data'], allParentKeys);
|
||||
|
||||
expect([...useSidebarStore.getState().openedKeys]).toEqual(['sales', 'sales-data']);
|
||||
expect(appStorage.setItem).toHaveBeenCalledWith('sidebar_open_menus', ['sales', 'sales-data']);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { useTranslation } from '@repo/core-i18n';
|
||||
import { shortcutsData } from '@repo/ui/constants';
|
||||
import { LAYOUT_EVENTS } from '../../../../core/constants/events';
|
||||
import { useSidebarStore } from './sidebar.store';
|
||||
import { getAllParentKeys, shouldShowMenuChildren } from './sidebar.utils';
|
||||
import { useDebouncedValue } from '@repo/ui/hooks';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,7 +75,7 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
||||
const isOpened = useSidebarStore((state) => state.openedKeys.has(item.key));
|
||||
const toggleMenu = useSidebarStore((state) => state.toggleMenu);
|
||||
|
||||
const effectivelyOpened = isSearching || isOpened;
|
||||
const effectivelyOpened = shouldShowMenuChildren(isOpened, isSearching);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newOpened: boolean) => {
|
||||
@@ -84,39 +85,48 @@ const MenuItemExpanded = memo(function MenuItemExpanded({
|
||||
);
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
component={hasChildren ? 'button' : (Link as any)}
|
||||
to={hasChildren ? undefined : item.path}
|
||||
label={t(item.label)}
|
||||
leftSection={<Icon size={18} strokeWidth={1.8} />}
|
||||
active={isExactActive}
|
||||
opened={effectivelyOpened}
|
||||
onChange={handleChange}
|
||||
variant="light"
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
color: isParentActive ? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))' : undefined,
|
||||
},
|
||||
label: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{hasChildren &&
|
||||
item.children!.map((child) => (
|
||||
<MenuItemExpanded
|
||||
key={child.key}
|
||||
item={child}
|
||||
activeKeys={activeKeys}
|
||||
isSearching={isSearching}
|
||||
allParentKeys={allParentKeys}
|
||||
/>
|
||||
))}
|
||||
</NavLink>
|
||||
<>
|
||||
<NavLink
|
||||
component={hasChildren ? 'button' : (Link as any)}
|
||||
to={hasChildren ? undefined : item.path}
|
||||
label={t(item.label)}
|
||||
leftSection={<Icon size={18} strokeWidth={1.8} />}
|
||||
active={isExactActive}
|
||||
opened={effectivelyOpened}
|
||||
onChange={handleChange}
|
||||
variant="light"
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
color: isParentActive
|
||||
? 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))'
|
||||
: undefined,
|
||||
},
|
||||
label: {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
fontWeight: 500,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Keep the chevron without nesting real items in Collapse (nested height clips siblings on refresh). */}
|
||||
{hasChildren ? <></> : undefined}
|
||||
</NavLink>
|
||||
{hasChildren && effectivelyOpened ? (
|
||||
<Box ps="lg">
|
||||
{item.children!.map((child) => (
|
||||
<MenuItemExpanded
|
||||
key={child.key}
|
||||
item={child}
|
||||
activeKeys={activeKeys}
|
||||
isSearching={isSearching}
|
||||
allParentKeys={allParentKeys}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -236,17 +246,6 @@ const MenuItemFlyout = memo(function MenuItemFlyout({ item, activeKeys, isRoot =
|
||||
// SidebarMenu Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getAllParentKeys = (items: MenuItemType[]): string[] => {
|
||||
let keys: string[] = [];
|
||||
for (const item of items) {
|
||||
if (item.children && item.children.length > 0) {
|
||||
keys.push(item.key);
|
||||
keys = keys.concat(getAllParentKeys(item.children));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
};
|
||||
|
||||
export const SidebarMenu = memo(function SidebarMenu({
|
||||
items,
|
||||
variantOverride,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MENU_ITEMS } from '../data/menu.data';
|
||||
import { getAllParentKeys, getVisibleMenuKeys, shouldShowMenuChildren } from './sidebar.utils';
|
||||
|
||||
describe('shouldShowMenuChildren', () => {
|
||||
it('shows children when the branch is open or the user is searching', () => {
|
||||
expect(shouldShowMenuChildren(true, false)).toBe(true);
|
||||
expect(shouldShowMenuChildren(false, true)).toBe(true);
|
||||
expect(shouldShowMenuChildren(false, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllParentKeys', () => {
|
||||
it('includes nested groups under sales, logistics, and settings', () => {
|
||||
expect(getAllParentKeys(MENU_ITEMS)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'sales',
|
||||
'sales-data',
|
||||
'sales-activities',
|
||||
'logistics',
|
||||
'logistics-data',
|
||||
'settings',
|
||||
'settings-data',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVisibleMenuKeys', () => {
|
||||
it('keeps all siblings of an expanded nested branch visible after restore', () => {
|
||||
const openedKeys = new Set(['sales', 'sales-data']);
|
||||
|
||||
const keys = getVisibleMenuKeys(MENU_ITEMS, openedKeys);
|
||||
|
||||
expect(keys).toContain('sales-employees');
|
||||
expect(keys).toContain('sales-cycles');
|
||||
expect(keys).toContain('sales-activities');
|
||||
expect(keys).toContain('sales-reports');
|
||||
expect(keys).not.toContain('sales-requests');
|
||||
});
|
||||
|
||||
it('reveals every nested item while searching', () => {
|
||||
const keys = getVisibleMenuKeys(MENU_ITEMS, new Set(), true);
|
||||
|
||||
expect(keys).toContain('sales-requests');
|
||||
expect(keys).toContain('logistics-packing-slips');
|
||||
expect(keys).toContain('system-users');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { MenuItemType } from '../types/menu.types';
|
||||
|
||||
export function getAllParentKeys(items: MenuItemType[]): string[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.children && item.children.length > 0) {
|
||||
keys.push(item.key);
|
||||
keys.push(...getAllParentKeys(item.children));
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function shouldShowMenuChildren(isOpened: boolean, isSearching: boolean): boolean {
|
||||
return isSearching || isOpened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys that must stay in the accessible tree when a branch is open.
|
||||
* Nested Collapse height bugs clip these siblings after refresh; this list is the contract.
|
||||
*/
|
||||
export function getVisibleMenuKeys(items: MenuItemType[], openedKeys: Set<string>, isSearching = false): string[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
const walk = (nodes: MenuItemType[]) => {
|
||||
for (const node of nodes) {
|
||||
keys.push(node.key);
|
||||
if (node.children?.length && shouldShowMenuChildren(openedKeys.has(node.key), isSearching)) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(items);
|
||||
return keys;
|
||||
}
|
||||
@@ -13,8 +13,17 @@ const flatten = (items: MenuItemType[]): MenuItemType[] =>
|
||||
items.flatMap((item) => [item, ...(item.children ? flatten(item.children) : [])]);
|
||||
|
||||
describe('MENU_ITEMS', () => {
|
||||
it('orders top-level items as dashboard, sales, logistics, settings', () => {
|
||||
expect(MENU_ITEMS.map((item) => item.key)).toEqual(['dashboard', 'sales', 'logistics', 'settings']);
|
||||
it('orders top-level items as dashboard, timeline, sales, logistics, settings', () => {
|
||||
expect(MENU_ITEMS.map((item) => item.key)).toEqual(['dashboard', 'timeline', 'sales', 'logistics', 'settings']);
|
||||
});
|
||||
|
||||
it('places timeline next to dashboard instead of inside sales activities', () => {
|
||||
const timeline = findItem(MENU_ITEMS, 'timeline');
|
||||
const sales = findItem(MENU_ITEMS, 'sales');
|
||||
|
||||
expect(timeline?.path).toBe('/app/timeline/index');
|
||||
expect(timeline?.moduleKey).toBe('ADMIN.SALES.ACTIVITIES.TIMELINE');
|
||||
expect(childKeys(findItem(sales?.children ?? [], 'sales-activities'))).not.toContain('sales-timeline');
|
||||
});
|
||||
|
||||
it('nests sales as data, activities, then reports', () => {
|
||||
@@ -58,6 +67,7 @@ describe('MENU_ITEMS', () => {
|
||||
'configuration-divisions',
|
||||
'configuration-customers',
|
||||
'configuration-products',
|
||||
'configuration-company-settings',
|
||||
]);
|
||||
expect(childKeys(findItem(settings?.children ?? [], 'settings-user'))).toEqual([
|
||||
'system-users',
|
||||
|
||||
@@ -33,6 +33,13 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
icon: LayoutDashboard,
|
||||
path: '/app/dashboard',
|
||||
},
|
||||
{
|
||||
key: 'timeline',
|
||||
label: 'nav:timeline',
|
||||
icon: MapPin,
|
||||
path: '/app/timeline/index',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
|
||||
},
|
||||
{
|
||||
key: 'sales',
|
||||
label: 'nav:sales',
|
||||
@@ -50,14 +57,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:configuration-employees',
|
||||
icon: Users,
|
||||
path: '/app/sales/employees/index',
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
},
|
||||
{
|
||||
key: 'sales-cycles',
|
||||
label: 'nav:sales-cycles',
|
||||
icon: Repeat,
|
||||
path: '/app/sales/cycles/index',
|
||||
moduleKey: 'SALES.CYCLE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.CYCLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -72,44 +79,44 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:sales-requests',
|
||||
icon: ClipboardList,
|
||||
path: '/app/sales/requests/index',
|
||||
moduleKey: 'SALES.REQUEST',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.REQUEST',
|
||||
},
|
||||
{
|
||||
key: 'sales-orders',
|
||||
label: 'nav:sales-orders',
|
||||
icon: Box,
|
||||
path: '/app/sales/orders/index',
|
||||
moduleKey: 'SALES.ORDER',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.ORDER',
|
||||
},
|
||||
{
|
||||
key: 'sales-invoices',
|
||||
label: 'nav:sales-invoices',
|
||||
icon: Receipt,
|
||||
path: '/app/sales/invoices/index',
|
||||
moduleKey: 'SALES.INVOICE',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
},
|
||||
{
|
||||
key: 'sales-payments',
|
||||
label: 'nav:sales-payments',
|
||||
icon: CreditCard,
|
||||
path: '/app/sales/payments/index',
|
||||
moduleKey: 'SALES.PAYMENT',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.PAYMENT',
|
||||
},
|
||||
{
|
||||
key: 'sales-plans',
|
||||
label: 'nav:sales-plans',
|
||||
icon: Calendar,
|
||||
path: '/app/sales/plans/index',
|
||||
moduleKey: 'SALES.PLAN',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.PLAN',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'sales-reports',
|
||||
label: 'nav:reports-coming-soon',
|
||||
label: 'nav:sales-reports',
|
||||
icon: FileText,
|
||||
path: '/app/sales/reports',
|
||||
isPlaceholder: true,
|
||||
path: '/app/sales/reports/index',
|
||||
moduleKey: 'ADMIN.SALES.REPORT',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -130,14 +137,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:configuration-employees',
|
||||
icon: Users,
|
||||
path: '/app/logistics/employees/index',
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
},
|
||||
{
|
||||
key: 'logistics-cycles',
|
||||
label: 'nav:logistics-cycles',
|
||||
icon: Repeat,
|
||||
path: '/app/logistics/cycles/index',
|
||||
moduleKey: 'LOGISTICS.CYCLE',
|
||||
moduleKey: 'ADMIN.LOGISTICS.DATA.CYCLE',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -152,23 +159,23 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:logistics-packing-slips',
|
||||
icon: Package,
|
||||
path: '/app/logistics/packing-slips/index',
|
||||
moduleKey: 'SALES.PACKING_SLIP',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
||||
},
|
||||
{
|
||||
key: 'logistics-plans',
|
||||
label: 'nav:logistics-plans',
|
||||
icon: Calendar,
|
||||
path: '/app/logistics/plans/index',
|
||||
moduleKey: 'LOGISTICS.PLAN',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'logistics-reports',
|
||||
label: 'nav:reports-coming-soon',
|
||||
label: 'nav:logistics-reports',
|
||||
icon: FileText,
|
||||
path: '/app/logistics/reports',
|
||||
isPlaceholder: true,
|
||||
path: '/app/logistics/reports/index',
|
||||
moduleKey: 'ADMIN.LOGISTICS.REPORT',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -189,28 +196,35 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:configuration-branches',
|
||||
icon: MapPin,
|
||||
path: '/app/configuration/branches/index',
|
||||
moduleKey: 'CONFIGURATION.BRANCH',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
|
||||
},
|
||||
{
|
||||
key: 'configuration-divisions',
|
||||
label: 'nav:configuration-divisions',
|
||||
icon: Layers,
|
||||
path: '/app/configuration/divisions/index',
|
||||
moduleKey: 'CONFIGURATION.DIVISION',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
|
||||
},
|
||||
{
|
||||
key: 'configuration-customers',
|
||||
label: 'nav:configuration-customers',
|
||||
icon: Users,
|
||||
path: '/app/configuration/customers/index',
|
||||
moduleKey: 'CONFIGURATION.CUSTOMER',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||
},
|
||||
{
|
||||
key: 'configuration-products',
|
||||
label: 'nav:configuration-products',
|
||||
icon: Package,
|
||||
path: '/app/configuration/products/index',
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||
},
|
||||
{
|
||||
key: 'configuration-company-settings',
|
||||
label: 'nav:configuration-company-settings',
|
||||
icon: Settings,
|
||||
path: '/app/configuration/company-settings/index',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.SETTING',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -225,14 +239,14 @@ export const MENU_ITEMS: MenuItemType[] = [
|
||||
label: 'nav:system-users',
|
||||
icon: Users,
|
||||
path: '/app/system/users/index',
|
||||
moduleKey: 'USERS',
|
||||
moduleKey: 'ADMIN.SETTINGS.USER.USERS',
|
||||
},
|
||||
{
|
||||
key: 'system-privileges',
|
||||
label: 'nav:system-privileges',
|
||||
icon: Shield,
|
||||
path: '/app/system/privileges/index',
|
||||
moduleKey: 'PRIVILEGES',
|
||||
moduleKey: 'ADMIN.SETTINGS.USER.PRIVILEGES',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -29,6 +29,11 @@ if (historyChannel) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Background Task: Initialization & Purge (Remain the same)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function resetHistoryCache(): void {
|
||||
memoryHistoryCache = [];
|
||||
isHistoryReady = false;
|
||||
}
|
||||
|
||||
export async function initializeAndPurgeHistoryBackground() {
|
||||
if (isHistoryReady) return;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"dashboard": "Dashboard",
|
||||
"timeline": "Timeline",
|
||||
"crm": "CRM",
|
||||
"crm-leads": "Leads",
|
||||
"crm-pipelines": "Pipelines",
|
||||
@@ -31,6 +32,8 @@
|
||||
"data": "Data",
|
||||
"activities": "Activities",
|
||||
"reports-coming-soon": "Reports (Coming Soon)",
|
||||
"sales-reports": "Sales Reports",
|
||||
"logistics-reports": "Logistics Reports",
|
||||
"user": "User",
|
||||
"settings": "Settings",
|
||||
"settings-general": "General Settings",
|
||||
@@ -53,5 +56,6 @@
|
||||
"logistics-plans": "Logistics Plans",
|
||||
"logistics-packing-slips": "Packing Slips",
|
||||
"configuration-employees": "Employees",
|
||||
"configuration-products": "Products"
|
||||
"configuration-products": "Products",
|
||||
"configuration-company-settings": "Company settings"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"dashboard": "Dasbor",
|
||||
"timeline": "Timeline",
|
||||
"crm": "CRM",
|
||||
"crm-leads": "Prospek",
|
||||
"crm-pipelines": "Alur Penjualan",
|
||||
@@ -31,6 +32,8 @@
|
||||
"data": "Data",
|
||||
"activities": "Aktivitas",
|
||||
"reports-coming-soon": "Laporan (Segera Hadir)",
|
||||
"sales-reports": "Laporan Penjualan",
|
||||
"logistics-reports": "Laporan Logistik",
|
||||
"user": "Pengguna",
|
||||
"settings": "Pengaturan",
|
||||
"settings-general": "Pengaturan Umum",
|
||||
@@ -53,5 +56,6 @@
|
||||
"logistics-plans": "Rencana Logistik",
|
||||
"logistics-packing-slips": "Surat Jalan",
|
||||
"configuration-employees": "Karyawan",
|
||||
"configuration-products": "Produk"
|
||||
"configuration-products": "Produk",
|
||||
"configuration-company-settings": "Pengaturan perusahaan"
|
||||
}
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('BranchesRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new BranchesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/branches',
|
||||
moduleKey: 'CONFIGURATION.BRANCH',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
|
||||
transformer: new BranchesRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { BranchEntity } from '../entities';
|
||||
|
||||
export const branchesModuleConfig: ModuleConfigEntity<BranchEntity> = {
|
||||
moduleKey: 'CONFIGURATION.BRANCH',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.BRANCH',
|
||||
translationNamespace: 'BRANCHES',
|
||||
apiUrl: '/branches',
|
||||
webUrl: '/app/configuration/branches',
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import type { CompanySettingsEntity, UpdateCompanySettingsPayload } from '../domain/entities/company-settings.entity';
|
||||
|
||||
export class CompanySettingsRemoteService {
|
||||
constructor(private readonly client: AxiosInstance) {}
|
||||
|
||||
async get(): Promise<CompanySettingsEntity> {
|
||||
const { data } = await this.client.get<CompanySettingsEntity>('/settings');
|
||||
return data;
|
||||
}
|
||||
|
||||
async update(payload: UpdateCompanySettingsPayload): Promise<CompanySettingsEntity> {
|
||||
const { data } = await this.client.patch<CompanySettingsEntity>('/settings', payload);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
import type { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
|
||||
export type CompanySettingsShellEntity = BaseEntity & { id: string };
|
||||
|
||||
export const companySettingsModuleConfig: ModuleConfigEntity<CompanySettingsShellEntity> = {
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.SETTING',
|
||||
translationNamespace: 'COMPANY_SETTINGS',
|
||||
apiUrl: '/settings',
|
||||
webUrl: '/app/configuration/company-settings',
|
||||
moduleCategory: 'SINGLE_PAGE',
|
||||
moduleType: 'MASTER_DATA',
|
||||
} as const;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
export type CompanySettingsEntity = {
|
||||
id: string;
|
||||
cycleStartDate: number;
|
||||
checkInRadiusMeters: number;
|
||||
gpsIntervalSeconds: number;
|
||||
checkoutWarningRadiusMeters: number;
|
||||
status: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
};
|
||||
|
||||
export type UpdateCompanySettingsPayload = {
|
||||
cycleStartDate?: string;
|
||||
checkInRadiusMeters?: number;
|
||||
gpsIntervalSeconds?: number;
|
||||
checkoutWarningRadiusMeters?: number;
|
||||
};
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
|
||||
import { companySettingsModuleConfig, type CompanySettingsShellEntity } from '../constants/company-settings.constants';
|
||||
import { CompanySettingsRemoteService } from '../../data/company-settings.remote.service';
|
||||
|
||||
class CompanySettingsShellTransformer extends BaseDataTransformer<CompanySettingsShellEntity> {
|
||||
transformToEntity(dto: CompanySettingsShellEntity): CompanySettingsShellEntity {
|
||||
return dto;
|
||||
}
|
||||
|
||||
transformToDTO(entity: CompanySettingsShellEntity): CompanySettingsShellEntity {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
export const companySettingsDataService = new TrackGoRemoteDataServices(apiClient, {
|
||||
apiUrl: companySettingsModuleConfig.apiUrl,
|
||||
moduleKey: companySettingsModuleConfig.moduleKey,
|
||||
transformer: new CompanySettingsShellTransformer(),
|
||||
});
|
||||
|
||||
export const companySettingsRemoteService = new CompanySettingsRemoteService(apiClient);
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { companySettingsModuleConfig } from '../../domain/constants/company-settings.constants';
|
||||
import { companySettingsDataService } from '../../domain/factories';
|
||||
import { companySettingsStore } from '../store';
|
||||
|
||||
import companySettingsEn from '../languages/en/company-settings.json';
|
||||
import companySettingsId from '../languages/id/company-settings.json';
|
||||
|
||||
const IndexPage = lazy(() => import('../pages/company-settings.page'));
|
||||
|
||||
registerModuleNamespace(companySettingsModuleConfig.translationNamespace, {
|
||||
en: companySettingsEn,
|
||||
id: companySettingsId,
|
||||
});
|
||||
|
||||
export default function CompanySettingsModule() {
|
||||
return (
|
||||
<EnterpriseModuleProvider
|
||||
config={companySettingsModuleConfig}
|
||||
dataServices={companySettingsDataService}
|
||||
store={companySettingsStore}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/" element={<Navigate to={`${companySettingsModuleConfig.webUrl}/index`} replace />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Company settings",
|
||||
"description": "Configure cycle start date, check-in radius, and timeline tracking.",
|
||||
"fields": {
|
||||
"cycleStartDate": "Cycle start date",
|
||||
"checkInRadiusMeters": "Check-in radius (meters)",
|
||||
"gpsIntervalSeconds": "GPS interval (seconds)",
|
||||
"checkoutWarningRadiusMeters": "Checkout warning radius (meters)"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save settings"
|
||||
},
|
||||
"messages": {
|
||||
"saved": "Company settings updated.",
|
||||
"loadFailed": "Could not load company settings."
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Pengaturan perusahaan",
|
||||
"description": "Atur tanggal awal siklus, radius check-in, dan pelacakan timeline.",
|
||||
"fields": {
|
||||
"cycleStartDate": "Tanggal awal siklus",
|
||||
"checkInRadiusMeters": "Radius check-in (meter)",
|
||||
"gpsIntervalSeconds": "Interval GPS (detik)",
|
||||
"checkoutWarningRadiusMeters": "Radius peringatan checkout (meter)"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Simpan pengaturan"
|
||||
},
|
||||
"messages": {
|
||||
"saved": "Pengaturan perusahaan diperbarui.",
|
||||
"loadFailed": "Gagal memuat pengaturan perusahaan."
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, CorePageContainer, Grid, Stack, Text } from '@repo/ui/components';
|
||||
import { ModulePageHeader } from '@repo/ui/foundations';
|
||||
import { FieldDatePicker, FieldNumberInput } from '@repo/ui/form';
|
||||
import { useTranslation, registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { companySettingsModuleConfig } from '../../domain/constants/company-settings.constants';
|
||||
import { companySettingsRemoteService } from '../../domain/factories';
|
||||
|
||||
import companySettingsEn from '../languages/en/company-settings.json';
|
||||
import companySettingsId from '../languages/id/company-settings.json';
|
||||
|
||||
registerModuleNamespace(companySettingsModuleConfig.translationNamespace, {
|
||||
en: companySettingsEn,
|
||||
id: companySettingsId,
|
||||
});
|
||||
|
||||
type CompanySettingsForm = {
|
||||
cycleStartDate: string;
|
||||
checkInRadiusMeters: number;
|
||||
gpsIntervalSeconds: number;
|
||||
checkoutWarningRadiusMeters: number;
|
||||
};
|
||||
|
||||
function unixDayToIsoDate(unixMs: number): string {
|
||||
const date = new Date(unixMs);
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export default function CompanySettingsPage() {
|
||||
const { t } = useTranslation(companySettingsModuleConfig.translationNamespace);
|
||||
const { t: tNav } = useTranslation('nav');
|
||||
const form = useForm<CompanySettingsForm>();
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const settings = await companySettingsRemoteService.get();
|
||||
form.reset({
|
||||
cycleStartDate: unixDayToIsoDate(settings.cycleStartDate),
|
||||
checkInRadiusMeters: settings.checkInRadiusMeters,
|
||||
gpsIntervalSeconds: settings.gpsIntervalSeconds,
|
||||
checkoutWarningRadiusMeters: settings.checkoutWarningRadiusMeters,
|
||||
});
|
||||
} catch {
|
||||
setErrorMessage(t('messages.loadFailed'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [form, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
await companySettingsRemoteService.update(values);
|
||||
setSuccessMessage(t('messages.saved'));
|
||||
} catch {
|
||||
setErrorMessage(t('messages.loadFailed'));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<CorePageContainer>
|
||||
<ModulePageHeader
|
||||
icon={Settings}
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
moduleKey={companySettingsModuleConfig.moduleKey}
|
||||
breadcrumbs={[
|
||||
{ label: tNav('settings'), type: 'text' },
|
||||
{ label: tNav('data'), type: 'text' },
|
||||
{ label: t('title'), type: 'text' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<FormProvider {...form}>
|
||||
<Card withBorder padding="xl" radius="md">
|
||||
<Stack gap="md" component="form" onSubmit={onSubmit}>
|
||||
<Grid gutter="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<FieldDatePicker control={form.control} name="cycleStartDate" label={t('fields.cycleStartDate')} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<FieldNumberInput
|
||||
control={form.control}
|
||||
name="checkInRadiusMeters"
|
||||
label={t('fields.checkInRadiusMeters')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<FieldNumberInput
|
||||
control={form.control}
|
||||
name="gpsIntervalSeconds"
|
||||
label={t('fields.gpsIntervalSeconds')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<FieldNumberInput
|
||||
control={form.control}
|
||||
name="checkoutWarningRadiusMeters"
|
||||
label={t('fields.checkoutWarningRadiusMeters')}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{errorMessage ? (
|
||||
<Text size="sm" c="red">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
{successMessage ? (
|
||||
<Text size="sm" c="teal">
|
||||
{successMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" loading={isLoading}>
|
||||
{t('actions.save')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</FormProvider>
|
||||
</CorePageContainer>
|
||||
);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import { EnterpriseModuleState } from '@repo/ui/foundations';
|
||||
import type { CompanySettingsShellEntity } from '../../domain/constants/company-settings.constants';
|
||||
|
||||
export const companySettingsStore = create<EnterpriseModuleState<CompanySettingsShellEntity>>((set) => ({
|
||||
metaData: { limit: 15 },
|
||||
setMetaData: (data) => set({ metaData: data }),
|
||||
filterData: {},
|
||||
setFilterData: (data) => set({ filterData: data }),
|
||||
selectedRows: [],
|
||||
setSelectedRows: (rows) => set({ selectedRows: rows }),
|
||||
privileges: [],
|
||||
setPrivileges: (privileges) => set({ privileges }),
|
||||
tableConfig: null,
|
||||
setTableConfig: (config) => set({ tableConfig: config }),
|
||||
}));
|
||||
+1
-1
@@ -42,7 +42,7 @@ describe('CustomersRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new CustomersRemoteDataServices(httpClient, {
|
||||
apiUrl: '/customers',
|
||||
moduleKey: 'CONFIGURATION.CUSTOMER',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||
transformer: new CustomersRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { CustomerEntity } from '../entities';
|
||||
|
||||
export const customersModuleConfig: ModuleConfigEntity<CustomerEntity> = {
|
||||
moduleKey: 'CONFIGURATION.CUSTOMER',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.CUSTOMER',
|
||||
translationNamespace: 'CUSTOMERS',
|
||||
apiUrl: '/customers',
|
||||
webUrl: '/app/configuration/customers',
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ describe('DivisionsRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new DivisionsRemoteDataServices(httpClient, {
|
||||
apiUrl: '/divisions',
|
||||
moduleKey: 'CONFIGURATION.DIVISION',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
|
||||
transformer: new DivisionsRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { DivisionEntity } from '../entities';
|
||||
|
||||
export const divisionsModuleConfig: ModuleConfigEntity<DivisionEntity> = {
|
||||
moduleKey: 'CONFIGURATION.DIVISION',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.DIVISION',
|
||||
translationNamespace: 'DIVISIONS',
|
||||
apiUrl: '/divisions',
|
||||
webUrl: '/app/configuration/divisions',
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('EmployeesRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new EmployeesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/employees',
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
transformer: new EmployeesRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ describe('employee purpose helpers', () => {
|
||||
});
|
||||
|
||||
it('keeps a shared employee privilege key', () => {
|
||||
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
|
||||
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('CONFIGURATION.EMPLOYEE');
|
||||
expect(createEmployeeModuleConfig('sales').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
|
||||
expect(createEmployeeModuleConfig('logistics').moduleKey).toBe('ADMIN.SALES.DATA.EMPLOYEE');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import type { EmployeeEntity } from '../entities';
|
||||
|
||||
export function createEmployeeModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<EmployeeEntity> {
|
||||
return {
|
||||
moduleKey: 'CONFIGURATION.EMPLOYEE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.EMPLOYEE',
|
||||
translationNamespace: 'EMPLOYEES',
|
||||
apiUrl: '/employees',
|
||||
webUrl: `/app/${purpose}/employees`,
|
||||
|
||||
@@ -6,6 +6,7 @@ const DivisionsModule = lazy(() => import('./divisions/presentation/factory'));
|
||||
const BranchesModule = lazy(() => import('./branches/presentation/factory'));
|
||||
const CustomersModule = lazy(() => import('./customers/presentation/factory'));
|
||||
const ProductsModule = lazy(() => import('./products/presentation/factory'));
|
||||
const CompanySettingsModule = lazy(() => import('./company-settings/presentation/factory'));
|
||||
|
||||
export default function ConfigurationModule() {
|
||||
return (
|
||||
@@ -14,6 +15,7 @@ export default function ConfigurationModule() {
|
||||
<Route path="/branches/*" element={<BranchesModule />} />
|
||||
<Route path="/customers/*" element={<CustomersModule />} />
|
||||
<Route path="/products/*" element={<ProductsModule />} />
|
||||
<Route path="/company-settings/*" element={<CompanySettingsModule />} />
|
||||
<Route path="/employees/*" element={<Navigate to={`${WEB_URL.SALES_EMPLOYEES}/index`} replace={true} />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ describe('ProductsRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new ProductsRemoteDataServices(httpClient, {
|
||||
apiUrl: '/products',
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||
transformer: new ProductsRemoteDataTransformer(),
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { ProductEntity } from '../entities';
|
||||
|
||||
export const productsModuleConfig: ModuleConfigEntity<ProductEntity> = {
|
||||
moduleKey: 'CONFIGURATION.PRODUCT',
|
||||
moduleKey: 'ADMIN.SETTINGS.DATA.PRODUCT',
|
||||
translationNamespace: 'PRODUCTS',
|
||||
apiUrl: '/products',
|
||||
webUrl: '/app/configuration/products',
|
||||
|
||||
+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,
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('CyclesRemoteDataServices', () => {
|
||||
httpClient = createMockHttpClient();
|
||||
service = new CyclesRemoteDataServices(httpClient, {
|
||||
apiUrl: '/cycles',
|
||||
moduleKey: 'SALES.CYCLE',
|
||||
moduleKey: 'ADMIN.SALES.DATA.CYCLE',
|
||||
transformer: new CyclesRemoteDataTransformer('sales'),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CycleEntity, CycleWeekdayRow } from '../entities';
|
||||
|
||||
export function createCycleModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<CycleEntity> {
|
||||
return {
|
||||
moduleKey: purpose === 'sales' ? 'SALES.CYCLE' : 'LOGISTICS.CYCLE',
|
||||
moduleKey: purpose === 'sales' ? 'ADMIN.SALES.DATA.CYCLE' : 'ADMIN.LOGISTICS.DATA.CYCLE',
|
||||
translationNamespace: 'CYCLES',
|
||||
apiUrl: '/cycles',
|
||||
webUrl: `/app/${purpose}/cycles`,
|
||||
|
||||
+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: 'ADMIN.LOGISTICS.REPORT',
|
||||
translationNamespace: 'LOGISTICS_REPORTS',
|
||||
apiUrl: '/reports',
|
||||
webUrl: '/app/logistics/reports',
|
||||
moduleCategory: 'SINGLE_PAGE',
|
||||
moduleType: 'TRANSACTION',
|
||||
} as const;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
|
||||
import { logisticsReportsModuleConfig, type ReportShellEntity } from '../constants/reports.constants';
|
||||
|
||||
class ReportShellTransformer extends BaseDataTransformer<ReportShellEntity> {
|
||||
transformToEntity(dto: ReportShellEntity): ReportShellEntity {
|
||||
return dto;
|
||||
}
|
||||
|
||||
transformToDTO(entity: ReportShellEntity): ReportShellEntity {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
export const logisticsReportsDataService = new TrackGoRemoteDataServices(apiClient, {
|
||||
apiUrl: logisticsReportsModuleConfig.apiUrl,
|
||||
moduleKey: logisticsReportsModuleConfig.moduleKey,
|
||||
transformer: new ReportShellTransformer(),
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EnterpriseModuleProvider } from '@repo/ui/foundations';
|
||||
import { registerModuleNamespace } from '@repo/core-i18n';
|
||||
import { logisticsReportsModuleConfig, type ReportShellEntity } from '../../domain/constants/reports.constants';
|
||||
import { logisticsReportsDataService } from '../../domain/factories';
|
||||
import { logisticsReportsStore } from '../store';
|
||||
|
||||
import reportsEn from '../languages/en/reports.json';
|
||||
import reportsId from '../languages/id/reports.json';
|
||||
|
||||
const IndexPage = lazy(() => import('../pages/reports.page.index'));
|
||||
|
||||
registerModuleNamespace(logisticsReportsModuleConfig.translationNamespace, {
|
||||
en: reportsEn,
|
||||
id: reportsId,
|
||||
});
|
||||
|
||||
export default function LogisticsReportsModule() {
|
||||
return (
|
||||
<EnterpriseModuleProvider<ReportShellEntity>
|
||||
config={logisticsReportsModuleConfig}
|
||||
dataServices={logisticsReportsDataService}
|
||||
store={logisticsReportsStore}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/index" element={<IndexPage />} />
|
||||
<Route path="/" element={<Navigate to={`${logisticsReportsModuleConfig.webUrl}/index`} replace />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
</EnterpriseModuleProvider>
|
||||
);
|
||||
}
|
||||
+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,6 @@
|
||||
import { lazy } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { EmbeddedComingSoonPage } from '../../../../../core/components/coming-soon-page';
|
||||
const LogisticsReportsModule = lazy(() => import('../logistics-reports/presentation/factory'));
|
||||
|
||||
const EmployeesModule = lazy(() => import('../../configuration/employees/presentation/factory'));
|
||||
const CyclesModule = lazy(() => import('../cycles/presentation/factory'));
|
||||
@@ -14,7 +14,7 @@ export default function LogisticsFieldModule() {
|
||||
<Route path="/cycles/*" element={<CyclesModule purpose="logistics" />} />
|
||||
<Route path="/plans/*" element={<PlansModule purpose="logistics" />} />
|
||||
<Route path="/packing-slips/*" element={<PackingSlipsModule />} />
|
||||
<Route path="/reports" element={<EmbeddedComingSoonPage />} />
|
||||
<Route path="/reports/*" element={<LogisticsReportsModule />} />
|
||||
<Route path="*" element={<Navigate to={'/404'} replace={true} />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
import type { PackingSlipEntity } from '../entities';
|
||||
|
||||
export const packingSlipsModuleConfig: ModuleConfigEntity<PackingSlipEntity> = {
|
||||
moduleKey: 'SALES.PACKING_SLIP',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
||||
translationNamespace: 'PACKING_SLIPS',
|
||||
apiUrl: '/packing-slips',
|
||||
webUrl: '/app/logistics/packing-slips',
|
||||
|
||||
+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",
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('PlansRemoteDataServices', () => {
|
||||
httpClient,
|
||||
{
|
||||
apiUrl: '/plans',
|
||||
moduleKey: 'SALES.PLAN',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.PLAN',
|
||||
transformer: new PlansRemoteDataTransformer('sales'),
|
||||
},
|
||||
'sales',
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { PlanEntity } from '../entities';
|
||||
|
||||
export function createPlanModuleConfig(purpose: FieldPurpose): ModuleConfigEntity<PlanEntity> {
|
||||
return {
|
||||
moduleKey: purpose === 'sales' ? 'SALES.PLAN' : 'LOGISTICS.PLAN',
|
||||
moduleKey: purpose === 'sales' ? 'ADMIN.SALES.ACTIVITIES.PLAN' : 'ADMIN.LOGISTICS.ACTIVITIES.PLAN',
|
||||
translationNamespace: 'PLANS',
|
||||
apiUrl: '/plans',
|
||||
webUrl: `/app/${purpose}/plans`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { RouteGeometry } from '../../../cycles/domain/entities';
|
||||
export interface PlanDestinationEntity {
|
||||
id?: string;
|
||||
customerId: string;
|
||||
customer?: RelationRef | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { loadPlanDocumentOptions } from './load-plan-document-options';
|
||||
|
||||
describe('loadPlanDocumentOptions', () => {
|
||||
it('returns no options until customers are selected', async () => {
|
||||
const getMany = vi.fn();
|
||||
const load = loadPlanDocumentOptions(getMany, []);
|
||||
await expect(load('', 1, [])).resolves.toEqual({ options: [], hasMore: false });
|
||||
expect(getMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requests documents for the selected customers', async () => {
|
||||
const getMany = vi.fn().mockResolvedValue({
|
||||
data: { data: [{ id: 'inv-1' }], meta: { totalPages: 1 } },
|
||||
});
|
||||
const load = loadPlanDocumentOptions(getMany, ['cus-1', 'cus-2']);
|
||||
const result = await load('INV', 1, []);
|
||||
expect(getMany).toHaveBeenCalledWith({
|
||||
params: { search: 'INV', page: 1, limit: 20, customerIds: 'cus-1,cus-2' },
|
||||
});
|
||||
expect(result).toEqual({ options: [{ id: 'inv-1' }], hasMore: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
import { createOptionLoader } from '../../shared/create-option-loader';
|
||||
|
||||
export function loadPlanDocumentOptions<T>(
|
||||
getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>,
|
||||
customerIds: string[],
|
||||
): LoadOptionsFn<T> {
|
||||
if (customerIds.length === 0) {
|
||||
return async () => ({ options: [], hasMore: false });
|
||||
}
|
||||
return createOptionLoader<T>(getMany, { customerIds: customerIds.join(',') });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calendarDateToday, isCalendarDateBefore } from './plan-date';
|
||||
|
||||
describe('calendarDateToday', () => {
|
||||
it('formats a local calendar date as YYYY-MM-DD', () => {
|
||||
expect(calendarDateToday(new Date(2026, 8, 1, 22, 15))).toBe('2026-09-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCalendarDateBefore', () => {
|
||||
it('compares ISO calendar dates lexicographically', () => {
|
||||
expect(isCalendarDateBefore('2026-08-31', '2026-09-01')).toBe(true);
|
||||
expect(isCalendarDateBefore('2026-09-01', '2026-09-01')).toBe(false);
|
||||
expect(isCalendarDateBefore('2026-09-02', '2026-09-01')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export function calendarDateToday(now = new Date()): string {
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function isCalendarDateBefore(date: string, minDate: string): boolean {
|
||||
return date < minDate;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
documentCustomerId,
|
||||
documentsBelongToCustomers,
|
||||
keepDocumentsForCustomers,
|
||||
needsDocumentHydration,
|
||||
} from './plan-documents';
|
||||
|
||||
describe('documentCustomerId', () => {
|
||||
it('prefers customerId then nested customer.id', () => {
|
||||
expect(documentCustomerId({ customerId: 'cus-1', customer: { id: 'cus-2' } })).toBe('cus-1');
|
||||
expect(documentCustomerId({ customer: { id: 'cus-2' } })).toBe('cus-2');
|
||||
expect(documentCustomerId({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('documentsBelongToCustomers', () => {
|
||||
it('allows stubs without a customer and rejects other customers', () => {
|
||||
expect(documentsBelongToCustomers([{ id: 'inv-1' } as never, { customerId: 'cus-1' }], ['cus-1'])).toBe(true);
|
||||
expect(documentsBelongToCustomers([{ customerId: 'cus-2' }], ['cus-1'])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepDocumentsForCustomers', () => {
|
||||
it('drops documents whose customer is not selected and keeps stubs without a customer', () => {
|
||||
expect(
|
||||
keepDocumentsForCustomers(
|
||||
[{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-2', customerId: 'cus-2' }, { id: 'inv-3' }],
|
||||
['cus-1'],
|
||||
),
|
||||
).toEqual([{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-3' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsDocumentHydration', () => {
|
||||
it('is true when only an id is present', () => {
|
||||
expect(needsDocumentHydration({})).toBe(true);
|
||||
expect(needsDocumentHydration({ id: 'inv-1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when a customer is already present', () => {
|
||||
expect(needsDocumentHydration({ customerId: 'cus-1' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
export type PlanDocumentCustomer = {
|
||||
id?: string | number;
|
||||
code?: string | null;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type PlanDocumentRef = {
|
||||
id?: string | number;
|
||||
customerId?: string | null;
|
||||
customer?: PlanDocumentCustomer | null;
|
||||
};
|
||||
|
||||
export function documentCustomerId(doc: PlanDocumentRef): string {
|
||||
if (doc.customerId) return String(doc.customerId);
|
||||
if (doc.customer?.id != null && doc.customer.id !== '') return String(doc.customer.id);
|
||||
return '';
|
||||
}
|
||||
|
||||
export function documentsBelongToCustomers(
|
||||
documents: ReadonlyArray<PlanDocumentRef> | undefined,
|
||||
customerIds: Array<string | undefined>,
|
||||
): boolean {
|
||||
const allowed = new Set(customerIds.filter((id): id is string => Boolean(id)));
|
||||
return (documents ?? []).every((doc) => {
|
||||
const customerId = documentCustomerId(doc);
|
||||
return !customerId || allowed.has(customerId);
|
||||
});
|
||||
}
|
||||
|
||||
export function keepDocumentsForCustomers<T extends PlanDocumentRef>(
|
||||
documents: T[] | undefined,
|
||||
customerIds: Array<string | undefined>,
|
||||
): T[] {
|
||||
const allowed = new Set(customerIds.filter((id): id is string => Boolean(id)));
|
||||
return (documents ?? []).filter((doc) => {
|
||||
const customerId = documentCustomerId(doc);
|
||||
return !customerId || allowed.has(customerId);
|
||||
});
|
||||
}
|
||||
|
||||
export function needsDocumentHydration(doc: PlanDocumentRef): boolean {
|
||||
return !documentCustomerId(doc);
|
||||
}
|
||||
|
||||
export function documentIdsKey(documents: Array<{ id?: string | number }> | undefined): string {
|
||||
return (documents ?? [])
|
||||
.map((item) => (item.id == null ? '' : String(item.id)))
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
}
|
||||
+47
@@ -29,6 +29,39 @@ describe('PlansRemoteDataTransformer', () => {
|
||||
expect(entity.customers).toEqual([{ id: 'cus-1' }]);
|
||||
});
|
||||
|
||||
it('maps nested destination.customer onto customers for the edit form', () => {
|
||||
const entity = salesTransformer.transformToEntity({
|
||||
id: 'plan-1',
|
||||
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
purpose: 'sales',
|
||||
date: Date.UTC(2026, 0, 12),
|
||||
startBranch: { id: 'br-1', code: 'BDG', name: 'Bandung' },
|
||||
endBranch: { id: 'br-2', code: 'BDG', name: 'Bandung' },
|
||||
destinations: [
|
||||
{
|
||||
id: 'd-1',
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme Corp' },
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
invoices: [{ id: 'inv-1', code: 'SI-001' }],
|
||||
packingSlips: [],
|
||||
status: 'active',
|
||||
} as any);
|
||||
|
||||
expect(entity.customers).toEqual([{ id: 'cus-1', code: 'C1', name: 'Acme Corp' }]);
|
||||
expect(entity.destinations).toEqual([
|
||||
{
|
||||
id: 'd-1',
|
||||
customerId: 'cus-1',
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme Corp' },
|
||||
sortOrder: 0,
|
||||
},
|
||||
]);
|
||||
expect(entity.invoices).toEqual([{ id: 'inv-1', code: 'SI-001' }]);
|
||||
expect(entity.invoiceIds).toEqual(['inv-1']);
|
||||
});
|
||||
|
||||
it('injects purpose and sales invoice attachments on create', () => {
|
||||
const payload = salesTransformer.transformCreatePayload({
|
||||
employee: { id: 'emp-1' },
|
||||
@@ -63,4 +96,18 @@ describe('PlansRemoteDataTransformer', () => {
|
||||
expect(payload.packingSlipIds).toEqual(['ps-1']);
|
||||
expect(payload).not.toHaveProperty('invoiceIds');
|
||||
});
|
||||
|
||||
it('does not resurrect invoices when the form list is emptied', () => {
|
||||
const payload = salesTransformer.transformEditPayload({
|
||||
employee: { id: 'emp-1' },
|
||||
date: '2026-01-12',
|
||||
startBranch: { id: 'br-1' },
|
||||
endBranch: { id: 'br-2' },
|
||||
customers: [{ id: 'cus-1' }],
|
||||
invoices: [],
|
||||
invoiceIds: ['inv-1'],
|
||||
} as any);
|
||||
|
||||
expect(payload.invoiceIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+53
-9
@@ -2,7 +2,8 @@ import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { formatDateValue, parseDateValue } from '@repo/ui/form';
|
||||
import { omitEmptyFields } from '../../../../../../../core/domain/configuration-field-validators';
|
||||
import type { FieldPurpose } from '../../../../../../../core/domain/field-purpose';
|
||||
import type { PlanEntity } from '../entities';
|
||||
import type { RelationRef } from '../../../../../../../core/domain/relation-ref';
|
||||
import type { PlanDestinationEntity, PlanEntity } from '../entities';
|
||||
|
||||
function relationId(value: unknown): string | undefined {
|
||||
if (value && typeof value === 'object' && 'id' in value) {
|
||||
@@ -17,12 +18,55 @@ function relationIds(value: unknown): string[] {
|
||||
return value.map((item) => relationId(item)).filter((id): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
function relationRef(value: unknown): RelationRef | undefined {
|
||||
const id = relationId(value);
|
||||
if (!id || !value || typeof value !== 'object') return undefined;
|
||||
const row = value as { code?: unknown; name?: unknown };
|
||||
return {
|
||||
id,
|
||||
...(typeof row.code === 'string' ? { code: row.code } : {}),
|
||||
...(typeof row.name === 'string' ? { name: row.name } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function customerFromDestination(destination: unknown): RelationRef | undefined {
|
||||
if (!destination || typeof destination !== 'object') return undefined;
|
||||
const row = destination as { customer?: unknown; customerId?: unknown };
|
||||
if (row.customerId != null && row.customerId !== '') {
|
||||
return relationRef(row.customer) ?? { id: String(row.customerId) };
|
||||
}
|
||||
return relationRef(row.customer);
|
||||
}
|
||||
|
||||
function mapDestinations(destinations: unknown): PlanDestinationEntity[] {
|
||||
if (!Array.isArray(destinations)) return [];
|
||||
return destinations.map((destination, index) => {
|
||||
const row = destination && typeof destination === 'object' ? (destination as Record<string, unknown>) : {};
|
||||
const customer = customerFromDestination(destination);
|
||||
return {
|
||||
id: typeof row.id === 'string' ? row.id : undefined,
|
||||
customerId: customer?.id ?? '',
|
||||
customer: customer ?? null,
|
||||
sortOrder: typeof row.sortOrder === 'number' ? row.sortOrder : index,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity> {
|
||||
constructor(private readonly purpose: FieldPurpose) {
|
||||
super();
|
||||
}
|
||||
|
||||
transformToEntity(dto: PlanEntity): PlanEntity {
|
||||
const destinations = mapDestinations(dto.destinations);
|
||||
const customers =
|
||||
Array.isArray(dto.customers) && dto.customers.length > 0
|
||||
? dto.customers
|
||||
: destinations
|
||||
.map((destination) => destination.customer)
|
||||
.filter((customer): customer is RelationRef => Boolean(customer?.id));
|
||||
const invoices = dto.invoices ?? (dto.invoiceIds ?? []).map((id) => ({ id }));
|
||||
const packingSlips = dto.packingSlips ?? (dto.packingSlipIds ?? []).map((id) => ({ id }));
|
||||
return {
|
||||
id: dto.id,
|
||||
employeeId: dto.employeeId,
|
||||
@@ -34,12 +78,12 @@ export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity>
|
||||
endBranchId: dto.endBranchId,
|
||||
endBranch: dto.endBranch ?? (dto.endBranchId ? { id: dto.endBranchId } : null),
|
||||
routeGeometry: dto.routeGeometry ?? null,
|
||||
destinations: dto.destinations ?? [],
|
||||
customers: dto.customers ?? (dto.destinations ?? []).map((destination) => ({ id: destination.customerId })),
|
||||
invoiceIds: dto.invoiceIds ?? [],
|
||||
invoices: dto.invoices ?? (dto.invoiceIds ?? []).map((id) => ({ id })),
|
||||
packingSlipIds: dto.packingSlipIds ?? [],
|
||||
packingSlips: dto.packingSlips ?? (dto.packingSlipIds ?? []).map((id) => ({ id })),
|
||||
destinations,
|
||||
customers,
|
||||
invoiceIds: dto.invoiceIds ?? relationIds(invoices),
|
||||
invoices,
|
||||
packingSlipIds: dto.packingSlipIds ?? relationIds(packingSlips),
|
||||
packingSlips,
|
||||
status: dto.status,
|
||||
createdAt: dto.createdAt,
|
||||
updatedAt: dto.updatedAt,
|
||||
@@ -63,9 +107,9 @@ export class PlansRemoteDataTransformer extends BaseDataTransformer<PlanEntity>
|
||||
customerIds,
|
||||
};
|
||||
if (this.purpose === 'sales') {
|
||||
payload.invoiceIds = relationIds(entity.invoices).length ? relationIds(entity.invoices) : entity.invoiceIds;
|
||||
payload.invoiceIds = Array.isArray(entity.invoices) ? relationIds(entity.invoices) : entity.invoiceIds;
|
||||
} else {
|
||||
payload.packingSlipIds = relationIds(entity.packingSlips).length
|
||||
payload.packingSlipIds = Array.isArray(entity.packingSlips)
|
||||
? relationIds(entity.packingSlips)
|
||||
: entity.packingSlipIds;
|
||||
}
|
||||
|
||||
+58
-3
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createPlanSchema } from './plan.validator';
|
||||
import { createGeneratePlansSchema, createPlanSchema } from './plan.validator';
|
||||
|
||||
describe('createPlanSchema', () => {
|
||||
const t = (key: string) => key;
|
||||
const schema = createPlanSchema(t);
|
||||
const schema = createPlanSchema(t, { today: '2026-09-01' });
|
||||
const valid = {
|
||||
employee: { id: 'emp-1' },
|
||||
date: '2026-01-12',
|
||||
date: '2026-09-01',
|
||||
startBranch: { id: 'br-1' },
|
||||
endBranch: { id: 'br-2' },
|
||||
customers: [{ id: 'cus-1' }],
|
||||
@@ -20,7 +20,62 @@ describe('createPlanSchema', () => {
|
||||
expect(schema.safeParse({ ...valid, date: '' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a date before today', () => {
|
||||
expect(schema.safeParse({ ...valid, date: '2026-08-31' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a past date when editing an existing plan', () => {
|
||||
const editSchema = createPlanSchema(t, { today: '2026-09-01', allowPast: true });
|
||||
expect(editSchema.safeParse({ ...valid, date: '2026-08-31' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty customers', () => {
|
||||
expect(schema.safeParse({ ...valid, customers: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invoices that belong to other customers', () => {
|
||||
expect(
|
||||
schema.safeParse({
|
||||
...valid,
|
||||
invoices: [{ id: 'inv-1', customerId: 'cus-2' }],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps invoices for selected customers', () => {
|
||||
expect(
|
||||
schema.safeParse({
|
||||
...valid,
|
||||
invoices: [{ id: 'inv-1', customerId: 'cus-1', customer: { id: 'cus-1' } }],
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps customer location fields for the form preview', () => {
|
||||
const result = schema.safeParse({
|
||||
...valid,
|
||||
customers: [{ id: 'cus-1', name: 'Acme', address: 'Jl Sudirman', latitude: -6.2, longitude: 106.8 }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.data.customers?.[0]).toMatchObject({
|
||||
address: 'Jl Sudirman',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGeneratePlansSchema', () => {
|
||||
const t = (key: string) => key;
|
||||
const schema = createGeneratePlansSchema(t, { today: '2026-09-01' });
|
||||
const valid = { employee: { id: 'emp-1' }, from: '2026-09-01', to: '2026-09-08' };
|
||||
|
||||
it('accepts a range starting today', () => {
|
||||
expect(schema.safeParse(valid).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a from date before today', () => {
|
||||
expect(schema.safeParse({ ...valid, from: '2026-08-31' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { z } from 'zod';
|
||||
const relationSchema = z.object({
|
||||
id: z.string(),
|
||||
code: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
import { calendarDateToday, isCalendarDateBefore } from '../plan-date';
|
||||
import { documentsBelongToCustomers, type PlanDocumentRef } from '../plan-documents';
|
||||
|
||||
export const createPlanSchema = (t: (key: string) => string) => {
|
||||
const relationSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
code: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type PlanSchemaOptions = {
|
||||
today?: string;
|
||||
allowPast?: boolean;
|
||||
};
|
||||
|
||||
function requiredIssue(t: (key: string) => string, fieldKey: string) {
|
||||
return JSON.stringify({ key: 'validation:required', values: { field: t(fieldKey) } });
|
||||
}
|
||||
|
||||
function notPastIssue(t: (key: string) => string, fieldKey: string) {
|
||||
return JSON.stringify({ key: 'validation:not_past', values: { field: t(fieldKey) } });
|
||||
}
|
||||
|
||||
export const createPlanSchema = (t: (key: string) => string, options?: PlanSchemaOptions) => {
|
||||
const today = options?.today ?? calendarDateToday();
|
||||
return z
|
||||
.object({
|
||||
employee: relationSchema.nullable().optional(),
|
||||
@@ -21,34 +40,63 @@ export const createPlanSchema = (t: (key: string) => string) => {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['employee'],
|
||||
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.employee') } }),
|
||||
message: requiredIssue(t, 'common:fields.employee'),
|
||||
});
|
||||
}
|
||||
if (value.date && isCalendarDateBefore(value.date, today) && !options?.allowPast) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['date'],
|
||||
message: notPastIssue(t, 'common:fields.date'),
|
||||
});
|
||||
}
|
||||
if (!value.startBranch?.id) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['startBranch'],
|
||||
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.startBranch') } }),
|
||||
message: requiredIssue(t, 'common:fields.startBranch'),
|
||||
});
|
||||
}
|
||||
if (!value.endBranch?.id) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['endBranch'],
|
||||
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.endBranch') } }),
|
||||
message: requiredIssue(t, 'common:fields.endBranch'),
|
||||
});
|
||||
}
|
||||
if (!value.customers?.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['customers'],
|
||||
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.customers') } }),
|
||||
message: requiredIssue(t, 'common:fields.customers'),
|
||||
});
|
||||
}
|
||||
const customerIds = (value.customers ?? []).map((customer) => customer.id);
|
||||
if (!documentsBelongToCustomers(value.invoices as PlanDocumentRef[] | undefined, customerIds)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['invoices'],
|
||||
message: JSON.stringify({
|
||||
key: 'validation:not_in_selection',
|
||||
values: { field: t('common:fields.invoices') },
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (!documentsBelongToCustomers(value.packingSlips as PlanDocumentRef[] | undefined, customerIds)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['packingSlips'],
|
||||
message: JSON.stringify({
|
||||
key: 'validation:not_in_selection',
|
||||
values: { field: t('common:fields.packingSlips') },
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const createGeneratePlansSchema = (t: (key: string) => string) => {
|
||||
export const createGeneratePlansSchema = (t: (key: string) => string, options?: PlanSchemaOptions) => {
|
||||
const today = options?.today ?? calendarDateToday();
|
||||
return z
|
||||
.object({
|
||||
employee: relationSchema.nullable().optional(),
|
||||
@@ -60,7 +108,21 @@ export const createGeneratePlansSchema = (t: (key: string) => string) => {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['employee'],
|
||||
message: JSON.stringify({ key: 'validation:required', values: { field: t('common:fields.employee') } }),
|
||||
message: requiredIssue(t, 'common:fields.employee'),
|
||||
});
|
||||
}
|
||||
if (value.from && isCalendarDateBefore(value.from, today)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['from'],
|
||||
message: notPastIssue(t, 'common:fields.from'),
|
||||
});
|
||||
}
|
||||
if (value.to && isCalendarDateBefore(value.to, today)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['to'],
|
||||
message: notPastIssue(t, 'common:fields.to'),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+8
-17
@@ -26,13 +26,13 @@ import { loadCustomerOptions } from '../../../../shared/load-customer-options';
|
||||
import type { PlansRemoteDataServices } from '../../../data/plan.remote.service';
|
||||
import type { PlanEntity } from '../../../domain/entities';
|
||||
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
|
||||
import { FormDocumentsPreview } from '../form-component/form-documents-preview';
|
||||
|
||||
export function DetailGeneral() {
|
||||
const { detailData, reload } = useDetailPageContext<PlanEntity>();
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const { dataServices } = useEnterpriseModuleDataServiceContext<PlanEntity, PlansRemoteDataServices>();
|
||||
const data = detailData;
|
||||
const attachments = data?.purpose === 'sales' ? data?.invoices : data?.packingSlips;
|
||||
const destinationForm = useForm<{ customer: CustomerEntity | null }>({ defaultValues: { customer: null } });
|
||||
|
||||
const handleAdd = destinationForm.handleSubmit(async (values) => {
|
||||
@@ -92,9 +92,9 @@ export function DetailGeneral() {
|
||||
</Text>
|
||||
<Stack gap="sm" mb="md">
|
||||
{(data?.destinations ?? []).map((destination, index) => (
|
||||
<Group key={destination.id ?? destination.customerId} justify="space-between">
|
||||
<Group key={destination.id ?? `${destination.customerId}-${index}`} justify="space-between">
|
||||
<Text size="sm">
|
||||
{index + 1}. {destination.customerId}
|
||||
{index + 1}. {relationLabel(destination.customer) || destination.customerId}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
@@ -127,20 +127,11 @@ export function DetailGeneral() {
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
{attachments?.length ? (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_attachments')}
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
{attachments.map((item) => (
|
||||
<Text key={item.id} size="sm">
|
||||
{relationLabel(item) || item.id}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
{data?.purpose === 'sales' ? (
|
||||
<FormDocumentsPreview kind="invoice" items={data.invoices ?? []} customers={data.customers} />
|
||||
) : (
|
||||
<FormDocumentsPreview kind="packingSlip" items={data?.packingSlips ?? []} customers={data?.customers} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
+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>
|
||||
);
|
||||
}
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
Box,
|
||||
FieldValue,
|
||||
Paper,
|
||||
RenderCurrency,
|
||||
RenderDate,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
StatusBadge,
|
||||
Table,
|
||||
Text,
|
||||
} from '@repo/ui/components';
|
||||
import { useEnterpriseModuleTranslationContext } from '@repo/ui/foundations';
|
||||
import { formatDecimal, formatRupiah } from '@repo/utils';
|
||||
import { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories';
|
||||
import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories';
|
||||
import { relationLabel } from '../../../../shared/relation-label';
|
||||
import type { SalesInvoiceEntity } from '../../../../../sales/invoices/domain/entities';
|
||||
import type { PackingSlipEntity } from '../../../../packing-slips/domain/entities';
|
||||
import type { SalesDocumentEntity, SalesLineEntity } from '../../../../../sales/shared/sales-document.entity';
|
||||
import { salesLinesTotal, unwrapEntity, groupDocumentsByCustomer } from './plan-form-preview';
|
||||
import { useHydratedRecords } from './use-hydrated-records';
|
||||
|
||||
type PreviewDocument = SalesDocumentEntity & {
|
||||
balance?: string | null;
|
||||
salesOrder?: { id: string; code?: string; name?: string } | null;
|
||||
packingSlip?: { id: string; code?: string; name?: string } | null;
|
||||
};
|
||||
|
||||
async function fetchInvoice(id: string): Promise<SalesInvoiceEntity | null> {
|
||||
return unwrapEntity<SalesInvoiceEntity>(await salesInvoicesDataService.getOne(id));
|
||||
}
|
||||
|
||||
async function fetchPackingSlip(id: string): Promise<PackingSlipEntity | null> {
|
||||
return unwrapEntity<PackingSlipEntity>(await packingSlipsModuleDataService.getOne(id));
|
||||
}
|
||||
|
||||
type PlanDocumentPreviewItem = {
|
||||
id?: string | number;
|
||||
code?: string | null;
|
||||
name?: string;
|
||||
customer?: { id?: string | number; code?: string | null; name?: string } | null;
|
||||
};
|
||||
|
||||
export function FormDocumentsPreview({
|
||||
kind,
|
||||
items,
|
||||
customers = [],
|
||||
}: {
|
||||
kind: 'invoice' | 'packingSlip';
|
||||
items: PlanDocumentPreviewItem[];
|
||||
customers?: Array<{ id?: string | number; code?: string | null; name?: string }>;
|
||||
}) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const { records: hydratedInvoices, pending: invoicesPending } = useHydratedRecords(
|
||||
kind === 'invoice' ? (items as SalesInvoiceEntity[]) : [],
|
||||
fetchInvoice,
|
||||
);
|
||||
const { records: hydratedSlips, pending: slipsPending } = useHydratedRecords(
|
||||
kind === 'packingSlip' ? (items as PackingSlipEntity[]) : [],
|
||||
fetchPackingSlip,
|
||||
);
|
||||
const hydrated = kind === 'invoice' ? hydratedInvoices : hydratedSlips;
|
||||
const pending = kind === 'invoice' ? invoicesPending : slipsPending;
|
||||
const groups = groupDocumentsByCustomer(hydrated, customers);
|
||||
if (hydrated.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{kind === 'invoice' ? t('section_preview_invoices') : t('section_preview_packing_slips')}
|
||||
</Text>
|
||||
<Stack gap="xl">
|
||||
{groups.map((group) => (
|
||||
<Box key={group.customerId || 'unassigned'}>
|
||||
<Text fw={600} mb="md">
|
||||
{group.label || t('unassigned_customer')}
|
||||
</Text>
|
||||
<Stack gap="lg">
|
||||
{group.documents.map((document, index) => (
|
||||
<DocumentPreviewCard
|
||||
key={String(document.id ?? index)}
|
||||
document={document}
|
||||
showBalance={kind === 'invoice'}
|
||||
pending={pending}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentPreviewCard({
|
||||
document,
|
||||
showBalance,
|
||||
pending,
|
||||
}: {
|
||||
document: PreviewDocument;
|
||||
showBalance: boolean;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
const lines = document.products ?? [];
|
||||
const total = salesLinesTotal(lines);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text fw={600} mb="sm">
|
||||
{document.code || relationLabel(document) || document.id}
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" mb="md">
|
||||
<FieldValue
|
||||
label={t('common:fields.date')}
|
||||
value={document.date}
|
||||
render={(val) => <RenderDate value={typeof val === 'string' || typeof val === 'number' ? val : null} />}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.customer')}
|
||||
value={relationLabel(document.customer) || document.customerId}
|
||||
/>
|
||||
<FieldValue
|
||||
label={t('common:fields.status')}
|
||||
value={document.status}
|
||||
render={(val) => <StatusBadge status={String(val ?? '')} />}
|
||||
/>
|
||||
{showBalance ? (
|
||||
<FieldValue
|
||||
label={t('common:fields.balance')}
|
||||
value={document.balance}
|
||||
render={(val) => <RenderCurrency value={val as string | number | null} />}
|
||||
/>
|
||||
) : (
|
||||
<FieldValue
|
||||
label={t('common:fields.salesOrder')}
|
||||
value={relationLabel(document.salesOrder) || document.salesOrder?.id}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
{showBalance && document.salesOrder ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" mb="md">
|
||||
<FieldValue label={t('common:fields.salesOrder')} value={relationLabel(document.salesOrder)} />
|
||||
{document.packingSlip ? (
|
||||
<FieldValue label={t('common:fields.packingSlip')} value={relationLabel(document.packingSlip)} />
|
||||
) : null}
|
||||
</SimpleGrid>
|
||||
) : null}
|
||||
<DocumentProductsTable lines={lines} total={total} pending={pending} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentProductsTable({
|
||||
lines,
|
||||
total,
|
||||
pending,
|
||||
}: {
|
||||
lines: SalesLineEntity[];
|
||||
total: number;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const { t } = useEnterpriseModuleTranslationContext();
|
||||
return (
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('common:fields.product')}</Table.Th>
|
||||
<Table.Th ta="right">{t('common:fields.quantity')}</Table.Th>
|
||||
<Table.Th ta="right">{t('common:fields.price')}</Table.Th>
|
||||
<Table.Th ta="right">{t('common:fields.lineTotal')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{lines.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{pending ? t('preview_loading') : t('empty_products')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : (
|
||||
lines.map((line, index) => {
|
||||
const qty = Number(line.quantity);
|
||||
const price = Number(line.price);
|
||||
const lineTotal = Number.isFinite(qty) && Number.isFinite(price) ? qty * price : 0;
|
||||
return (
|
||||
<Table.Tr key={line.id ?? `${line.productId}-${index}`}>
|
||||
<Table.Td>{relationLabel(line.product) || line.productId}</Table.Td>
|
||||
<Table.Td ta="right">{formatDecimal(line.quantity) || '-'}</Table.Td>
|
||||
<Table.Td ta="right">{line.price ? formatRupiah(line.price) : '-'}</Table.Td>
|
||||
<Table.Td ta="right">{formatRupiah(lineTotal)}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{!pending || lines.length > 0 ? (
|
||||
<Text fw={600} ta="right" mt="md">
|
||||
{t('common:fields.total')}: {formatRupiah(total)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+167
-87
@@ -1,19 +1,43 @@
|
||||
import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Text } from '@repo/ui/components';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Box, FieldAsyncSelect, FieldDatePicker, Paper, SimpleGrid, Stack, Text } from '@repo/ui/components';
|
||||
import {
|
||||
useEnterpriseModuleTranslationContext,
|
||||
useFormPageContext,
|
||||
useEnterpriseModuleConfigContext,
|
||||
} from '@repo/ui/foundations';
|
||||
import { parseDateValue } from '@repo/ui/form';
|
||||
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
|
||||
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
|
||||
import { loadBranchOptions } from '../../../../shared/load-branch-options';
|
||||
import { loadCustomerOptions } from '../../../../shared/load-customer-options';
|
||||
import { loadSalesInvoiceOptions, loadPackingSlipOptions } from '../../../../shared/lookup.factories';
|
||||
import { relationLabel } from '../../../../shared/relation-label';
|
||||
import { salesInvoicesDataService } from '../../../../../sales/invoices/domain/factories';
|
||||
import { packingSlipsModuleDataService } from '../../../../packing-slips/domain/factories';
|
||||
import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
|
||||
import type { BranchEntity } from '../../../../../configuration/branches/domain/entities';
|
||||
import type { CustomerEntity } from '../../../../../configuration/customers/domain/entities';
|
||||
import type { LookupEntity } from '../../../../shared/lookup.entity';
|
||||
import type { SalesInvoiceEntity } from '../../../../../sales/invoices/domain/entities';
|
||||
import type { PackingSlipEntity } from '../../../../packing-slips/domain/entities';
|
||||
import { calendarDateToday } from '../../../domain/plan-date';
|
||||
import {
|
||||
documentCustomerId,
|
||||
documentIdsKey,
|
||||
keepDocumentsForCustomers,
|
||||
needsDocumentHydration,
|
||||
} from '../../../domain/plan-documents';
|
||||
import { loadPlanDocumentOptions } from '../../../domain/load-plan-document-options';
|
||||
import { documentOptionLabel, unwrapEntity } from './plan-form-preview';
|
||||
import { FormCustomersPreview } from './form-customers-preview';
|
||||
import { FormDocumentsPreview } from './form-documents-preview';
|
||||
import { useHydratedRecords } from './use-hydrated-records';
|
||||
|
||||
async function fetchInvoice(id: string): Promise<SalesInvoiceEntity | null> {
|
||||
return unwrapEntity<SalesInvoiceEntity>(await salesInvoicesDataService.getOne(id));
|
||||
}
|
||||
|
||||
async function fetchPackingSlip(id: string): Promise<PackingSlipEntity | null> {
|
||||
return unwrapEntity<PackingSlipEntity>(await packingSlipsModuleDataService.getOne(id));
|
||||
}
|
||||
|
||||
export function FormGeneral() {
|
||||
const { formControl } = useFormPageContext();
|
||||
@@ -21,102 +45,158 @@ export function FormGeneral() {
|
||||
const { config } = useEnterpriseModuleConfigContext();
|
||||
const purpose = purposeFromModuleKey(config.moduleKey);
|
||||
const employee = formControl.watch('employee');
|
||||
const startBranch = formControl.watch('startBranch');
|
||||
const endBranch = formControl.watch('endBranch');
|
||||
const customers = formControl.watch('customers') ?? [];
|
||||
const invoices = formControl.watch('invoices') ?? [];
|
||||
const packingSlips = formControl.watch('packingSlips') ?? [];
|
||||
const startBranch = formControl.watch('startBranch') as BranchEntity | null | undefined;
|
||||
const endBranch = formControl.watch('endBranch') as BranchEntity | null | undefined;
|
||||
const customers = (formControl.watch('customers') ?? []) as CustomerEntity[];
|
||||
const invoices = (formControl.watch('invoices') ?? []) as SalesInvoiceEntity[];
|
||||
const packingSlips = (formControl.watch('packingSlips') ?? []) as PackingSlipEntity[];
|
||||
const customerIds = useMemo(
|
||||
() => customers.map((customer) => String(customer.id ?? '')).filter(Boolean),
|
||||
[customers],
|
||||
);
|
||||
const customerIdsKey = customerIds.join(',');
|
||||
const minDate = parseDateValue(calendarDateToday());
|
||||
const loadInvoiceOptions = useMemo(
|
||||
() =>
|
||||
loadPlanDocumentOptions<SalesInvoiceEntity>((config) => salesInvoicesDataService.getMany(config), customerIds),
|
||||
[customerIdsKey],
|
||||
);
|
||||
const loadSlipOptions = useMemo(
|
||||
() =>
|
||||
loadPlanDocumentOptions<PackingSlipEntity>(
|
||||
(config) => packingSlipsModuleDataService.getMany(config),
|
||||
customerIds,
|
||||
),
|
||||
[customerIdsKey],
|
||||
);
|
||||
const { records: hydratedInvoices } = useHydratedRecords(invoices, fetchInvoice, needsDocumentHydration);
|
||||
const { records: hydratedSlips } = useHydratedRecords(packingSlips, fetchPackingSlip, needsDocumentHydration);
|
||||
const invoiceHydrationKey = hydratedInvoices.map((item) => `${item.id ?? ''}:${documentCustomerId(item)}`).join(',');
|
||||
const slipHydrationKey = hydratedSlips.map((item) => `${item.id ?? ''}:${documentCustomerId(item)}`).join(',');
|
||||
const documentsDisabled = customerIds.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
const nextInvoices = keepDocumentsForCustomers(hydratedInvoices, customerIds);
|
||||
if (documentIdsKey(nextInvoices) !== documentIdsKey(invoices)) {
|
||||
formControl.setValue('invoices', nextInvoices, { shouldDirty: true, shouldValidate: true });
|
||||
}
|
||||
const nextSlips = keepDocumentsForCustomers(hydratedSlips, customerIds);
|
||||
if (documentIdsKey(nextSlips) !== documentIdsKey(packingSlips)) {
|
||||
formControl.setValue('packingSlips', nextSlips, { shouldDirty: true, shouldValidate: true });
|
||||
}
|
||||
}, [customerIdsKey, invoiceHydrationKey, slipHydrationKey]);
|
||||
|
||||
return (
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldAsyncSelect<EmployeeEntity>
|
||||
control={formControl.control}
|
||||
name="employee"
|
||||
label={t('common:fields.employee')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
|
||||
defaultOptions={employee ? [employee] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldDatePicker control={formControl.control} name="date" label={t('common:fields.date')} required />
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={formControl.control}
|
||||
name="startBranch"
|
||||
label={t('common:fields.startBranch')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadBranchOptions}
|
||||
defaultOptions={startBranch ? [startBranch] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={formControl.control}
|
||||
name="endBranch"
|
||||
label={t('common:fields.endBranch')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadBranchOptions}
|
||||
defaultOptions={endBranch ? [endBranch] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Box mt="md">
|
||||
<FieldAsyncSelect<CustomerEntity>
|
||||
control={formControl.control}
|
||||
name="customers"
|
||||
label={t('common:fields.customers')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadCustomerOptions}
|
||||
defaultOptions={customers}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</Box>
|
||||
<Box mt="md">
|
||||
{purpose === 'sales' ? (
|
||||
<FieldAsyncSelect<LookupEntity>
|
||||
<Stack gap="md">
|
||||
<Paper withBorder shadow="sm" radius="md" p="xl">
|
||||
<Text fw={600} mb="md">
|
||||
{t('section_general')}
|
||||
</Text>
|
||||
<Box>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<FieldAsyncSelect<EmployeeEntity>
|
||||
control={formControl.control}
|
||||
name="invoices"
|
||||
label={t('common:fields.invoices')}
|
||||
name="employee"
|
||||
label={t('common:fields.employee')}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadSalesInvoiceOptions}
|
||||
defaultOptions={invoices}
|
||||
loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
|
||||
defaultOptions={employee ? [employee] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
) : (
|
||||
<FieldAsyncSelect<LookupEntity>
|
||||
<FieldDatePicker
|
||||
control={formControl.control}
|
||||
name="packingSlips"
|
||||
label={t('common:fields.packingSlips')}
|
||||
name="date"
|
||||
label={t('common:fields.date')}
|
||||
required
|
||||
minDate={minDate ?? undefined}
|
||||
/>
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={formControl.control}
|
||||
name="startBranch"
|
||||
label={t('common:fields.startBranch')}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadPackingSlipOptions}
|
||||
defaultOptions={packingSlips}
|
||||
loadOptions={loadBranchOptions}
|
||||
defaultOptions={startBranch ? [startBranch] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
)}
|
||||
<FieldAsyncSelect<BranchEntity>
|
||||
control={formControl.control}
|
||||
name="endBranch"
|
||||
label={t('common:fields.endBranch')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
loadOptions={loadBranchOptions}
|
||||
defaultOptions={endBranch ? [endBranch] : []}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<Box mt="md">
|
||||
<FieldAsyncSelect<CustomerEntity>
|
||||
control={formControl.control}
|
||||
name="customers"
|
||||
label={t('common:fields.customers')}
|
||||
valueKey="id"
|
||||
labelKey="name"
|
||||
required
|
||||
searchable
|
||||
multiple
|
||||
loadOptions={loadCustomerOptions}
|
||||
defaultOptions={customers}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
</Box>
|
||||
<Box mt="md">
|
||||
{purpose === 'sales' ? (
|
||||
<FieldAsyncSelect<SalesInvoiceEntity>
|
||||
key={`invoices-${customerIdsKey}`}
|
||||
control={formControl.control}
|
||||
name="invoices"
|
||||
label={t('common:fields.invoices')}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
searchable
|
||||
multiple
|
||||
disabled={documentsDisabled}
|
||||
description={documentsDisabled ? t('select_customers_first') : undefined}
|
||||
loadOptions={loadInvoiceOptions}
|
||||
defaultOptions={invoices}
|
||||
renderLabel={documentOptionLabel}
|
||||
/>
|
||||
) : (
|
||||
<FieldAsyncSelect<PackingSlipEntity>
|
||||
key={`packing-slips-${customerIdsKey}`}
|
||||
control={formControl.control}
|
||||
name="packingSlips"
|
||||
label={t('common:fields.packingSlips')}
|
||||
valueKey="id"
|
||||
labelKey="code"
|
||||
searchable
|
||||
multiple
|
||||
disabled={documentsDisabled}
|
||||
description={documentsDisabled ? t('select_customers_first') : undefined}
|
||||
loadOptions={loadSlipOptions}
|
||||
defaultOptions={packingSlips}
|
||||
renderLabel={documentOptionLabel}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Paper>
|
||||
|
||||
<FormCustomersPreview customers={customers} startBranch={startBranch} endBranch={endBranch} />
|
||||
{purpose === 'sales' ? (
|
||||
<FormDocumentsPreview kind="invoice" items={hydratedInvoices} customers={customers} />
|
||||
) : (
|
||||
<FormDocumentsPreview kind="packingSlip" items={hydratedSlips} customers={customers} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
documentCustomerId,
|
||||
documentOptionLabel,
|
||||
groupDocumentsByCustomer,
|
||||
keepDocumentsForCustomers,
|
||||
mergeHydrated,
|
||||
needsCustomerHydration,
|
||||
salesLinesTotal,
|
||||
selectionIds,
|
||||
toPlanTrackGeometry,
|
||||
unwrapEntity,
|
||||
} from './plan-form-preview';
|
||||
|
||||
describe('selectionIds', () => {
|
||||
it('returns string ids and drops empty values', () => {
|
||||
expect(selectionIds([{ id: 'a' }, { id: 2 }, {}, { id: '' }])).toEqual(['a', '2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeHydrated', () => {
|
||||
it('replaces selected stubs with fetched details by id', () => {
|
||||
const merged = mergeHydrated(
|
||||
[
|
||||
{ id: 'cus-1', name: 'Stub' },
|
||||
{ id: 'cus-2', name: 'Keep' },
|
||||
],
|
||||
{ 'cus-1': { id: 'cus-1', name: 'Acme', address: 'Jl Sudirman' } },
|
||||
);
|
||||
expect(merged).toEqual([
|
||||
{ id: 'cus-1', name: 'Acme', address: 'Jl Sudirman' },
|
||||
{ id: 'cus-2', name: 'Keep' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsCustomerHydration', () => {
|
||||
it('is true when only an id is present', () => {
|
||||
expect(needsCustomerHydration({})).toBe(true);
|
||||
expect(needsCustomerHydration({ latitude: null, longitude: null })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when address, phone, or coordinates are present', () => {
|
||||
expect(needsCustomerHydration({ address: 'Jl Sudirman' })).toBe(false);
|
||||
expect(needsCustomerHydration({ phone: '+62811' })).toBe(false);
|
||||
expect(needsCustomerHydration({ latitude: -6.2, longitude: 106.8 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwrapEntity', () => {
|
||||
it('reads nested data envelopes from getOne', () => {
|
||||
expect(unwrapEntity({ data: { data: { id: 'inv-1' } } })).toEqual({ id: 'inv-1' });
|
||||
});
|
||||
|
||||
it('returns null when the envelope is empty', () => {
|
||||
expect(unwrapEntity({ data: { data: undefined } })).toBeNull();
|
||||
expect(unwrapEntity(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('salesLinesTotal', () => {
|
||||
it('sums quantity times price and ignores invalid lines', () => {
|
||||
expect(
|
||||
salesLinesTotal([
|
||||
{ quantity: '2', price: '1000' },
|
||||
{ quantity: '1', price: '500.5' },
|
||||
{ quantity: 'x', price: '10' },
|
||||
]),
|
||||
).toBe(2500.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPlanTrackGeometry', () => {
|
||||
it('builds a LineString from start branch, customers, and end branch', () => {
|
||||
expect(
|
||||
toPlanTrackGeometry(
|
||||
{ latitude: -6.1, longitude: 106.7 },
|
||||
[
|
||||
{ latitude: -6.2, longitude: 106.8 },
|
||||
{ latitude: -6.3, longitude: 106.9 },
|
||||
],
|
||||
{ latitude: -6.4, longitude: 107.0 },
|
||||
),
|
||||
).toEqual({
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[106.7, -6.1],
|
||||
[106.8, -6.2],
|
||||
[106.9, -6.3],
|
||||
[107.0, -6.4],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('skips missing coordinates and returns null when nothing is plottable', () => {
|
||||
expect(toPlanTrackGeometry(null, [{ latitude: null, longitude: null }], undefined)).toBeNull();
|
||||
expect(toPlanTrackGeometry(null, [{ latitude: -6.2, longitude: 106.8 }])).toEqual({
|
||||
type: 'LineString',
|
||||
coordinates: [[106.8, -6.2]],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('documentOptionLabel', () => {
|
||||
it('joins document code with customer when both exist', () => {
|
||||
expect(
|
||||
documentOptionLabel({
|
||||
id: 'inv-1',
|
||||
code: 'INV-1',
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||
}),
|
||||
).toBe('INV-1 · C1 - Acme');
|
||||
});
|
||||
});
|
||||
|
||||
describe('documentCustomerId', () => {
|
||||
it('prefers customerId then nested customer.id', () => {
|
||||
expect(documentCustomerId({ customerId: 'cus-1', customer: { id: 'cus-2' } })).toBe('cus-1');
|
||||
expect(documentCustomerId({ customer: { id: 'cus-2' } })).toBe('cus-2');
|
||||
expect(documentCustomerId({})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('keepDocumentsForCustomers', () => {
|
||||
it('drops documents whose customer is not selected and keeps stubs without a customer', () => {
|
||||
expect(
|
||||
keepDocumentsForCustomers(
|
||||
[{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-2', customerId: 'cus-2' }, { id: 'inv-3' }],
|
||||
['cus-1'],
|
||||
),
|
||||
).toEqual([{ id: 'inv-1', customerId: 'cus-1' }, { id: 'inv-3' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupDocumentsByCustomer', () => {
|
||||
it('groups documents in customer order and keeps unknown customers last', () => {
|
||||
expect(
|
||||
groupDocumentsByCustomer(
|
||||
[
|
||||
{ id: 'inv-2', customerId: 'cus-2', customer: { id: 'cus-2', code: 'C2', name: 'Beta' } },
|
||||
{ id: 'inv-1', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
|
||||
{ id: 'inv-3', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
|
||||
{ id: 'inv-4' },
|
||||
],
|
||||
[
|
||||
{ id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||
{ id: 'cus-2', code: 'C2', name: 'Beta' },
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
customerId: 'cus-1',
|
||||
label: 'C1 - Acme',
|
||||
documents: [
|
||||
{ id: 'inv-1', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
|
||||
{ id: 'inv-3', customerId: 'cus-1', customer: { id: 'cus-1', code: 'C1', name: 'Acme' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
customerId: 'cus-2',
|
||||
label: 'C2 - Beta',
|
||||
documents: [{ id: 'inv-2', customerId: 'cus-2', customer: { id: 'cus-2', code: 'C2', name: 'Beta' } }],
|
||||
},
|
||||
{
|
||||
customerId: '',
|
||||
label: '',
|
||||
documents: [{ id: 'inv-4' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import type { RouteGeometry } from '../../../../cycles/domain/entities';
|
||||
import { documentCustomerId, type PlanDocumentRef } from '../../../domain/plan-documents';
|
||||
import { relationLabel } from '../../../../shared/relation-label';
|
||||
|
||||
export { documentCustomerId, keepDocumentsForCustomers } from '../../../domain/plan-documents';
|
||||
|
||||
export type GeoPoint = {
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
};
|
||||
|
||||
export function selectionIds(selected: Array<{ id?: string | number }> | undefined): string[] {
|
||||
return (selected ?? []).map((item) => (item.id == null ? '' : String(item.id))).filter(Boolean);
|
||||
}
|
||||
|
||||
export function mergeHydrated<T extends { id?: string | number }>(selected: T[], details: Record<string, T>): T[] {
|
||||
return selected.map((item) => {
|
||||
const id = item.id == null ? '' : String(item.id);
|
||||
const hydrated = details[id];
|
||||
return hydrated ? { ...item, ...hydrated } : item;
|
||||
});
|
||||
}
|
||||
|
||||
export function needsCustomerHydration(customer: {
|
||||
address?: string | null;
|
||||
phone?: string | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
}): boolean {
|
||||
const hasLocation =
|
||||
customer.latitude != null &&
|
||||
customer.longitude != null &&
|
||||
Number.isFinite(Number(customer.latitude)) &&
|
||||
Number.isFinite(Number(customer.longitude));
|
||||
return !customer.address && !customer.phone && !hasLocation;
|
||||
}
|
||||
|
||||
export function unwrapEntity<T>(result: { data?: { data?: T } | T } | null | undefined): T | null {
|
||||
if (!result?.data) return null;
|
||||
const payload = result.data;
|
||||
if (typeof payload === 'object' && payload !== null && 'data' in payload) {
|
||||
return (payload as { data?: T }).data ?? null;
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export function salesLinesTotal(lines: Array<{ quantity?: string; price?: string | null }> | undefined): number {
|
||||
return (lines ?? []).reduce((sum, line) => {
|
||||
const qty = Number(line.quantity);
|
||||
const price = Number(line.price);
|
||||
if (!Number.isFinite(qty) || !Number.isFinite(price)) return sum;
|
||||
return sum + qty * price;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function toPlanTrackGeometry(
|
||||
startBranch?: GeoPoint | null,
|
||||
customers: GeoPoint[] = [],
|
||||
endBranch?: GeoPoint | null,
|
||||
): RouteGeometry | null {
|
||||
const points: Array<[number, number]> = [];
|
||||
for (const point of [startBranch, ...customers, endBranch]) {
|
||||
if (point?.latitude == null || point?.longitude == null) continue;
|
||||
const lat = Number(point.latitude);
|
||||
const lng = Number(point.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||
points.push([lng, lat]);
|
||||
}
|
||||
if (points.length === 0) return null;
|
||||
return { type: 'LineString', coordinates: points };
|
||||
}
|
||||
|
||||
export function documentOptionLabel(item: {
|
||||
code?: string | null;
|
||||
name?: string;
|
||||
id?: string | number;
|
||||
customer?: { code?: string | null; name?: string; id?: string | number } | null;
|
||||
}) {
|
||||
const base = relationLabel(item);
|
||||
const customer = relationLabel(item.customer);
|
||||
if (base && customer) return `${base} · ${customer}`;
|
||||
return base || customer;
|
||||
}
|
||||
|
||||
export function groupDocumentsByCustomer<T extends PlanDocumentRef>(
|
||||
documents: T[],
|
||||
customers: Array<{ id?: string | number; code?: string | null; name?: string }> = [],
|
||||
): Array<{ customerId: string; label: string; documents: T[] }> {
|
||||
const buckets = new Map<string, T[]>();
|
||||
for (const document of documents) {
|
||||
const customerId = documentCustomerId(document);
|
||||
buckets.set(customerId, [...(buckets.get(customerId) ?? []), document]);
|
||||
}
|
||||
|
||||
const groups: Array<{ customerId: string; label: string; documents: T[] }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const customer of customers) {
|
||||
const customerId = customer.id == null ? '' : String(customer.id);
|
||||
if (!customerId || seen.has(customerId)) continue;
|
||||
const grouped = buckets.get(customerId);
|
||||
if (!grouped?.length) continue;
|
||||
seen.add(customerId);
|
||||
groups.push({
|
||||
customerId,
|
||||
label: relationLabel(customer) || customerId,
|
||||
documents: grouped,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [customerId, grouped] of buckets) {
|
||||
if (seen.has(customerId) || grouped.length === 0) continue;
|
||||
groups.push({
|
||||
customerId,
|
||||
label: relationLabel(grouped[0]?.customer) || customerId,
|
||||
documents: grouped,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { mergeHydrated, selectionIds } from './plan-form-preview';
|
||||
|
||||
export function useHydratedRecords<T extends { id?: string | number }>(
|
||||
selected: T[] | undefined,
|
||||
fetchOne: (id: string) => Promise<T | null>,
|
||||
shouldFetch: (item: T) => boolean = () => true,
|
||||
): { records: T[]; pending: boolean } {
|
||||
const items = selected ?? [];
|
||||
const ids = selectionIds(items);
|
||||
const idsKey = ids.join(',');
|
||||
const [details, setDetails] = useState<Record<string, T>>({});
|
||||
const [settled, setSettled] = useState<Record<string, true>>({});
|
||||
const itemsRef = useRef(items);
|
||||
const fetchOneRef = useRef(fetchOne);
|
||||
const shouldFetchRef = useRef(shouldFetch);
|
||||
itemsRef.current = items;
|
||||
fetchOneRef.current = fetchOne;
|
||||
shouldFetchRef.current = shouldFetch;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const toLoad = itemsRef.current.filter((item) => {
|
||||
const id = item.id == null ? '' : String(item.id);
|
||||
return Boolean(id) && shouldFetchRef.current(item);
|
||||
});
|
||||
if (toLoad.length === 0) return undefined;
|
||||
|
||||
void Promise.all(
|
||||
toLoad.map(async (item) => {
|
||||
const id = String(item.id);
|
||||
try {
|
||||
return { id, entity: await fetchOneRef.current(id) };
|
||||
} catch {
|
||||
return { id, entity: null };
|
||||
}
|
||||
}),
|
||||
).then((rows) => {
|
||||
if (cancelled) return;
|
||||
setDetails((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const row of rows) {
|
||||
if (row.entity) next[row.id] = row.entity;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSettled((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const row of rows) {
|
||||
next[row.id] = true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [idsKey]);
|
||||
|
||||
const pending = items.some((item) => {
|
||||
const id = item.id == null ? '' : String(item.id);
|
||||
return Boolean(id) && shouldFetch(item) && !details[id] && !settled[id];
|
||||
});
|
||||
|
||||
return { records: mergeHydrated(items, details), pending };
|
||||
}
|
||||
+17
-2
@@ -7,9 +7,11 @@ import {
|
||||
useEnterpriseModuleDataServiceContext,
|
||||
useEnterpriseModuleTranslationContext,
|
||||
} from '@repo/ui/foundations';
|
||||
import { parseDateValue } from '@repo/ui/form';
|
||||
import { purposeFromModuleKey } from '../../../../../../../../core/domain/field-purpose';
|
||||
import { loadLogisticsEmployeeOptions, loadSalesEmployeeOptions } from '../../../../shared/load-employee-options';
|
||||
import { relationLabel } from '../../../../shared/relation-label';
|
||||
import { calendarDateToday } from '../../../domain/plan-date';
|
||||
import { createGeneratePlansSchema } from '../../../domain/validators/plan.validator';
|
||||
import type { PlansRemoteDataServices } from '../../../data/plan.remote.service';
|
||||
import type { EmployeeEntity } from '../../../../../configuration/employees/domain/entities';
|
||||
@@ -22,6 +24,7 @@ export function GeneratePlansModal({ opened, onClose }: { opened: boolean; onClo
|
||||
const { dataServices } = useEnterpriseModuleDataServiceContext<PlanEntity, PlansRemoteDataServices>();
|
||||
const validator = useMemo(() => createGeneratePlansSchema(t), [t]);
|
||||
const form = useForm({ resolver: zodResolver(validator) });
|
||||
const minDate = parseDateValue(calendarDateToday());
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
const result = await dataServices.generate({
|
||||
@@ -55,8 +58,20 @@ export function GeneratePlansModal({ opened, onClose }: { opened: boolean; onClo
|
||||
loadOptions={purpose === 'sales' ? loadSalesEmployeeOptions : loadLogisticsEmployeeOptions}
|
||||
renderLabel={relationLabel}
|
||||
/>
|
||||
<FieldDatePicker control={form.control as any} name="from" label={t('common:fields.from')} required />
|
||||
<FieldDatePicker control={form.control as any} name="to" label={t('common:fields.to')} required />
|
||||
<FieldDatePicker
|
||||
control={form.control as any}
|
||||
name="from"
|
||||
label={t('common:fields.from')}
|
||||
required
|
||||
minDate={minDate ?? undefined}
|
||||
/>
|
||||
<FieldDatePicker
|
||||
control={form.control as any}
|
||||
name="to"
|
||||
label={t('common:fields.to')}
|
||||
required
|
||||
minDate={minDate ?? undefined}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" type="button" onClick={onClose}>
|
||||
{t('common:cancel')}
|
||||
|
||||
@@ -13,13 +13,20 @@
|
||||
"section_route": "Route",
|
||||
"section_destinations": "Destinations",
|
||||
"section_attachments": "Attachments",
|
||||
"section_preview_customers": "Customer preview",
|
||||
"section_preview_track": "Track preview",
|
||||
"section_preview_invoices": "Invoice preview",
|
||||
"section_preview_packing_slips": "Packing slip preview",
|
||||
"generate": "Generate",
|
||||
"generate_title": "Generate plans",
|
||||
"generate_success": "Created {{created}} plan(s), skipped {{skipped}}.",
|
||||
"add_destination": "Add destination",
|
||||
"remove_destination": "Remove destination",
|
||||
"empty_route": "No route geometry",
|
||||
"section_attachments": "Attachments",
|
||||
"empty_products": "No products on this document",
|
||||
"preview_loading": "Loading document details…",
|
||||
"select_customers_first": "Select customers first",
|
||||
"unassigned_customer": "Unassigned",
|
||||
"purpose_sales": "Sales",
|
||||
"purpose_logistics": "Logistics",
|
||||
"status_draft": "Draft",
|
||||
|
||||
@@ -13,13 +13,20 @@
|
||||
"section_route": "Rute",
|
||||
"section_destinations": "Destinasi",
|
||||
"section_attachments": "Lampiran",
|
||||
"section_preview_customers": "Pratinjau pelanggan",
|
||||
"section_preview_track": "Pratinjau rute",
|
||||
"section_preview_invoices": "Pratinjau faktur",
|
||||
"section_preview_packing_slips": "Pratinjau surat jalan",
|
||||
"generate": "Generate",
|
||||
"generate_title": "Generate rencana",
|
||||
"generate_success": "Berhasil membuat {{created}} rencana, {{skipped}} dilewati.",
|
||||
"add_destination": "Tambah destinasi",
|
||||
"remove_destination": "Hapus destinasi",
|
||||
"empty_route": "Tidak ada geometri rute",
|
||||
"section_attachments": "Lampiran",
|
||||
"empty_products": "Tidak ada produk pada dokumen ini",
|
||||
"preview_loading": "Memuat detail dokumen…",
|
||||
"select_customers_first": "Pilih pelanggan terlebih dahulu",
|
||||
"unassigned_customer": "Belum ditetapkan",
|
||||
"purpose_sales": "Penjualan",
|
||||
"purpose_logistics": "Logistik",
|
||||
"status_draft": "Draft",
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function PlanPageForm({ formPageType }: { formPageType: FormPageT
|
||||
return { title: '', description: '' };
|
||||
}, [formPageType, t]);
|
||||
|
||||
const validator = useMemo(() => createPlanSchema(t), [t]);
|
||||
const validator = useMemo(() => createPlanSchema(t, { allowPast: formPageType === 'EDIT' }), [t, formPageType]);
|
||||
const formControl = useForm({ resolver: zodResolver(validator) });
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createOptionLoader } from './create-option-loader';
|
||||
|
||||
describe('createOptionLoader', () => {
|
||||
it('requests the first page with search and limit', async () => {
|
||||
const getMany = vi.fn().mockResolvedValue({
|
||||
data: { data: [{ id: 'a' }], meta: { totalPages: 1 } },
|
||||
});
|
||||
|
||||
const load = createOptionLoader(getMany);
|
||||
const result = await load('ada', 1, []);
|
||||
|
||||
expect(getMany).toHaveBeenCalledWith({
|
||||
params: { search: 'ada', page: 1, limit: 20 },
|
||||
});
|
||||
expect(result).toEqual({ options: [{ id: 'a' }], hasMore: false });
|
||||
});
|
||||
|
||||
it('merges extra params so lookups can require active status', async () => {
|
||||
const getMany = vi.fn().mockResolvedValue({
|
||||
data: { data: [{ id: 'a', status: 'active' }], meta: { totalPages: 3 } },
|
||||
});
|
||||
|
||||
const load = createOptionLoader(getMany, { status: 'active' });
|
||||
const result = await load('', 2, []);
|
||||
|
||||
expect(getMany).toHaveBeenCalledWith({
|
||||
params: { search: '', page: 2, limit: 20, status: 'active' },
|
||||
});
|
||||
expect(result.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { LoadOptionsFn } from '@repo/ui/form';
|
||||
|
||||
export const ACTIVE_LOOKUP_PARAMS = { status: 'active' } as const;
|
||||
|
||||
export function createOptionLoader<T>(
|
||||
getMany: (config: { params: Record<string, unknown> }) => Promise<{ data?: unknown }>,
|
||||
extraParams?: Record<string, unknown>,
|
||||
): LoadOptionsFn<T> {
|
||||
return async (search, page) => {
|
||||
const result = await getMany({
|
||||
params: { search, page, limit: 20 },
|
||||
params: { search, page, limit: 20, ...extraParams },
|
||||
});
|
||||
const rows = (result.data as { data?: T[]; meta?: { totalPages?: number } })?.data ?? [];
|
||||
const totalPages = (result.data as { meta?: { totalPages?: number } })?.meta?.totalPages ?? 1;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { branchesDataService } from '../../configuration/branches/domain/factories';
|
||||
import type { BranchEntity } from '../../configuration/branches/domain/entities';
|
||||
import { createOptionLoader } from './create-option-loader';
|
||||
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
|
||||
|
||||
export const loadBranchOptions = createOptionLoader<BranchEntity>((config) => branchesDataService.getMany(config));
|
||||
export const loadBranchOptions = createOptionLoader<BranchEntity>(
|
||||
(config) => branchesDataService.getMany(config),
|
||||
ACTIVE_LOOKUP_PARAMS,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { customersDataService } from '../../configuration/customers/domain/factories';
|
||||
import type { CustomerEntity } from '../../configuration/customers/domain/entities';
|
||||
import { createOptionLoader } from './create-option-loader';
|
||||
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
|
||||
|
||||
export const loadCustomerOptions = createOptionLoader<CustomerEntity>((config) => customersDataService.getMany(config));
|
||||
export const loadCustomerOptions = createOptionLoader<CustomerEntity>(
|
||||
(config) => customersDataService.getMany(config),
|
||||
ACTIVE_LOOKUP_PARAMS,
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
salesEmployeesDataService,
|
||||
} from '../../configuration/employees/domain/factories';
|
||||
import type { EmployeeEntity } from '../../configuration/employees/domain/entities';
|
||||
import { createOptionLoader } from './create-option-loader';
|
||||
import { ACTIVE_LOOKUP_PARAMS, createOptionLoader } from './create-option-loader';
|
||||
|
||||
function employeeServiceForPurpose(purpose?: FieldPurpose) {
|
||||
if (purpose === 'sales') {
|
||||
@@ -20,7 +20,7 @@ function employeeServiceForPurpose(purpose?: FieldPurpose) {
|
||||
|
||||
export function loadEmployeeOptionsForPurpose(purpose?: FieldPurpose): LoadOptionsFn<EmployeeEntity> {
|
||||
const service = employeeServiceForPurpose(purpose);
|
||||
return createOptionLoader<EmployeeEntity>((config) => service.getMany(config));
|
||||
return createOptionLoader<EmployeeEntity>((config) => service.getMany(config), ACTIVE_LOOKUP_PARAMS);
|
||||
}
|
||||
|
||||
export const loadSalesEmployeeOptions = loadEmployeeOptionsForPurpose('sales');
|
||||
|
||||
@@ -5,12 +5,12 @@ import type { LookupEntity } from './lookup.entity';
|
||||
|
||||
export const salesInvoicesDataService = new LookupRemoteDataServices(apiClient, {
|
||||
apiUrl: '/sales-invoices',
|
||||
moduleKey: 'SALES.INVOICE',
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.INVOICE',
|
||||
});
|
||||
|
||||
export const packingSlipsDataService = new LookupRemoteDataServices(apiClient, {
|
||||
apiUrl: '/packing-slips',
|
||||
moduleKey: 'SALES.PACKING_SLIP',
|
||||
moduleKey: 'ADMIN.LOGISTICS.ACTIVITIES.PACKING_SLIP',
|
||||
});
|
||||
|
||||
export const loadSalesInvoiceOptions = createOptionLoader<LookupEntity>((config) =>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AxiosInstance } from '@repo/core-api/http-client';
|
||||
import type { TimelineDayEntity } from '../domain/entities/timeline.entity';
|
||||
|
||||
export type TimelineQuery = {
|
||||
date?: string;
|
||||
employeeId?: string;
|
||||
};
|
||||
|
||||
export class TimelineRemoteService {
|
||||
constructor(private readonly client: AxiosInstance) {}
|
||||
|
||||
async getDay(query: TimelineQuery = {}): Promise<TimelineDayEntity> {
|
||||
const { data } = await this.client.get<TimelineDayEntity>('/timeline', {
|
||||
params: query,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { BaseEntity } from '@repo/core-api/data-services';
|
||||
import type { ModuleConfigEntity } from '@repo/ui/foundations';
|
||||
|
||||
export type TimelineShellEntity = BaseEntity & { id: string };
|
||||
|
||||
export const salesTimelineModuleConfig: ModuleConfigEntity<TimelineShellEntity> = {
|
||||
moduleKey: 'ADMIN.SALES.ACTIVITIES.TIMELINE',
|
||||
translationNamespace: 'SALES_TIMELINE',
|
||||
apiUrl: '/timeline',
|
||||
webUrl: '/app/timeline',
|
||||
moduleCategory: 'SINGLE_PAGE',
|
||||
moduleType: 'TRANSACTION',
|
||||
} as const;
|
||||
@@ -0,0 +1,32 @@
|
||||
export type TimelineRelation = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TimelineFootprintEntity = {
|
||||
id: string;
|
||||
employee: TimelineRelation;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
recordedAt: number;
|
||||
};
|
||||
|
||||
export type TimelineActivityEntity = {
|
||||
id: string;
|
||||
employee: TimelineRelation;
|
||||
customer: TimelineRelation | null;
|
||||
visitId: string | null;
|
||||
type: string;
|
||||
sourceType: string;
|
||||
sourceId: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
recordedAt: number;
|
||||
};
|
||||
|
||||
export type TimelineDayEntity = {
|
||||
date: string;
|
||||
footprints: TimelineFootprintEntity[];
|
||||
activities: TimelineActivityEntity[];
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseDataTransformer } from '@repo/core-api/data-services';
|
||||
import { apiClient } from '../../../../../../../core/lib/api-client';
|
||||
import { TrackGoRemoteDataServices } from '../../../../../../../core/lib/trackgo-remote-data-services';
|
||||
import { salesTimelineModuleConfig, type TimelineShellEntity } from '../constants/timeline.constants';
|
||||
import { TimelineRemoteService } from '../../data/timeline.remote.service';
|
||||
|
||||
class TimelineShellTransformer extends BaseDataTransformer<TimelineShellEntity> {
|
||||
transformToEntity(dto: TimelineShellEntity): TimelineShellEntity {
|
||||
return dto;
|
||||
}
|
||||
|
||||
transformToDTO(entity: TimelineShellEntity): TimelineShellEntity {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
export const salesTimelineDataService = new TrackGoRemoteDataServices(apiClient, {
|
||||
apiUrl: salesTimelineModuleConfig.apiUrl,
|
||||
moduleKey: salesTimelineModuleConfig.moduleKey,
|
||||
transformer: new TimelineShellTransformer(),
|
||||
});
|
||||
|
||||
export const timelineRemoteService = new TimelineRemoteService(apiClient);
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { Avatar, Badge, Box, Card, Group, Stack, Text, Timeline, UnstyledButton } from '@repo/ui/components';
|
||||
import { formatClock, type TimelineActivityGroup } from './timeline-helpers';
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function TimelineActivityList({
|
||||
groups,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
activityLabel,
|
||||
emptyLabel,
|
||||
ungroupedLabel,
|
||||
}: {
|
||||
groups: TimelineActivityGroup[];
|
||||
selectedKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
activityLabel: (type: string) => string;
|
||||
emptyLabel: string;
|
||||
ungroupedLabel: string;
|
||||
}) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed" py="md">
|
||||
{emptyLabel}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{groups.map((group) => {
|
||||
const selected = group.key === selectedKey;
|
||||
const title = group.key === 'ungrouped' ? ungroupedLabel : group.title;
|
||||
const first = group.activities[0];
|
||||
const last = group.activities[group.activities.length - 1];
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={group.key}
|
||||
withBorder
|
||||
padding="md"
|
||||
radius="md"
|
||||
shadow={selected ? 'sm' : undefined}
|
||||
style={{
|
||||
borderColor: selected ? 'var(--mantine-color-blue-filled)' : 'var(--mantine-color-default-border)',
|
||||
borderWidth: selected ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
<UnstyledButton w="100%" onClick={() => onSelect(group.key)}>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" gap="sm">
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Text fw={600} lineClamp={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatClock(group.firstRecordedAt)}
|
||||
{first && last && first.id !== last.id ? ` → ${formatClock(group.lastRecordedAt)}` : ''}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Badge variant="light" color={group.isOnTheWay ? 'blue' : 'teal'} tt="none">
|
||||
{activityLabel(group.lastType)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
{selected ? (
|
||||
<Stack gap="sm" mt="md">
|
||||
<Group gap="sm">
|
||||
<Avatar radius="xl" size="md" color="blue">
|
||||
{initials(group.employeeName)}
|
||||
</Avatar>
|
||||
<Text size="sm" fw={500}>
|
||||
{group.employeeName}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box bg="var(--mantine-color-blue-light)" p="sm" bdrs="md">
|
||||
<Timeline active={group.activities.length - 1} bulletSize={12} lineWidth={2} color="blue">
|
||||
{group.activities.map((item) => (
|
||||
<Timeline.Item key={item.id} title={activityLabel(item.type)}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatClock(item.recordedAt)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
</Box>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Paper, ScrollArea, SegmentedControl, Stack, Text, TextInput } from '@repo/ui/components';
|
||||
import { Search } from 'lucide-react';
|
||||
import { TimelineActivityList } from './timeline-activity-list';
|
||||
import type { TimelineActivityGroup, TimelineActivityTab } from './timeline-helpers';
|
||||
|
||||
export function TimelineActivityPanel({
|
||||
title,
|
||||
search,
|
||||
searchPlaceholder,
|
||||
onSearchChange,
|
||||
tab,
|
||||
onTabChange,
|
||||
onTheWayLabel,
|
||||
completedLabel,
|
||||
filters,
|
||||
groups,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
activityLabel,
|
||||
emptyLabel,
|
||||
ungroupedLabel,
|
||||
errorMessage,
|
||||
}: {
|
||||
title: string;
|
||||
search: string;
|
||||
searchPlaceholder: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
tab: TimelineActivityTab;
|
||||
onTabChange: (value: TimelineActivityTab) => void;
|
||||
onTheWayLabel: string;
|
||||
completedLabel: string;
|
||||
filters: ReactNode;
|
||||
groups: TimelineActivityGroup[];
|
||||
selectedKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
activityLabel: (type: string) => string;
|
||||
emptyLabel: string;
|
||||
ungroupedLabel: string;
|
||||
errorMessage: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius="lg"
|
||||
p="md"
|
||||
h="100%"
|
||||
style={{ display: 'flex', flexDirection: 'column', minHeight: 0 }}
|
||||
>
|
||||
<Stack gap="md" style={{ flex: 1, minHeight: 0 }}>
|
||||
<Text fw={700} size="xl">
|
||||
{title}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.currentTarget.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
leftSection={<Search size={16} />}
|
||||
/>
|
||||
{filters}
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
value={tab}
|
||||
onChange={(value) => onTabChange(value as TimelineActivityTab)}
|
||||
data={[
|
||||
{ label: onTheWayLabel, value: 'on_the_way' },
|
||||
{ label: completedLabel, value: 'completed' },
|
||||
]}
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<Text c="red" size="sm">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
<ScrollArea flex={1} type="scroll" offsetScrollbars>
|
||||
<TimelineActivityList
|
||||
groups={groups}
|
||||
selectedKey={selectedKey}
|
||||
onSelect={onSelect}
|
||||
activityLabel={activityLabel}
|
||||
emptyLabel={emptyLabel}
|
||||
ungroupedLabel={ungroupedLabel}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user