20 KiB
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/httpClientFetcherinsrc/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 buildnpm run lint— ESLintnpm run typecheck—next typegen && tsc --noEmitnpm run format— Prettiernpm 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.
- Types — Define payload and response types in
src/types/api/{feature}(or{feature}.d.tsfor small features). - API service — Add
src/services/api/{feature}.tsexporting a class that extendsBaseApiService<T, CreatePayload, UpdatePayload>from src/services/api/base.ts. Use a subfolder (e.g.src/services/api/daily-checklist/) when the feature has multiple resource classes. - Page — Create the route under
src/app/{feature}and a matchingsrc/components/pages/{feature}folder for its components. - Component slicing — Break the page UI into components inside
src/components/pages/{feature}. - Wire up the API — Consume the service class from step 2 inside the page/components (often via SWR).
- Detail layout — When a route reads URL params via
useSearchParams(e.g./feature/detail?id=123), addsrc/app/{feature}/detail/layout.tsxthat wrapschildrenin<SuspenseHelper>from@/components/helper/SuspenseHelper. - Shared state — Use zustand stores in
src/stores/{domain}when state must cross component boundaries. - Helpers — Reuse from 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 tosrc/. - Detail pages that read
useSearchParamsMUST be wrapped in<SuspenseHelper>via alayout.tsx(see src/app/finance/detail/layout.tsx for the canonical pattern). - API service classes inherit CRUD methods (
getAll,getSingle, etc.) fromBaseApiService— extend the class for feature-specific endpoints rather than callinghttpClientdirectly 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):
-
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
-
Pass
trueas last parameter to updateFilter callsupdateFilter('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
-
Create custom formikResetHandler function
- Call
resetFilter()(single call — resets alluseTableFilterstate 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
onResethandler (notformik.handleReset)
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}> - Call
Optimization: Avoid useCallback and useMemo for trivial operations
-
useCallbackanduseMemoadd 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:
// ✅ 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] ); -
useMemoIS 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 asString(value)in the query stringOptionType<T>[]→ serialized as comma-separated values (CSV) — ideal for multi-select API params likecustomer_ids,sales_ids
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
Filtersuffix (e.g. avoidcustomerFilter,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).
// ❌ 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,CustomersTableinsrc/components/pages/master-data/BalanceMonitoringTabinsrc/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:
// ✅ 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:
-
Local sort state —
const [sorting, setSorting] = useState<SortingState>([]); -
useTableFilterconfig — Addsort_byandorder_bytoinitialandparamMap. TheparamMapkey is the internal name; the value is the query param name sent to the server (they can differ, e.g.order_by→sort_order):initial: { sort_by: '', order_by: '' } paramMap: { sort_by: 'sort_by', order_by: 'sort_order' } -
useEffectsync — Watchessortingand pushes changes intouseTableFilter: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]); -
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>:
<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.
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)
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)
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,exceljsin page/tab components.
Reference implementation: MarketingReportApiService.exportDailyMarketingToExcel / exportDailyMarketingToPDF in src/services/api/report/marketing-report.ts, consumed by 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:
# ❌ 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)
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)
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)
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)
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)
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)
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)
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)
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)
rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
Meta Commands
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.