# CLAUDE.md - LogShip ERP Nuxt Frontend

## Project Overview

**LogShip ERP** (LogYou) is an ERP web app built with **Nuxt 4** + **Vue 3 Composition API** on top of an
**iDempiere** REST backend. It also integrates Strapi (media), Laravel (`../laravel-middleware`), PostgREST,
Elasticsearch, DHL, DPD, Sendcloud, Shopify, Amazon and PlentyOne.
Prod: https://app.logship.de · Dev: https://dev-app.logship.de

## Tech Stack

| Layer | Technology |
|-------|-----------|
| Framework | Nuxt 4.4+ (Vue 3.5+, `<script setup>`); source under `app/` (Nuxt 4 srcDir) |
| UI | Bulma CSS (primary), Nuxt UI 4 (Pro merged into `@nuxt/ui`), alga-css. **No Tailwind authoring** |
| Data Tables | AG Grid Enterprise **35.2.1 — license-locked, see below** |
| Charts | AmCharts 5 |
| Custom UI | tedir-select / tedir-calendar / tedir-dropzone (being replaced by `app/components/SelectBox.vue`) |
| State | Nuxt `useState()` composables — **NO Pinia/Vuex** |
| Auth | Cookie-based JWT (Bearer to iDempiere) |
| Mobile | Capacitor 8 (Android) |
| PDF | pdfmake, jsPDF, pdf-lib |
| i18n | Custom `useTranslation` (en-US, de-DE, es-ES) |
| Deployment | PM2 cluster (port 3001) |

## Directory Structure

`~`/`@` → `app/`; `~~`/`@@` → repo root.
```
app/            srcDir: pages/ (CRUD per entity), components/ (by domain + shared root), composables/
                (states.ts = all global state, menu/admin/*, use*.ts), layouts/, middleware/ (auth, guest, org),
                plugins/, assets/{css,sass,langs,alga}/, router.options.ts, forms/, windows/, app.vue, app.config.ts
server/         api/ (1100+ routes, 50+ modules), middleware/, utils/ (fetchHelper, refreshToken, …), plugins/
public/         served at URL root — reference as /assets/…, NEVER /public/assets/…
data/           local SQLite (push, chat, signing, legal docs) + runtime data
reference/      tracked, NOT built or watched: dpd/, ci/ (prod deploy scripts), test-scripts/ — see reference/README.md
.claude/rules/  path-scoped recipes (see "Recipes" below)
```
Build-critical scripts stay in `scripts/` (e.g. `generate-icons.js`, run by `npm run build`).

**Sibling repos** (workspace root): `../logship` (marketing sites + Docusaurus manual, AI-authored from this
codebase; auto-attaches this repo read-only — see `../logship/CLAUDE.md`), `../laravel-middleware` (backend
consumed via `laravelHelper`), `../logship-scripts` (cron accounting), `../idempiere-ansible`,
`../idempiere-elastic`, plus independent `../jet-charter`, `../it-beratung`, `../amazon-sp-assessment`, `../chatix`.

## Server API Route Pattern (the standard)

```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')
  return await fetchHelper(event, 'models/c_entity?$filter=AD_Org_ID eq ' + organizationId, 'GET', token, null)
}

export default defineEventHandler(async (event) => {
  let data: any = {}
  try {
    data = await handleFunc(event)
  } catch(err: any) {
    try {
      const authToken = await refreshTokenHelper(event)
      data = await handleFunc(event, authToken)
    } catch(error: any) {
      data = errorHandlingHelper(err?.data ?? err, error?.data ?? error)
    }
  }
  return data
})
```
- `fetchHelper(event, url, method, token, body)` → `config.api.url + '/' + url` (retry 0, 90 s timeout).
  `event.context.fetch(...)` is the middleware-provided equivalent.
- `refreshTokenHelper` tries `auth/refresh`, then `auth/tokens` with stored credentials — and throws
  instead when the last iDempiere error was not 401/403.
