Compare commits

..
Author SHA1 Message Date
Worktree Fix Verifier 7058e1a73e test(pleno-vue): queue two API responses for second ordering test too 2026-08-10 20:23:30 +02:00
Worktree Fix Verifier 02e56a3e5f test(pleno-vue): queue two API responses for OrderContentTable mount
OrderContentTable fires loadOrderItems twice on initial mount: once via
the immediate watch on props.orderId and once via the explicit
top-level call. Queue both responses to keep the test deterministic.
2026-08-10 20:22:57 +02:00
Worktree Fix Verifier 97700841ba test(pleno-vue): use vi.hoisted() for the request mock holder
vitest hoists vi.mock factory calls above the module's top-level
bindings, so referencing a plain const from inside the factory throws
"Cannot access X before initialization". Use vi.hoisted() to declare a
mutable holder the mock factory can mutate.
2026-08-10 20:17:12 +02:00
Jeppe B 88d5252814 style(pleno-vue): satisfy Prettier check on the OrderContentTable change
format-tests failed on the previous commits because the new test file and
the .vue file had inconsistent whitespace and missing semicolons. Run
Prettier to fix.
2026-08-10 20:12:44 +02:00
Jeppe B 7d3ebf8d52 test(pleno-vue): cover OrderContentTable addon ordering
Pins the defensive sort in OrderContentTable so a regression cannot
silently put addons before their primary on the invoice table. Covers
the API path, the local-items path, and addon siblings ordering.
2026-08-10 20:06:10 +02:00
Jeppe B 0a03cd5f98 fix(pleno-vue): sort order_items defensively in OrderContentTable
The OrderContentTable component rendered items via v-for on the array
returned by GET /order/items. The backend used to return rows in undefined
order, which put addons before their primary on the invoice and POS
displays.

