# 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` 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 `` 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 `` 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(); }; // ...
``` **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` → serialized as `String(value)` in the query string - `OptionType[]` → 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[]; // multi-select → serializes as CSV location?: OptionType; // single-select → serializes as value string filterBy?: OptionType; // 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 formik.setFieldValue('customers', Array.isArray(val) ? val : [])} /> formik.setFieldValue('location', 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 ``` 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, AxiosError, 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([]);` 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 ``:** ```tsx
``` `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(`${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 (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 # 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 # 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