# Claude Code — App (Tenant Area)

> Multi-tenant Laravel + Inertia/React SaaS. Tenant DB is the default connection; central DB is explicit.

---

## Architecture

| Layer | Location | Rule |
|-------|----------|------|
| PHP modules | `{App}/{Module}/` (e.g. `InventoryApp/StockAdjust/`) | nwidart/laravel-modules, no shared contracts |
| Frontend pages | `{App}/{Module}/resources/assets/js/pages/{Module}/*.tsx` | Inertia — resolved via glob in `resources/js/app.tsx` |
| Shared UI | `resources/js/components/`, `resources/js/layouts/` | Import via `@/` alias |
| Enums | `app/Enums/` | Shared across all modules |

---

## ⚠️ CRITICAL: Duplicate Page Shadowing

The Inertia resolver in `resources/js/app.tsx` checks `resources/js/pages/` **first** before module pages. Any file placed there will shadow the real module page.

**Never place module pages in `resources/js/pages/`.**

If a page renders stale/wrong UI, check for a duplicate at:
```
app/resources/js/pages/{PageName}/
```
and delete it. Module pages live exclusively at:
```
{App}/{Module}/resources/assets/js/pages/{Module}/{Page}.tsx
```

---

## Inertia Persistent Layout — CRITICAL

**Always use `Index.layout`, never wrap `AppLayout` in the render body.**

```tsx
// ✅ Correct — sidebar persists
function Index() {
    return (
        <>
            <Head title="Page Title" />
            {/* content */}
        </>
    );
}
Index.layout = (page: ReactNode) => (
    <AppLayout breadcrumbs={[{ title: 'Section', href: '#' }, { title: 'Page', href: '#' }]}>
        {page}
    </AppLayout>
);
export default Index;

// ❌ Wrong — sidebar disappears / re-mounts
function Index() {
    return (
        <AppLayout breadcrumbs={breadcrumbs}>
            <Head title="Page Title" />
        </AppLayout>
    );
}
Index.layout = (page: ReactNode) => page; // ❌
```

See `CONSTITUTION.md §11` for full detail and DataTable pattern.

---

## Index Page Pattern (DataTable with Summary Cards + Bulk Actions)

All index list pages must pass data as a **plain array** from the controller — never use `ResourceCollection::additional()` for Inertia renders (it does not merge additional keys into Inertia props).

### Controller index method:
```php
return Inertia::render('Module/Index', [
    'moduleData' => [
        'data'        => ModuleResource::collection($items->items()),
        'meta'        => simple_pagination_meta($items),
        'links'       => [
            'prev' => $items->previousPageUrl(),
            'next' => $items->nextPageUrl(),
        ],
        'queryParams' => $queryParams,
        'summary'     => $this->service->summary($queryParams),
        'statuses'    => $this->service->statuses(),
    ],
]);
```

### Controller wantsJson (AJAX datatable):
```php
if ($request->wantsJson()) {
    return response()->json([
        'data'     => ModuleResource::collection($items->items()),
        'meta'     => simple_pagination_meta($items),
        'summary'  => $summary,
        'statuses' => $this->service->statuses(),
        'status'   => 'success',
    ]);
}
```

### ⚠️ STRICT RULE: Status updates and mutations MUST return redirect(), never response()->json()

Inertia intercepts all non-JSON requests. If a controller action (status update, delete, bulk action) returns `response()->json()`, Inertia throws:
> "All Inertia requests must receive a valid Inertia response, however a plain JSON response was received."

**Always redirect after any mutation:**
```php
// ✅ Correct
return redirect()->route('module.index')->with('success', 'Status updated.');

// ❌ Wrong — breaks Inertia
return response()->json(['status' => 'success']);
```

The only exception is endpoints explicitly called via `axios`/`fetch` (search endpoints, file uploads) — those may return JSON. The `wantsJson()` branch on `index()` is also fine because it's triggered by `fetchDatatable`.

### Bulk action route + controller:
```php
// routes/tenant.php — declare BEFORE Route::resource()
Route::post('module/bulk-action', [Controller::class, 'bulkAction'])->name('module.bulk-action');
Route::resource('module', Controller::class)->names('module');
```

