# 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<T, CreatePayload, UpdatePayload>` 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 `<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](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](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();
   };
   // ...
   <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:

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

```tsx
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).

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

```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<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 state** — `const [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_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 `<Table>`:**

```tsx
<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](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<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)

```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-instructions v2 -->

# 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 <cmd>          # 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 <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)

```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 <script>    # Compact npm script output
rtk npx <cmd>           # Compact npx command output
rtk prisma              # Prisma without ASCII art (88%)
```

### Files & Search (60-75% savings)

```bash
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)

```bash
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)

```bash
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)

```bash
rtk curl <url>          # Compact HTTP responses (70%)
rtk wget <url>          # Compact download output (65%)
```

### Meta Commands

```bash
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.

<!-- /rtk-instructions -->
