Files
lti-web-client/CLAUDE.md
T
2026-05-20 16:08:19 +07:00

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 / 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 typechecknext 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. 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 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 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)
    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:

    // ✅ 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
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).

// ❌ 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:

// ✅ 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 stateconst [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_bysort_order):

    initial: { sort_by: '', order_by: '' }
    paramMap:  { sort_by: 'sort_by', order_by: 'sort_order' }
    
  3. useEffect sync — Watches sorting and pushes changes into useTableFilter:

    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>:

<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=excelexport=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, exceljs in 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.