### Service bulkStatusUpdate:
```php
public function bulkStatusUpdate(string $statusLabel, array $ids): array
{
    $toStatus = $this->statusMap()[$statusLabel] ?? null;
    $count = Model::whereIn('id', $ids)->update(['status' => $toStatus]);
    return ['count' => $count];
}
```

---

## ⚠️ STRICT RULE: ModelFilter for All Filterable Models

**Every model that has a list/index page with search, filter, or sort MUST use `EloquentFilter\Filterable` and a dedicated `ModelFilter` class. Manual `when()`/`where()` chains in `baseQuery()` are FORBIDDEN.**

### Checklist — required every time you create or touch a module with listing:

1. **Model** — add `use Filterable;` and `modelFilter()`:
```php
use EloquentFilter\Filterable;
use MyApp\MyModule\ModelFilters\MyModelFilter;

class MyModel extends Model
{
    use Filterable, SoftDeletes;

    public function modelFilter(): string
    {
        return MyModelFilter::class;
    }
}
```

2. **Filter class** — create `app/ModelFilters/MyModelFilter.php` (or inside the module at `app/MyModule/app/ModelFilters/`), always `use CommonFilter`:
```php
use App\ModelFilters\CommonFilter;
use EloquentFilter\ModelFilter;

class MyModelFilter extends ModelFilter
{
    use CommonFilter; // provides: status(), sortBy(), createdAtStart(), createdAtEnd()

    public function search(string $value): self
    {
        return $this->where(fn ($q) => $q
            ->where('name', 'like', "%{$value}%")
            ->orWhere('uid', 'like', "%{$value}%")
        );
    }

    // Add one method per filterable param (snake_case key → camelCase method)
    // e.g. purchase_date_start → purchaseDateStart()
}
```

3. **Service `baseQuery()`** — one line only:
```php
public function baseQuery(array $params)
{
    return MyModel::filter($params);
}
```

4. **`CommonFilter` trait** (`app/app/ModelFilters/CommonFilter.php`) already provides:
   - `status($value)` — comma-separated `whereIn`
   - `sortBy($column)` — reads `sort_dir` from request
   - `createdAtStart($date)` / `createdAtEnd($date)` — date range on `created_at`

5. **Param naming convention** — EloquentFilter maps `snake_case` → `camelCase` automatically:
   - `purchase_date_start` → `purchaseDateStart()`
   - `return_date_end` → `returnDateEnd()`
   - `supplier_id` → `supplierId()`
   - `sort_by` → `sortBy()` (from CommonFilter)
   - `status` → `status()` (from CommonFilter)

### ❌ Never do this:
```php
// FORBIDDEN — never write manual filter chains in baseQuery()
public function baseQuery(array $params)
{
    $query = MyModel::query();
    if (!empty($params['search'])) { $query->where(...); }
    if (!empty($params['status'])) { $query->where(...); }
    // ... etc
    return $query;
}
```

---

## Status / Enum Mapping — CRITICAL

**Always use `StatusEnum` values in resource transformers and factories.** Never hardcode integers that may not match the enum.

```php
// ✅ Correct
$statusMap = [
    StatusEnum::PENDING->value   => 'pending',
    StatusEnum::APPROVED->value  => 'approved',
    StatusEnum::CANCELLED->value => 'cancelled',
];

// ❌ Wrong — integers may not match enum values
$statusMap = [2 => 'pending', 3 => 'approved', 6 => 'cancelled'];
```

Key `StatusEnum` values (check `app/Enums/StatusEnum.php` for the full list):
- `PENDING = 2`, `APPROVED = 3`, `CANCELLED = 6`
- `ORDERED = 32`, `RECEIVED = 33`, `REFUNDED = 34`
- `UNPAID = 29`, `PARTIALLY_PAID = 25`, `PAID = 26`, `OVERDUE = 27`

---

## Module Registration Checklist (new module)

1. `app/composer.json` — add PSR-4 entry + merge-plugin glob
2. `app/modules_statuses.json` — set module to `true`
3. `AdminApp/database/seeders/FeatureManagement/{App}.php` — add module definition
4. `AdminApp/database/seeders/FeatureManagement/Contracts/AppRegistry.php` — uncomment entry
5. `app/resources/js/components/menuItems/menuLists/application-menu-items.tsx` — add sidebar entry

