Files
lti-web-client/CLAUDE.md
T

11 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

    • Clear each filter with updateFilter(fieldName, defaultValue, true)
    • Call formik.resetForm({ values: { ...defaults } })
    • Close the modal at the end
    • Attach to both button onClick and form onReset handler

Optimization: Avoid useCallback for simple handlers

  • useCallback adds 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, CustomersTable in src/components/pages/master-data/
  • Use same pattern for data tables in other modules (inventory, finance, purchase, etc.)

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.