<!-- Generated from CLAUDE.md plus inline reference/recipes.md for Codex. -->

# AGENTS.md - LogShip ERP Nuxt Frontend

## Project Overview

**LogShip ERP** is an enterprise resource planning web application built with **Nuxt 4** and **Vue 3 Composition API**. It connects to an **iDempiere** ERP backend via REST API. The app also integrates with Strapi (CMS/media), Laravel, PostgREST, Elasticsearch, DHL, Sendcloud, Shopify, and PlentyOne.

- **App Name:** LogShip WMS / ERP Solutions
- **Company:** LogYou
- **Production URL:** https://app.logship.de
- **Dev URL:** https://dev-app.logship.de

---

## Tech Stack

| Layer | Technology |
|-------|-----------|
| Framework | Nuxt 4.4+ (Vue 3.5+, Composition API, `<script setup>`); source lives under `app/` (Nuxt 4 srcDir) |
| UI | Bulma CSS (primary), Nuxt UI 4 (Pro merged into `@nuxt/ui` — no separate `@nuxt/ui-pro`), alga-css (PostCSS) |
| Data Tables | AG Grid Enterprise (v35.2.1 — license-locked, see AG Grid Usage; server-side row model) |
| Charts | AmCharts 5 |
| Custom UI | tedir-select (SelectBox), tedir-calendar (DatePicker), tedir-dropzone (AttachBox) |
| State | Nuxt `useState()` composables (NO Pinia/Vuex) |
| Auth | Cookie-based JWT tokens (Bearer auth to iDempiere) |
| Mobile | Capacitor 8 (Android) |
| PDF | pdfmake, jsPDF, pdf-lib |
| Search | Elasticsearch 8 |
| i18n | Custom composable (`useTranslation`) with en-US, de-DE, es-ES |
| Deployment | PM2 cluster mode (2 instances, port 3001) |

---

## Directory Structure

**Nuxt 4 structure:** all application source lives under `app/` (the Nuxt 4 default `srcDir`).
The `~`/`@` aliases resolve to `app/`; `~~`/`@@` resolve to the repo root. `server/`, `public/`,
`data/`, `scripts/` and config files stay at the repo root.

```
/
├── app/                      # srcDir (Nuxt 4) — everything below resolves via ~/ and @/
│   ├── pages/                # File-based routing (469 files, CRUD per entity)
│   ├── components/           # Vue components (524 files, organized by domain)
│   │   ├── admin/           # Admin forms/actions (UserForm, CountryForm, etc.)
│   │   ├── materials/       # Product/material management
│   │   ├── sales/           # Orders, invoices, shipments
│   │   ├── procurements/    # Purchase orders, receipts
│   │   ├── partners/        # Business partner management
│   │   ├── accountings/     # Accounting entities
│   │   ├── manufacturings/  # Production & BOM
│   │   ├── settings/        # System config forms
│   │   ├── fulfillment/     # Fulfillment-specific
│   │   ├── requests/        # Request/ticket management
│   │   ├── rmas/            # Return management
│   │   ├── assets/          # Asset management
│   │   ├── projects/        # Project management
│   │   ├── AgGrid/          # Custom AG Grid cell renderers
│   │   ├── BI/              # Business intelligence visuals
│   │   ├── Charts/          # Chart components
│   │   └── [Root]           # Shared: NavBar, SideBar, ComboBox, AgGrid, modals, etc.
│   ├── composables/          # State & logic composables (37+ files)
│   │   ├── states.ts        # All global UI state (useState)
│   │   ├── menu/admin/      # Admin menu definitions
│   │   ├── menu/fulfillmentCustomer/ # Customer menu definitions
│   │   └── use*.ts          # Utility composables
│   ├── layouts/              # app, admin, auth, mobile, org, tracking, new-dash
│   ├── middleware/           # auth.ts, guest.ts, org.ts
│   ├── plugins/              # Service worker, push notifications, amcharts
│   ├── assets/
│   │   ├── css/             # fonts, style, custom, main, sidebar, theme
│   │   ├── sass/            # main.scss
│   │   ├── langs/           # shared.js, menus.js, navs.js (translations)
│   │   └── alga/            # alga-css modules
│   ├── router.options.ts    # Custom router config (Nuxt-conventional location)
│   ├── forms/               # Custom route-definition helpers (imported by router.options.ts)
│   ├── windows/             # Route-wrapper component for /windows, /create, /edit, ...
│   ├── app.vue              # Root: <UApp><NuxtPage :keepalive="{ max: 10 }" /></UApp>
│   └── app.config.ts        # ui: { colors: { primary: 'blue', neutral: 'slate' } }
├── server/                   # NOT under app/ — stays at root
│   ├── api/                 # Server API routes (1177 files, 50+ modules)
│   ├── middleware/           # Server middleware (auth, fetch)
│   └── utils/               # Helpers: fetchHelper, refreshToken, DHL, Strapi, etc.
├── public/                   # Static files, flags, PWA icons, sw-notifications.js (served at root URL — reference as /assets/..., NOT /public/assets/...)
├── data/                     # Local SQLite (push subscriptions), runtime data
├── nuxt.config.ts
├── reference/                # Tracked-but-not-built reference material, incl. recipes.md (see below)
└── tsconfig.json
```

### Sibling repo: `../logship` (ERP-linked marketing sites + product docs)

The marketing/landing sites and the Docusaurus manual that used to live here under
`docs/` and `doc-saurus/` were **extracted to an independent sibling repo `../logship`**
(own git, own per-site hosting) to keep this ERP project fast — the real bloat was
`doc-saurus/`'s ~354 MB `node_modules` being watched/indexed. That content is
**AI-authored from this codebase**: open `../logship` as its own project and the ERP is
auto-attached as a read-only root via its `.claude/settings.local.json` (no
`--add-dir` needed; nothing is copied/synced, the ERP is read live). See
`../logship/AGENTS.md`. The ERP's own `public/assets/...` are unaffected (the sites
carry their own asset copies).

> Each project is its own sibling repo under the workspace root, like this one:
> `../logship` (ERP-linked), plus `../jet-charter` and `../it-beratung` (independent
> client sites, no ERP link) and `../amazon-sp-assessment` (moved out of this repo's
> root). Only `../logship` has the ERP attached.

### `reference/` folder (tracked; excluded from Nuxt/Vite watching)

`reference/` holds reference / dev / ops-staging material that is **tracked in git** but
**not consumed by the build or runtime**, and is kept out of the dev server's
file-watching (`.nuxtignore` + `nuxt.config.ts` watch-ignore) so it never slows the app.
Contents: `dpd/` (DPD API reference), `ci/` (production deploy scripts — copied into
Jenkins manually, not run from the repo), `test-scripts/` (manual dev/diagnostic tools).
See `reference/README.md`. Build-critical scripts stay OUT of here — e.g.
`scripts/generate-icons.js`, which `npm run build` runs via `generate:icons`.

---

## Architecture Patterns

### Server API Route Pattern (the standard)

Every server API route follows this **try/catch with token refresh** pattern:

```typescript
// server/api/[domain]/[entity]/index.get.ts
const handleFunc = async (event: any, authToken: string | null = null) => {
  const token = authToken ?? await getTokenHelper(event)
  const organizationId = getCookie(event, 'logship_organization_id')
  const query = getQuery(event)

  const res = await fetchHelper(event, 'models/c_entity?$filter=AD_Org_ID eq ' + organizationId, 'GET', token, null)
  return res
}

export default defineEventHandler(async (event) => {
  let data: any = {}
  try {
    data = await handleFunc(event)
  } catch(err: any) {
    try {
      let authToken = await refreshTokenHelper(event)
      data = await handleFunc(event, authToken)
    } catch(error: any) {
      data = errorHandlingHelper(err?.data ?? err, error?.data ?? error)
    }
  }
  return data
})
```

