Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3873849de1 | ||
|
|
f22ca71558 | ||
|
|
7d5ec8894c | ||
|
|
f2fc7643a2 | ||
|
|
06b6498f7a | ||
|
|
c5c186242b | ||
|
|
3112a4f985 | ||
|
|
ddd31bd2dd | ||
|
|
f07c15972c | ||
|
|
210d7d051d | ||
|
|
0d2098449a | ||
|
|
cafe671e85 | ||
|
|
2401ebb674 | ||
|
|
6f39eb897c | ||
|
|
670746d70c | ||
|
|
a345d91ae4 | ||
|
|
19fbbcddce | ||
|
|
7817f0c13b | ||
|
|
c19aeffb98 | ||
|
|
2e1d7f3e8f | ||
|
|
7c9eb8b644 | ||
|
|
e2cc76091f | ||
|
|
4db3be34f8 | ||
|
|
b1e0c61df0 | ||
|
|
eb8482585b | ||
|
|
c207fea61e | ||
|
|
01c5864382 | ||
|
|
c01596aeb5 | ||
|
|
5d4de1d932 | ||
|
|
768b6dcdab | ||
|
|
253d72f7fb | ||
|
|
a1fa132c99 | ||
|
|
113f49f018 | ||
|
|
666d467b46 | ||
|
|
9c74c4d477 | ||
|
|
0b7efc3be5 | ||
|
|
4cfd003864 | ||
|
|
58adb1bef5 | ||
|
|
e4bd3420c6 | ||
|
|
e08f1ecba8 | ||
|
|
187da74794 | ||
|
|
c9935d1e0a |
@@ -1,66 +0,0 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,112 @@
|
||||
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,4 +1,4 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
ruby ">= 3.2", "< 3.5"
|
||||
gem "fastlane", "2.237.0"
|
||||
gem "fastlane", "2.238.0"
|
||||
|
||||
@@ -8,8 +8,8 @@ GEM
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1271.0)
|
||||
aws-sdk-core (3.254.0)
|
||||
aws-partitions (1.1281.0)
|
||||
aws-sdk-core (3.254.1)
|
||||
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.228.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.0)
|
||||
aws-sdk-s3 (1.229.0)
|
||||
aws-sdk-core (~> 3, >= 3.254.1)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
@@ -35,45 +35,33 @@ GEM
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.5)
|
||||
csv (3.3.6)
|
||||
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)
|
||||
excon (1.6.0)
|
||||
erb (6.0.7)
|
||||
excon (1.7.0)
|
||||
logger
|
||||
faraday (2.14.3)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
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-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 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)
|
||||
faraday-net_http (3.4.4)
|
||||
net-http (~> 0.5)
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.237.0)
|
||||
fastlane (2.238.0)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
@@ -89,9 +77,11 @@ GEM
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 2.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
faraday (~> 2.7)
|
||||
faraday-cookie_jar (~> 0.0.8)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-retry (~> 2.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
@@ -101,6 +91,7 @@ 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)
|
||||
@@ -117,7 +108,7 @@ GEM
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
terminal-table (~> 4)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
@@ -126,21 +117,22 @@ 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.105.0)
|
||||
google-apis-androidpublisher_v3 (0.106.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
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
|
||||
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)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
retriable (>= 3.1, < 5.0)
|
||||
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.65.0)
|
||||
google-apis-storage_v1 (0.66.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
@@ -159,7 +151,7 @@ GEM
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.0)
|
||||
googleauth (1.17.1)
|
||||
googleauth (1.17.3)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
@@ -170,10 +162,14 @@ GEM
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
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)
|
||||
jmespath (1.6.2)
|
||||
json (2.21.1)
|
||||
json (2.21.2)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
@@ -184,22 +180,38 @@ 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 (3.8.0)
|
||||
retriable (4.2.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)
|
||||
@@ -210,15 +222,19 @@ GEM
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
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 (2.6.0)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
uri (1.1.1)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.28.1)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
@@ -239,7 +255,7 @@ PLATFORMS
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (= 2.237.0)
|
||||
fastlane (= 2.238.0)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.12p206
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# AGENT MCP SMOKE
|
||||
|
||||
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||
Safe to close.
|
||||
@@ -0,0 +1,477 @@
|
||||
# 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.
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -45,11 +45,12 @@ platform :ios do
|
||||
skip_screenshots: false,
|
||||
overwrite_screenshots: true,
|
||||
force: true,
|
||||
submit_for_review: false,
|
||||
submit_for_review: true,
|
||||
automatic_release: true,
|
||||
phased_release: false,
|
||||
run_precheck_before_submit: false,
|
||||
precheck_include_in_app_purchases: false
|
||||
precheck_include_in_app_purchases: false,
|
||||
ignore_language_directory_validation: true
|
||||
)
|
||||
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,5 +1,5 @@
|
||||
{
|
||||
"marketingVersion": "1.0.1",
|
||||
"marketingVersion": "1.1.0",
|
||||
"bundleId": "io.truckwash.app",
|
||||
"minimumIosVersion": "15.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/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;
|
||||
});
|
||||
@@ -7,8 +7,11 @@ const strict = argv.includes("--strict");
|
||||
const failures = [];
|
||||
const warnings = [];
|
||||
const root = process.cwd();
|
||||
const metadataRoot = join(root, "fastlane/metadata/da-DK");
|
||||
const screenshotRoot = join(root, "fastlane/screenshots/da-DK");
|
||||
// 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 fail = (message) => failures.push(message);
|
||||
const warn = (message) => warnings.push(message);
|
||||
|
||||
@@ -122,6 +122,7 @@ export const ownedFilesByRole = {
|
||||
"superuser-users.spec.ts",
|
||||
"superuser-vehicles.smoke.spec.js",
|
||||
"workfeed-config.smoke.spec.js",
|
||||
"xlvask-flag-to-selvvash-navigation.spec.ts",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/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
|
||||
@@ -144,3 +144,30 @@ 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,6 +328,47 @@ 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') {
|
||||
@@ -493,19 +534,29 @@ setTimeout(() => {
|
||||
:data-testid="`pos-recent-scan-details-${scan.id}`"
|
||||
>
|
||||
<div
|
||||
v-if="getCustomerDetailItems(scan).length > 0"
|
||||
v-if="getCustomerDetailItems(scan).length > 0 || getLastWashDetailItem(scan)"
|
||||
class="pos-scan-details__grid pos-scan-details__grid--customer"
|
||||
>
|
||||
<div
|
||||
v-for="item in getCustomerDetailItems(scan)"
|
||||
v-for="item in [...getCustomerDetailItems(scan), getLastWashDetailItem(scan)].filter(Boolean)"
|
||||
: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"; // Højglans
|
||||
import productImage26 from "/src/assets/piktogrammer/26.png"; // Voksforsegling (TRU-130: brand-agnostic after wax supplier swap)
|
||||
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,6 +3,11 @@ 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: {
|
||||
@@ -29,6 +34,8 @@ const form = reactive({
|
||||
notes: '',
|
||||
reference: '',
|
||||
quantity: '1',
|
||||
extraSaleReasonCode: '',
|
||||
extraSaleComment: '',
|
||||
});
|
||||
|
||||
const isSubmitting = ref(false);
|
||||
@@ -45,6 +52,8 @@ 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 = '';
|
||||
};
|
||||
|
||||
@@ -60,8 +69,27 @@ 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;
|
||||
return Boolean(props.orderItem)
|
||||
&& props.canEdit
|
||||
&& !isSubmitting.value
|
||||
&& isPriceValid.value
|
||||
&& isQuantityValid.value
|
||||
&& isExtraSaleAuditValid.value;
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
@@ -90,7 +118,11 @@ 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) {
|
||||
@@ -177,6 +209,41 @@ 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,17 +38,23 @@ 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 { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
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";
|
||||
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";
|
||||
@@ -58,6 +64,7 @@ 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();
|
||||
|
||||
@@ -345,14 +352,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
|
||||
@@ -885,83 +892,12 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
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];
|
||||
};
|
||||
// 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 sortComparableLastWashShapes = (shapes: Array<{ kind: string; product_id: number; quantity: number }>) =>
|
||||
shapes.slice().sort((left, right) => {
|
||||
@@ -988,8 +924,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),
|
||||
@@ -998,8 +934,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),
|
||||
@@ -1074,65 +1010,19 @@ const syncCurrentTransactionToOrder = async () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
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]);
|
||||
// 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 },
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1140,8 +1030,7 @@ 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);
|
||||
|
||||
@@ -1152,13 +1041,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[] = [];
|
||||
@@ -1168,8 +1057,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 (
|
||||
@@ -1181,8 +1070,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);
|
||||
@@ -1538,15 +1427,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"
|
||||
@@ -1562,8 +1451,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"
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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,5 +1,18 @@
|
||||
export const XLVASK_IMPORT_STATES = ["new", "updated", "unchanged", "invalid"];
|
||||
export const XLVASK_RESOLUTION_STATES = [
|
||||
/**
|
||||
* 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 = [
|
||||
"already_linked",
|
||||
"auto_linked",
|
||||
"auto_created",
|
||||
@@ -8,240 +21,29 @@ export 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",
|
||||
];
|
||||
|
||||
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 XLVASK_IMPORT_STATES = ["new", "updated", "unchanged", "invalid"];
|
||||
const XLVASK_CERTAINTY_STATES = ["certain", "uncertain", "none"];
|
||||
|
||||
const nonNegativeInteger = (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
|
||||
};
|
||||
|
||||
export const normalizeXlvaskAutopilotSummary = (summary) => {
|
||||
export const emptyXlvaskAutopilotSummary = () => ({
|
||||
total: 0,
|
||||
...Object.fromEntries(
|
||||
[...XLVASK_IMPORT_STATES, ...XLVASK_RESOLUTION_STATES, ...XLVASK_CERTAINTY_STATES].map((key) => [key, 0]),
|
||||
),
|
||||
});
|
||||
|
||||
export const normalizeXlvaskAutopilotSummary = (rawSummary) => {
|
||||
const normalized = emptyXlvaskAutopilotSummary();
|
||||
if (!rawSummary || typeof rawSummary !== "object") {
|
||||
return normalized;
|
||||
}
|
||||
Object.keys(normalized).forEach((key) => {
|
||||
normalized[key] = nonNegativeInteger(summary?.[key]);
|
||||
normalized[key] = nonNegativeInteger(rawSummary[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,15 +5,6 @@ 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);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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 };
|
||||
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<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,6 +2,7 @@
|
||||
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;
|
||||
@@ -17,6 +18,7 @@ const props = defineProps<{
|
||||
vehicleTypes: Array<any>;
|
||||
vehicleStepError?: string | null;
|
||||
vehicleStepGuidance?: string | null;
|
||||
isRedCar?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -141,6 +143,11 @@ 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,7 +50,16 @@ const getOrders = (object) => {
|
||||
let invoice_orders = invoice.objects;
|
||||
orders = orders.concat(invoice_orders);
|
||||
}
|
||||
return 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 ?? ""));
|
||||
});
|
||||
}
|
||||
|
||||
getDepartments();
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<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";
|
||||
@@ -36,15 +40,19 @@ 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, but we sort defensively here so a stale
|
||||
// cache or older API proxy cannot regress the render order.
|
||||
// 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).
|
||||
const sortOrderItemsForDisplay = (items) => {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
@@ -70,63 +78,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 || "")
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
})
|
||||
return String(left?.created_at || left?.fingerprint || "").localeCompare(
|
||||
String(right?.created_at || right?.fingerprint || "")
|
||||
);
|
||||
});
|
||||
|
||||
const getOrderItemFlagIconClass = (orderItem) => (getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : "");
|
||||
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 getOrderItemFlagColorClass = (orderItem) => {
|
||||
const flags = getOrderItemFlags(orderItem);
|
||||
@@ -137,23 +145,19 @@ 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>
|
||||
@@ -161,128 +165,125 @@ watch(
|
||||
<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>
|
||||
<!-- 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 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>
|
||||
</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>
|
||||
@@ -303,7 +304,11 @@ watch(
|
||||
<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>
|
||||
@@ -315,7 +320,7 @@ watch(
|
||||
>
|
||||
<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 } from "@/components/shop/OrdersItems.vue";
|
||||
import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem, removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -28,7 +28,12 @@ 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();
|
||||
@@ -385,10 +390,15 @@ 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 = (productId) => {
|
||||
|
||||
const showAddMultipleProducts = async (productId) => {
|
||||
const product = findProductById(productId);
|
||||
const restriction = getScopedProductRestriction(product);
|
||||
if (restriction.restricted) {
|
||||
@@ -396,7 +406,7 @@ const showAddMultipleProducts = (productId) => {
|
||||
return;
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
const quantityResult = await Swal.fire({
|
||||
title: 'Tilføj flere produkter',
|
||||
input: 'number',
|
||||
inputAttributes: {
|
||||
@@ -404,7 +414,7 @@ const showAddMultipleProducts = (productId) => {
|
||||
},
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Tilføj',
|
||||
showLoaderOnConfirm: true,
|
||||
showLoaderOnConfirm: false,
|
||||
preConfirm: (inputValue) => {
|
||||
// Check if the input number is higher than 200
|
||||
if (inputValue > 200) {
|
||||
@@ -422,17 +432,32 @@ const showAddMultipleProducts = (productId) => {
|
||||
Swal.showValidationMessage('Order ID is required');
|
||||
return false;
|
||||
}
|
||||
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));
|
||||
return Number(inputValue);
|
||||
}
|
||||
},
|
||||
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";
|
||||
@@ -576,28 +601,62 @@ const addAddonsToOrderMiddleware = async (product_id, quantity = 1, related_item
|
||||
throw new Error("Created parent order item ID is missing");
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
// 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));
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -650,7 +709,7 @@ const addProductWithAddonsToOrder = async (product_id) => {
|
||||
// Check if the product requires a note
|
||||
if (productRequiresOrderItemNote(product)) {
|
||||
// Show the note input
|
||||
await Swal.fire({
|
||||
const noteResult = await Swal.fire({
|
||||
title: 'Tilføj en note',
|
||||
input: 'text',
|
||||
inputLabel: 'Noten kan ses af kunden. F.eks. "Fjernelse af graffiti på venstre side"',
|
||||
@@ -659,35 +718,32 @@ const addProductWithAddonsToOrder = async (product_id) => {
|
||||
},
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Tilføj',
|
||||
showLoaderOnConfirm: true,
|
||||
showLoaderOnConfirm: false,
|
||||
inputValidator: (note) => {
|
||||
if (!String(note || '').trim()) {
|
||||
return 'Note er påkrævet for dette produkt';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
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()
|
||||
preConfirm: (note) => String(note || '').trim(),
|
||||
});
|
||||
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
|
||||
@@ -704,7 +760,12 @@ const addProductWithAddonsToOrder = async (product_id) => {
|
||||
return;
|
||||
}
|
||||
// Create the order item
|
||||
await createOrderItem(orderId, product_id, quantity)
|
||||
const audit = await resolveExtraSaleAudit(product);
|
||||
if (audit === null) {
|
||||
clearPendingOrderItems();
|
||||
return;
|
||||
}
|
||||
await createOrderItem(orderId, product_id, quantity, null, null, null, audit)
|
||||
.then(async (result) => {
|
||||
let order_item_id = result.data.data.id;
|
||||
// Add the addons to the order
|
||||
@@ -781,12 +842,19 @@ 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 ?? "")
|
||||
String(orderItem?.notes ?? ""),
|
||||
null,
|
||||
audit
|
||||
).catch((error) => handleCreateOrderItemError(error));
|
||||
const sourceItemId = getPreviousOrderItemId(orderItem);
|
||||
const createdItemId = Number(result?.data?.data?.id ?? 0);
|
||||
@@ -819,12 +887,19 @@ 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 ?? "")
|
||||
String(orderItem?.notes ?? ""),
|
||||
null,
|
||||
audit
|
||||
).catch((error) => handleCreateOrderItemError(error));
|
||||
}
|
||||
|
||||
@@ -888,7 +963,12 @@ const addRecommendedProductToOrder = async (productId) => {
|
||||
}
|
||||
|
||||
showPendingCreateOrderItem(product, 1, getRecommendedProductPrice(productId));
|
||||
const result = await createOrderItem(orderId, productId, 1).catch((error) => handleCreateOrderItemError(error));
|
||||
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 createdItemId = Number(result?.data?.data?.id ?? 0);
|
||||
if (createdItemId > 0) {
|
||||
await addAddonsToOrderMiddleware(productId, 1, createdItemId, orderId);
|
||||
|
||||
@@ -38,6 +38,9 @@ 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({});
|
||||
|
||||
@@ -170,6 +173,7 @@ 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, {
|
||||
@@ -193,10 +197,16 @@ export function usePaginatedList() {
|
||||
setLastUpdated();
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (!isCanceledRequest(error)) {
|
||||
parseError(error, 'paginatedGetRequest');
|
||||
console.log(error);
|
||||
if (isCanceledRequest(error)) {
|
||||
return null;
|
||||
}
|
||||
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) {
|
||||
@@ -487,6 +497,7 @@ export function usePaginatedList() {
|
||||
isLoading,
|
||||
isExporting,
|
||||
latestSearch,
|
||||
lastError,
|
||||
additionalQueryParameters,
|
||||
exportTransform,
|
||||
setHideSearchField,
|
||||
@@ -542,6 +553,7 @@ 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;
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<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>
|
||||
@@ -1,22 +0,0 @@
|
||||
<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,22 +65,6 @@ 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,7 +23,6 @@ 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";
|
||||
@@ -106,9 +105,6 @@ export const SuperUserObject = {
|
||||
get openai() {
|
||||
return OpenAI;
|
||||
},
|
||||
get minimax() {
|
||||
return MiniMax;
|
||||
},
|
||||
get licenseplaterecognizer() {
|
||||
return LicensePlateRecognizer;
|
||||
},
|
||||
|
||||
@@ -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, 24, 25, 26, 27]);
|
||||
export const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 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,12 +13,34 @@ export const buildAuditedOrderItemReasonPayload = (product_id, notes = null, rea
|
||||
}
|
||||
|
||||
const reason = reasonData && typeof reasonData === "object" ? reasonData : {};
|
||||
const comment = String(reason.reason_comment ?? reason.comment ?? notes ?? "").trim();
|
||||
|
||||
// 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
|
||||
);
|
||||
|
||||
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: comment,
|
||||
reason_label_snapshot: String(
|
||||
reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL
|
||||
),
|
||||
reason_comment: reasonComment,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,7 +56,16 @@ export const getOrderItems = (order_id) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const createOrderItem = (order_id, product_id, quantity, related_item_id = null, notes = null, forcePrice = null, reasonData = null) => {
|
||||
export const createOrderItem = (
|
||||
order_id,
|
||||
product_id,
|
||||
quantity,
|
||||
related_item_id = null,
|
||||
notes = null,
|
||||
forcePrice = null,
|
||||
reasonData = null,
|
||||
extraSaleAudit = {}
|
||||
) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
return null;
|
||||
@@ -50,6 +81,7 @@ export const createOrderItem = (order_id, product_id, quantity, related_item_id
|
||||
related_item_id,
|
||||
notes,
|
||||
...buildAuditedOrderItemReasonPayload(product_id, notes, reasonData),
|
||||
...extraSaleAudit
|
||||
};
|
||||
if (forcePrice !== null && forcePrice !== undefined) {
|
||||
payload.price = forcePrice;
|
||||
@@ -73,7 +105,7 @@ export const removeOrderItem = (id) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const editOrderItem = (id, price, notes, reference, quantity, reasonData = null) => {
|
||||
export const editOrderItem = (id, price, notes, reference, quantity, reasonData = null, extraSaleAudit = {}) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
return null;
|
||||
@@ -85,6 +117,7 @@ export const editOrderItem = (id, price, notes, reference, quantity, reasonData
|
||||
reference,
|
||||
quantity,
|
||||
...(reasonData && typeof reasonData === "object" ? reasonData : {}),
|
||||
...extraSaleAudit
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
|
||||
@@ -10,6 +10,10 @@ 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,
|
||||
@@ -1572,19 +1576,43 @@ const copyLastWashReferenceToEmptyCurrentOrder = async (sourceReference) => {
|
||||
}
|
||||
};
|
||||
|
||||
const createCopiedOrderItem = (targetOrderId, sourceItem, relatedItemId = null) => {
|
||||
const createCopiedOrderItem = async (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)
|
||||
getOrderItemNotes(sourceItem),
|
||||
null,
|
||||
sourceReasonData,
|
||||
audit
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2611,13 +2639,19 @@ 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
|
||||
forcedPrimaryPrice,
|
||||
null,
|
||||
primaryAudit
|
||||
);
|
||||
const relatedPrimaryItemId = toPositiveInteger(primaryItemResponse?.data?.data?.id);
|
||||
|
||||
@@ -2629,6 +2663,10 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
}
|
||||
|
||||
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
|
||||
const secondaryAudit = await promptExtraSaleAuditIfRequired(secondaryProduct || { id: secondaryProductId });
|
||||
if (secondaryAudit === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await createOrderItem(
|
||||
normalizedOrderId,
|
||||
@@ -2636,7 +2674,9 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
|
||||
relatedPrimaryItemId,
|
||||
String(bookingItem?.notes ?? "").trim() || null,
|
||||
secondaryProduct?.price ?? null
|
||||
secondaryProduct?.price ?? null,
|
||||
null,
|
||||
secondaryAudit
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -2143,6 +2143,7 @@
|
||||
"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'}",
|
||||
@@ -4238,6 +4239,7 @@
|
||||
"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'}"
|
||||
@@ -4678,7 +4680,7 @@
|
||||
"workflow": "Arbejdsgang"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Accepter forslag",
|
||||
"attach_order": "Tilknyt ordre",
|
||||
@@ -4874,7 +4876,8 @@
|
||||
"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."
|
||||
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes.",
|
||||
"api_endpoint_not_implemented": "API-endepunkt ikke implementeret endnu"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5445,6 +5448,7 @@
|
||||
},
|
||||
"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'}",
|
||||
@@ -5913,6 +5917,9 @@
|
||||
"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'}",
|
||||
|
||||
@@ -2260,6 +2260,7 @@
|
||||
"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": {
|
||||
@@ -4340,7 +4341,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'} @:{'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'}."
|
||||
"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."
|
||||
},
|
||||
"preview": {
|
||||
"entities": {
|
||||
@@ -4348,6 +4349,7 @@
|
||||
"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'}"
|
||||
@@ -4788,7 +4790,7 @@
|
||||
"workflow": "Arbeitsablauf"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Vorschlag annehmen",
|
||||
"attach_order": "Auftrag verknüpfen",
|
||||
@@ -4984,7 +4986,8 @@
|
||||
"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."
|
||||
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden.",
|
||||
"api_endpoint_not_implemented": "API-Endpunkt ist noch nicht implementiert"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5555,6 +5558,7 @@
|
||||
},
|
||||
"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'}",
|
||||
@@ -6023,6 +6027,9 @@
|
||||
"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'}",
|
||||
|
||||
@@ -1974,6 +1974,7 @@
|
||||
"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'}",
|
||||
@@ -4069,6 +4070,7 @@
|
||||
"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'}"
|
||||
@@ -4509,7 +4511,7 @@
|
||||
"workflow": "Workflow"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Accept suggestion",
|
||||
"attach_order": "Attach order",
|
||||
@@ -4705,7 +4707,8 @@
|
||||
"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."
|
||||
"load_usage_log_failed": "The usage log could not be loaded.",
|
||||
"api_endpoint_not_implemented": "API endpoint not implemented yet"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5276,6 +5279,7 @@
|
||||
},
|
||||
"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'}",
|
||||
@@ -5744,6 +5748,9 @@
|
||||
"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'}",
|
||||
|
||||
@@ -786,6 +786,7 @@
|
||||
"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'}",
|
||||
@@ -1658,6 +1659,7 @@
|
||||
"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",
|
||||
@@ -3420,6 +3422,7 @@
|
||||
"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",
|
||||
@@ -3895,203 +3898,204 @@
|
||||
"workflow": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.workflow'}"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"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'}"
|
||||
"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'}"
|
||||
},
|
||||
"adjudication": {
|
||||
"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'}",
|
||||
"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'}",
|
||||
"outcomes": {
|
||||
"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'}"
|
||||
"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'}"
|
||||
},
|
||||
"saved": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.saved'}",
|
||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.title'}"
|
||||
"saved": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.saved'}",
|
||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.title'}"
|
||||
},
|
||||
"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'}",
|
||||
"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'}",
|
||||
"controls": {
|
||||
"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'}"
|
||||
"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'}"
|
||||
},
|
||||
"clear_selection": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.clear_selection'}",
|
||||
"evidence": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.evidence'}",
|
||||
"clear_selection": "@:{'templates.generated.compat.invoicing_period.xlvask_review.clear_selection'}",
|
||||
"evidence": "@:{'templates.generated.compat.invoicing_period.xlvask_review.evidence'}",
|
||||
"filters": {
|
||||
"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'}"
|
||||
"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'}"
|
||||
},
|
||||
"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'}",
|
||||
"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'}",
|
||||
"preview": {
|
||||
"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'}"
|
||||
"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'}"
|
||||
},
|
||||
"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'}",
|
||||
"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'}",
|
||||
"run_phases": {
|
||||
"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'}"
|
||||
"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'}"
|
||||
},
|
||||
"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'}",
|
||||
"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'}",
|
||||
"states": {
|
||||
"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'}"
|
||||
"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'}"
|
||||
},
|
||||
"summary_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.summary_error'}",
|
||||
"summary_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.summary_title'}",
|
||||
"summary_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.summary_error'}",
|
||||
"summary_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.summary_title'}",
|
||||
"labels": {
|
||||
"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'}"
|
||||
"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'}"
|
||||
},
|
||||
"time": {
|
||||
"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'}",
|
||||
"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'}",
|
||||
"units": {
|
||||
"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'}"
|
||||
"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'}"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"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'}"
|
||||
"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'}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4848,6 +4852,8 @@
|
||||
"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'}",
|
||||
|
||||
@@ -2263,6 +2263,7 @@
|
||||
"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": {
|
||||
@@ -4343,7 +4344,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'} @:{'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'}."
|
||||
"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'}."
|
||||
},
|
||||
"preview": {
|
||||
"entities": {
|
||||
@@ -4351,6 +4352,7 @@
|
||||
"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'}"
|
||||
@@ -4791,7 +4793,7 @@
|
||||
"workflow": "Arbeidsflyt"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Godta forslag",
|
||||
"attach_order": "Knytt til ordre",
|
||||
@@ -4987,7 +4989,8 @@
|
||||
"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."
|
||||
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes.",
|
||||
"api_endpoint_not_implemented": "API-endepunkt er ikke implementert ennå"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5558,6 +5561,7 @@
|
||||
},
|
||||
"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'}",
|
||||
@@ -6026,6 +6030,9 @@
|
||||
"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'}",
|
||||
|
||||
@@ -2313,6 +2313,7 @@
|
||||
"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": {
|
||||
@@ -4393,7 +4394,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'} @:{'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'}."
|
||||
"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."
|
||||
},
|
||||
"preview": {
|
||||
"entities": {
|
||||
@@ -4401,6 +4402,7 @@
|
||||
"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'}"
|
||||
@@ -4841,7 +4843,7 @@
|
||||
"workflow": "Arbetsflöde"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Godkänn förslag",
|
||||
"attach_order": "Koppla order",
|
||||
@@ -5037,7 +5039,8 @@
|
||||
"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."
|
||||
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas.",
|
||||
"api_endpoint_not_implemented": "API-slutpunkt är inte implementerad ännu"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5608,6 +5611,7 @@
|
||||
},
|
||||
"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'}",
|
||||
@@ -6076,6 +6080,9 @@
|
||||
"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,6 +5,7 @@
|
||||
"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,6 +34,7 @@
|
||||
"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_autopilot": {
|
||||
"compat": { "invoicing_period": { "xlvask_review": {
|
||||
"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,7 +112,8 @@
|
||||
"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."
|
||||
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes.",
|
||||
"api_endpoint_not_implemented": "API-endepunkt ikke implementeret endnu"
|
||||
}
|
||||
} } }
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"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,6 +24,9 @@
|
||||
"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,6 +12,7 @@
|
||||
"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'} @:{'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'}."
|
||||
"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."
|
||||
},
|
||||
"preview": {
|
||||
"entities": {
|
||||
@@ -34,6 +34,7 @@
|
||||
"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'}"
|
||||
@@ -54,4 +55,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Vorschlag annehmen",
|
||||
"attach_order": "Auftrag verknüpfen",
|
||||
@@ -191,7 +191,8 @@
|
||||
"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."
|
||||
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden.",
|
||||
"api_endpoint_not_implemented": "API-Endpunkt ist noch nicht implementiert"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"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,6 +24,9 @@
|
||||
"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,6 +5,7 @@
|
||||
"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,6 +34,7 @@
|
||||
"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'}"
|
||||
@@ -54,4 +55,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"compat": { "invoicing_period": { "xlvask_autopilot": {
|
||||
"compat": { "invoicing_period": { "xlvask_review": {
|
||||
"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,7 +112,8 @@
|
||||
"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."
|
||||
"load_usage_log_failed": "The usage log could not be loaded.",
|
||||
"api_endpoint_not_implemented": "API endpoint not implemented yet"
|
||||
}
|
||||
} } }
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"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,6 +24,9 @@
|
||||
"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,6 +5,7 @@
|
||||
"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,6 +403,7 @@
|
||||
"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,6 +36,7 @@
|
||||
"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",
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
{
|
||||
"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'}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"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,6 +76,8 @@
|
||||
"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,6 +12,7 @@
|
||||
"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'} @:{'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'}."
|
||||
"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'}."
|
||||
},
|
||||
"preview": {
|
||||
"entities": {
|
||||
@@ -34,6 +34,7 @@
|
||||
"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'}"
|
||||
@@ -54,4 +55,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||