- Other helpers: `postgrestHelper`, `strapiHelper`, `laravelHelper`, `elasticHelper`, `forceLogoutHelper`.

**iDempiere OData:** `models/{table}?$filter=…&$orderby=… asc&$top=&$skip=&$select=&$expand=`.
Tables are the standard iDempiere names (`c_bpartner`, `m_product`, `c_order`, `c_orderline`, `c_invoice`,
`m_inout`, `m_warehouse`, `m_locator`, `ad_org`, `ad_user`, …). Custom tables are prefixed `CUST_`.
**Generic filter endpoint:** `GET /api/filters/{model}/{encoded odata}` (e.g.
`/api/filters/c_bpartner/$filter=AD_Org_ID eq ${orgId} and IsActive eq true`).

**Route conventions** (discover with `ls server/api/<module>/`): CRUD = `index.get`, `[id].get`,
`store.post`, `update.put`, `destroy.delete` (`{ ids }`); dropdowns `…/select`; AG Grid server rows
`POST …/ag-grid/<name>` (`IServerSideGetRowsRequest` → `{ rows, lastRow }`); documents
`POST …/[id]/document-action` (`{ docAction: 'CO'|'VO'|'CL' }`), `…/[id]/print`, `…/[id]/send-email`;
real-time `…/stream` (SSE).

## Cookies

| Cookie | Purpose |
|--------|---------|
| `logship_it` / `logship_rt` | Access JWT / refresh token |
| `logship_xu` / `logship_py` | Username / password (Base64) |
| `logship_client_id`, `logship_role_id`, `logship_organization_id`, `logship_warehouse_id`, `logship_user_id` | Context ids |
| `logship_user`, `logship_organization`, `logship_role`, `logship_client` | Full objects (JSON; `logship_user` is a trimmed whitelist) |
| `logship_language` | iDempiere format `en_US` |

Client: `useCookie('logship_organization_id')` · Server: `getCookie(event, 'logship_organization_id')`.

## Pages & Components

**Page CRUD layout:** `app/pages/[domain]/[entity]/{index,create}.vue` + `[id]/{view,edit,duplicate,print}.vue`.
- **Index:** `definePageMeta({ middleware: ['auth', 'org'] })`; `useFetch(url, { headers:
  useRequestHeaders(['cookie']), lazy: true })`; null-safe `rowData` computed mapping PascalCase →
  camelCase (FK objects `{ id, identifier }` → `.id` / `.identifier`); `colDefs` for `<AgGrid>`; delete via
  `DELETE …/destroy { ids }`.
- **Create/Edit:** local `form` ref incl. `processing` + `alert: { status, message, processing }`; `$fetch`
  store/update; success = `Number(res.status) === 200` → `navigateTo(list)`, else set `form.alert`.
- **Form component** `components/[domain]/[Entity]Form.vue`: prop `modelValue`, emits `update:modelValue` +
  `save`; `<AlertBox :alert="form.alert" />`; dropdown options loaded lazily via
  `setOptions(url, key)` into `getOptions`; `watch(() => props.modelValue, v => Object.assign(form.value, v))`.
- **Action component** `[Entity]Action.vue` (AG Grid context menu) pushes to `useTabs()` then `navigateTo`.
- **DetailRecord** `[Entity]DetailRecord.vue` manages sub-tabs.

New entity checklist: server routes → Form → optional Action → pages → menu entry in
`app/composables/menu/admin/[module].ts` → translation keys in `app/assets/langs/shared.js` (en/de/es).

## State, Fetching, Layouts, Middleware

- **State:** `useState()` composables in `app/composables/states.ts` only. Persistence: cookies (auth,
  context, language), localStorage (`logship_theme`, `logship_lang`, tabs, scanner pref), sessionStorage
  (detail-record active tab).