**Key server utilities:**
- `fetchHelper(event, url, method, token, body)` — wraps `$fetch` to iDempiere API (`config.api.url + '/' + url`)
- `refreshTokenHelper(event)` — two-tier: tries `auth/refresh`, falls back to `auth/tokens` with stored credentials
- `getTokenHelper(event)` — reads `logship_it` cookie
- `errorHandlingHelper(err, error)` — formats error responses
- `forceLogoutHelper(event, error)` — logs out + redirects on auth failure

### iDempiere API URL Structure

The backend uses OData-style queries:
```
models/{table_name}?$filter={condition}&$orderby={field} asc&$top={limit}&$skip={offset}&$select={fields}&$expand={relations}
```

**Common table names (iDempiere models):**
- `c_bpartner` — Business Partners
- `c_bpartner_location` — Partner Locations
- `ad_org` — Organizations
- `ad_user` — Users
- `m_product` — Products
- `m_product_category` — Product Categories
- `c_order` — Orders (Sales + Purchase)
- `c_orderline` — Order Lines
- `c_invoice` — Invoices
- `c_invoiceline` — Invoice Lines
- `m_inout` — Shipments/Receipts (Material Movements)
- `m_inoutline` — Shipment Lines
- `c_payment` — Payments
- `m_warehouse` — Warehouses
- `m_locator` — Locators (storage locations)
- `m_pricelist` — Price Lists
- `m_pricelist_version` — Price List Versions
- `c_currency` — Currencies
- `c_country` — Countries
- `c_tax` — Tax Rates
- `c_tax_category` — Tax Categories
- `c_doctype` — Document Types
- `c_charge` — Charges
- `c_paymentterm` — Payment Terms
- `ad_role` — Roles
- `ad_client` — Tenants/Clients

### Generic Filter Endpoint

```
GET /api/filters/{model}/{encoded_odata_filter}
```
Used to query any iDempiere model with OData filters. Example:
```typescript
const res = await $fetch(`/api/filters/c_bpartner/$filter=AD_Org_ID eq ${orgId} and IsActive eq true`, {
  headers: useRequestHeaders(['cookie'])
})
```

---

## Cookie System (Authentication & Context)

| Cookie | Purpose | Format |
|--------|---------|--------|
| `logship_it` | Access token (JWT) | Bearer token string |
| `logship_rt` | Refresh token | Token string |
| `logship_xu` | Username | Base64 encoded |
| `logship_py` | Password | Base64 encoded |
| `logship_session` | Session ID | String |
| `logship_client_id` | Tenant/Client ID | Number |
| `logship_role_id` | Role ID | Number |
| `logship_organization_id` | Current Org ID | Number |
| `logship_warehouse_id` | Current Warehouse ID | Number |
| `logship_user_id` | User ID | Number |
| `logship_user` | Full user object | JSON object |
| `logship_organization` | Full org object | JSON object |
| `logship_role` | Full role object | JSON object |
| `logship_client` | Full client object | JSON object |
| `logship_language` | Language (iDempiere fmt) | e.g. `en_US` |

**Client-side access:**
```typescript
const organizationId = useCookie('logship_organization_id')
const user = useCookie('logship_user')
```

**Server-side access:**
```typescript
const organizationId = getCookie(event, 'logship_organization_id')
const token = getCookie(event, 'logship_it')
```

---

## Key API Endpoints by Entity

Routes live under `server/api/<module>/` and mirror the file tree — discover them
with `ls server/api/<module>/` rather than maintaining a catalog here. Modules
include `partners`, `materials`, `orders`, `invoices`, `inouts`, `organizations`,
`settings`, `admin`, `dashboard`, `bi`, `fulfillment`, `commission`, `chat` (50+).

Conventions that repeat across modules:
- **CRUD:** `index.get` (list), `[id].get` (one), `store.post`, `update.put`, `destroy.delete` (body `{ ids: [...] }`)
- **Dropdowns:** `…/select`. **AG Grid server rows:** `POST …/ag-grid/<name>` (body = `IServerSideGetRowsRequest`, returns `{ rows, lastRow }`)
- **Documents:** `POST …/[id]/document-action` (`{ docAction: 'CO' | 'VO' | 'CL' }` — Complete / Void / Close), `…/[id]/print` (PDF), `…/[id]/send-email`
- **Lines / sub-resources:** `…/[id]/order-line`, `…/[id]/inout-line`, `…/[id]/packages`, `…/[id]/locations`, …
- **Real-time:** `…/stream` (SSE)
- **Ad-hoc queries against any model:** the generic filter endpoint (see Architecture Patterns above).

---

## Page CRUD Pattern

Every entity follows this file structure:

```
pages/[domain]/[entity]/
├── index.vue          — List page (AG Grid table)
├── create.vue         — Create page (renders Form component)
└── [id]/
    ├── view.vue       — Read-only detail view
    ├── edit.vue       — Editable detail view
    ├── duplicate.vue  — Copy record
    └── print.vue      — Print view (if applicable)
```

### Index Page Pattern (List)

```vue
<script setup>
definePageMeta({ middleware: ['auth', 'org'] })

const { data, pending, error, refresh } = await useFetch('/api/[domain]/[entity]', {
  headers: useRequestHeaders(['cookie'])
})

// Map backend PascalCase to frontend camelCase
const rowData = computed(() => data.value?.records?.map(item => ({
  id: item.id,
  name: item.Name,
  description: item.Description,
  isActive: item.IsActive,
  // Map nested objects: item.AD_Org_ID?.identifier → organization
})) || [])

const colDefs = ref([
  { width: 50, checkboxSelection: true, headerCheckboxSelection: true },
  { headerName: 'Name', field: 'name', cellComponent: 'AgCustomLink' },
  // ...more columns
])

const alert = ref({ status: '', message: '' })

// Delete handler
const deleteRecords = async (selectedIds) => {
  const res = await $fetch('/api/[domain]/[entity]/destroy', {
    method: 'DELETE',
    headers: useRequestHeaders(['cookie']),
    body: { ids: selectedIds }
  })
}
</script>
```

### Create Page Pattern

```vue
<script setup>
definePageMeta({ middleware: ['auth', 'org'] })

const form = ref({
  id: '', name: '', description: '', isActive: true,
  processing: false,
  alert: { status: '', message: '', processing: false }
})

const save = async (record) => {
  const res = await $fetch('/api/[domain]/[entity]/store', {
    method: 'POST',
    headers: useRequestHeaders(['cookie']),
    body: { /* mapped fields */ }
  })
  if (Number(res.status) === 200) {
    navigateTo('/[domain]/[entity]')
  } else {
    form.value.alert = { status: res.status, message: res.message }
    form.value.processing = false
  }
}
</script>

<template>
  <DomainEntityForm v-model="form" @save="save" />
</template>
```

---

## Component Patterns

### Form Component Pattern

**Naming:** `components/[domain]/[Entity]Form.vue`

```vue
<script setup>
import SelectBox from 'tedir-select'

const props = defineProps({ modelValue: Object })
const emit = defineEmits(['update:modelValue', 'save'])

const form = ref({
  ...props.modelValue,
  processing: false,
  alert: { status: '', message: '', processing: false }
})

// Load dropdown options
const loading = ref({})
const getOptions = ref({})
const setOptions = async (url, key) => {
  loading.value[key] = true
  const res = await $fetch('/api/' + url, { headers: useRequestHeaders(['cookie']) })
  if (res) getOptions.value[key] = res
  loading.value[key] = false
}

// Save handler
const save = () => {
  form.value.processing = true
  emit('save', form.value, props.modelValue.id)
}

// Watch for parent changes
watch(() => props.modelValue, (val) => {
  Object.assign(form.value, val)
})
</script>

<template>
  <div>
    <AlertBox :alert="form.alert" />
    <!-- Form fields -->
    <div class="field">
      <label class="label">Name</label>
      <input class="input" v-model="form.name" />
    </div>
    <SelectBox v-model="form.currencyId" :options="getOptions.currencies" />
    <button class="button is-primary" @click="save" :disabled="form.processing">
      Save
    </button>
  </div>
</template>
```

