Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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"
|
||||
|
||||
+69
-53
@@ -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.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
+49
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -45,7 +45,7 @@ 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,
|
||||
|
||||
+1
-1
@@ -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;
|
||||
});
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Executable
+100
@@ -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
|
||||
Executable
+80
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
+157
-745
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -47,9 +47,35 @@ const emit = defineEmits(["flagStatusChanged"]);
|
||||
|
||||
const orderItems = ref([]);
|
||||
|
||||
// Stable ordering for the order items table: primary items (related_item_id === 0
|
||||
// / null) first, then addons grouped by their parent, in insertion order. The
|
||||
// backend now also orders the SELECT (api PR), but we sort defensively here so a
|
||||
// stale cache or older API proxy cannot regress the render order (which previously
|
||||
// made it look like only Trailer/Dolly were attached to a Trækker order because
|
||||
// Spot-free and Undervognsskyld were listed above the primary and visually buried).
|
||||
const sortOrderItemsForDisplay = (items) => {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
return items.slice().sort((left, right) => {
|
||||
const leftRelatedId = Number(left?.related_item_id ?? 0);
|
||||
const rightRelatedId = Number(right?.related_item_id ?? 0);
|
||||
// Primary items (related_item_id 0 / null) come first.
|
||||
if ((leftRelatedId === 0) !== (rightRelatedId === 0)) {
|
||||
return leftRelatedId === 0 ? -1 : 1;
|
||||
}
|
||||
// Within addons, group by parent.
|
||||
if (leftRelatedId !== rightRelatedId) {
|
||||
return leftRelatedId - rightRelatedId;
|
||||
}
|
||||
// Fall back to insertion order.
|
||||
return Number(left?.id ?? 0) - Number(right?.id ?? 0);
|
||||
});
|
||||
};
|
||||
|
||||
const loadOrderItems = async () => {
|
||||
if (props.useLocalOrderItems) {
|
||||
orderItems.value = props.localOrderItems;
|
||||
orderItems.value = sortOrderItemsForDisplay(props.localOrderItems);
|
||||
return;
|
||||
}
|
||||
await SessionUser.request(
|
||||
@@ -59,7 +85,7 @@ const loadOrderItems = async () => {
|
||||
order_id: props.orderId,
|
||||
}
|
||||
).then((response) => {
|
||||
orderItems.value = response.data.data;
|
||||
orderItems.value = sortOrderItemsForDisplay(response.data.data);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5913,6 +5916,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6023,6 +6026,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -5744,6 +5747,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'}",
|
||||
|
||||
+178
-174
@@ -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'}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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å"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6026,6 +6029,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6076,6 +6079,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'}"
|
||||
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
} } }
|
||||
}
|
||||
@@ -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'}"
|
||||
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'}"
|
||||
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
} } }
|
||||
}
|
||||
@@ -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'}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'}"
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Godta forslag",
|
||||
"attach_order": "Knytt til ordre",
|
||||
@@ -191,7 +191,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å"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@
|
||||
"open_property_exit_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} utgangsport",
|
||||
"perform_wash": "Utfør @:{'terms.glossary.vask'}",
|
||||
"questions_answered": "@.capitalize:{'terms.glossary.spørsmal'} @:{'terms.glossary.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:{'terms.glossary.velg'} {plate} @:{'terms.glossary.fra'} @:{'terms.glossary.kjøretøyene'} @:{'terms.glossary.dine'}",
|
||||
"select_vehicle_type": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøytype'}",
|
||||
"select_vehicle": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøy'}",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"username": "@.capitalize:{'terms.glossary.anvandarnamn'}",
|
||||
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} @:{'terms.glossary.match'} @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
|
||||
"wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.tvattcertifikat_2'} ar bifogat @:{'terms.glossary.utan'} @:{'terms.replication.host_definite_suffix'} tvattcertifikatrad.",
|
||||
"wash_certificate_item_without_certificate": "@:{'terms.glossary.tvattcertifikatraden'} @:{'terms.glossary.finns'} @:{'terms.glossary.utan'} @:{'terms.glossary.ett'} @:{'terms.glossary.tvattcertifikat_2'}.",
|
||||
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor @:{'terms.glossary.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.ar'} varken ignorerad @:{'terms.glossary.eller'} kopplad @:{'terms.glossary.till'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.order'} @:{'terms.glossary.i'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valda'} perioden."
|
||||
},
|
||||
"preview": {
|
||||
"entities": {
|
||||
@@ -34,6 +34,7 @@
|
||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.uppgifter'}"
|
||||
},
|
||||
"expected_price": "@.capitalize:{'terms.glossary.forvantat'} @:{'terms.glossary.pris'}",
|
||||
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
|
||||
"start_time": "Starttid",
|
||||
"wash_id": "@.capitalize:{'terms.glossary.tvatt_2'}-@.upper:{'terms.glossary.id'}",
|
||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Godkänn förslag",
|
||||
"attach_order": "Koppla order",
|
||||
@@ -191,7 +191,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@
|
||||
"open_property_exit_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} utgangsgrind",
|
||||
"perform_wash": "Perform @:{'terms.glossary.wash'}",
|
||||
"questions_answered": "@.capitalize:{'terms.glossary.fragor'} @:{'terms.glossary.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:{'terms.glossary.valj'} {plate} @:{'terms.glossary.fran'} @:{'terms.glossary.dina'} @:{'terms.glossary.fordon'}",
|
||||
"select_vehicle_type": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordonstyp'}",
|
||||
"select_vehicle": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordon'}",
|
||||
|
||||
@@ -31,6 +31,23 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
|
||||
<LoginForm />
|
||||
</ConnectivityIssue>
|
||||
<p>{{ $t('auth.terms_acceptance') }}</p>
|
||||
|
||||
<!-- Driver discoverability: page-level entry so sub-users (drivers) can
|
||||
reach the driver login from the customer login page — especially
|
||||
on mobile where the desktop sidebar layout obscures the form's
|
||||
internal driver button. -->
|
||||
<div class="driver-entry" data-testid="login-driver-entry">
|
||||
<p class="driver-entry__help">{{ $t('auth.driver_login_help') }}</p>
|
||||
<router-link
|
||||
:to="{ name: 'subuserlogin' }"
|
||||
class="submit driver-login-link"
|
||||
id="login-driver-login-button"
|
||||
data-testid="login-driver-login-link"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-truck"></i></span>
|
||||
<span>{{ $t('auth.driver_login_button') }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -158,6 +175,55 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Driver entry block — page-level, always visible on every viewport.
|
||||
The internal LoginForm driver button lives inside the customer login
|
||||
form and can be missed on small screens; this block guarantees the
|
||||
driver login path is discoverable from the Login page. */
|
||||
.login-section .wrapper .main .driver-entry {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
padding: 16px 20px;
|
||||
background-color: #F2F8FC;
|
||||
border: 1px solid #BFE0EF;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-section .wrapper .main .driver-entry__help {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #13324C;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.login-section .wrapper .main .driver-entry .driver-login-link {
|
||||
background-color: #1584BC;
|
||||
color: #fff;
|
||||
border: 0;
|
||||
border-radius: 30px;
|
||||
min-height: 43px;
|
||||
font-size: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
margin: 0;
|
||||
width: auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.login-section .wrapper .main .driver-entry .driver-login-link:hover {
|
||||
background-color: #1a99d6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.login-section .wrapper .sidebar h1{
|
||||
font-size: 2.3em;
|
||||
@@ -187,5 +253,20 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
|
||||
padding: 70px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile (<768px): ensure the driver entry block is always visible and
|
||||
takes full width so it's impossible to miss on phones. */
|
||||
@media (max-width: 767px) {
|
||||
.login-section .wrapper .main .driver-entry {
|
||||
margin-top: 20px;
|
||||
padding: 14px 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-section .wrapper .main .driver-entry .driver-login-link {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
}
|
||||
/*end login-section*/
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,7 @@ import XLVaskUsagePagination from "@/components/displays/pagination/models/Depar
|
||||
<template #default>
|
||||
<DepartmentDashboardHero />
|
||||
<!-- Syncronize Orders -->
|
||||
<XLVaskUsagePagination/>
|
||||
<XLVaskUsagePagination :department-id="SessionUser.functions.getDepartmentIdFromUrl()"/>
|
||||
</template>
|
||||
</NotFoundFallBackPageWrapper>
|
||||
</DepartmentDashboardPageWrapper>
|
||||
|
||||
+5
-1
@@ -15,7 +15,11 @@ const FALLBACK_PRODUCT_TITLES = {
|
||||
24: "Spot Free (Lastbil)",
|
||||
25: "Fælg flex pr. enhed",
|
||||
27: "Ekstraordinær pr. 10 min inkl. kemi",
|
||||
26: "Højglans - Voksforsegling pr. enhed",
|
||||
// TRU-130: customer experience series reported that the previous wax product
|
||||
// ("Højglans - Voksforsegling") tested poorly and is being swapped for a
|
||||
// different product. Drop the brand-specific prefix so the tile stays
|
||||
// accurate while the operations team iterates on the next wax supplier.
|
||||
26: "Voksforsegling pr. enhed",
|
||||
21: "Undervognsskyl pr. enhed",
|
||||
22: "Tillæg for Specialsæbe - DD",
|
||||
};
|
||||
|
||||
+18
-3
@@ -240,9 +240,10 @@ const xlvaskUsageLogHtml = (flag: any) => {
|
||||
].filter(([, value]) => String(value ?? "").trim() !== "");
|
||||
|
||||
if (rows.length === 0) {
|
||||
return escapeHtml(translate("invoice_period.flags.preview.no_xlvask_usage_log", "No XL Vask details available.", {
|
||||
entity: translate("invoice_period.flags.preview.entities.xlvask_usage_log", "XL Vask details"),
|
||||
}));
|
||||
return escapeHtml(translate(
|
||||
"invoice_period.flags.preview.no_xlvask_usage_log_metadata",
|
||||
"XL Vask registration (no metadata available — see api flag-context serializer)..."
|
||||
));
|
||||
}
|
||||
|
||||
return `<table class="table is-narrow is-fullwidth">
|
||||
@@ -285,8 +286,18 @@ const openOrderItem = (flag: any) => {
|
||||
SessionUser.functions.redirectTo.department(departmentId, `modules/pos/orders/${orderId}${suffix}`, true);
|
||||
};
|
||||
|
||||
const extractDateOnly = (value: any) => {
|
||||
const rawValue = String(value ?? "").trim();
|
||||
if (rawValue === "") {
|
||||
return "";
|
||||
}
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(rawValue);
|
||||
return match ? `${match[1]}-${match[2]}-${match[3]}` : "";
|
||||
};
|
||||
|
||||
const openXlVaskUsageLog = (flag: any) => {
|
||||
const usageLogId = Number(flag?.xlvask_usage_log_id || flag?.context?.xlvask_usage_log_id || flag?.target_id || 0);
|
||||
const startTime = extractDateOnly(flag?.context?.start_time || flag?.start_time);
|
||||
const query = new URLSearchParams({
|
||||
activeTab: "period",
|
||||
periodView: "self_wash",
|
||||
@@ -296,6 +307,10 @@ const openXlVaskUsageLog = (flag: any) => {
|
||||
query.set("xlvaskUsageLogId", String(usageLogId));
|
||||
}
|
||||
|
||||
if (startTime !== "") {
|
||||
query.set("xlvaskUsageLogStartTime", startTime);
|
||||
}
|
||||
|
||||
SessionUser.functions.redirectTo.superUser(`/invoices?${query.toString()}`, true);
|
||||
};
|
||||
|
||||
|
||||
+2
-5
@@ -2710,14 +2710,11 @@ const decideXlvaskAutomation = (
|
||||
: treeText("actions.xlvask.deny.preview", "{count} XL Vask forslag afvises.", { count: rows.length }),
|
||||
],
|
||||
async () => {
|
||||
const endpointSuffix = decision === "deny" ? "reject" : decision;
|
||||
for (const node of rows) {
|
||||
await SessionUser.request(
|
||||
`/modules/xlvask/services/usage/orders/${node.meta.usageId}/automation/${decision}`,
|
||||
`/modules/xlvask/services/usage/orders/${node.meta.usageId}/${endpointSuffix}`,
|
||||
"POST",
|
||||
{
|
||||
suggestion_id: node.meta.automation?.id ?? null,
|
||||
reason: `${decision} from invoice period tree`,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
+33
-5
@@ -51,12 +51,40 @@ const ATTRIBUTE_DISPLAY_PRIORITY: Record<string, number> = {
|
||||
};
|
||||
const UNKNOWN_ATTRIBUTE_PRIORITY = 99;
|
||||
|
||||
const list_views_with_customer = computed(() => {
|
||||
const matched = view_keys.value.filter((view_key) => {
|
||||
// Skip if the view type is "all".
|
||||
if (view_key === 'all') return false;
|
||||
// Pre-compute a Set<customer_number> per view bucket so chip membership
|
||||
// resolution stays O(1) regardless of bucket size. The backend now ships
|
||||
// lightweight `{customer_number, membership_only}` markers for every
|
||||
// non-active bucket, so this lookup also drives the category chips shown
|
||||
// on the Alle tab.
|
||||
const membershipIndexes = computed<Record<string, Set<number>>>(() => {
|
||||
const result: Record<string, Set<number>> = {};
|
||||
for (const view_key of view_keys.value) {
|
||||
if (view_key === 'all') {
|
||||
result[view_key] = new Set<number>();
|
||||
continue;
|
||||
}
|
||||
const view_type = sharedTypes.value[view_key];
|
||||
return view_type && view_type.some((v: any) => v.customer_number === props.customer.customer_number);
|
||||
const set = new Set<number>();
|
||||
if (Array.isArray(view_type)) {
|
||||
for (const entry of view_type) {
|
||||
const raw = (entry as { customer_number?: number | string } | null)
|
||||
?.customer_number;
|
||||
const number = Number(raw);
|
||||
if (Number.isInteger(number) && number > 0) {
|
||||
set.add(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
result[view_key] = set;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const list_views_with_customer = computed(() => {
|
||||
const customerNumber = Number(props.customer.customer_number);
|
||||
const matched = view_keys.value.filter((view_key) => {
|
||||
if (view_key === 'all') return false;
|
||||
return membershipIndexes.value[view_key]?.has(customerNumber) === true;
|
||||
});
|
||||
return [...matched].sort((left, right) => {
|
||||
const leftPriority = ATTRIBUTE_DISPLAY_PRIORITY[left] ?? UNKNOWN_ATTRIBUTE_PRIORITY;
|
||||
|
||||
+16
@@ -35,7 +35,11 @@ const getResponsePayload = (response: any = {}) => {
|
||||
* Fixed pricing distribution data structure:
|
||||
*/
|
||||
const fixed_pricing_department_distribution = ref<any[]>([]);
|
||||
const fixedPricingDistributionLoaded = ref(false);
|
||||
const fixedPricingDistributionFailed = ref(false);
|
||||
const fetchFixedPricingDistribution = async () => {
|
||||
fixedPricingDistributionLoaded.value = false;
|
||||
fixedPricingDistributionFailed.value = false;
|
||||
SessionUser.request(
|
||||
'/superuser/invoicing/period/distribution/fixed-pricing',
|
||||
'GET',
|
||||
@@ -47,15 +51,20 @@ const fetchFixedPricingDistribution = async () => {
|
||||
|
||||
if (response.data && response.data.includes.collective_fixed_pricing_results) {
|
||||
fixed_pricing_department_distribution.value = response.data.includes.collective_fixed_pricing_results;
|
||||
fixedPricingDistributionLoaded.value = true;
|
||||
fixedPricingDistributionFailed.value = false;
|
||||
/**
|
||||
* Fixed pricing distribution data structure:
|
||||
* { "total_fixed_price": 137962, "total_original_price": 72799, "total_department_totals": { "1": 11826, "2": 22687, "3": 17009, "4": 12457, "5": 1161, "6": 6611, "7": 1048 }, "total_department_totals_relative": { "1": 30962.876996916584, "2": 28790.813863868243, "3": 32997.60337384933, "4": 15442.430865260254, "5": 1407.808993176734, "6": 9521.78041528052, "7": 1010.6854916483431 }, "total_department_totals_parsed": { "Glostrup": 17009, "Taastrup": 22687, "Hvidovre": 11826, "Roskilde": 6611, "Køge": 12457, "Aarhus C": 1161, "Taulov": 1048 }, "total_department_totals_relative_parsed": { "Glostrup": 32997.60337384933, "Taastrup": 28790.813863868243, "Hvidovre": 30962.876996916584, "Roskilde": 9521.78041528052, "Køge": 15442.430865260254, "Aarhus C": 1407.808993176734, "Taulov": 1010.6854916483431 } }
|
||||
*/
|
||||
} else {
|
||||
console.error('Unexpected response structure:', response);
|
||||
fixedPricingDistributionFailed.value = true;
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Error fetching fixed pricing distribution:', error);
|
||||
fixedPricingDistributionLoaded.value = true;
|
||||
fixedPricingDistributionFailed.value = true;
|
||||
});
|
||||
}
|
||||
const clearFixedPricingDistribution = () => {
|
||||
@@ -134,7 +143,11 @@ watch(
|
||||
* Subscription display logic:
|
||||
*/
|
||||
const vehicleSubscriptionDistribution = ref<any>(null);
|
||||
const vehicleSubscriptionDistributionLoaded = ref(false);
|
||||
const vehicleSubscriptionDistributionFailed = ref(false);
|
||||
const fetchVehicleSubscriptionDistribution = async () => {
|
||||
vehicleSubscriptionDistributionLoaded.value = false;
|
||||
vehicleSubscriptionDistributionFailed.value = false;
|
||||
SessionUser.request(
|
||||
'/superuser/invoicing/period/distribution/wash-subscriptions',
|
||||
'GET',
|
||||
@@ -150,9 +163,12 @@ const fetchVehicleSubscriptionDistribution = async () => {
|
||||
vehicleSubscriptionDistribution.value = response.data.includes.collective_subscription_results;
|
||||
} else {
|
||||
console.error('Unexpected response structure:', response);
|
||||
vehicleSubscriptionDistributionFailed.value = true;
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Error fetching vehicle subscription distribution:', error);
|
||||
vehicleSubscriptionDistributionLoaded.value = true;
|
||||
vehicleSubscriptionDistributionFailed.value = true;
|
||||
});
|
||||
}
|
||||
const clearVehicleSubscriptionDistribution = () => {
|
||||
|
||||
-1
@@ -467,7 +467,6 @@ export const makeXlvaskNode = (usage, order = null, options = {}) => {
|
||||
usageId,
|
||||
washId,
|
||||
orderId: toPositiveInteger(order?.id ?? usage?.linked_order_id ?? usage?.order_id),
|
||||
automation: usage?.automation,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+76
-3
@@ -5,12 +5,86 @@ import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
/**
|
||||
* Number of days to widen the date window on either side of a flagged
|
||||
* wash's `start_time` when navigating to the Selvvask view from a
|
||||
* flag-link in the invoice period review.
|
||||
*
|
||||
* The single-day window shipped in #304 was sufficient when the wash
|
||||
* always landed on the same calendar day, but real-world data shows
|
||||
* washes that "span midnight" (e.g. a wash at 23:50 visible at 00:10)
|
||||
* would fall outside the visible range. Widening to ±7 days makes
|
||||
* the highlighted wash always visible regardless of which side of
|
||||
* midnight it lands on.
|
||||
*/
|
||||
const FLAGGED_WASH_DATE_WINDOW_DAYS = 7;
|
||||
|
||||
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
const parseRouteDateOnly = (value: any) => {
|
||||
const rawValue = Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");
|
||||
const match = DATE_ONLY_PATTERN.exec(rawValue.trim());
|
||||
if (!match) {
|
||||
return "";
|
||||
}
|
||||
return `${match[1]}-${match[2]}-${match[3]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pad an ISO-8601 date string (`YYYY-MM-DD`) by `±days` and return a
|
||||
* new `YYYY-MM-DD` string. Invalid inputs return "".
|
||||
*/
|
||||
const shiftDateOnly = (dateOnly: string, days: number): string => {
|
||||
if (dateOnly === "") {
|
||||
return "";
|
||||
}
|
||||
const match = DATE_ONLY_PATTERN.exec(dateOnly);
|
||||
if (!match) {
|
||||
return "";
|
||||
}
|
||||
const utc = Date.UTC(
|
||||
Number.parseInt(match[1], 10),
|
||||
Number.parseInt(match[2], 10) - 1,
|
||||
Number.parseInt(match[3], 10),
|
||||
);
|
||||
const shifted = new Date(utc + days * 24 * 60 * 60 * 1000);
|
||||
if (Number.isNaN(shifted.getTime())) {
|
||||
return "";
|
||||
}
|
||||
const yyyy = shifted.getUTCFullYear();
|
||||
const mm = String(shifted.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(shifted.getUTCDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const highlightedUsageLogId = computed(() => {
|
||||
const parsedId = Number.parseInt(String(route.query.xlvaskUsageLogId || ""), 10);
|
||||
return Number.isInteger(parsedId) && parsedId > 0 ? parsedId : 0;
|
||||
});
|
||||
|
||||
const flaggedWashStartDate = computed(() => parseRouteDateOnly(route.query.xlvaskUsageLogStartTime));
|
||||
|
||||
/**
|
||||
* Date window for the Selvvask view:
|
||||
* - If a flagged wash start time is in the route, open a ±7d window
|
||||
* around it so the highlighted wash is always visible.
|
||||
* - Otherwise fall back to the current period date range.
|
||||
*/
|
||||
const initialDateFrom = computed(() => {
|
||||
if (flaggedWashStartDate.value) {
|
||||
return shiftDateOnly(flaggedWashStartDate.value, -FLAGGED_WASH_DATE_WINDOW_DAYS);
|
||||
}
|
||||
return dates.computed.formattedStartDate.value;
|
||||
});
|
||||
|
||||
const initialDateTo = computed(() => {
|
||||
if (flaggedWashStartDate.value) {
|
||||
return shiftDateOnly(flaggedWashStartDate.value, FLAGGED_WASH_DATE_WINDOW_DAYS);
|
||||
}
|
||||
return dates.computed.formattedEndDate.value;
|
||||
});
|
||||
|
||||
const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
|
||||
</script>
|
||||
|
||||
@@ -18,12 +92,11 @@ const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
|
||||
<section data-testid="invoicing-period-self-wash-view">
|
||||
<XLVaskUsagePagination
|
||||
:title="selfWashTitle"
|
||||
:initial-date-from="dates.computed.formattedStartDate.value"
|
||||
:initial-date-to="dates.computed.formattedEndDate.value"
|
||||
:initial-date-from="initialDateFrom"
|
||||
:initial-date-to="initialDateTo"
|
||||
:inherit-period-filters="true"
|
||||
:load-all-at-once="false"
|
||||
:highlight-usage-log-id="highlightedUsageLogId"
|
||||
:automation-workspace="true"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { view } from '../imports/InvoicingBillingPeriodImportView.vue';
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import {ref} from "vue";
|
||||
import "@/components/displays/superuser/tables/customersTable.vue";
|
||||
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
|
||||
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
|
||||
|
||||
const customersWithSubscriptions = ref<Array<{
|
||||
id: number;
|
||||
customer_name: string;
|
||||
customer_number?: number;
|
||||
display_name?: string;
|
||||
group_id?: number;
|
||||
phone?: {
|
||||
country_code: string | null;
|
||||
number: string | null;
|
||||
};
|
||||
email?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
|
||||
}>[]>([]);
|
||||
|
||||
const onLoad = () => {
|
||||
// Send a test request
|
||||
SessionUser.request(
|
||||
'/superuser/users-with-vehicle-subscriptions',
|
||||
'GET',
|
||||
{}
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
customersWithSubscriptions.value = response.data.data;
|
||||
} else {
|
||||
console.error('Failed to fetch vehicles with subscriptions:', response);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching vehicles with subscriptions:', error);
|
||||
});
|
||||
};
|
||||
|
||||
onLoad();
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--{{ view.variables.sharedVariables.value }}-->
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<template v-for="customer in view.variables.sharedVariables.value.types.vehicle_subscriptions" :key="customer.customer_number">
|
||||
<div class="column is-12">
|
||||
<WhiteBox class="is-clickable">
|
||||
<!--{{ customer }}-->
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column">
|
||||
<ColorIndicator
|
||||
v-bind:color_class="(customer.transactions.length > 0) ? 'has-text-success' : 'has-text-grey'"
|
||||
v-bind:label="{
|
||||
text: customer.customer_name,
|
||||
classes: [],
|
||||
}"
|
||||
v-bind:visibility="{
|
||||
icon: true,
|
||||
dropdown: false,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<p>{{customer.transactions.length}} transactions</p>
|
||||
</div>
|
||||
</div>
|
||||
</WhiteBox>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p>
|
||||
VA customers with no transactions
|
||||
|
||||
</p>
|
||||
<button class="button is-primary" @click="view.functions.setCurrentView('home')">
|
||||
Go to Home
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -3,107 +3,64 @@ import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import ConfigurationSubPageWrapper
|
||||
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
||||
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
||||
import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
|
||||
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
|
||||
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useModuleConfig } from "@/composables/useModuleConfig.js";
|
||||
const { t } = useI18n();
|
||||
|
||||
// Get the department from the route
|
||||
|
||||
|
||||
|
||||
const module_config = ref([]);
|
||||
|
||||
|
||||
|
||||
const getModuleConfig = async () => {
|
||||
await SessionUser.superUser.modules.email.config.get_all().then((response) => {
|
||||
let tmp_module_config = response.data.data;
|
||||
let tmp_module_config_array = [];
|
||||
for (const value of Object.values(tmp_module_config)) {
|
||||
console.log(`${value.variable}: ${value.value}`);
|
||||
tmp_module_config_array.push({
|
||||
variable: value.variable,
|
||||
value: value.value,
|
||||
});
|
||||
}
|
||||
module_config.value = tmp_module_config_array;
|
||||
console.log(module_config.value);
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const getModuleConfigValue = (variable) => {
|
||||
const config = module_config.value.find((config) => config.variable === variable);
|
||||
// Log to the console, what the value was found
|
||||
if (config) {
|
||||
console.log(`Found value for ${variable}: ${config.value}`);
|
||||
} else {
|
||||
console.log(`No value found for ${variable}`);
|
||||
}
|
||||
return config ? config.value : '';
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await getModuleConfig();
|
||||
};
|
||||
const { module_config, getModuleConfigValue } = useModuleConfig("email");
|
||||
|
||||
const encryptionOptions = [
|
||||
{ value: 'ssl', label: 'SSL' },
|
||||
{ value: 'tls', label: 'TLS' },
|
||||
{ value: 'starttls', label: 'STARTTLS' },
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: "ssl", label: "SSL" },
|
||||
{ value: "tls", label: "TLS" },
|
||||
{ value: "starttls", label: "STARTTLS" },
|
||||
{ value: "none", label: "None" },
|
||||
];
|
||||
|
||||
const showSendTestEmail = async () => {
|
||||
await Swal.fire({
|
||||
title: t('configuration.email.send_test_email'),
|
||||
text: t('configuration.email.send_test_email_prompt'),
|
||||
input: 'email',
|
||||
title: t("configuration.email.send_test_email"),
|
||||
text: t("configuration.email.send_test_email_prompt"),
|
||||
input: "email",
|
||||
inputAttributes: {
|
||||
autocapitalize: 'off'
|
||||
autocapitalize: "off",
|
||||
},
|
||||
showCancelButton: true,
|
||||
confirmButtonText: t('configuration.email.send'),
|
||||
confirmButtonText: t("configuration.email.send"),
|
||||
showLoaderOnConfirm: true,
|
||||
preConfirm: (email) => {
|
||||
return SessionUser.superUser.modules.email.sendTestEmail(email)
|
||||
.then(() => {
|
||||
Swal.fire({
|
||||
title: t('configuration.email.test_email_sent'),
|
||||
text: t('configuration.email.test_email_sent_success'),
|
||||
icon: 'success',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
Swal.fire({
|
||||
title: t('common.error'),
|
||||
text: t('configuration.email.test_email_error'),
|
||||
icon: 'error',
|
||||
});
|
||||
return SessionUser.superUser.modules.email
|
||||
.sendTestEmail(email)
|
||||
.then(() => {
|
||||
Swal.fire({
|
||||
title: t("configuration.email.test_email_sent"),
|
||||
text: t("configuration.email.test_email_sent_success"),
|
||||
icon: "success",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
Swal.fire({
|
||||
title: t("common.error"),
|
||||
text: t("configuration.email.test_email_error"),
|
||||
icon: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
allowOutsideClick: () => !Swal.isLoading()
|
||||
allowOutsideClick: () => !Swal.isLoading(),
|
||||
});
|
||||
};
|
||||
|
||||
load();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<ConfigurationSubPageWrapper>
|
||||
<template #title>
|
||||
<PageTitle :title="$t('configuration.email.title')" :subtitle="$t('configuration.email.subtitle')"/>
|
||||
<PageTitle :title="$t('configuration.email.title')" :subtitle="$t('configuration.email.subtitle')" />
|
||||
</template>
|
||||
<template v-if="module_config.length > 0" #content>
|
||||
<ConfigurationCategory
|
||||
@@ -113,16 +70,18 @@ load();
|
||||
:description="$t('configuration.email.general_settings_desc')"
|
||||
icon="fas fa-cogs"
|
||||
>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.enable_system')"
|
||||
:description="$t('configuration.email.enable_system_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.email.config.enabled.set"
|
||||
/>
|
||||
<button class="button is-dark mt-2" @click="showSendTestEmail()">{{ $t('configuration.email.send_test_email') }}</button>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.enable_system')"
|
||||
:description="$t('configuration.email.enable_system_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.email.config.enabled.set"
|
||||
/>
|
||||
<button class="button is-dark mt-2" @click="showSendTestEmail()">
|
||||
{{ $t("configuration.email.send_test_email") }}
|
||||
</button>
|
||||
</ConfigurationCategory>
|
||||
<!-- Sender identity -->
|
||||
<ConfigurationCategory
|
||||
@@ -133,40 +92,40 @@ load();
|
||||
icon="fas fa-cogs"
|
||||
>
|
||||
<ConfigurationInput
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.from_email')"
|
||||
:description="$t('configuration.email.from_email_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_from')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from.set"
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.from_email')"
|
||||
:description="$t('configuration.email.from_email_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_from')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from.set"
|
||||
/>
|
||||
<ConfigurationInput
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.from_name')"
|
||||
:description="$t('configuration.email.from_name_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_from_name')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from_name.set"
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.from_name')"
|
||||
:description="$t('configuration.email.from_name_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_from_name')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from_name.set"
|
||||
/>
|
||||
<ConfigurationInput
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.reply_to_email')"
|
||||
:description="$t('configuration.email.reply_to_email_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_reply_to')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to.set"
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.reply_to_email')"
|
||||
:description="$t('configuration.email.reply_to_email_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_reply_to')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to.set"
|
||||
/>
|
||||
<ConfigurationInput
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.reply_to_name')"
|
||||
:description="$t('configuration.email.reply_to_name_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_reply_to_name')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to_name.set"
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.reply_to_name')"
|
||||
:description="$t('configuration.email.reply_to_name_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_reply_to_name')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to_name.set"
|
||||
/>
|
||||
</ConfigurationCategory>
|
||||
<ConfigurationCategory
|
||||
@@ -224,7 +183,6 @@ load();
|
||||
:value="getModuleConfigValue('smtp_encryption')"
|
||||
:on-select="SessionUser.superUser.modules.email.config.keys.smtp_encryption.set"
|
||||
/>
|
||||
|
||||
</ConfigurationCategory>
|
||||
<ConfigurationCategory
|
||||
class="mt-2"
|
||||
@@ -257,6 +215,4 @@ load();
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -3,57 +3,14 @@ import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
import { ref } from 'vue';
|
||||
import ConfigurationSubPageWrapper
|
||||
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
||||
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
||||
|
||||
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
|
||||
import { useModuleConfig } from "@/composables/useModuleConfig.js";
|
||||
|
||||
// Get the department from the route
|
||||
|
||||
|
||||
|
||||
const module_config = ref([]);
|
||||
|
||||
|
||||
|
||||
const getModuleConfig = async () => {
|
||||
await SessionUser.superUser.modules.openai.config.get_all().then((response) => {
|
||||
let tmp_module_config = response.data.data;
|
||||
let tmp_module_config_array = [];
|
||||
for (const value of Object.values(tmp_module_config)) {
|
||||
console.log(`${value.variable}: ${value.value}`);
|
||||
tmp_module_config_array.push({
|
||||
variable: value.variable,
|
||||
value: value.value,
|
||||
});
|
||||
}
|
||||
module_config.value = tmp_module_config_array;
|
||||
console.log(module_config.value);
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const getModuleConfigValue = (variable) => {
|
||||
const config = module_config.value.find((config) => config.variable === variable);
|
||||
// Log to the console, what the value was found
|
||||
if (config) {
|
||||
console.log(`Found value for ${variable}: ${config.value}`);
|
||||
} else {
|
||||
console.log(`No value found for ${variable}`);
|
||||
}
|
||||
return config ? config.value : '';
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await getModuleConfig();
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
const { module_config, getModuleConfigValue } = useModuleConfig("openai");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -102,6 +59,4 @@ load();
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -3,10 +3,7 @@ import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import ConfigurationSubPageWrapper
|
||||
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
||||
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
||||
import "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
|
||||
@@ -14,95 +11,64 @@ import ConfigurationSecretKey from "@/components/displays/superuser/configuratio
|
||||
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
|
||||
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useModuleConfig } from "@/composables/useModuleConfig.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Get the department from the route
|
||||
|
||||
|
||||
|
||||
const module_config = ref([]);
|
||||
|
||||
|
||||
|
||||
const getModuleConfig = async () => {
|
||||
await SessionUser.superUser.modules.stripe.config.get_all().then((response) => {
|
||||
let tmp_module_config = response.data.data;
|
||||
let tmp_module_config_array = [];
|
||||
for (const value of Object.values(tmp_module_config)) {
|
||||
console.log(`${value.variable}: ${value.value}`);
|
||||
tmp_module_config_array.push({
|
||||
variable: value.variable,
|
||||
value: value.value,
|
||||
});
|
||||
}
|
||||
module_config.value = tmp_module_config_array;
|
||||
console.log(module_config.value);
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const getModuleConfigValue = (variable) => {
|
||||
const config = module_config.value.find((config) => config.variable === variable);
|
||||
// Log to the console, what the value was found
|
||||
if (config) {
|
||||
console.log(`Found value for ${variable}: ${config.value}`);
|
||||
} else {
|
||||
console.log(`No value found for ${variable}`);
|
||||
}
|
||||
return config ? config.value : '';
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await getModuleConfig();
|
||||
};
|
||||
|
||||
load();
|
||||
const { module_config, getModuleConfigValue } = useModuleConfig("stripe");
|
||||
|
||||
const showGetCustomers = async () => {
|
||||
await SessionUser.superUser.modules.stripe.functions.customers.list().then((response) => {
|
||||
console.log('Stripe customers: ', response.data.data);
|
||||
Swal.fire({
|
||||
title: t('configuration.stripe.customers'),
|
||||
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
await SessionUser.superUser.modules.stripe.functions.customers
|
||||
.list()
|
||||
.then((response) => {
|
||||
console.log("Stripe customers: ", response.data.data);
|
||||
Swal.fire({
|
||||
title: t("configuration.stripe.customers"),
|
||||
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const showGetProducts = async () => {
|
||||
await SessionUser.superUser.modules.stripe.functions.products.list().then((response) => {
|
||||
console.log('Stripe products: ', response.data.data);
|
||||
Swal.fire({
|
||||
title: t('configuration.stripe.products'),
|
||||
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
await SessionUser.superUser.modules.stripe.functions.products
|
||||
.list()
|
||||
.then((response) => {
|
||||
console.log("Stripe products: ", response.data.data);
|
||||
Swal.fire({
|
||||
title: t("configuration.stripe.products"),
|
||||
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const showGetPrices = async () => {
|
||||
await SessionUser.superUser.modules.stripe.functions.prices.list().then((response) => {
|
||||
console.log('Stripe prices: ', response.data.data);
|
||||
Swal.fire({
|
||||
title: t('configuration.stripe.prices'),
|
||||
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
await SessionUser.superUser.modules.stripe.functions.prices
|
||||
.list()
|
||||
.then((response) => {
|
||||
console.log("Stripe prices: ", response.data.data);
|
||||
Swal.fire({
|
||||
title: t("configuration.stripe.prices"),
|
||||
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -120,15 +86,15 @@ const showGetPrices = async () => {
|
||||
:description="$t('configuration.stripe.general_settings_desc')"
|
||||
icon="fas fa-cogs"
|
||||
>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="Stripe"
|
||||
:title="$t('configuration.stripe.enable')"
|
||||
:description="$t('configuration.stripe.enable_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.stripe.config.enabled.set"
|
||||
/>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="Stripe"
|
||||
:title="$t('configuration.stripe.enable')"
|
||||
:description="$t('configuration.stripe.enable_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.stripe.config.enabled.set"
|
||||
/>
|
||||
</ConfigurationCategory>
|
||||
<ConfigurationCategory
|
||||
class="mt-2"
|
||||
@@ -177,25 +143,23 @@ const showGetPrices = async () => {
|
||||
<span class="icon">
|
||||
<i class="fas fa-users"></i>
|
||||
</span>
|
||||
<span>{{ $t('configuration.stripe.get_customers') }}</span>
|
||||
<span>{{ $t("configuration.stripe.get_customers") }}</span>
|
||||
</button>
|
||||
<button class="button is-dark" @click="showGetProducts">
|
||||
<span class="icon">
|
||||
<i class="fas fa-box"></i>
|
||||
</span>
|
||||
<span>{{ $t('configuration.stripe.get_products') }}</span>
|
||||
<span>{{ $t("configuration.stripe.get_products") }}</span>
|
||||
</button>
|
||||
<button class="button is-dark" @click="showGetPrices">
|
||||
<span class="icon">
|
||||
<i class="fas fa-money-bill-wave"></i>
|
||||
</span>
|
||||
<span>{{ $t('configuration.stripe.get_prices') }}</span>
|
||||
<span>{{ $t("configuration.stripe.get_prices") }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</ConfigurationSubPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -83,152 +83,8 @@ const onClickTestConnection = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
// --- MiniMax re-authenticate / remove ---------------------------------------
|
||||
const minimaxApiKeyIsSet = ref(false);
|
||||
const minimaxEnabled = ref(false);
|
||||
const reauthenticatingMiniMax = ref(false);
|
||||
const removingMiniMax = ref(false);
|
||||
const togglingMiniMaxEnabled = ref(false);
|
||||
|
||||
/**
|
||||
* The backend returns the get-config response as an array (one entry per
|
||||
* module variable). Extract the single entry we asked for, then trust the
|
||||
* explicit `isSet` flag instead of the redacted `value`.
|
||||
*/
|
||||
const extractConfigEntry = (response) => {
|
||||
const payload = response?.data?.data;
|
||||
if (Array.isArray(payload)) {
|
||||
return payload[0] ?? null;
|
||||
}
|
||||
return payload ?? null;
|
||||
};
|
||||
|
||||
const refreshMiniMaxApiKeyStatus = async () => {
|
||||
try {
|
||||
const response = await SessionUser.superUser.modules.minimax.config.keys.api_key.get();
|
||||
const entry = extractConfigEntry(response);
|
||||
minimaxApiKeyIsSet.value = entry?.isSet === true;
|
||||
} catch (error) {
|
||||
// If the endpoint is unreachable or the key isn't set yet, treat as not-set.
|
||||
minimaxApiKeyIsSet.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshMiniMaxEnabled = async () => {
|
||||
try {
|
||||
const response = await SessionUser.superUser.modules.minimax.config.enabled.get();
|
||||
const entry = extractConfigEntry(response);
|
||||
const raw = entry?.value;
|
||||
minimaxEnabled.value = raw === true || raw === 'true' || raw === '1' || raw === 1;
|
||||
} catch (error) {
|
||||
minimaxEnabled.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onClickReauthenticateMiniMax = async () => {
|
||||
const { value: apiKey } = await Swal.fire({
|
||||
title: t('configuration.xlvask.minimax_reauth_title'),
|
||||
text: t('configuration.xlvask.minimax_reauth_desc'),
|
||||
input: 'password',
|
||||
inputAttributes: { autocomplete: 'off', autocapitalize: 'off', spellcheck: 'false' },
|
||||
inputPlaceholder: t('configuration.xlvask.minimax_api_key_placeholder'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: t('configuration.xlvask.minimax_reauth_confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
preConfirm: (val) => {
|
||||
if (!val || String(val).trim() === '') {
|
||||
Swal.showValidationMessage(t('configuration.xlvask.minimax_api_key_required'));
|
||||
return false;
|
||||
}
|
||||
return String(val).trim();
|
||||
},
|
||||
});
|
||||
if (!apiKey) return;
|
||||
reauthenticatingMiniMax.value = true;
|
||||
try {
|
||||
await SessionUser.superUser.modules.minimax.config.keys.api_key.set(apiKey);
|
||||
// Re-fetch from the backend so the UI matches actual persistence (and so a
|
||||
// silent failure surfaces as "still not set" instead of a misleading green
|
||||
// checkmark).
|
||||
await refreshMiniMaxApiKeyStatus();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: t('configuration.xlvask.minimax_reauth_success'),
|
||||
text: t('configuration.xlvask.minimax_reauth_success_desc'),
|
||||
});
|
||||
} catch (error) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('configuration.xlvask.minimax_reauth_failed'),
|
||||
text: error?.message ?? String(error),
|
||||
});
|
||||
} finally {
|
||||
reauthenticatingMiniMax.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onClickRemoveMiniMax = async () => {
|
||||
const confirmation = await Swal.fire({
|
||||
title: t('configuration.xlvask.minimax_remove_title'),
|
||||
text: t('configuration.xlvask.minimax_remove_desc'),
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#d33',
|
||||
confirmButtonText: t('configuration.xlvask.minimax_remove_confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
if (!confirmation.isConfirmed) return;
|
||||
removingMiniMax.value = true;
|
||||
try {
|
||||
await SessionUser.superUser.modules.minimax.config.keys.api_key.set('');
|
||||
await refreshMiniMaxApiKeyStatus();
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: t('configuration.xlvask.minimax_remove_success'),
|
||||
text: t('configuration.xlvask.minimax_remove_success_desc'),
|
||||
});
|
||||
} catch (error) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('configuration.xlvask.minimax_remove_failed'),
|
||||
text: error?.message ?? String(error),
|
||||
});
|
||||
} finally {
|
||||
removingMiniMax.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The ConfigurationSecretKey inline-edit flow calls onSave and resolves on
|
||||
* success. Refresh the parent state from the API so the "Hidden" view replaces
|
||||
* the warning as soon as the request actually persists.
|
||||
*/
|
||||
const onMiniMaxApiKeySaved = async () => {
|
||||
await refreshMiniMaxApiKeyStatus();
|
||||
};
|
||||
|
||||
/**
|
||||
* The MiniMax "Enable" toggle calls on-switch with the next boolean value.
|
||||
* Re-fetch after the save so the UI reflects persisted state (the inline
|
||||
* `set()` call does not refresh the UI on its own).
|
||||
*/
|
||||
const onMiniMaxEnabledSwitch = async (nextValue) => {
|
||||
togglingMiniMaxEnabled.value = true;
|
||||
try {
|
||||
await SessionUser.superUser.modules.minimax.config.enabled.set(nextValue);
|
||||
await refreshMiniMaxEnabled();
|
||||
} catch (error) {
|
||||
// Roll back the optimistic UI flip on failure.
|
||||
minimaxEnabled.value = !nextValue;
|
||||
throw error;
|
||||
} finally {
|
||||
togglingMiniMaxEnabled.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await getModuleConfig();
|
||||
await Promise.all([refreshMiniMaxApiKeyStatus(), refreshMiniMaxEnabled()]);
|
||||
};
|
||||
|
||||
load();
|
||||
@@ -301,84 +157,9 @@ load();
|
||||
:description="$t('configuration.xlvask.automation_settings_desc')"
|
||||
icon="fas fa-magic"
|
||||
>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.enable_automatic_attachment')"
|
||||
:description="$t('configuration.xlvask.enable_automatic_attachment_desc')"
|
||||
icon="fas fa-link"
|
||||
:value="getModuleConfigValue('automatic_order_attachment_enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.automatic_order_attachment_enabled.set"
|
||||
/>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.enable_automatic_creation')"
|
||||
:description="$t('configuration.xlvask.enable_automatic_creation_desc')"
|
||||
icon="fas fa-plus-circle"
|
||||
:value="getModuleConfigValue('automatic_order_creation_enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.automatic_order_creation_enabled.set"
|
||||
/>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.enable_openai_integration')"
|
||||
:description="$t('configuration.xlvask.enable_openai_integration_desc')"
|
||||
icon="fas fa-brain"
|
||||
:value="getModuleConfigValue('openai_integration_enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.openai_integration_enabled.set"
|
||||
/>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.enable_minimax_integration')"
|
||||
:description="$t('configuration.xlvask.enable_minimax_integration_desc')"
|
||||
icon="fas fa-robot"
|
||||
:value="getModuleConfigValue('minimax_integration_enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.minimax_integration_enabled.set"
|
||||
/>
|
||||
</ConfigurationCategory>
|
||||
<ConfigurationCategory
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.minimax_settings')"
|
||||
:description="$t('configuration.xlvask.minimax_settings_desc')"
|
||||
icon="fas fa-robot"
|
||||
>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.minimax_enable')"
|
||||
:description="$t('configuration.xlvask.minimax_enable_desc')"
|
||||
icon="fas fa-robot"
|
||||
:value="minimaxEnabled"
|
||||
:on-switch="onMiniMaxEnabledSwitch"
|
||||
:disabled="togglingMiniMaxEnabled"
|
||||
/>
|
||||
<ConfigurationSecretKey
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.minimax_api_key')"
|
||||
:description="$t('configuration.xlvask.minimax_api_key_desc')"
|
||||
icon="fas fa-key"
|
||||
:isSet="minimaxApiKeyIsSet"
|
||||
:on-save="SessionUser.superUser.modules.minimax.config.keys.api_key.set"
|
||||
@saved="onMiniMaxApiKeySaved"
|
||||
/>
|
||||
<div class="buttons mt-2">
|
||||
<button class="button is-warning" @click="onClickReauthenticateMiniMax" :disabled="reauthenticatingMiniMax">
|
||||
<span class="icon">
|
||||
<i class="fas fa-redo"></i>
|
||||
</span>
|
||||
<span>{{ reauthenticatingMiniMax ? $t('configuration.xlvask.minimax_reauth_in_progress') : $t('configuration.xlvask.minimax_reauthenticate') }}</span>
|
||||
</button>
|
||||
<button class="button is-danger ml-2" @click="onClickRemoveMiniMax" :disabled="removingMiniMax">
|
||||
<span class="icon">
|
||||
<i class="fas fa-trash"></i>
|
||||
</span>
|
||||
<span>{{ removingMiniMax ? $t('configuration.xlvask.minimax_remove_in_progress') : $t('configuration.xlvask.minimax_remove') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
{{ $t('configuration.xlvask.automation_settings_removed_desc') }}
|
||||
</p>
|
||||
</ConfigurationCategory>
|
||||
<div class="buttons">
|
||||
<button class="button is-dark" @click="onClickTestConnection">
|
||||
|
||||
@@ -1,849 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
import { createOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
reg: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
dateFrom: {
|
||||
type: Date,
|
||||
default: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
/** Dynamic variables */
|
||||
const usageLog = ref(null);
|
||||
const usageLogLoading = ref(false);
|
||||
const xlvask_vehicle_types = ref(null);
|
||||
const product_options = ref(null);
|
||||
const departments = ref(null);
|
||||
const related_orders = ref(null);
|
||||
|
||||
const getUsageLog = async () => {
|
||||
usageLogLoading.value = true;
|
||||
usageLog.value = null;
|
||||
SessionUser.superUser.modules.xlvask.functions.getUsageLog(
|
||||
(props.dateFrom === null ? null : SessionUser.superUser.modules.xlvask.functions.convertDateTimeToISO(props.dateFrom)),
|
||||
props.reg,
|
||||
).then(
|
||||
(response) => {
|
||||
if (response.status === 200) {
|
||||
usageLog.value = response.data.data;
|
||||
} else {
|
||||
console.error("XL-Vask: usage log returned non-OK status.", { status: response?.status, reg: props.reg });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_usage_log'),
|
||||
});
|
||||
}
|
||||
}
|
||||
).catch(
|
||||
(error) => {
|
||||
console.error("XL-Vask: failed to load usage log.", { reg: props.reg, error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_usage_log'),
|
||||
});
|
||||
}
|
||||
).finally(() => {
|
||||
usageLogLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const getVehicleTypes = async () => {
|
||||
xlvask_vehicle_types.value = null;
|
||||
SessionUser.superUser.modules.xlvask.functions.getVehicleTypes().then(
|
||||
(response) => {
|
||||
if (response.status === 200) {
|
||||
xlvask_vehicle_types.value = response.data.data;
|
||||
} else {
|
||||
console.error("XL-Vask: vehicle types returned non-OK status.", { status: response?.status });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'),
|
||||
});
|
||||
}
|
||||
}
|
||||
).catch(
|
||||
(error) => {
|
||||
console.error("XL-Vask: failed to load vehicle types.", { error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'),
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getProductOptions = async () => {
|
||||
if (product_options.value === null) {
|
||||
SessionUser.objects.product_options.get.all().then((result) => {
|
||||
product_options.value = result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const getDepartments = async () => {
|
||||
SessionUser.objects.departments.get.all().then((result) => {
|
||||
departments.value = result;
|
||||
});
|
||||
}
|
||||
|
||||
const getRelatedOrders = async () => {
|
||||
related_orders.value = null;
|
||||
SessionUser.superUser.modules.xlvask.functions.getRelatedOrders(listWashIds()).then(
|
||||
(response) => {
|
||||
if (response.status === 200) {
|
||||
related_orders.value = response.data.data;
|
||||
} else {
|
||||
console.error("XL-Vask: related orders returned non-OK status.", { status: response?.status });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_related_orders'),
|
||||
});
|
||||
}
|
||||
}
|
||||
).catch(
|
||||
(error) => {
|
||||
console.error("XL-Vask: failed to load related orders.", { error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_related_orders'),
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Watch for changes in the usage log and update the related orders
|
||||
watch(usageLog, (newValue) => {
|
||||
if (newValue) {
|
||||
getRelatedOrders();
|
||||
}
|
||||
});
|
||||
|
||||
const listWashIds = () => {
|
||||
let washIds = [];
|
||||
if (usageLog.value) {
|
||||
for (const usage of usageLog.value) {
|
||||
washIds.push(usage.WashId);
|
||||
}
|
||||
}
|
||||
return washIds;
|
||||
}
|
||||
|
||||
const _example = {
|
||||
"WashId": "892ae789-aeea-4bda-9374-cf931290aefd",
|
||||
"CustomerId": "59440200",
|
||||
"Customer": "DITOBUS EXCURSIONS A/S",
|
||||
"VatNumber": "31171520",
|
||||
"Location": "Hvidovre",
|
||||
"Hall": "Hvidovre_1",
|
||||
"HallId": "845d29a1-a7d2-4e3b-bbc3-2b13242d744a",
|
||||
"StartTime": "2024-01-26T14:46:53.067",
|
||||
"FinishTime": "2024-01-26T14:54:08.653",
|
||||
"RegistrationNumber": "BJ22227",
|
||||
"VehicleType": "Bus/autocamper, M",
|
||||
"IdentificationType": "LPR",
|
||||
"IdentificationId": "BJ22227",
|
||||
"Info": "BJ22227",
|
||||
"Updated": "",
|
||||
"Prepaid": false,
|
||||
"FinishStatus": 1,
|
||||
"CustomerGuid": "21ba156a-b2d2-44be-8398-4b67d66003d6",
|
||||
"VehicleId": "0584ef66-3deb-491e-9b82-0a28cfc20e9e",
|
||||
"WashItems": [
|
||||
{
|
||||
"WashItemId": "399b25c1-5c03-4f25-b5e9-00731e9b94c4",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "Ikke HT dysebom bag",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
},
|
||||
{
|
||||
"WashItemId": "ef3ca812-bbe5-4cf3-916f-06fd17c04225",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": "Spot Free",
|
||||
"OriginalProductName": "Skylning med RO",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 35,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 35,
|
||||
"Vat": 3.06,
|
||||
"PriceIncVat": 15.31
|
||||
},
|
||||
{
|
||||
"WashItemId": "5c4ec0d9-cffc-45eb-9213-406dbcb8c975",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "2-børstevask",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
},
|
||||
{
|
||||
"WashItemId": "f7bd7404-f5a3-4a59-8e85-53b284df3260",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "Halleje",
|
||||
"Unit": "min",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
},
|
||||
{
|
||||
"WashItemId": "6dba6c24-8191-4c2b-913c-7366518fb41d",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "Stor bil",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 559,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 559,
|
||||
"Vat": 48.91,
|
||||
"PriceIncVat": 244.56
|
||||
},
|
||||
{
|
||||
"WashItemId": "61d11d87-2ee4-4a4d-890b-a0870ae1a924",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "HT sider",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
},
|
||||
{
|
||||
"WashItemId": "ec6eb1c4-b58c-457f-8224-b674cf41dc29",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "EU spejl",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
},
|
||||
{
|
||||
"WashItemId": "54b4deec-be3e-4cb2-b68d-b9af9cff6fcf",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "HT chassis",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
const _exampleVehicleTypes = [
|
||||
{
|
||||
"id": 1,
|
||||
"vehicleTypeId": "0f915576-587c-4494-bcce-388b3b3fe55a",
|
||||
"product": 17,
|
||||
"name": "Bus/autocamper, M",
|
||||
"created_at": "2025-05-19 09:41:20",
|
||||
"updated_at": "2025-05-19 09:41:20"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"vehicleTypeId": "e2638c21-366d-4b7f-b0af-eb3634ae2c8c",
|
||||
"product": 15,
|
||||
"name": "Kassevogn/Varevogn, L",
|
||||
"created_at": "2025-05-19 09:44:39",
|
||||
"updated_at": "2025-05-19 09:44:39"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"vehicleTypeId": "5e6cfa13-df14-4a11-8d3d-603a41d37f68",
|
||||
"product": 17,
|
||||
"name": "Bus/autocamper, L",
|
||||
"created_at": "2025-05-19 10:12:54",
|
||||
"updated_at": "2025-05-19 10:12:54"
|
||||
}
|
||||
];
|
||||
onMounted(() => {
|
||||
getVehicleTypes();
|
||||
getUsageLog();
|
||||
getProductOptions();
|
||||
getDepartments();
|
||||
});
|
||||
|
||||
const getProductName = (item) => {
|
||||
if (item.ExternalProductName) {
|
||||
return item.ExternalProductName;
|
||||
} else if (item.OriginalProductName) {
|
||||
return item.OriginalProductName;
|
||||
} else {
|
||||
return t('invoicing_period.xlvask_autopilot.labels.unknown_product_with_id', { id: item.WashItemId });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
||||
};
|
||||
|
||||
|
||||
const getOrderFromUsageLogEntry = (usageLogEntry) => {
|
||||
return {
|
||||
customer_id: usageLogEntry.CustomerId,
|
||||
reg_1: usageLogEntry.RegistrationNumber,
|
||||
reg_2: "",
|
||||
reg_3: "",
|
||||
wash_id: usageLogEntry.WashId,
|
||||
}
|
||||
}
|
||||
|
||||
const getPrimaryServiceProductId = (usageLogEntry) => {
|
||||
// Check if the vehicle type is in the list
|
||||
const vehicleType = xlvask_vehicle_types.value.find(type => type.name === usageLogEntry.VehicleType);
|
||||
if (vehicleType) {
|
||||
// If the vehicle type is found, return the product ID
|
||||
return vehicleType.product;
|
||||
} else {
|
||||
// If the vehicle type is not found, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const recognizedItems = {
|
||||
"Undervognskylning": {
|
||||
getProductId: (usageLogEntry) => {
|
||||
let primaryProductId = getPrimaryServiceProductId(usageLogEntry);
|
||||
// Get all the matching options where the product id is the same as the primary product id
|
||||
let options = product_options.value.filter(option => option.product_id === primaryProductId);
|
||||
// Check if any options have the option_id 21
|
||||
let option = options.find(option => option.option_id === 21);
|
||||
if (option) {
|
||||
// If the option is found, return the option_id (21)
|
||||
return option.option_id;
|
||||
} else {
|
||||
// If the option is not found, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
// Spot Free
|
||||
"Skylning med RO": {
|
||||
getProductId: (usageLogEntry) => {
|
||||
let primaryProductId = getPrimaryServiceProductId(usageLogEntry);
|
||||
// Get all the matching options where the product id is the same as the primary product id
|
||||
let options = product_options.value.filter(option => option.product_id === primaryProductId);
|
||||
// Check if any options have the option_id 23, or 24
|
||||
let option = options.find(option => option.option_id === 23 || option.option_id === 24);
|
||||
if (option) {
|
||||
// If the option is found, return the option_id (23 or 24)
|
||||
return option.option_id;
|
||||
} else {
|
||||
// If the option is not found, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
// Primary services
|
||||
"Stor bil": {
|
||||
getProductId: (usageLogEntry) => {
|
||||
// Check if the vehicle type is in the list
|
||||
return getPrimaryServiceProductId(usageLogEntry);
|
||||
}
|
||||
},
|
||||
"Lille bil": {
|
||||
getProductId: (usageLogEntry) => {
|
||||
// Check if the vehicle type is in the list
|
||||
return getPrimaryServiceProductId(usageLogEntry);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const round_price_down = (price) => {
|
||||
// Round the price down to the nearest whole number
|
||||
return Math.floor(price);
|
||||
}
|
||||
|
||||
const getOrderItemsFromUsageLogEntry = (usageLogEntry) => {
|
||||
let items = [];
|
||||
let unrecognizedItems = [];
|
||||
// Filter out all the free items
|
||||
const filteredItems = usageLogEntry.WashItems.filter(item => item.PriceIncVat > 0);
|
||||
// If the "Stor bil" item is present, set it to be the first item
|
||||
const storBilIndex = filteredItems.findIndex(item => item.OriginalProductName === "Stor bil" || item.OriginalProductName === "Lille bil");
|
||||
if (storBilIndex > -1) {
|
||||
const storBilItem = filteredItems.splice(storBilIndex, 1)[0];
|
||||
filteredItems.unshift(storBilItem);
|
||||
}
|
||||
// Loop through the filtered items
|
||||
for (const item of filteredItems) {
|
||||
// Check if the item is recognized
|
||||
if (recognizedItems[item.OriginalProductName]) {
|
||||
items.push({
|
||||
product_id: recognizedItems[item.OriginalProductName].getProductId(usageLogEntry),
|
||||
quantity: item.Count,
|
||||
discount_percentage: item.Discount,
|
||||
price: {
|
||||
unit: round_price_down(item.UnitPrice), // Before discount
|
||||
each: round_price_down( item.UnitPrice - (item.UnitPrice * item.Discount / 100) ), // One item after discount
|
||||
total: round_price_down( (item.UnitPrice * item.Count) - (item.UnitPrice * item.Count * item.Discount / 100) ), // Total price after discount x quantity
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If the item is not recognized, add it to the unrecognized items
|
||||
unrecognizedItems.push(item);
|
||||
}
|
||||
}
|
||||
// Calculate the total price
|
||||
let price = {
|
||||
total: 0,
|
||||
}
|
||||
for (const item of items) {
|
||||
price.total += item.price.total;
|
||||
}
|
||||
return { items, unrecognizedItems, price };
|
||||
}
|
||||
|
||||
const onClickCreateOrder = async (usageLogEntry) => {
|
||||
// Check if the order can be created
|
||||
if (!canCreateOrder(usageLogEntry)) {
|
||||
console.warn("XL-Vask: cannot create order from usage log entry.", {
|
||||
washId: usageLogEntry?.WashId,
|
||||
hasUnrecognizedItems: hasUnrecognizedItems(usageLogEntry),
|
||||
hasUnrecognizedDepartment: hasUnrecognizedDepartment(usageLogEntry),
|
||||
hasRelatedOrder: hasRelatedOrder(usageLogEntry),
|
||||
});
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.create_order_unrecognized_items'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let order = getOrderFromUsageLogEntry(usageLogEntry);
|
||||
let orderItems = getOrderItemsFromUsageLogEntry(usageLogEntry);
|
||||
// Create the order
|
||||
let order_props = {
|
||||
customer_id: parseInt(order.customer_id),
|
||||
cashier_id: null,
|
||||
department_id: getDepartmentId(usageLogEntry),
|
||||
reference: null,
|
||||
reg_1: order.reg_1,
|
||||
reg_2: order.reg_2,
|
||||
reg_3: order.reg_3,
|
||||
notes: null,
|
||||
invoice_collection_id: null,
|
||||
}
|
||||
await SessionUser.objects.orders
|
||||
.add(
|
||||
order_props.customer_id,
|
||||
order_props.cashier_id,
|
||||
order_props.department_id,
|
||||
order_props.reference,
|
||||
order_props.reg_1,
|
||||
order_props.reg_2,
|
||||
order_props.reg_3,
|
||||
order_props.notes,
|
||||
order_props.invoice_collection_id,
|
||||
).then(
|
||||
async (response) => {
|
||||
if (response.status === 200) {
|
||||
let orderId = parseInt(response.data.data.id);
|
||||
// Add the wash id to the order
|
||||
await SessionUser.objects.orders.set.wash_id(orderId, usageLogEntry.WashId);
|
||||
// Set the time created to the start time
|
||||
await SessionUser.objects.orders.set.created_at(orderId, SessionUser.functions.date.format(SessionUser.superUser.modules.xlvask.functions.convertISODateTimeToDate(usageLogEntry.StartTime)));
|
||||
let relational_id = null;
|
||||
let itemFailure = false;
|
||||
// Add the order items to the order
|
||||
for (const item of orderItems.items) {
|
||||
await createOrderItem(
|
||||
orderId,
|
||||
item.product_id,
|
||||
item.quantity,
|
||||
relational_id,
|
||||
null,
|
||||
item.price.each
|
||||
).then(
|
||||
(response) => {
|
||||
if (response.status === 200) {
|
||||
// If the relational_id is null, set it to the order item id
|
||||
if (relational_id === null) {
|
||||
relational_id = parseInt(response.data.data.id);
|
||||
}
|
||||
} else {
|
||||
itemFailure = true;
|
||||
console.error("XL-Vask: order item returned non-OK status.", {
|
||||
orderId,
|
||||
productId: item?.product_id,
|
||||
status: response?.status,
|
||||
});
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.create_order_item_failed'),
|
||||
});
|
||||
}
|
||||
}
|
||||
).catch(
|
||||
(error) => {
|
||||
itemFailure = true;
|
||||
console.error("XL-Vask: failed to create order item.", { orderId, error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.create_order_item_failed'),
|
||||
});
|
||||
}
|
||||
)
|
||||
}
|
||||
if (!itemFailure) {
|
||||
onOrderCreated(orderId);
|
||||
}
|
||||
} else {
|
||||
console.error("XL-Vask: order creation returned non-OK status.", { status: response?.status });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.create_order_failed'),
|
||||
});
|
||||
}
|
||||
}
|
||||
).catch(
|
||||
(error) => {
|
||||
console.error("XL-Vask: failed to create order.", { washId: usageLogEntry?.WashId, error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.create_order_failed'),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const onOrderCreated = (orderId) => {
|
||||
Swal.fire({
|
||||
title: t('tables.xlvask.order_created'),
|
||||
text: t('tables.xlvask.order_id', { id: orderId }),
|
||||
icon: "success",
|
||||
confirmButtonText: t('invoicing_period.xlvask_autopilot.labels.ok')
|
||||
});
|
||||
// Load the related orders
|
||||
getRelatedOrders();
|
||||
}
|
||||
|
||||
const hasUnrecognizedItems = (usageLogEntry) => {
|
||||
let orderItems = getOrderItemsFromUsageLogEntry(usageLogEntry);
|
||||
return orderItems.unrecognizedItems.length > 0;
|
||||
}
|
||||
|
||||
const hasUnrecognizedDepartment = (usageLogEntry) => {
|
||||
// Check if the department is in the list
|
||||
const department = getDepartmentId(usageLogEntry);
|
||||
return ( department === null || department === undefined );
|
||||
}
|
||||
|
||||
const listRelatedOrders = (usageLogEntry) => {
|
||||
// Check if the order is already created (If the wash id key is present in the related orders)
|
||||
if (related_orders.value) {
|
||||
let keys = Object.keys(related_orders.value);
|
||||
if (keys.includes(usageLogEntry.WashId)) {
|
||||
return related_orders.value[usageLogEntry.WashId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasRelatedOrder = (usageLogEntry) => {
|
||||
// Check if the order is already created (If the wash id key is present in the related orders)
|
||||
/**
|
||||
* {
|
||||
* "892ae789-aeea-4bda-9374-cf931290aefd": [
|
||||
* 9997, // Order ID
|
||||
* 9998, // Order ID #2 (If multiple orders are related)
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
return listRelatedOrders(usageLogEntry) !== undefined && listRelatedOrders(usageLogEntry).length > 0;
|
||||
}
|
||||
|
||||
const canCreateOrder = (usageLogEntry) => {
|
||||
// Check if there are any unrecognized items, and if the department is recognized
|
||||
return (
|
||||
!hasUnrecognizedItems(usageLogEntry) &&
|
||||
!hasUnrecognizedDepartment(usageLogEntry) &&
|
||||
!hasRelatedOrder(usageLogEntry)
|
||||
);
|
||||
}
|
||||
|
||||
const getDepartmentId = (usageLogEntry) => {
|
||||
// Check if the department is in the list
|
||||
const department = departments.value.find(department => department.name === usageLogEntry.Location);
|
||||
if (department) {
|
||||
// If the department is found, return the department id
|
||||
return parseInt(department.id);
|
||||
} else {
|
||||
// If the department is not found, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const redirectDepartmentOrderPage = async (orderId) => {
|
||||
// Send the user to the order page (In a new tab)
|
||||
// Get the department id from the order
|
||||
await SessionUser.objects.orders.functions.get_department_id(orderId).then((response) => {
|
||||
// Get the department id from the response
|
||||
// Send the user to the order page (In a new tab)
|
||||
window.open(`/admin/${response}/modules/pos/orders/${orderId}`, '_blank');
|
||||
}).catch((error) => {
|
||||
console.error("XL-Vask: failed to resolve department id for related order.", { orderId, error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.redirect_order_failed'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getUsageStatus = (usageLogEntry) => {
|
||||
let result = {
|
||||
color_class: "has-text-grey",
|
||||
price: {
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
if (hasRelatedOrder(usageLogEntry)) {
|
||||
result.color_class = "has-text-success";
|
||||
}
|
||||
if (hasUnrecognizedItems(usageLogEntry)) {
|
||||
result.color_class = "has-text-danger";
|
||||
}
|
||||
if (hasUnrecognizedDepartment(usageLogEntry)) {
|
||||
result.color_class = "has-text-warning";
|
||||
}
|
||||
// Add the price to the result
|
||||
result.price = getOrderItemsFromUsageLogEntry(usageLogEntry).price;
|
||||
return result;
|
||||
}
|
||||
|
||||
const onClickCreateOrderAllApplicable = async () => {
|
||||
// Loop through all the usage log entries
|
||||
for (const usageLogEntry of usageLog.value) {
|
||||
// Check if the order can be created
|
||||
if (canCreateOrder(usageLogEntry)) {
|
||||
await onClickCreateOrder(usageLogEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ t('tables.xlvask.usage_log_title') }}</h1>
|
||||
<div class="buttons">
|
||||
<button class="button is-primary" @click="onClickCreateOrderAllApplicable">{{ t('tables.xlvask.create_order_all') }}</button>
|
||||
<button class="button is-info" :class="{ 'is-loading': usageLogLoading }" :disabled="usageLogLoading" @click="getUsageLog">{{ t('tables.xlvask.refresh') }}</button>
|
||||
</div>
|
||||
<table class="table mb-6">
|
||||
<!-- Table header -->
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="is-narrow"><!-- Status --></th>
|
||||
<th>{{ t('tables.common.customer') }}</th>
|
||||
<th>{{ t('tables.common.registration_number') }}</th>
|
||||
<th>{{ t('tables.common.price') }}</th>
|
||||
<th>{{ t('tables.common.start_time') }}</th>
|
||||
<th>{{ t('tables.common.end_time') }}</th>
|
||||
<th>{{ t('tables.common.location') }}</th>
|
||||
<th><!-- Actions --></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- Table body -->
|
||||
<tbody>
|
||||
<tr v-if="!usageLog || usageLog.length === 0">
|
||||
<td colspan="8" class="has-text-centered has-text-grey">
|
||||
{{ t('tables.xlvask.usage_log_empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="(usage, index) in usageLog" :key="index">
|
||||
<tr>
|
||||
<td>
|
||||
<ColorIndicator
|
||||
v-bind:color_class="getUsageStatus(usage).color_class"
|
||||
v-bind:visibility="{
|
||||
icon: true,
|
||||
dropdown: false
|
||||
}"
|
||||
@onClick="() => { /* status indicator clicked */ }"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ usage.CustomerId }}</td>
|
||||
<td>{{ usage.RegistrationNumber }}</td>
|
||||
<td>
|
||||
<ColorIndicator
|
||||
v-bind:color_class="getUsageStatus(usage).color_class"
|
||||
v-bind:visibility="{
|
||||
icon: false,
|
||||
dropdown: true
|
||||
}"
|
||||
v-bind:label="{
|
||||
text: SessionUser.functions.currency.toLocal(getUsageStatus(usage).price.total),
|
||||
classes: []
|
||||
}"
|
||||
v-bind:dropdown_content="{
|
||||
content: [
|
||||
{
|
||||
text: t('common.services'),
|
||||
button: false,
|
||||
action: () => {},
|
||||
},
|
||||
...(getOrderItemsFromUsageLogEntry(usage).items.map(item => {
|
||||
return {
|
||||
text: `${SessionUser.objects.products.functions.getProductName(item.product_id)} - ${item.quantity} x ${SessionUser.functions.currency.toLocal(item.price.each)}`,
|
||||
button: true,
|
||||
action: () => {},
|
||||
button_text: SessionUser.functions.currency.toLocal(item.price.total),
|
||||
v_centered: true,
|
||||
}
|
||||
})),
|
||||
...(getOrderItemsFromUsageLogEntry(usage).unrecognizedItems.map(item => {
|
||||
return {
|
||||
text: `${item.OriginalProductName} - ${item.Count} x ${SessionUser.functions.currency.toLocal(item.UnitPrice)}`,
|
||||
button: true,
|
||||
action: () => {},
|
||||
icon: 'fas fa-exclamation-triangle',
|
||||
button_text: SessionUser.functions.currency.toLocal(item.PriceIncVat),
|
||||
v_centered: true,
|
||||
}
|
||||
}))
|
||||
]
|
||||
}"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ formatDate(usage.StartTime) }}</td>
|
||||
<td>{{ formatDate(usage.FinishTime) }}</td>
|
||||
<td>
|
||||
<ColorIndicator
|
||||
v-bind:color_class="getUsageStatus(usage).color_class"
|
||||
v-bind:visibility="{
|
||||
icon: false,
|
||||
dropdown: false
|
||||
}"
|
||||
v-bind:label="{
|
||||
text: usage.Location,
|
||||
classes: []
|
||||
}"
|
||||
v-bind:dropdown_content="{
|
||||
content: [
|
||||
{
|
||||
text: t('self_wash.lane'),
|
||||
button: true,
|
||||
button_text: usage.Hall,
|
||||
action: () => {},
|
||||
v_centered: true,
|
||||
}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<ActionSettingsWheelButton>
|
||||
<template #actions>
|
||||
<!-- Create order based on usage log entry -->
|
||||
<ActionSettingsWheelItem
|
||||
:label="t('tables.xlvask.create_order')"
|
||||
icon="fas fa-plus"
|
||||
v-bind:disabled="!canCreateOrder(usage)"
|
||||
v-bind:click-action="() => onClickCreateOrder(usage)"
|
||||
/>
|
||||
<!-- If there's related orders, add them as buttons -->
|
||||
<template v-if="hasRelatedOrder(usage)">
|
||||
<ActionSettingsWheelItemLabel
|
||||
:label="SessionUser.objects.orders.meta.labels.multiple"
|
||||
/>
|
||||
<ActionSettingsWheelItem
|
||||
v-for="(order, orderIndex) in listRelatedOrders(usage)"
|
||||
:key="orderIndex"
|
||||
:label="`${SessionUser.functions.ucFirst(SessionUser.objects.orders.meta.labels.single)} #${order}`"
|
||||
icon="fas fa-file-invoice"
|
||||
v-bind:click-action="() => redirectDepartmentOrderPage(order)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-if="usage.WashItems && usage.WashItems.length > 0">
|
||||
<!-- Nested table for wash items -->
|
||||
<tr class="xlvask-usage-log-wash-items-row">
|
||||
<td colspan="100%">
|
||||
<details class="xlvask-usage-log-wash-items" data-testid="xlvask-usage-log-wash-items">
|
||||
<summary class="is-size-7">
|
||||
{{ t('tables.common.wash_item') }} ({{ usage.WashItems.length }})
|
||||
</summary>
|
||||
<table class="table is-fullwidth is-striped is-hoverable is-bordered mt-2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('tables.common.wash_item') }}</th>
|
||||
<th>{{ t('tables.common.count') }}</th>
|
||||
<th>{{ t('tables.common.price') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, itemIndex) in usage.WashItems" :key="itemIndex">
|
||||
<td>{{ getProductName(item) }}</td>
|
||||
<td>{{ item.Count }}</td>
|
||||
<td>{{ SessionUser.functions.currency.toLocal(Number(item.PriceIncVat ?? 0).toFixed(2)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.xlvask-usage-log-wash-items-row > td {
|
||||
background: rgba(10, 10, 10, 0.03);
|
||||
padding: 0.5rem 0.85rem;
|
||||
}
|
||||
|
||||
.xlvask-usage-log-wash-items > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xlvask-usage-log-wash-items[open] > summary {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -35,14 +35,14 @@ const fetchObjects = () => {
|
||||
console.error("XL-Vask: customer list returned non-OK status.", { status: response?.status });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.load_customers_failed'),
|
||||
title: t('invoicing_period.xlvask_review.errors.load_customers_failed'),
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error("XL-Vask: failed to load customers.", { error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.load_customers_failed'),
|
||||
title: t('invoicing_period.xlvask_review.errors.load_customers_failed'),
|
||||
});
|
||||
}).finally(() => {
|
||||
isLoading.value = false;
|
||||
|
||||
@@ -29,7 +29,7 @@ const fetchObjects = () => {
|
||||
if (!isoDateFrom) {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'),
|
||||
title: t('invoicing_period.xlvask_review.errors.load_usage_log_failed'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -43,14 +43,14 @@ const fetchObjects = () => {
|
||||
console.error("XL-Vask: usage log list returned non-OK status.", { status: response?.status });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'),
|
||||
title: t('invoicing_period.xlvask_review.errors.load_usage_log_failed'),
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error("XL-Vask: failed to load usage log list.", { error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'),
|
||||
title: t('invoicing_period.xlvask_review.errors.load_usage_log_failed'),
|
||||
});
|
||||
}).finally(() => {
|
||||
isLoading.value = false;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
|
||||
import { useWashProgress } from "@/composables/useWashProgress";
|
||||
import { useWashFlowState } from "@/composables/useWashFlowState";
|
||||
import { useWashSessionActions } from "@/composables/useWashSessionActions";
|
||||
import { useRedCarWarning } from "@/composables/useRedCarWarning.js";
|
||||
import { getSelfServeTaskDynamicImageButtons } from "@/services/selfServeDynamicImage.js";
|
||||
import type { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
||||
|
||||
@@ -860,6 +861,17 @@ const applyResolvedVehicleTypeSelection = () => {
|
||||
|
||||
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
|
||||
|
||||
const effectiveCustomerNumberForRedCar = computed(() => {
|
||||
const authenticated = getAuthenticatedCustomerNumber();
|
||||
if (authenticated) {
|
||||
return authenticated;
|
||||
}
|
||||
const typed = resolveEffectiveCustomerNumber(customerNumberInput.value);
|
||||
return typed ?? null;
|
||||
});
|
||||
|
||||
const { isRedCar: isRedCarCustomerFlag } = useRedCarWarning(effectiveCustomerNumberForRedCar);
|
||||
|
||||
const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
|
||||
|
||||
const canUseDepartmentHeaderSelection = computed(
|
||||
@@ -2383,6 +2395,7 @@ watch(
|
||||
:vehicle-types="vehicleTypes"
|
||||
:vehicle-step-error="vehicleStepError"
|
||||
:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"
|
||||
:is-red-car="isRedCarCustomerFlag"
|
||||
@update:customer-number="onUpdateCustomerNumber"
|
||||
@update:registration-number="onUpdateRegistrationNumber"
|
||||
@select-vehicle-type="onSelectVehicleType"
|
||||
|
||||
@@ -20,6 +20,7 @@ defineProps<{
|
||||
vehicleTypes: any[];
|
||||
vehicleStepError?: string | null;
|
||||
vehicleStepGuidance?: string | null;
|
||||
isRedCar?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -56,6 +57,7 @@ const emitVehicleTypeSelection = (selection: VehicleTypeSelection) => {
|
||||
:vehicle-types="vehicleTypes"
|
||||
:vehicle-step-error="vehicleStepError"
|
||||
:vehicle-step-guidance="vehicleStepGuidance"
|
||||
:is-red-car="isRedCar"
|
||||
@update:customer-number="emitCustomerNumber"
|
||||
@update:customerNumber="emitCustomerNumber"
|
||||
@update:registration-number="emitRegistrationNumber"
|
||||
|
||||
@@ -64,7 +64,7 @@ const buildOverviewPayload = ({
|
||||
{
|
||||
product_id: 26,
|
||||
slug: "hoejglans",
|
||||
title: "Højglans - Voksforsegling pr. enhed",
|
||||
title: "Voksforsegling pr. enhed",
|
||||
state: "ready",
|
||||
value: 4,
|
||||
out_of: 8,
|
||||
@@ -268,7 +268,7 @@ test.describe("Admin daily report", () => {
|
||||
await expect(page.getByTestId("daily-report-tile-extraordinary-10-min")).toContainText(
|
||||
"Ekstraordinær pr. 10 min inkl. kemi"
|
||||
);
|
||||
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Højglans - Voksforsegling pr. enhed");
|
||||
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Voksforsegling pr. enhed");
|
||||
await expect(page.getByTestId("daily-report-tile-undervognsskyl")).toContainText("Undervognsskyl pr. enhed");
|
||||
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
|
||||
});
|
||||
@@ -667,7 +667,7 @@ test.describe("Admin daily report", () => {
|
||||
await expect(page.getByTestId("daily-report-tile-extraordinary-10-min")).toContainText(
|
||||
"Ekstraordinær pr. 10 min inkl. kemi"
|
||||
);
|
||||
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Højglans - Voksforsegling pr. enhed");
|
||||
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Voksforsegling pr. enhed");
|
||||
await expect(page.getByTestId("daily-report-tile-undervognsskyl")).toContainText("Undervognsskyl pr. enhed");
|
||||
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
|
||||
});
|
||||
|
||||
@@ -814,4 +814,49 @@ test.describe("i18n v2 catalog integrity", () => {
|
||||
|
||||
expect(unexpectedGroups).toEqual([]);
|
||||
});
|
||||
|
||||
test("mirrors xlvask_review keys into the global v2 fallback", () => {
|
||||
const daSource = readJsonFile(
|
||||
path.join(SOURCE_DIRECTORY, "da", "phrases", "compat", "invoicing_period", "xlvask_review.json")
|
||||
);
|
||||
const globalShared = readJsonFile(
|
||||
path.join(SOURCE_DIRECTORY, "global", "shared", "invoicing_period", "xlvask_review.json")
|
||||
);
|
||||
|
||||
const daEntries = flattenStringEntries(getValueAtPath(daSource, "compat.invoicing_period.xlvask_review"));
|
||||
const globalEntries = flattenStringEntries(getValueAtPath(globalShared, "invoicing_period.xlvask_review"));
|
||||
|
||||
const daKeys = daEntries.map((entry) => entry.key).sort();
|
||||
const globalKeys = globalEntries.map((entry) => entry.key).sort();
|
||||
|
||||
expect(globalKeys, "global fallback should mirror every da xlvask_review key").toEqual(daKeys);
|
||||
|
||||
const nonLinked = globalEntries.filter((entry) => !entry.value.startsWith("@")).map((entry) => entry.key);
|
||||
|
||||
expect(nonLinked, "every global xlvask_review entry should be a linked reference").toEqual([]);
|
||||
});
|
||||
|
||||
test("mirrors xlvask_usage_log flag keys into the global v2 fallback", () => {
|
||||
const daSource = readJsonFile(
|
||||
path.join(SOURCE_DIRECTORY, "da", "phrases", "compat", "invoice_period", "flags.json")
|
||||
);
|
||||
const globalShared = readJsonFile(path.join(SOURCE_DIRECTORY, "global", "shared", "invoice_period", "flags.json"));
|
||||
|
||||
const daEntries = flattenStringEntries(getValueAtPath(daSource, "compat.invoice_period.flags")).filter(
|
||||
(entry) => entry.key.includes("xlvask_usage_log") || entry.key.includes("xlvask_missing_order_link")
|
||||
);
|
||||
const globalEntries = flattenStringEntries(getValueAtPath(globalShared, "invoice_period.flags")).filter(
|
||||
(entry) => entry.key.includes("xlvask_usage_log") || entry.key.includes("xlvask_missing_order_link")
|
||||
);
|
||||
|
||||
const daKeySet = new Set(daEntries.map((entry) => entry.key));
|
||||
const globalKeySet = new Set(globalEntries.map((entry) => entry.key));
|
||||
|
||||
const missing = [...daKeySet].filter((key) => !globalKeySet.has(key));
|
||||
expect(missing, "global flags fallback should mirror every da xlvask flag key").toEqual([]);
|
||||
|
||||
const nonLinked = globalEntries.filter((entry) => !entry.value.startsWith("@")).map((entry) => entry.key);
|
||||
|
||||
expect(nonLinked, "every mirrored global xlvask flag entry should be a linked reference").toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,6 +179,44 @@ function createPeriodPayload() {
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors the real backend's applyPeriodPagination: only the requested
|
||||
// `periodView` bucket keeps full customer cards; every other bucket is
|
||||
// replaced with lightweight `{customer_number, membership_only}`
|
||||
// memberships deduplicated by customer_number. This keeps the e2e mock
|
||||
// in lock-step with the live API contract so the chip-stacking
|
||||
// scenarios actually exercise the membership path.
|
||||
function projectPeriodMockPagedPayload(payload, periodView) {
|
||||
if (!payload || !payload.types || payload.__rawResponse) {
|
||||
return payload;
|
||||
}
|
||||
const activeView = periodView && payload.types[periodView] !== undefined ? periodView : "all";
|
||||
const types = payload.types;
|
||||
const projected = {};
|
||||
for (const [typeName, customers] of Object.entries(types)) {
|
||||
if (typeName === activeView) {
|
||||
projected[typeName] = Array.isArray(customers) ? customers.slice() : [];
|
||||
continue;
|
||||
}
|
||||
const seen = new Set();
|
||||
const memberships = [];
|
||||
if (Array.isArray(customers)) {
|
||||
for (const customer of customers) {
|
||||
if (!customer || typeof customer !== "object") continue;
|
||||
const customerNumber = Number(customer.customer_number);
|
||||
if (!Number.isInteger(customerNumber) || customerNumber < 1) continue;
|
||||
if (seen.has(customerNumber)) continue;
|
||||
seen.add(customerNumber);
|
||||
memberships.push({
|
||||
customer_number: customerNumber,
|
||||
membership_only: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
projected[typeName] = memberships;
|
||||
}
|
||||
return { ...payload, types: projected };
|
||||
}
|
||||
|
||||
function createObjectTreePeriodPayload({ dateFrom = "2026-07-14" } = {}) {
|
||||
const payload = createPeriodPayload();
|
||||
const fixtureDate = periodFixtureDate(dateFrom);
|
||||
@@ -905,6 +943,12 @@ async function setupPeriodEndpoints(page, requests, options = {}) {
|
||||
payload = createPeriodPayload();
|
||||
}
|
||||
|
||||
// Mirror the real backend contract: only the requested periodView
|
||||
// bucket carries full customer cards; every other bucket carries a
|
||||
// lightweight `{customer_number, membership_only}` membership so the
|
||||
// front-end can render category indicator chips on every subpage.
|
||||
payload = projectPeriodMockPagedPayload(payload, url.searchParams.get("periodView"));
|
||||
|
||||
await route.fulfill(
|
||||
json(
|
||||
payload?.__rawResponse ?? {
|
||||
@@ -1780,493 +1824,6 @@ test.describe("Invoicing period tab", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("@smoke period view opens Selvvask import and attaching view", async ({ page }, testInfo) => {
|
||||
const usageOrderRequests = [];
|
||||
const fastLinkRequests = [];
|
||||
const automationPreviewRequests = [];
|
||||
const automationApplyRequests = [];
|
||||
const adjudicationRequests = [];
|
||||
const importUsageRequests = [];
|
||||
let accepted = false;
|
||||
let actionHalted = false;
|
||||
let automation = {
|
||||
id: 7101,
|
||||
status: "suggested",
|
||||
action: "attach_order",
|
||||
confidence: 0.93,
|
||||
calibrated_probability: 0.995,
|
||||
source: "fuzzy",
|
||||
reason: "Samme primære produkt og relaterede tillæg matcher en ordre fra samme dag. Ordre #7001.",
|
||||
matched_order_id: 7001,
|
||||
created_order_id: null,
|
||||
candidate_order: {
|
||||
id: 7001,
|
||||
department_id: 1,
|
||||
created_at: "2026-03-10 12:03:00",
|
||||
},
|
||||
proposed_order: null,
|
||||
can_accept: true,
|
||||
can_deny: true,
|
||||
can_ignore: true,
|
||||
can_attach_order: true,
|
||||
can_create_order: true,
|
||||
review_eligible: true,
|
||||
evidence: ["Samme registrering og total"],
|
||||
contradictions: [],
|
||||
risk_flags: [],
|
||||
plan_steps: ["Kontrollér ordre #7001", "Tilknyt vasken atomisk"],
|
||||
candidate_orders: [{ order_id: 7001, reason: "Samme registrering og total" }],
|
||||
expected_version: "usage-v1",
|
||||
run_id: "run-history-1",
|
||||
policy_version: "policy-v1",
|
||||
model: "gpt-5.6-sol",
|
||||
};
|
||||
let autoAdjudication = {
|
||||
id: 7201,
|
||||
suggestion_id: 7201,
|
||||
status: "auto_accepted",
|
||||
action: "create_order",
|
||||
source: "openai",
|
||||
review_eligible: false,
|
||||
adjudication_eligible: true,
|
||||
allowed_adjudication_outcomes: ["correct", "incorrect", "duplicate", "cross_hall", "unaudited"],
|
||||
run_id: "run-auto-1",
|
||||
policy_version: "policy-v1",
|
||||
model: "gpt-5.6-sol",
|
||||
};
|
||||
|
||||
await openPeriodView(page);
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/automation/capabilities**", async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
can_view: true,
|
||||
can_review: true,
|
||||
can_dry_run: true,
|
||||
can_execute: !actionHalted,
|
||||
can_manage_policy: true,
|
||||
can_halt: true,
|
||||
effective_stage: actionHalted ? "halted" : "ai_attach_canary",
|
||||
allowed_modes: actionHalted ? ["dry_run"] : ["dry_run", "execute"],
|
||||
blocked_reasons: actionHalted ? ["automation_halted"] : [],
|
||||
effective_action_sources: ["deterministic", "openai"],
|
||||
readiness: {
|
||||
ready: !actionHalted,
|
||||
effective_stage: actionHalted ? "halted" : "ai_attach_canary",
|
||||
policy_version: "policy-v1",
|
||||
model: "gpt-5.6-sol",
|
||||
worker_healthy: true,
|
||||
budgets: {
|
||||
attach_order: { remaining_global: 100, remaining_hall: 10 },
|
||||
create_order: { remaining_global: 20, remaining_hall: 3 },
|
||||
},
|
||||
review_progress: {
|
||||
attach_order: { reviewed: actionHalted ? 41 : 40, target: 200 },
|
||||
create_order: { reviewed: 0, target: 50 },
|
||||
},
|
||||
eligible_counts: { attach_order: 1, create_order: 0, total: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/automation/admin/readiness**", async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
ready: !actionHalted,
|
||||
effective_stage: actionHalted ? "halted" : "ai_attach_canary",
|
||||
policy_version: "policy-v1",
|
||||
model: "gpt-5.6-sol",
|
||||
worker_healthy: true,
|
||||
blocked_reasons: actionHalted ? ["automation_halted"] : [],
|
||||
budgets: {
|
||||
attach_order: { remaining_global: 100, remaining_hall: 10 },
|
||||
create_order: { remaining_global: 20, remaining_hall: 3 },
|
||||
},
|
||||
review_progress: {
|
||||
attach_order: { reviewed: actionHalted ? 41 : 40, target: 200 },
|
||||
create_order: { reviewed: 0, target: 50 },
|
||||
},
|
||||
eligible_counts: { attach_order: 1, create_order: 0, total: 1 },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/autopilot-runs**", async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (route.request().method() === "GET" && path.endsWith("/autopilot-runs/active")) {
|
||||
await route.fulfill(json({ data: { run: null } }));
|
||||
return;
|
||||
}
|
||||
if (route.request().method() !== "POST" || !path.endsWith("/autopilot-runs")) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
importUsageRequests.push(JSON.parse(route.request().postData() || "{}"));
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
run: {
|
||||
id: "run-1",
|
||||
status: "completed",
|
||||
phase: "completed",
|
||||
processed: 2,
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/automation/decisions/**", async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
const body = JSON.parse(route.request().postData() || "{}");
|
||||
if (path.endsWith("/preview")) {
|
||||
automationPreviewRequests.push(body);
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
preview: {
|
||||
id: "preview-1",
|
||||
selection_hash: "selection-1",
|
||||
action: body.action,
|
||||
items: [{ usage_log_id: 8101, before: {}, after: { linked_order_id: 7001 }, warnings: [] }],
|
||||
requires_confirmation: true,
|
||||
confirmation_phrase: "CONFIRM XLVASK",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/apply")) {
|
||||
automationApplyRequests.push(body);
|
||||
accepted = true;
|
||||
automation = { ...automation, status: "accepted", can_accept: false, can_deny: false };
|
||||
await route.fulfill(json({ data: { applied: 1, results: [{ usage_log_id: 8101 }], failed: [] } }));
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/automation/admin/calibrations/labels**", async (route) => {
|
||||
const body = JSON.parse(route.request().postData() || "{}");
|
||||
adjudicationRequests.push(body);
|
||||
if (body.outcome === "correct") {
|
||||
autoAdjudication = {
|
||||
...autoAdjudication,
|
||||
id: 7202,
|
||||
suggestion_id: 7202,
|
||||
allowed_adjudication_outcomes: ["incorrect"],
|
||||
};
|
||||
await route.fulfill(json({ data: { automatic_action_review: { outcome: "correct" } } }));
|
||||
return;
|
||||
}
|
||||
actionHalted = true;
|
||||
autoAdjudication = {
|
||||
...autoAdjudication,
|
||||
adjudication_eligible: false,
|
||||
allowed_adjudication_outcomes: [],
|
||||
};
|
||||
await route.fulfill(json({ data: { action_halted: true, automatic_action_review: { outcome: body.outcome } } }));
|
||||
});
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/orders**", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesApiPath(route.request().url(), "/modules/xlvask/services/usage/orders/fast-link")) {
|
||||
fastLinkRequests.push(route.request().url());
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
order_items: [
|
||||
{
|
||||
id: 9101,
|
||||
product_id: 301,
|
||||
product: { name: "Kassevogn/varevogn" },
|
||||
quantity: 1,
|
||||
price: 125,
|
||||
},
|
||||
],
|
||||
potential_duplicates: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesApiPath(route.request().url(), "/modules/xlvask/services/usage/orders/summary")) {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
summary: {
|
||||
total: 3,
|
||||
new: 1,
|
||||
updated: 0,
|
||||
unchanged: 2,
|
||||
invalid: 0,
|
||||
already_linked: accepted ? 1 : 0,
|
||||
auto_linked: 0,
|
||||
auto_created: 1,
|
||||
needs_review: accepted ? 0 : 1,
|
||||
blocked: 0,
|
||||
ignored: 0,
|
||||
failed: 1,
|
||||
certain: accepted ? 2 : 1,
|
||||
uncertain: accepted ? 0 : 1,
|
||||
none: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!matchesApiPath(route.request().url(), "/modules/xlvask/services/usage/orders")) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(route.request().url());
|
||||
usageOrderRequests.push({
|
||||
filters: url.searchParams.get("filters") || "",
|
||||
limit: url.searchParams.get("limit") || "",
|
||||
});
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: 8101,
|
||||
reg_1: "AB12345",
|
||||
created_at: "2026-03-10 12:00:00",
|
||||
customer_id: 4001,
|
||||
customer_number: 4001,
|
||||
customer_name: "DEKRA AMU Center Hovedstaden A/S",
|
||||
department_id: 1,
|
||||
lane: 1,
|
||||
wash_id: "wash-selvvask-1",
|
||||
duplicates: [],
|
||||
fast_link_key: "temporary_cache_selfwash8101",
|
||||
total_net_amount: 125,
|
||||
xlvask_primary_product_name: "Kassevogn/varevogn",
|
||||
import_state: "new",
|
||||
resolution_state: accepted ? "already_linked" : "needs_review",
|
||||
certainty: accepted ? "certain" : "uncertain",
|
||||
planned_action: accepted ? "none" : "attach_order",
|
||||
automation,
|
||||
},
|
||||
{
|
||||
id: 8102,
|
||||
reg_1: "CD67890",
|
||||
created_at: "2026-03-10 12:15:00",
|
||||
customer_id: 4002,
|
||||
customer_number: 4002,
|
||||
customer_name: "Self Wash Transport",
|
||||
department_id: 2,
|
||||
lane: 2,
|
||||
wash_id: "wash-selvvask-2",
|
||||
duplicates: [],
|
||||
fast_link_key: null,
|
||||
total_net_amount: 88,
|
||||
xlvask_primary_product_name: "Varebil",
|
||||
import_state: "unchanged",
|
||||
resolution_state: "failed",
|
||||
certainty: "none",
|
||||
planned_action: "none",
|
||||
automation: {
|
||||
id: 7102,
|
||||
status: "failed",
|
||||
action: "attach_order",
|
||||
error: "No safe match",
|
||||
can_accept: false,
|
||||
can_deny: false,
|
||||
can_ignore: false,
|
||||
can_attach_order: false,
|
||||
can_create_order: false,
|
||||
review_eligible: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 8103,
|
||||
reg_1: "EF24680",
|
||||
created_at: "2026-03-10 12:30:00",
|
||||
customer_id: 4003,
|
||||
customer_number: 4003,
|
||||
customer_name: "Automatic Action Transport",
|
||||
department_id: 1,
|
||||
lane: 1,
|
||||
wash_id: "wash-selvvask-3",
|
||||
duplicates: [],
|
||||
fast_link_key: null,
|
||||
total_net_amount: 150,
|
||||
xlvask_primary_product_name: "Lastbil",
|
||||
import_state: "unchanged",
|
||||
resolution_state: "auto_created",
|
||||
certainty: "certain",
|
||||
planned_action: "none",
|
||||
automation: autoAdjudication,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
total: 3,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await setEntireMarchPeriod(page);
|
||||
|
||||
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toBeVisible();
|
||||
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (1/3)");
|
||||
const selfWashProgress = page.getByTestId("invoicing-period-view-selector-progress-self_wash");
|
||||
const selfWashProgressMetrics = await selfWashProgress.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const slot = element.parentElement?.getBoundingClientRect();
|
||||
const controls = element.parentElement?.parentElement?.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
slotWidth: slot?.width || 0,
|
||||
controlsWidth: controls?.width || 0,
|
||||
};
|
||||
});
|
||||
expect(
|
||||
selfWashProgressMetrics.width,
|
||||
`self-wash selector progress geometry: ${JSON.stringify(selfWashProgressMetrics)}`
|
||||
).toBeGreaterThanOrEqual(56);
|
||||
expect(selfWashProgressMetrics.height).toBeGreaterThan(0);
|
||||
await expect(selfWashProgress).toBeVisible();
|
||||
|
||||
await page.getByTestId("invoicing-period-view-selector-self_wash").click();
|
||||
await expect(page.getByTestId("invoicing-period-self-wash-view")).toBeVisible();
|
||||
const selfWashView = page.getByTestId("invoicing-period-self-wash-view");
|
||||
await expect(selfWashView.getByTestId("date-period-start")).toHaveCount(0);
|
||||
await expect(selfWashView.locator("input[placeholder='Søg i transaktioner']")).toHaveCount(0);
|
||||
await expect(selfWashView.getByText("Per side")).toHaveCount(0);
|
||||
await expect(selfWashView.getByText("Rækkefølge")).toHaveCount(0);
|
||||
await expect(selfWashView.getByText("Dato fra")).toHaveCount(0);
|
||||
await expect(selfWashView.getByText("Dato til")).toHaveCount(0);
|
||||
await expect(selfWashView.getByText("Vis kun ikke tilknyttede vaske")).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: /Selvvask/ })).toBeVisible();
|
||||
await expect(page.getByText("AB12345")).toBeVisible();
|
||||
await expect(page.getByText("CD67890")).toBeVisible();
|
||||
if (await captureXlvaskVisualEvidence(page, testInfo)) {
|
||||
return;
|
||||
}
|
||||
await expect(page.getByTestId("xlvask-summary-new")).toContainText("1");
|
||||
await expect(page.getByTestId("xlvask-summary-unchanged")).toContainText("2");
|
||||
await expect(page.getByTestId("xlvask-summary-uncertain")).toContainText("1");
|
||||
await expect(page.getByTestId("xlvask-summary-failed")).toContainText("1");
|
||||
await expect(page.getByTestId("xlvask-automation-controls")).toBeVisible();
|
||||
await expect(page.getByTestId("xlvask-automation-readiness")).toContainText("policy-v1");
|
||||
await expect(selfWashView.locator(".xlvask-usage-price").first()).toContainText("125");
|
||||
await expect(page.getByTestId("xlvask-resolution-state-8102")).toContainText("Mislykket");
|
||||
await expect(page.getByTestId("xlvask-certainty-8102")).toContainText("Ikke vurderet");
|
||||
expect(fastLinkRequests).toHaveLength(0);
|
||||
await expect(page.getByTestId("xlvask-automation-suggestion-8101")).toContainText("Foreslået: Tilknyt ordre #7001");
|
||||
await expect(selfWashView.locator(".xlvask-usage-card-header").first()).toBeVisible();
|
||||
|
||||
await page.getByText("EF24680").click();
|
||||
await page.getByTestId("xlvask-adjudication-correct-8103").click();
|
||||
await expect(page.getByRole("heading", { name: "Kontrollér det automatiske resultat" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Korrekt" }).click();
|
||||
await expect.poll(() => adjudicationRequests.length).toBe(1);
|
||||
expect(adjudicationRequests[0]).toEqual({ suggestion_id: 7201, outcome: "correct" });
|
||||
await expect(page.locator(".swal2-popup")).toHaveCount(0, { timeout: 4000 });
|
||||
|
||||
await selfWashView.getByRole("checkbox", { name: "Vælg vask #8101" }).check();
|
||||
await expect(page.getByTestId("xlvask-autopilot-bulk-bar")).toContainText("1 vaske valgt");
|
||||
await page.getByTestId("xlvask-autopilot-bulk-bar").getByRole("button", { name: "Ryd valg" }).click();
|
||||
await expect(page.getByTestId("xlvask-autopilot-bulk-bar")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
|
||||
.toBeLessThanOrEqual(1);
|
||||
|
||||
await page.getByText("AB12345").click();
|
||||
await expect(page.getByTestId("xlvask-automation-evidence-8101")).toContainText("Samme registrering og total");
|
||||
await expect(page.getByTestId("xlvask-automation-candidates-8101")).toContainText("#7001");
|
||||
await expect(page.getByTestId("xlvask-automation-accept-8101")).toBeVisible();
|
||||
await page.getByTestId("xlvask-automation-accept-8101").click();
|
||||
await expect(page.getByRole("heading", { name: "Kontrollér ændringen" })).toBeVisible();
|
||||
await page.locator(".swal2-input").fill("CONFIRM XLVASK");
|
||||
await page.getByRole("button", { name: "Udfør" }).click();
|
||||
await expect(page.getByTestId("xlvask-automation-suggestion-8101")).toContainText("Accepteret #7001");
|
||||
expect(automationPreviewRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
usage_log_ids: [8101],
|
||||
action: "accept",
|
||||
suggestion_id: 7101,
|
||||
}),
|
||||
]);
|
||||
expect(automationApplyRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
preview_id: "preview-1",
|
||||
selection_hash: "selection-1",
|
||||
confirmation_text: "CONFIRM XLVASK",
|
||||
}),
|
||||
]);
|
||||
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (2/3)");
|
||||
|
||||
await expect.poll(() => usageOrderRequests.length).toBeGreaterThan(0);
|
||||
expect(usageOrderRequests.some((request) => request.limit === "10000")).toBe(false);
|
||||
expect(
|
||||
usageOrderRequests.some(
|
||||
(request) =>
|
||||
request.filters.includes("StartTime-date_from:2026-03-01") &&
|
||||
request.filters.includes("StartTime-date_to:2026-03-31")
|
||||
)
|
||||
).toBe(true);
|
||||
await selfWashView.getByTestId("xlvask-autopilot-dry-run").click();
|
||||
await expect.poll(() => importUsageRequests.length).toBe(1);
|
||||
expect(importUsageRequests[0]).toMatchObject({
|
||||
dateFrom: "2026-03-01",
|
||||
dateTo: "2026-03-31",
|
||||
mode: "dry_run",
|
||||
forceRefetch: true,
|
||||
});
|
||||
expect(importUsageRequests[0].idempotency_key).toEqual(expect.any(String));
|
||||
|
||||
await selfWashView.getByTestId("xlvask-autopilot-execute").click();
|
||||
const executeDialog = page.locator(".swal2-popup");
|
||||
await expect(executeDialog).toContainText("2026-03-01");
|
||||
await expect(executeDialog).toContainText("deterministic, openai");
|
||||
await expect(executeDialog).toContainText("100 globalt; laveste restgrænse blandt haller 10");
|
||||
await expect(executeDialog).toContainText("20 globalt; laveste restgrænse blandt haller 3");
|
||||
await executeDialog.locator(".swal2-input").fill("KØR AUTOMATIK");
|
||||
await executeDialog.getByRole("button", { name: "Kør automatiske handlinger" }).click();
|
||||
await expect.poll(() => importUsageRequests.length).toBe(2);
|
||||
expect(importUsageRequests[1]).toMatchObject({ mode: "execute", forceRefetch: true });
|
||||
expect(importUsageRequests[1].idempotency_key).toEqual(expect.any(String));
|
||||
expect(importUsageRequests[1].idempotency_key).not.toBe(importUsageRequests[0].idempotency_key);
|
||||
|
||||
const incorrectAdjudication = page.getByTestId("xlvask-adjudication-incorrect-8103");
|
||||
if (!(await incorrectAdjudication.isVisible())) {
|
||||
await page.getByText("EF24680").click();
|
||||
}
|
||||
await incorrectAdjudication.click();
|
||||
await expect(page.getByRole("heading", { name: "Kontrollér det automatiske resultat" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Forkert" }).click();
|
||||
await expect.poll(() => adjudicationRequests.length).toBe(2);
|
||||
expect(adjudicationRequests[1]).toEqual({ suggestion_id: 7202, outcome: "incorrect" });
|
||||
await expect(page.getByRole("heading", { name: "Resultatet er gemt, og automatikken er stoppet" })).toBeVisible();
|
||||
await expect(page.locator(".swal2-popup")).toHaveCount(0, { timeout: 4000 });
|
||||
await expect(selfWashView.getByTestId("xlvask-autopilot-execute")).toHaveCount(0);
|
||||
await expect(page.getByTestId("xlvask-automation-readiness")).toContainText("Ikke klar");
|
||||
expect(importUsageRequests.filter((request) => request.mode === "execute")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("@smoke period view shows flags and saves automatic flag decisions", async ({ page }) => {
|
||||
const automaticStatusRequests = [];
|
||||
const manualStatusRequests = [];
|
||||
@@ -2947,6 +2504,47 @@ test.describe("Invoicing period tab", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("@smoke period customer attribute chips render on every subpage including Alle", async ({ page }) => {
|
||||
// Pin down the regression: customer indicator chips (e.g. "Faktura pr.
|
||||
// ordre") were previously invisible on the Alle tab because the backend
|
||||
// only returned full customer data for the active view bucket. The
|
||||
// backend now projects lightweight `{customer_number, membership_only}`
|
||||
// entries on every non-active bucket so the front-end can resolve
|
||||
// category membership regardless of the active view.
|
||||
await openPeriodView(page, { payloadFactory: createAttributeStackPeriodPayload });
|
||||
|
||||
await page.getByTestId("invoicing-period-view-selector-all").click();
|
||||
await expect(page).toHaveURL(/periodView=all/);
|
||||
|
||||
const multiAttributeCard = page.getByTestId("invoicing-period-customer-4101");
|
||||
await expect(multiAttributeCard).toBeVisible();
|
||||
|
||||
const multiAttributeStack = multiAttributeCard.locator('[data-testid="invoicing-period-customer-attributes-4101"]');
|
||||
await expect(multiAttributeStack).toBeVisible();
|
||||
|
||||
for (const viewKey of ["invoice_per_order", "fixed_pricing", "tank_cleaning"]) {
|
||||
const chip = page.getByTestId(`invoicing-period-customer-attribute-4101-${viewKey}`);
|
||||
await expect(chip, `expected chip "${viewKey}" on the Alle tab`).toBeVisible();
|
||||
const chipText = (await chip.textContent())?.trim() ?? "";
|
||||
expect(chipText, `chip ${viewKey} carries a non-empty label`).not.toBe("");
|
||||
}
|
||||
|
||||
// The single-attribute customer should still show exactly one chip
|
||||
// (matches the "fixed_pricing" bucket in the fixture).
|
||||
const singleAttributeCard = page.getByTestId("invoicing-period-customer-4102");
|
||||
await expect(singleAttributeCard).toBeVisible();
|
||||
const singleStack = singleAttributeCard.locator('[data-testid="invoicing-period-customer-attributes-4102"]');
|
||||
await expect(singleStack).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("invoicing-period-customer-attribute-4102-fixed_pricing"),
|
||||
"single-category customer renders its fixed_pricing chip on the Alle tab"
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("invoicing-period-customer-attribute-4102-invoice_per_order"),
|
||||
"single-category customer does not render an invoice_per_order chip"
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("@smoke period view selector switch updates visible customer set", async ({ page }) => {
|
||||
await openPeriodView(page);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user