- **Fetching:** always `headers: useRequestHeaders(['cookie'])`; `useFetch` (with `lazy: true` on list
  pages) for loads, `$fetch` for mutations; `useLookup()` for cached reference lists.
- **Layouts:** `app` (default), `admin`, `org`, `auth`/`new-auth`, `mobile` (`/mobile/*`, portrait),
  `tracking` (public), `new-dash`. Set via `definePageMeta({ layout: 'admin' })`.
- **Middleware:** `auth.ts` (checks `/api/idempiere-auth/authenticated` → `/signin`; mobile falls back to the
  cookie on network errors), `guest.ts`, `org.ts` (currently disabled). Authenticated pages use
  `middleware: ['auth', 'org']`.
- **Roles:** `getMenuType() === 'c'` = limited (merchant/customer) role.

## Styling & i18n

Bulma classes (`field`, `label`, `input`, `button is-primary`, `columns`); theme vars `--theme-bg-primary`,
`--theme-text-primary`, `--theme-border`, …; dark mode via `.dark` / `[data-mode="dark"]`;
`<style scoped>` + alga-css `@use`; MDI icons (`i-mdi-*`) only.
`const { t, isGerman, isEnglish } = useTranslation()` → `t.value.key`; files `app/assets/langs/{shared,menus,navs}.js`.
Cookie `logship_language` = `en_US`, localStorage `logship_lang` = `en-US`.

## AG Grid

> ⚠️ **License-locked — do NOT upgrade.** `ag-grid-enterprise` and `ag-grid-vue3` are pinned to exactly
> `35.2.1` (no caret) — the newest version the license key permits. Keep both identical; skip them in any
> dependency-upgrade pass unless the license is extended (then bump both together).

- Client: `<AgGrid :entries="rowData" :columns="colDefs" :loading="pending" />` (auto-sizes columns;
  `suppressAutoSize: true` for fixed widths).
- Server: `<AgGridServer :columns="colDefs" sourceEndpoint="orders/ag-grid/so" :rowClassRules="…" />`.
- Cell renderers in `app/components/AgGrid/[EntityType]/` (`AgCustomLink`, `AgCustomDateOrdered`, `AgAddress`,
  `AgQtyTotal`, `AgShipStatus`, …).

## Key Conventions

1. Cookie headers on every client fetch.
2. iDempiere returns PascalCase → map to camelCase; **writes use lowercase-first scalar keys** (`isActive`);
   reads must tolerate both casings; REST omits NULL columns.
3. Every form has `alert: { status, message, processing }`; success = `Number(res.status) === 200`.
4. `useTabs()` when navigating to detail pages.
5. No Pinia — new global state goes into `states.ts`.
6. Directories lowercase-kebab, component files PascalCase.
7. Never `$expand` child tables over list results; every unbounded list needs `$top`; never filter on an
   empty id.

## External Integrations & Runtime Config