### Action Component Pattern (AG Grid context menu)

**Naming:** `components/[domain]/[Entity]Action.vue`

```vue
<script setup>
const props = defineProps({ params: Object })
const tabs = useTabs()

const viewHandler = () => {
  tabs.value.push({ link: '/[domain]/[entity]/' + props.params.data.id + '/view', text: 'View' })
  navigateTo('/[domain]/[entity]/' + props.params.data.id + '/view')
}
</script>
```

### Detail Record Tab Pattern

**Naming:** `components/[domain]/[Entity]DetailRecord.vue`

Manages sub-tabs for complex entity views (e.g., Partner → Locations, Bank Accounts, Users).

---

## State Management

**NO Pinia/Vuex.** All state uses Nuxt's `useState()` in `composables/states.ts`:

```typescript
export const useSideBar = () => useState<boolean>('sideBar', () => false)
export const useTabs = () => useState<any[]>('tabs', () => [{ link: '/', text: 'Home', active: true }])
export const usePrintPreview = () => useState<boolean>('printPreview', () => false)
export const usePrintParam = () => useState<any>('printParam', () => {})
// ... 33 total state composables
```

**State persistence strategy:**
- **Cookies:** Auth data, user, org, role, language (persistent, cross-tab)
- **localStorage:** Theme (`logship_theme`), language (`logship_lang`), tabs, scanner preference
- **sessionStorage:** Detail record active tab
- **useState:** UI runtime state (sidebar, modals, active menus)

---

## Data Fetching

### Client-side (pages/components)

```typescript
// SSR-safe initial data load
const { data, pending, error, refresh } = await useFetch('/api/endpoint', {
  headers: useRequestHeaders(['cookie'])  // ALWAYS include cookies for auth
})

// Client-side mutations
const res = await $fetch('/api/endpoint', {
  method: 'POST',
  headers: useRequestHeaders(['cookie']),
  body: { /* data */ }
})
```

### Server-side (API routes)

```typescript
// Using fetchHelper (for iDempiere)
const res = await fetchHelper(event, 'models/c_bpartner?$filter=...', 'GET', token, null)

// Using event.context.fetch (middleware-provided, auto-handles cookies)
const res = await event.context.fetch('models/c_bpartner?$filter=...', 'GET', token, null)

// Other backend helpers
const res = await postgrestHelper(event, '/rpc/function_name', 'POST', body)
const res = await strapiHelper(event, 'endpoint', 'GET', null)
const res = await laravelHelper(event, 'api/endpoint', 'POST', body)
const res = await elasticHelper(event, 'index/_search', 'POST', body)
```

---

## Layouts

| Layout | Usage | Components |
|--------|-------|-----------|
| `app` | Default authenticated pages | NavBar + SideBar + TabBar + Footer |
| `admin` | Admin panel | NavBarAdmin + SideBarAdmin |
| `org` | Organization selection | NavBarOrg + SideBarOrg |
| `auth` / `new-auth` | Login/register pages | Centered form |
| `mobile` | Mobile routes (`/mobile/*`) | Portrait-locked, landscape warning |
| `tracking` | Public tracking page | Minimal layout |
| `new-dash` | New dashboard | TopMegaBar + NewSideBar + MainNavigationMenu |

**Usage in pages:**
```typescript
definePageMeta({ layout: 'admin' })
```

---

## Middleware

- **`auth.ts`** — Checks `/api/idempiere-auth/authenticated`; redirects to `/signin` if not authenticated. Mobile pages fall back to cookie check on network errors.
- **`guest.ts`** — Redirects authenticated users away from signin/register pages.
- **`org.ts`** — Organization context check (currently disabled/commented out).

**Page usage:**
```typescript
definePageMeta({ middleware: ['auth', 'org'] })
```

---

## Styling Conventions

- **Primary CSS:** Bulma classes (`field`, `label`, `input`, `button is-primary`, `columns`, `column`)
- **Theme variables:** `--theme-bg-primary`, `--theme-bg-secondary`, `--theme-text-primary`, `--theme-border`, etc.
- **Dark mode:** CSS variables switch values; classes `.dark` / `[data-mode="dark"]`
- **Scoped styles:** Use `<style scoped>` with alga-css `@use` directives
- **Icons:** MDI icons via Nuxt UI (`@iconify-json/mdi`)
- **No Tailwind** — uses Bulma + custom CSS + alga-css

---

## i18n / Localization

```typescript
const { t, isGerman, isEnglish } = useTranslation()
// t.value.fieldName returns translated string
```

- Translation files: `assets/langs/shared.js`, `assets/langs/menus.js`, `assets/langs/navs.js`
- Supported: `en-US`, `de-DE`, `es-ES`
- Cookie format: `logship_language` = `en_US` (iDempiere format, underscore)
- localStorage format: `logship_lang` = `en-US` (browser format, dash)

---

## AG Grid Usage

> ⚠️ **Version is license-locked — do NOT upgrade.** `ag-grid-enterprise` and
> `ag-grid-vue3` are pinned to **exactly `35.2.1`** (no caret) in `package.json`.
> 35.2.1 is the **latest version the current AG Grid Enterprise license key permits**;
> a newer AG Grid would refuse the key (license-invalid watermark/console errors).
> Leave both at 35.2.1 and keep the versions identical. Only bump them if the license
> is extended to cover a later version — and if so, bump both packages together to the
> same version. (Routine `npm update`/dependency-upgrade passes must skip AG Grid.)

### Client-side AG Grid
```vue
<AgGrid :rowData="rowData" :columnDefs="colDefs" />
```

### Server-side AG Grid
```vue
<AgGridServer
  :columns="colDefs"
  sourceEndpoint="orders/ag-grid/so"
  :rowClassRules="rowClassRules"
/>
```

The server endpoint receives AG Grid's `IServerSideGetRowsRequest` as POST body and returns `{ rows, lastRow }`.

### Custom Cell Renderers
Located in `components/AgGrid/[EntityType]/`:
- `AgCustomLink.vue` — Clickable link to detail page
- `AgCustomDateOrdered.vue` — Formatted date
- `AgAddress.vue` — Address with indicators
- `AgQtyTotal.vue` — Calculated quantities
- `AgShipStatus.vue` — Status badges

---

## Step-by-Step: Creating a New Feature/Entity

Combines patterns already shown above — no new concepts:
1. **Server routes** — `server/api/[domain]/[entity]/{index.get,[id].get,store.post,update.put,destroy.delete}.ts`, each using the `handleFunc` + try/catch/refreshToken pattern (see Architecture Patterns).
2. **Form component** — `components/[domain]/[Entity]Form.vue`: accept `modelValue`, emit `save`, local `ref` with `processing` + `alert`, `SelectBox` for dropdowns, Bulma layout (see Component Patterns).
3. **Action component** (optional, AG Grid menu) — `components/[domain]/[Entity]Action.vue`.
4. **Pages** — `pages/[domain]/[entity]/{index,create,[id]/view,[id]/edit}.vue` (see Page CRUD Pattern).
5. **Menu entry** — add to `composables/menu/admin/[module].ts`.
6. **Translations** — add keys to `assets/langs/shared.js` for en-US / de-DE / es-ES.