---

## ⚠️ STRICT: Breadcrumb Pattern — every page, no exceptions

Every page **must** pass `breadcrumbs` and `title` to `AppLayout` in the `Page.layout` assignment.

```tsx
Index.layout = (page: ReactNode) => (
    <AppLayout
        title="Page Title"
        breadcrumbs={[
            { title: 'Home',    href: '/' },
            { title: 'Section', href: '#' },  // e.g. 'Inventory', 'Sales', 'Purchase'
            { title: 'Page',    href: '#' },  // current page — always href: '#'
        ]}
    >
        {page}
    </AppLayout>
);
```

Rules:
- First crumb is always `{ title: 'Home', href: '/' }`
- Middle crumb is the app/section name (e.g. `Inventory`, `Sales`, `Purchase`) with `href: '#'`
- Last crumb is the current page title with `href: '#'`
- Dashboard pages: `Home → Inventory → Dashboard` (not just `Dashboard` alone)
- `title` prop on `AppLayout` must match the last breadcrumb title exactly
- Never use a plain `BreadcrumbItem[]` const defined outside the layout — define inline
- Never wrap `AppLayout` inside the render body — always use `Page.layout`

### Page subtitle — `Section › Page` pattern (MANDATORY)

Every index page header must show the section path as subtitle, not a description sentence:

```tsx
<div className="grid grid-cols-1 gap-1">
    <h2 className="text-xl font-bold sm:text-2xl">Page Title</h2>
    <div className="flex items-center text-sm text-gray-600">
        <span>Section</span>
        <span className="mx-2">›</span>
        <span>Page Title</span>
    </div>
</div>
```

- Use `›` (HTML `›`) as separator, not `/` or `>`
- Section name matches the sidebar group (e.g. `Inventory`, `Sales`, `Purchase`)
- Never write a description sentence as the subtitle (e.g. ~~"Manage inventory additions"~~)

### ⚠️ STRICT: Index page content wrapper (MANDATORY)

Every index/list page render body **must** use this exact outer wrapper — no exceptions:

```tsx
<div className="no-scrollbar rounded-xl bg-gray-100/55 p-2 sm:p-4">
    {/* header, stat cards, datatable */}
</div>
```

- `bg-gray-100/55` — the light gray tinted background that distinguishes content from the AppLayout shell
- `p-2 sm:p-4` — responsive padding (mobile 8px, desktop 16px)
- `rounded-xl` — rounded corners
- `no-scrollbar` — hides scrollbar without disabling scroll
- This applies equally to pages nested inside a settings layout (e.g. `InventorySettingsLayout`) — they must still use this wrapper for their inner content div
- Dashboard pages use the same wrapper (not `flex h-full flex-col p-3` or any other variant)

---

## ⚠️ FRONTEND RULES — Read before writing any UI

### Minimal token discipline
- Do not explain what you are doing — just write the code.
- Never generate boilerplate you are not asked for (no unused imports, no placeholder comments, no "TODO" stubs).
- One component per file. No barrel re-export files unless explicitly asked.
- Never invent new color tokens or spacing scales — use the project tokens defined below.

### Component reuse (MANDATORY)
- **`StatisticsCard`** (`@/components/statistics-card`) — the **only** component for dashboard metric cards. Never write an inline stat card div anywhere. Props: `title`, `value`, `subtitle`, `icon` (string URL or Lucide component), `iconBg`, `iconColor`, `subtitleColor` (`'green' | 'red' | 'gray'`).
- **`DataTable`** (`@/components/datatable`) — the only paginated table component.
- **`BulkStatusEditModal`** — the only bulk-action modal.
- **`AppLayout`** — always via `Page.layout`, never wrapped in JSX body.

### ⚠️ STRICT: StatisticsCard anatomy — never deviate