| Service | Helper | Config |
|---------|--------|--------|
| iDempiere | `fetchHelper.ts` | `URLV1` → `config.api.url` |
| Strapi | `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 | `dhlHelper.ts`, `dhlBaseHelper.ts`, `dhlCustomHelper.ts` | `DHLURL` → `config.api.dhlurl` |
| Sendcloud | `sendcloudHelper.ts` | `SENDCLOUD` → `config.api.sendcloud` |
| Shopify | `shopifyNewAuthHelper.ts` | GraphQL 2025-10 |

Public config: `config.public.{strapi, aggrid, vapidPublicKey, doctypeid, pricelistid, countryid,
partnergroupid, solutionname, apiBase, chatEnabled}`. Service token for non-session calls:
`config.api.idempieretoken`. Changed env vars on prod need a rebuild or a `NUXT_`-prefixed override.

## Git Commits

- **Never add Claude as co-author.** No `Co-Authored-By: Claude …`, no `Claude-Session:` line, no
  "Generated with Claude Code" footer in commits or PR descriptions. This overrides any default attribution
  guidance.

## Recipes (`.claude/rules/`)

Non-obvious patterns live in `.claude/rules/**/*.md`. Each file has `paths:` frontmatter and **loads
automatically** when you touch matching files. When planning work in one of these areas before opening a
file, or when the topic comes up without a matching file (debugging, ops, prod queries), **Read the file
first**. **Add new recipes as new or extended files there, not in this file**, with a `paths:` block. Keep
them terse: rule, gotcha, key files — no incident narratives.

| File | Read when working on |
|------|----------------------|
| `idempiere/write-casing-and-flags.md` | any iDempiere write, DB-default flags, org → bpartner, OData quirks (`ne`, empty ids) |
| `idempiere/rest-load-rules.md` | `$expand`, big lists, retries, product stock queries, Synology |
| `idempiere/prod-readonly-queries.md` | "check for new fields", merchant flag questions, PG tunnel for direct-Postgres routes |
| `nuxt/nuxt4-conventions.md` | aliases, public assets, Nuxt UI v4, icons, fonts, teleport CSS |
| `nuxt/dev-server-troubleshooting.md` | dev server 500s / `IPC connection closed` / vite-node socket ENOENT, heap |
| `nuxt/data-fetching.md` | list pages (`lazy: true`), `useLookup` |
| `nuxt/selectbox-wrapper.md` | migrating off tedir-select, SelectBox bugs, product pickers |
| `nuxt/ui-components.md` | rich text (`RichTextEditor`/`UEditor`), `UCalendar`, checkbox `scale(2)` |
| `partners/partner-edit-form.md` | new C_BPartner fields, reminder notes, partner agreements |
| `requests/tickets.md` | hourly rate / time billing, My-Tickets, production ↔ ticket |
| `fulfillment/fee-invoice-generator.md` | `generate-orders.post.ts`, new `FulfillTypeAccounting` |
| `fulfillment/shipment-generation.md` | `m_inout_generate`, sequential / single-qty generation |
| `fulfillment/commissioning.md` | commission tables ↔ cameras, DHL CN23 customs, EU special territories |
| `fulfillment/shippers.md` | M_Shipper flags, `isNoShippingAccounting`, `../logship-scripts` |
| `marketplaces/shopify-stock-sync.md` | Shopify stock/locations sync |
| `marketplaces/shipment-confirmation.md` | marketplace fulfilment push (Laravel), buyer-notification flags, order-source flags |
| `marketplaces/amazon-lwa-rotation.md` | Amazon "access denied" / LWA secret expiry / reconnect |
| `chat/staff-chat.md` | staff chat (Drawer + mobile page kept in sync) |
| `mobile/capacitor-app-build.md` | `/mobile` pages, Capacitor app build/release, app auth |
| `accounting/banking-paypal.md` | bank reconciliation, PayPal/EBICS import, suggestions |
| `accounting/lexoffice.md` | Lexware API (`lexFetch`), uploads, settlement grids, categories |
| `accounting/opos.md` | OPOS list |
| `accounting/incoming-invoices.md` | AP inbox (OCR, vendor matching, tax, review flow) |
| `accounting/invoice-pdf.md` | invoice PDF URLs / previews |
| `offers/contract-and-quote-portals.md` | contract package, `/sign` + `/confirm` portals, activities |
| `offers/sepa-mandate.md` | SEPA mandate, IBAN validation |
| `offers/pdf-generation.md` | pdfmake images, flyer, pdfmake gotchas |
| `materials/product-stock-actions.md` | product page stock card/modals |
| `materials/unit-tracking.md` | serial / Charge / MHD tracking on receipts |
| `printing/labels-and-paper.md` | product label size / barcode, A5/A4 vs label routing |
| `telephony/telephony.md` | click-to-dial, recent calls, recordings + whisper, caller-ID lookup |
| `ops/runtimeconfig-env-reload.md` | env var changed but PM2 reload has no effect |