---

## Key Conventions

1. **Always include cookie headers** in client-side fetches: `headers: useRequestHeaders(['cookie'])`
2. **Backend field mapping:** iDempiere returns PascalCase (`Name`, `IsActive`, `AD_Org_ID`); map to camelCase in frontend
3. **Nested ID fields:** iDempiere returns `{ id: 123, identifier: "Name", model-name: "table" }` for foreign keys; extract `.id` or `.identifier`
4. **Page middleware:** Always use `definePageMeta({ middleware: ['auth', 'org'] })` for authenticated pages
5. **Form alert pattern:** Every form has `alert: { status: '', message: '', processing: false }` for error display
6. **Status check:** API success = `Number(res.status) === 200`
7. **Navigation:** Use `useTabs()` to add tabs when navigating to detail pages
8. **No Pinia** — use `useState()` composables in `composables/states.ts` for any new global state
9. **SelectBox component:** Import from `tedir-select` for all dropdown/select fields
10. **File naming:** Pages and components follow lowercase-kebab for directories, PascalCase for component files

---

## External Integrations

| Service | Helper | Base URL Config |
|---------|--------|-----------------|
| iDempiere (main ERP) | `fetchHelper.ts` | `URLV1` → `config.api.url` |
| Strapi (CMS/media) | `strapiHelper.ts` | `STRAPIV1` → `config.api.strapi` |
| Laravel | `laravelHelper.ts` | `LARAVEL` → `config.api.laravel` |
| PostgREST | `postgrestHelper.ts` | `POSTGREST` → `config.api.postgrest` |
| Elasticsearch | `elasticHelper.ts` | `ELASTIC` → `config.api.elastic` |
| DHL Shipping | `dhlHelper.ts`, `dhlBaseHelper.ts`, `dhlCustomHelper.ts` | `DHLURL` → `config.api.dhlurl` |
| Sendcloud | `sendcloudHelper.ts` | `SENDCLOUD` → `config.api.sendcloud` |
| Shopify | `shopifyNewAuthHelper.ts` | GraphQL API 2025-10 |
| PlentyOne | Direct API calls | Via server routes |

---

## Runtime Config Keys

**Private (server-only):** `config.api.url`, `config.api.strapi`, `config.api.postgrest`, `config.api.laravel`, `config.api.dhlurl`, `config.api.elastic`, etc.

**Public (client-accessible):** `config.public.strapi`, `config.public.aggrid`, `config.public.vapidPublicKey`, `config.public.doctypeid`, `config.public.pricelistid`, `config.public.countryid`, `config.public.partnergroupid`, `config.public.solutionname`

---

## Common Recipes

The recipes from `reference/recipes.md` are inlined below so Codex has a single repo instruction file. Keep `reference/recipes.md` as the source of truth for long-lived recipe updates unless you intentionally refresh this generated file.

> The following recipes were copied from `reference/recipes.md` during generation.
> Add new long-lived recipes there first, then refresh this file when Codex should
> load the updated recipe set automatically.

Living reference for non-obvious patterns — extend this section whenever a
pattern would otherwise have to be rediscovered by reading code or trial and
error. If you're about to write custom plumbing that another file already
solves, look here first.

> Note: recipe paths that read `components/…`, `pages/…`, `composables/…`,
> `assets/…` live under `app/` (Nuxt 4 srcDir). The `~/` and `@/` aliases
> still resolve them unchanged; only bare relative/OS paths gained the `app/` prefix.

### Get the c_bpartner linked to an ad_org
Every organization has a linked business partner (`ad_org.C_BPartner_ID`).
Use the dedicated endpoint — do NOT re-resolve via `models/ad_org/{id}` plus
a second filter call.

```typescript
// Returns the full c_bpartner record (all fields, no $select)
const bpartner = await $fetch(`/api/admin/organizations/${orgId}/bpartner`, {
  headers: useRequestHeaders(['cookie'])
})
```

Server route: `server/api/admin/organizations/[id]/bpartner.get.ts`

### Hourly rate for billing (tasks / requests)
Field on `c_bpartner`: **`fulfillment_service_hourly_rate`** (number, EUR).
Standard fallback chain used across the app:
1. request's own `C_BPartner_ID`
2. the org's linked c_bpartner (recipe above)
3. `33` EUR hard fallback

Reference implementations:
- Desktop: `components/requests/RequestForm.vue` → `fetchHourlyRate()`
- Mobile: `pages/mobile/tasks.vue` → `resolveOrgBpartner()` + `extractHourlyRate()`

When reading from a c_bpartner record, check multiple case variants
(`fulfillment_service_hourly_rate`, `Fulfillment_Service_Hourly_Rate`) because
iDempiere's OData response casing varies by model.

### Appending time-billing notes to a request
When billing time (either via desktop "Zeit abrechnen" or mobile task stop),
append a `[Zeitabrechnung]` block alongside `[START:]` / `[END:]` / `[DURATION:]`
so how the amount was calculated is recorded on the request itself:

```
[Zeitabrechnung]
Zeit erfasst: 1h 15min
Stundensatz: 45,00 €/h
Berechneter Betrag: 56,25 €
```

Desktop writes this to a chat `request-update` entry; mobile appends it to
the request's `summary` field (no chat on mobile).

### iDempiere boolean flags with a DB-side default
When a `C_*` column has a DB default (e.g. `C_Order.isFulfillmentOrder` defaults
to `true`), do NOT send the field on every create call. Send it only when the
user picks the non-default value — saves payload bytes and keeps the DB default
authoritative for any flow that doesn't know about the field yet.

Pattern in `*/store.post.ts` (or any creator route):
```typescript
// Only send when explicitly false — DB column defaults to true.
if (body.isFulfillmentOrder === false) {
  newObjValue = { ...newObjValue, isFulfillmentOrder: false }
}
```

For background creators that should set the non-default value (e.g.
`server/api/fulfillment/generate-orders.post.ts` creates bookkeeping orders that
must NOT be treated as fulfilment), inline the override directly on the payload:
```typescript
orderPayload = {
  AD_Org_ID: organizationId,
  IsSOTrx: true,
  isFulfillmentOrder: false,
  // …
}
```

Key always lowercase-first on writes (`isFulfillmentOrder`, not
`IsFulfillmentOrder`) — see the existing memory note. On reads, accept both
casings because iDempiere's OData response varies by model:
```typescript
isFulfillmentOrder: res?.isFulfillmentOrder ?? res?.IsFulfillmentOrder ?? true,
```

### Partner reminder notes — auto-popup on order open
`C_BPartner.PartnerInvoiceReminderNotes` is a per-partner HTML field
(`<ul><li>…</li></ul>`) that staff use as a manual checklist when finalising
invoice-type orders. The reminder modal auto-opens on `pages/sales/orders/[id]/{view,edit}.vue`
when ALL of:
- `getMenuType() !== 'c'` (non-limited role)
- `form.value.isFulfillmentOrder === false`
- `docStatusId` ∉ {`CO`, `VO`, `CL`}
- partner has non-empty notes

Cached on the page in `reminderNotesContent` so the manual "🔔 Erinnerungen"
button can re-open the modal without another partner fetch. Components:
- `components/sales/ReminderNotesModal.vue` — bound to `useReminderNotes()` /
  `useReminderNotesParam()` (see `composables/states.ts`)
- `components/partners/PartnerReminderNotesEditor.vue` — list editor with `+` /
  reorder / remove. Editor parses both HTML (`<li>`) and legacy newline-separated
  plain text; always writes back as `<ul class="invoice-reminder-list">…</ul>`.