```
┌─────────────────────────────────────────┐
│ [icon circle]  Title text (sm, gray-500)│  ← row 1: icon + title side by side
│ 106,477.93                              │  ← row 2: value (2xl bold gray-800)
│ ↑ +0% from last month                  │  ← row 3: subtitle (sm, green/red/gray)
└─────────────────────────────────────────┘
Card wrapper: rounded-2xl border border-gray-100 bg-white p-5 shadow-sm
Icon circle:  h-11 w-11 rounded-full, color set by iconBg/iconColor props
Grid layout:  grid-cols-2 gap-4 lg:grid-cols-4
```

Usage example (controller passes `stats` array to Inertia):
```tsx
import StatisticsCard from '@/components/statistics-card';
// In JSX:
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
  {stats.map((s, i) => (
    <StatisticsCard key={i} title={s.title} value={s.value} subtitle={s.subtitle}
      icon={s.icon} iconBg={s.iconBg} subtitleColor={s.subtitleColor} />
  ))}
</div>
```

Backend `stats` array shape (return from controller/service):
```php
[
  ['title' => 'Total Sales',      'value' => money($totalSales),   'subtitle' => '+0% from last month', 'subtitleColor' => 'green', 'iconBg' => 'bg-blue-100',   'icon' => asset('images/icons/total-sales.svg')],
  ['title' => 'Total Orders',     'value' => $totalOrders,          'subtitle' => '+0% from last month', 'subtitleColor' => 'green', 'iconBg' => 'bg-purple-100', 'icon' => asset('images/icons/total-orders.svg')],
  ['title' => 'Total Paid Amount','value' => money($totalPaid),    'subtitle' => '70.8% Collection rate','subtitleColor' => 'green', 'iconBg' => 'bg-green-100',  'icon' => asset('images/icons/total-paid.svg')],
  ['title' => 'Total Due Amount', 'value' => money($totalDue),     'subtitle' => '+0% from last month', 'subtitleColor' => 'gray',  'iconBg' => 'bg-red-100',    'icon' => asset('images/icons/total-due.svg')],
]
```

---

## Show / Detail Page Design System

All detail (Show) pages must follow the visual design established by `SalesApp/OrderProduct/resources/assets/js/pages/OrderProduct/Show.tsx`. Do not invent a new layout — reuse the exact patterns below.

### Route helper
Use `declare const route: (...args: any[]) => string;` (NOT `import { route } from 'ziggy-js'`).

### Page wrapper
```tsx
<div className="min-h-screen bg-[#f6f6f7]">
  <div className="mx-auto flex w-full flex-col gap-4 p-3 sm:gap-5 sm:p-4">
    {/* content */}
  </div>
</div>
```

### Gradient header banner (always first card)
```tsx
<div className="overflow-hidden rounded-2xl border border-[#d8d8d8] bg-gradient-to-r from-[#ffffff] via-[#f5fbf8] to-[#eef9f3]">
  <div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between sm:p-5">
    {/* Back button (Button asChild + Link), title + UID, status badges, action buttons */}
  </div>
</div>
```

### Status badges
Use `Badge` with `border` prop and a colored `<span>` dot:
```tsx
<Badge className={`border px-3 py-1 ${statusClass}`}>
  <span className={`mr-2 inline-block h-2 w-2 rounded-full ${statusDot}`} />
  {titleCase(status)}
</Badge>
```
Status color token maps:
- `pending`  → `border-yellow-300 bg-yellow-100 text-yellow-800` / dot `bg-yellow-600`
- `approved` → `border-blue-300 bg-blue-100 text-blue-800` / dot `bg-blue-600`
- `received` → `border-green-300 bg-green-100 text-green-800` / dot `bg-green-600`
- `cancelled`→ `border-red-300 bg-red-100 text-red-800` / dot `bg-red-600`

### 4 summary stat cards (below header)
```tsx
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
  <Card className="border-[#d8d8d8] shadow-sm">
    <CardContent className="p-4">
      <div className="flex items-center justify-between">
        <div>
          <p className="text-xs tracking-wide text-[#6d7175] uppercase">Grand Total</p>
          <p className="mt-1 text-lg font-semibold text-[#202223]">{money(value, currency)}</p>
        </div>
        <div className="rounded-lg border border-[#d8d8d8] bg-white p-2">
          <Icon className="h-5 w-5 text-[#008060]" />
        </div>
      </div>
    </CardContent>
  </Card>
  {/* Paid, Due, Supplier/Customer cards */}
</div>
```