The api now sorts the SELECT (copenhagentruckwash/api PR), but we sort
defensively here so any older cached or proxied payload still renders
primary first, then addons grouped by their parent, in insertion order.
2026-08-10 20:05:39 +02:00
165 changed files with 6158 additions and 7464 deletions
+66
View File
@@ -0,0 +1,66 @@
name: Qodana
on:
workflow_dispatch:
pull_request:
branches: [master, beta, canary, internal]
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [master, beta, canary, internal]
permissions:
contents: read
checks: write
pull-requests: write
concurrency:
group: qodana-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: true
jobs:
qodana:
name: Qodana
if: >-
github.event_name != 'pull_request' ||
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login != 'dependabot[bot]'
)
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout repository
# v5.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Require Qodana project token
shell: bash
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
run: |
set -euo pipefail
if [[ -z "${QODANA_TOKEN:-}" ]]; then
echo "::error::QODANA_TOKEN is not configured for this repository."
exit 1
fi
- name: Qodana
# v2026.1.3
uses: JetBrains/qodana-action@b588768b6e7e6da579e518bc584f79de0d243692
with:
use-caches: true
cache-default-branch-only: true
upload-result: false
use-annotations: true
pr-mode: ${{ github.event_name == 'pull_request' }}
post-pr-comment: true
github-token: ${{ github.token }}
push-fixes: none
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
-112
View File
@@ -1,112 +0,0 @@
name: Deploy pleno-vue to Hetzner (staging)
on:
push:
branches: [master]
workflow_dispatch:
concurrency:
group: deploy-pleno-vue
cancel-in-progress: false
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
jobs:
test-and-deploy:
name: Build + Deploy
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'npm'
- name: Install + Build
run: |
npm ci --ignore-scripts
npm run build
- name: Setup SSH
uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Add host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Pre-deploy snapshot
id: pre
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git rev-parse HEAD > /tmp/last_deploy_sha
echo "pre_sha=$(cat /tmp/last_deploy_sha)" >> $GITHUB_OUTPUT
'
- name: Deploy
id: deploy
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git fetch origin master
git reset --hard origin/master
npm ci --ignore-scripts
npm run build
sudo systemctl reload nginx || true
sudo systemctl reload pleno-vue || true
echo "Deploy complete: $(git rev-parse --short HEAD)"
'
- name: Smoke test
id: smoke
continue-on-error: true
env:
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
run: |
bash scripts/smoke-test.sh "$SMOKE_BASE_URL"
- name: Sergii Review Batch smoke test (TRU-96)
id: smoke_sergii
continue-on-error: true
env:
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
run: |
bash scripts/smoke-test-sergii.sh "$SMOKE_BASE_URL"
- name: Auto-rollback on smoke failure
if: steps.smoke.outcome == 'failure' || steps.smoke_sergii.outcome == 'failure'
run: |
reason="$([ "${{ steps.smoke.outcome }}" = 'failure' ] && echo 'generic smoke' || echo 'Sergii Review Batch smoke')"
echo "::error::$reason test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}"
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git reset --hard ${{ steps.pre.outputs.pre_sha }}
npm ci --ignore-scripts
npm run build
sudo systemctl reload nginx || true
'
- name: Post Slack status
if: always()
uses: slackapi/slack-github-action@v4.0.0
with:
channel-id: ${{ secrets.AI_DAILY_CHANNEL }}
payload: |
{
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *pleno-vue@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Generic smoke FAILED → auto-rolled back' || steps.smoke_sergii.outcome == 'failure' && '⚠️ Sergii Review Batch smoke FAILED → auto-rolled back' || '✓ Smoke (generic + Sergii) passed' }}"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
-1
View File
@@ -1 +0,0 @@
legacy-peer-deps=true
+1 -1
View File
@@ -1,4 +1,4 @@
source "https://rubygems.org"
ruby ">= 3.2", "< 3.5"
gem "fastlane", "2.238.0"
gem "fastlane", "2.237.0"
+53 -69
View File
@@ -8,8 +8,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1281.0)
aws-sdk-core (3.254.1)
aws-partitions (1.1271.0)
aws-sdk-core (3.254.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -20,8 +20,8 @@ GEM
aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.229.0)
aws-sdk-core (~> 3, >= 3.254.1)
aws-sdk-s3 (1.228.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -35,33 +35,45 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.6)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
erb (6.0.7)
excon (1.7.0)
logger
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
excon (1.6.0)
logger
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.238.0)
fastlane (2.237.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
@@ -77,11 +89,9 @@ GEM
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 2.0.0)
faraday (~> 2.7)
faraday-cookie_jar (~> 0.0.8)
faraday-follow_redirects (~> 0.3)
faraday-multipart (~> 1.0)
faraday-retry (~> 2.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
@@ -91,7 +101,6 @@ GEM
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
irb (>= 1.8)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
@@ -108,7 +117,7 @@ GEM
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 4)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
@@ -117,22 +126,21 @@ GEM
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.106.0)
google-apis-androidpublisher_v3 (0.105.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (1.2.5)
addressable (~> 2.9)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
multi_json (~> 1.11)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 3.1, < 5.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.66.0)
google-apis-storage_v1 (0.65.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
@@ -151,7 +159,7 @@ GEM
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.0)
googleauth (1.17.3)
googleauth (1.17.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
@@ -162,14 +170,10 @@ GEM
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
io-console (0.9.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.21.2)
json (2.21.1)
jwt (3.2.0)
base64
logger (1.7.0)
@@ -180,38 +184,22 @@ GEM
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
net-http (0.9.1)
uri (>= 0.11.1)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
reline (0.7.0)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (4.2.0)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
@@ -222,19 +210,15 @@ GEM
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tsort (0.2.0)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
@@ -255,7 +239,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
fastlane (= 2.238.0)
fastlane (= 2.237.0)
RUBY VERSION
ruby 3.3.12p206
-4
View File
@@ -1,4 +0,0 @@
# AGENT MCP SMOKE
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
Safe to close.
@@ -1,477 +0,0 @@
# Plan: Show customer tags on every "Superuser → Fakturaer → Periode" subpage
## Goal
Today, the customer indicator chips (e.g. "Faktura pr. ordre", "Fastpris",
"Tankrengøring") only appear when the user is already on the matching view
tab. On the "Alle" tab the chips never show, even when a customer actually
belongs to several categories.
We want every chip to render on every subpage whenever the customer belongs
to that category — independent of which view tab is active.
---
## 1. Root cause (already confirmed by investigation)
### Front-end rendering path
* `Right.vue` (line ~300+) declares view tabs and fetches
`/superuser/invoicing/period` with the corresponding `periodView` query
param (`all`, `invoice_per_order`, …).
* `InvoicingBillingPeriodViewAll.vue` is rendered for every active view
(including `all`). It reads the active bucket via
`view.variables.sharedVariables.value.types[componentName]`.
* For each customer card it mounts
`InvoicingBillingPeriodCustomerAttributes.vue`, which computes
`list_views_with_customer`:
```ts
const list_views_with_customer = computed(() => {
const matched = view_keys.value.filter((view_key) => {
if (view_key === 'all') return false;
const view_type = sharedTypes.value[view_key];
return view_type && view_type.some(
(v: any) => v.customer_number === props.customer.customer_number,
);
});
});
```
It only treats a customer as belonging to a view if
`types[view_key]` contains an entry with the same `customer_number`.
### Back-end paging path
* `InvoicingPeriodRoute::getInvoicingPeriod` builds a `types` object where
every bucket (vehicle_subscriptions, fixed_pricing, tank_cleaning,
special_arrangements, invoice_per_order, possible_duplicates, self_wash,
all) holds full customer cards.
* `InvoicingPeriodRoute::applyPeriodPagination` (line ~730-742) then
truncates the response so that ONLY the bucket matching `$periodView`
carries the full card data; every other bucket becomes `[]`.
```php
$pagedTypes = array_fill_keys(array_keys($types), []);
if ($isAllLimit) {
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
} else {
$offset = ($page - 1) * $perPage;
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
}
```
* The frontend then iterates over the (empty) non-active buckets and finds
no customer entries → no chip is rendered → the bug.
### Why the existing e2e test missed it
`tests/e2e/invoicing-period.smoke.spec.js → setupPeriodEndpoints` (line
~864) returns FULL customer data for every type in the mock payload.
Because the mock already mimics the "pre-fix" backend behaviour (every type
populated), the chip-rendering path is exercised even when the real backend
strips the data. Updating the mock to mirror the new, real backend shape
gives us an end-to-end safety net.
---
## 2. Fix strategy
We want one round trip, no N+1 calls, and a payload that stays bounded.
**Approach: lightweight membership entries**
Extend `applyPeriodPagination` so that, after pagination, every non-active
view bucket is populated with "membership only" entries — each entry is
just `{ customer_number }` so the frontend can resolve membership via the
existing `view_type.some(v => v.customer_number === …)` check.
* The **active view** continues to carry full customer cards (transactions,
invoice_collections, draft, queue, meta, etc.) — no behaviour change for
it.
* **Every other view** carries a `{customer_number: N}` array (one per
matching customer after all filters / search / sort / pagination). No
transactions or auxiliary fields — keeping the payload small.
* `ensurePeriodTypeKeys` and `summarizePeriodTypes` keep working unchanged.
`type_counts` (already computed before pagination) keeps the totals per
view, so tab counters remain correct.
* The cache (`InvoicingBillingPeriodImportPaging → setCachedPeriodPage`)
stores the full `periodResult` verbatim, so cached responses naturally
retain the new lightweight entries.
### Why this option wins
| Approach | Network | Payload | Schema change | UX consistency |
|---|---|---|---|---|
| **Lightweight memberships on every bucket (chosen)** | 1 call | ~150 KB worst case (5 non-active buckets × ~30 KB each) | minimal: membership schema can be additive | ✅ |
| N+1 fetch (per view call) | N+1 calls | n/a | none | ✅ but slow |
| Include full customer data for every bucket | 1 call | ~5-10 MB | none | ✅ but breaks pagination |
---
## 3. Concrete code changes
### 3.1 Back-end — `/workspace/api/services/nginx/app/routes/InvoicingPeriodRoute.php`
In `applyPeriodPagination(...)` (around line 730-742), after the active
bucket is sliced, populate every non-active bucket with lightweight
memberships derived from the already-filtered/searched/sorted `$types`
arrays:
```php
// Existing pagination of the active bucket
$pagedTypes = array_fill_keys(array_keys($types), []);
if ($isAllLimit) {
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
} else {
$offset = ($page - 1) * $perPage;
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
}
// NEW: lightweight memberships for every non-active view so the front-end
// can render category chips regardless of which tab is active.
foreach ($types as $typeName => $customers) {
if ($typeName === $periodView) {
continue;
}
$pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships(
is_array($customers) ? $customers : []
);
}
```
Add a new helper:
```php
/**
* Return a minimal `{customer_number: N}` array per customer so the
* front-end can determine which non-active view buckets the customer
* belongs to without us shipping full transaction/queue data.
*
* Filters, searches, sort and visibility rules have already been applied
* to `$customers` by the time we run, so we just de-duplicate and emit.
*
* @param array<int, array<string, mixed>> $customers
* @return array<int, array{customer_number: int, membership_only: true}>
*/
private static function summarizePeriodCustomerMemberships(array $customers): array
{
$memberships = [];
$seen = [];
foreach ($customers as $customer) {
if (!is_array($customer)) {
continue;
}
$customerNumber = (int) ($customer['customer_number'] ?? 0);
if ($customerNumber < 1 || isset($seen[$customerNumber])) {
continue;
}
$seen[$customerNumber] = true;
$memberships[] = [
'customer_number' => $customerNumber,
'membership_only' => true,
];
}
return $memberships;
}
```
Notes:
* We deduplicate on `customer_number` so a customer appearing twice in a
bucket (rare but possible — multiple PO transactions for the same
customer in `invoice_per_order`) still only emits one membership.
* We keep the existing `ensurePeriodTypeKeys` (`array_fill_keys`) guarantees
so consumers that iterate `Object.keys(types)` still see every view
even when the filtered list ends up empty.
* The active bucket's structure is **unchanged** — the front-end
`customersInCurrentView` and `list_views_with_customer` paths continue to
work as before.
* `type_counts` and `type_totals` are computed before pagination (see
`summarizePeriodTypes`) and remain authoritative for tab counters.
### 3.2 OpenAPI specs
Both repositories carry a copy of the schema and must stay in lock-step.
**`/workspace/api/openapi.yaml`** and **`/workspace/pleno-vue/openapi.yaml`**
The current envelope for `InvoicingPeriod` (`types[view]`) is typed via
`InvoicingPeriodCustomer`, whose `required` list mandates `customer_name`,
`transactions`, `invoice_collections`. Membership entries don't carry those
fields, so we need to relax the `required` constraint on non-active buckets
and document the new shape.
Add a new sibling component:
```yaml
InvoicingPeriodCustomerMembership:
type: object
description: >-
Lightweight customer marker returned for every non-active view bucket.
Used only by the front-end to render category chips (e.g. "Faktura pr.
ordre") regardless of which tab is active. Full transaction / queue
data is intentionally omitted; see InvoicingPeriodCustomer for the
shape returned for the active bucket.
additionalProperties: false
required: [customer_number, membership_only]
properties:
customer_number:
type: integer
minimum: 1
membership_only:
type: true
enum: [true]
```
In the `InvoicingPeriod` schema, switch the `types` property from
`additionalProperties: $ref(InvoicingPeriodCustomer)` to:
```yaml
types:
type: object
additionalProperties:
type: array
items:
oneOf:
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
discriminator:
propertyName: membership_only
```
Also relax `InvoicingPeriodCustomer` so `customer_name`, `transactions`,
`invoice_collections`, `meta`, `queue`, `draft`, `requires_action` are no
longer `required` (they remain documented in `properties`). The active
bucket still emits them, but the union makes the membership shape valid.
### 3.3 Front-end — `/workspace/pleno-vue/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
After the backend fix, the chip rendering logic in
`list_views_with_customer` will start working on every subpage. To keep
performance bounded when buckets grow large, we also turn the membership
arrays into `Set<number>` lookups via a small `computed`:
```ts
const membershipIndexes = computed(() => {
const result: Record<string, Set<number>> = {};
for (const view_key of view_keys.value) {
if (view_key === 'all') continue;
const view_type = sharedTypes.value[view_key];
if (!Array.isArray(view_type)) {
result[view_key] = new Set<number>();
continue;
}
result[view_key] = new Set(
view_type
.map((entry) => Number(entry?.customer_number ?? 0))
.filter((n) => Number.isInteger(n) && n > 0),
);
}
return result;
});
const list_views_with_customer = computed(() => {
const matched = view_keys.value.filter((view_key) => {
if (view_key === 'all') return false;
return membershipIndexes.value[view_key]?.has(props.customer.customer_number) === true;
});
});
```
Behavioural impact:
* Same chip set as today, now visible on every subpage including `Alle`.
* Lookup is O(1) per (view × customer) instead of O(bucket size).
* Defensive against the lightweight entries (no `customer_name`,
`transactions`, etc. fields) — the chip only needs the view's friendly
name, which already comes from `view.computed.getViewFriendlyName(...)`.
### 3.4 Front-end — e2e mock
`tests/e2e/invoicing-period.smoke.spec.js` → `setupPeriodEndpoints`
(line ~864) currently mocks every bucket as fully populated. Update the
mock so that:
* The **active** bucket (whichever the page requested) carries full
customer cards (unchanged).
* Every **other** bucket carries membership-only entries
(`{customer_number, membership_only: true}`).
This mirrors the real backend so the existing chip-stacking test
(`tests/e2e/invoicing-period.smoke.spec.js` lines ~2360-2393) actually
guards the membership path.
---
## 4. Tests to add / update
### 4.1 Backend unit — `/workspace/api/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
Existing assertion at line 292:
```php
expect($result['period']['types']['fixed_pricing'])->toBe([]);
```
…becomes:
```php
expect($result['period']['types']['fixed_pricing'])
->toBe(array_map(
static fn(int $n): array => ['customer_number' => $n, 'membership_only' => true],
[1001], // the test fixture's other-bucket membership
));
```
Add a new test that, given a period with two customers in `all` and one
in `invoice_per_order`, paging `periodView=all` yields:
* `types.all` — full customer cards (existing behaviour preserved)
* `types.invoice_per_order` — one lightweight membership entry
* `types.fixed_pricing` / `types.tank_cleaning` / etc. — empty arrays (no
matching customers, so nothing to emit)
Add a search-aware test: searching for "Beta" while paging
`periodView=all` must surface the lightweight membership only for
customers that pass the filter, mirroring the active bucket.
Add a flag-tab-aware test: the `red` flag filter must propagate to the
membership arrays just as it does to `type_counts`.
### 4.2 Front-end unit — `tests/unit/superuser-invoices-view.spec.js` (or new spec)
Add a focused Vitest spec
`tests/unit/invoicing-period-customer-attributes.spec.js` that mounts
`InvoicingBillingPeriodCustomerAttributes` with a stubbed
`sharedVariables.value.types` containing:
```ts
{
all: [...full cards],
invoice_per_order: [{customer_number: 1001, membership_only: true}, …],
fixed_pricing: [],
}
```
…and asserts that the rendered chips include "Faktura pr. ordre" (and any
other categories the stubbed customer is a member of), independent of
which view tab is "active" in the stub.
### 4.3 E2E — `tests/e2e/invoicing-period.smoke.spec.js`
* Update `setupPeriodEndpoints` (line ~864) so the mock returns
membership-only entries for non-active buckets — matching the real
backend contract.
* Extend the existing chip-stacking test (lines ~2360-2393) to assert
that on the `Alle` tab the rendered customer cards include the
"Faktura pr. ordre" chip, "Fastpris" chip, "Tankrengøring" chip, etc.
* Add a new spec scenario:
`Given: Alle tab with mixed customers across categories. When: page
loads. Then: every customer card shows chips for every category it
belongs to.` Guarded with `@smoke` so it runs in the PR pipeline.
### 4.4 OpenAPI consistency
Run `node scripts/check-openapi-drift.mjs` (if present) or the equivalent
script in `scripts/sync-ai-workflow.mjs` to verify that the two
`openapi.yaml` files remain aligned. If a drift check is not wired up, add
it so future schema edits surface in CI.
---
## 5. Verification steps (manual + automated)
### 5.1 Manual smoke test (in dev)
1. `bash scripts/setup.sh` (or the appropriate docker compose command) to
bring up the API stack.
2. `cd /workspace/pleno-vue && npm run dev`.
3. Sign in as a superuser that owns customers spanning multiple categories
(fixed_pricing + invoice_per_order, for instance).
4. Navigate to **Superuser → Fakturaer → Periode**, pick a date range.
5. On the **Alle** tab confirm every customer card shows every chip it
qualifies for.
6. Click into the **Faktura pr. ordre** tab and confirm the same chips
render (sans the active tab's own chip).
7. Repeat for **Fastpris**, **Tankrengøring**, **Wash Subscriptions**.
8. Apply the search box; chips should update with the filter.
9. Toggle the **Kræver handling** flag tab; chips should narrow to the
flagged subset.
10. Switch page sizes (10/25/50/100/200/500/all) and confirm chips remain
consistent across pages.
11. Reload the page — chips must persist from the cache layer
(`setCachedPeriodPage`) and not flash empty.
### 5.2 Automated
* Backend unit tests: `bash scripts/php-ci-test.sh unit` (in CI; locally
inside `php1` container per `scripts/setup.sh`).
* Backend static analysis: `composer analyse` (phpstan).
* Backend rector dry-run: `composer rector:dry-run`.
* Front-end unit: `npm run test:unit`.
* Front-end e2e (smoke): `npm run test:e2e:smoke`.
* Front-end e2e (PR slice): `npm run test:e2e:pr`.
* Front-end lint: `npm run lint:strict`.
* AI workflow sync: `node scripts/sync-ai-workflow.mjs --check`.
### 5.3 CI checks to watch
* `.github/workflows/tests.yml` (api) — PHP matrix
(`unit`/`integration`/`api`/`legacy`) and Edge Agent job.
* `.github/workflows/tests.yml` (pleno-vue) — Playwright e2e matrix.
* `.github/workflows/code_quality.yml` — Qodana scan.
---
## 6. Roll-out plan
1. Branch: cut `fix/invoicing-period-tag-membership` from `master` in
`api` and from `pr-296` (current dev branch) in `pleno-vue`.
2. Backend change (3.1) + new helper + updated/new unit tests (4.1).
3. OpenAPI updates (3.2) in both repos.
4. Frontend attribute component (3.3) — add the `Set` index, keep the
array `.some()` fallback for back-compat.
5. E2E mock update (3.4) + extended chip-stacking test (4.3).
6. Run the full verification suite (5.2) locally before pushing.
7. Open the PR; CI should turn green; Qodana should not flag the new
memberships (they are deliberate additive fields).
8. After merge, monitor the period page in staging for payload size and
chip rendering parity.
---
## 7. Risk assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| Payload bloat from membership entries | Low | Memberships are `{customer_number}` only — ~30 KB per bucket at 1000 customers. |
| Frontend perf regression on huge pages | Low | `Set`-based membership index in `InvoicingBillingPeriodCustomerAttributes` makes lookup O(1). |
| OpenAPI drift between repos | Medium | Existing `sync-ai-workflow.mjs` check + new schema explicitly documents the `oneOf` shape. |
| Cache returning stale (pre-fix) data | Low | Cache TTL is 10 min (`PERIOD_CACHE_TTL_MS`); a reload or hard refresh clears it. No schema-driven cache busting required for this change. |
| Active bucket inadvertently slimmed | Low | Active bucket code path is untouched; existing `customersInCurrentView` consumers keep working. |
---
## 8. Files touched (summary)
**Backend (`/workspace/api`):**
* `services/nginx/app/routes/InvoicingPeriodRoute.php` — add
`summarizePeriodCustomerMemberships`, populate non-active buckets.
* `services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
— relax line 292, add membership / search / flag-tab tests.
* `openapi.yaml` — add `InvoicingPeriodCustomerMembership`, relax
`InvoicingPeriodCustomer` requireds, union-typed `types` items.
**Front-end (`/workspace/pleno-vue`):**
* `src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
— `Set`-based membership index.
* `tests/unit/invoicing-period-customer-attributes.spec.js` — new spec.
* `tests/e2e/invoicing-period.smoke.spec.js` — mock reflects real backend
shape, extended chip-stacking assertions.
* `openapi.yaml` — mirror backend schema edits.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

@@ -1,49 +0,0 @@
{
"kind": "VisualEvidenceManifestV1",
"taskId": "workboard-94209138-31f6-422e-ac8c-181ad391b8a7",
"view": "POS extra sale audit",
"files": [
{
"device": "mobile",
"state": "before",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-before.png",
"width": 390,
"height": 844
},
{
"device": "mobile",
"state": "after",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-after.png",
"width": 390,
"height": 844
},
{
"device": "tablet",
"state": "before",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-before.png",
"width": 768,
"height": 1024
},
{
"device": "tablet",
"state": "after",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-after.png",
"width": 768,
"height": 1024
},
{
"device": "desktop",
"state": "before",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-before.png",
"width": 1440,
"height": 900
},
{
"device": "desktop",
"state": "after",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-after.png",
"width": 1440,
"height": 900
}
]
}
+2 -3
View File
@@ -45,12 +45,11 @@ platform :ios do
skip_screenshots: false,
overwrite_screenshots: true,
force: true,
submit_for_review: true,
submit_for_review: false,
automatic_release: true,
phased_release: false,
run_precheck_before_submit: false,
precheck_include_in_app_purchases: false,
ignore_language_directory_validation: true
precheck_include_in_app_purchases: false
)
end
end

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
{
"marketingVersion": "1.1.0",
"marketingVersion": "1.0.1",
"bundleId": "io.truckwash.app",
"minimumIosVersion": "15.0"
}
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env node
// AUT-1 smoke run — capture the before/after visual diff for the
// `primary-button hover state` change in `src/assets/main.css`.
//
// Renders tests/visual-previews/AUT-1/preview.html in a real Chromium
// against the project's bundled Bulma stylesheet, then takes two
// screenshots:
//
// desktop-before.png — Bulma defaults, button idle (no hover effect).
// desktop-after.png — With the AUT-1 CSS rules applied, button
// hovered (lift + brightness shift visible).
// mobile-before.png — Same as desktop-before, captured at the
// Pixel 5 viewport.
// mobile-after.png — Same as desktop-after, captured at the
// Pixel 5 viewport.
//
// Output is written to tests/visual-previews/AUT-1/. The script is
// idempotent: existing files are overwritten, not appended to.
//
// Invoke with: `node scripts/aut-1-capture-hover-preview.mjs`
// (Chromium must already be installed via `npm run test:e2e:install`.)
import { chromium } from "playwright";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..");
const PREVIEW_HTML = path.join(
REPO_ROOT,
"tests/visual-previews/AUT-1/preview.html",
);
const OUTPUT_DIR = path.join(REPO_ROOT, "tests/visual-previews/AUT-1");
// The AUT-1 hover rules — kept in lockstep with the diff in
// src/assets/main.css. We inject these via a <style> tag on the
// "after" pass and leave them off for the "before" pass so the
// screenshots show the same Bulma theme but with vs without the new
// hover effect.
const AUT1_HOVER_CSS = `
.button.is-primary {
transition:
filter 0.15s ease,
transform 0.15s ease;
}
.button.is-primary:hover,
.button.is-primary.is-hovered {
filter: brightness(1.08);
transform: translateY(-1px);
}
.button.is-primary:active,
.button.is-primary.is-active {
filter: brightness(0.95);
transform: translateY(0);
}
.button.is-primary:focus-visible {
filter: brightness(1.04);
transform: translateY(-1px);
}
`;
async function captureVariant({ browser, viewport, label, withHoverCss, hover }) {
const context = await browser.newContext({
viewport,
deviceScaleFactor: 2,
});
const page = await context.newPage();
await page.goto(pathToFileUrl(PREVIEW_HTML));
if (withHoverCss) {
await page.addStyleTag({ content: AUT1_HOVER_CSS });
}
const target = page.locator(
hover
? '[data-testid="primary-button-hover"]'
: '[data-testid="primary-button-idle"]',
);
const stage = page.locator(
hover ? "#stage-hover" : "#stage-idle",
);
if (hover) {
await target.hover();
// Wait for the 0.15s transition to settle.
await page.waitForTimeout(220);
}
const filename = `${label}-${withHoverCss ? "after" : "before"}.png`;
const destination = path.join(OUTPUT_DIR, filename);
await stage.screenshot({ path: destination, type: "png" });
await context.close();
return destination;
}
function pathToFileUrl(filePath) {
// Playwright's `file://` URLs need absolute paths. On POSIX this is
// straightforward; on Windows this helper keeps the script cross-platform
// should it ever run there.
const absolute = path.resolve(filePath);
return absolute.startsWith("/") ? `file://${absolute}` : `file:///${absolute}`;
}
async function main() {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const viewports = [
{ label: "desktop", viewport: { width: 1280, height: 720 } },
{
label: "mobile",
viewport: { width: 393, height: 851 },
},
];
for (const { label, viewport } of viewports) {
for (const withHoverCss of [false, true]) {
for (const hover of [false, true]) {
// We only want idle on the "before" pass and hover on the
// "after" pass — skip the two redundant combinations.
const isIdleShot = !hover;
const isBeforeShot = !withHoverCss;
if (isIdleShot !== isBeforeShot) continue;
const file = await captureVariant({
browser,
viewport,
label,
withHoverCss,
hover,
});
// eslint-disable-next-line no-console
console.log("wrote", path.relative(REPO_ROOT, file));
}
}
}
} finally {
await browser.close();
}
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error("aut-1 capture failed:", err);
process.exitCode = 1;
});
+2 -5
View File
@@ -7,11 +7,8 @@ const strict = argv.includes("--strict");
const failures = [];
const warnings = [];
const root = process.cwd();
// App Store Connect uses the bare `da` locale code for Danish (not `da-DK`).
// Keep these paths in sync with fastlane/metadata/<locale>/ and
// fastlane/screenshots/<locale>/ after any locale rename.
const metadataRoot = join(root, "fastlane/metadata/da");
const screenshotRoot = join(root, "fastlane/screenshots/da");
const metadataRoot = join(root, "fastlane/metadata/da-DK");
const screenshotRoot = join(root, "fastlane/screenshots/da-DK");
const fail = (message) => failures.push(message);
const warn = (message) => warnings.push(message);
-1
View File
@@ -122,7 +122,6 @@ export const ownedFilesByRole = {
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js",
"workfeed-config.smoke.spec.js",
"xlvask-flag-to-selvvash-navigation.spec.ts",
],
};
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env bash
# Sergii Review Batch smoke test (TRU-96).
#
# Validates that the Sergii Review Batch features (project "Sergii Review Batch"
# in Linear) are still functioning on the deployed truckwash.io dashboard
# after a release.
#
# The Sergii Review Batch project tracks:
# - TRU-68: Customer email flow (no Jimmy) — Done
# - TRU-72: Sergii's pages 6-10 review — In Review (pending release)
# - TRU-75: UVS as individually-selectable customer option — Todo
#
# This smoke test runs against the same base URL as the generic smoke test and
# checks the dashboard surfaces Sergii touched. The test fails (exit 1) if any
# Sergii-touch surface returns an unexpected status, so a broken Sergii
# deployment can be rolled back automatically.
#
# Usage: ./scripts/smoke-test-sergii.sh [base_url]
# Default: https://staging.truckwash.io
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-2xx}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
elif [[ "$status" == "$expected" ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Sergii Review Batch smoke test against $BASE_URL"
echo " (project: Sergii Review Batch — TRU-68, TRU-72, TRU-75)"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Sergii pages 6-10 (TRU-72) ===
# Sergii's batch touched the customer, products, and orders surfaces of the
# dashboard. Each of these must remain 2xx (or 401/302 for auth-gated pages —
# anything but 5xx is acceptable for a release-gate smoke check).
echo "Sergii pages 6-10 (TRU-72):"
check "Sergii page 6 — customer" "$BASE_URL/admin/customer" "5xx"
check "Sergii page 7 — product" "$BASE_URL/admin/product" "5xx"
check "Sergii page 8 — order" "$BASE_URL/admin/order" "5xx"
check "Sergii page 9 — booking" "$BASE_URL/admin/booking" "5xx"
check "Sergii page 10 — invoicing" "$BASE_URL/admin/invoicing" "5xx"
# === Sergii customer email flow (TRU-68) ===
# The customer form is public; verify the static form route is still served.
echo
echo "Sergii customer email flow (TRU-68):"
check "customer list (Sergii touched)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form (Sergii touched)" "$BASE_URL/kundeoprettelse" "2xx"
# === Sergii UVS program option (TRU-75) ===
# The UVS wash program must be exposed in the public self-serve program
# registry so customers can pick it individually (not bundled into FF).
echo
echo "Sergii UVS program option (TRU-75):"
check "self-serve program registry" "$BASE_URL/self-serve/program" "2xx"
check "self-serve vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Sergii Review Batch sanity — dashboard still bootstraps ===
echo
echo "Dashboard bootstrap:"
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
check "login page" "$BASE_URL/login" "2xx"
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ Sergii Review Batch smoke test passed${NC}"
exit 0
else
echo -e "${RED}✗ Sergii Review Batch smoke test FAILED${NC}"
echo " A Sergii-touch surface on $BASE_URL is broken."
echo " Consider rolling back the most recent Sergii batch."
exit 1
fi
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env bash
# Generic smoke test for any deployed app.
#
# Usage: ./scripts/smoke-test.sh [base_url]
# Default: https://staging.truckwash.io
#
# Required env vars (set by GitHub Action):
# SMOKE_BASE_URL - base URL to test (default: https://staging.truckwash.io)
#
# Optional env vars:
# SMOKE_TOKEN - bearer token for authenticated checks
# SMOKE_TIMEOUT - curl timeout in seconds (default: 10)
#
# Exits 0 on all-pass, 1 on any failure.
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-200}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$status" =~ ^($expected)$ ]] || [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Smoke test against $BASE_URL"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Health endpoints (universal) ===
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
# === Authentication (should NOT 500) ===
check "login page" "$BASE_URL/login" "2xx"
# === Public endpoints (api repo) ===
check "customer list (public schema)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form" "$BASE_URL/kundeoprettelse" "2xx"
# === Public endpoints (pleno-vue) ===
check "self-serve program picker" "$BASE_URL/self-serve/program" "2xx"
check "vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Custom 404 should not 500 ===
check "404 page" "$BASE_URL/this-route-does-not-exist" "404"
# === Optional authenticated check ===
if [ -n "${SMOKE_TOKEN:-}" ]; then
check "auth check" "$BASE_URL/api/me" "2xx"
fi
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ All smoke tests passed${NC}"
exit 0
else
echo -e "${RED}✗ Some smoke tests failed${NC}"
exit 1
fi
-27
View File
@@ -144,30 +144,3 @@ body:not(.pleno-large-table-headers) .table thead th {
margin-top: 30px !important;
}
}
/* AUT-1: enhanced primary-button hover state.
Bulma's default only nudges background lightness; this adds a small
lift + brightness shift so the hover is unambiguous. Transitions keep
it smooth so it doesn't feel jarring on click-heavy screens. */
.button.is-primary {
transition:
filter 0.15s ease,
transform 0.15s ease;
}
.button.is-primary:hover,
.button.is-primary.is-hovered {
filter: brightness(1.08);
transform: translateY(-1px);
}
.button.is-primary:active,
.button.is-primary.is-active {
filter: brightness(0.95);
transform: translateY(0);
}
.button.is-primary:focus-visible {
filter: brightness(1.04);
transform: translateY(-1px);
}
@@ -328,47 +328,6 @@ const getCustomerDetailItems = (scan) => {
return items;
};
const getLastWashDetailItem = (scan) => {
// TRU-78 / DRIFT 17: surface the "last washed" timestamp for a scanned
// license plate on the landing page so the operator can see at a glance
// when a trailer was last washed (DHL pick-up use case).
const lastWash = formatLastWashTimestamp(scan?.last_wash);
if (!lastWash) {
return null;
}
return {
label: SessionUser.objects.global.language.last_wash || t('admin.pos.recent_washes'),
value: lastWash,
testKey: 'last-wash',
};
};
const getLastWashEmptyStateText = () => {
return t('admin.pos.never_washed');
};
const formatLastWashTimestamp = (value) => {
if (!isMeaningfulValue(value)) {
return '';
}
const parsedDate = new Date(String(value).replace(' ', 'T'));
if (Number.isNaN(parsedDate.getTime())) {
return String(value);
}
try {
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(parsedDate);
} catch (error) {
console.error('Unable to format last_wash timestamp', error);
return String(value);
}
};
const getVehicleDetailItems = (scan) => {
const detailState = getPlateDetailState(scan.plate);
if (detailState.status !== 'loaded') {
@@ -534,29 +493,19 @@ setTimeout(() => {
:data-testid="`pos-recent-scan-details-${scan.id}`"
>
<div
v-if="getCustomerDetailItems(scan).length > 0 || getLastWashDetailItem(scan)"
v-if="getCustomerDetailItems(scan).length > 0"
class="pos-scan-details__grid pos-scan-details__grid--customer"
>
<div
v-for="item in [...getCustomerDetailItems(scan), getLastWashDetailItem(scan)].filter(Boolean)"
v-for="item in getCustomerDetailItems(scan)"
:key="item.label"
class="pos-scan-detail-field"
:data-testid="`pos-recent-scan-detail-${scan.id}-${item.testKey || item.label}`"
>
<span class="pos-scan-detail-field__label">{{ item.label }}</span>
<span class="pos-scan-detail-field__value">{{ item.value }}</span>
</div>
</div>
<p
v-if="!getLastWashDetailItem(scan) && scan.last_wash == null"
class="pos-scan-details__state"
:data-testid="`pos-recent-scan-never-washed-${scan.id}`"
>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<span>{{ getLastWashEmptyStateText() }}</span>
</p>
<div v-if="getPlateDetailState(scan.plate).status === 'loading'" class="pos-scan-details__state">
<i class="fas fa-spinner fa-spin"></i>
<span>{{ $t('admin.pos.recent_scan_details_loading') }}</span>
@@ -27,7 +27,7 @@ import productImage21 from "/src/assets/piktogrammer/21.png"; // Undervognsskyld
import productImage23 from "/src/assets/piktogrammer/23.png"; // Spotfree
import productImage24 from "/src/assets/piktogrammer/24.png"; // Spotfree
import productImage25 from "/src/assets/piktogrammer/25.png"; // Fælg flex
import productImage26 from "/src/assets/piktogrammer/26.png"; // Voksforsegling (TRU-130: brand-agnostic after wax supplier swap)
import productImage26 from "/src/assets/piktogrammer/26.png"; // Højglans
import productImage27 from "/src/assets/piktogrammer/27.png"; // 10 Minutter
import productImage39 from "/src/assets/piktogrammer/39.png"; // Trailer - Varevogn
import productImage43 from "/src/assets/piktogrammer/43.png"; // Handling of materials
@@ -3,11 +3,6 @@ import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { buildAuditedOrderItemReasonPayload, editOrderItem } from "@/components/shop/OrdersItems.vue";
import {
EXTRA_SALE_REASONS,
isExtraSaleAuditProduct,
isExtraSaleCommentRequired,
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
const props = defineProps({
modelValue: {
@@ -34,8 +29,6 @@ const form = reactive({
notes: '',
reference: '',
quantity: '1',
extraSaleReasonCode: '',
extraSaleComment: '',
});
const isSubmitting = ref(false);
@@ -52,8 +45,6 @@ const syncForm = () => {
form.notes = String(props.orderItem?.notes ?? '');
form.reference = String(props.orderItem?.reference ?? '');
form.quantity = String(props.orderItem?.quantity ?? 1);
form.extraSaleReasonCode = String(props.orderItem?.extra_sale_reason_code ?? '');
form.extraSaleComment = String(props.orderItem?.extra_sale_comment ?? '');
errorMessage.value = '';
};
@@ -69,27 +60,8 @@ const isQuantityValid = computed(() => {
return Number.isInteger(value) && value > 0;
});
const requiresExtraSaleAudit = computed(() => isExtraSaleAuditProduct(props.orderItem?.product || props.orderItem));
const isExtraSaleAuditValid = computed(() => {
if (!requiresExtraSaleAudit.value) {
return true;
}
if (!form.extraSaleReasonCode) {
return false;
}
return !isExtraSaleCommentRequired(form.extraSaleReasonCode) || form.extraSaleComment.trim().length > 0;
});
const canSubmit = computed(() => {
return Boolean(props.orderItem)
&& props.canEdit
&& !isSubmitting.value
&& isPriceValid.value
&& isQuantityValid.value
&& isExtraSaleAuditValid.value;
return Boolean(props.orderItem) && props.canEdit && !isSubmitting.value && isPriceValid.value && isQuantityValid.value;
});
const closeModal = () => {
@@ -118,11 +90,7 @@ const saveChanges = async () => {
reason_code: props.orderItem.reason_code,
reason_label_snapshot: props.orderItem.reason_label_snapshot,
reason_comment: form.notes,
}),
{
extra_sale_reason_code: form.extraSaleReasonCode || null,
extra_sale_comment: form.extraSaleComment.trim() || null,
}
})
);
emits('saved');
} catch (error) {
@@ -209,41 +177,6 @@ const saveChanges = async () => {
></textarea>
</div>
<template v-if="requiresExtraSaleAudit">
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-extra-sale-reason">Årsag</label>
<div class="select is-fullwidth">
<select
id="pos-order-item-extra-sale-reason"
v-model="form.extraSaleReasonCode"
:disabled="!canEdit"
data-testid="pos-order-item-extra-sale-reason"
>
<option value="">Vælg godkendt årsag</option>
<option
v-for="reason in EXTRA_SALE_REASONS"
:key="reason.code"
:value="reason.code"
>
{{ reason.label }}
</option>
</select>
</div>
</div>
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-extra-sale-comment">Kommentar</label>
<input
id="pos-order-item-extra-sale-comment"
v-model="form.extraSaleComment"
class="input"
type="text"
:disabled="!canEdit"
data-testid="pos-order-item-extra-sale-comment"
>
</div>
</template>
<div class="column is-12">
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
<input
@@ -38,23 +38,17 @@ import {
reg_2,
reg_3,
department_id,
customer_id,
customer_id,
customer_attributes,
customer_attributes_status,
customer_name,
getAddonRestriction,
getProductRestriction,
customer_name,
getAddonRestriction,
getProductRestriction,
retryCustomerAttributes,
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import {
AUDITED_ORDER_ITEM_PRODUCT_IDS,
createOrderItem,
getOrderItems,
removeOrderItem,
} from "@/components/shop/OrdersItems.vue";
import { syncMobileOrderItems } from "@/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js";
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
@@ -64,7 +58,6 @@ import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
import { useI18n } from "vue-i18n";
import { promptExtraSaleAuditIfRequired } from "@/components/displays/department/pos/utils/extraSaleAudit.js";
const { t } = useI18n();
@@ -352,14 +345,14 @@ const applyPendingBookingFromSelection = async () => {
effectivePrimaryProduct = firstWash;
}
}
effectivePrimaryProduct.addons = preparedAddons as any;
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value.addons = preparedAddons as any;
}
sanitizeRestrictedTransactionItems();
effectivePrimaryProduct.addons = preparedAddons as any;
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value.addons = preparedAddons as any;
}
sanitizeRestrictedTransactionItems();
lastAppliedBookingId.value = booking.id;
lastAppliedBookingId.value = booking.id;
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
@@ -892,12 +885,83 @@ watch(
{ immediate: true }
);
// The pre-existing helpers buildDesiredOrderItemShapes and
// normalizeExistingOrderItemShapes (and their shared normalizeOrderItemShape
// normalizer) used to live here. They have been extracted to
// src/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js
// so they can be unit-tested in isolation and so the same comparison logic
// is used by syncCurrentTransactionToOrder and any future caller.
const normalizeOrderItemShape = (item: any) => ({
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
related_item_id:
item?.related_item_id === null || item?.related_item_id === undefined ? null : Number(item.related_item_id),
price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ""),
});
const buildDesiredOrderItemShapes = () => {
if (!transactionItems.primaryItem.value) {
return [];
}
const primaryShape = {
kind: "primary",
relatedKey: "primary",
product_id: Number(transactionItems.primaryItem.value.id),
quantity: 1,
related_item_id: null,
price: Number(transactionItems.primaryItem.value.price ?? 0),
notes: String(transactionItems.primaryItem.value?.notes ?? ""),
skip_price_override: transactionItems.primaryItem.value?.skip_price_override === true,
};
const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return {
kind: "addon",
relatedKey: "primary",
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: "__PRIMARY__",
price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
};
});
const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({
kind: "additional",
relatedKey: null,
product_id: Number(item?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
related_item_id: null,
price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ""),
skip_price_override: item?.skip_price_override === true,
}));
return [primaryShape, ...addonShapes, ...additionalShapes];
};
const normalizeExistingOrderItemShapes = (items: any[]) => {
const primaryItems = items.filter(
(item: any) => item?.related_item_id === null || item?.related_item_id === undefined
);
if (primaryItems.length === 0) {
return [];
}
const additionalItems = primaryItems.filter(
(item: any) =>
Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(transactionItems.primaryItem.value?.id ?? 0)
);
const primaryItemShape = normalizeOrderItemShape(primaryItems[0]);
const addonShapes = items
.filter((item: any) => item?.related_item_id === primaryItems[0]?.id)
.map(normalizeOrderItemShape);
const additionalShapes = additionalItems.map(normalizeOrderItemShape);
return [primaryItemShape, ...addonShapes, ...additionalShapes];
};
const sortComparableLastWashShapes = (shapes: Array<{ kind: string; product_id: number; quantity: number }>) =>
shapes.slice().sort((left, right) => {
@@ -924,8 +988,8 @@ const buildCurrentSelectionComparableShapes = () => {
};
const addonShapes = sortComparableLastWashShapes(
(transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
(transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => ({
kind: "addon",
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
@@ -934,8 +998,8 @@ const buildCurrentSelectionComparableShapes = () => {
);
const additionalShapes = sortComparableLastWashShapes(
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({
kind: "additional",
product_id: Number(item?.id ?? 0),
@@ -1010,19 +1074,65 @@ const syncCurrentTransactionToOrder = async () => {
return false;
}
// The sync helper owns the create / rollback / allSettled logic and lives
// in tests/unit/pos-mobile-step-2-addon-sync.spec.js. Returning its result
// unchanged preserves the existing contract: true = synced (or already in
// sync), false = primary product blocked, throw = partial failure.
await syncMobileOrderItems({
orderId: normalizedOrderId,
primaryItem: transactionItems.primaryItem.value,
additionalItems: transactionItems.additionalItems.value || [],
isAddonRestricted: isMobileAddonRestricted,
isStandaloneRestricted: isStandaloneAdditionalItemRestricted,
isPrimaryRestricted: (item) => getProductRestriction(item).restricted,
api: { createOrderItem, getOrderItems, removeOrderItem },
});
const existingItemsResponse = await getOrderItems(normalizedOrderId);
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
const desiredShapes = buildDesiredOrderItemShapes();
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
const comparableDesiredShapes = desiredShapes.map(({
kind: _kind,
relatedKey: _relatedKey,
skip_price_override: _skipPriceOverride,
...shape
}) => shape);
if (!shouldForceRecreateForRepricing && JSON.stringify(currentShapes) === JSON.stringify(comparableDesiredShapes)) {
return true;
}
await Promise.all(existingItems.map((item: any) => removeOrderItem(item.id)));
const createdPrimaryItemResponse = await createOrderItem(
normalizedOrderId,
transactionItems.primaryItem.value.id,
1,
null,
transactionItems.primaryItem.value?.notes || "",
transactionItems.primaryItem.value.skip_price_override === true ? null : transactionItems.primaryItem.value.price
);
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return createOrderItem(
normalizedOrderId,
addonProduct.id,
Number(addon.quantity),
createdPrimaryItemId,
addonProduct?.notes || addon?.notes || "",
addonProduct?.skip_price_override === true || addon?.skip_price_override === true
? null
: addonProduct.price ?? addon.price
);
});
const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) =>
createOrderItem(
normalizedOrderId,
item.id,
Number(item.quantity),
null,
item?.notes || "",
item?.skip_price_override === true ? null : item.price
)
);
await Promise.all([...addonPromises, ...additionalPromises]);
return true;
};
@@ -1030,7 +1140,8 @@ const normalizeText = (value: unknown) => String(value ?? "").trim();
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
const getProductId = (product: any) => Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductId = (product: any) =>
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
@@ -1041,13 +1152,13 @@ const productRequiresOrderItemNote = (product: any) => {
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
AUDITED_ORDER_ITEM_PRODUCT_IDS.has(getProductId(product)) ||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
};
const productHasOrderItemNote = (product: any) => normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const productHasOrderItemNote = (product: any) =>
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const getSelectedProductsMissingRequiredNotes = () => {
const missingProducts: any[] = [];
@@ -1057,8 +1168,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
missingProducts.push(primaryProduct);
}
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.forEach((addon: any) => {
const addonProduct = addon?.product ?? addon;
if (
@@ -1070,8 +1181,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
}
});
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item);
@@ -1427,15 +1538,15 @@ const filteredAddons = computed(() => {
<i class="fa fa-search"></i>
</span>
</button>
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<p
v-if="restrictionWarningMessageKey"
class="notification is-warning is-light py-2 px-3 mb-0"
data-testid="pos-mobile-restriction-warning"
>
{{ t(restrictionWarningMessageKey) }}
</p>
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<p
v-if="restrictionWarningMessageKey"
class="notification is-warning is-light py-2 px-3 mb-0"
data-testid="pos-mobile-restriction-warning"
>
{{ t(restrictionWarningMessageKey) }}
</p>
<div
v-if="customer_attributes_status === 'error'"
class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between py-2 px-3 mb-0"
@@ -1451,8 +1562,8 @@ const filteredAddons = computed(() => {
{{ t("common.retry") }}
</button>
</div>
<!-- Product -->
<PosDepartmentStepMobile2Product
<!-- Product -->
<PosDepartmentStepMobile2Product
v-on:pointerdown="onPrimaryProductPointerDown"
v-on:pointermove="onPrimaryProductPointerMove"
v-on:pointerup="onPrimaryProductPointerUp"
@@ -1,335 +0,0 @@
/**
* Pure, side-effect-free (apart from the injected API calls) helper that
* reconciles the in-memory mobile POS transaction with the server-side
* order_items table.
*
* Behavior contract:
* - Every primary product add-on with quantity > 0 that is not
* customer-rule-restricted becomes its own order_items row, linked to
* the freshly-created primary row by related_item_id.
* - Every additional (standalone) item with quantity > 0 that is not
* customer-rule-restricted becomes its own order_items row.
* - The old "Promise.all over parallel POSTs" fan-out silently dropped
* rows on a single rejection: the rows that already landed stayed on
* the server while the operator saw only a generic failure popup.
* This helper delegates the add-on / additional-item fan-out to the
* shared `addOrderItemAddons` helper, which uses Promise.allSettled,
* collects every per-product failure, and rolls back every
* order_items row created during this attempt before throwing, so a
* retry starts from a clean state.
*
* The shared error class + helpers live in
* `src/components/displays/department/pos/utils/orderItemsPartialSync.js`
* so the desktop `addAddonsToOrderMiddleware` can throw the same shape.
*/
import {
OrderItemsPartialSyncError,
extractErrorMessage,
formatFailureFragment,
} from "@/components/displays/department/pos/utils/orderItemsPartialSync.js";
import { addOrderItemAddons } from "@/components/displays/department/pos/utils/addOrderItemAddons.js";
// Re-export for back-compat with existing tests / call sites.
export { OrderItemsPartialSyncError, extractErrorMessage, formatFailureFragment };
const PLACEHOLDER_PRIMARY_RELATED_ITEM_ID = "__PRIMARY__";
const toInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) ? parsed : null;
};
const toPositiveInteger = (value) => {
const parsed = toInteger(value);
return parsed !== null && parsed > 0 ? parsed : null;
};
const toNonNegativeNumber = (value) => {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
};
const safeGet = (object, ...keys) => {
for (const key of keys) {
const candidate = object?.[key];
if (candidate !== undefined && candidate !== null && candidate !== "") {
return candidate;
}
}
return undefined;
};
/**
* Normalize a single order item from the server (returned by GET
* /order/items) into the comparison shape used by the idempotency check.
*
* related_item_id is intentionally excluded from the comparison shape so
* that "desired add-on with placeholder related_item_id" can be matched
* against "existing add-on with real numeric related_item_id". The other
* fields (product_id, quantity, price, notes) fully characterize the row.
*/
export const normalizeExistingOrderItemShape = (item) => ({
product_id: Number(safeGet(item, "product_id", "product_id") ?? 0),
quantity: toNonNegativeNumber(safeGet(item, "quantity", "quantity")),
price: Number(safeGet(item, "price", "price") ?? 0),
notes: String(safeGet(item, "notes", "notes") ?? ""),
});
/**
* Group raw server items into [primaryShape, ...addonShapes, ...additionalShapes]
* so we can compare the server state with what we want the server to look like.
*
* - `primaryItems` is every item with related_item_id === null. The first one
* is treated as the primary; subsequent ones are standalone "additional"
* items unless their product id matches the in-memory primary product.
* - `addonShapes` are items whose related_item_id equals the primary's id.
*/
export const normalizeExistingOrderItemShapes = (existingItems, primaryProductId) => {
const items = Array.isArray(existingItems) ? existingItems : [];
const primaryItems = items.filter((item) => item?.related_item_id === null || item?.related_item_id === undefined);
if (primaryItems.length === 0) {
return [];
}
const primaryItem = primaryItems[0];
const primaryItemId = Number(primaryItem?.id ?? 0);
const primaryShape = normalizeExistingOrderItemShape(primaryItem);
const additionalItems = primaryItems
.filter((item) => Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(primaryProductId ?? 0))
.map(normalizeExistingOrderItemShape);
const addonShapes =
primaryItemId > 0
? items
.filter((item) => Number(item?.related_item_id ?? 0) === primaryItemId)
.map(normalizeExistingOrderItemShape)
: [];
return [primaryShape, ...addonShapes, ...additionalItems];
};
/**
* Build the desired shapes from the in-memory primaryItem + additionalItems.
* Uses a stable placeholder ("__PRIMARY__") for the add-on related_item_id
* because the real primary id is not known until the primary POST resolves.
*/
export const buildDesiredOrderItemShapes = ({
primaryItem,
additionalItems,
isAddonRestricted = () => false,
isStandaloneRestricted = () => false,
}) => {
if (!primaryItem) {
return [];
}
const primaryShape = {
kind: "primary",
relatedKey: "primary",
product_id: Number(primaryItem.id ?? 0),
quantity: 1,
related_item_id: null,
price: Number(primaryItem.price ?? 0),
notes: String(primaryItem.notes ?? ""),
skip_price_override: primaryItem.skip_price_override === true,
};
const addonShapes = (Array.isArray(primaryItem.addons) ? primaryItem.addons : [])
.filter((addon) => Number(addon?.quantity ?? 0) > 0 && !isAddonRestricted(addon))
.map((addon) => {
const addonProduct = addon?.product ?? addon;
return {
kind: "addon",
relatedKey: "primary",
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
quantity: Number(addon?.quantity ?? 0),
related_item_id: PLACEHOLDER_PRIMARY_RELATED_ITEM_ID,
price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
};
});
const additionalShapes = (Array.isArray(additionalItems) ? additionalItems : [])
.filter((item) => Number(item?.quantity ?? 0) > 0 && !isStandaloneRestricted(item))
.map((item) => ({
kind: "additional",
relatedKey: null,
product_id: Number(item?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
related_item_id: null,
price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ""),
skip_price_override: item?.skip_price_override === true,
}));
return [primaryShape, ...addonShapes, ...additionalShapes];
};
const stripKindMarkers = (shape) => {
const {
kind: _kind,
relatedKey: _relatedKey,
skip_price_override: _skip,
// related_item_id is intentionally stripped: in the desired shapes it is
// the placeholder "__PRIMARY__" for add-ons (the real primary id is not
// known until the primary POST resolves), while in the existing shapes
// it is the real numeric primary id. Without stripping, the comparison
// would always fail and the helper would rebuild the order on every
// Fuldfør click.
related_item_id: _relatedItemId,
...rest
} = shape;
return rest;
};
const shallowEqual = (left, right) => {
if (left === right) return true;
if (!left || !right) return false;
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) return false;
for (const key of leftKeys) {
if (left[key] !== right[key]) return false;
}
return true;
};
const arraysEqual = (left, right) => {
if (left === right) return true;
if (!Array.isArray(left) || !Array.isArray(right)) return false;
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
if (!shallowEqual(left[i], right[i])) return false;
}
return true;
};
/**
* @typedef {Object} OrderItemApi
* @property {(orderId, productId, quantity, relatedItemId, notes, price) => Promise<{data:{data:{id:number}}}>} createOrderItem
* @property {(orderId) => Promise<{data:{data:Array<object>}}>} getOrderItems
* @property {(orderItemId) => Promise<unknown>} removeOrderItem
*/
/**
* Reconcile the in-memory transaction against the server's order_items.
*
* @param {object} args
* @param {number} args.orderId
* @param {object|null} args.primaryItem
* @param {Array<object>} [args.additionalItems]
* @param {(addon:object) => boolean} [args.isAddonRestricted]
* @param {(item:object) => boolean} [args.isStandaloneRestricted]
* @param {(primary:object) => boolean} [args.isPrimaryRestricted]
* @param {OrderItemApi} args.api
* @returns {Promise<{createdIds: number[], createdPrimaryItemId: number|null}>}
* @throws {OrderItemsPartialSyncError} when any add-on or additional POST fails
* after the partial rollback has already run.
*/
export const syncMobileOrderItems = async ({
orderId,
primaryItem,
additionalItems = [],
isAddonRestricted = () => false,
isStandaloneRestricted = () => false,
isPrimaryRestricted = () => false,
api,
}) => {
const normalizedOrderId = toPositiveInteger(orderId);
if (!normalizedOrderId) {
throw new Error("Order ID is required");
}
if (!primaryItem) {
throw new Error("No primary item selected");
}
if (isPrimaryRestricted(primaryItem)) {
return { createdIds: [], createdPrimaryItemId: null };
}
const existingItemsResponse = await api.getOrderItems(normalizedOrderId);
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
const desiredShapes = buildDesiredOrderItemShapes({
primaryItem,
additionalItems,
isAddonRestricted,
isStandaloneRestricted,
});
const currentShapes = normalizeExistingOrderItemShapes(existingItems, primaryItem?.id);
const comparableDesiredShapes = desiredShapes.map(stripKindMarkers);
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
if (!shouldForceRecreateForRepricing && arraysEqual(currentShapes, comparableDesiredShapes)) {
return { createdIds: [], createdPrimaryItemId: null };
}
// Tear down whatever the server currently has for this order, including
// any half-synced rows left over from a previous failed attempt.
await Promise.allSettled(existingItems.map((item) => api.removeOrderItem(Number(item.id))));
let primaryCreate;
try {
primaryCreate = await api.createOrderItem(
normalizedOrderId,
Number(primaryItem.id ?? 0),
1,
null,
String(primaryItem.notes ?? ""),
primaryItem.skip_price_override === true ? null : Number(primaryItem.price ?? 0)
);
} catch (reason) {
// The primary row never landed, so there is nothing to roll back. Wrap
// the rejection so the UI layer gets the same error shape for both
// primary and add-on failures.
throw new OrderItemsPartialSyncError(
extractErrorMessage(reason),
[{ productId: Number(primaryItem.id ?? 0), message: extractErrorMessage(reason) }],
[]
);
}
const createdPrimaryItemId = toPositiveInteger(primaryCreate?.data?.data?.id);
const addonCandidates = (Array.isArray(primaryItem.addons) ? primaryItem.addons : [])
.filter((addon) => Number(addon?.quantity ?? 0) > 0 && !isAddonRestricted(addon))
.map((addon) => ({ ...addon, priceOverride: true }));
const additionalCandidates = (Array.isArray(additionalItems) ? additionalItems : [])
.filter((item) => Number(item?.quantity ?? 0) > 0 && !isStandaloneRestricted(item))
.map((item) => ({ ...item, priceOverride: true }));
try {
const { createdIds: childIds } = await addOrderItemAddons({
orderId: normalizedOrderId,
primaryItemId: createdPrimaryItemId,
addons: addonCandidates,
additionalItems: additionalCandidates,
api,
});
return {
createdIds: [createdPrimaryItemId, ...childIds].filter(Boolean),
createdPrimaryItemId,
};
} catch (error) {
// The shared helper has already rolled back every add-on / additional
// row that landed during this attempt. Roll back the primary too so a
// retry starts from a clean state, then re-throw with the same error
// shape (`OrderItemsPartialSyncError`) the rest of the UI expects.
// Preserve the helper's aggregated failures and grow the
// rolledBackIds list to include the primary for callers / tests that
// inspect the error.
if (createdPrimaryItemId) {
await Promise.allSettled([api.removeOrderItem(createdPrimaryItemId)]);
}
if (error && typeof error === "object" && error.name === "OrderItemsPartialSyncError") {
const rolledBackIds = Array.isArray(error.rolledBackIds) ? error.rolledBackIds.slice() : [];
if (createdPrimaryItemId && !rolledBackIds.includes(createdPrimaryItemId)) {
rolledBackIds.push(createdPrimaryItemId);
}
throw new OrderItemsPartialSyncError(error.message, error.failures, rolledBackIds);
}
throw error;
}
};
@@ -1,18 +1,5 @@
/**
* Compatibility shim for the now-removed XL Vask autopilot UI helpers.
*
* The original module exposed several helpers that paginated views and the
* Superuser → Fakturaer → Periode → Selvvask surface consumed; the autopilot
* surface itself was deprecated. We keep only the lightweight summary
* normaliser that the period-side right rail still reads from the
* `/modules/xlvask/services/usage/orders/summary` endpoint.
*
* Any caller that previously imported removed helpers (e.g.
* `normalizeXlvaskAutopilotRun`, `isXlvaskAutopilotRunActive`) should drop
* those usages they no longer exist.
*/
const XLVASK_RESOLUTION_STATES = [
export const XLVASK_IMPORT_STATES = ["new", "updated", "unchanged", "invalid"];
export const XLVASK_RESOLUTION_STATES = [
"already_linked",
"auto_linked",
"auto_created",
@@ -21,29 +8,240 @@ const XLVASK_RESOLUTION_STATES = [
"ignored",
"failed",
];
export const XLVASK_CERTAINTY_STATES = ["certain", "uncertain", "none"];
export const XLVASK_PLANNED_ACTIONS = [
"attach_order",
"create_order",
"resolve_mapping",
"recheck",
"ignore",
"none",
];
const XLVASK_IMPORT_STATES = ["new", "updated", "unchanged", "invalid"];
const XLVASK_CERTAINTY_STATES = ["certain", "uncertain", "none"];
export const emptyXlvaskAutopilotSummary = () => ({
total: 0,
new: 0,
updated: 0,
unchanged: 0,
invalid: 0,
already_linked: 0,
auto_linked: 0,
auto_created: 0,
needs_review: 0,
blocked: 0,
ignored: 0,
failed: 0,
certain: 0,
uncertain: 0,
none: 0,
});
const nonNegativeInteger = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
};
export const emptyXlvaskAutopilotSummary = () => ({
total: 0,
...Object.fromEntries(
[...XLVASK_IMPORT_STATES, ...XLVASK_RESOLUTION_STATES, ...XLVASK_CERTAINTY_STATES].map((key) => [key, 0]),
),
});
export const normalizeXlvaskAutopilotSummary = (rawSummary) => {
export const normalizeXlvaskAutopilotSummary = (summary) => {
const normalized = emptyXlvaskAutopilotSummary();
if (!rawSummary || typeof rawSummary !== "object") {
return normalized;
}
Object.keys(normalized).forEach((key) => {
normalized[key] = nonNegativeInteger(rawSummary[key]);
normalized[key] = nonNegativeInteger(summary?.[key]);
});
return normalized;
};
export const normalizeXlvaskAutopilotRun = (run) => {
if (!run || typeof run !== "object") {
return null;
}
return {
...run,
id: run.id ?? null,
status: String(run.status || "queued"),
phase: String(run.phase || run.status || "queued"),
processed: nonNegativeInteger(run.processed),
total: nonNegativeInteger(run.total),
summary: normalizeXlvaskAutopilotSummary(run.summary),
};
};
const stringList = (value) => Array.isArray(value)
? value.map((entry) => String(entry || "").trim()).filter(Boolean)
: [];
const booleanCapability = (source, key) => source?.[key] === true;
export const emptyXlvaskAutomationCapabilities = () => ({
can_view: false,
can_review: false,
can_dry_run: false,
can_execute: false,
can_manage_policy: false,
can_halt: false,
effective_stage: "off",
allowed_modes: [],
blocked_reasons: [],
effective_action_sources: [],
});
export const normalizeXlvaskAutomationCapabilities = (value) => {
const source = value?.capabilities && typeof value.capabilities === "object"
? value.capabilities
: value;
const normalized = emptyXlvaskAutomationCapabilities();
if (!source || typeof source !== "object") return normalized;
Object.keys(normalized).forEach((key) => {
if (key.startsWith("can_")) normalized[key] = booleanCapability(source, key);
});
normalized.effective_stage = String(source.effective_stage || source.policy_stage || "off").toLowerCase();
normalized.allowed_modes = stringList(source.allowed_modes).map((mode) => mode.toLowerCase());
normalized.blocked_reasons = stringList(source.blocked_reasons);
normalized.effective_action_sources = stringList(source.effective_action_sources);
return normalized;
};
export const emptyXlvaskAutomationReadiness = () => ({
ready: false,
effective_stage: "off",
policy_version: "",
model: "",
worker_healthy: false,
blocked_reasons: [],
budgets: {
attach_order: { remaining_global: 0, remaining_hall: 0 },
create_order: { remaining_global: 0, remaining_hall: 0 },
},
review_progress: {
attach_order: { reviewed: 0, target: 200 },
create_order: { reviewed: 0, target: 50 },
},
eligible_counts: { attach_order: 0, create_order: 0, total: 0 },
calibrations: {},
});
const normalizeProgress = (value, defaultTarget) => ({
reviewed: nonNegativeInteger(value?.reviewed ?? value?.completed),
target: nonNegativeInteger(value?.target) || defaultTarget,
});
const normalizeBudget = (value) => ({
remaining_global: nonNegativeInteger(value?.remaining_global ?? value?.global_remaining),
remaining_hall: nonNegativeInteger(value?.remaining_hall ?? value?.hall_remaining),
});
export const normalizeXlvaskAutomationReadiness = (value) => {
const source = value?.readiness && typeof value.readiness === "object" ? value.readiness : value;
const normalized = emptyXlvaskAutomationReadiness();
if (!source || typeof source !== "object") return normalized;
const policy = source.policy && typeof source.policy === "object" ? source.policy : {};
const worker = source.worker && typeof source.worker === "object"
? source.worker
: source.workers && typeof source.workers === "object"
? source.workers
: {};
normalized.ready = source.ready === true;
normalized.effective_stage = String(
source.effective_stage || policy.effective_stage || policy.stage || "off"
).toLowerCase();
normalized.policy_version = String(source.policy_version || policy.version || "");
normalized.model = String(source.model || source.model_identity || policy.model || "");
normalized.worker_healthy = source.worker_healthy === true || worker.healthy === true;
normalized.blocked_reasons = stringList(source.blocked_reasons);
normalized.budgets.attach_order = normalizeBudget(source.budgets?.attach_order);
normalized.budgets.create_order = normalizeBudget(source.budgets?.create_order);
normalized.review_progress.attach_order = normalizeProgress(source.review_progress?.attach_order, 200);
normalized.review_progress.create_order = normalizeProgress(source.review_progress?.create_order, 50);
normalized.eligible_counts.attach_order = nonNegativeInteger(source.eligible_counts?.attach_order);
normalized.eligible_counts.create_order = nonNegativeInteger(source.eligible_counts?.create_order);
normalized.eligible_counts.total = nonNegativeInteger(source.eligible_counts?.total)
|| normalized.eligible_counts.attach_order + normalized.eligible_counts.create_order;
normalized.calibrations = source.calibrations && typeof source.calibrations === "object"
? source.calibrations
: {};
return normalized;
};
export const isXlvaskReviewEligible = (row) => {
return row?.automation?.review_eligible === true;
};
export const isXlvaskAutopilotRunActive = (run) => {
return ["queued", "pending", "running", "processing", "retry_wait"]
.includes(String(run?.status || "").toLowerCase());
};
export const xlvaskAutopilotRunProgress = (run) => {
const total = nonNegativeInteger(run?.total);
const processed = Math.min(total, nonNegativeInteger(run?.processed));
return total > 0 ? Math.round((processed / total) * 100) : 0;
};
export const getXlvaskAutomation = (row) => row?.automation && typeof row.automation === "object"
? row.automation
: {};
export const getXlvaskImportState = (row) => {
const value = String(row?.import_state || "").toLowerCase();
return XLVASK_IMPORT_STATES.includes(value) ? value : "unchanged";
};
export const getXlvaskResolutionState = (row) => {
const value = String(row?.resolution_state || "").toLowerCase();
if (XLVASK_RESOLUTION_STATES.includes(value)) {
return value;
}
const status = String(getXlvaskAutomation(row).status || "").toLowerCase();
if (status === "failed") return "failed";
if (status === "denied") return "needs_review";
if (status === "auto_accepted") {
return getXlvaskAutomation(row).action === "create_order" ? "auto_created" : "auto_linked";
}
if (status === "accepted" || row?.linked_order_id || row?.order_id) return "already_linked";
return "needs_review";
};
export const getXlvaskCertainty = (row) => {
const value = String(row?.certainty || "").toLowerCase();
return XLVASK_CERTAINTY_STATES.includes(value) ? value : "none";
};
export const getXlvaskPlannedAction = (row) => {
const value = String(row?.planned_action || getXlvaskAutomation(row).action || "none").toLowerCase();
return XLVASK_PLANNED_ACTIONS.includes(value) ? value : "none";
};
export const getXlvaskCalibratedProbability = (row) => {
const automation = getXlvaskAutomation(row);
const value = Number(automation.calibrated_probability ?? 0);
if (!Number.isFinite(value) || value <= 0) return null;
return Math.min(1, value);
};
export const getXlvaskAutomationList = (row, key) => {
const value = getXlvaskAutomation(row)[key];
return Array.isArray(value) ? value.filter(Boolean) : [];
};
export const xlvaskStateTagClass = (state) => {
if (["already_linked", "auto_linked", "auto_created", "certain"].includes(state)) return "is-success";
if (["invalid", "blocked", "failed"].includes(state)) return "is-danger";
if (["new", "updated", "needs_review", "uncertain"].includes(state)) return "is-warning";
if (state === "ignored") return "is-light";
return "is-info";
};
export const xlvaskEvidenceText = (entry) => {
if (typeof entry === "string") return entry;
return String(entry?.label ?? entry?.message ?? entry?.reason ?? entry?.code ?? "");
};
export const xlvaskCandidateOrderId = (candidate) => candidate?.order_id ?? candidate?.id ?? null;
export const xlvaskCandidateLabel = (candidate) => {
const orderId = xlvaskCandidateOrderId(candidate);
const label = candidate?.label || candidate?.reason || candidate?.customer_name || "";
return [orderId ? `#${orderId}` : "", label].filter(Boolean).join(" · ");
};
@@ -5,6 +5,15 @@ export const isUsageOrderAttachedToOrder = (object) => {
return true;
}
const automation = object?.automation;
const automationStatus = automation?.status;
if (
["auto_accepted", "accepted"].includes(automationStatus) &&
["attach_order", "create_order"].includes(automation?.action)
) {
return true;
}
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
};
File diff suppressed because it is too large Load Diff
@@ -1,204 +0,0 @@
/**
* Pure, side-effect-free (apart from the injected API calls) helper that
* fans out a batch of `createOrderItem` POSTs for primary-product add-ons
* (and, optionally, additional standalone items) and rolls back every
* successful row when any one of the POSTs rejects.
*
* The same fan-out + rollback pattern is shared between the desktop
* (`SelectProductsFormPOS.vue → addAddonsToOrderMiddleware`) and the mobile
* (`PosDepartmentStepMobile2.vue → syncMobileOrderItems`) reconciliation
* paths. The desktop path passes a `primaryItemId` of the row it has just
* created; the mobile helper uses the same helper for its add-on /
* additional-item fan-out after the primary POST resolves.
*
* Why Promise.allSettled: a single rejection inside Promise.all would
* short-circuit the rest of the batch while the rows that already landed
* stayed on the server. On retry the operator saw only "some" of the
* selected add-ons persisted and a generic failure popup. allSettled
* collects every outcome, and the rollback restores a clean server state
* for the next attempt.
*/
import {
OrderItemsPartialSyncError,
extractErrorMessage,
formatFailureFragment,
} from "./orderItemsPartialSync.js";
const toPositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const toNonNegativeNumber = (value) => {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
};
/**
* Build the createOrderItem arguments for one add-on row. The caller passes
* either an addon-shaped object (with `option_id` / `addon_id` / nested
* `product`) or a plain object with `product_id` + `quantity`; this helper
* normalizes both shapes.
*
* The `price` field is only included when the caller has explicitly set
* `priceOverride: true` on the addon. The mobile path always sets the
* override (it computes the customer-discounted price locally and wants
* the server to record it); the desktop path does not (it lets the server
* fall back to the product's default price). This matches the original
* desktop behaviour pre-PR-289 fix, where the existing `createOrderItem`
* call was made with five arguments and the server-side price was left
* untouched.
*
* @param {object} addon
* @param {number} primaryItemId The order_items.id of the freshly-created
* primary row that the add-on should be linked to via related_item_id.
*/
export const buildAddonCreateOrderItemArgs = (addon, primaryItemId) => {
const addonProduct = addon?.product ?? addon ?? {};
const productId = Number(addonProduct?.id ?? addon?.option_id ?? addon?.addon_id ?? addon?.id ?? 0);
const quantity = toNonNegativeNumber(addon?.quantity ?? addonProduct?.quantity);
const relatedItemId = toPositiveInteger(primaryItemId);
const notes = String(addonProduct?.notes ?? addon?.notes ?? "");
const skipPriceOverride =
addonProduct?.skip_price_override === true || addon?.skip_price_override === true;
const shouldOverridePrice = addon?.priceOverride === true;
const price = shouldOverridePrice
? skipPriceOverride
? null
: Number(addonProduct?.price ?? addon?.price ?? 0)
: null;
return {
productId,
quantity,
relatedItemId,
notes,
skipPriceOverride,
overridePrice: shouldOverridePrice,
price,
};
};
/**
* @typedef {Object} AddOrderItemAddonsApi
* @property {(orderId, productId, quantity, relatedItemId, notes, price) => Promise<{data:{data:{id:number}}}>} createOrderItem
* @property {(orderItemId) => Promise<unknown>} removeOrderItem
*/
/**
* Fan out `createOrderItem` for every add-on (and additional item) and
* rollback any rows that landed if any POST rejects.
*
* @param {object} args
* @param {number} args.orderId
* @param {number} args.primaryItemId The order_items.id of the freshly-
* created primary row. Every add-on POST links to it via
* related_item_id.
* @param {Array<object>} [args.addons] Primary-product add-ons to POST.
* @param {Array<object>} [args.additionalItems] Standalone additional
* items to POST (related_item_id is null).
* @param {AddOrderItemAddonsApi} args.api
* @returns {Promise<{createdIds: number[], createdAddonIds: number[], createdAdditionalIds: number[]}>}
* @throws {OrderItemsPartialSyncError} when any add-on or additional POST
* fails, after the partial rollback has already run.
*/
export const addOrderItemAddons = async ({ orderId, primaryItemId, addons = [], additionalItems = [], api }) => {
const normalizedOrderId = toPositiveInteger(orderId);
if (!normalizedOrderId) {
throw new Error("Order ID is required");
}
const normalizedPrimaryItemId = toPositiveInteger(primaryItemId);
if (!normalizedPrimaryItemId) {
throw new Error("Primary order item ID is required");
}
if (!api || typeof api.createOrderItem !== "function" || typeof api.removeOrderItem !== "function") {
throw new Error("createOrderItem and removeOrderItem are required");
}
const addonCandidates = Array.isArray(addons) ? addons.filter((addon) => toNonNegativeNumber(addon?.quantity) > 0) : [];
const additionalCandidates = Array.isArray(additionalItems)
? additionalItems.filter((item) => toNonNegativeNumber(item?.quantity) > 0)
: [];
const addonPromises = addonCandidates.map((addon) => {
const args = buildAddonCreateOrderItemArgs(addon, normalizedPrimaryItemId);
return Promise.resolve(
api.createOrderItem(
normalizedOrderId,
args.productId,
args.quantity,
args.relatedItemId,
args.notes,
args.price
)
).then((response) => ({
productId: args.productId,
relatedItemId: args.relatedItemId,
response,
}));
});
const additionalPromises = additionalCandidates.map((item) => {
const productId = Number(item?.id ?? item?.product_id ?? 0);
const quantity = toNonNegativeNumber(item?.quantity);
const notes = String(item?.notes ?? "");
const skipPriceOverride = item?.skip_price_override === true;
const shouldOverridePrice = item?.priceOverride === true;
const price = shouldOverridePrice ? (skipPriceOverride ? null : Number(item?.price ?? 0)) : null;
return Promise.resolve(
api.createOrderItem(normalizedOrderId, productId, quantity, null, notes, price)
).then((response) => ({
productId,
relatedItemId: null,
response,
}));
});
const results = await Promise.allSettled([...addonPromises, ...additionalPromises]);
const createdAddonIds = [];
const createdAdditionalIds = [];
const failures = [];
results.forEach((result, index) => {
const productId =
index < addonPromises.length
? Number(addonCandidates[index]?.product?.id ?? addonCandidates[index]?.id ?? 0)
: Number(additionalCandidates[index - addonPromises.length]?.id ?? 0);
if (result.status === "fulfilled") {
const id = toPositiveInteger(result.value?.response?.data?.data?.id);
if (id) {
if (index < addonPromises.length) {
createdAddonIds.push(id);
} else {
createdAdditionalIds.push(id);
}
}
return;
}
failures.push({
productId,
message: extractErrorMessage(result.reason),
});
});
if (failures.length === 0) {
return {
createdIds: [...createdAddonIds, ...createdAdditionalIds],
createdAddonIds,
createdAdditionalIds,
};
}
// Roll back every row we created in this attempt so the next attempt
// starts from a clean server state. Use allSettled so a rollback
// rejection doesn't mask the original failure.
const createdIdsToRollBack = [...createdAddonIds, ...createdAdditionalIds];
await Promise.allSettled(createdIdsToRollBack.map((id) => Promise.resolve(api.removeOrderItem(id))));
const aggregated = failures.map(formatFailureFragment).join("; ");
throw new OrderItemsPartialSyncError(aggregated, failures, createdIdsToRollBack);
};
export { OrderItemsPartialSyncError };
@@ -1,91 +0,0 @@
import Swal from "sweetalert2";
export const EXTRA_SALE_PRODUCT_ID = 27;
export const EXTRA_SALE_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
export const EXTRA_SALE_REASONS = [
{ code: "customer_request", label: "Kunde ønskede ekstra tid", commentRequired: false },
{ code: "operational_delay", label: "Driftsforsinkelse i vaskehal", commentRequired: false },
{ code: "rewash_quality", label: "Omkørsel/kvalitet", commentRequired: false },
{ code: "other", label: "Anden godkendt årsag", commentRequired: true },
];
export const isExtraSaleAuditProduct = (product) => {
if (!product || typeof product !== "object") {
return false;
}
if (product.requires_extra_sale_audit === true) {
return true;
}
if (Number(product.id ?? product.product_id ?? 0) === EXTRA_SALE_PRODUCT_ID) {
return true;
}
return String(product.name ?? "").trim() === EXTRA_SALE_PRODUCT_NAME;
};
export const isExtraSaleCommentRequired = (reasonCode) => {
return EXTRA_SALE_REASONS.some((reason) => reason.code === reasonCode && reason.commentRequired);
};
export const extraSaleAuditPayload = ({ reasonCode = null, comment = null } = {}) => ({
extra_sale_reason_code: String(reasonCode || "").trim() || null,
extra_sale_comment: String(comment || "").trim() || null,
});
export const getExtraSaleAuditFromOrderItem = (orderItem = {}) =>
extraSaleAuditPayload({
reasonCode: orderItem.extra_sale_reason_code,
comment: orderItem.extra_sale_comment,
});
export const promptExtraSaleAuditIfRequired = async (product, initial = {}) => {
if (!isExtraSaleAuditProduct(product)) {
return extraSaleAuditPayload(initial);
}
const initialReason = String(initial.reasonCode ?? initial.extra_sale_reason_code ?? "").trim();
const initialComment = String(initial.comment ?? initial.extra_sale_comment ?? "").trim();
const optionsMarkup = EXTRA_SALE_REASONS.map((reason) => {
const selected = reason.code === initialReason ? " selected" : "";
return `<option value="${reason.code}"${selected}>${reason.label}</option>`;
}).join("");
const result = await Swal.fire({
title: "Godkend 10 min ekstra",
html: `
<label class="label has-text-left" for="extra-sale-reason-code">Årsag</label>
<div class="select is-fullwidth mb-3">
<select id="extra-sale-reason-code" class="swal2-select" style="display:block;width:100%;margin:0;">
<option value="">Vælg godkendt årsag</option>
${optionsMarkup}
</select>
</div>
<label class="label has-text-left" for="extra-sale-comment">Kommentar</label>
<textarea id="extra-sale-comment" class="swal2-textarea" rows="4" style="display:block;width:100%;margin:0;" placeholder="Uddyb når årsagen kræver det">${initialComment}</textarea>
`,
showCancelButton: true,
confirmButtonText: "Godkend",
focusConfirm: false,
preConfirm: () => {
const reasonCode = document.getElementById("extra-sale-reason-code")?.value || "";
const comment = document.getElementById("extra-sale-comment")?.value || "";
if (!reasonCode) {
Swal.showValidationMessage("Vælg en godkendt årsag");
return false;
}
if (isExtraSaleCommentRequired(reasonCode) && !String(comment || "").trim()) {
Swal.showValidationMessage("Kommentar er påkrævet for denne årsag");
return false;
}
return extraSaleAuditPayload({ reasonCode, comment });
},
});
return result.isConfirmed ? result.value : null;
};
@@ -1,56 +0,0 @@
/**
* Shared error class + helpers for the POS order_items partial-sync pattern.
*
* Both the desktop (SelectProductsFormPOS.vue → addAddonsToOrderMiddleware)
* and the mobile (PosDepartmentStepMobile2.vue → syncMobileOrderItems)
* reconciliation paths use the same fan-out strategy:
*
* 1. Fan out every pending createOrderItem POST via Promise.allSettled so
* no single rejection short-circuits the batch.
* 2. If any POST rejects, roll back the rows that did land via
* Promise.allSettled(removeOrderItem) so a retry starts from a clean
* server state instead of the half-synced snapshot that caused the
* "only some of the selected primary product add-ons were persisted"
* bug originally filed for the mobile path.
* 3. Throw OrderItemsPartialSyncError so the UI can surface an aggregated
* error message that names every failed product.
*
* Keeping this contract in one place lets both call sites produce the same
* error shape and rollback guarantees.
*/
/**
* Map an axios-like rejection to the human-readable string the operator
* should see. Falls back to the error message itself.
*/
export const extractErrorMessage = (reason) => {
const message =
reason?.response?.data?.data?.message ?? reason?.response?.data?.message ?? reason?.message ?? "Unknown error";
return String(message);
};
/**
* Format a per-product failure into a human-readable fragment. Used to
* aggregate multiple add-on / additional-item rejections into one operator
* message.
*/
export const formatFailureFragment = ({ productId, message }) => {
if (!Number.isFinite(Number(productId)) || Number(productId) <= 0) {
return message;
}
return `Product ${productId}: ${message}`;
};
/**
* Thrown when one or more order_items POSTs fail during a fan-out. Carries
* the aggregated message and the per-product failure list so the UI layer
* can either display it directly or surface structured details.
*/
export class OrderItemsPartialSyncError extends Error {
constructor(message, failures, rolledBackIds) {
super(message);
this.name = "OrderItemsPartialSyncError";
this.failures = failures;
this.rolledBackIds = rolledBackIds;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,44 +0,0 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps({
isRedCar: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
customerNumber: {
type: [String, Number, null],
default: null,
},
});
const emit = defineEmits(["dismiss"]);
const { t } = useI18n();
const shouldRender = computed(() => Boolean(props.isRedCar));
</script>
<template>
<b-message
v-if="shouldRender"
type="is-warning"
has-icon
:closable="true"
data-testid="red-car-warning"
:title="t('self_wash.red_car_warning_title')"
@close="emit('dismiss')"
>
<p data-testid="red-car-warning-message">
{{ t("self_wash.red_car_warning_message") }}
</p>
<p class="mt-2" data-testid="red-car-warning-suggestion">
{{ t("self_wash.red_car_warning_suggestion") }}
</p>
</b-message>
</template>
@@ -2,7 +2,6 @@
import { computed, ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
import RedCarWarning from "@/components/displays/selfServe/RedCarWarning.vue";
const props = defineProps<{
customerNumber: string | number | null;
@@ -18,7 +17,6 @@ const props = defineProps<{
vehicleTypes: Array<any>;
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
isRedCar?: boolean;
}>();
const emit = defineEmits<{
@@ -143,11 +141,6 @@ const emitVehicleTypeSelection = (selection: any) => {
>
{{ props.vehicleStepError }}
</b-message>
<RedCarWarning
v-if="!props.vehicleStepError && props.isRedCar"
:is-red-car="true"
:customer-number="customerNumber"
/>
<b-message
v-else-if="props.vehicleStepGuidance"
type="is-info"
@@ -50,16 +50,7 @@ const getOrders = (object) => {
let invoice_orders = invoice.objects;
orders = orders.concat(invoice_orders);
}
return orders
.slice()
.sort((left, right) => {
const leftId = Number.parseInt(String(left?.id ?? ""), 10);
const rightId = Number.parseInt(String(right?.id ?? ""), 10);
if (Number.isInteger(leftId) && Number.isInteger(rightId)) {
return leftId - rightId;
}
return String(left?.id ?? "").localeCompare(String(right?.id ?? ""));
});
return orders;
}
getDepartments();
@@ -1,8 +1,4 @@
<script setup>
import { computed, ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
@@ -40,19 +36,15 @@ const props = defineProps({
type: Array,
default: () => [],
},
})
});
const emit = defineEmits(["flagStatusChanged"]);
const orderItems = ref([]);
// Stable ordering for the order items table: primary items (related_item_id === 0
// / null) first, then addons grouped by their parent, in insertion order. The
// backend now also orders the SELECT (api PR), but we sort defensively here so a
// stale cache or older API proxy cannot regress the render order (which previously
// made it look like only Trailer/Dolly were attached to a Trækker order because
// Spot-free and Undervognsskyld were listed above the primary and visually buried).
// backend now also orders the SELECT, but we sort defensively here so a stale
// cache or older API proxy cannot regress the render order.
const sortOrderItemsForDisplay = (items) => {
if (!Array.isArray(items)) {
return [];
@@ -78,63 +70,63 @@ const loadOrderItems = async () => {
orderItems.value = sortOrderItemsForDisplay(props.localOrderItems);
return;
}
await SessionUser.request(
'/order/items',
'GET',
{
order_id: props.orderId,
}
).then((response) => {
orderItems.value = sortOrderItemsForDisplay(response.data.data);
}).catch((error) => {
console.error(error);
});
}
await SessionUser.request("/order/items", "GET", {
order_id: props.orderId,
})
.then((response) => {
orderItems.value = sortOrderItemsForDisplay(response.data.data);
})
.catch((error) => {
console.error(error);
});
};
const isOrderItemAddon = (item) => {
return item.related_item_id !== 0;
}
};
const getItemAddons = (item) => {
return orderItems.value.filter((order_item) => {
return order_item.related_item_id === item.id;
});
}
};
const _ucFirst = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1);
}
};
const sortInvoicePeriodFlags = (flags = []) => [...flags].sort((left, right) => {
const sourceOrder = { manual: 0, automatic: 1 };
const leftSource = sourceOrder[left?.source] ?? 99;
const rightSource = sourceOrder[right?.source] ?? 99;
if (leftSource !== rightSource) {
return leftSource - rightSource;
}
const sortInvoicePeriodFlags = (flags = []) =>
[...flags].sort((left, right) => {
const sourceOrder = { manual: 0, automatic: 1 };
const leftSource = sourceOrder[left?.source] ?? 99;
const rightSource = sourceOrder[right?.source] ?? 99;
if (leftSource !== rightSource) {
return leftSource - rightSource;
}
return String(left?.created_at || left?.fingerprint || "").localeCompare(
String(right?.created_at || right?.fingerprint || "")
return String(left?.created_at || left?.fingerprint || "").localeCompare(
String(right?.created_at || right?.fingerprint || "")
);
});
const getOrderItemFlags = (orderItem) =>
sortInvoicePeriodFlags(
(props.invoicePeriodFlags || []).filter((flag) => {
if (String(flag?.status || "active") !== "active") {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order_item", "order_item_field"].includes(targetType)) {
return false;
}
const flagOrderItemId = Number(flag?.order_item_id || flag?.target_id || flag?.context?.order_item_id || 0);
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
})
);
});
const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
if (String(flag?.status || "active") !== "active") {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order_item", "order_item_field"].includes(targetType)) {
return false;
}
const flagOrderItemId = Number(flag?.order_item_id || flag?.target_id || flag?.context?.order_item_id || 0);
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
}));
const getOrderItemFlagIconClass = (orderItem) => (
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
);
const getOrderItemFlagIconClass = (orderItem) => (getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : "");
const getOrderItemFlagColorClass = (orderItem) => {
const flags = getOrderItemFlags(orderItem);
@@ -145,19 +137,23 @@ const getOrderItemFlagColorClass = (orderItem) => {
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
};
const displayColumnCount = computed(() => (
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
));
const displayColumnCount = computed(
() => 2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
);
const emitFlagStatusChanged = (flag) => emit("flagStatusChanged", flag);
loadOrderItems();
watch(() => props.orderId, (newValue, oldValue) => {
if (newValue !== oldValue) {
loadOrderItems();
}
}, { immediate: true });
watch(
() => props.orderId,
(newValue, oldValue) => {
if (newValue !== oldValue) {
loadOrderItems();
}
},
{ immediate: true }
);
</script>
<template>
@@ -165,125 +161,128 @@ watch(() => props.orderId, (newValue, oldValue) => {
<table class="table is-fullwidth" v-if="!props.summaryMode">
<thead>
<tr>
<th>{{ $t('tables.products.name') }}</th>
<th v-if="props.displayNotes">{{ $t('objects.columns.notes') }}</th>
<th v-if="props.displayReference">{{ $t('common.reference') }}</th>
<th>{{ $t('common.quantity') }}</th>
<th v-if="props.displayPrice">{{ $t('tables.products.price') }}</th>
<th>{{ $t("tables.products.name") }}</th>
<th v-if="props.displayNotes">{{ $t("objects.columns.notes") }}</th>
<th v-if="props.displayReference">{{ $t("common.reference") }}</th>
<th>{{ $t("common.quantity") }}</th>
<th v-if="props.displayPrice">{{ $t("tables.products.price") }}</th>
</tr>
</thead>
<tbody>
<template v-for="item in orderItems" :key="item.id">
<!-- If the item is a primary item, show it -->
<template v-if="!isOrderItemAddon(item)">
<!-- If the item is included in the invoice, show it normally -->
<tr v-if="item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
{{ item.product.name }}
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(item.price) }}</td>
</tr>
<!-- If the item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning-light">
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
<td v-if="props.displayPrice" style="text-decoration: line-through;">{{ SessionUser.functions.currency.toLocal(item.price) }}</td>
</tr>
<tr
v-if="getOrderItemFlags(item).length > 0"
:data-testid="`order-content-item-flags-${item.id}`"
>
<td :colspan="displayColumnCount">
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(item)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
<template v-for="item in orderItems" :key="item.id">
<!-- If the item is a primary item, show it -->
<template v-if="!isOrderItemAddon(item)">
<!-- If the item is included in the invoice, show it normally -->
<tr v-if="item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
{{ item.product.name }}
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(item.price) }}</td>
</tr>
<!-- If the item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning-light">
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<span style="text-decoration: line-through">{{ item.product.name }}</span> (
{{ SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
<td v-if="props.displayPrice" style="text-decoration: line-through">
{{ SessionUser.functions.currency.toLocal(item.price) }}
</td>
</tr>
<tr v-if="getOrderItemFlags(item).length > 0" :data-testid="`order-content-item-flags-${item.id}`">
<td :colspan="displayColumnCount">
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(item)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
</template>
<!-- Show the add-on items -->
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
<!-- If the add-on item is included in the invoice, show it normally -->
<tr v-if="addon_item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }}
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
</tr>
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ <span style="text-decoration: line-through">{{ addon_item.product.name }}</span> (
{{ SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice" style="text-decoration: line-through">
{{ SessionUser.functions.currency.toLocal(addon_item.price) }}
</td>
</tr>
<tr
v-if="getOrderItemFlags(addon_item).length > 0"
:data-testid="`order-content-item-flags-${addon_item.id}`"
>
<td :colspan="displayColumnCount">
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(addon_item)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
</template>
</template>
<!-- Show the add-on items -->
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
<!-- If the add-on item is included in the invoice, show it normally -->
<tr v-if="addon_item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }}
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
</tr>
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice" style="text-decoration: line-through;">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
</tr>
<tr
v-if="getOrderItemFlags(addon_item).length > 0"
:data-testid="`order-content-item-flags-${addon_item.id}`"
>
<td :colspan="displayColumnCount">
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(addon_item)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
</template>
</template>
</tbody>
</table>
<!-- Summary Mode -->
<div v-else>
<!-- Loader -->
<template v-if="orderItems.length === 0">
<span class="is-flex is-justify-content-center is-align-items-center" style="height: 100px;">
<span class="is-flex is-justify-content-center is-align-items-center" style="height: 100px">
<span class="loader is-loading is-align-self-center"></span>
</span>
</template>
@@ -304,11 +303,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
<strong>{{ item.product.name }}</strong> x {{ item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
</div>
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(item)"
@statusChanged="emitFlagStatusChanged"
/>
<InvoicingPeriodFlagList compact :flags="getOrderItemFlags(item)" @statusChanged="emitFlagStatusChanged" />
<!-- Show the add-on items -->
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
<small>
@@ -320,7 +315,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
</small>
<InvoicingPeriodFlagList
@@ -20,7 +20,7 @@ import {
loadCustomerAttributes
} from "@/components/shop/POSDepartmentProcess.vue";
import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem } from "@/components/shop/OrdersItems.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useRoute } from 'vue-router';
import { useI18n } from "vue-i18n";
@@ -28,12 +28,7 @@ import Swal from "sweetalert2";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ProductBox from "@/components/displays/boxes/ProductBox.vue";
import { addOrderItemAddons } from "@/components/displays/department/pos/utils/addOrderItemAddons.js";
import { getPicture } from "@/components/displays/department/pos/displays/Piktogrammer.vue";
import {
getExtraSaleAuditFromOrderItem,
promptExtraSaleAuditIfRequired,
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
const emits = defineEmits(['onAddToCartProduct', 'onAddProduct', 'onSelectProduct', 'onSelectionInvalidated']);
const route = useRoute();
@@ -390,15 +385,10 @@ const warnIfRestrictedAddonsWereSkipped = async (restrictedSelections = []) => {
await showRestrictionWarning("pos.restrictions.restricted_items_removed");
};
const resolveExtraSaleAudit = async (product, initial = {}) => {
const audit = await promptExtraSaleAuditIfRequired(product, initial);
return audit === null ? null : audit;
};
const showAddMultipleProducts = async (productId) => {
const showAddMultipleProducts = (productId) => {
const product = findProductById(productId);
const restriction = getScopedProductRestriction(product);
if (restriction.restricted) {
@@ -406,7 +396,7 @@ const showAddMultipleProducts = async (productId) => {
return;
}
const quantityResult = await Swal.fire({
Swal.fire({
title: 'Tilføj flere produkter',
input: 'number',
inputAttributes: {
@@ -414,7 +404,7 @@ const showAddMultipleProducts = async (productId) => {
},
showCancelButton: true,
confirmButtonText: 'Tilføj',
showLoaderOnConfirm: false,
showLoaderOnConfirm: true,
preConfirm: (inputValue) => {
// Check if the input number is higher than 200
if (inputValue > 200) {
@@ -432,32 +422,17 @@ const showAddMultipleProducts = async (productId) => {
Swal.showValidationMessage('Order ID is required');
return false;
}
return Number(inputValue);
return createOrderItem(orderId, productId, inputValue)
.then(async (result) => {
const createdItemId = Number(result?.data?.data?.id ?? 0);
await addAddonsToOrderMiddleware(productId, inputValue, createdItemId, orderId);
await loadOrderItems();
})
.catch((error) => handleCreateOrderItemError(error, { validationMessage: true }).then(() => false));
}
},
allowOutsideClick: () => !Swal.isLoading()
});
if (!quantityResult.isConfirmed) {
return;
}
const orderId = getValidOrderId();
if (!orderId) {
return;
}
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
return;
}
await createOrderItem(orderId, productId, quantityResult.value, null, null, null, audit)
.then(async (result) => {
const createdItemId = Number(result?.data?.data?.id ?? 0);
await addAddonsToOrderMiddleware(productId, quantityResult.value, createdItemId, orderId);
await loadOrderItems();
})
.catch((error) => handleCreateOrderItemError(error));
};
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
@@ -601,62 +576,28 @@ const addAddonsToOrderMiddleware = async (product_id, quantity = 1, related_item
throw new Error("Created parent order item ID is missing");
}
// Show the pending rows for every add-on so the cart panel reflects the
// optimistic state immediately. The real POST fan-out happens below and
// the pending rows are cleared by `loadOrderItems` on success or by
// `clearPendingOrderItems` on failure.
const addonDefinitions = product_addons.map((selection) => {
const addon_quantity = selection.quantity === 0 ? quantity : selection.quantity * quantity;
const addonAddon = getProductAddonDefinition(product_id, selection.addon_id);
return {
addon_id: selection.addon_id,
quantity: addon_quantity,
notes: selection.notes === undefined ? null : selection.notes,
product: getPendingProductFromAddon(product_id, selection.addon_id),
price: getAddonPrice(product_id, selection.addon_id),
_addon_definition: addonAddon,
};
});
for (const addon of addonDefinitions) {
showPendingCreateOrderItem(addon.product, addon.quantity, addon.price, normalizedRelatedItemId, addon.notes);
for (let i = 0; i < product_addons.length; i++) {
// Get the addons quantity (If the addon quantity is 0, use the product quantity, otherwise multiply the addon quantity with the product quantity)
let addon_quantity = product_addons[i].quantity === 0 ? quantity : product_addons[i].quantity * quantity;
// Get the products addon, and show the fake create order item
showPendingCreateOrderItem(
getPendingProductFromAddon(product_id, product_addons[i].addon_id),
addon_quantity,
getAddonPrice(product_id, product_addons[i].addon_id),
normalizedRelatedItemId,
product_addons[i].notes === undefined ? null : product_addons[i].notes
);
}
// Build the add-on payloads the shared fan-out helper expects. The
// helper accepts either an addon-shaped object (`option_id` / nested
// `product`) or a plain object with `product_id` + `quantity`; we pass
// the nested `product` form so the helper can read both `product.id`
// and `product.price` for the POST without us flattening it twice.
const helperAddons = addonDefinitions.map((addon) => ({
option_id: addon.addon_id,
quantity: addon.quantity,
notes: addon.notes,
skip_price_override: addon._addon_definition?.product?.skip_price_override === true,
product: {
id: addon.product?.id,
price: addon.price,
notes: addon.notes ?? "",
},
}));
try {
await addOrderItemAddons({
orderId: targetOrderId,
primaryItemId: normalizedRelatedItemId,
addons: helperAddons,
api: {
createOrderItem: (orderId, productId, addonQuantity, relatedItemId, notes, price) =>
createOrderItem(orderId, productId, addonQuantity, relatedItemId, notes, price),
removeOrderItem: (orderItemId) => removeOrderItem(orderItemId),
},
});
} catch (error) {
// The shared helper has already rolled back every row that landed
// during this attempt. Surface the error through the same handler
// the rest of the desktop flow uses so the operator sees a friendly
// restriction warning for backend customer-rule blocks and the
// original error otherwise.
await handleCreateOrderItemError(error);
// Add the addons to the order
for (let i = 0; i < product_addons.length; i++) {
let addon_quantity = product_addons[i].quantity === 0 ? quantity : product_addons[i].quantity * quantity;
await createOrderItem(
targetOrderId,
product_addons[i].addon_id,
addon_quantity,
normalizedRelatedItemId,
product_addons[i].notes === undefined ? null : product_addons[i].notes
).catch((error) => handleCreateOrderItemError(error));
}
};
@@ -709,7 +650,7 @@ const addProductWithAddonsToOrder = async (product_id) => {
// Check if the product requires a note
if (productRequiresOrderItemNote(product)) {
// Show the note input
const noteResult = await Swal.fire({
await Swal.fire({
title: 'Tilføj en note',
input: 'text',
inputLabel: 'Noten kan ses af kunden. F.eks. "Fjernelse af graffiti på venstre side"',
@@ -718,32 +659,35 @@ const addProductWithAddonsToOrder = async (product_id) => {
},
showCancelButton: true,
confirmButtonText: 'Tilføj',
showLoaderOnConfirm: false,
showLoaderOnConfirm: true,
inputValidator: (note) => {
if (!String(note || '').trim()) {
return 'Note er påkrævet for dette produkt';
}
return null;
},
preConfirm: (note) => String(note || '').trim(),
preConfirm: (note) => {
const normalizedNote = String(note || '').trim();
const confirmedOrderId = getValidOrderId();
if (!confirmedOrderId) {
Swal.showValidationMessage('Order ID is required');
return false;
}
// Show the fake create order item
showPendingCreateOrderItem(product, 1, getUserProductPrice(product), 0, normalizedNote);
// Create the order item
return createOrderItem(confirmedOrderId, product_id, 1, 0, normalizedNote)
.then(async (result) => {
let order_item_id = result.data.data.id;
// Add the addons to the order
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => {
loadOrderItems();
});
})
.catch((error) => handleCreateOrderItemError(error, { validationMessage: true }).then(() => false));
},
allowOutsideClick: () => !Swal.isLoading()
});
if (!noteResult.isConfirmed) {
return;
}
const normalizedNote = noteResult.value;
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
return;
}
showPendingCreateOrderItem(product, 1, getUserProductPrice(product), 0, normalizedNote);
await createOrderItem(orderId, product_id, 1, 0, normalizedNote, null, audit)
.then(async (result) => {
let order_item_id = result.data.data.id;
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, orderId).then(() => {
loadOrderItems();
});
})
.catch((error) => handleCreateOrderItemError(error));
return;
}
// If the product requires a note, show the note input
@@ -760,12 +704,7 @@ const addProductWithAddonsToOrder = async (product_id) => {
return;
}
// Create the order item
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
clearPendingOrderItems();
return;
}
await createOrderItem(orderId, product_id, quantity, null, null, null, audit)
await createOrderItem(orderId, product_id, quantity)
.then(async (result) => {
let order_item_id = result.data.data.id;
// Add the addons to the order
@@ -842,19 +781,12 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
const discountedPrice = String(getUserProductPrice({ ...previousOrderProduct, price: basePrice }));
showPendingCreateOrderItem({ ...previousOrderProduct, price: basePrice }, quantity, discountedPrice);
const audit = await resolveExtraSaleAudit(previousOrderProduct, getExtraSaleAuditFromOrderItem(orderItem));
if (audit === null) {
clearPendingOrderItems();
continue;
}
const result = await createOrderItem(
orderId,
productId,
quantity,
null,
String(orderItem?.notes ?? ""),
null,
audit
String(orderItem?.notes ?? "")
).catch((error) => handleCreateOrderItemError(error));
const sourceItemId = getPreviousOrderItemId(orderItem);
const createdItemId = Number(result?.data?.data?.id ?? 0);
@@ -887,19 +819,12 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
String(getUserProductPrice(previousOrderProduct)),
relatedItemId
);
const audit = await resolveExtraSaleAudit(previousOrderProduct, getExtraSaleAuditFromOrderItem(orderItem));
if (audit === null) {
clearPendingOrderItems();
continue;
}
await createOrderItem(
orderId,
productId,
quantity,
relatedItemId,
String(orderItem?.notes ?? ""),
null,
audit
String(orderItem?.notes ?? "")
).catch((error) => handleCreateOrderItemError(error));
}
@@ -963,12 +888,7 @@ const addRecommendedProductToOrder = async (productId) => {
}
showPendingCreateOrderItem(product, 1, getRecommendedProductPrice(productId));
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
clearPendingOrderItems();
return;
}
const result = await createOrderItem(orderId, productId, 1, null, null, null, audit).catch((error) => handleCreateOrderItemError(error));
const result = await createOrderItem(orderId, productId, 1).catch((error) => handleCreateOrderItemError(error));
const createdItemId = Number(result?.data?.data?.id ?? 0);
if (createdItemId > 0) {
await addAddonsToOrderMiddleware(productId, 1, createdItemId, orderId);
+3 -15
View File
@@ -38,9 +38,6 @@ export function usePaginatedList() {
let activeRequestController = null;
let searchDebounceTimeout = null;
/** The last error produced by `paginatedGetRequest` (null on success). */
const lastError = ref(null);
/** Additional query parameters */
const additionalQueryParameters = ref({});
@@ -173,7 +170,6 @@ export function usePaginatedList() {
abortActiveRequest();
const requestController = typeof AbortController !== "undefined" ? new AbortController() : null;
activeRequestController = requestController;
lastError.value = null;
try {
const response = await axios.get(API_URL + endpoint.value, {
@@ -197,16 +193,10 @@ export function usePaginatedList() {
setLastUpdated();
return response;
} catch (error) {
if (isCanceledRequest(error)) {
return null;
if (!isCanceledRequest(error)) {
parseError(error, 'paginatedGetRequest');
console.log(error);
}
lastError.value = {
status: Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10) || null,
endpoint: endpoint.value,
message: error?.response?.data?.data?.message ?? error?.response?.data?.message ?? error?.message ?? null,
};
parseError(error, 'paginatedGetRequest');
console.log(error);
return null;
} finally {
if (activeRequestController === requestController) {
@@ -497,7 +487,6 @@ export function usePaginatedList() {
isLoading,
isExporting,
latestSearch,
lastError,
additionalQueryParameters,
exportTransform,
setHideSearchField,
@@ -553,7 +542,6 @@ export const hideSearchField = globalInstance.hideSearchField;
export const isLoading = globalInstance.isLoading;
export const isExporting = globalInstance.isExporting;
export const latestSearch = globalInstance.latestSearch;
export const lastError = globalInstance.lastError;
export const additionalQueryParameters = globalInstance.additionalQueryParameters;
export const exportTransform = globalInstance.exportTransform;
@@ -0,0 +1,47 @@
<script>
import {authenticatedRequest} from "@/components/session/authenticatedRequest.vue";
/**
* The MiniMax -> Config object
*/
export const Config = {
get: async ( variable ) => {
return authenticatedRequest(
"/minimax/config?variable=" + variable,
"GET")
},
get_all: async () => {
return authenticatedRequest(
"/minimax/config",
"GET")
},
set: async ( variable, value ) => {
return authenticatedRequest(
"/minimax/config",
"POST",
{
variable: variable,
value: value
})
},
keys: {
api_key: {
get: async () => {
return Config.get("api_key");
},
set: async (value) => {
return Config.set("api_key", value);
},
},
},
enabled: {
get: async () => {
return Config.get("enabled");
},
set: async (enabled) => {
return Config.set("enabled", enabled);
},
},
};
</script>
@@ -0,0 +1,22 @@
<script>
import { Config } from "@/components/session/token/superUser/modules/miniMax/Config.vue";
/**
* The MiniMax object
*/
export const MiniMax = {
meta: {
title: "MiniMax M3",
icon: "fas fa-robot",
description: "MiniMax M3 (Anthropic-messages) integration used by XL Vask autopilot and other AI features.",
endpoint: "/modules/MiniMax",
config_endpoint: "/configuration/MiniMax",
labels: {
single: "MiniMax",
multiple: "MiniMax"
}
},
config: Config,
functions: {}
};
</script>
@@ -65,6 +65,22 @@ export const Config = {
return Config.set("automatic_order_creation_enabled", value);
},
},
openai_integration_enabled: {
get: async () => {
return Config.get("openai_integration_enabled");
},
set: async (value) => {
return Config.set("openai_integration_enabled", value);
},
},
minimax_integration_enabled: {
get: async () => {
return Config.get("minimax_integration_enabled");
},
set: async (value) => {
return Config.set("minimax_integration_enabled", value);
},
},
},
enabled: {
get: async () => {
@@ -23,6 +23,7 @@ import { Entra } from "@/components/session/token/superUser/modules/entra/Entra.
import { Limble } from "@/components/session/token/superUser/modules/limble/Limble.vue";
import { OcrSpace } from "@/components/session/token/superUser/modules/ocrSpace/OcrSpace.vue";
import { OpenAI } from "@/components/session/token/superUser/modules/openAI/OpenAI.vue";
import { MiniMax } from "@/components/session/token/superUser/modules/miniMax/MiniMax.vue";
import { LicensePlateRecognizer } from "@/components/session/token/superUser/modules/licensePlateRecognizer/LicensePlateRecognizer.vue";
import { VirkData } from "@/components/session/token/superUser/modules/virkdata/VirkData.vue";
import { Shelly } from "@/components/session/token/superUser/modules/shelly/Shelly.vue";
@@ -105,6 +106,9 @@ export const SuperUserObject = {
get openai() {
return OpenAI;
},
get minimax() {
return MiniMax;
},
get licenseplaterecognizer() {
return LicensePlateRecognizer;
},
+6 -39
View File
@@ -3,7 +3,7 @@
import axios from 'axios'
import {API_URL} from "@/config.js";
export const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 25, 26, 27]);
export const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 24, 25, 26, 27]);
export const DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE = "customer_approved_extra_work";
export const DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL = "Kunde godkendte ekstra arbejde";
@@ -13,34 +13,12 @@ export const buildAuditedOrderItemReasonPayload = (product_id, notes = null, rea
}
const reason = reasonData && typeof reasonData === "object" ? reasonData : {};
// Server requires `reason_comment` to be present (and non-empty after trim)
// for audited products. Walk the precedence chain in order so callers can
// supply either an explicit override or fall back to the legacy `notes`
// field, and never emit an empty value.
const trimmedFirstNonEmpty = (...candidates) => {
for (const candidate of candidates) {
const trimmed = String(candidate ?? "").trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return "";
};
const reasonComment = trimmedFirstNonEmpty(
reason.reason_comment,
reason.comment,
notes,
DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL
);
const comment = String(reason.reason_comment ?? reason.comment ?? notes ?? "").trim();
return {
reason_code: String(reason.reason_code || DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE),
reason_label_snapshot: String(
reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL
),
reason_comment: reasonComment,
reason_label_snapshot: String(reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL),
reason_comment: comment,
};
};
@@ -56,16 +34,7 @@ export const getOrderItems = (order_id) => {
});
};
export const createOrderItem = (
order_id,
product_id,
quantity,
related_item_id = null,
notes = null,
forcePrice = null,
reasonData = null,
extraSaleAudit = {}
) => {
export const createOrderItem = (order_id, product_id, quantity, related_item_id = null, notes = null, forcePrice = null, reasonData = null) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
@@ -81,7 +50,6 @@ export const createOrderItem = (
related_item_id,
notes,
...buildAuditedOrderItemReasonPayload(product_id, notes, reasonData),
...extraSaleAudit
};
if (forcePrice !== null && forcePrice !== undefined) {
payload.price = forcePrice;
@@ -105,7 +73,7 @@ export const removeOrderItem = (id) => {
});
};
export const editOrderItem = (id, price, notes, reference, quantity, reasonData = null, extraSaleAudit = {}) => {
export const editOrderItem = (id, price, notes, reference, quantity, reasonData = null) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
@@ -117,7 +85,6 @@ export const editOrderItem = (id, price, notes, reference, quantity, reasonData
reference,
quantity,
...(reasonData && typeof reasonData === "object" ? reasonData : {}),
...extraSaleAudit
}, {
headers: {
Authorization: `Bearer ${token}`
+4 -44
View File
@@ -10,10 +10,6 @@ import { getAttributes } from "@/components/shop/CustomerAttributes.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { doesOrderContainWashCertificateProduct } from "@/components/displays/department/pos/utils/washCertificate.js";
import {
getExtraSaleAuditFromOrderItem,
promptExtraSaleAuditIfRequired,
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
import {
getCustomerProductRestriction,
getProductCategoryRestrictionForCustomer,
@@ -1576,43 +1572,19 @@ const copyLastWashReferenceToEmptyCurrentOrder = async (sourceReference) => {
}
};
const createCopiedOrderItem = async (targetOrderId, sourceItem, relatedItemId = null) => {
const createCopiedOrderItem = (targetOrderId, sourceItem, relatedItemId = null) => {
const productId = getOrderItemProductId(sourceItem);
const quantity = getOrderItemQuantity(sourceItem);
if (!productId || !quantity) {
return Promise.resolve(null);
}
const audit = await promptExtraSaleAuditIfRequired(
sourceItem?.product || { id: productId },
getExtraSaleAuditFromOrderItem(sourceItem)
);
if (audit === null) {
return Promise.resolve(null);
}
// Preserve audited-order-item reason metadata from the source row so the
// copied POST satisfies the server-side `reason_comment` requirement for
// products in AUDITED_ORDER_ITEM_PRODUCT_IDS. We only forward the fields
// we actually saw on the source; `buildAuditedOrderItemReasonPayload`
// still falls back to the order-item `notes` and then to the default
// label for non-audited products, so this is safe for every other case.
const sourceReasonData = sourceItem && typeof sourceItem === "object"
? {
reason_code: sourceItem.reason_code,
reason_label_snapshot: sourceItem.reason_label_snapshot,
reason_comment: sourceItem.reason_comment ?? sourceItem.comment,
}
: null;
return createOrderItem(
targetOrderId,
productId,
quantity,
relatedItemId,
getOrderItemNotes(sourceItem),
null,
sourceReasonData,
audit
getOrderItemNotes(sourceItem)
);
};
@@ -2639,19 +2611,13 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
}
const forcedPrimaryPrice = primaryProduct.price ?? null;
const primaryAudit = await promptExtraSaleAuditIfRequired(primaryProduct);
if (primaryAudit === null) {
return false;
}
const primaryItemResponse = await createOrderItem(
normalizedOrderId,
primaryProduct.id,
1,
null,
null,
forcedPrimaryPrice,
null,
primaryAudit
forcedPrimaryPrice
);
const relatedPrimaryItemId = toPositiveInteger(primaryItemResponse?.data?.data?.id);
@@ -2663,10 +2629,6 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
}
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
const secondaryAudit = await promptExtraSaleAuditIfRequired(secondaryProduct || { id: secondaryProductId });
if (secondaryAudit === null) {
return false;
}
await createOrderItem(
normalizedOrderId,
@@ -2674,9 +2636,7 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
relatedPrimaryItemId,
String(bookingItem?.notes ?? "").trim() || null,
secondaryProduct?.price ?? null,
null,
secondaryAudit
secondaryProduct?.price ?? null
);
}
-115
View File
@@ -1,115 +0,0 @@
/**
* Red-car detection utilities for the customer portal.
*
* SENERE 7 / TRU-99: Until Mads defines the canonical rule for
* identifying a "red car" (e.g. plate scan, color tag, manual
* customer flag), we use a manual customer-attribute flag as the
* default heuristic. The attribute keys that trigger the warning
* are configurable so the rule can be tightened later without
* touching call sites.
*/
/**
* Default attribute keys that mark a customer as owning a red car.
* Multiple keys are supported to give staff a few options when
* tagging accounts in the customer-attribute editor.
*/
export const DEFAULT_RED_CAR_ATTRIBUTE_KEYS = Object.freeze([
"isRedCar",
"is_red_car",
"redCar",
"red_car",
]);
const normalizeKey = (key) => String(key ?? "").trim();
const toNormalizedKeySet = (keys) => {
const set = new Set();
if (!Array.isArray(keys)) {
return set;
}
for (const key of keys) {
const normalized = normalizeKey(key).toLowerCase();
if (normalized) {
set.add(normalized);
}
}
return set;
};
/**
* Extract the attribute key string from a single customer-attribute
* row. Accepts both the API shape (object with `attribute`) and the
* plain-string shorthand used in some call sites.
*
* @param {string|object|undefined|null} entry
* @returns {string}
*/
export const extractAttributeKey = (entry) => {
if (typeof entry === "string") {
return normalizeKey(entry);
}
if (entry && typeof entry === "object") {
const candidate = entry.attribute ?? entry.key ?? entry.name;
return normalizeKey(candidate);
}
return "";
};
/**
* Extract the attribute key strings from a list of customer
* attributes. Returns an empty array for invalid input.
*
* @param {Array<string|object|undefined|null>|null|undefined} attributes
* @returns {string[]}
*/
export const extractAttributeKeys = (attributes) => {
if (!Array.isArray(attributes)) {
return [];
}
const keys = [];
for (const entry of attributes) {
const key = extractAttributeKey(entry);
if (key) {
keys.push(key);
}
}
return keys;
};
/**
* Determine whether a customer should be flagged as a "red car"
* based on their attribute list. The check is case-insensitive.
*
* @param {Array<string|object|undefined|null>|null|undefined} attributes
* @param {object} [options]
* @param {string[]} [options.attributeKeys] - attribute keys that
* mark a red car. Defaults to DEFAULT_RED_CAR_ATTRIBUTE_KEYS.
* @returns {boolean}
*/
export const isRedCarCustomer = (attributes, options = {}) => {
const keys = toNormalizedKeySet(
options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
);
if (keys.size === 0) {
return false;
}
const attributeKeys = extractAttributeKeys(attributes);
return attributeKeys.some((key) => keys.has(key.toLowerCase()));
};
/**
* Return the configured red-car attribute keys (normalized,
* deduplicated, lowercased). Useful for displaying the active
* heuristic to operators.
*
* @param {object} [options]
* @param {string[]} [options.attributeKeys]
* @returns {string[]}
*/
export const getRedCarAttributeKeys = (options = {}) => {
const keys = toNormalizedKeySet(
options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
);
return Array.from(keys);
};
-98
View File
@@ -1,98 +0,0 @@
import { onMounted, ref } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const TRUTHY_BOOL_LITERALS = new Set(["true", "1", "yes", "on"]);
const coerceConfigBoolean = (raw) => {
if (raw === true || raw === 1) return true;
if (typeof raw === "string") {
return TRUTHY_BOOL_LITERALS.has(raw.trim().toLowerCase());
}
return false;
};
const normalizeEntries = (payload) => {
if (Array.isArray(payload)) {
return payload.map((entry) => ({
variable: entry?.variable,
value: entry?.value,
isSecret: entry?.isSecret === true,
isSet: entry?.isSet === true,
}));
}
return Object.values(payload ?? {}).map((entry) => ({
variable: entry?.variable,
value: entry?.value,
isSecret: entry?.isSecret === true,
isSet: entry?.isSet === true,
}));
};
/**
* Replaces the copy-pasted `getModuleConfig` / `getModuleConfigValue` /
* `module_config = ref([])` triple that 20+ `Configuration*.vue` pages
* each defined themselves.
*
* @param {string} moduleName - e.g. "stripe", "xlvask", "bird"
* @param {object} [options]
* @param {boolean} [options.coerceBooleans=true] - return `true`/`false` for `'true'`/`'false'` strings
* @param {boolean} [options.autoLoad=true] - load on mount
* @returns module_config, loading, error, refresh, getModuleConfigValue, getEntry, isVariableSet
*/
export const useModuleConfig = (moduleName, options = {}) => {
const { coerceBooleans = true, autoLoad = true } = options;
const module_config = ref([]);
const loading = ref(false);
const error = ref(null);
const getEntry = (variable) => module_config.value.find((entry) => entry.variable === variable);
const getModuleConfigValue = (variable) => {
const entry = getEntry(variable);
if (!entry) return "";
const raw = entry.value;
if (coerceBooleans) {
if (raw === "true" || raw === true) return true;
if (raw === "false" || raw === false) return false;
}
return raw ?? "";
};
const isVariableSet = (variable) => {
const entry = getEntry(variable);
if (!entry) return false;
if (entry.isSecret) return entry.isSet;
return entry.value !== null && entry.value !== undefined && entry.value !== "";
};
const load = async () => {
loading.value = true;
error.value = null;
try {
const response = await SessionUser.superUser.modules[moduleName].config.get_all();
module_config.value = normalizeEntries(response?.data?.data);
} catch (err) {
console.error(`useModuleConfig(${moduleName}): load failed`, err);
module_config.value = [];
error.value = err;
} finally {
loading.value = false;
}
};
if (autoLoad) {
onMounted(load);
}
return {
module_config,
loading,
error,
load,
getEntry,
getModuleConfigValue,
isVariableSet,
coerceConfigBoolean,
};
};
-104
View File
@@ -1,104 +0,0 @@
import { computed, ref, unref, watch } from "vue";
import {
extractCustomerAttributesData,
listCustomerAttributes,
} from "@/features/customer/customerAttributeService.js";
import {
DEFAULT_RED_CAR_ATTRIBUTE_KEYS,
isRedCarCustomer,
} from "./redCarDetector.js";
const isPositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0;
};
/**
* Composable that watches a customer number and exposes a reactive
* "is this customer flagged as a red car" state for the customer
* portal wash flow.
*
* SENERE 7 / TRU-99 — the canonical rule is still TBD by Mads; this
* defaults to a manual customer-attribute flag and lets callers
* override the attribute keys so the rule can be tightened later.
*
* @param {import("vue").Ref<number|string|null|undefined>|number|string|null|undefined} customerNumberSource
* @param {object} [options]
* @param {string[]} [options.attributeKeys] - attribute keys that
* mark a red car. Defaults to DEFAULT_RED_CAR_ATTRIBUTE_KEYS.
* @returns {{
* attributes: import("vue").Ref<Array>,
* isLoading: import("vue").Ref<boolean>,
* error: import("vue").Ref<string|null>,
* isRedCar: import("vue").ComputedRef<boolean>,
* reload: () => Promise<void>,
* reset: () => void,
* }}
*/
export const useRedCarWarning = (customerNumberSource, options = {}) => {
const attributes = ref([]);
const isLoading = ref(false);
const error = ref(null);
const loadedCustomerNumber = ref(null);
const attributeKeys = options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS;
const isRedCar = computed(() => isRedCarCustomer(attributes.value, { attributeKeys }));
const reset = () => {
attributes.value = [];
isLoading.value = false;
error.value = null;
loadedCustomerNumber.value = null;
};
const reload = async () => {
const customerNumber = unref(customerNumberSource);
if (!isPositiveInteger(customerNumber)) {
reset();
return;
}
if (loadedCustomerNumber.value === customerNumber && attributes.value.length > 0) {
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await listCustomerAttributes({ customerNumber });
const nextAttributes = extractCustomerAttributesData(response);
attributes.value = nextAttributes;
loadedCustomerNumber.value = customerNumber;
} catch (err) {
console.warn("Unable to load red-car customer attributes", err);
attributes.value = [];
loadedCustomerNumber.value = null;
error.value = err?.message || "load_failed";
} finally {
isLoading.value = false;
}
};
if (typeof customerNumberSource === "object" && customerNumberSource !== null && "value" in customerNumberSource) {
watch(
() => unref(customerNumberSource),
(next) => {
if (isPositiveInteger(next)) {
reload();
} else {
reset();
}
},
{ immediate: true }
);
}
return {
attributes,
isLoading,
error,
isRedCar,
reload,
reset,
};
};
+2 -9
View File
@@ -2143,7 +2143,6 @@
"customer_login_title": "@:{'templates.generated.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.chauffør'}? @.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @:{'words.generated.her'} @:{'words.generated.for'} @:{'words.generated.at'} @:{'words.generated.fa'} @:{'words.generated.adgang'} @:{'words.generated.til'} @:{'words.generated.dine'} @:{'words.generated.vaske'}.",
"driver_login_button": "@.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @.lower:{'words.generated.som'} @:{'words.generated.chauffør'}",
"driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.",
"driver_login_title": "@.capitalize:{'words.generated.chauffør'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Glemt @:{'words.generated.adgangskode'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} @:{'words.generated.digitale'} @:{'words.generated.løsninger'} @:{'words.generated.til'} @:{'words.generated.lastbilvask'}",
@@ -4239,7 +4238,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.oplysninger'}"
},
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -4680,7 +4678,7 @@
"workflow": "Arbejdsgang"
}
},
"xlvask_review": {
"xlvask_autopilot": {
"actions": {
"accept": "Accepter forslag",
"attach_order": "Tilknyt ordre",
@@ -4876,8 +4874,7 @@
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
"redirect_order_failed": "Ordren kunne ikke åbnes.",
"load_customers_failed": "Kunderne kunne ikke hentes.",
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes.",
"api_endpoint_not_implemented": "API-endepunkt ikke implementeret endnu"
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes."
}
}
},
@@ -5448,7 +5445,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.ydelse'}, @:{'words.generated.tilvalg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.sidste'} @:{'words.generated.vask'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldrig vasket",
"new_order": "@.capitalize:{'words.generated.ny'} order",
"new_sale": "@.capitalize:{'words.generated.nyt'} @:{'words.generated.salg'}",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bookinger'} @:{'words.generated.fundet'}",
@@ -5917,9 +5913,6 @@
"open_property_exit_gate": "@:{'words.generated.abn'} udgangsport",
"perform_wash": "@:{'words.generated.udfør'} @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørgsmal'} @:{'words.generated.besvaret'}",
"red_car_warning_title": "Rød bil på pladsen",
"red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.vælg'} {plate} @:{'words.generated.fra'} @:{'words.generated.dine'} @:{'words.generated.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.type'} @:{'words.generated.af'} @:{'words.generated.køretøj'}",
"select_vehicle": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.køretøj'}",
+3 -10
View File
@@ -2260,7 +2260,6 @@
"username": "@:{'words.generated.benutzername'}",
"your_username": "@:{'words.generated.ihr'} @:{'words.generated.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
},
"book_wash": {
@@ -4341,7 +4340,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@:{'words.generated.waschzertifikat'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} angehaengt.",
"wash_certificate_item_without_certificate": "@:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'} vorhanden.",
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrierung'} @:{'words.generated.ist'} weder ignoriert noch @:{'words.generated.mit'} einer @:{'words.generated.bestellung'} im ausgewaehlten @:{'words.generated.zeitraum'} verknuepft."
"xlvask_missing_order_link": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor linked @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
},
"preview": {
"entities": {
@@ -4349,7 +4348,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'}-@.capitalize:{'words.generated.details'}"
},
"expected_price": "Erwarteter @:{'words.generated.preis'}",
"no_xlvask_usage_log_metadata": "Keine Metadaten @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@:{'templates.generated.compat.tables.common.start_time'}",
"wash_id": "Wasch-@.upper:{'words.generated.id'}",
"xlvask_usage_log": "@:{'templates.generated.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
@@ -4790,7 +4788,7 @@
"workflow": "Arbeitsablauf"
}
},
"xlvask_review": {
"xlvask_autopilot": {
"actions": {
"accept": "Vorschlag annehmen",
"attach_order": "Auftrag verknüpfen",
@@ -4986,8 +4984,7 @@
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
"load_customers_failed": "Die Kunden konnten nicht geladen werden.",
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden.",
"api_endpoint_not_implemented": "API-Endpunkt ist noch nicht implementiert"
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden."
}
}
},
@@ -5558,7 +5555,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.gleiche'} @:{'words.generated.leistung'}, @:{'words.generated.zusatzoptionen'} @:{'words.generated.und'} @:{'words.generated.zusatzartikel'} @:{'words.generated.wie'} @:{'words.generated.bei'} @:{'words.generated.der'} @:{'words.generated.letzten'} @:{'words.generated.wasche'}",
"manual_entry": "@.capitalize:{'words.generated.manuell'} registrering",
"never_washed": "Noch nie gewaschen",
"new_order": "@.capitalize:{'words.generated.neuer'} @:{'words.generated.auftrag'}",
"new_sale": "Nytt salg",
"no_bookings": "@.capitalize:{'words.generated.keine'} @:{'words.generated.buchungen'} @:{'words.generated.gefunden'}",
@@ -6027,9 +6023,6 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'words.generated.oeffnen'}",
"perform_wash": "@.upper:{'words.generated.w'}?@:{'words.generated.sche'} durchf?@:{'words.generated.hren'}",
"questions_answered": "@:{'words.generated.fragen'} @:{'words.generated.beantwortet'}",
"red_car_warning_title": "Rotes Auto vor Ort",
"red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
"red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'words.generated.w'}?@:{'words.generated.hlen'} @.capitalize:{'words.generated.sie'} {plate} @:{'words.generated.aus'} @:{'words.generated.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'words.generated.fahrzeugtyp'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
"select_vehicle": "@:{'words.generated.fahrzeug'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
+2 -9
View File
@@ -1974,7 +1974,6 @@
"customer_login_title": "@.capitalize:{'words.generated.customer'} @.capitalize:{'words.generated.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.a'} @:{'words.generated.driver'}? @.capitalize:{'words.generated.log'} @:{'words.generated.in'} @:{'words.generated.here'} @:{'words.generated.to'} @:{'words.generated.register'} @:{'words.generated.a'} @:{'words.generated.wash'}.",
"driver_login_button": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"driver_login_help": "Drivers (sub-users) log in here to register a wash.",
"driver_login_title": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Forgot @.capitalize:{'words.generated.password'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} - @:{'words.generated.digital'} @:{'words.generated.solutions'} @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.washing'}",
@@ -4070,7 +4069,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.details'}"
},
"expected_price": "@.capitalize:{'words.generated.expected'} @:{'words.generated.price'}",
"no_xlvask_usage_log_metadata": "No metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@.capitalize:{'words.generated.start'} @:{'words.generated.time'}",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.registration'}"
@@ -4511,7 +4509,7 @@
"workflow": "Workflow"
}
},
"xlvask_review": {
"xlvask_autopilot": {
"actions": {
"accept": "Accept suggestion",
"attach_order": "Attach order",
@@ -4707,8 +4705,7 @@
"create_order_item_failed": "The wash item could not be added to the order.",
"redirect_order_failed": "The order could not be opened.",
"load_customers_failed": "The customers could not be loaded.",
"load_usage_log_failed": "The usage log could not be loaded.",
"api_endpoint_not_implemented": "API endpoint not implemented yet"
"load_usage_log_failed": "The usage log could not be loaded."
}
}
},
@@ -5279,7 +5276,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.same'} @:{'words.generated.service'}, @:{'words.generated.add'}-@:{'words.generated.ons'}, @:{'words.generated.and'} @:{'words.generated.extra'} @:{'words.generated.items'} @:{'words.generated.as'} @:{'words.replication.article.host_mention'} @:{'words.generated.previous'} @:{'words.generated.wash'}",
"manual_entry": "@:{'words.generated.manual'} @:{'words.generated.registration'}",
"never_washed": "Never washed",
"new_order": "@.capitalize:{'words.generated.new'} @:{'words.generated.order'}",
"new_sale": "@.capitalize:{'words.generated.new'} sale",
"no_bookings": "@.capitalize:{'words.generated.no'} @:{'words.generated.orders'} @:{'words.generated.found'}",
@@ -5748,9 +5744,6 @@
"open_property_exit_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.exit'} @:{'words.generated.gate'}",
"perform_wash": "@.capitalize:{'words.generated.perform'} @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.questions'} @:{'words.generated.answered'}",
"red_car_warning_title": "Red car on site",
"red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
"red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.select'} {plate} @:{'words.generated.from'} @:{'words.generated.your'} @:{'words.generated.vehicles'}",
"select_vehicle_type": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'} @:{'words.generated.type'}",
"select_vehicle": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'}",
+174 -180
View File
@@ -786,7 +786,6 @@
"customer_number": "@:{'templates.generated.compat.objects.columns.customer_number'}",
"driver_entry_intro": "@:{'templates.generated.compat.auth.driver_entry_intro'}",
"driver_login_button": "@:{'templates.generated.compat.auth.driver_login_button'}",
"driver_login_help": "@:{'templates.generated.compat.auth.driver_login_help'}",
"driver_login_title": "@:{'templates.generated.compat.auth.driver_login_title'}",
"forgot_password": "@:{'templates.generated.compat.auth.forgot_password'}",
"login_as_customer": "@:{'templates.generated.compat.global.login_as_customer'}",
@@ -1659,7 +1658,6 @@
"api_settings_desc": "@:{'templates.generated.compat.configuration.xlvask.api_settings_desc'}",
"automation_settings": "@:{'templates.generated.compat.configuration.xlvask.automation_settings'}",
"automation_settings_desc": "@:{'templates.generated.compat.configuration.xlvask.automation_settings_desc'}",
"automation_settings_removed_desc": "Automatic attachment, automatic creation, OpenAI and MiniMax integrations have been removed. Operator review is now performed from the Superuser -> Fakturaer -> Periode -> Selvvask view.",
"connection_failed": "@:{'templates.generated.compat.configuration.xlvask.connection_failed'}",
"connection_failed_desc": "@:{'templates.generated.compat.configuration.xlvask.connection_failed_desc'}",
"connection_success": "@:configuration.limble.connection_success",
@@ -3422,7 +3420,6 @@
"expected_price": "@:{'templates.generated.compat.invoice_period.flags.preview.expected_price'}",
"no_order_items": "@:common.templates.no_entity_available",
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
"no_xlvask_usage_log_metadata": "@:{'templates.generated.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}",
"order_items": "@:{'templates.generated.compat.global_search.entity_types.order_items'}",
"price": "@:common.price",
"product": "@:common.product",
@@ -3898,204 +3895,203 @@
"workflow": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.workflow'}"
}
},
"xlvask_review": {
"xlvask_autopilot": {
"actions": {
"accept": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.accept'}",
"attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.attach_order'}",
"compare": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare'}",
"compare_modal_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_modal_title'}",
"compare_no_candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_no_candidates'}",
"compare_price_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_price_match'}",
"compare_price_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_price_mismatch'}",
"compare_usage_price": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_usage_price'}",
"compare_candidate_price": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_candidate_price'}",
"create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.create_order'}",
"deny": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.deny'}",
"ignore": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.ignore'}",
"link": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link'}",
"link_prompt_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_label'}",
"link_prompt_invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_invalid'}",
"link_prompt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_title'}",
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.none'}",
"recheck": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.recheck'}",
"resolve_mapping": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.resolve_mapping'}"
"accept": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.accept'}",
"attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.attach_order'}",
"compare": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare'}",
"compare_modal_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_modal_title'}",
"compare_no_candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_no_candidates'}",
"compare_price_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_price_match'}",
"compare_price_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_price_mismatch'}",
"compare_usage_price": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_usage_price'}",
"compare_candidate_price": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_candidate_price'}",
"create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.create_order'}",
"deny": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.deny'}",
"ignore": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.ignore'}",
"link": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link'}",
"link_prompt_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_label'}",
"link_prompt_invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_invalid'}",
"link_prompt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_title'}",
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.none'}",
"recheck": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.recheck'}",
"resolve_mapping": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.resolve_mapping'}"
},
"adjudication": {
"confirm_correct": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_correct'}",
"confirm_cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_cross_hall'}",
"confirm_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_duplicate'}",
"confirm_incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_incorrect'}",
"confirm_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_title'}",
"confirm_unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_unaudited'}",
"description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.description'}",
"halted": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.halted'}",
"confirm_correct": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_correct'}",
"confirm_cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_cross_hall'}",
"confirm_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_duplicate'}",
"confirm_incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_incorrect'}",
"confirm_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_title'}",
"confirm_unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_unaudited'}",
"description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.description'}",
"halted": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.halted'}",
"outcomes": {
"correct": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.correct'}",
"cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.cross_hall'}",
"duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.duplicate'}",
"incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.incorrect'}",
"unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.unaudited'}"
"correct": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.correct'}",
"cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.cross_hall'}",
"duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.duplicate'}",
"incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.incorrect'}",
"unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.unaudited'}"
},
"saved": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.saved'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.title'}"
"saved": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.saved'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.title'}"
},
"audit": "@:{'templates.generated.compat.invoicing_period.xlvask_review.audit'}",
"bulk_selected": "@:{'templates.generated.compat.invoicing_period.xlvask_review.bulk_selected'}",
"bulk_eligibility": "@:{'templates.generated.compat.invoicing_period.xlvask_review.bulk_eligibility'}",
"calibrated_probability": "@:{'templates.generated.compat.invoicing_period.xlvask_review.calibrated_probability'}",
"candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_review.candidates'}",
"contradictions": "@:{'templates.generated.compat.invoicing_period.xlvask_review.contradictions'}",
"audit": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.audit'}",
"bulk_selected": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.bulk_selected'}",
"bulk_eligibility": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.bulk_eligibility'}",
"calibrated_probability": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.calibrated_probability'}",
"candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.candidates'}",
"contradictions": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.contradictions'}",
"controls": {
"active_run_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.active_run_error'}",
"analyze": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.analyze'}",
"budget": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.budget'}",
"execute": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute'}",
"execute_description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_description'}",
"execute_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_phrase'}",
"execute_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_title'}",
"halt": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.halt'}",
"halt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.halt_title'}",
"load_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.load_error'}",
"no_access": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.no_access'}",
"not_ready": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.not_ready'}",
"policy_advance": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_advance'}",
"policy_apply": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_apply'}",
"policy_description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_description'}",
"policy_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_phrase'}",
"policy_reason": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_reason'}",
"policy_reason_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_reason_title'}",
"policy_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_title'}",
"readiness_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.readiness_error'}",
"ready": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.ready'}",
"stage": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.stage'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.title'}",
"worker_healthy": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.worker_healthy'}",
"worker_unhealthy": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.worker_unhealthy'}"
"active_run_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.active_run_error'}",
"analyze": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.analyze'}",
"budget": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.budget'}",
"execute": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute'}",
"execute_description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_description'}",
"execute_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_phrase'}",
"execute_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_title'}",
"halt": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.halt'}",
"halt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.halt_title'}",
"load_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.load_error'}",
"no_access": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.no_access'}",
"not_ready": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.not_ready'}",
"policy_advance": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_advance'}",
"policy_apply": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_apply'}",
"policy_description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_description'}",
"policy_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_phrase'}",
"policy_reason": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_reason'}",
"policy_reason_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_reason_title'}",
"policy_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_title'}",
"readiness_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.readiness_error'}",
"ready": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.ready'}",
"stage": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.stage'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.title'}",
"worker_healthy": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.worker_healthy'}",
"worker_unhealthy": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.worker_unhealthy'}"
},
"clear_selection": "@:{'templates.generated.compat.invoicing_period.xlvask_review.clear_selection'}",
"evidence": "@:{'templates.generated.compat.invoicing_period.xlvask_review.evidence'}",
"clear_selection": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.clear_selection'}",
"evidence": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.evidence'}",
"filters": {
"all": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.all'}",
"certainty": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.certainty'}",
"clear": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.clear'}",
"import_state": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.import_state'}",
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.planned_action'}",
"resolution_state": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.resolution_state'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.title'}",
"unattached_only": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.unattached_only'}"
"all": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.all'}",
"certainty": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.certainty'}",
"clear": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.clear'}",
"import_state": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.import_state'}",
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.planned_action'}",
"resolution_state": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.resolution_state'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.title'}",
"unattached_only": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.unattached_only'}"
},
"hide_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.hide_match'}",
"model": "@:{'templates.generated.compat.invoicing_period.xlvask_review.model'}",
"no_safe_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.no_safe_match'}",
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_review.planned_action'}",
"policy_version": "@:{'templates.generated.compat.invoicing_period.xlvask_review.policy_version'}",
"hide_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.hide_match'}",
"model": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.model'}",
"no_safe_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.no_safe_match'}",
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.planned_action'}",
"policy_version": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.policy_version'}",
"preview": {
"after": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.after'}",
"applied": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.applied'}",
"apply": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.apply'}",
"before": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.before'}",
"confirmation_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_label'}",
"confirmation_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_mismatch'}",
"confirmation_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_phrase'}",
"error_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.error_title'}",
"reason_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.reason_label'}",
"reason_required": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.reason_required'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.title'}"
"after": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.after'}",
"applied": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.applied'}",
"apply": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.apply'}",
"before": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.before'}",
"confirmation_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_label'}",
"confirmation_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_mismatch'}",
"confirmation_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_phrase'}",
"error_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.error_title'}",
"reason_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.reason_label'}",
"reason_required": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.reason_required'}",
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.title'}"
},
"resume_status": "@:{'templates.generated.compat.invoicing_period.xlvask_review.resume_status'}",
"run": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run'}",
"run_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_id'}",
"resume_status": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.resume_status'}",
"run": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run'}",
"run_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_id'}",
"run_phases": {
"circuit_breaker": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.circuit_breaker'}",
"completed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.completed'}",
"completed_with_warnings": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.completed_with_warnings'}",
"evaluating": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.evaluating'}",
"executing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.executing'}",
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.failed'}",
"importing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.importing'}",
"pending": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.pending'}",
"processing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.processing'}",
"queued": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.queued'}",
"reconciling": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.reconciling'}",
"retry_wait": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.retry_wait'}",
"running": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.running'}"
"circuit_breaker": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.circuit_breaker'}",
"completed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.completed'}",
"completed_with_warnings": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.completed_with_warnings'}",
"evaluating": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.evaluating'}",
"executing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.executing'}",
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.failed'}",
"importing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.importing'}",
"pending": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.pending'}",
"processing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.processing'}",
"queued": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.queued'}",
"reconciling": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.reconciling'}",
"retry_wait": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.retry_wait'}",
"running": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.running'}"
},
"run_progress": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_progress'}",
"run_start_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_start_error'}",
"run_status_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_status_error'}",
"select_record": "@:{'templates.generated.compat.invoicing_period.xlvask_review.select_record'}",
"show_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.show_match'}",
"source_revision": "@:{'templates.generated.compat.invoicing_period.xlvask_review.source_revision'}",
"run_progress": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_progress'}",
"run_start_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_start_error'}",
"run_status_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_status_error'}",
"select_record": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.select_record'}",
"show_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.show_match'}",
"source_revision": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.source_revision'}",
"states": {
"accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.accepted_attach'}",
"accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.accepted_create'}",
"already_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.already_linked'}",
"auto_accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_accepted_attach'}",
"auto_accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_accepted_create'}",
"auto_created": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_created'}",
"auto_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_linked'}",
"blocked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.blocked'}",
"certain": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.certain'}",
"denied": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.denied'}",
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.failed'}",
"ignored": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.ignored'}",
"invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.invalid'}",
"needs_review": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.needs_review'}",
"new": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.new'}",
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.none'}",
"suggested_attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.suggested_attach_order'}",
"suggested_create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.suggested_create_order'}",
"uncertain": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.uncertain'}",
"unchanged": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.unchanged'}",
"updated": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.updated'}"
"accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.accepted_attach'}",
"accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.accepted_create'}",
"already_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.already_linked'}",
"auto_accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_attach'}",
"auto_accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_create'}",
"auto_created": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_created'}",
"auto_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_linked'}",
"blocked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.blocked'}",
"certain": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.certain'}",
"denied": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.denied'}",
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.failed'}",
"ignored": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.ignored'}",
"invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.invalid'}",
"needs_review": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.needs_review'}",
"new": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.new'}",
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.none'}",
"suggested_attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.suggested_attach_order'}",
"suggested_create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.suggested_create_order'}",
"uncertain": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.uncertain'}",
"unchanged": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.unchanged'}",
"updated": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.updated'}"
},
"summary_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.summary_error'}",
"summary_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.summary_title'}",
"summary_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.summary_error'}",
"summary_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.summary_title'}",
"labels": {
"attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.attached_to_order'}",
"not_attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.not_attached_to_order'}",
"compare_select_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.compare_select_duplicate'}",
"compare_price_mismatch_inline": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.compare_price_mismatch_inline'}",
"wash_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.wash_id'}",
"wash_already_withdrawn": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.wash_already_withdrawn'}",
"go_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.go_to_order'}",
"unknown_product": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.unknown_product'}",
"unknown_product_with_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.unknown_product_with_id'}",
"ok": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.ok'}"
"attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.attached_to_order'}",
"not_attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.not_attached_to_order'}",
"compare_select_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.compare_select_duplicate'}",
"compare_price_mismatch_inline": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.compare_price_mismatch_inline'}",
"wash_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.wash_id'}",
"wash_already_withdrawn": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.wash_already_withdrawn'}",
"go_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.go_to_order'}",
"unknown_product": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.unknown_product'}",
"unknown_product_with_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.unknown_product_with_id'}",
"ok": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.ok'}"
},
"time": {
"just_now": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.just_now'}",
"ago_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.ago_one'}",
"ago_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.ago_other'}",
"just_now": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.just_now'}",
"ago_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.ago_one'}",
"ago_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.ago_other'}",
"units": {
"year_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.year_one'}",
"year_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.year_other'}",
"month_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.month_one'}",
"month_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.month_other'}",
"week_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.week_one'}",
"week_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.week_other'}",
"day_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.day_one'}",
"day_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.day_other'}",
"hour_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.hour_one'}",
"hour_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.hour_other'}",
"minute_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.minute_one'}",
"minute_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.minute_other'}",
"second_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.second_one'}",
"second_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.second_other'}"
"year_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.year_one'}",
"year_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.year_other'}",
"month_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.month_one'}",
"month_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.month_other'}",
"week_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.week_one'}",
"week_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.week_other'}",
"day_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.day_one'}",
"day_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.day_other'}",
"hour_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.hour_one'}",
"hour_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.hour_other'}",
"minute_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.minute_one'}",
"minute_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.minute_other'}",
"second_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.second_one'}",
"second_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.second_other'}"
}
},
"errors": {
"fetch_usage_log": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_usage_log'}",
"fetch_vehicle_types": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_vehicle_types'}",
"fetch_related_orders": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_related_orders'}",
"fetch_fast_link": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_fast_link'}",
"create_order_unrecognized_items": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_unrecognized_items'}",
"create_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_failed'}",
"create_order_item_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}",
"api_endpoint_not_implemented": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.api_endpoint_not_implemented'}"
"fetch_usage_log": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_usage_log'}",
"fetch_vehicle_types": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'}",
"fetch_related_orders": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_related_orders'}",
"fetch_fast_link": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_fast_link'}",
"create_order_unrecognized_items": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.create_order_unrecognized_items'}",
"create_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.create_order_failed'}",
"create_order_item_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'}"
}
}
},
@@ -4852,8 +4848,6 @@
"last_wash_matches_current_selection": "@:{'templates.generated.compat.pos.last_wash_matches_current_selection'}",
"license_plate": "@:{'templates.generated.compat.tables.common.registration_number'}",
"manual_entry": "@:{'templates.generated.compat.pos.manual_entry'}",
"last_wash": "@:{'templates.generated.compat.global.last_wash'}",
"never_washed": "@:{'templates.generated.compat.pos.never_washed'}",
"new_order": "@:{'templates.generated.compat.pos.new_order'}",
"new_sale": "@:{'templates.generated.compat.pos.new_sale'}",
"no_bookings": "@:{'templates.generated.compat.pos.no_bookings'}",
+3 -10
View File
@@ -2263,7 +2263,6 @@
"username": "@.capitalize:{'words.generated.brukernavn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
},
"book_wash": {
@@ -4344,7 +4343,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@.capitalize:{'words.generated.vaskesertifikat'} @:{'words.generated.er'} @:{'words.generated.vedlagt'} @:{'words.generated.uten'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.vaskesertifikat'}-@:{'words.generated.linje'}.",
"wash_certificate_item_without_certificate": "@.capitalize:{'words.generated.vaskesertifikat'}-@:{'words.generated.linjen'} @:{'words.generated.finnes'} @:{'words.generated.uten'} @:{'words.generated.et'} @:{'words.generated.vaskesertifikat'}.",
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-registreringen @:{'words.generated.er'} verken ignorert @:{'words.generated.eller'} knyttet @:{'words.generated.til'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.ordre'} @:{'words.generated.i'} @:{'words.replication.article.host_mention'} @:{'words.generated.valgte'} @:{'words.generated.periode'}."
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor linked @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
},
"preview": {
"entities": {
@@ -4352,7 +4351,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-opplysninger"
},
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -4793,7 +4791,7 @@
"workflow": "Arbeidsflyt"
}
},
"xlvask_review": {
"xlvask_autopilot": {
"actions": {
"accept": "Godta forslag",
"attach_order": "Knytt til ordre",
@@ -4989,8 +4987,7 @@
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
"redirect_order_failed": "Ordren kunne ikke åpnes.",
"load_customers_failed": "Kundene kunne ikke hentes.",
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes.",
"api_endpoint_not_implemented": "API-endepunkt er ikke implementert ennå"
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes."
}
}
},
@@ -5561,7 +5558,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.tjeneste'}, @:{'words.generated.tillegg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.forrige'} @:{'words.generated.vask'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldri vasket",
"new_order": "@.capitalize:{'words.generated.bestillingen'}",
"new_sale": "@.capitalize:{'words.generated.nytt'} @:{'words.generated.salg'}",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
@@ -6030,9 +6026,6 @@
"open_property_exit_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} utgangsport",
"perform_wash": "Utfør @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørsmal'} @:{'words.generated.besvart'}",
"red_car_warning_title": "Rød bil på plassen",
"red_car_warning_message": "Denne kunden er markert som rød bil. Rød lakk er mer utsatt for flekker og virvler — håndter med ekstra forsiktighet.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: unngå høytrykk og harde børster, og tørk med en myk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.velg'} {plate} @:{'words.generated.fra'} @:{'words.generated.kjøretøyene'} @:{'words.generated.dine'}",
"select_vehicle_type": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøytype'}",
"select_vehicle": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøy'}",
+3 -10
View File
@@ -2313,7 +2313,6 @@
"username": "@.capitalize:{'words.generated.anvandarnamn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.anvandarnamn'}",
"driver_login_button": "Förarinloggning",
"driver_login_help": "Förare (underanvändare) loggar in här för att registrera en tvätt.",
"driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt."
},
"book_wash": {
@@ -4394,7 +4393,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} @:{'words.generated.match'} @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@.capitalize:{'words.generated.tvattcertifikat_2'} ar bifogat @:{'words.generated.utan'} @:{'words.replication.host_definite_suffix'} tvattcertifikatrad.",
"wash_certificate_item_without_certificate": "@:{'words.generated.tvattcertifikatraden'} @:{'words.generated.finns'} @:{'words.generated.utan'} @:{'words.generated.ett'} @:{'words.generated.tvattcertifikat_2'}.",
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-registreringen @:{'words.generated.ar'} varken ignorerad @:{'words.generated.eller'} kopplad @:{'words.generated.till'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.order'} @:{'words.generated.i'} @:{'words.replication.article.host_mention'} @:{'words.generated.valda'} perioden."
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor @:{'words.generated.linked'} @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
},
"preview": {
"entities": {
@@ -4402,7 +4401,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.uppgifter'}"
},
"expected_price": "@.capitalize:{'words.generated.forvantat'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttid",
"wash_id": "@.capitalize:{'words.generated.tvatt_2'}-@.upper:{'words.generated.id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -4843,7 +4841,7 @@
"workflow": "Arbetsflöde"
}
},
"xlvask_review": {
"xlvask_autopilot": {
"actions": {
"accept": "Godkänn förslag",
"attach_order": "Koppla order",
@@ -5039,8 +5037,7 @@
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
"redirect_order_failed": "Ordern kunde inte öppnas.",
"load_customers_failed": "Kunderna kunde inte hämtas.",
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas.",
"api_endpoint_not_implemented": "API-slutpunkt är inte implementerad ännu"
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas."
}
}
},
@@ -5611,7 +5608,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samma'} @:{'words.generated.tjanst'}, @:{'words.generated.tillval'} @:{'words.generated.och'} @:{'words.generated.extra'} @:{'words.generated.artiklar'} @:{'words.generated.som'} @:{'words.generated.senaste'} @:{'words.generated.tvatten'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldrig tvättad",
"new_order": "@.capitalize:{'words.generated.ny'} @:{'words.generated.order'}",
"new_sale": "@.capitalize:{'words.generated.nytt'} salg",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
@@ -6080,9 +6076,6 @@
"open_property_exit_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} utgangsgrind",
"perform_wash": "Perform @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.fragor'} @:{'words.generated.besvarade'}",
"red_car_warning_title": "Röd bil på plats",
"red_car_warning_message": "Denna kund är flaggad som röd bil. Röd lack är känsligare för fläckar och virvlar — hantera med extra försiktighet.",
"red_car_warning_suggestion": "Rekommendera en skonsam tvätt: undvik högtryck och hårda borstar, och torka med en mjuk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.valj'} {plate} @:{'words.generated.fran'} @:{'words.generated.dina'} @:{'words.generated.fordon'}",
"select_vehicle_type": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordonstyp'}",
"select_vehicle": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordon'}",
@@ -5,7 +5,6 @@
"customer_login_title": "@:{'phrases.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.chauffør'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @:{'terms.glossary.her'} @:{'terms.glossary.for'} @:{'terms.glossary.at'} @:{'terms.glossary.fa'} @:{'terms.glossary.adgang'} @:{'terms.glossary.til'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @.lower:{'terms.glossary.som'} @:{'terms.glossary.chauffør'}",
"driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.",
"driver_login_title": "@.capitalize:{'terms.glossary.chauffør'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Glemt @:{'terms.glossary.adgangskode'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} @:{'terms.glossary.digitale'} @:{'terms.glossary.løsninger'} @:{'terms.glossary.til'} @:{'terms.glossary.lastbilvask'}",
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.oplysninger'}"
},
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
@@ -1,5 +1,5 @@
{
"compat": { "invoicing_period": { "xlvask_review": {
"compat": { "invoicing_period": { "xlvask_autopilot": {
"actions": { "accept": "Accepter forslag", "attach_order": "Tilknyt ordre", "compare": "Sammenlign kandidater", "compare_modal_title": "Sammenlign kandidater med vask", "compare_no_candidates": "Ingen kandidater at sammenligne", "compare_price_match": "Pris matcher", "compare_price_mismatch": "Pris matcher ikke", "compare_usage_price": "Vaskens beregnede pris", "compare_candidate_price": "Kandidatpris", "create_order": "Opret ordre", "deny": "Afvis", "ignore": "Ignorer", "link": "Tilknyt ordre-ID", "link_prompt_label": "Indtast ordre-ID", "link_prompt_invalid": "Indtast et gyldigt numerisk ordre-ID", "link_prompt_title": "Tilknyt vask til en eksisterende ordre", "none": "Ingen handling", "recheck": "Kontrollér igen", "resolve_mapping": "Ret tilknytning" },
"adjudication": {
"confirm_correct": "Bekræft, at den automatiske handling var korrekt.",
@@ -112,8 +112,7 @@
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
"redirect_order_failed": "Ordren kunne ikke åbnes.",
"load_customers_failed": "Kunderne kunne ikke hentes.",
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes.",
"api_endpoint_not_implemented": "API-endepunkt ikke implementeret endnu"
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes."
}
} } }
}
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.ydelse'}, @:{'terms.glossary.tilvalg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.sidste'} @:{'terms.glossary.vask'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
"never_washed": "Aldrig vasket",
"new_order": "@.capitalize:{'terms.glossary.ny'} order",
"new_sale": "@.capitalize:{'terms.glossary.nyt'} @:{'terms.glossary.salg'}",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bookinger'} @:{'terms.glossary.fundet'}",
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@:{'terms.glossary.abn'} udgangsport",
"perform_wash": "@:{'terms.glossary.udfør'} @:{'terms.glossary.vask'}",
"questions_answered": "@.capitalize:{'terms.glossary.spørgsmal'} @:{'terms.glossary.besvaret'}",
"red_car_warning_title": "Rød bil på pladsen",
"red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.vælg'} {plate} @:{'terms.glossary.fra'} @:{'terms.glossary.dine'} @:{'terms.glossary.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.type'} @:{'terms.glossary.af'} @:{'terms.glossary.køretøj'}",
"select_vehicle": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.køretøj'}",
@@ -12,7 +12,6 @@
"username": "@:{'terms.glossary.benutzername'}",
"your_username": "@:{'terms.glossary.ihr'} @:{'terms.glossary.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
}
}
@@ -26,7 +26,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@:{'terms.glossary.waschzertifikat'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} angehaengt.",
"wash_certificate_item_without_certificate": "@:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'} vorhanden.",
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrierung'} @:{'terms.glossary.ist'} weder ignoriert noch @:{'terms.glossary.mit'} einer @:{'terms.glossary.bestellung'} im ausgewaehlten @:{'terms.glossary.zeitraum'} verknuepft."
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor linked @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}."
},
"preview": {
"entities": {
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'}-@.capitalize:{'terms.glossary.details'}"
},
"expected_price": "Erwarteter @:{'terms.glossary.preis'}",
"no_xlvask_usage_log_metadata": "Keine Metadaten @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@:{'phrases.compat.tables.common.start_time'}",
"wash_id": "Wasch-@.upper:{'terms.glossary.id'}",
"xlvask_usage_log": "@:{'phrases.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
@@ -55,4 +54,4 @@
}
}
}
}
}
@@ -1,7 +1,7 @@
{
"compat": {
"invoicing_period": {
"xlvask_review": {
"xlvask_autopilot": {
"actions": {
"accept": "Vorschlag annehmen",
"attach_order": "Auftrag verknüpfen",
@@ -191,8 +191,7 @@
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
"load_customers_failed": "Die Kunden konnten nicht geladen werden.",
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden.",
"api_endpoint_not_implemented": "API-Endpunkt ist noch nicht implementiert"
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden."
}
}
}
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.gleiche'} @:{'terms.glossary.leistung'}, @:{'terms.glossary.zusatzoptionen'} @:{'terms.glossary.und'} @:{'terms.glossary.zusatzartikel'} @:{'terms.glossary.wie'} @:{'terms.glossary.bei'} @:{'terms.glossary.der'} @:{'terms.glossary.letzten'} @:{'terms.glossary.wasche'}",
"manual_entry": "@.capitalize:{'terms.glossary.manuell'} registrering",
"never_washed": "Noch nie gewaschen",
"new_order": "@.capitalize:{'terms.glossary.neuer'} @:{'terms.glossary.auftrag'}",
"new_sale": "Nytt salg",
"no_bookings": "@.capitalize:{'terms.glossary.keine'} @:{'terms.glossary.buchungen'} @:{'terms.glossary.gefunden'}",
@@ -24,9 +24,6 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'terms.glossary.oeffnen'}",
"perform_wash": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.sche'} durchf?@:{'terms.glossary.hren'}",
"questions_answered": "@:{'terms.glossary.fragen'} @:{'terms.glossary.beantwortet'}",
"red_car_warning_title": "Rotes Auto vor Ort",
"red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
"red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.hlen'} @.capitalize:{'terms.glossary.sie'} {plate} @:{'terms.glossary.aus'} @:{'terms.glossary.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'terms.glossary.fahrzeugtyp'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
"select_vehicle": "@:{'terms.glossary.fahrzeug'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
@@ -5,7 +5,6 @@
"customer_login_title": "@.capitalize:{'terms.glossary.customer'} @.capitalize:{'terms.glossary.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.are'} @:{'terms.glossary.you'} @:{'terms.glossary.a'} @:{'terms.glossary.driver'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.in'} @:{'terms.glossary.here'} @:{'terms.glossary.to'} @:{'terms.glossary.register'} @:{'terms.glossary.a'} @:{'terms.glossary.wash'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"driver_login_help": "Drivers (sub-users) log in here to register a wash.",
"driver_login_title": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Forgot @.capitalize:{'terms.glossary.password'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} - @:{'terms.glossary.digital'} @:{'terms.glossary.solutions'} @:{'terms.glossary.for'} @:{'terms.glossary.truck'} @:{'terms.glossary.washing'}",
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.details'}"
},
"expected_price": "@.capitalize:{'terms.glossary.expected'} @:{'terms.glossary.price'}",
"no_xlvask_usage_log_metadata": "No metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@.capitalize:{'terms.glossary.start'} @:{'terms.glossary.time'}",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.registration'}"
@@ -55,4 +54,4 @@
}
}
}
}
}
@@ -1,5 +1,5 @@
{
"compat": { "invoicing_period": { "xlvask_review": {
"compat": { "invoicing_period": { "xlvask_autopilot": {
"actions": { "accept": "Accept suggestion", "attach_order": "Attach order", "compare": "Compare candidates", "compare_modal_title": "Compare candidates with wash", "compare_no_candidates": "No candidates to compare", "compare_price_match": "Price matches", "compare_price_mismatch": "Price does not match", "compare_usage_price": "Computed wash price", "compare_candidate_price": "Candidate price", "create_order": "Create order", "deny": "Deny", "ignore": "Ignore", "link": "Link order ID", "link_prompt_label": "Enter order ID", "link_prompt_invalid": "Enter a valid numeric order ID", "link_prompt_title": "Link the wash to an existing order", "none": "No action", "recheck": "Check again", "resolve_mapping": "Fix mapping" },
"adjudication": {
"confirm_correct": "Confirm that the automatic action was correct.",
@@ -112,8 +112,7 @@
"create_order_item_failed": "The wash item could not be added to the order.",
"redirect_order_failed": "The order could not be opened.",
"load_customers_failed": "The customers could not be loaded.",
"load_usage_log_failed": "The usage log could not be loaded.",
"api_endpoint_not_implemented": "API endpoint not implemented yet"
"load_usage_log_failed": "The usage log could not be loaded."
}
} } }
}
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.same'} @:{'terms.glossary.service'}, @:{'terms.glossary.add'}-@:{'terms.glossary.ons'}, @:{'terms.glossary.and'} @:{'terms.glossary.extra'} @:{'terms.glossary.items'} @:{'terms.glossary.as'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.previous'} @:{'terms.glossary.wash'}",
"manual_entry": "@:{'terms.glossary.manual'} @:{'terms.glossary.registration'}",
"never_washed": "Never washed",
"new_order": "@.capitalize:{'terms.glossary.new'} @:{'terms.glossary.order'}",
"new_sale": "@.capitalize:{'terms.glossary.new'} sale",
"no_bookings": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.orders'} @:{'terms.glossary.found'}",
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.exit'} @:{'terms.glossary.gate'}",
"perform_wash": "@.capitalize:{'terms.glossary.perform'} @:{'terms.glossary.wash'}",
"questions_answered": "@.capitalize:{'terms.glossary.questions'} @:{'terms.glossary.answered'}",
"red_car_warning_title": "Red car on site",
"red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
"red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.select'} {plate} @:{'terms.glossary.from'} @:{'terms.glossary.your'} @:{'terms.glossary.vehicles'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.type'}",
"select_vehicle": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'}",
@@ -5,7 +5,6 @@
"customer_number": "@:{'phrases.compat.objects.columns.customer_number'}",
"driver_entry_intro": "@:{'phrases.compat.auth.driver_entry_intro'}",
"driver_login_button": "@:{'phrases.compat.auth.driver_login_button'}",
"driver_login_help": "@:{'phrases.compat.auth.driver_login_help'}",
"driver_login_title": "@:{'phrases.compat.auth.driver_login_title'}",
"forgot_password": "@:{'phrases.compat.auth.forgot_password'}",
"login_as_customer": "@:{'phrases.compat.global.login_as_customer'}",
@@ -403,7 +403,6 @@
"api_settings_desc": "@:{'phrases.compat.configuration.xlvask.api_settings_desc'}",
"automation_settings": "@:{'phrases.compat.configuration.xlvask.automation_settings'}",
"automation_settings_desc": "@:{'phrases.compat.configuration.xlvask.automation_settings_desc'}",
"automation_settings_removed_desc": "Automatic attachment, automatic creation, OpenAI and MiniMax integrations have been removed. Operator review is now performed from the Superuser -> Fakturaer -> Periode -> Selvvask view.",
"connection_failed": "@:{'phrases.compat.configuration.xlvask.connection_failed'}",
"connection_failed_desc": "@:{'phrases.compat.configuration.xlvask.connection_failed_desc'}",
"connection_success": "@:configuration.limble.connection_success",
@@ -36,7 +36,6 @@
"expected_price": "@:{'phrases.compat.invoice_period.flags.preview.expected_price'}",
"no_order_items": "@:common.templates.no_entity_available",
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
"no_xlvask_usage_log_metadata": "@:{'phrases.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}",
"order_items": "@:{'phrases.compat.global_search.entity_types.order_items'}",
"price": "@:common.price",
"product": "@:common.product",
@@ -0,0 +1,203 @@
{
"invoicing_period": {
"xlvask_autopilot": {
"actions": {
"accept": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.accept'}",
"attach_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.attach_order'}",
"compare": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare'}",
"compare_modal_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_modal_title'}",
"compare_no_candidates": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_no_candidates'}",
"compare_price_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_price_match'}",
"compare_price_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_price_mismatch'}",
"compare_usage_price": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_usage_price'}",
"compare_candidate_price": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_candidate_price'}",
"create_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.create_order'}",
"deny": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.deny'}",
"ignore": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.ignore'}",
"link": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link'}",
"link_prompt_label": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_label'}",
"link_prompt_invalid": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_invalid'}",
"link_prompt_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_title'}",
"none": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.none'}",
"recheck": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.recheck'}",
"resolve_mapping": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.resolve_mapping'}"
},
"adjudication": {
"confirm_correct": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_correct'}",
"confirm_cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_cross_hall'}",
"confirm_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_duplicate'}",
"confirm_incorrect": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_incorrect'}",
"confirm_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_title'}",
"confirm_unaudited": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_unaudited'}",
"description": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.description'}",
"halted": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.halted'}",
"outcomes": {
"correct": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.correct'}",
"cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.cross_hall'}",
"duplicate": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.duplicate'}",
"incorrect": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.incorrect'}",
"unaudited": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.unaudited'}"
},
"saved": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.saved'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.title'}"
},
"audit": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.audit'}",
"bulk_selected": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.bulk_selected'}",
"bulk_eligibility": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.bulk_eligibility'}",
"calibrated_probability": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.calibrated_probability'}",
"candidates": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.candidates'}",
"contradictions": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.contradictions'}",
"controls": {
"active_run_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.active_run_error'}",
"analyze": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.analyze'}",
"budget": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.budget'}",
"execute": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute'}",
"execute_description": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute_description'}",
"execute_phrase": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute_phrase'}",
"execute_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute_title'}",
"halt": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.halt'}",
"halt_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.halt_title'}",
"load_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.load_error'}",
"no_access": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.no_access'}",
"not_ready": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.not_ready'}",
"policy_advance": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_advance'}",
"policy_apply": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_apply'}",
"policy_description": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_description'}",
"policy_phrase": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_phrase'}",
"policy_reason": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_reason'}",
"policy_reason_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_reason_title'}",
"policy_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_title'}",
"readiness_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.readiness_error'}",
"ready": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.ready'}",
"stage": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.stage'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.title'}",
"worker_healthy": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.worker_healthy'}",
"worker_unhealthy": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.worker_unhealthy'}"
},
"clear_selection": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.clear_selection'}",
"evidence": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.evidence'}",
"filters": {
"all": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.all'}",
"certainty": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.certainty'}",
"clear": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.clear'}",
"import_state": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.import_state'}",
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.planned_action'}",
"resolution_state": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.resolution_state'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.title'}",
"unattached_only": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.unattached_only'}"
},
"hide_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.hide_match'}",
"model": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.model'}",
"no_safe_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.no_safe_match'}",
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.planned_action'}",
"policy_version": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.policy_version'}",
"preview": {
"after": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.after'}",
"applied": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.applied'}",
"apply": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.apply'}",
"before": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.before'}",
"confirmation_label": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.confirmation_label'}",
"confirmation_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.confirmation_mismatch'}",
"confirmation_phrase": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.confirmation_phrase'}",
"error_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.error_title'}",
"reason_label": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.reason_label'}",
"reason_required": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.reason_required'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.title'}"
},
"resume_status": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.resume_status'}",
"run": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run'}",
"run_id": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_id'}",
"run_phases": {
"circuit_breaker": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.circuit_breaker'}",
"completed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.completed'}",
"completed_with_warnings": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.completed_with_warnings'}",
"evaluating": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.evaluating'}",
"executing": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.executing'}",
"failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.failed'}",
"importing": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.importing'}",
"pending": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.pending'}",
"processing": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.processing'}",
"queued": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.queued'}",
"reconciling": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.reconciling'}",
"retry_wait": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.retry_wait'}",
"running": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.running'}"
},
"run_progress": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_progress'}",
"run_start_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_start_error'}",
"run_status_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_status_error'}",
"select_record": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.select_record'}",
"show_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.show_match'}",
"source_revision": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.source_revision'}",
"states": {
"accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.accepted_attach'}",
"accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.accepted_create'}",
"already_linked": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.already_linked'}",
"auto_accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_attach'}",
"auto_accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_create'}",
"auto_created": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_created'}",
"auto_linked": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_linked'}",
"blocked": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.blocked'}",
"certain": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.certain'}",
"denied": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.denied'}",
"failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.failed'}",
"ignored": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.ignored'}",
"invalid": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.invalid'}",
"needs_review": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.needs_review'}",
"new": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.new'}",
"none": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.none'}",
"suggested_attach_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.suggested_attach_order'}",
"suggested_create_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.suggested_create_order'}",
"uncertain": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.uncertain'}",
"unchanged": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.unchanged'}",
"updated": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.updated'}"
},
"summary_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.summary_error'}",
"summary_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.summary_title'}",
"labels": {
"attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.attached_to_order'}",
"not_attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.not_attached_to_order'}",
"compare_select_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.compare_select_duplicate'}",
"compare_price_mismatch_inline": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.compare_price_mismatch_inline'}",
"wash_id": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.wash_id'}",
"wash_already_withdrawn": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.wash_already_withdrawn'}",
"go_to_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.go_to_order'}",
"unknown_product": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.unknown_product'}",
"unknown_product_with_id": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.unknown_product_with_id'}",
"ok": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.ok'}"
},
"time": {
"just_now": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.just_now'}",
"ago_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.ago_one'}",
"ago_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.ago_other'}",
"units": {
"year_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.year_one'}",
"year_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.year_other'}",
"month_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.month_one'}",
"month_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.month_other'}",
"week_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.week_one'}",
"week_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.week_other'}",
"day_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.day_one'}",
"day_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.day_other'}",
"hour_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.hour_one'}",
"hour_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.hour_other'}",
"minute_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.minute_one'}",
"minute_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.minute_other'}",
"second_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.second_one'}",
"second_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.second_other'}"
}
},
"errors": {
"fetch_usage_log": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_usage_log'}",
"fetch_vehicle_types": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'}",
"fetch_related_orders": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_related_orders'}",
"fetch_fast_link": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_fast_link'}",
"create_order_unrecognized_items": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.create_order_unrecognized_items'}",
"create_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.create_order_failed'}",
"create_order_item_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'}"
}
}
}
}
@@ -1,204 +0,0 @@
{
"invoicing_period": {
"xlvask_review": {
"actions": {
"accept": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.accept'}",
"attach_order": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.attach_order'}",
"compare": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare'}",
"compare_modal_title": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_modal_title'}",
"compare_no_candidates": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_no_candidates'}",
"compare_price_match": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_price_match'}",
"compare_price_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_price_mismatch'}",
"compare_usage_price": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_usage_price'}",
"compare_candidate_price": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_candidate_price'}",
"create_order": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.create_order'}",
"deny": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.deny'}",
"ignore": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.ignore'}",
"link": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link'}",
"link_prompt_label": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link_prompt_label'}",
"link_prompt_invalid": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link_prompt_invalid'}",
"link_prompt_title": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link_prompt_title'}",
"none": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.none'}",
"recheck": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.recheck'}",
"resolve_mapping": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.resolve_mapping'}"
},
"adjudication": {
"confirm_correct": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_correct'}",
"confirm_cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_cross_hall'}",
"confirm_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_duplicate'}",
"confirm_incorrect": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_incorrect'}",
"confirm_title": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_title'}",
"confirm_unaudited": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_unaudited'}",
"description": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.description'}",
"halted": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.halted'}",
"outcomes": {
"correct": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.correct'}",
"cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.cross_hall'}",
"duplicate": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.duplicate'}",
"incorrect": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.incorrect'}",
"unaudited": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.unaudited'}"
},
"saved": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.saved'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.title'}"
},
"audit": "@:{'phrases.compat.invoicing_period.xlvask_review.audit'}",
"bulk_selected": "@:{'phrases.compat.invoicing_period.xlvask_review.bulk_selected'}",
"bulk_eligibility": "@:{'phrases.compat.invoicing_period.xlvask_review.bulk_eligibility'}",
"calibrated_probability": "@:{'phrases.compat.invoicing_period.xlvask_review.calibrated_probability'}",
"candidates": "@:{'phrases.compat.invoicing_period.xlvask_review.candidates'}",
"contradictions": "@:{'phrases.compat.invoicing_period.xlvask_review.contradictions'}",
"controls": {
"active_run_error": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.active_run_error'}",
"analyze": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.analyze'}",
"budget": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.budget'}",
"execute": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute'}",
"execute_description": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute_description'}",
"execute_phrase": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute_phrase'}",
"execute_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute_title'}",
"halt": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.halt'}",
"halt_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.halt_title'}",
"load_error": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.load_error'}",
"no_access": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.no_access'}",
"not_ready": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.not_ready'}",
"policy_advance": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_advance'}",
"policy_apply": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_apply'}",
"policy_description": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_description'}",
"policy_phrase": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_phrase'}",
"policy_reason": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_reason'}",
"policy_reason_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_reason_title'}",
"policy_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_title'}",
"readiness_error": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.readiness_error'}",
"ready": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.ready'}",
"stage": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.stage'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.title'}",
"worker_healthy": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.worker_healthy'}",
"worker_unhealthy": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.worker_unhealthy'}"
},
"clear_selection": "@:{'phrases.compat.invoicing_period.xlvask_review.clear_selection'}",
"evidence": "@:{'phrases.compat.invoicing_period.xlvask_review.evidence'}",
"filters": {
"all": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.all'}",
"certainty": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.certainty'}",
"clear": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.clear'}",
"import_state": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.import_state'}",
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.planned_action'}",
"resolution_state": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.resolution_state'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.title'}",
"unattached_only": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.unattached_only'}"
},
"hide_match": "@:{'phrases.compat.invoicing_period.xlvask_review.hide_match'}",
"model": "@:{'phrases.compat.invoicing_period.xlvask_review.model'}",
"no_safe_match": "@:{'phrases.compat.invoicing_period.xlvask_review.no_safe_match'}",
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_review.planned_action'}",
"policy_version": "@:{'phrases.compat.invoicing_period.xlvask_review.policy_version'}",
"preview": {
"after": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.after'}",
"applied": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.applied'}",
"apply": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.apply'}",
"before": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.before'}",
"confirmation_label": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.confirmation_label'}",
"confirmation_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.confirmation_mismatch'}",
"confirmation_phrase": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.confirmation_phrase'}",
"error_title": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.error_title'}",
"reason_label": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.reason_label'}",
"reason_required": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.reason_required'}",
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.title'}"
},
"resume_status": "@:{'phrases.compat.invoicing_period.xlvask_review.resume_status'}",
"run": "@:{'phrases.compat.invoicing_period.xlvask_review.run'}",
"run_id": "@:{'phrases.compat.invoicing_period.xlvask_review.run_id'}",
"run_phases": {
"circuit_breaker": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.circuit_breaker'}",
"completed": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.completed'}",
"completed_with_warnings": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.completed_with_warnings'}",
"evaluating": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.evaluating'}",
"executing": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.executing'}",
"failed": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.failed'}",
"importing": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.importing'}",
"pending": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.pending'}",
"processing": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.processing'}",
"queued": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.queued'}",
"reconciling": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.reconciling'}",
"retry_wait": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.retry_wait'}",
"running": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.running'}"
},
"run_progress": "@:{'phrases.compat.invoicing_period.xlvask_review.run_progress'}",
"run_start_error": "@:{'phrases.compat.invoicing_period.xlvask_review.run_start_error'}",
"run_status_error": "@:{'phrases.compat.invoicing_period.xlvask_review.run_status_error'}",
"select_record": "@:{'phrases.compat.invoicing_period.xlvask_review.select_record'}",
"show_match": "@:{'phrases.compat.invoicing_period.xlvask_review.show_match'}",
"source_revision": "@:{'phrases.compat.invoicing_period.xlvask_review.source_revision'}",
"states": {
"accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_review.states.accepted_attach'}",
"accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_review.states.accepted_create'}",
"already_linked": "@:{'phrases.compat.invoicing_period.xlvask_review.states.already_linked'}",
"auto_accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_accepted_attach'}",
"auto_accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_accepted_create'}",
"auto_created": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_created'}",
"auto_linked": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_linked'}",
"blocked": "@:{'phrases.compat.invoicing_period.xlvask_review.states.blocked'}",
"certain": "@:{'phrases.compat.invoicing_period.xlvask_review.states.certain'}",
"denied": "@:{'phrases.compat.invoicing_period.xlvask_review.states.denied'}",
"failed": "@:{'phrases.compat.invoicing_period.xlvask_review.states.failed'}",
"ignored": "@:{'phrases.compat.invoicing_period.xlvask_review.states.ignored'}",
"invalid": "@:{'phrases.compat.invoicing_period.xlvask_review.states.invalid'}",
"needs_review": "@:{'phrases.compat.invoicing_period.xlvask_review.states.needs_review'}",
"new": "@:{'phrases.compat.invoicing_period.xlvask_review.states.new'}",
"none": "@:{'phrases.compat.invoicing_period.xlvask_review.states.none'}",
"suggested_attach_order": "@:{'phrases.compat.invoicing_period.xlvask_review.states.suggested_attach_order'}",
"suggested_create_order": "@:{'phrases.compat.invoicing_period.xlvask_review.states.suggested_create_order'}",
"uncertain": "@:{'phrases.compat.invoicing_period.xlvask_review.states.uncertain'}",
"unchanged": "@:{'phrases.compat.invoicing_period.xlvask_review.states.unchanged'}",
"updated": "@:{'phrases.compat.invoicing_period.xlvask_review.states.updated'}"
},
"summary_error": "@:{'phrases.compat.invoicing_period.xlvask_review.summary_error'}",
"summary_title": "@:{'phrases.compat.invoicing_period.xlvask_review.summary_title'}",
"labels": {
"attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.attached_to_order'}",
"not_attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.not_attached_to_order'}",
"compare_select_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.compare_select_duplicate'}",
"compare_price_mismatch_inline": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.compare_price_mismatch_inline'}",
"wash_id": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.wash_id'}",
"wash_already_withdrawn": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.wash_already_withdrawn'}",
"go_to_order": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.go_to_order'}",
"unknown_product": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.unknown_product'}",
"unknown_product_with_id": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.unknown_product_with_id'}",
"ok": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.ok'}"
},
"time": {
"just_now": "@:{'phrases.compat.invoicing_period.xlvask_review.time.just_now'}",
"ago_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.ago_one'}",
"ago_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.ago_other'}",
"units": {
"year_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.year_one'}",
"year_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.year_other'}",
"month_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.month_one'}",
"month_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.month_other'}",
"week_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.week_one'}",
"week_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.week_other'}",
"day_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.day_one'}",
"day_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.day_other'}",
"hour_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.hour_one'}",
"hour_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.hour_other'}",
"minute_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.minute_one'}",
"minute_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.minute_other'}",
"second_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.second_one'}",
"second_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.second_other'}"
}
},
"errors": {
"fetch_usage_log": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_usage_log'}",
"fetch_vehicle_types": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_vehicle_types'}",
"fetch_related_orders": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_related_orders'}",
"fetch_fast_link": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_fast_link'}",
"create_order_unrecognized_items": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.create_order_unrecognized_items'}",
"create_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.create_order_failed'}",
"create_order_item_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}",
"api_endpoint_not_implemented": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.api_endpoint_not_implemented'}"
}
}
}
}
@@ -76,8 +76,6 @@
"last_wash_matches_current_selection": "@:{'phrases.compat.pos.last_wash_matches_current_selection'}",
"license_plate": "@:{'phrases.compat.tables.common.registration_number'}",
"manual_entry": "@:{'phrases.compat.pos.manual_entry'}",
"last_wash": "@:{'phrases.compat.global.last_wash'}",
"never_washed": "@:{'phrases.compat.pos.never_washed'}",
"new_order": "@:{'phrases.compat.pos.new_order'}",
"new_sale": "@:{'phrases.compat.pos.new_sale'}",
"no_bookings": "@:{'phrases.compat.pos.no_bookings'}",
@@ -12,7 +12,6 @@
"username": "@.capitalize:{'terms.glossary.brukernavn'}",
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
}
}
@@ -26,7 +26,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.vaskesertifikat'} @:{'terms.glossary.er'} @:{'terms.glossary.vedlagt'} @:{'terms.glossary.uten'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linje'}.",
"wash_certificate_item_without_certificate": "@.capitalize:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linjen'} @:{'terms.glossary.finnes'} @:{'terms.glossary.uten'} @:{'terms.glossary.et'} @:{'terms.glossary.vaskesertifikat'}.",
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-registreringen @:{'terms.glossary.er'} verken ignorert @:{'terms.glossary.eller'} knyttet @:{'terms.glossary.til'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ordre'} @:{'terms.glossary.i'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valgte'} @:{'terms.glossary.periode'}."
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor linked @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}."
},
"preview": {
"entities": {
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-opplysninger"
},
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
@@ -55,4 +54,4 @@
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More