The editor is dropped into the customer-tab section of `PartnerForm.vue`.
Persisted via `addIfChanged('partnerInvoiceReminderNotes', 'partnerInvoiceReminderNotes')`
in `server/api/partners/partners/update.put.ts` and read tolerantly:
`res?.partnerInvoiceReminderNotes || res?.PartnerInvoiceReminderNotes || ''`.

### Adding a new editable field to the Business Partner edit form
A new `C_BPartner` column becomes editable on `/partners/partners/[id]/edit` only after
it is wired through **four** places (the edit page does NOT pass the form through
generically — it explicitly maps every field three times). Miss one and the field
silently won't load or won't save.

1. **`components/partners/PartnerForm.vue`** — the shared form UI.
   - Add the key(s) to the `form = ref({...})` init (`fooBar: props.modelValue.fooBar ?? …`).
     For an FK add both `fooBar` (identifier string) and `fooBarId`.
   - Render the control in the right tab. Booleans → an `ff-toggle-card` in the
     fulfillment tab's *Feature Flags* section; FKs → a `SelectBox` mirroring the
     `models/ad_org` select (`@click="setOptions('models/<table>', '<key>')"`,
     `@change="(val, opt) => {form.foo = opt.Name; save()}"`). Every control calls `save()`.
2. **`pages/partners/partners/[id]/edit.vue`** — three edits:
   - add the key(s) to its own `form = ref({...})`;
   - map them in `retrieve()` (`res?.Foo_ID?.identifier`/`.id`, or for a Yes/No flag parse
     tolerantly: `(res?.IsFoo ?? res?.isFoo) === true || … === 'Y'`);
   - add them to the `fieldMapping` in `getChangedFields()` (FK → list the `…Id` key;
     this is what actually gets sent on save).
3. **`server/api/partners/partners/update.put.ts`** — `addIfChanged('fooBar','fooBar')` for
   scalars/booleans (**lowercase-first key** per the iDempiere write-casing rule, even when
   the DB column is PascalCase), or `addRefIfChanged('fooBarId','Foo_ID','FooTable')` for FKs
   (sending `''` clears it to `0`).
4. **`assets/langs/shared.js`** — add label/tooltip keys to en-US / de-DE / es-ES.