### Cards / tables
- `Card className="border-[#d8d8d8] shadow-sm"`
- `CardHeader className="border-b border-[#e3e3e3] pb-3"` + `CardTitle className="text-base font-semibold text-[#202223]"`
- `CardContent className="p-0"` for tables, `p-4` for content
- Table header row: `className="border-b border-[#e3e3e3] bg-[#fafafa] text-left text-xs tracking-wide text-[#6d7175] uppercase"`
- Table body row: `className="border-b border-[#efefef] text-[#202223]"`
- Quantity badge: `className="inline-flex min-w-10 justify-center rounded-md border border-[#d8d8d8] bg-[#f6f6f7] px-2 py-1 font-medium"`

### Color tokens
| Token | Usage |
|-------|-------|
| `text-[#202223]` | Primary text |
| `text-[#6d7175]` | Secondary/muted text |
| `text-[#8c9196]` | Tertiary (timestamps) |
| `bg-[#f6f6f7]` | Page background |
| `bg-[#fafafa]` | Card/table header background |
| `border-[#d8d8d8]` | Card borders |
| `border-[#e3e3e3]` | Inner section borders |
| `#008060` | Action green (buttons, icons) |

### Action buttons
```tsx
<Button size="sm" className="bg-[#008060] text-white hover:bg-[#006b51]">Primary Action</Button>
<Button variant="outline" size="sm" className="border-[#d8d8d8] bg-white text-[#202223] hover:bg-[#f6f6f7]">Secondary</Button>
```

### Flat layout — no Tabs
Place sections sequentially as Cards: Line Items → Supplier/Customer → Payments → Financial Summary + Timeline + Status History.

---

## Key Shared Utilities

| Utility | Import | Purpose |
|---------|--------|---------|
| `AppLayout` | `@/layouts/app-layout` | Main shell with sidebar |
| `DataTable` | `@/components/datatable` | Server-side paginated table |
| `fetchDatatable` | `@/lib/datatable-fetch` | AJAX fetch for DataTable navigate |
| `BulkStatusEditModal` | `@/components/modals/bulk-status-edit-modal` | Bulk status update modal |
| `usePage` | `@inertiajs/react` | Access Inertia props |
| `route()` | `declare const route` | Ziggy route helper (declare at top of file) |

---

## Tenant Routes

All tenant routes go in `{Module}/routes/tenant.php` with:
```php
Route::middleware([
    'web',
    InitializeTenancyByDomain::class,
    PreventAccessFromCentralDomains::class,
])->group(function () {
    Route::middleware(['auth', 'verified'])->group(function () {
        // routes here
    });
});
```

---

## Database

- Tenant DB: default connection — use standard Eloquent
- Central DB: explicit — `\DB::connection('central')` or `protected $connection = 'central'`
- Existing tables to reuse (never recreate): `products`, `product_variants`, `product_inventory`, `stock_update`, `branches`, `taxes`, `media`, `settings`, `users`
- See `CONSTITUTION.md` for migration conventions

---

## Index Page QA Checklist

Before marking any index/list page done, verify all items below. Fix failures before proceeding.

| # | Item | How to verify |
|---|------|---------------|
| 1 | **List loads** | Page renders rows with data |
| 2 | **Pagination** | Next/prev page works; per-page selector changes row count |
| 3 | **Add** | Create form opens, submits, row appears in list |
| 4 | **Edit** | Edit modal/form opens with existing data, saves changes |
| 5 | **Delete** | Delete dialog confirms, row removed from list |
| 6 | **Bulk action** | Select rows → status update / export fires correctly |
| 7 | **Filter / Search** | Search input narrows rows; filter dropdowns apply correctly |
| 8 | **Statistics cards** | Counts/values reflect current filter state |
| 9 | **Import** | CSV upload triggers import, rows appear in list |
| 10 | **Export** | Export button downloads file with correct rows |

---

## Vite / HMR Debugging

