mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 21:41:57 +00:00
487 lines
20 KiB
Markdown
487 lines
20 KiB
Markdown
# LTI Web Client
|
|
|
|
Next.js 15 (App Router) + React 19 + TypeScript front-end for the LTI ERP system.
|
|
|
|
## Tech stack
|
|
|
|
- **Framework:** Next.js 15.5 (App Router, Turbopack)
|
|
- **UI:** React 19, Tailwind CSS v4, Radix UI, daisyUI, lucide-react
|
|
- **State:** zustand
|
|
- **Forms:** Formik + Yup, react-hook-form
|
|
- **Data fetching:** axios + SWR (custom `httpClient` / `httpClientFetcher` in `src/services/http`)
|
|
- **Tables:** @tanstack/react-table
|
|
- **Reporting:** @react-pdf/renderer, jspdf, exceljs, xlsx, recharts
|
|
|
|
## Scripts
|
|
|
|
- `npm run dev` — lint + dev server (Turbopack)
|
|
- `npm run build` — production build
|
|
- `npm run lint` — ESLint
|
|
- `npm run typecheck` — `next typegen && tsc --noEmit`
|
|
- `npm run format` — Prettier
|
|
- `npm run pre-commit` — format + lint + typecheck + build (Husky pre-commit hook)
|
|
|
|
## Project structure
|
|
|
|
```
|
|
src/
|
|
app/ # Next.js App Router routes (one folder per feature)
|
|
components/
|
|
pages/{feature}/ # Page-specific components (mirrors src/app)
|
|
helper/ # Cross-cutting helpers (e.g. SuspenseHelper)
|
|
ui/ # Shared UI primitives
|
|
services/
|
|
api/ # API service classes (extend BaseApiService)
|
|
http/ # httpClient / httpClientFetcher
|
|
hooks/ # Service-level hooks
|
|
stores/ # zustand stores grouped by domain
|
|
types/api/ # Request/response types per feature
|
|
lib/ # Shared helpers (api-helper, formik-helper, utils, validation, …)
|
|
config/, styles/
|
|
```
|
|
|
|
## Feature development standard
|
|
|
|
**Always follow this order when adding a new feature.** This is a team convention — deviating creates churn in code review.
|
|
|
|
1. **Types** — Define payload and response types in `src/types/api/{feature}` (or `{feature}.d.ts` for small features).
|
|
2. **API service** — Add `src/services/api/{feature}.ts` exporting a class that extends `BaseApiService<T, CreatePayload, UpdatePayload>` from [src/services/api/base.ts](src/services/api/base.ts). Use a subfolder (e.g. `src/services/api/daily-checklist/`) when the feature has multiple resource classes.
|
|
3. **Page** — Create the route under `src/app/{feature}` and a matching `src/components/pages/{feature}` folder for its components.
|
|
4. **Component slicing** — Break the page UI into components inside `src/components/pages/{feature}`.
|
|
5. **Wire up the API** — Consume the service class from step 2 inside the page/components (often via SWR).
|
|
6. **Detail layout** — When a route reads URL params via `useSearchParams` (e.g. `/feature/detail?id=123`), add `src/app/{feature}/detail/layout.tsx` that wraps `children` in `<SuspenseHelper>` from `@/components/helper/SuspenseHelper`.
|
|
7. **Shared state** — Use zustand stores in `src/stores/{domain}` when state must cross component boundaries.
|
|
8. **Helpers** — Reuse from [src/lib](src/lib) first (`api-helper.ts`, `formik-helper.ts`, `utils/`, `validation/`, etc.). Add new helpers there.
|
|
|
|
### Reference implementations
|
|
|
|
`closing`, `finance`, `expense`, `production`, `inventory`, `marketing`, `master-data`, `purchase`, `report`, `daily-checklist`, `dashboard` — all live in both `src/app/{feature}` and `src/components/pages/{feature}` and follow the standard above.
|
|
|
|
## Conventions
|
|
|
|
- Path alias `@/` maps to `src/`.
|
|
- Detail pages that read `useSearchParams` MUST be wrapped in `<SuspenseHelper>` via a `layout.tsx` (see [src/app/finance/detail/layout.tsx](src/app/finance/detail/layout.tsx) for the canonical pattern).
|
|
- API service classes inherit CRUD methods (`getAll`, `getSingle`, etc.) from `BaseApiService` — extend the class for feature-specific endpoints rather than calling `httpClient` directly from components.
|
|
- Pre-commit runs format + lint + typecheck + build; do not bypass with `--no-verify`.
|
|
|
|
## Table filter persistence pattern
|
|
|
|
Data tables across all modules (master-data, inventory, finance, purchase, etc.) use `useTableFilter` with `persist: true` to persist filter state in localStorage. This allows users' search, pagination, and filter choices to survive page refreshes.
|
|
|
|
**Three core principles (apply to all table components):**
|
|
|
|
1. **Set formik initialValues from tableFilterState** (not hardcoded defaults)
|
|
- Ensures the filter modal displays currently active filters when opened
|
|
- Initialize directly from persisted state: `location: tableFilterState.locationFilter`
|
|
|
|
2. **Pass `true` as last parameter to updateFilter calls**
|
|
- `updateFilter('fieldName', value, true)` immediately persists to localStorage
|
|
- Resets pagination to page 1 when filters change (via SWR revalidation)
|
|
- Apply to: search handlers, filter form submissions, reset handlers
|
|
|
|
3. **Create custom formikResetHandler function**
|
|
- Call `resetFilter()` (single call — resets all `useTableFilter` state to defaults)
|
|
- Reset any local error state (e.g. `setHasDateError(false)`, dismiss toasts)
|
|
- Call `formik.resetForm({ values: { ...defaults } })` to sync formik to defaults
|
|
- Call `filterModal.closeModal()` at the end
|
|
- Attach to form `onReset` handler (not `formik.handleReset`)
|
|
|
|
```tsx
|
|
const formikResetHandler = () => {
|
|
resetFilter();
|
|
setHasDateError(false);
|
|
if (dateErrorShown) { toast.dismiss(); setDateErrorShown(false); }
|
|
formik.resetForm({ values: { start_date: '', end_date: '', customers: [], filterBy: undefined } });
|
|
filterModal.closeModal();
|
|
};
|
|
// ...
|
|
<form onSubmit={formik.handleSubmit} onReset={formikResetHandler}>
|
|
```
|
|
|
|
**Optimization: Avoid useCallback and useMemo for trivial operations**
|
|
|
|
- `useCallback` and `useMemo` add overhead; only use them when the computation is expensive or the result is passed to a memoized child
|
|
- Simple derivations and pass-through handlers don't need them:
|
|
|
|
```tsx
|
|
// ✅ Good: plain derivation
|
|
const data = isResponseSuccess(response) ? (response.data ?? []) : [];
|
|
const meta =
|
|
isResponseSuccess(response) && response.meta ? response.meta : null;
|
|
|
|
// ❌ Avoid: useMemo for trivial conditional access
|
|
const data = useMemo(
|
|
() => (isResponseSuccess(response) ? (response.data ?? []) : []),
|
|
[response]
|
|
);
|
|
|
|
// ✅ Good: simple handler
|
|
const handleChange = (val) => setFieldValue('location', val);
|
|
|
|
// ❌ Avoid: unnecessary useCallback
|
|
const handleChange = useCallback(
|
|
(val) => setFieldValue('location', val),
|
|
[setFieldValue]
|
|
);
|
|
```
|
|
|
|
- `useMemo` IS justified for large column definition arrays (TanStack Table re-processes on every render)
|
|
|
|
**Best practice: Store OptionType objects directly, not IDs**
|
|
|
|
For select inputs, store the complete `OptionType` object (or `OptionType[]` for multi-select) in both formik state and tableFilterState. `useTableFilter`'s `serializeValue` handles serialization automatically:
|
|
|
|
- `OptionType<T>` → serialized as `String(value)` in the query string
|
|
- `OptionType<T>[]` → serialized as comma-separated values (CSV) — ideal for multi-select API params like `customer_ids`, `sales_ids`
|
|
|
|
```tsx
|
|
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
|
|
search: string;
|
|
customers: OptionType<number>[]; // multi-select → serializes as CSV
|
|
location?: OptionType<string>; // single-select → serializes as value string
|
|
filterBy?: OptionType<string>; // single-select radio
|
|
}>({
|
|
initial: {
|
|
search: '',
|
|
customers: [],
|
|
location: undefined,
|
|
filterBy: undefined,
|
|
},
|
|
paramMap: {
|
|
page: 'page',
|
|
pageSize: 'limit',
|
|
customers: 'customer_ids', // serializes OptionType[] → "1,2,3"
|
|
location: 'location_id', // serializes OptionType → "abc"
|
|
filterBy: 'filter_by',
|
|
},
|
|
persist: true,
|
|
storeName: 'my-table',
|
|
});
|
|
|
|
// Initialize formik directly from tableFilterState (no hardcoded defaults)
|
|
const formik = useFormik({
|
|
initialValues: {
|
|
customers: tableFilterState.customers,
|
|
location: tableFilterState.location,
|
|
filterBy: tableFilterState.filterBy,
|
|
},
|
|
...
|
|
});
|
|
|
|
// Use formik values directly — no computed helpers needed
|
|
<SelectInputCheckbox value={formik.values.customers} onChange={(val) => formik.setFieldValue('customers', Array.isArray(val) ? val : [])} />
|
|
<SelectInput value={formik.values.location} onChange={(val) => formik.setFieldValue('location', val)} />
|
|
<SelectInputRadio value={formik.values.filterBy ?? null} onChange={(val) => formik.setFieldValue('filterBy', !Array.isArray(val) ? (val ?? undefined) : undefined)} />
|
|
```
|
|
|
|
**Filter field naming convention**
|
|
|
|
- Multi-select fields: use plural entity name — `customers`, `salesPersons`, `locations`
|
|
- Single-select fields: use descriptive camelCase — `filterBy`, `status`, `category`
|
|
- No `Filter` suffix (e.g. avoid `customerFilter`, `locationFilter`)
|
|
|
|
**Filter modal: pass `openModal` directly, never use `enableReinitialize`**
|
|
|
|
`enableReinitialize: true` resets formik mid-interaction whenever `tableFilterState` changes, breaking the modal UX. Pass `filterModal.openModal` directly to the button — no ref wrapper needed. Formik retains its last state across open/close, which is acceptable UX (values sync with `tableFilterState` on submit and reset anyway).
|
|
|
|
```tsx
|
|
// ❌ Avoid: enableReinitialize breaks modal mid-interaction
|
|
const formik = useFormik({ initialValues: { ... }, enableReinitialize: true });
|
|
|
|
// ❌ Avoid: unnecessary ref indirection
|
|
const handleFilterModalOpenRef = useRef(() => {});
|
|
handleFilterModalOpenRef.current = () => { formik.setValues({...}); filterModal.openModal(); };
|
|
|
|
// ✅ Correct: pass openModal directly
|
|
<ButtonFilter onClick={filterModal.openModal} ... />
|
|
```
|
|
|
|
Include `filterModal.openModal` in the `useEffect` deps array when it's used inside the effect.
|
|
|
|
**Apply this pattern to:**
|
|
|
|
- Any data table component across any module that needs persistent filters
|
|
- Master-data tables, inventory lists, finance reports, purchase orders, etc.
|
|
- Whenever users' filter/search/pagination choices should survive page refreshes
|
|
|
|
**Reference implementations:**
|
|
|
|
- `SupplierTable`, `KandangsTable`, `LocationsTable`, `CustomersTable` in `src/components/pages/master-data/`
|
|
- `BalanceMonitoringTab` in `src/components/pages/report/finance/tab/` — multi-select + radio + date range
|
|
|
|
## SWR fetch pattern
|
|
|
|
Use `FinanceApi.getAllFetcher` (or the relevant service's `getAllFetcher`) when the result type matches the service generic `T`. When it differs, use `httpClientFetcher` with an explicit type:
|
|
|
|
```tsx
|
|
// ✅ Same type as service generic — use getAllFetcher
|
|
const { data } = useSWR(
|
|
`${Api.basePath}${getTableFilterQueryString()}`,
|
|
Api.getAllFetcher
|
|
);
|
|
|
|
// ✅ Different type — use httpClientFetcher with explicit useSWR type
|
|
const { data } = useSWR<
|
|
BaseApiResponse<BalanceMonitoringRow[]>,
|
|
AxiosError<BaseApiResponse>,
|
|
SWRHttpKey
|
|
>(
|
|
`${FinanceApi.basePath}/balance-monitoring${getTableFilterQueryString()}`,
|
|
httpClientFetcher
|
|
);
|
|
```
|
|
|
|
Always name the `toQueryString` alias `getTableFilterQueryString` when destructuring from `useTableFilter`.
|
|
|
|
## Server-side sorting pattern
|
|
|
|
Data tables use TanStack Table's `SortingState` wired to `useTableFilter` so that sorting triggers a server re-fetch rather than client-side reordering.
|
|
|
|
**Four-part wiring:**
|
|
|
|
1. **Local sort state** — `const [sorting, setSorting] = useState<SortingState>([]);`
|
|
|
|
2. **`useTableFilter` config** — Add `sort_by` and `order_by` to `initial` and `paramMap`. The `paramMap` key is the internal name; the value is the query param name sent to the server (they can differ, e.g. `order_by` → `sort_order`):
|
|
|
|
```ts
|
|
initial: { sort_by: '', order_by: '' }
|
|
paramMap: { sort_by: 'sort_by', order_by: 'sort_order' }
|
|
```
|
|
|
|
3. **`useEffect` sync** — Watches `sorting` and pushes changes into `useTableFilter`:
|
|
|
|
```ts
|
|
useEffect(() => {
|
|
if (sorting.length > 0) {
|
|
updateFilter('sort_by', sorting[0].id, true);
|
|
updateFilter('order_by', sorting[0].desc ? 'desc' : 'asc', true);
|
|
} else {
|
|
updateFilter('sort_by', '');
|
|
updateFilter('order_by', '');
|
|
}
|
|
}, [sorting]);
|
|
```
|
|
|
|
4. **SWR key** — SWR uses `getTableFilterToQueryString()` as its key, so any filter change (including sort) automatically re-fetches with the new query params. TanStack Table's built-in client sorting is effectively disabled; the server does the sorting.
|
|
|
|
**Pass `sorting`, `setSorting`, and `manualSorting` to `<Table>`:**
|
|
|
|
```tsx
|
|
<Table sorting={sorting} setSorting={handleSortingChange} manualSorting={true} ... />
|
|
```
|
|
|
|
`manualSorting={true}` is required — without it TanStack Table still applies its own client-side sort pass on top of the server-sorted data, producing incorrect order.
|
|
|
|
**Reference implementation:** `MarketingTable` in [src/components/pages/marketing/MarketingTable.tsx](src/components/pages/marketing/MarketingTable.tsx).
|
|
|
|
## Server-side file export pattern
|
|
|
|
All file exports (Excel, PDF, or any format) must use **server-side generation** — the server returns a binary blob and the browser triggers a download. Never generate files client-side with `xlsx`, `@react-pdf/renderer`, `jspdf`, or similar libraries.
|
|
|
|
**Rule:** Export methods live in the API service class, not in components. Components only build the query string and call the service method.
|
|
|
|
### Service method (in `src/services/api/{feature}.ts`)
|
|
|
|
```ts
|
|
async exportToExcel(initialQueryString: string) {
|
|
const params = new URLSearchParams(initialQueryString);
|
|
|
|
params.set('export', 'excel'); // or 'pdf', 'csv', etc.
|
|
params.set('page', '1');
|
|
params.set('limit', '99999999999');
|
|
|
|
const res = await httpClient<Blob>(`${this.basePath}?${params.toString()}`, {
|
|
method: 'GET',
|
|
responseType: 'blob',
|
|
});
|
|
|
|
const url = window.URL.createObjectURL(new Blob([res]));
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute('download', `filename-${formatDate(Date.now(), 'DD-MM-YYYY')}.xlsx`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
}
|
|
```
|
|
|
|
- Change `export=excel` → `export=pdf` (and the file extension) for PDF exports.
|
|
- Add one method per format; keep them side-by-side in the same service class.
|
|
|
|
### Component handler (in the page/tab component)
|
|
|
|
```ts
|
|
const handleExportExcel = useCallback(async () => {
|
|
setIsExcelExportLoading(true);
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (filterParams.foo) params.set('foo', filterParams.foo);
|
|
// ... map all active filter params ...
|
|
|
|
await FeatureApi.exportToExcel(params.toString());
|
|
toast.success('Excel berhasil dibuat dan diunduh.');
|
|
} catch {
|
|
toast.error('Gagal membuat Excel. Silakan coba lagi.');
|
|
} finally {
|
|
setIsExcelExportLoading(false);
|
|
}
|
|
}, [filterParams, searchValue]);
|
|
```
|
|
|
|
- Do **not** fetch all rows into the component to build the file — delegate entirely to the service method.
|
|
- Do **not** import `xlsx`, `@react-pdf/renderer`, `jspdf`, `exceljs` in page/tab components.
|
|
|
|
**Reference implementation:** `MarketingReportApiService.exportDailyMarketingToExcel` / `exportDailyMarketingToPDF` in [src/services/api/report/marketing-report.ts](src/services/api/report/marketing-report.ts), consumed by [src/components/pages/report/marketing/tab/DailyMarketingTab.tsx](src/components/pages/report/marketing/tab/DailyMarketingTab.tsx).
|
|
|
|
<!-- rtk-instructions v2 -->
|
|
|
|
# RTK (Rust Token Killer) - Token-Optimized Commands
|
|
|
|
## Golden Rule
|
|
|
|
**Always prefix commands with `rtk`**. If RTK has a dedicated filter, it uses it. If not, it passes through unchanged. This means RTK is always safe to use.
|
|
|
|
**Important**: Even in command chains with `&&`, use `rtk`:
|
|
|
|
```bash
|
|
# ❌ Wrong
|
|
git add . && git commit -m "msg" && git push
|
|
|
|
# ✅ Correct
|
|
rtk git add . && rtk git commit -m "msg" && rtk git push
|
|
```
|
|
|
|
## RTK Commands by Workflow
|
|
|
|
### Build & Compile (80-90% savings)
|
|
|
|
```bash
|
|
rtk cargo build # Cargo build output
|
|
rtk cargo check # Cargo check output
|
|
rtk cargo clippy # Clippy warnings grouped by file (80%)
|
|
rtk tsc # TypeScript errors grouped by file/code (83%)
|
|
rtk lint # ESLint/Biome violations grouped (84%)
|
|
rtk prettier --check # Files needing format only (70%)
|
|
rtk next build # Next.js build with route metrics (87%)
|
|
```
|
|
|
|
### Test (60-99% savings)
|
|
|
|
```bash
|
|
rtk cargo test # Cargo test failures only (90%)
|
|
rtk go test # Go test failures only (90%)
|
|
rtk jest # Jest failures only (99.5%)
|
|
rtk vitest # Vitest failures only (99.5%)
|
|
rtk playwright test # Playwright failures only (94%)
|
|
rtk pytest # Python test failures only (90%)
|
|
rtk rake test # Ruby test failures only (90%)
|
|
rtk rspec # RSpec test failures only (60%)
|
|
rtk test <cmd> # Generic test wrapper - failures only
|
|
```
|
|
|
|
### Git (59-80% savings)
|
|
|
|
```bash
|
|
rtk git status # Compact status
|
|
rtk git log # Compact log (works with all git flags)
|
|
rtk git diff # Compact diff (80%)
|
|
rtk git show # Compact show (80%)
|
|
rtk git add # Ultra-compact confirmations (59%)
|
|
rtk git commit # Ultra-compact confirmations (59%)
|
|
rtk git push # Ultra-compact confirmations
|
|
rtk git pull # Ultra-compact confirmations
|
|
rtk git branch # Compact branch list
|
|
rtk git fetch # Compact fetch
|
|
rtk git stash # Compact stash
|
|
rtk git worktree # Compact worktree
|
|
```
|
|
|
|
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
|
|
|
|
### GitHub (26-87% savings)
|
|
|
|
```bash
|
|
rtk gh pr view <num> # Compact PR view (87%)
|
|
rtk gh pr checks # Compact PR checks (79%)
|
|
rtk gh run list # Compact workflow runs (82%)
|
|
rtk gh issue list # Compact issue list (80%)
|
|
rtk gh api # Compact API responses (26%)
|
|
```
|
|
|
|
### JavaScript/TypeScript Tooling (70-90% savings)
|
|
|
|
```bash
|
|
rtk pnpm list # Compact dependency tree (70%)
|
|
rtk pnpm outdated # Compact outdated packages (80%)
|
|
rtk pnpm install # Compact install output (90%)
|
|
rtk npm run <script> # Compact npm script output
|
|
rtk npx <cmd> # Compact npx command output
|
|
rtk prisma # Prisma without ASCII art (88%)
|
|
```
|
|
|
|
### Files & Search (60-75% savings)
|
|
|
|
```bash
|
|
rtk ls <path> # Tree format, compact (65%)
|
|
rtk read <file> # Code reading with filtering (60%)
|
|
rtk grep <pattern> # Search grouped by file (75%)
|
|
rtk find <pattern> # Find grouped by directory (70%)
|
|
```
|
|
|
|
### Analysis & Debug (70-90% savings)
|
|
|
|
```bash
|
|
rtk err <cmd> # Filter errors only from any command
|
|
rtk log <file> # Deduplicated logs with counts
|
|
rtk json <file> # JSON structure without values
|
|
rtk deps # Dependency overview
|
|
rtk env # Environment variables compact
|
|
rtk summary <cmd> # Smart summary of command output
|
|
rtk diff # Ultra-compact diffs
|
|
```
|
|
|
|
### Infrastructure (85% savings)
|
|
|
|
```bash
|
|
rtk docker ps # Compact container list
|
|
rtk docker images # Compact image list
|
|
rtk docker logs <c> # Deduplicated logs
|
|
rtk kubectl get # Compact resource list
|
|
rtk kubectl logs # Deduplicated pod logs
|
|
```
|
|
|
|
### Network (65-70% savings)
|
|
|
|
```bash
|
|
rtk curl <url> # Compact HTTP responses (70%)
|
|
rtk wget <url> # Compact download output (65%)
|
|
```
|
|
|
|
### Meta Commands
|
|
|
|
```bash
|
|
rtk gain # View token savings statistics
|
|
rtk gain --history # View command history with savings
|
|
rtk discover # Analyze Claude Code sessions for missed RTK usage
|
|
rtk proxy <cmd> # Run command without filtering (for debugging)
|
|
rtk init # Add RTK instructions to CLAUDE.md
|
|
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
|
|
```
|
|
|
|
## Token Savings Overview
|
|
|
|
| Category | Commands | Typical Savings |
|
|
| ---------------- | ------------------------------ | --------------- |
|
|
| Tests | vitest, playwright, cargo test | 90-99% |
|
|
| Build | next, tsc, lint, prettier | 70-87% |
|
|
| Git | status, log, diff, add, commit | 59-80% |
|
|
| GitHub | gh pr, gh run, gh issue | 26-87% |
|
|
| Package Managers | pnpm, npm, npx | 70-90% |
|
|
| Files | ls, read, grep, find | 60-75% |
|
|
| Infrastructure | docker, kubectl | 85% |
|
|
| Network | curl, wget | 65-70% |
|
|
|
|
Overall average: **60-90% token reduction** on common development operations.
|
|
|
|
<!-- /rtk-instructions -->
|