`store.post.ts` is OPTIONAL for parity (create-path is robust), but **`create.vue` is
deliberately NOT wired** for detailed fulfillment fields — it sends only a subset at
creation, so those fields are edit-only (the tab still renders on create, just won't persist).
The custom read-only `[id]/view.vue` does not use `PartnerForm` and is intentionally left out.

Example (this is how `Accounting_AD_Org_ID`, an `ad_org` FK in the *Accounting Assignment*
sub-head of the LogShip-account section, and `IsEditProductImage`, a *Feature Flags* toggle
that lets a limited-role customer manage product images — read by
`pages/materials/products/[id]/edit.vue` to expose the Gallery tab — were added).

### Adding a new `FulfillTypeAccounting` to the fulfillment invoice generator
`server/api/fulfillment/generate-orders.post.ts` fetches all `cust_fulfillmentfeeline`
records with `A_Processed eq false` and routes each line through a per-type
`if / else if` chain. **A line with an unhandled `FulfillTypeAccounting` value is
silently skipped but still marked `a_processed='Y'` at the end** (`batch-process-lines`
sweeps every fetched line ID regardless), so missing a branch = silent data loss
on the invoice. Always extend the chain when a new type appears in the table.

Currently handled types (in branch order): `fulfillment`, `return`, `spacerentqm3`,
`spacerentflat`, `parcel`, `subscription`, `shippingfee`, `request`.

Two billing shapes are in use:
- **Aggregated** (default — `fulfillment`/`return`/`parcel`/etc.): bucket lines
  by `M_Product_ID` into a keyed object, sum `LineTotalAmt`, emit one order line
  per product with a summary description like `${t.parcel}: ${qty} ${t.parcels}`.
- **Per-entry** (`request`): push each fee line into a flat array, emit one
  order line per fee line, copy `line.Description` verbatim onto the order line
  so the ticket reference stays on the invoice.

Adding a type touches **six** places in `generate-orders.post.ts`:
1. Bucket declaration near `const fulfillmentLines: any = {}` (keyed object for
   aggregated, `any[] = []` for per-entry).
2. New `else if (line.FulfillTypeAccounting === 'X')` branch in the grouping
   loop (around the existing `shippingfee` branch).
3. `orderLines.push({...})` loop — the actual iDempiere `c_orderline` payload.
4. `previewOrderLines.push({...})` loop (used for the preview modal).
5. `responseOrderLines.push({...})` loop (post-create API response).
6. `reportOrderLines.push({...})` loop (PDF report generation).

If you add a translation label for the description, also update `t.X` /
`t.Xs` (singular/plural) across all four language blocks in
`assets/langs/shared.js`. Mirror the new branch in
`server/api/fulfillment/test-grouping.post.ts` (diagnostic endpoint — same
grouping, no order creation).

`components/fulfillment/FulfillmentGenerateOrdersModal.vue` already falls back
to a 4-column detail layout (Fee Line ID + Date + Qty + Unit Price + Amount)
for any `line.type` it doesn't have an explicit branch for, so a per-entry type
needs no UI change unless it has order/shipment links worth surfacing.

### Nuxt 4 + Nuxt UI v4 conventions (post-migration)
The app was migrated from Nuxt 3 (flat layout) to **Nuxt 4** with the idiomatic
`app/` srcDir and **Nuxt UI v4**. Key things that bite if forgotten:

- **srcDir is `app/`.** `~`/`@` → `app/`; `~~`/`@@` → repo root. `server/`, `public/`,
  `data/`, `scripts/` and configs stay at root. When importing a root-level file
  from inside `app/`, use `~~/…` (e.g. `~~/node_modules/...`) — though for
  node_modules CSS prefer **bare specifiers** (`'bulma/css/…'`).
- **Reference public assets as `/assets/…`, never `/public/assets/…`.** `public/`
  is served at the URL root; the `/public/` prefix 404s at runtime AND breaks the
  production build (Rollup tries to resolve it as a module).
- **Custom router config** lives at `app/router.options.ts` (Nuxt-conventional).
  Its dynamic page imports use the `~/pages/...` alias (not relative `../pages`).
  `app/forms/*.ts` and `app/windows/index.vue` are custom helpers it imports.
- **Nuxt UI v4:** single `@nuxt/ui` module (no `@nuxt/ui-pro`); Pro components
  (e.g. `UFooter`) need the `NUXT_UI_PRO_LICENSE` env (set in `.env*`). `<UApp>`
  now provides the tooltip context — there is no `<UTooltipProvider>`. Theme is
  `app.config.ts` → `ui: { colors: { primary, neutral } }` (the old `gray` key is
  now `neutral`; `'cool'`/other UIv2 aliases are invalid — use a Tailwind color
  like `'slate'`). On components, `color="gray"` → `color="neutral"`.
- **Fonts:** `@nuxt/fonts` (bundled with UI v4) rejects `provider: 'none'`. Fonts
  are self-hosted via `app/assets/css/fonts.css`, so use `provider: 'local'`.

### Dev/build need an elevated Node heap (large app)
This codebase is large enough that under Nuxt 4 + Vite 7 the default Node heap is
exhausted, killing the dev `vite-node` SSR worker — surfacing as **`IPC connection
closed`** or a stale-socket **`connect ENOENT …nuxt-vite-node-*.sock`** with every
route returning 500 (no V8 OOM trace because the OS kills the worker). The
production build OOMs similarly.

- **Dev:** the `dev` script sets `TMPDIR=/tmp NODE_OPTIONS=--max-old-space-size=8192 nuxt dev`.
  (Append host flag as `npm run dev -- --host`.) The `TMPDIR=/tmp` prefix is the
  macOS socket-length fix below — keep it.
- **Build/deploy:** do NOT hardcode `NODE_OPTIONS` in the `build` script —
  `ci/deploy-production.sh` already `export`s `NODE_OPTIONS="--max-old-space-size=12288"`
  before `npm run build`; an inline value would override and lower it.
- If a fresh `nuxt dev` misbehaves right after a `nuxt build`, do a clean restart:
  kill node, `rm -rf .nuxt .output node_modules/.cache`, then `npm run dev`.

**Two distinct causes produce the same `connect ENOENT …nuxt-vite-node-*.sock`:**
1. **OOM** — the SSR worker is killed; fixed by the `--max-old-space-size` heap above.
2. **macOS 104-char unix-socket limit (the subtle one).** macOS caps socket paths
   at 104 bytes. The default macOS `$TMPDIR` (`/var/folders/<hash>/T/`, ~49 chars)
   pushes the vite-node socket path to ~110 — over the limit. A recent
   `@nuxt/vite-builder` security step `chmodSync`s the socket right after `listen()`;
   because the kernel truncated the over-long name at bind, the chmod targets a path
   that doesn't exist → it logs **`Failed to restrict vite-node socket permissions;
   closing`** and closes the server, so every request then ENOENTs. **Tell-tale:** the
   `chmod ENOENT …` line *above* the ENOENT spam, and a socket path under
   `/var/folders/…/T/`. Clean restarts don't help (the path is long every boot).
   **Fix:** shorten `$TMPDIR` — the `dev` script forces `TMPDIR=/tmp` (socket path
   drops to ~66 chars). Must live *inside* the npm script (not a shell prefix),
   because IDE runners (PhpStorm) launch with their own launchd `$TMPDIR`.

### Non-blocking list pages — `lazy: true` + AgGrid `:loading`
In Nuxt 4 a top-level `await useFetch(...)` in `<script setup>` **blocks navigation**:
clicking a menu item freezes on the *previous* page until the request resolves. On
the dashboard (many parallel fetches) this was egregious, but every list page has
the same class of stall. Fix = make the fetch lazy and show a spinner instead of a
frozen page.

```js
// page <script setup> — render the shell immediately, fill in when data arrives
const { data, pending } = await useFetch('/api/materials/stock-units', {
  headers: useRequestHeaders(['cookie']),
  lazy: true
})
```
```vue
<!-- AgGrid.vue has a `loading` prop bound to AG Grid's built-in spinner overlay
     (default false, backward-compatible). Pass pending so the grid shows a spinner
     instead of a "No Rows" flash while data is null. -->
<AgGrid :entries="rowData" :columns="colDefs" :loading="pending" />
```
Requirements / gotchas:
- The `rowData`/list computed MUST be null-safe (`data.value?.records?.map(...) || []`)
  because `data.value` is `null` until the fetch resolves. The standard list-page
  pattern (null-safe computed + `alert` via `?.` + `watch(data)`) already satisfies this.
- Custom card-layout pages (not AgGrid) usually already have a `v-if="pending"`
  spinner block — for those just add `lazy: true`; the spinner then actually shows on
  first navigation (it never did before, because the page only rendered post-await).
- Multi-fetch pages: making the option/dropdown fetches lazy unblocks nav even when
  the grid loads its own rows separately (`AgGridServer` / `onMounted`).
- **Skip** tiny lookup tables and create/edit pages whose top-level await is one small
  fast fetch — the stall is imperceptible and not worth the churn.

### `useLookup` — cached fetch for shared reference data
`app/composables/useLookup.ts` wraps `useFetch` with a URL-keyed cache (`getCachedData`)
+ `lazy: true` + cookie headers. Use it for near-static reference lists fetched on many
pages (countries, organizations `ad_org`, `order-sources`, …) so they aren't re-fetched
on every navigation:
```js
const { data: organizations } = await useLookup('/api/models/ad_org')
const { data: orderSources }  = await useLookup('/api/settings/order-sources')
```
**Do NOT use it on an entity's own CRUD/management list page** (e.g.
`settings/order-sources/index.vue`) — those must always refetch to show just-created/
edited rows. `useLookup` is for the *consumer* dropdown fetches only; keep plain
`useFetch` on the management page.

### Staff live-chat module (WebSocket presence + 1:1 DMs)
An **isolated, bolt-on** real-time chat for staff (`ad_user.IsSystemUser`, non-limited
roles only). Independent of the requests/ticket system. It is the **only WebSocket** in
the app — everything else is SSE. Designed so it can be disabled with **zero risk**.

**Kill-switch:** `CHAT_ENABLED` env (private `config.chatEnabled` + public
`config.public.chatEnabled`). When not `true`: client opens no socket and renders nothing,
server routes return 404, `/chat/ws` closes immediately. Set in `.env`, `.env-dev`,
`.env-prod`. Toggling one env var fully enables/disables the feature.

**Transport:** Nitro native WebSocket — `nitro.experimental.websocket: true` +
`server/routes/chat/ws.ts` (`defineWebSocketHandler`). One socket per tab carries messages,
presence, heartbeats and activity signals. REST (`server/api/chat/{history,unread,read}`)
only loads history / seeds unread / marks read.

**Presence reflects ACTIVITY, not just connection.** Three states (online / away / offline).
The client (`useChatSocket`) watches `mousemove/keydown/scroll/touch/click` + visibility and
reports `{type:'activity',active}`; after **5 min idle** (or hidden tab) the user flips to
**away** even with a tab open. 25s ping + 35s server stale-sweep reap zombie sockets.
Presence is an in-memory `Map<userId,…>` in `server/utils/chatPresence.ts` (refcounted per
tab — last tab closing → offline).

**Access gate is server-authoritative** (`server/utils/chatAccess.ts`). The JWT carries only
`AD_User_ID`/`AD_Role_ID`, so `assertStaffAccess()` resolves the role's `FrontendMenu`
(reject `'c'`) and the user's `IsSystemUser` against iDempiere once, caches the verdict
(10-min TTL), **fail-closed**. Enforced in the WS `open()` hook and every REST route
(`gateFromEvent`). Client `getMenuType() !== 'c'` is defense-in-depth only.

**Storage:** frontend-owned `data/chat.db` (better-sqlite3, `server/utils/chatDb.ts`,
lazy/try-catch open — a DB failure degrades chat only, never crashes boot). NOT an iDempiere
table. **Offline delivery** reuses web-push: `sendPushToUser()` in `pushNotifier.ts` fires
when the recipient has no live socket.

**Isolation rules (keep them):** nothing in the app imports chat code (chat → `pushNotifier`
is the only, one-way, dependency). The 4 shared-file edits are additive only: `nuxt.config.ts`
(websocket flag + `chatEnabled`), `states.ts` (6 `useChat*` keys), `pushNotifier.ts`
(`sendPushToUser` + `buildChatMessagePayload`), `NewNavBar.vue` (one `<ChatBadge>` block
inside `<ClientOnly><NuxtErrorBoundary>`). UI: `components/chat/{Badge,Drawer}.vue` (Drawer
is teleported → its `<style>` is **non-scoped** per the teleport+scoped-CSS gotcha).
Gotcha: don't name a chat util export `parseCookies` — it shadows h3's auto-import
(use `parseCookieHeader`).

**Two chat surfaces — desktop drawer + mobile page (KEEP IN SYNC):** the chat UI exists in
**two** files that render the *same* state/composables (`useChat`, `useStaffDirectory`, the
`useChat*` state, `useChatEligible`):
- `components/chat/Drawer.vue` — desktop: a teleported right-side slide-in overlay.
- `pages/chat.vue` — mobile: a dedicated full-screen route (the teleport+overlay renders
  unreliably on phones — only the backdrop paints, panel content doesn't).

`<ChatBadge>` routes by viewport: `window.innerWidth <= 1024` → `navigateTo('/chat')`, else it
toggles the desktop drawer's `open` state. The drawer self-hides on mobile via an `isNarrow`
(`<= 1024`) gate so a stray socket-driven `open` can't surface the broken overlay on a phone.
**Rule: any change to the chat conversation UI/logic (message rendering, composer, search,
share cards, typing, read receipts, presence helpers) must be applied to BOTH `Drawer.vue` and
`pages/chat.vue` together** — they are intentional clones, not a shared component, so they drift
silently if you edit only one. (`pages/chat.vue` is single-pane with a back button and `mchat-*`
class names; `Drawer.vue` is two-pane with `chat-*` names — adapt class names accordingly.)

**Ops to enable in prod:** set `CHAT_ENABLED=true`; add the nginx `/chat/ws` upgrade block
(`proxy_http_version 1.1`, `Upgrade`/`Connection: upgrade`, long `proxy_read_timeout`); keep
PM2 at `instances: 1` — presence + registry are **per-process** (same caveat as
`notificationBus`); scaling to 2+ needs a Redis backplane (documented in `chatPresence.ts`).

### Commissioning-table → camera relation (which Tisch packed a shipment)
Each physical commissioning workstation ("Tisch 1/2/3…") has its **own label printer
AND its own surveillance camera**. To play the right commissioning video for a shipment
we must know which table packed it. Source of truth is the iDempiere table
**`CUST_CommissionTable`** (one row per Tisch), managed in-app at
`Settings → Commission Tables` (full CRUD). Columns: `Name`, `CommissionTableNumber`
(int Tisch no.), `shipping_printer` (e.g. `labelprinter-1` — the lookup key), `a4_printer`
(e.g. `commission-a4-1`), `synology_camera_id` (int), `Description`, `IsActive`.

**The join key is the shipping printer** the operator already picks on the commissioning
page. Flow:
- **Write (completion):** all 6 parcel routes (`commission/parcels/{index,other,dhl/index,
  dhl/multi,dpd/index,dpd/multi}.post.ts`) call `commissionTableFkPatch(event, shippingPrinter,
  token)` from `server/utils/commissionTable.ts` and merge the returned
  `CUST_CommissionTable_ID` FK onto the `m_inout` PUT. No-op when the printer is unmapped.
- **Read (video):** `surveillance/shipment-recording/init.post.ts` `$expand`s that FK off the
  shipment and uses the row's `synology_camera_id`, falling back to `SYNOLOGY_CAMERA_IDS` for
  old/un-linked shipments.
- **Picker (desktop + mobile):** `integrations/commission.vue`, `CommissionModal.vue` and
  `mobile/commission.vue` build the Tisch dropdown/buttons from `GET /api/commission/tables`
  (cached via `useLookup`), and `getA4Printer()` reads the row's `a4_printer`. **All fall back
  to hardcoded `Tisch 1/2/3` defaults** when the table is empty/unavailable, so the hot path
  can't break before the table is seeded. A `watch(printerOptions)` snaps a stale remembered
  printer to the first valid option.

`server/utils/commissionTable.ts` caches the rows per-org (10-min TTL, fail-soft → empty list).
Adding a Tisch later = add one row in the CRUD (printer + camera id + number). No code/env change.
Requires the iDempiere `CUST_CommissionTable` table + a nullable FK column
`M_InOut.CUST_CommissionTable_ID`. NB `manual-label.vue` still hardcodes `Tisch 1/2/3` — it's
a separate page, not part of the commissioning flow.

### DHL CN23 customs — no "Comments" field; use `exportDescription`
DHL's **Parcel DE Shipping API v2** customs object (the CN23 declaration) has **no
dedicated comments/remarks field**. Its only free-text fields are `invoiceNo`,
`exportType`, `exportDescription` (the export-purpose text, DHL-required when
`exportType === 'OTHER'`), plus the ID-number refs `shipperCustomsRef` /
`consigneeCustomsRef`. So anything you want to surface as CN23 "Comments" must go
into **`exportDescription`** (≤80 chars — matches the modal `maxlength` and the
existing `.substring(0, 80)` cap). DPD's `<international>` block likewise has no
remarks field (its `comment` element lives on the *address*, not customs).

The `customs` object is built **server-side** in
`server/api/commission/parcels/dhl/{index,multi}.post.ts` from the request body —
the customs item descriptions, `customsExportType`, etc. all come from the frontend,
NOT from a server-side order fetch. The CN23 is created whenever `body.parcelItems`
is present (every DHL commission label), defaulting `exportType` to `'OTHER'`.

`c_order.Description` → CN23 Comments is wired as: frontend passes
`orderDescription: shipment?.C_Order_ID?.Description || ''` (the shipment is loaded
with a full `$expand=C_Order_ID`, no `$select`, so `Description` is present — no
extra fetch), and the DHL routes set `exportDescription` from it **as a fallback**
(the explicit `OTHER` reason in `customsExportDescription` wins):
```js
if(customsObj.exportType === 'OTHER' && body.customsExportDescription) {
  customsObj.exportDescription = String(body.customsExportDescription).substring(0, 80)
} else if(body.orderDescription && String(body.orderDescription).trim()) {
  customsObj.exportDescription = String(body.orderDescription).trim().substring(0, 80)
}
```
**All three DHL entry points** pass `orderDescription`: `integrations/commission.vue`
(single `printParcelLabel` + multi `printMultiParcelLabels`),
`CommissionModal.vue`, and `mobile/commission.vue`. `dhl/manual.post.ts` is excluded
— a manual label has no linked order. The CN23 doc DHL returns is saved to Strapi
attachments as `CN23-{shipmentNo}-{timestamp}.pdf` (table 259 = C_Order).

### Dual build target — web SSR + bundled Capacitor app SPA
The Android app has TWO build targets from ONE codebase. The existing web/SSR build and the
`/mobile/*` pages it serves are UNTOUCHED — all app-only behavior is new, additive, env-gated code
(every branch keys off `process.env.BUILD_TARGET === 'capacitor'`, so the web build is byte-identical
when it is unset). **Do NOT clone the mobile pages and do NOT edit existing `/api` routes** — that is
the whole point of this design.

- **Web (default):** `npm run build` → SSR site, all pages. Unchanged.
- **App:** `npm run build:app` (`BUILD_TARGET=capacitor`) → static SPA (`ssr:false`) in
  `.output/public` containing ONLY `/mobile/**` + auth routes. Then `npm run cap:sync:app`
  (`CAP_BUNDLED=1`) copies it into `android/`. The old remote-URL shell still builds when
  `CAP_BUNDLED` is unset (a one-flag fallback). **Run `build:app` BEFORE `cap:sync:app`** (sync copies
  `.output/public`). **One-command release: `scripts/release-app.sh`** does the whole cycle — bump
  version (build.gradle + version.json) → build:app → cap:sync:app → `assembleDebug` → stage into
  `android/update-files/` → hand off to `scripts/upload-mobile-update.sh`. Because the mobile pages are
  BAKED INTO the APK, any `/mobile` UI change needs this re-release; only server/API changes ship via a
  normal backend deploy.

**The switch — `nuxt.config.ts`:** `ssr:false`; injects `runtimeConfig.public.apiBase` (default
`https://app.logship.de`); a `pages:extend` hook filters routes to `/mobile/**` + auth; an
`app:resolve` hook drops server-coupled/desktop plugins (amcharts, service-worker, push,
notifications-stream/SSE, chat) from the APP build AND drops the app-only API plugin from the WEB
build (so `@capacitor/preferences` never enters the web bundle); `vite.define __CAP_BUILD__`.
`app/router.options.ts` wraps the desktop `/windows,/create,…` routes in
`typeof __CAP_BUILD__ !== 'undefined' ? [{path:'/',redirect:'/mobile'}] : [ …desktop… ]`.
`capacitor.config.ts` `CAP_BUNDLED=1` drops `server.url` and serves `webDir`; `androidScheme:'https'`
→ app origin `https://localhost` (a secure context for getUserMedia + a clean CORS origin).

**Data layer — ZERO edits to the mobile pages.** `app/plugins/00.capacitor-api.client.ts` (app build
only; early-returns when `!apiBase`) overrides BOTH `$fetch`/`useFetch` AND raw `window.fetch` to:
rebase relative `/api/...` → `${apiBase}/api/...`, inject `Authorization: Bearer` + `X-Logship-*`
headers, rewrite the login call to `/api/app-auth/login` and capture it, and refresh the token once
on a 401. `window.fetch` MUST be wrapped too — `tasks.vue` transcribe and the Strapi
`/media-api/upload` calls use raw `fetch`. Absolute (Strapi) + `data:` URLs pass through, so
multipart/binary uploads (ocr-scan, galleries, attachments, voice-command, transcribe) keep native
WebView semantics. `app/composables/useAuthSession.ts` is a Preferences-backed token/refresh/context
store; at login it ALSO writes the `logship_*` context cookies into the WebView's own jar so the
existing pages' `useCookie('logship_*')` reads and the `auth` middleware work unchanged.

**Auth — token based, existing routes untouched.** `server/middleware/00.app-auth.ts` (runs first via
`00.`) is a no-op for web; for app traffic (`X-Logship-App: 1`) it synthesizes
`event.node.req.headers.cookie` from the Bearer token + `X-Logship-*` headers, so getTokenHelper /
getCookie / `auth.ts` / every route serve the app unchanged. It also emits CORS for the app origin
and answers the `OPTIONS` preflight (we use normal WebView fetch + server CORS, NOT CapacitorHttp,
because CapacitorHttp is unreliable for binary `FormData`). `server/api/app-auth/login.post.ts`
proxies the existing `/api/idempiere-auth/login` (reusing the full auto-finalize flow) and surfaces
`refresh_token` — which the web flow keeps httpOnly — by reading the inner Set-Cookie.
`token-refresh.post.ts` calls iDempiere `auth/refresh` from the body.

**Ops / gotchas:** deploy the new middleware + `app-auth` endpoints to the API host (the app calls
them remotely); add the app origin `https://localhost` to **Strapi's CORS** for `/media-api/upload`;
ensure **nginx passes `OPTIONS` preflight + `Authorization`/`X-Logship-*`** to Nitro; the
`app-auth/login` refresh_token relies on Nitro internal `$fetch.raw` surfacing the inner Set-Cookie —
verify on device, it falls back to re-login if absent.

### Rich-text (WYSIWYG) — use Nuxt UI's `<UEditor>`, do NOT add TipTap yourself
Nuxt UI v4 (already a dependency) ships a **full TipTap v3 editor**: `<UEditor>` + `<UEditorToolbar>`
(plus `useEditorMenu`, mention/emoji menus, drag handle). **All `@tiptap/*` packages are direct deps
of `@nuxt/ui`** and already in `node_modules` — so the app has rich text for free. Do **not** `npm
install @tiptap/*` separately: `@nuxt/ui@4.x` pins TipTap to `^3`, so adding `@tiptap/...@^2` fails
with an ERESOLVE peer conflict (that error is the tell-tale that v3 is already present). StarterKit v3
includes bold/italic/**underline**/strike/headings/lists/undo-redo, so no extra extensions are needed.

Key `<UEditor>` facts (from `node_modules/@nuxt/ui/dist/runtime/components/Editor.vue`):
- `v-model` is the content; `contentType` auto-infers **`'html'`** for a string model (objects → `json`).
  So binding a plain HTML string just works and round-trips as HTML.
- It forwards TipTap lifecycle callbacks as props — pass **`:on-blur`** to persist on blur (the app's
  autonomous-editor convention). `onUpdate` is overridden internally to emit `update:modelValue`.
- Image + Mention extensions are **on by default** → set **`:image="false" :mention="false"`** for a
  plain notes editor (otherwise "@" opens a mention menu and paste-image handling kicks in).
- `:editable="!readonly"`, `:placeholder="…"` supported. It renders nothing during SSR (`v-if="editor"`),
  but wrap usage in `<ClientOnly>` anyway (matches the app's `SelectBox` pattern).
- Toolbar items are objects keyed by a built-in handler `kind`, e.g.
  `{ kind:'mark', mark:'bold', icon:'i-mdi-format-bold' }`, `{ kind:'heading', level:2 }`,
  `{ kind:'bulletList' }`, `{ kind:'orderedList' }`, `{ kind:'undo' }`, `{ kind:'redo' }`.
  **Only the `mdi` iconify collection is installed** — use `i-mdi-*` icon names (lucide names render blank).

Reusable wrapper already built: **`app/components/RichTextEditor.vue`** — `v-model` (HTML) + `@blur`,
B/I/U/S + H2 + bullet/ordered list + undo/redo toolbar, image/mention disabled, `<ClientOnly>` + list
CSS. Reuse it instead of re-wiring `<UEditor>` each time.

### Partner agreements — per-partner child notes (date + rich-text), autonomous editor
Free-form **agreements made with a partner**, recorded on `/partners/partners/[id]/edit` (**General
tab**, always visible — for customers *and* vendors). Each agreement = one row in the iDempiere table
**`CUST_Agreement_CBartner`** (note the literal spelling **"CBartner"**, model-name
`cust_agreement_cbartner`) with `DateFrom` (date), `AgreementNote` (HTML), `C_BPartner_ID`,
`AD_Org_ID`, `IsActive`.

This is a **clone of the invoice-reminder-notes pattern** (see the "Partner reminder notes —
auto-popup on order open" recipe above) — an autonomous child-table editor that owns its own CRUD and does **not** touch
`pages/partners/partners/[id]/edit.vue` or `partners/partners/update.put.ts`:
- **UI:** `app/components/partners/PartnerAgreementNotesEditor.vue` (modeled on
  `PartnerReminderNotesEditor.vue`) — props `bpartnerId`/`readonly`; each row = a `tedir-calendar`
  `DatePicker` (`@update:modelValue` with an equality guard so the picker's mount-echo doesn't persist)
  + a `<RichTextEditor>`; coalesced **save-on-blur** (`saving`/`_dirty`); newest-first; "save partner
  first" hint when `bpartnerId` is empty (create page). Mounted in `PartnerForm.vue` as
  `<PartnersPartnerAgreementNotesEditor :bpartner-id="form.id" />` in the `tab === 'partner'` block.
- **Server:** `server/api/partners/agreements/{index.get,store.post,update.put,destroy.delete}.ts` —
  same try/catch + `refreshTokenHelper` wrapper as the reminder routes. List filters
  `C_BPartner_ID eq <id> AND IsActive eq true`, orderby `DateFrom desc`. Writes use **lowercase-first
  scalars** (`dateFrom`, `agreementNote`, `isActive`) with PascalCase FK objects (`C_BPartner_ID`,
  `AD_Org_ID` → `{ id, tableName }`); `tableName: 'CUST_Agreement_CBartner'` on POST/PUT
  (per the iDempiere REST write-casing rule).
- Requires the iDempiere `CUST_Agreement_CBartner` table to exist (already created). Translation keys
  `agreement_*` live in `assets/langs/shared.js` (en/de/es), with inline German fallbacks.
