7.1 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
- Clear each filter with
updateFilter(fieldName, defaultValue, true) - Call
formik.resetForm({ values: { ...defaults } }) - Close the modal at the end
- Attach to both button
onClickand formonResethandler
- Clear each filter with
Optimization: Avoid useCallback for simple handlers
-
useCallbackadds overhead and is only useful for complex logic or memoized child components -
Simple pass-through handlers don't need it:
// ✅ Good: Simple handler without useCallback const handleFilterChange = (val) => setFieldValue('location', val); // ❌ Avoid: Unnecessary useCallback overhead const handleFilterChange = useCallback( (val) => setFieldValue('location', val), [setFieldValue] );
Best practice: Store OptionType objects directly, not IDs
For select inputs, store the complete OptionType object in both formik state and tableFilterState. This eliminates the need for computed helper values (like searching options arrays to find the matching object).
// Type the useTableFilter with the filter state structure
const { state: tableFilterState, updateFilter, ... } = useTableFilter<{
search: string;
locationFilter?: OptionType<string>;
picFilter?: OptionType<string>;
}>({
initial: {
search: '',
locationFilter: undefined,
picFilter: undefined
},
paramMap: {
page: 'page',
pageSize: 'limit',
locationFilter: 'location_id',
picFilter: 'pic_id',
},
persist: true,
storeName: 'kandangs-table',
});
// Initialize formik with tableFilterState values (now typed OptionType objects)
const formik = useFormik<KandangFilterType>({
initialValues: {
location: tableFilterState.locationFilter,
pic: tableFilterState.picFilter,
},
...
});
// Handlers store the complete OptionType, not just the ID
const handleFilterLocationChange = useCallback(
(val) => setFieldValue('location', val),
[setFieldValue]
);
// Use formik values directly in select inputs (no computed helpers needed)
<SelectInput
value={formik.values.location}
onChange={handleFilterLocationChange}
...
/>
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/- Use same pattern for data tables in other modules (inventory, finance, purchase, etc.)