The app runs Vite in dev mode (HMR). If page edits don't appear:
1. Check `public/hot` — it contains the Vite dev server URL (e.g. `http://localhost:5176`)
2. Verify the correct Vite process is listening on that port: `ss -tlnp | grep 5176`
3. Check the process cwd — it must be the app directory, not `/var/www/html` (the host default nginx dir)
4. Run `npm run build` inside the container only as a last resort (production-style rebuild)

---

## Import System

All CSV imports use a shared strategy pattern. Every import page renders `Import/Index` (at `resources/js/pages/Import/Index.tsx`) which delegates to `ImportList` component.

### Architecture

| Layer | File | Role |
|-------|------|------|
| Strategy | `app/Services/Import/Strategies/{Model}ImportStrategy.php` | validate / transform / persist one CSV row |
| Service | `app/Services/Import/ImportService.php` | upload, queue dispatch, summary aggregates |
| Field resolver | `app/Services/Import/ImportFieldResolver.php` | reads config → returns `fields[]` and `tableRows[][]` |
| Config | `config/import.php` | strategies map, per-model UI fields, sample data |
| Controller | Module controller (e.g. `UserController::importIndex`) | renders `Import/Index` with all props |
| Frontend | `resources/js/components/ImportList.tsx` | Quick Import card, summary cards, DataTable |
| Modal | `resources/js/components/modals/csv-import-modal.tsx` | upload → column mapping → POST |

### Controller pattern — `importIndex`

```php
$modelType     = 'Product';                                 // used for strategy lookup
$uiKey         = 'Product';                                 // key into config ui_payload_fields / ui_sample_data
$fields        = ImportFieldResolver::getFieldsForModel($uiKey);
$tableRows     = ImportFieldResolver::getSampleDataForModel($uiKey);
$sampleCsvFilename = 'sample_products_import.csv';
$importModalTitle  = 'Import Products from CSV';
$importPostRoute   = 'products.import';
$postRouteParams   = [];                                    // becomes ?key=value on the POST URL

return Inertia::render('Import/Index', [
    'importData'        => ImportResource::collection($imports)->additional(['queryParams' => $queryParams]),
    'importSummary'     => $summary,
    'modelType'         => $modelType,
    'fields'            => $fields,
    'tableRows'         => $tableRows,
    'sampleCsvFilename' => $sampleCsvFilename,
    'importModalTitle'  => $importModalTitle,
    'importPostRoute'   => $importPostRoute,
    'postRouteParams'   => $postRouteParams,
    'backHref'          => route('products.index'),
    'backTitle'         => 'Import Products',
]);
```

### User import — type-specific UI

Users have 4 types (`UserTypeEnum`: 1=Employee, 2=Partner, 3=Teacher, 4=Board Member). Each gets its own field labels and sample data via dedicated config keys:

| URL param | Config key | Sample CSV filename |
|-----------|-----------|---------------------|
| `?type=1` | `User_1`  | `sample_employees_import.csv` |
| `?type=2` | `User_2`  | `sample_partners_import.csv` |
| `?type=3` | `User_3`  | `sample_teachers_import.csv` |
| `?type=4` | `User_4`  | `sample_board_members_import.csv` |

The `postRouteParams = ['type' => $userType]` ensures the POST to `users.import` carries `?type=X` so `UserImportStrategy` sets the correct `type` value.

### Adding a new importable model

1. **Strategy** — create `app/Services/Import/Strategies/{Model}ImportStrategy.php` implementing `ImportStrategyInterface` (`validate`, `transform`, `persist`).
2. **Config** — register in `config/import.php`:
   - `strategies`: `'ModelName' => ModelImportStrategy::class`
   - `ui_payload_fields.ModelName`: ordered array of `['key', 'label']`
   - `ui_sample_data.ModelName`: matching array of sample rows (same column order as fields)
3. **Controller** — add `importIndex` and `modelImport` methods following the pattern above.
4. **Route** — `GET model/import` → `importIndex`, `POST model/import` → `modelImport`.

### Adding a sub-type import (like user types)

When one model has multiple import contexts (different labels / sample data per type):

1. Add `'ModelName_{type}'` keys to `ui_payload_fields` and `ui_sample_data` in config.
2. In the controller, compute `$uiKey = 'ModelName_' . $subType` for `ImportFieldResolver` calls.
3. Keep `$modelType = 'ModelName'` unchanged — it must match the strategy key.
4. Pass `'postRouteParams' => ['type' => $subType]` so the POST route receives the type.

