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

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

```tsx
// 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 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` to `<Table>`:**

```tsx
<Table sorting={sorting} setSorting={setSorting} ... />
```

**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).
