# Common Recipes
> This file is imported into `CLAUDE.md` via `@reference/recipes.md`, so everything
> here loads into context exactly as if it were inline. It was split out only to keep
> `CLAUDE.md` under its size limit. **Add new recipes here, not in `CLAUDE.md`.**
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
(`
`) 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 (``) and legacy newline-separated
plain text; always writes back as ``.
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/', '')"`,
`@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*`). ``
now provides the tooltip context — there is no ``. 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//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`.
**Only one `nuxt dev` per project directory.** Nuxt's dev-lock refuses a second `nuxt dev`
against the same repo (`Another Nuxt dev server is already running: PID …` — the second
process exits immediately) — so a stray/IDE-launched instance on a different port (e.g.
`nuxt dev --port 3010 --host` from a PhpStorm run config) silently coexists with a
terminal-launched one only until something breaks the lock. Running `rm -rf .nuxt` while
BOTH are alive (e.g. the terminal one just exited and its lock file lingered) lets a second
`npm run dev` start clean and acquire a new lock — leaving two servers writing into the same
`.nuxt`/Vite cache concurrently, which corrupts shared build state and makes the older one
start 503ing. **Symptom:** a page that works in one tab but looks broken/unstyled in another,
even after "fixing" and restarting — first check `ps aux | grep "nuxt dev"` for more than one
matching this repo's path before assuming a code/CSS problem. Fix: kill every one of them,
`rm -rf .nuxt node_modules/.cache`, start exactly one.
### Non-blocking list pages — `lazy: true` + AgGrid `:loading`
In Nuxt 4 a top-level `await useFetch(...)` in `