### `UserImportStrategy` handled fields

`name` (or `first_name` + `last_name`), `email`, `role`, `staff_id`, `uid`, `password`, `phone`, `address`.
- `role` is resolved to a `Role` model and assigned via `assignRole()`.
- `staff_id` / `uid` are auto-generated if omitted.
- `_type` is injected by the controller via `column_mapping` merge (never in the CSV).

---

## WooCommerce Product Import

### Overview

Dedicated pipeline for importing the standard WooCommerce product CSV export (249 deterministic columns). No column mapping UI — all columns are auto-mapped by the strategy.

| Item | Value |
|------|-------|
| Strategy class | `App\Services\Import\Strategies\WooCommerceProductImportStrategy` |
| Config key (`strategies` map) | `ProductWooCommerce` |
| GET route | `products.woocommerce-import-index` → `GET /products/woocommerce-import` |
| POST route | `products.woocommerce-import` → `POST /products/woocommerce-import` |
| Frontend page | `Import/Index` with `woocommerceMode=true` |
| Import mode param | `import_mode` (query/form field): `upsert` (default) \| `update` \| `skip` |

### Product sources (multi-source registry)

`product_sources` table tracks where each product came from:

| Column | Purpose |
|--------|---------|
| `product_id` | FK to `products` |
| `source` | Source identifier string (e.g. `woocommerce`) |
| `external_id` | ID in the source system (WooCommerce `ID` column) |
| `last_synced_at` | Timestamp of last sync |
| `sync_hash` | MD5 of last row for change detection |

Model: `ProductApp\Product\Models\ProductSource`  
Relationship: `Product::sources()` → `hasMany(ProductSource::class)`  
The `products` table also has `external_id` and `source` columns for a quick fallback lookup.

### Idempotency (4-level, in order)

1. `product_sources` table match by `source` + `external_id` (most precise).
2. `products.external_id` + `products.source` fallback (legacy, pre-sources-table rows).
3. SKU match — logs a warning if the SKU belongs to a different source.
4. name/slug match — logs a warning (ambiguous, use only as last resort).

### Import mode

`_import_mode` is passed in `column_mapping` by the controller (not in the CSV):

| Mode | Behaviour |
|------|-----------|
| `upsert` (default) | Create new products; update existing ones. |
| `update` | Only update products that already exist; skip new. |
| `skip` | Skip any product that already exists; only insert new. |

### `boot()` preloads (4 queries, O(1) row processing)

- `wcSourceMap`: `product_sources` indexed by `external_id` for the `woocommerce` source.
- `wcProductFallback`: `products` indexed by `external_id` where `source = 'woocommerce'`.
- `categoryCache`: all product categories keyed by slug/name.
- `brandCache`: all brands keyed by slug/name.

### Row classification

`classifyRow()` returns one of:
- `simple` — `post_parent = 0`, no variation attributes.
- `variable_parent` — `post_parent = 0`, has `attribute:*` columns with `visible=1`.
- `variation` — `post_parent != 0`.

Orphan variations (parent not yet seen) are buffered in `$pendingVariations` and flushed after all rows are processed.

### `afterProductPersist()` side-effects

- `ProductSource::updateOrCreate()` — registers/updates the source entry.
- Image stored via `storeImageAsReference()` using the WooCommerce image URL.
- `ProductShipping` upserted with weight/dimensions.
- Buffered variation rows flushed.

### Controller methods (`ProductController`)

```php
// GET /products/woocommerce-import
public function woocommerceImportIndex(Request $request): Response

// POST /products/woocommerce-import
public function woocommerceImport(Request $request): RedirectResponse
// Validates: file (required, csv, max 20MB), import_mode (optional: skip|update|upsert)
// Calls: ImportService::createPending('ProductWooCommerce', $path, ['_import_mode' => $importMode])
```

### Frontend — `ImportList` `woocommerceMode` prop

When `woocommerceMode=true` the `QuickImportCard` hides the sample CSV download and template table, showing a "How it works" + "Import modes" info panel instead. The Import File button remains.
