Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d5ec8894c | ||
|
|
f2fc7643a2 | ||
|
|
06b6498f7a | ||
|
|
c5c186242b | ||
|
|
3112a4f985 | ||
|
|
ddd31bd2dd | ||
|
|
f07c15972c | ||
|
|
210d7d051d | ||
|
|
0d2098449a | ||
|
|
cafe671e85 | ||
|
|
2401ebb674 | ||
|
|
6f39eb897c | ||
|
|
670746d70c | ||
|
|
a345d91ae4 | ||
|
|
19fbbcddce | ||
|
|
7817f0c13b | ||
|
|
c19aeffb98 | ||
|
|
2e1d7f3e8f | ||
|
|
7c9eb8b644 |
@@ -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"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
ruby ">= 3.2", "< 3.5"
|
ruby ">= 3.2", "< 3.5"
|
||||||
gem "fastlane", "2.237.0"
|
gem "fastlane", "2.238.0"
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ GEM
|
|||||||
artifactory (3.0.17)
|
artifactory (3.0.17)
|
||||||
atomos (0.1.3)
|
atomos (0.1.3)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1271.0)
|
aws-partitions (1.1281.0)
|
||||||
aws-sdk-core (3.254.0)
|
aws-sdk-core (3.254.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
aws-sigv4 (~> 1.9)
|
aws-sigv4 (~> 1.9)
|
||||||
@@ -20,8 +20,8 @@ GEM
|
|||||||
aws-sdk-kms (1.130.0)
|
aws-sdk-kms (1.130.0)
|
||||||
aws-sdk-core (~> 3, >= 3.254.0)
|
aws-sdk-core (~> 3, >= 3.254.0)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sdk-s3 (1.228.0)
|
aws-sdk-s3 (1.229.0)
|
||||||
aws-sdk-core (~> 3, >= 3.254.0)
|
aws-sdk-core (~> 3, >= 3.254.1)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.5)
|
aws-sigv4 (~> 1.5)
|
||||||
aws-sigv4 (1.12.1)
|
aws-sigv4 (1.12.1)
|
||||||
@@ -35,45 +35,33 @@ GEM
|
|||||||
colored2 (3.1.2)
|
colored2 (3.1.2)
|
||||||
commander (4.6.0)
|
commander (4.6.0)
|
||||||
highline (~> 2.0.0)
|
highline (~> 2.0.0)
|
||||||
csv (3.3.5)
|
csv (3.3.6)
|
||||||
declarative (0.0.20)
|
declarative (0.0.20)
|
||||||
digest-crc (0.7.0)
|
digest-crc (0.7.0)
|
||||||
rake (>= 12.0.0, < 14.0.0)
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
domain_name (0.6.20240107)
|
domain_name (0.6.20240107)
|
||||||
dotenv (2.8.1)
|
dotenv (2.8.1)
|
||||||
emoji_regex (3.2.3)
|
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
|
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-cookie_jar (0.0.8)
|
||||||
faraday (>= 0.8.0)
|
faraday (>= 0.8.0)
|
||||||
http-cookie (>= 1.0.0)
|
http-cookie (>= 1.0.0)
|
||||||
faraday-em_http (1.0.0)
|
faraday-follow_redirects (0.5.0)
|
||||||
faraday-em_synchrony (1.0.1)
|
faraday (>= 1, < 3)
|
||||||
faraday-excon (1.1.0)
|
|
||||||
faraday-httpclient (1.0.1)
|
|
||||||
faraday-multipart (1.2.0)
|
faraday-multipart (1.2.0)
|
||||||
multipart-post (~> 2.0)
|
multipart-post (~> 2.0)
|
||||||
faraday-net_http (1.0.2)
|
faraday-net_http (3.4.4)
|
||||||
faraday-net_http_persistent (1.2.0)
|
net-http (~> 0.5)
|
||||||
faraday-patron (1.0.0)
|
faraday-retry (2.4.0)
|
||||||
faraday-rack (1.0.0)
|
faraday (~> 2.0)
|
||||||
faraday-retry (1.0.4)
|
|
||||||
faraday_middleware (1.2.1)
|
|
||||||
faraday (~> 1.0)
|
|
||||||
fastimage (2.4.1)
|
fastimage (2.4.1)
|
||||||
fastlane (2.237.0)
|
fastlane (2.238.0)
|
||||||
CFPropertyList (>= 2.3, < 5.0.0)
|
CFPropertyList (>= 2.3, < 5.0.0)
|
||||||
abbrev (~> 0.1)
|
abbrev (~> 0.1)
|
||||||
addressable (>= 2.9.0, < 3.0.0)
|
addressable (>= 2.9.0, < 3.0.0)
|
||||||
@@ -89,9 +77,11 @@ GEM
|
|||||||
dotenv (>= 2.1.1, < 3.0.0)
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
emoji_regex (>= 0.1, < 4.0)
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
excon (>= 0.71.0, < 2.0.0)
|
excon (>= 0.71.0, < 2.0.0)
|
||||||
faraday (~> 1.0)
|
faraday (~> 2.7)
|
||||||
faraday-cookie_jar (~> 0.0.6)
|
faraday-cookie_jar (~> 0.0.8)
|
||||||
faraday_middleware (~> 1.0)
|
faraday-follow_redirects (~> 0.3)
|
||||||
|
faraday-multipart (~> 1.0)
|
||||||
|
faraday-retry (~> 2.0)
|
||||||
fastimage (>= 2.1.0, < 3.0.0)
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
fastlane-sirp (>= 1.1.0)
|
fastlane-sirp (>= 1.1.0)
|
||||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
@@ -101,6 +91,7 @@ GEM
|
|||||||
google-cloud-storage (~> 1.31)
|
google-cloud-storage (~> 1.31)
|
||||||
highline (~> 2.0)
|
highline (~> 2.0)
|
||||||
http-cookie (~> 1.0.5)
|
http-cookie (~> 1.0.5)
|
||||||
|
irb (>= 1.8)
|
||||||
json (< 3.0.0)
|
json (< 3.0.0)
|
||||||
jwt (>= 2.10.3, < 4)
|
jwt (>= 2.10.3, < 4)
|
||||||
logger (>= 1.6, < 2.0)
|
logger (>= 1.6, < 2.0)
|
||||||
@@ -117,7 +108,7 @@ GEM
|
|||||||
security (= 0.1.5)
|
security (= 0.1.5)
|
||||||
simctl (~> 1.6.3)
|
simctl (~> 1.6.3)
|
||||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||||
terminal-table (~> 3)
|
terminal-table (~> 4)
|
||||||
tty-screen (>= 0.6.3, < 1.0.0)
|
tty-screen (>= 0.6.3, < 1.0.0)
|
||||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
word_wrap (~> 1.0.0)
|
word_wrap (~> 1.0.0)
|
||||||
@@ -126,21 +117,22 @@ GEM
|
|||||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||||
fastlane-sirp (1.1.0)
|
fastlane-sirp (1.1.0)
|
||||||
gh_inspector (1.1.3)
|
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.15.0, < 2.a)
|
||||||
google-apis-core (0.18.0)
|
google-apis-core (1.2.5)
|
||||||
addressable (~> 2.5, >= 2.5.1)
|
addressable (~> 2.9)
|
||||||
googleauth (~> 1.9)
|
faraday (~> 2.13)
|
||||||
httpclient (>= 2.8.3, < 3.a)
|
faraday-follow_redirects (~> 0.3)
|
||||||
mini_mime (~> 1.0)
|
googleauth (~> 1.14)
|
||||||
mutex_m
|
mini_mime (~> 1.1)
|
||||||
|
multi_json (~> 1.11)
|
||||||
representable (~> 3.0)
|
representable (~> 3.0)
|
||||||
retriable (>= 2.0, < 4.a)
|
retriable (>= 3.1, < 5.0)
|
||||||
google-apis-iamcredentials_v1 (0.28.0)
|
google-apis-iamcredentials_v1 (0.28.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
google-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-apis-playcustomapp_v1 (0.18.0)
|
google-apis-playcustomapp_v1 (0.18.0)
|
||||||
google-apis-core (>= 0.15.0, < 2.a)
|
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-apis-core (>= 0.15.0, < 2.a)
|
||||||
google-cloud-core (1.9.0)
|
google-cloud-core (1.9.0)
|
||||||
google-cloud-env (>= 1.0, < 3.a)
|
google-cloud-env (>= 1.0, < 3.a)
|
||||||
@@ -159,7 +151,7 @@ GEM
|
|||||||
googleauth (~> 1.9)
|
googleauth (~> 1.9)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
google-logging-utils (0.2.0)
|
google-logging-utils (0.2.0)
|
||||||
googleauth (1.17.1)
|
googleauth (1.17.3)
|
||||||
faraday (>= 1.0, < 3.a)
|
faraday (>= 1.0, < 3.a)
|
||||||
google-cloud-env (~> 2.2)
|
google-cloud-env (~> 2.2)
|
||||||
google-logging-utils (~> 0.1)
|
google-logging-utils (~> 0.1)
|
||||||
@@ -170,10 +162,14 @@ GEM
|
|||||||
highline (2.0.3)
|
highline (2.0.3)
|
||||||
http-cookie (1.0.8)
|
http-cookie (1.0.8)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
httpclient (2.9.0)
|
io-console (0.9.2)
|
||||||
mutex_m
|
irb (1.18.0)
|
||||||
|
pp (>= 0.6.0)
|
||||||
|
prism (>= 1.3.0)
|
||||||
|
rdoc (>= 4.0.0)
|
||||||
|
reline (>= 0.4.2)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.21.1)
|
json (2.21.2)
|
||||||
jwt (3.2.0)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
@@ -184,22 +180,38 @@ GEM
|
|||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
nanaimo (0.4.0)
|
nanaimo (0.4.0)
|
||||||
naturally (2.3.0)
|
naturally (2.3.0)
|
||||||
|
net-http (0.9.1)
|
||||||
|
uri (>= 0.11.1)
|
||||||
nkf (0.3.0)
|
nkf (0.3.0)
|
||||||
optparse (0.8.1)
|
optparse (0.8.1)
|
||||||
os (1.1.4)
|
os (1.1.4)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
plist (3.7.2)
|
plist (3.7.2)
|
||||||
|
pp (0.6.4)
|
||||||
|
prettyprint
|
||||||
|
prettyprint (0.2.0)
|
||||||
|
prism (1.9.0)
|
||||||
pstore (0.2.1)
|
pstore (0.2.1)
|
||||||
public_suffix (7.0.5)
|
public_suffix (7.0.5)
|
||||||
rake (13.4.2)
|
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)
|
representable (3.2.0)
|
||||||
declarative (< 0.1.0)
|
declarative (< 0.1.0)
|
||||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
uber (< 0.2.0)
|
uber (< 0.2.0)
|
||||||
retriable (3.8.0)
|
retriable (4.2.0)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rouge (3.28.0)
|
rouge (3.28.0)
|
||||||
ruby2_keywords (0.0.5)
|
|
||||||
rubyzip (2.4.1)
|
rubyzip (2.4.1)
|
||||||
security (0.1.5)
|
security (0.1.5)
|
||||||
signet (0.22.0)
|
signet (0.22.0)
|
||||||
@@ -210,15 +222,19 @@ GEM
|
|||||||
CFPropertyList
|
CFPropertyList
|
||||||
naturally
|
naturally
|
||||||
terminal-notifier (2.0.0)
|
terminal-notifier (2.0.0)
|
||||||
terminal-table (3.0.2)
|
terminal-table (4.0.0)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 4)
|
||||||
trailblazer-option (0.1.2)
|
trailblazer-option (0.1.2)
|
||||||
|
tsort (0.2.0)
|
||||||
tty-cursor (0.7.1)
|
tty-cursor (0.7.1)
|
||||||
tty-screen (0.8.2)
|
tty-screen (0.8.2)
|
||||||
tty-spinner (0.9.3)
|
tty-spinner (0.9.3)
|
||||||
tty-cursor (~> 0.7)
|
tty-cursor (~> 0.7)
|
||||||
uber (0.1.0)
|
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)
|
word_wrap (1.0.0)
|
||||||
xcodeproj (1.28.1)
|
xcodeproj (1.28.1)
|
||||||
CFPropertyList (>= 2.3.3, < 4.0)
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
@@ -239,7 +255,7 @@ PLATFORMS
|
|||||||
x86_64-linux
|
x86_64-linux
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
fastlane (= 2.237.0)
|
fastlane (= 2.238.0)
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
ruby 3.3.12p206
|
ruby 3.3.12p206
|
||||||
|
|||||||
@@ -45,11 +45,12 @@ platform :ios do
|
|||||||
skip_screenshots: false,
|
skip_screenshots: false,
|
||||||
overwrite_screenshots: true,
|
overwrite_screenshots: true,
|
||||||
force: true,
|
force: true,
|
||||||
submit_for_review: false,
|
submit_for_review: true,
|
||||||
automatic_release: true,
|
automatic_release: true,
|
||||||
phased_release: false,
|
phased_release: false,
|
||||||
run_precheck_before_submit: false,
|
run_precheck_before_submit: false,
|
||||||
precheck_include_in_app_purchases: false
|
precheck_include_in_app_purchases: false,
|
||||||
|
ignore_language_directory_validation: true
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"marketingVersion": "1.0.1",
|
"marketingVersion": "1.1.0",
|
||||||
"bundleId": "io.truckwash.app",
|
"bundleId": "io.truckwash.app",
|
||||||
"minimumIosVersion": "15.0"
|
"minimumIosVersion": "15.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// AUT-1 smoke run — capture the before/after visual diff for the
|
||||||
|
// `primary-button hover state` change in `src/assets/main.css`.
|
||||||
|
//
|
||||||
|
// Renders tests/visual-previews/AUT-1/preview.html in a real Chromium
|
||||||
|
// against the project's bundled Bulma stylesheet, then takes two
|
||||||
|
// screenshots:
|
||||||
|
//
|
||||||
|
// desktop-before.png — Bulma defaults, button idle (no hover effect).
|
||||||
|
// desktop-after.png — With the AUT-1 CSS rules applied, button
|
||||||
|
// hovered (lift + brightness shift visible).
|
||||||
|
// mobile-before.png — Same as desktop-before, captured at the
|
||||||
|
// Pixel 5 viewport.
|
||||||
|
// mobile-after.png — Same as desktop-after, captured at the
|
||||||
|
// Pixel 5 viewport.
|
||||||
|
//
|
||||||
|
// Output is written to tests/visual-previews/AUT-1/. The script is
|
||||||
|
// idempotent: existing files are overwritten, not appended to.
|
||||||
|
//
|
||||||
|
// Invoke with: `node scripts/aut-1-capture-hover-preview.mjs`
|
||||||
|
// (Chromium must already be installed via `npm run test:e2e:install`.)
|
||||||
|
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "..");
|
||||||
|
const PREVIEW_HTML = path.join(
|
||||||
|
REPO_ROOT,
|
||||||
|
"tests/visual-previews/AUT-1/preview.html",
|
||||||
|
);
|
||||||
|
const OUTPUT_DIR = path.join(REPO_ROOT, "tests/visual-previews/AUT-1");
|
||||||
|
|
||||||
|
// The AUT-1 hover rules — kept in lockstep with the diff in
|
||||||
|
// src/assets/main.css. We inject these via a <style> tag on the
|
||||||
|
// "after" pass and leave them off for the "before" pass so the
|
||||||
|
// screenshots show the same Bulma theme but with vs without the new
|
||||||
|
// hover effect.
|
||||||
|
const AUT1_HOVER_CSS = `
|
||||||
|
.button.is-primary {
|
||||||
|
transition:
|
||||||
|
filter 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.button.is-primary:hover,
|
||||||
|
.button.is-primary.is-hovered {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.button.is-primary:active,
|
||||||
|
.button.is-primary.is-active {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.button.is-primary:focus-visible {
|
||||||
|
filter: brightness(1.04);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function captureVariant({ browser, viewport, label, withHoverCss, hover }) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport,
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(pathToFileUrl(PREVIEW_HTML));
|
||||||
|
if (withHoverCss) {
|
||||||
|
await page.addStyleTag({ content: AUT1_HOVER_CSS });
|
||||||
|
}
|
||||||
|
const target = page.locator(
|
||||||
|
hover
|
||||||
|
? '[data-testid="primary-button-hover"]'
|
||||||
|
: '[data-testid="primary-button-idle"]',
|
||||||
|
);
|
||||||
|
const stage = page.locator(
|
||||||
|
hover ? "#stage-hover" : "#stage-idle",
|
||||||
|
);
|
||||||
|
if (hover) {
|
||||||
|
await target.hover();
|
||||||
|
// Wait for the 0.15s transition to settle.
|
||||||
|
await page.waitForTimeout(220);
|
||||||
|
}
|
||||||
|
const filename = `${label}-${withHoverCss ? "after" : "before"}.png`;
|
||||||
|
const destination = path.join(OUTPUT_DIR, filename);
|
||||||
|
await stage.screenshot({ path: destination, type: "png" });
|
||||||
|
await context.close();
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToFileUrl(filePath) {
|
||||||
|
// Playwright's `file://` URLs need absolute paths. On POSIX this is
|
||||||
|
// straightforward; on Windows this helper keeps the script cross-platform
|
||||||
|
// should it ever run there.
|
||||||
|
const absolute = path.resolve(filePath);
|
||||||
|
return absolute.startsWith("/") ? `file://${absolute}` : `file:///${absolute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const viewports = [
|
||||||
|
{ label: "desktop", viewport: { width: 1280, height: 720 } },
|
||||||
|
{
|
||||||
|
label: "mobile",
|
||||||
|
viewport: { width: 393, height: 851 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const { label, viewport } of viewports) {
|
||||||
|
for (const withHoverCss of [false, true]) {
|
||||||
|
for (const hover of [false, true]) {
|
||||||
|
// We only want idle on the "before" pass and hover on the
|
||||||
|
// "after" pass — skip the two redundant combinations.
|
||||||
|
const isIdleShot = !hover;
|
||||||
|
const isBeforeShot = !withHoverCss;
|
||||||
|
if (isIdleShot !== isBeforeShot) continue;
|
||||||
|
const file = await captureVariant({
|
||||||
|
browser,
|
||||||
|
viewport,
|
||||||
|
label,
|
||||||
|
withHoverCss,
|
||||||
|
hover,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("wrote", path.relative(REPO_ROOT, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("aut-1 capture failed:", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -7,8 +7,11 @@ const strict = argv.includes("--strict");
|
|||||||
const failures = [];
|
const failures = [];
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
const metadataRoot = join(root, "fastlane/metadata/da-DK");
|
// App Store Connect uses the bare `da` locale code for Danish (not `da-DK`).
|
||||||
const screenshotRoot = join(root, "fastlane/screenshots/da-DK");
|
// Keep these paths in sync with fastlane/metadata/<locale>/ and
|
||||||
|
// fastlane/screenshots/<locale>/ after any locale rename.
|
||||||
|
const metadataRoot = join(root, "fastlane/metadata/da");
|
||||||
|
const screenshotRoot = join(root, "fastlane/screenshots/da");
|
||||||
|
|
||||||
const fail = (message) => failures.push(message);
|
const fail = (message) => failures.push(message);
|
||||||
const warn = (message) => warnings.push(message);
|
const warn = (message) => warnings.push(message);
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export const ownedFilesByRole = {
|
|||||||
"superuser-users.spec.ts",
|
"superuser-users.spec.ts",
|
||||||
"superuser-vehicles.smoke.spec.js",
|
"superuser-vehicles.smoke.spec.js",
|
||||||
"workfeed-config.smoke.spec.js",
|
"workfeed-config.smoke.spec.js",
|
||||||
|
"xlvask-flag-to-selvvash-navigation.spec.ts",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sergii Review Batch smoke test (TRU-96).
|
||||||
|
#
|
||||||
|
# Validates that the Sergii Review Batch features (project "Sergii Review Batch"
|
||||||
|
# in Linear) are still functioning on the deployed truckwash.io dashboard
|
||||||
|
# after a release.
|
||||||
|
#
|
||||||
|
# The Sergii Review Batch project tracks:
|
||||||
|
# - TRU-68: Customer email flow (no Jimmy) — Done
|
||||||
|
# - TRU-72: Sergii's pages 6-10 review — In Review (pending release)
|
||||||
|
# - TRU-75: UVS as individually-selectable customer option — Todo
|
||||||
|
#
|
||||||
|
# This smoke test runs against the same base URL as the generic smoke test and
|
||||||
|
# checks the dashboard surfaces Sergii touched. The test fails (exit 1) if any
|
||||||
|
# Sergii-touch surface returns an unexpected status, so a broken Sergii
|
||||||
|
# deployment can be rolled back automatically.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/smoke-test-sergii.sh [base_url]
|
||||||
|
# Default: https://staging.truckwash.io
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
|
||||||
|
TIMEOUT="${SMOKE_TIMEOUT:-10}"
|
||||||
|
|
||||||
|
# Color codes
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local name="$1"
|
||||||
|
local url="$2"
|
||||||
|
local expected="${3:-2xx}"
|
||||||
|
local method="${4:-GET}"
|
||||||
|
|
||||||
|
local status
|
||||||
|
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
|
||||||
|
|
||||||
|
if [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
|
||||||
|
echo -e " ${GREEN}✓${NC} $name ($status) — $url"
|
||||||
|
elif [[ "$status" == "$expected" ]]; then
|
||||||
|
echo -e " ${GREEN}✓${NC} $name ($status) — $url"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}✗${NC} $name (expected $expected, got $status) — $url"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Sergii Review Batch smoke test against $BASE_URL"
|
||||||
|
echo " (project: Sergii Review Batch — TRU-68, TRU-72, TRU-75)"
|
||||||
|
echo " (timeout ${TIMEOUT}s per check)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# === Sergii pages 6-10 (TRU-72) ===
|
||||||
|
# Sergii's batch touched the customer, products, and orders surfaces of the
|
||||||
|
# dashboard. Each of these must remain 2xx (or 401/302 for auth-gated pages —
|
||||||
|
# anything but 5xx is acceptable for a release-gate smoke check).
|
||||||
|
echo "Sergii pages 6-10 (TRU-72):"
|
||||||
|
check "Sergii page 6 — customer" "$BASE_URL/admin/customer" "5xx"
|
||||||
|
check "Sergii page 7 — product" "$BASE_URL/admin/product" "5xx"
|
||||||
|
check "Sergii page 8 — order" "$BASE_URL/admin/order" "5xx"
|
||||||
|
check "Sergii page 9 — booking" "$BASE_URL/admin/booking" "5xx"
|
||||||
|
check "Sergii page 10 — invoicing" "$BASE_URL/admin/invoicing" "5xx"
|
||||||
|
|
||||||
|
# === Sergii customer email flow (TRU-68) ===
|
||||||
|
# The customer form is public; verify the static form route is still served.
|
||||||
|
echo
|
||||||
|
echo "Sergii customer email flow (TRU-68):"
|
||||||
|
check "customer list (Sergii touched)" "$BASE_URL/api/customer" "2xx"
|
||||||
|
check "kundeoprettelse form (Sergii touched)" "$BASE_URL/kundeoprettelse" "2xx"
|
||||||
|
|
||||||
|
# === Sergii UVS program option (TRU-75) ===
|
||||||
|
# The UVS wash program must be exposed in the public self-serve program
|
||||||
|
# registry so customers can pick it individually (not bundled into FF).
|
||||||
|
echo
|
||||||
|
echo "Sergii UVS program option (TRU-75):"
|
||||||
|
check "self-serve program registry" "$BASE_URL/self-serve/program" "2xx"
|
||||||
|
check "self-serve vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
|
||||||
|
|
||||||
|
# === Sergii Review Batch sanity — dashboard still bootstraps ===
|
||||||
|
echo
|
||||||
|
echo "Dashboard bootstrap:"
|
||||||
|
check "health check" "$BASE_URL/healthz" "2xx"
|
||||||
|
check "ping" "$BASE_URL/api/ping" "2xx"
|
||||||
|
check "login page" "$BASE_URL/login" "2xx"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAIL" -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ Sergii Review Batch smoke test passed${NC}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Sergii Review Batch smoke test FAILED${NC}"
|
||||||
|
echo " A Sergii-touch surface on $BASE_URL is broken."
|
||||||
|
echo " Consider rolling back the most recent Sergii batch."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generic smoke test for any deployed app.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/smoke-test.sh [base_url]
|
||||||
|
# Default: https://staging.truckwash.io
|
||||||
|
#
|
||||||
|
# Required env vars (set by GitHub Action):
|
||||||
|
# SMOKE_BASE_URL - base URL to test (default: https://staging.truckwash.io)
|
||||||
|
#
|
||||||
|
# Optional env vars:
|
||||||
|
# SMOKE_TOKEN - bearer token for authenticated checks
|
||||||
|
# SMOKE_TIMEOUT - curl timeout in seconds (default: 10)
|
||||||
|
#
|
||||||
|
# Exits 0 on all-pass, 1 on any failure.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
|
||||||
|
TIMEOUT="${SMOKE_TIMEOUT:-10}"
|
||||||
|
|
||||||
|
# Color codes
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local name="$1"
|
||||||
|
local url="$2"
|
||||||
|
local expected="${3:-200}"
|
||||||
|
local method="${4:-GET}"
|
||||||
|
|
||||||
|
local status
|
||||||
|
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
|
||||||
|
|
||||||
|
if [[ "$status" =~ ^($expected)$ ]] || [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
|
||||||
|
echo -e " ${GREEN}✓${NC} $name ($status) — $url"
|
||||||
|
else
|
||||||
|
echo -e " ${RED}✗${NC} $name (expected $expected, got $status) — $url"
|
||||||
|
FAIL=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Smoke test against $BASE_URL"
|
||||||
|
echo " (timeout ${TIMEOUT}s per check)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# === Health endpoints (universal) ===
|
||||||
|
check "health check" "$BASE_URL/healthz" "2xx"
|
||||||
|
check "ping" "$BASE_URL/api/ping" "2xx"
|
||||||
|
|
||||||
|
# === Authentication (should NOT 500) ===
|
||||||
|
check "login page" "$BASE_URL/login" "2xx"
|
||||||
|
|
||||||
|
# === Public endpoints (api repo) ===
|
||||||
|
check "customer list (public schema)" "$BASE_URL/api/customer" "2xx"
|
||||||
|
check "kundeoprettelse form" "$BASE_URL/kundeoprettelse" "2xx"
|
||||||
|
|
||||||
|
# === Public endpoints (pleno-vue) ===
|
||||||
|
check "self-serve program picker" "$BASE_URL/self-serve/program" "2xx"
|
||||||
|
check "vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
|
||||||
|
|
||||||
|
# === Custom 404 should not 500 ===
|
||||||
|
check "404 page" "$BASE_URL/this-route-does-not-exist" "404"
|
||||||
|
|
||||||
|
# === Optional authenticated check ===
|
||||||
|
if [ -n "${SMOKE_TOKEN:-}" ]; then
|
||||||
|
check "auth check" "$BASE_URL/api/me" "2xx"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAIL" -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ All smoke tests passed${NC}"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗ Some smoke tests failed${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -144,3 +144,30 @@ body:not(.pleno-large-table-headers) .table thead th {
|
|||||||
margin-top: 30px !important;
|
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 productImage23 from "/src/assets/piktogrammer/23.png"; // Spotfree
|
||||||
import productImage24 from "/src/assets/piktogrammer/24.png"; // Spotfree
|
import productImage24 from "/src/assets/piktogrammer/24.png"; // Spotfree
|
||||||
import productImage25 from "/src/assets/piktogrammer/25.png"; // Fælg flex
|
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 productImage27 from "/src/assets/piktogrammer/27.png"; // 10 Minutter
|
||||||
import productImage39 from "/src/assets/piktogrammer/39.png"; // Trailer - Varevogn
|
import productImage39 from "/src/assets/piktogrammer/39.png"; // Trailer - Varevogn
|
||||||
import productImage43 from "/src/assets/piktogrammer/43.png"; // Handling of materials
|
import productImage43 from "/src/assets/piktogrammer/43.png"; // Handling of materials
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|||||||
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
|
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
|
||||||
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
|
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
|
||||||
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
||||||
|
import { removeError } from "@/components/request/HandleGlobalError.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
autoLoad: {
|
autoLoad: {
|
||||||
@@ -37,6 +38,8 @@ const props = defineProps({
|
|||||||
type: Boolean, default: false
|
type: Boolean, default: false
|
||||||
}, highlightUsageLogId: {
|
}, highlightUsageLogId: {
|
||||||
type: Number, default: 0
|
type: Number, default: 0
|
||||||
|
}, departmentId: {
|
||||||
|
type: Number, default: 0
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,9 +63,11 @@ const {
|
|||||||
setOrder,
|
setOrder,
|
||||||
hideSearchField,
|
hideSearchField,
|
||||||
setHideSearchField,
|
setHideSearchField,
|
||||||
|
lastError,
|
||||||
} = paginatedList;
|
} = paginatedList;
|
||||||
|
|
||||||
setEndpoint("/modules/xlvask/services/usage/orders", false);
|
const XLVASK_USAGE_ORDERS_ENDPOINT = "/modules/xlvask/services/usage/orders";
|
||||||
|
setEndpoint(XLVASK_USAGE_ORDERS_ENDPOINT, false);
|
||||||
if (props.loadAllAtOnce) {
|
if (props.loadAllAtOnce) {
|
||||||
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
|
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
|
||||||
}
|
}
|
||||||
@@ -98,12 +103,24 @@ if (props.hideSearch) {
|
|||||||
setHideSearchField(false);
|
setHideSearchField(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (router.currentRoute.value.params.departmentId) {
|
const routeDepartmentId = computed(() => Number.parseInt(
|
||||||
setOrder("StartTime", "desc");
|
String(router.currentRoute.value.params.departmentId ?? ""),
|
||||||
} else {
|
10
|
||||||
setOrder("StartTime", "desc");
|
));
|
||||||
|
const effectiveDepartmentId = computed(() =>
|
||||||
|
props.departmentId > 0
|
||||||
|
? props.departmentId
|
||||||
|
: Number.isInteger(routeDepartmentId.value) && routeDepartmentId.value > 0
|
||||||
|
? routeDepartmentId.value
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
|
||||||
|
if (effectiveDepartmentId.value > 0) {
|
||||||
|
setFilter("HallId", effectiveDepartmentId.value, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setOrder("StartTime", "desc");
|
||||||
|
|
||||||
const extractResponseData = (response) => response?.data?.data ?? response?.data ?? {};
|
const extractResponseData = (response) => response?.data?.data ?? response?.data ?? {};
|
||||||
|
|
||||||
const summary = ref({});
|
const summary = ref({});
|
||||||
@@ -115,6 +132,7 @@ const buildUsagePaginationParams = (extra = {}) => ({
|
|||||||
...extra,
|
...extra,
|
||||||
dateFrom: props.initialDateFrom || formatLocalDateOnly(dateFrom.value),
|
dateFrom: props.initialDateFrom || formatLocalDateOnly(dateFrom.value),
|
||||||
dateTo: props.initialDateTo || formatLocalDateOnly(dateTo.value),
|
dateTo: props.initialDateTo || formatLocalDateOnly(dateTo.value),
|
||||||
|
...(effectiveDepartmentId.value > 0 ? { HallId: effectiveDepartmentId.value } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadSummary = async () => {
|
const loadSummary = async () => {
|
||||||
@@ -199,6 +217,31 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Re-apply the department (HallId) filter and re-issue the query when
|
||||||
|
// the department selector changes (either via the `departmentId` prop
|
||||||
|
// or via the `departmentId` route param). Without this watcher the
|
||||||
|
// filter was set only once at setup, so changing departments left the
|
||||||
|
// Selvvask usage query bound to the original department.
|
||||||
|
watch(
|
||||||
|
effectiveDepartmentId,
|
||||||
|
(nextDepartmentId, previousDepartmentId) => {
|
||||||
|
if (nextDepartmentId === previousDepartmentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextDepartmentId > 0) {
|
||||||
|
setFilter("HallId", nextDepartmentId, false);
|
||||||
|
} else {
|
||||||
|
setFilter("HallId", "*", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.autoLoad) {
|
||||||
|
loadList();
|
||||||
|
loadSummary();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener("xlvask-usage-order-updated", () => {
|
window.addEventListener("xlvask-usage-order-updated", () => {
|
||||||
loadSummary();
|
loadSummary();
|
||||||
@@ -253,6 +296,20 @@ const visibleObjectsCount = computed(() => {
|
|||||||
}
|
}
|
||||||
return currentList.filter((object) => !isUsageOrderAttachedToOrder(object)).length;
|
return currentList.filter((object) => !isUsageOrderAttachedToOrder(object)).length;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 404 on the orders endpoint means the API surface has not been
|
||||||
|
// implemented yet. Show a friendly notice instead of the generic error
|
||||||
|
// popper the rest of the paginated surfaces surface.
|
||||||
|
const apiEndpointNotImplemented = computed(() => (
|
||||||
|
lastError.value?.status === 404
|
||||||
|
&& lastError.value?.endpoint === XLVASK_USAGE_ORDERS_ENDPOINT
|
||||||
|
));
|
||||||
|
|
||||||
|
watch(apiEndpointNotImplemented, (isNotImplemented) => {
|
||||||
|
if (isNotImplemented) {
|
||||||
|
removeError("paginatedGetRequest");
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -366,6 +423,13 @@ const visibleObjectsCount = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</PaginationDisplay>
|
</PaginationDisplay>
|
||||||
|
<div
|
||||||
|
v-else-if="apiEndpointNotImplemented"
|
||||||
|
class="notification is-info is-light mb-4"
|
||||||
|
data-testid="xlvask-api-not-implemented"
|
||||||
|
>
|
||||||
|
{{ t('invoicing_period.xlvask_review.errors.api_endpoint_not_implemented') }}
|
||||||
|
</div>
|
||||||
<ShowErrorField v-else error="paginatedGetRequest"/>
|
<ShowErrorField v-else error="paginatedGetRequest"/>
|
||||||
<XlvaskUsageOrdersTable
|
<XlvaskUsageOrdersTable
|
||||||
:objects="list"
|
:objects="list"
|
||||||
|
|||||||
@@ -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 { computed, ref, watch } from "vue";
|
||||||
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
|
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
|
||||||
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
|
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
|
||||||
|
import RedCarWarning from "@/components/displays/selfServe/RedCarWarning.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
customerNumber: string | number | null;
|
customerNumber: string | number | null;
|
||||||
@@ -17,6 +18,7 @@ const props = defineProps<{
|
|||||||
vehicleTypes: Array<any>;
|
vehicleTypes: Array<any>;
|
||||||
vehicleStepError?: string | null;
|
vehicleStepError?: string | null;
|
||||||
vehicleStepGuidance?: string | null;
|
vehicleStepGuidance?: string | null;
|
||||||
|
isRedCar?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -141,6 +143,11 @@ const emitVehicleTypeSelection = (selection: any) => {
|
|||||||
>
|
>
|
||||||
{{ props.vehicleStepError }}
|
{{ props.vehicleStepError }}
|
||||||
</b-message>
|
</b-message>
|
||||||
|
<RedCarWarning
|
||||||
|
v-if="!props.vehicleStepError && props.isRedCar"
|
||||||
|
:is-red-car="true"
|
||||||
|
:customer-number="customerNumber"
|
||||||
|
/>
|
||||||
<b-message
|
<b-message
|
||||||
v-else-if="props.vehicleStepGuidance"
|
v-else-if="props.vehicleStepGuidance"
|
||||||
type="is-info"
|
type="is-info"
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ export function usePaginatedList() {
|
|||||||
let activeRequestController = null;
|
let activeRequestController = null;
|
||||||
let searchDebounceTimeout = null;
|
let searchDebounceTimeout = null;
|
||||||
|
|
||||||
|
/** The last error produced by `paginatedGetRequest` (null on success). */
|
||||||
|
const lastError = ref(null);
|
||||||
|
|
||||||
/** Additional query parameters */
|
/** Additional query parameters */
|
||||||
const additionalQueryParameters = ref({});
|
const additionalQueryParameters = ref({});
|
||||||
|
|
||||||
@@ -170,6 +173,7 @@ export function usePaginatedList() {
|
|||||||
abortActiveRequest();
|
abortActiveRequest();
|
||||||
const requestController = typeof AbortController !== "undefined" ? new AbortController() : null;
|
const requestController = typeof AbortController !== "undefined" ? new AbortController() : null;
|
||||||
activeRequestController = requestController;
|
activeRequestController = requestController;
|
||||||
|
lastError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(API_URL + endpoint.value, {
|
const response = await axios.get(API_URL + endpoint.value, {
|
||||||
@@ -193,10 +197,16 @@ export function usePaginatedList() {
|
|||||||
setLastUpdated();
|
setLastUpdated();
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCanceledRequest(error)) {
|
if (isCanceledRequest(error)) {
|
||||||
parseError(error, 'paginatedGetRequest');
|
return null;
|
||||||
console.log(error);
|
|
||||||
}
|
}
|
||||||
|
lastError.value = {
|
||||||
|
status: Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10) || null,
|
||||||
|
endpoint: endpoint.value,
|
||||||
|
message: error?.response?.data?.data?.message ?? error?.response?.data?.message ?? error?.message ?? null,
|
||||||
|
};
|
||||||
|
parseError(error, 'paginatedGetRequest');
|
||||||
|
console.log(error);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (activeRequestController === requestController) {
|
if (activeRequestController === requestController) {
|
||||||
@@ -487,6 +497,7 @@ export function usePaginatedList() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
isExporting,
|
isExporting,
|
||||||
latestSearch,
|
latestSearch,
|
||||||
|
lastError,
|
||||||
additionalQueryParameters,
|
additionalQueryParameters,
|
||||||
exportTransform,
|
exportTransform,
|
||||||
setHideSearchField,
|
setHideSearchField,
|
||||||
@@ -542,6 +553,7 @@ export const hideSearchField = globalInstance.hideSearchField;
|
|||||||
export const isLoading = globalInstance.isLoading;
|
export const isLoading = globalInstance.isLoading;
|
||||||
export const isExporting = globalInstance.isExporting;
|
export const isExporting = globalInstance.isExporting;
|
||||||
export const latestSearch = globalInstance.latestSearch;
|
export const latestSearch = globalInstance.latestSearch;
|
||||||
|
export const lastError = globalInstance.lastError;
|
||||||
export const additionalQueryParameters = globalInstance.additionalQueryParameters;
|
export const additionalQueryParameters = globalInstance.additionalQueryParameters;
|
||||||
export const exportTransform = globalInstance.exportTransform;
|
export const exportTransform = globalInstance.exportTransform;
|
||||||
|
|
||||||
|
|||||||
@@ -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,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'}",
|
"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_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_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'}",
|
"driver_login_title": "@.capitalize:{'words.generated.chauffør'} @.capitalize:{'words.generated.login'}",
|
||||||
"forgot_password": "Glemt @:{'words.generated.adgangskode'}",
|
"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'}",
|
"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'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.oplysninger'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
|
"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",
|
"start_time": "Starttidspunkt",
|
||||||
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
||||||
@@ -4874,7 +4876,8 @@
|
|||||||
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
|
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
|
||||||
"redirect_order_failed": "Ordren kunne ikke åbnes.",
|
"redirect_order_failed": "Ordren kunne ikke åbnes.",
|
||||||
"load_customers_failed": "Kunderne kunne ikke hentes.",
|
"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",
|
"open_property_exit_gate": "@:{'words.generated.abn'} udgangsport",
|
||||||
"perform_wash": "@:{'words.generated.udfør'} @:{'words.generated.vask'}",
|
"perform_wash": "@:{'words.generated.udfør'} @:{'words.generated.vask'}",
|
||||||
"questions_answered": "@.capitalize:{'words.generated.spørgsmal'} @:{'words.generated.besvaret'}",
|
"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_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_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'}",
|
"select_vehicle": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.køretøj'}",
|
||||||
|
|||||||
@@ -2260,6 +2260,7 @@
|
|||||||
"username": "@:{'words.generated.benutzername'}",
|
"username": "@:{'words.generated.benutzername'}",
|
||||||
"your_username": "@:{'words.generated.ihr'} @:{'words.generated.benutzername'}",
|
"your_username": "@:{'words.generated.ihr'} @:{'words.generated.benutzername'}",
|
||||||
"driver_login_button": "Fahrer-Login",
|
"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."
|
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
|
||||||
},
|
},
|
||||||
"book_wash": {
|
"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}.",
|
"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_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.",
|
"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": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -4348,6 +4349,7 @@
|
|||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'}-@.capitalize:{'words.generated.details'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'}-@.capitalize:{'words.generated.details'}"
|
||||||
},
|
},
|
||||||
"expected_price": "Erwarteter @:{'words.generated.preis'}",
|
"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'}",
|
"start_time": "@:{'templates.generated.compat.tables.common.start_time'}",
|
||||||
"wash_id": "Wasch-@.upper:{'words.generated.id'}",
|
"wash_id": "Wasch-@.upper:{'words.generated.id'}",
|
||||||
"xlvask_usage_log": "@:{'templates.generated.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
|
"xlvask_usage_log": "@:{'templates.generated.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
|
||||||
@@ -4984,7 +4986,8 @@
|
|||||||
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
|
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
|
||||||
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
|
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
|
||||||
"load_customers_failed": "Die Kunden konnten nicht geladen 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'}",
|
"open_property_exit_gate": "Grundstuecksausgangstor @:{'words.generated.oeffnen'}",
|
||||||
"perform_wash": "@.upper:{'words.generated.w'}?@:{'words.generated.sche'} durchf?@:{'words.generated.hren'}",
|
"perform_wash": "@.upper:{'words.generated.w'}?@:{'words.generated.sche'} durchf?@:{'words.generated.hren'}",
|
||||||
"questions_answered": "@:{'words.generated.fragen'} @:{'words.generated.beantwortet'}",
|
"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_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_type": "@:{'words.generated.fahrzeugtyp'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
|
||||||
"select_vehicle": "@:{'words.generated.fahrzeug'} @:{'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'}",
|
"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_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_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'}",
|
"driver_login_title": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
|
||||||
"forgot_password": "Forgot @.capitalize:{'words.generated.password'}",
|
"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'}",
|
"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'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.details'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'words.generated.expected'} @:{'words.generated.price'}",
|
"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'}",
|
"start_time": "@.capitalize:{'words.generated.start'} @:{'words.generated.time'}",
|
||||||
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.registration'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.registration'}"
|
||||||
@@ -4705,7 +4707,8 @@
|
|||||||
"create_order_item_failed": "The wash item could not be added to the order.",
|
"create_order_item_failed": "The wash item could not be added to the order.",
|
||||||
"redirect_order_failed": "The order could not be opened.",
|
"redirect_order_failed": "The order could not be opened.",
|
||||||
"load_customers_failed": "The customers could not be loaded.",
|
"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'}",
|
"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'}",
|
"perform_wash": "@.capitalize:{'words.generated.perform'} @:{'words.generated.wash'}",
|
||||||
"questions_answered": "@.capitalize:{'words.generated.questions'} @:{'words.generated.answered'}",
|
"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_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_type": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'} @:{'words.generated.type'}",
|
||||||
"select_vehicle": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'}",
|
"select_vehicle": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'}",
|
||||||
|
|||||||
@@ -786,6 +786,7 @@
|
|||||||
"customer_number": "@:{'templates.generated.compat.objects.columns.customer_number'}",
|
"customer_number": "@:{'templates.generated.compat.objects.columns.customer_number'}",
|
||||||
"driver_entry_intro": "@:{'templates.generated.compat.auth.driver_entry_intro'}",
|
"driver_entry_intro": "@:{'templates.generated.compat.auth.driver_entry_intro'}",
|
||||||
"driver_login_button": "@:{'templates.generated.compat.auth.driver_login_button'}",
|
"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'}",
|
"driver_login_title": "@:{'templates.generated.compat.auth.driver_login_title'}",
|
||||||
"forgot_password": "@:{'templates.generated.compat.auth.forgot_password'}",
|
"forgot_password": "@:{'templates.generated.compat.auth.forgot_password'}",
|
||||||
"login_as_customer": "@:{'templates.generated.compat.global.login_as_customer'}",
|
"login_as_customer": "@:{'templates.generated.compat.global.login_as_customer'}",
|
||||||
@@ -3421,6 +3422,7 @@
|
|||||||
"expected_price": "@:{'templates.generated.compat.invoice_period.flags.preview.expected_price'}",
|
"expected_price": "@:{'templates.generated.compat.invoice_period.flags.preview.expected_price'}",
|
||||||
"no_order_items": "@:common.templates.no_entity_available",
|
"no_order_items": "@:common.templates.no_entity_available",
|
||||||
"no_xlvask_usage_log": "@: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'}",
|
"order_items": "@:{'templates.generated.compat.global_search.entity_types.order_items'}",
|
||||||
"price": "@:common.price",
|
"price": "@:common.price",
|
||||||
"product": "@:common.product",
|
"product": "@:common.product",
|
||||||
@@ -4092,7 +4094,8 @@
|
|||||||
"create_order_item_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_item_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'}",
|
"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_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'}"
|
"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'}",
|
"username": "@.capitalize:{'words.generated.brukernavn'}",
|
||||||
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.brukernavn'}",
|
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.brukernavn'}",
|
||||||
"driver_login_button": "Sjåfør-innlogging",
|
"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."
|
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
|
||||||
},
|
},
|
||||||
"book_wash": {
|
"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}.",
|
"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_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'}.",
|
"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": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -4351,6 +4352,7 @@
|
|||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-opplysninger"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-opplysninger"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
|
"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",
|
"start_time": "Starttidspunkt",
|
||||||
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
||||||
@@ -4987,7 +4989,8 @@
|
|||||||
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
|
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
|
||||||
"redirect_order_failed": "Ordren kunne ikke åpnes.",
|
"redirect_order_failed": "Ordren kunne ikke åpnes.",
|
||||||
"load_customers_failed": "Kundene kunne ikke hentes.",
|
"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",
|
"open_property_exit_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} utgangsport",
|
||||||
"perform_wash": "Utfør @:{'words.generated.vask'}",
|
"perform_wash": "Utfør @:{'words.generated.vask'}",
|
||||||
"questions_answered": "@.capitalize:{'words.generated.spørsmal'} @:{'words.generated.besvart'}",
|
"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_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_type": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøytype'}",
|
||||||
"select_vehicle": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøy'}",
|
"select_vehicle": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøy'}",
|
||||||
|
|||||||
@@ -2313,6 +2313,7 @@
|
|||||||
"username": "@.capitalize:{'words.generated.anvandarnamn'}",
|
"username": "@.capitalize:{'words.generated.anvandarnamn'}",
|
||||||
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.anvandarnamn'}",
|
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.anvandarnamn'}",
|
||||||
"driver_login_button": "Förarinloggning",
|
"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."
|
"driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt."
|
||||||
},
|
},
|
||||||
"book_wash": {
|
"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}.",
|
"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_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'}.",
|
"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": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -4401,6 +4402,7 @@
|
|||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.uppgifter'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.uppgifter'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'words.generated.forvantat'} @:{'words.generated.pris'}",
|
"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",
|
"start_time": "Starttid",
|
||||||
"wash_id": "@.capitalize:{'words.generated.tvatt_2'}-@.upper:{'words.generated.id'}",
|
"wash_id": "@.capitalize:{'words.generated.tvatt_2'}-@.upper:{'words.generated.id'}",
|
||||||
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
|
||||||
@@ -5037,7 +5039,8 @@
|
|||||||
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
|
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
|
||||||
"redirect_order_failed": "Ordern kunde inte öppnas.",
|
"redirect_order_failed": "Ordern kunde inte öppnas.",
|
||||||
"load_customers_failed": "Kunderna kunde inte hämtas.",
|
"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",
|
"open_property_exit_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} utgangsgrind",
|
||||||
"perform_wash": "Perform @:{'words.generated.wash'}",
|
"perform_wash": "Perform @:{'words.generated.wash'}",
|
||||||
"questions_answered": "@.capitalize:{'words.generated.fragor'} @:{'words.generated.besvarade'}",
|
"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_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_type": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordonstyp'}",
|
||||||
"select_vehicle": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordon'}",
|
"select_vehicle": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordon'}",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"customer_login_title": "@:{'phrases.compat.global.login'}",
|
"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_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_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'}",
|
"driver_login_title": "@.capitalize:{'terms.glossary.chauffør'} @.capitalize:{'terms.glossary.login'}",
|
||||||
"forgot_password": "Glemt @:{'terms.glossary.adgangskode'}",
|
"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'}",
|
"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'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.oplysninger'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
|
"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",
|
"start_time": "Starttidspunkt",
|
||||||
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
||||||
|
|||||||
@@ -112,7 +112,8 @@
|
|||||||
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
|
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
|
||||||
"redirect_order_failed": "Ordren kunne ikke åbnes.",
|
"redirect_order_failed": "Ordren kunne ikke åbnes.",
|
||||||
"load_customers_failed": "Kunderne kunne ikke hentes.",
|
"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",
|
"open_property_exit_gate": "@:{'terms.glossary.abn'} udgangsport",
|
||||||
"perform_wash": "@:{'terms.glossary.udfør'} @:{'terms.glossary.vask'}",
|
"perform_wash": "@:{'terms.glossary.udfør'} @:{'terms.glossary.vask'}",
|
||||||
"questions_answered": "@.capitalize:{'terms.glossary.spørgsmal'} @:{'terms.glossary.besvaret'}",
|
"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_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_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'}",
|
"select_vehicle": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.køretøj'}",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"username": "@:{'terms.glossary.benutzername'}",
|
"username": "@:{'terms.glossary.benutzername'}",
|
||||||
"your_username": "@:{'terms.glossary.ihr'} @:{'terms.glossary.benutzername'}",
|
"your_username": "@:{'terms.glossary.ihr'} @:{'terms.glossary.benutzername'}",
|
||||||
"driver_login_button": "Fahrer-Login",
|
"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."
|
"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}.",
|
"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_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.",
|
"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": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'}-@.capitalize:{'terms.glossary.details'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'}-@.capitalize:{'terms.glossary.details'}"
|
||||||
},
|
},
|
||||||
"expected_price": "Erwarteter @:{'terms.glossary.preis'}",
|
"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'}",
|
"start_time": "@:{'phrases.compat.tables.common.start_time'}",
|
||||||
"wash_id": "Wasch-@.upper:{'terms.glossary.id'}",
|
"wash_id": "Wasch-@.upper:{'terms.glossary.id'}",
|
||||||
"xlvask_usage_log": "@:{'phrases.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
|
"xlvask_usage_log": "@:{'phrases.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
|
||||||
|
|||||||
@@ -191,7 +191,8 @@
|
|||||||
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
|
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
|
||||||
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
|
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
|
||||||
"load_customers_failed": "Die Kunden konnten nicht geladen 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'}",
|
"open_property_exit_gate": "Grundstuecksausgangstor @:{'terms.glossary.oeffnen'}",
|
||||||
"perform_wash": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.sche'} durchf?@:{'terms.glossary.hren'}",
|
"perform_wash": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.sche'} durchf?@:{'terms.glossary.hren'}",
|
||||||
"questions_answered": "@:{'terms.glossary.fragen'} @:{'terms.glossary.beantwortet'}",
|
"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_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_type": "@:{'terms.glossary.fahrzeugtyp'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
|
||||||
"select_vehicle": "@:{'terms.glossary.fahrzeug'} @:{'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'}",
|
"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_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_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'}",
|
"driver_login_title": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
|
||||||
"forgot_password": "Forgot @.capitalize:{'terms.glossary.password'}",
|
"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'}",
|
"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'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.details'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'terms.glossary.expected'} @:{'terms.glossary.price'}",
|
"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'}",
|
"start_time": "@.capitalize:{'terms.glossary.start'} @:{'terms.glossary.time'}",
|
||||||
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.registration'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.registration'}"
|
||||||
|
|||||||
@@ -112,7 +112,8 @@
|
|||||||
"create_order_item_failed": "The wash item could not be added to the order.",
|
"create_order_item_failed": "The wash item could not be added to the order.",
|
||||||
"redirect_order_failed": "The order could not be opened.",
|
"redirect_order_failed": "The order could not be opened.",
|
||||||
"load_customers_failed": "The customers could not be loaded.",
|
"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'}",
|
"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'}",
|
"perform_wash": "@.capitalize:{'terms.glossary.perform'} @:{'terms.glossary.wash'}",
|
||||||
"questions_answered": "@.capitalize:{'terms.glossary.questions'} @:{'terms.glossary.answered'}",
|
"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_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_type": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.type'}",
|
||||||
"select_vehicle": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'}",
|
"select_vehicle": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'}",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"customer_number": "@:{'phrases.compat.objects.columns.customer_number'}",
|
"customer_number": "@:{'phrases.compat.objects.columns.customer_number'}",
|
||||||
"driver_entry_intro": "@:{'phrases.compat.auth.driver_entry_intro'}",
|
"driver_entry_intro": "@:{'phrases.compat.auth.driver_entry_intro'}",
|
||||||
"driver_login_button": "@:{'phrases.compat.auth.driver_login_button'}",
|
"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'}",
|
"driver_login_title": "@:{'phrases.compat.auth.driver_login_title'}",
|
||||||
"forgot_password": "@:{'phrases.compat.auth.forgot_password'}",
|
"forgot_password": "@:{'phrases.compat.auth.forgot_password'}",
|
||||||
"login_as_customer": "@:{'phrases.compat.global.login_as_customer'}",
|
"login_as_customer": "@:{'phrases.compat.global.login_as_customer'}",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"expected_price": "@:{'phrases.compat.invoice_period.flags.preview.expected_price'}",
|
"expected_price": "@:{'phrases.compat.invoice_period.flags.preview.expected_price'}",
|
||||||
"no_order_items": "@:common.templates.no_entity_available",
|
"no_order_items": "@:common.templates.no_entity_available",
|
||||||
"no_xlvask_usage_log": "@: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'}",
|
"order_items": "@:{'phrases.compat.global_search.entity_types.order_items'}",
|
||||||
"price": "@:common.price",
|
"price": "@:common.price",
|
||||||
"product": "@:common.product",
|
"product": "@:common.product",
|
||||||
|
|||||||
@@ -196,7 +196,8 @@
|
|||||||
"create_order_item_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.create_order_item_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'}",
|
"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_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'}"
|
"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'}",
|
"username": "@.capitalize:{'terms.glossary.brukernavn'}",
|
||||||
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.brukernavn'}",
|
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.brukernavn'}",
|
||||||
"driver_login_button": "Sjåfør-innlogging",
|
"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."
|
"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}.",
|
"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_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'}.",
|
"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": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-opplysninger"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-opplysninger"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
|
"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",
|
"start_time": "Starttidspunkt",
|
||||||
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
|
||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
||||||
|
|||||||
@@ -191,7 +191,8 @@
|
|||||||
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
|
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
|
||||||
"redirect_order_failed": "Ordren kunne ikke åpnes.",
|
"redirect_order_failed": "Ordren kunne ikke åpnes.",
|
||||||
"load_customers_failed": "Kundene kunne ikke hentes.",
|
"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",
|
"open_property_exit_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} utgangsport",
|
||||||
"perform_wash": "Utfør @:{'terms.glossary.vask'}",
|
"perform_wash": "Utfør @:{'terms.glossary.vask'}",
|
||||||
"questions_answered": "@.capitalize:{'terms.glossary.spørsmal'} @:{'terms.glossary.besvart'}",
|
"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_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_type": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøytype'}",
|
||||||
"select_vehicle": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøy'}",
|
"select_vehicle": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøy'}",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"username": "@.capitalize:{'terms.glossary.anvandarnamn'}",
|
"username": "@.capitalize:{'terms.glossary.anvandarnamn'}",
|
||||||
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.anvandarnamn'}",
|
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.anvandarnamn'}",
|
||||||
"driver_login_button": "Förarinloggning",
|
"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."
|
"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}.",
|
"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_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'}.",
|
"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": {
|
"preview": {
|
||||||
"entities": {
|
"entities": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.uppgifter'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.uppgifter'}"
|
||||||
},
|
},
|
||||||
"expected_price": "@.capitalize:{'terms.glossary.forvantat'} @:{'terms.glossary.pris'}",
|
"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",
|
"start_time": "Starttid",
|
||||||
"wash_id": "@.capitalize:{'terms.glossary.tvatt_2'}-@.upper:{'terms.glossary.id'}",
|
"wash_id": "@.capitalize:{'terms.glossary.tvatt_2'}-@.upper:{'terms.glossary.id'}",
|
||||||
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
|
||||||
|
|||||||
@@ -191,7 +191,8 @@
|
|||||||
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
|
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
|
||||||
"redirect_order_failed": "Ordern kunde inte öppnas.",
|
"redirect_order_failed": "Ordern kunde inte öppnas.",
|
||||||
"load_customers_failed": "Kunderna kunde inte hämtas.",
|
"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",
|
"open_property_exit_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} utgangsgrind",
|
||||||
"perform_wash": "Perform @:{'terms.glossary.wash'}",
|
"perform_wash": "Perform @:{'terms.glossary.wash'}",
|
||||||
"questions_answered": "@.capitalize:{'terms.glossary.fragor'} @:{'terms.glossary.besvarade'}",
|
"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_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_type": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordonstyp'}",
|
||||||
"select_vehicle": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordon'}",
|
"select_vehicle": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordon'}",
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
|
|||||||
<LoginForm />
|
<LoginForm />
|
||||||
</ConnectivityIssue>
|
</ConnectivityIssue>
|
||||||
<p>{{ $t('auth.terms_acceptance') }}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -158,6 +175,55 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
|
|||||||
text-decoration: none;
|
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) {
|
@media (max-width: 1250px) {
|
||||||
.login-section .wrapper .sidebar h1{
|
.login-section .wrapper .sidebar h1{
|
||||||
font-size: 2.3em;
|
font-size: 2.3em;
|
||||||
@@ -187,5 +253,20 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
|
|||||||
padding: 70px 10px;
|
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*/
|
/*end login-section*/
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import XLVaskUsagePagination from "@/components/displays/pagination/models/Depar
|
|||||||
<template #default>
|
<template #default>
|
||||||
<DepartmentDashboardHero />
|
<DepartmentDashboardHero />
|
||||||
<!-- Syncronize Orders -->
|
<!-- Syncronize Orders -->
|
||||||
<XLVaskUsagePagination/>
|
<XLVaskUsagePagination :department-id="SessionUser.functions.getDepartmentIdFromUrl()"/>
|
||||||
</template>
|
</template>
|
||||||
</NotFoundFallBackPageWrapper>
|
</NotFoundFallBackPageWrapper>
|
||||||
</DepartmentDashboardPageWrapper>
|
</DepartmentDashboardPageWrapper>
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ const FALLBACK_PRODUCT_TITLES = {
|
|||||||
24: "Spot Free (Lastbil)",
|
24: "Spot Free (Lastbil)",
|
||||||
25: "Fælg flex pr. enhed",
|
25: "Fælg flex pr. enhed",
|
||||||
27: "Ekstraordinær pr. 10 min inkl. kemi",
|
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",
|
21: "Undervognsskyl pr. enhed",
|
||||||
22: "Tillæg for Specialsæbe - DD",
|
22: "Tillæg for Specialsæbe - DD",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -240,9 +240,10 @@ const xlvaskUsageLogHtml = (flag: any) => {
|
|||||||
].filter(([, value]) => String(value ?? "").trim() !== "");
|
].filter(([, value]) => String(value ?? "").trim() !== "");
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return escapeHtml(translate("invoice_period.flags.preview.no_xlvask_usage_log", "No XL Vask details available.", {
|
return escapeHtml(translate(
|
||||||
entity: translate("invoice_period.flags.preview.entities.xlvask_usage_log", "XL Vask details"),
|
"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">
|
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);
|
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 openXlVaskUsageLog = (flag: any) => {
|
||||||
const usageLogId = Number(flag?.xlvask_usage_log_id || flag?.context?.xlvask_usage_log_id || flag?.target_id || 0);
|
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({
|
const query = new URLSearchParams({
|
||||||
activeTab: "period",
|
activeTab: "period",
|
||||||
periodView: "self_wash",
|
periodView: "self_wash",
|
||||||
@@ -296,6 +307,10 @@ const openXlVaskUsageLog = (flag: any) => {
|
|||||||
query.set("xlvaskUsageLogId", String(usageLogId));
|
query.set("xlvaskUsageLogId", String(usageLogId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startTime !== "") {
|
||||||
|
query.set("xlvaskUsageLogStartTime", startTime);
|
||||||
|
}
|
||||||
|
|
||||||
SessionUser.functions.redirectTo.superUser(`/invoices?${query.toString()}`, true);
|
SessionUser.functions.redirectTo.superUser(`/invoices?${query.toString()}`, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,86 @@ import { computed } from "vue";
|
|||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import i18n from "@/i18n";
|
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 route = useRoute();
|
||||||
const highlightedUsageLogId = computed(() => {
|
const highlightedUsageLogId = computed(() => {
|
||||||
const parsedId = Number.parseInt(String(route.query.xlvaskUsageLogId || ""), 10);
|
const parsedId = Number.parseInt(String(route.query.xlvaskUsageLogId || ""), 10);
|
||||||
return Number.isInteger(parsedId) && parsedId > 0 ? parsedId : 0;
|
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"));
|
const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -18,8 +92,8 @@ const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
|
|||||||
<section data-testid="invoicing-period-self-wash-view">
|
<section data-testid="invoicing-period-self-wash-view">
|
||||||
<XLVaskUsagePagination
|
<XLVaskUsagePagination
|
||||||
:title="selfWashTitle"
|
:title="selfWashTitle"
|
||||||
:initial-date-from="dates.computed.formattedStartDate.value"
|
:initial-date-from="initialDateFrom"
|
||||||
:initial-date-to="dates.computed.formattedEndDate.value"
|
:initial-date-to="initialDateTo"
|
||||||
:inherit-period-filters="true"
|
:inherit-period-filters="true"
|
||||||
:load-all-at-once="false"
|
:load-all-at-once="false"
|
||||||
:highlight-usage-log-id="highlightedUsageLogId"
|
:highlight-usage-log-id="highlightedUsageLogId"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
|
|||||||
import { useWashProgress } from "@/composables/useWashProgress";
|
import { useWashProgress } from "@/composables/useWashProgress";
|
||||||
import { useWashFlowState } from "@/composables/useWashFlowState";
|
import { useWashFlowState } from "@/composables/useWashFlowState";
|
||||||
import { useWashSessionActions } from "@/composables/useWashSessionActions";
|
import { useWashSessionActions } from "@/composables/useWashSessionActions";
|
||||||
|
import { useRedCarWarning } from "@/composables/useRedCarWarning.js";
|
||||||
import { getSelfServeTaskDynamicImageButtons } from "@/services/selfServeDynamicImage.js";
|
import { getSelfServeTaskDynamicImageButtons } from "@/services/selfServeDynamicImage.js";
|
||||||
import type { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
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 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 hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
|
||||||
|
|
||||||
const canUseDepartmentHeaderSelection = computed(
|
const canUseDepartmentHeaderSelection = computed(
|
||||||
@@ -2383,6 +2395,7 @@ watch(
|
|||||||
:vehicle-types="vehicleTypes"
|
:vehicle-types="vehicleTypes"
|
||||||
:vehicle-step-error="vehicleStepError"
|
:vehicle-step-error="vehicleStepError"
|
||||||
:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"
|
:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"
|
||||||
|
:is-red-car="isRedCarCustomerFlag"
|
||||||
@update:customer-number="onUpdateCustomerNumber"
|
@update:customer-number="onUpdateCustomerNumber"
|
||||||
@update:registration-number="onUpdateRegistrationNumber"
|
@update:registration-number="onUpdateRegistrationNumber"
|
||||||
@select-vehicle-type="onSelectVehicleType"
|
@select-vehicle-type="onSelectVehicleType"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ defineProps<{
|
|||||||
vehicleTypes: any[];
|
vehicleTypes: any[];
|
||||||
vehicleStepError?: string | null;
|
vehicleStepError?: string | null;
|
||||||
vehicleStepGuidance?: string | null;
|
vehicleStepGuidance?: string | null;
|
||||||
|
isRedCar?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -56,6 +57,7 @@ const emitVehicleTypeSelection = (selection: VehicleTypeSelection) => {
|
|||||||
:vehicle-types="vehicleTypes"
|
:vehicle-types="vehicleTypes"
|
||||||
:vehicle-step-error="vehicleStepError"
|
:vehicle-step-error="vehicleStepError"
|
||||||
:vehicle-step-guidance="vehicleStepGuidance"
|
:vehicle-step-guidance="vehicleStepGuidance"
|
||||||
|
:is-red-car="isRedCar"
|
||||||
@update:customer-number="emitCustomerNumber"
|
@update:customer-number="emitCustomerNumber"
|
||||||
@update:customerNumber="emitCustomerNumber"
|
@update:customerNumber="emitCustomerNumber"
|
||||||
@update:registration-number="emitRegistrationNumber"
|
@update:registration-number="emitRegistrationNumber"
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const buildOverviewPayload = ({
|
|||||||
{
|
{
|
||||||
product_id: 26,
|
product_id: 26,
|
||||||
slug: "hoejglans",
|
slug: "hoejglans",
|
||||||
title: "Højglans - Voksforsegling pr. enhed",
|
title: "Voksforsegling pr. enhed",
|
||||||
state: "ready",
|
state: "ready",
|
||||||
value: 4,
|
value: 4,
|
||||||
out_of: 8,
|
out_of: 8,
|
||||||
@@ -268,7 +268,7 @@ test.describe("Admin daily report", () => {
|
|||||||
await expect(page.getByTestId("daily-report-tile-extraordinary-10-min")).toContainText(
|
await expect(page.getByTestId("daily-report-tile-extraordinary-10-min")).toContainText(
|
||||||
"Ekstraordinær pr. 10 min inkl. kemi"
|
"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-undervognsskyl")).toContainText("Undervognsskyl pr. enhed");
|
||||||
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
|
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(
|
await expect(page.getByTestId("daily-report-tile-extraordinary-10-min")).toContainText(
|
||||||
"Ekstraordinær pr. 10 min inkl. kemi"
|
"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-undervognsskyl")).toContainText("Undervognsskyl pr. enhed");
|
||||||
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
|
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([]);
|
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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||||
|
import { isDesktopProject } from "./support/projects";
|
||||||
|
|
||||||
|
const XL_VASK_USAGE_LOG_ID = 55;
|
||||||
|
const PERIOD_FROM_DATE = "2026-07-01";
|
||||||
|
const PERIOD_TO_DATE = "2026-07-31";
|
||||||
|
|
||||||
|
const PERIOD_VIEW_URL = `/superuser/invoices?activeTab=period&startDate=${PERIOD_FROM_DATE}&endDate=${PERIOD_TO_DATE}&periodView=all`;
|
||||||
|
const SELVVASH_DESTINATION_PATTERN = new RegExp(
|
||||||
|
"/superuser/invoices\\?activeTab=period&periodView=self_wash&xlvaskUsageLogId=" + XL_VASK_USAGE_LOG_ID
|
||||||
|
);
|
||||||
|
|
||||||
|
function json(body: unknown, status = 200) {
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createXlVaskFlaggedPeriodPayload({ dateFrom = PERIOD_FROM_DATE } = {}) {
|
||||||
|
const fixtureDate = String(dateFrom).slice(0, 10);
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
types: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
customer_number: 4001,
|
||||||
|
customer_name: "Acme Fleet",
|
||||||
|
requires_action: true,
|
||||||
|
transactions: [
|
||||||
|
{
|
||||||
|
id: 9001,
|
||||||
|
date: `${fixtureDate}T10:00:00.000Z`,
|
||||||
|
amount: 120,
|
||||||
|
booked: false,
|
||||||
|
excluded: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queue: { has_active_job: false, statuses: [], invoice_collection_ids: [], is_action_blocked: false },
|
||||||
|
meta: {},
|
||||||
|
flags: [
|
||||||
|
{
|
||||||
|
id: "auto-xlvask-missing-1",
|
||||||
|
source: "automatic",
|
||||||
|
severity: "yellow",
|
||||||
|
status: "active",
|
||||||
|
target_type: "xlvask_usage_log",
|
||||||
|
target_id: XL_VASK_USAGE_LOG_ID,
|
||||||
|
customer_number: 4001,
|
||||||
|
definition_key: "xlvask_missing_order_link",
|
||||||
|
fingerprint: "xlvask-missing-fingerprint-1",
|
||||||
|
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||||
|
message_params: {
|
||||||
|
wash_id: `wash-${XL_VASK_USAGE_LOG_ID}`,
|
||||||
|
registration_number: "AB12345",
|
||||||
|
},
|
||||||
|
message: "XL Vask wash is neither ignored nor linked to an order in the selected period.",
|
||||||
|
context: {
|
||||||
|
customer_name: "Acme Fleet",
|
||||||
|
xlvask_usage_log_id: XL_VASK_USAGE_LOG_ID,
|
||||||
|
wash_id: `wash-${XL_VASK_USAGE_LOG_ID}`,
|
||||||
|
registration_number: "AB12345",
|
||||||
|
start_time: `${fixtureDate}T10:00:00.000Z`,
|
||||||
|
},
|
||||||
|
xlvask_usage_log_id: XL_VASK_USAGE_LOG_ID,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
flag_counts: { manual: 0, automatic: 1, total: 1 },
|
||||||
|
status_indicator: "flag_yellow",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invoice_per_order: [],
|
||||||
|
fixed_pricing: [],
|
||||||
|
tank_cleaning: [],
|
||||||
|
special_arrangements: [],
|
||||||
|
vehicle_subscriptions: [],
|
||||||
|
possible_duplicates: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function suppressVueDevtoolsOverlay(page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const STYLE_ID = "__e2e-hide-vue-devtools";
|
||||||
|
localStorage.setItem("lastVersionCheck", String(Date.now()));
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
const target = document.head || document.documentElement;
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document.getElementById(STYLE_ID)) {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent =
|
||||||
|
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
|
||||||
|
target.appendChild(style);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function primePeriodViewApi(page) {
|
||||||
|
return page.route("**/superuser/invoicing/period**", async (route) => {
|
||||||
|
if (route.request().method() !== "GET") {
|
||||||
|
await route.fallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const dateFrom = url.searchParams.get("dateFrom") || PERIOD_FROM_DATE;
|
||||||
|
|
||||||
|
await route.fulfill(json(createXlVaskFlaggedPeriodPayload({ dateFrom })));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("XL Vask flag → Selvvash navigation", () => {
|
||||||
|
test("@smoke flag token opens the Selvvash tab with the highlighted wash", async ({ page, context }, testInfo) => {
|
||||||
|
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
const token = "superuser-xl-vask-navigation-token";
|
||||||
|
await suppressVueDevtoolsOverlay(page);
|
||||||
|
await seedAuthenticatedState(page, token);
|
||||||
|
await mockApi(page, {
|
||||||
|
authenticated: true,
|
||||||
|
permissions: ["superuser", "user"],
|
||||||
|
loginToken: token,
|
||||||
|
});
|
||||||
|
|
||||||
|
await primePeriodViewApi(page);
|
||||||
|
|
||||||
|
await context.addInitScript((tokenValue) => {
|
||||||
|
window.localStorage.setItem("token", tokenValue);
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
await page.goto(PERIOD_VIEW_URL, { waitUntil: "domcontentloaded" });
|
||||||
|
await expect(page).toHaveURL(/activeTab=period/);
|
||||||
|
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
const customerRow = page.getByTestId("invoicing-period-customer-4001");
|
||||||
|
await expect(customerRow).toBeVisible();
|
||||||
|
|
||||||
|
const flagRow = page.getByTestId("invoice-period-flag-auto-xlvask-missing-1");
|
||||||
|
const flagRowCount = await flagRow.count();
|
||||||
|
test.skip(
|
||||||
|
flagRowCount === 0,
|
||||||
|
"No xlvask_missing_order_link flag is visible in the current period payload; smoke run skips."
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(flagRow).toBeVisible();
|
||||||
|
const flagToken = flagRow.locator(".invoice-period-flag-token");
|
||||||
|
await expect(flagToken).toBeVisible();
|
||||||
|
|
||||||
|
const popupPromise = page.waitForEvent("popup", { timeout: 15_000 });
|
||||||
|
await flagToken.click();
|
||||||
|
const popup = await popupPromise;
|
||||||
|
|
||||||
|
await expect(popup).toHaveURL(SELVVASH_DESTINATION_PATTERN);
|
||||||
|
await popup.waitForLoadState("domcontentloaded").catch(() => {});
|
||||||
|
|
||||||
|
await popup.close();
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,6 +106,26 @@ describe("DepartmentDashboardDailyReportProductSales behavior", () => {
|
|||||||
expect(missingTileWrapper.text()).toContain("Tillæg for Specialsæbe - DD");
|
expect(missingTileWrapper.text()).toContain("Tillæg for Specialsæbe - DD");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to a brand-agnostic wax title for product 26 (TRU-130)", () => {
|
||||||
|
// TRU-130: the previous wax product ("Højglans - Voksforsegling") tested
|
||||||
|
// poorly in the field and is being replaced. The fallback title should
|
||||||
|
// therefore stay brand-agnostic so the tile keeps matching whatever
|
||||||
|
// wax product the API eventually reports.
|
||||||
|
dailyReportProductsState.value = {};
|
||||||
|
|
||||||
|
const waxWrapper = mount(DepartmentDashboardDailyReportProductSales, {
|
||||||
|
props: {
|
||||||
|
product_id: 26,
|
||||||
|
subtitle: "Dagens salg af produktet",
|
||||||
|
dataTestid: "daily-report-tile-hoejglans",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = waxWrapper.text();
|
||||||
|
expect(rendered).toContain("Voksforsegling pr. enhed");
|
||||||
|
expect(rendered).not.toContain("Højglans");
|
||||||
|
});
|
||||||
|
|
||||||
it("shows a saved target percentage below the product percentage", () => {
|
it("shows a saved target percentage below the product percentage", () => {
|
||||||
dailyReportProductsState.value = {
|
dailyReportProductsState.value = {
|
||||||
24: {
|
24: {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Regression guard for TRU-13 / AUT-9: every `invoicing_period.xlvask_review.*`
|
||||||
|
// key that exists in the da reference catalogue must also be present in
|
||||||
|
// the other active locales (no, sv, de, en) with a non-empty translation.
|
||||||
|
// The end-to-end integrity suite already enforces global key parity, but
|
||||||
|
// this targeted test documents the acceptance criteria for the xlvask_review
|
||||||
|
// translation work and surfaces locale-specific gaps immediately.
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const activeLocales = ["da", "no", "sv", "de", "en"];
|
||||||
|
const reviewFileFor = (locale) =>
|
||||||
|
join(root, `src/i18n/source/${locale}/phrases/compat/invoicing_period/xlvask_review.json`);
|
||||||
|
|
||||||
|
const flattenPairs = (node, prefix = "") => {
|
||||||
|
if (typeof node === "string") {
|
||||||
|
return [{ key: prefix, value: node }];
|
||||||
|
}
|
||||||
|
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(node).flatMap(([key, value]) => {
|
||||||
|
const nextPrefix = prefix ? `${prefix}.${key}` : key;
|
||||||
|
return flattenPairs(value, nextPrefix);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const readReviewSection = (locale) => {
|
||||||
|
const file = JSON.parse(readFileSync(reviewFileFor(locale), "utf8"));
|
||||||
|
return file?.compat?.invoicing_period?.xlvask_review ?? {};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("xlvask_review translation coverage", () => {
|
||||||
|
it("covers every da xlvask_review key in no, sv, de, en with a non-empty value", () => {
|
||||||
|
const daEntries = flattenPairs(readReviewSection("da"));
|
||||||
|
expect(daEntries.length, "da xlvask_review should expose translatable strings").toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const locale of activeLocales.filter((entry) => entry !== "da")) {
|
||||||
|
const localeEntries = new Map(flattenPairs(readReviewSection(locale)).map((entry) => [entry.key, entry.value]));
|
||||||
|
|
||||||
|
const missing = [];
|
||||||
|
const empty = [];
|
||||||
|
for (const { key } of daEntries) {
|
||||||
|
if (!localeEntries.has(key)) {
|
||||||
|
missing.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const localized = localeEntries.get(key);
|
||||||
|
if (typeof localized !== "string" || localized.trim().length === 0) {
|
||||||
|
empty.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(missing, `${locale} xlvask_review keys missing from da`).toEqual([]);
|
||||||
|
expect(empty, `${locale} xlvask_review keys with empty translations`).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,8 @@ const i18n = createTestI18n({
|
|||||||
preview: {
|
preview: {
|
||||||
no_order_items: "No order items available.",
|
no_order_items: "No order items available.",
|
||||||
no_xlvask_usage_log: "No XL Vask details available.",
|
no_xlvask_usage_log: "No XL Vask details available.",
|
||||||
|
no_xlvask_usage_log_metadata:
|
||||||
|
"XL Vask registration (no metadata available — see api flag-context serializer)...",
|
||||||
product: "Product",
|
product: "Product",
|
||||||
quantity: "Qty",
|
quantity: "Qty",
|
||||||
price: "Price",
|
price: "Price",
|
||||||
@@ -390,11 +392,90 @@ describe("InvoicingPeriodFlagList", () => {
|
|||||||
|
|
||||||
await token.trigger("click");
|
await token.trigger("click");
|
||||||
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenCalledWith(
|
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenCalledWith(
|
||||||
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=55",
|
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=55&xlvaskUsageLogStartTime=2026-05-11",
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("propagates the flagged wash start time when navigating to the Selvvask view", async () => {
|
||||||
|
const wrapper = mountList([
|
||||||
|
{
|
||||||
|
id: "auto-xlvask-root",
|
||||||
|
source: "automatic",
|
||||||
|
fingerprint: "xlvask-root",
|
||||||
|
definition_key: "xlvask_missing_order_link",
|
||||||
|
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||||
|
target_type: "xlvask_usage_log",
|
||||||
|
target_id: 77,
|
||||||
|
xlvask_usage_log_id: 77,
|
||||||
|
context: {
|
||||||
|
xlvask_usage_log_id: 77,
|
||||||
|
start_time: "2026-04-28T08:15:00",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await wrapper.get(".invoice-period-flag-token").trigger("click");
|
||||||
|
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenLastCalledWith(
|
||||||
|
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=77&xlvaskUsageLogStartTime=2026-04-28",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to Selvvask without a start-time query when the flag has no wash date", async () => {
|
||||||
|
const wrapper = mountList([
|
||||||
|
{
|
||||||
|
id: "auto-xlvask-no-date",
|
||||||
|
source: "automatic",
|
||||||
|
fingerprint: "xlvask-no-date",
|
||||||
|
definition_key: "xlvask_missing_order_link",
|
||||||
|
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||||
|
target_type: "xlvask_usage_log",
|
||||||
|
target_id: 91,
|
||||||
|
xlvask_usage_log_id: 91,
|
||||||
|
context: {
|
||||||
|
xlvask_usage_log_id: 91,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await wrapper.get(".invoice-period-flag-token").trigger("click");
|
||||||
|
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenLastCalledWith(
|
||||||
|
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=91",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the API flag-context serializer does not provide any of the
|
||||||
|
// identity fields (wash_id / registration_number / start_time / customer_name),
|
||||||
|
// the hover preview should not pretend the data is missing — it should
|
||||||
|
// tell the operator that the metadata simply isn't being supplied.
|
||||||
|
it("explains the empty XL Vask hover preview when flag context has no metadata fields", async () => {
|
||||||
|
const wrapper = mountList([
|
||||||
|
{
|
||||||
|
id: "auto-xlvask-empty",
|
||||||
|
source: "automatic",
|
||||||
|
fingerprint: "xlvask-empty-fingerprint",
|
||||||
|
definition_key: "xlvask_missing_order_link",
|
||||||
|
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
|
||||||
|
target_type: "xlvask_usage_log",
|
||||||
|
target_id: 99,
|
||||||
|
xlvask_usage_log_id: 99,
|
||||||
|
context: {},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const token = wrapper.get(".invoice-period-flag-token");
|
||||||
|
expect(token.text()).toBe("XL Vask wash");
|
||||||
|
|
||||||
|
await token.trigger("mouseover");
|
||||||
|
expect(popperBoxMock).toHaveBeenLastCalledWith(
|
||||||
|
"XL Vask registration",
|
||||||
|
"XL Vask registration (no metadata available — see api flag-context serializer)..."
|
||||||
|
);
|
||||||
|
expect(showPopperMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders invoice period warnings in proper Danish", () => {
|
it("renders invoice period warnings in proper Danish", () => {
|
||||||
const wrapper = mountList(
|
const wrapper = mountList(
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { defineComponent, h, nextTick } from "vue";
|
||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
hasStoredSessionToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/services/sessionStorage.js", () => ({
|
||||||
|
hasStoredSessionToken: mocks.hasStoredSessionToken,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/composables/useStoredSessionGuestRedirect.js", () => ({
|
||||||
|
useStoredSessionGuestRedirect: () => ({
|
||||||
|
hasPendingStoredSession: mocks.hasStoredSessionToken(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/forms/auth/LoginForm.vue", () => ({
|
||||||
|
default: defineComponent({
|
||||||
|
name: "MockLoginForm",
|
||||||
|
setup() {
|
||||||
|
return () => h("form", { "data-testid": "login-form" });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/views/errors/ConnectivityIssue.vue", () => ({
|
||||||
|
default: defineComponent({
|
||||||
|
name: "MockConnectivityIssue",
|
||||||
|
setup(_, { slots }) {
|
||||||
|
return () => h("div", { "data-testid": "connectivity-wrapper" }, slots.default?.());
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/global/BrandedLoadingScreen.vue", () => ({
|
||||||
|
default: defineComponent({
|
||||||
|
name: "MockBrandedLoadingScreen",
|
||||||
|
props: {
|
||||||
|
testId: { type: String, default: "branded-loading-screen" },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
return () => h("div", { "data-testid": props.testId });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import Login from "@/views/auth/Login.vue";
|
||||||
|
|
||||||
|
const routerLinkStub = defineComponent({
|
||||||
|
name: "RouterLinkStub",
|
||||||
|
props: {
|
||||||
|
to: {
|
||||||
|
type: [String, Object],
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(_, { slots, attrs }) {
|
||||||
|
return () => h("a", attrs, slots.default?.());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mountLogin = () =>
|
||||||
|
mount(Login, {
|
||||||
|
global: {
|
||||||
|
mocks: {
|
||||||
|
$t: (key) => key,
|
||||||
|
},
|
||||||
|
stubs: {
|
||||||
|
RouterLink: routerLinkStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Login.vue — driver entry block (TRU-61)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.hasStoredSessionToken.mockReset();
|
||||||
|
mocks.hasStoredSessionToken.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the page-level driver entry block on the customer login page", async () => {
|
||||||
|
const wrapper = mountLogin();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const entry = wrapper.find('[data-testid="login-driver-entry"]');
|
||||||
|
expect(entry.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a router-link to the subuser login route inside the driver entry block", async () => {
|
||||||
|
const wrapper = mountLogin();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const link = wrapper.find('[data-testid="login-driver-login-link"]');
|
||||||
|
expect(link.exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the driver login button with the localized driver_login_button label", async () => {
|
||||||
|
const wrapper = mountLogin();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const link = wrapper.find('[data-testid="login-driver-login-link"]');
|
||||||
|
expect(link.text()).toContain("auth.driver_login_button");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the driver login help text inside the entry block", async () => {
|
||||||
|
const wrapper = mountLogin();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const entry = wrapper.find('[data-testid="login-driver-entry"]');
|
||||||
|
expect(entry.text()).toContain("auth.driver_login_help");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places the driver entry block in the main content area (not the sidebar)", async () => {
|
||||||
|
const wrapper = mountLogin();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// The sidebar should NOT contain the driver entry — it must be in main
|
||||||
|
const html = wrapper.html();
|
||||||
|
const entryHtml = wrapper.find('[data-testid="login-driver-entry"]').html();
|
||||||
|
// Find the sidebar's outer container and ensure the entry is not inside it
|
||||||
|
const sidebarMatch = html.match(/<div class="sidebar">[\s\S]*?<\/div>\s*<div class="main">/);
|
||||||
|
if (sidebarMatch) {
|
||||||
|
expect(sidebarMatch[0]).not.toContain("login-driver-entry");
|
||||||
|
}
|
||||||
|
expect(entryHtml).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_RED_CAR_ATTRIBUTE_KEYS,
|
||||||
|
extractAttributeKey,
|
||||||
|
extractAttributeKeys,
|
||||||
|
getRedCarAttributeKeys,
|
||||||
|
isRedCarCustomer,
|
||||||
|
} from "@/composables/redCarDetector.js";
|
||||||
|
|
||||||
|
describe("redCarDetector.extractAttributeKey", () => {
|
||||||
|
it("returns trimmed string for plain string entries", () => {
|
||||||
|
expect(extractAttributeKey("isRedCar")).toBe("isRedCar");
|
||||||
|
expect(extractAttributeKey(" invoiceAllOrdersIndividually ")).toBe("invoiceAllOrdersIndividually");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts the attribute field from object rows", () => {
|
||||||
|
expect(extractAttributeKey({ attribute: "isRedCar" })).toBe("isRedCar");
|
||||||
|
expect(extractAttributeKey({ attribute: "isRedCar", customer_number: 42 })).toBe("isRedCar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to key/name aliases when attribute is missing", () => {
|
||||||
|
expect(extractAttributeKey({ key: "redCar" })).toBe("redCar");
|
||||||
|
expect(extractAttributeKey({ name: "red_car" })).toBe("red_car");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for invalid input", () => {
|
||||||
|
expect(extractAttributeKey(null)).toBe("");
|
||||||
|
expect(extractAttributeKey(undefined)).toBe("");
|
||||||
|
expect(extractAttributeKey(123)).toBe("");
|
||||||
|
expect(extractAttributeKey({})).toBe("");
|
||||||
|
expect(extractAttributeKey({ attribute: "" })).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redCarDetector.extractAttributeKeys", () => {
|
||||||
|
it("returns the attribute key for each row", () => {
|
||||||
|
expect(extractAttributeKeys(["isRedCar", { attribute: "redCar" }, { key: "is_red_car" }])).toEqual([
|
||||||
|
"isRedCar",
|
||||||
|
"redCar",
|
||||||
|
"is_red_car",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out invalid rows and empty keys", () => {
|
||||||
|
expect(
|
||||||
|
extractAttributeKeys([null, undefined, "isRedCar", "", { attribute: "" }, { attribute: " redCar " }])
|
||||||
|
).toEqual(["isRedCar", "redCar"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array for non-array input", () => {
|
||||||
|
expect(extractAttributeKeys(null)).toEqual([]);
|
||||||
|
expect(extractAttributeKeys(undefined)).toEqual([]);
|
||||||
|
expect(extractAttributeKeys("isRedCar")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redCarDetector.isRedCarCustomer (positive cases)", () => {
|
||||||
|
it("flags a customer with the default isRedCar attribute", () => {
|
||||||
|
expect(isRedCarCustomer([{ attribute: "isRedCar" }])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags any of the default attribute keys", () => {
|
||||||
|
for (const key of DEFAULT_RED_CAR_ATTRIBUTE_KEYS) {
|
||||||
|
expect(isRedCarCustomer([{ attribute: key }])).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags when the attribute is a plain string", () => {
|
||||||
|
expect(isRedCarCustomer(["isRedCar"])).toBe(true);
|
||||||
|
expect(isRedCarCustomer(["red_car"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches case-insensitively", () => {
|
||||||
|
expect(isRedCarCustomer([{ attribute: "ISREDCAR" }])).toBe(true);
|
||||||
|
expect(isRedCarCustomer([{ attribute: "isredcar" }])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a customer with a custom configured key", () => {
|
||||||
|
expect(
|
||||||
|
isRedCarCustomer([{ attribute: "needsGentleWash" }], {
|
||||||
|
attributeKeys: ["needsGentleWash"],
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags when at least one matching attribute is present among others", () => {
|
||||||
|
expect(
|
||||||
|
isRedCarCustomer([
|
||||||
|
{ attribute: "invoiceAllOrdersIndividually" },
|
||||||
|
{ attribute: "isRedCar" },
|
||||||
|
{ attribute: "onlyTankCleaning" },
|
||||||
|
])
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redCarDetector.isRedCarCustomer (negative cases)", () => {
|
||||||
|
it("returns false for an empty attribute list", () => {
|
||||||
|
expect(isRedCarCustomer([])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when no attribute matches the default keys", () => {
|
||||||
|
expect(isRedCarCustomer([{ attribute: "onlyTankCleaning" }, { attribute: "invoiceAllOrdersIndividually" }])).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for null/undefined input", () => {
|
||||||
|
expect(isRedCarCustomer(null)).toBe(false);
|
||||||
|
expect(isRedCarCustomer(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when configured keys are all empty strings", () => {
|
||||||
|
expect(isRedCarCustomer([{ attribute: "isRedCar" }], { attributeKeys: ["", " "] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for an unrelated custom key when defaults are used", () => {
|
||||||
|
expect(isRedCarCustomer([{ attribute: "needsGentleWash" }])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a custom key when a different custom key is configured", () => {
|
||||||
|
expect(
|
||||||
|
isRedCarCustomer([{ attribute: "needsGentleWash" }], {
|
||||||
|
attributeKeys: ["isRedCar"],
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redCarDetector.getRedCarAttributeKeys", () => {
|
||||||
|
it("returns the default keys when no override is given", () => {
|
||||||
|
const keys = getRedCarAttributeKeys();
|
||||||
|
expect(keys).toContain("isredcar");
|
||||||
|
expect(keys).toContain("is_red_car");
|
||||||
|
expect(keys).toContain("redcar");
|
||||||
|
expect(keys).toContain("red_car");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes and deduplicates custom keys", () => {
|
||||||
|
const keys = getRedCarAttributeKeys({
|
||||||
|
attributeKeys: [" isRedCar ", "ISREDCAR", "", "redCar"],
|
||||||
|
});
|
||||||
|
expect(keys).toEqual(["isredcar", "redcar"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty array when no valid keys are configured", () => {
|
||||||
|
expect(getRedCarAttributeKeys({ attributeKeys: ["", " "] })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const SOURCE_ROOT = resolve(new URL("../..", import.meta.url).pathname, "src/i18n/source");
|
||||||
|
|
||||||
|
const LOCALES = ["da", "en", "sv", "de", "no"];
|
||||||
|
const REQUIRED_KEYS = ["red_car_warning_title", "red_car_warning_message", "red_car_warning_suggestion"];
|
||||||
|
|
||||||
|
const readSelfWashSource = (locale) => {
|
||||||
|
const filePath = join(SOURCE_ROOT, locale, "phrases/compat/self_wash/index.json");
|
||||||
|
return JSON.parse(readFileSync(filePath, "utf8"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNestedValue = (root, path) => {
|
||||||
|
let current = root;
|
||||||
|
for (const segment of path) {
|
||||||
|
if (current == null || typeof current !== "object") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("red-car warning i18n keys", () => {
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
it(`exposes the required keys in locale '${locale}'`, () => {
|
||||||
|
const messages = readSelfWashSource(locale);
|
||||||
|
const selfWash = messages?.compat?.self_wash ?? {};
|
||||||
|
|
||||||
|
for (const key of REQUIRED_KEYS) {
|
||||||
|
const value = selfWash[key];
|
||||||
|
expect(value, `missing key self_wash.${key} in ${locale}`).toBeDefined();
|
||||||
|
expect(typeof value === "string" && value.trim() !== "", `empty value for self_wash.${key} in ${locale}`).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import RedCarWarning from "@/components/displays/selfServe/RedCarWarning.vue";
|
||||||
|
import { createTestI18n } from "./helpers/mountWithApp.js";
|
||||||
|
|
||||||
|
const BMessageStub = {
|
||||||
|
name: "BMessage",
|
||||||
|
props: {
|
||||||
|
type: { type: String, default: "" },
|
||||||
|
title: { type: String, default: "" },
|
||||||
|
},
|
||||||
|
emits: ["close"],
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="b-message-stub"
|
||||||
|
:data-type="type"
|
||||||
|
:data-title="title"
|
||||||
|
data-testid="red-car-warning"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const factory = (props = {}, messages = {}) => {
|
||||||
|
const i18n = createTestI18n({
|
||||||
|
en: {
|
||||||
|
self_wash: {
|
||||||
|
red_car_warning_title: "Red car on site",
|
||||||
|
red_car_warning_message: "Handle with extra care.",
|
||||||
|
red_car_warning_suggestion: "Use a gentler wash.",
|
||||||
|
...messages.en?.self_wash,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return mount(RedCarWarning, {
|
||||||
|
props,
|
||||||
|
global: {
|
||||||
|
plugins: [i18n],
|
||||||
|
stubs: {
|
||||||
|
BMessage: BMessageStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("RedCarWarning", () => {
|
||||||
|
it("renders the warning when isRedCar is true", () => {
|
||||||
|
const wrapper = factory({ isRedCar: true });
|
||||||
|
expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-testid="red-car-warning-message"]').text()).toBe("Handle with extra care.");
|
||||||
|
expect(wrapper.find('[data-testid="red-car-warning-suggestion"]').text()).toBe("Use a gentler wash.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the warning when isRedCar is false", () => {
|
||||||
|
const wrapper = factory({ isRedCar: false });
|
||||||
|
expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render by default (no prop passed)", () => {
|
||||||
|
const wrapper = factory();
|
||||||
|
expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards the close event from the b-message as a dismiss emit", () => {
|
||||||
|
const wrapper = factory({ isRedCar: true });
|
||||||
|
const bMessage = wrapper.findComponent(BMessageStub);
|
||||||
|
bMessage.vm.$emit("close");
|
||||||
|
expect(wrapper.emitted()).toHaveProperty("dismiss");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,8 +2,16 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
getSelfServeCompletedDynamicImageStep,
|
getSelfServeCompletedDynamicImageStep,
|
||||||
getSelfServeTaskDynamicImagePresentation,
|
getSelfServeTaskDynamicImagePresentation,
|
||||||
|
isSelfServeProgramNumberButton,
|
||||||
normalizeSelfServeProgramPickerTaskButtons,
|
normalizeSelfServeProgramPickerTaskButtons,
|
||||||
|
parseSelfServeDynamicImageThumbPosition,
|
||||||
} from "@/services/selfServeDynamicImage.js";
|
} from "@/services/selfServeDynamicImage.js";
|
||||||
|
import {
|
||||||
|
SELF_SERVE_TASK_BUTTON_OPTIONS,
|
||||||
|
SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
|
||||||
|
SELF_SERVE_TASK_BUTTON_RESET,
|
||||||
|
SELF_SERVE_TASK_BUTTON_START,
|
||||||
|
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
|
||||||
|
|
||||||
describe("selfServeDynamicImage", () => {
|
describe("selfServeDynamicImage", () => {
|
||||||
it("uses a selected program picker button number as thumb position", () => {
|
it("uses a selected program picker button number as thumb position", () => {
|
||||||
@@ -70,4 +78,94 @@ describe("selfServeDynamicImage", () => {
|
|||||||
"start",
|
"start",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Smoke check that the program number range is stable across the codebase.
|
||||||
|
// The wash bay exposes 12 programs (buttons 0-11, thumb positions 1-12).
|
||||||
|
// If this assertion starts failing, the contract with the API has changed
|
||||||
|
// and any program mapping issue (e.g. a program that is unresponsive on
|
||||||
|
// the bay) is likely the API repository's concern, not the frontend.
|
||||||
|
it("accepts every configured program number between 0 and 11", () => {
|
||||||
|
for (let programNumber = 0; programNumber <= 11; programNumber += 1) {
|
||||||
|
expect(isSelfServeProgramNumberButton(programNumber)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(isSelfServeProgramNumberButton(-1)).toBe(false);
|
||||||
|
expect(isSelfServeProgramNumberButton(12)).toBe(false);
|
||||||
|
expect(isSelfServeProgramNumberButton(100)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 1-indexed thumb positions 1..12 to the documented program range", () => {
|
||||||
|
for (let thumbPosition = 1; thumbPosition <= 12; thumbPosition += 1) {
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition(thumbPosition)).toBe(thumbPosition);
|
||||||
|
}
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition(0)).toBeNull();
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition(13)).toBeNull();
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition("not-a-number")).toBeNull();
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition(null)).toBeNull();
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition(undefined)).toBeNull();
|
||||||
|
expect(parseSelfServeDynamicImageThumbPosition("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Smoke check that the program button registry still covers every configured
|
||||||
|
// program. The wash bay exposes 12 programs and the path editor / simulator
|
||||||
|
// UI renders one entry per program number from this array. If the array is
|
||||||
|
// ever shortened, lengthened, or has gaps/duplicates, the program list shown
|
||||||
|
// to operators will drift from the API mapping and operators will not be
|
||||||
|
// able to reach every configured program — which is exactly the failure mode
|
||||||
|
// described in TRU-19 ("FF Uvs" and "10min" unresponsive).
|
||||||
|
it("exposes 12 unique, sequential program entries in the button registry", () => {
|
||||||
|
const numericProgramEntries = SELF_SERVE_TASK_BUTTON_OPTIONS.filter(
|
||||||
|
(entry) => Number.isInteger(entry?.id) && entry.id >= 0 && entry.id <= 11
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(numericProgramEntries).toHaveLength(12);
|
||||||
|
expect(numericProgramEntries.map((entry) => entry.id)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
|
||||||
|
expect(new Set(numericProgramEntries.map((entry) => entry.id)).size).toBe(12);
|
||||||
|
expect(new Set(numericProgramEntries.map((entry) => entry.name)).size).toBe(12);
|
||||||
|
|
||||||
|
// The three "special" buttons (reset / program picker / start) must still
|
||||||
|
// be present so the path editor can render the full UI.
|
||||||
|
const specialIds = new Set(SELF_SERVE_TASK_BUTTON_OPTIONS.map((entry) => entry?.id));
|
||||||
|
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_RESET)).toBe(true);
|
||||||
|
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER)).toBe(true);
|
||||||
|
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_START)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Snapshot lock for the full button-registry shape (TRU-19).
|
||||||
|
//
|
||||||
|
// The wash bay exposes 12 programs and 3 special buttons (reset / program
|
||||||
|
// picker / start). The dashboard renders one entry per program number
|
||||||
|
// from SELF_SERVE_TASK_BUTTON_OPTIONS, so the entire array must remain
|
||||||
|
// exactly 15 entries long: 1 reset + 1 program picker + 12 numeric
|
||||||
|
// programs (ids 0-11) + 1 start.
|
||||||
|
//
|
||||||
|
// If anyone adds, removes, or reorders entries without thinking about
|
||||||
|
// the operator UI and the api mapping, this snapshot will fail and
|
||||||
|
// force a deliberate update. The user-facing program names ("FF Uvs",
|
||||||
|
// "10min", "SF", etc.) live on the wash bay hardware and are NOT in
|
||||||
|
// this array — see TRU-19 and api SelfserveProgramRegistryContractTest.
|
||||||
|
it("locks the full SELF_SERVE_TASK_BUTTON_OPTIONS shape as a snapshot (15 entries, fixed order)", () => {
|
||||||
|
// Exact total length: 1 reset + 1 program picker + 12 programs + 1 start = 15
|
||||||
|
expect(SELF_SERVE_TASK_BUTTON_OPTIONS).toHaveLength(15);
|
||||||
|
|
||||||
|
// Full structural snapshot. The {id, name, description} triples must
|
||||||
|
// remain exactly as below. If you change one, change them all here
|
||||||
|
// deliberately.
|
||||||
|
expect(SELF_SERVE_TASK_BUTTON_OPTIONS.map((entry) => [entry.id, entry.name, entry.description])).toEqual([
|
||||||
|
[SELF_SERVE_TASK_BUTTON_RESET, "Reset", "Reset button"],
|
||||||
|
[SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER, "Program picker", "Program picker wheel"],
|
||||||
|
[0, "Program 1", "Machine button 0"],
|
||||||
|
[1, "Program 2", "Machine button 1"],
|
||||||
|
[2, "Program 3", "Machine button 2"],
|
||||||
|
[3, "Program 4", "Machine button 3"],
|
||||||
|
[4, "Program 5", "Machine button 4"],
|
||||||
|
[5, "Program 6", "Machine button 5"],
|
||||||
|
[6, "Program 7", "Machine button 6"],
|
||||||
|
[7, "Program 8", "Machine button 7"],
|
||||||
|
[8, "Program 9", "Machine button 8"],
|
||||||
|
[9, "Program 10", "Machine button 9"],
|
||||||
|
[10, "Program 11", "Machine button 10"],
|
||||||
|
[11, "Program 12", "Machine button 11"],
|
||||||
|
[SELF_SERVE_TASK_BUTTON_START, "Start", "Start button"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const SCRIPT_PATH = path.resolve("scripts/smoke-test-sergii.sh");
|
||||||
|
const WORKFLOW_PATH = path.resolve(".github/workflows/deploy.yml");
|
||||||
|
const EXISTING_SMOKE_PATH = path.resolve("scripts/smoke-test.sh");
|
||||||
|
|
||||||
|
describe("Sergii Review Batch smoke test (TRU-96)", () => {
|
||||||
|
it("ships a Sergii-specific smoke test script with executable bits set", () => {
|
||||||
|
expect(fs.existsSync(SCRIPT_PATH)).toBe(true);
|
||||||
|
const stat = fs.statSync(SCRIPT_PATH);
|
||||||
|
// owner-execute bit (0o100) is what `chmod +x` sets; we don't care about
|
||||||
|
// the user the test runs as, just that the file is marked executable.
|
||||||
|
expect(stat.mode & 0o111).not.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers every Sergii Review Batch project issue (TRU-68, TRU-72, TRU-75)", () => {
|
||||||
|
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
|
||||||
|
// TRU-68: customer email flow
|
||||||
|
expect(contents).toContain("TRU-68");
|
||||||
|
expect(contents).toMatch(/customer email flow/i);
|
||||||
|
// TRU-72: pages 6-10 review
|
||||||
|
expect(contents).toContain("TRU-72");
|
||||||
|
expect(contents).toMatch(/pages 6-10/);
|
||||||
|
// TRU-75: UVS option
|
||||||
|
expect(contents).toContain("TRU-75");
|
||||||
|
expect(contents).toMatch(/UVS/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes the Sergii-touch surfaces that the Sergii batch changed (pages 6-10)", () => {
|
||||||
|
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
|
||||||
|
// Pages 6-10 — Sergii's batch touched customer, product, order, booking, invoicing.
|
||||||
|
expect(contents).toMatch(/\/admin\/customer/);
|
||||||
|
expect(contents).toMatch(/\/admin\/product/);
|
||||||
|
expect(contents).toMatch(/\/admin\/order/);
|
||||||
|
expect(contents).toMatch(/\/admin\/booking/);
|
||||||
|
expect(contents).toMatch(/\/admin\/invoicing/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes the Sergii-touch customer email flow surfaces (TRU-68)", () => {
|
||||||
|
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
|
||||||
|
expect(contents).toMatch(/\/api\/customer/);
|
||||||
|
expect(contents).toMatch(/\/kundeoprettelse/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("probes the Sergii-touch UVS self-serve surfaces (TRU-75)", () => {
|
||||||
|
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
|
||||||
|
expect(contents).toMatch(/\/self-serve\/program/);
|
||||||
|
expect(contents).toMatch(/\/self-serve\/vehicle/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is wired into the pleno-vue deploy workflow alongside the generic smoke test", () => {
|
||||||
|
const workflow = fs.readFileSync(WORKFLOW_PATH, "utf8");
|
||||||
|
expect(workflow).toMatch(/smoke-test-sergii\.sh/);
|
||||||
|
expect(workflow).toMatch(/Sergii Review Batch smoke test \(TRU-96\)/);
|
||||||
|
// Generic smoke test still runs first.
|
||||||
|
expect(workflow).toMatch(/smoke-test\.sh "\$SMOKE_BASE_URL"/);
|
||||||
|
// Auto-rollback now also triggers on Sergii smoke failure.
|
||||||
|
expect(workflow).toMatch(/steps\.smoke\.outcome == 'failure' \|\| steps\.smoke_sergii\.outcome == 'failure'/);
|
||||||
|
// Slack status message distinguishes the two failure modes.
|
||||||
|
expect(workflow).toMatch(/Generic smoke FAILED/);
|
||||||
|
expect(workflow).toMatch(/Sergii Review Batch smoke FAILED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate the generic smoke test (it complements, not replaces)", () => {
|
||||||
|
expect(fs.existsSync(EXISTING_SMOKE_PATH)).toBe(true);
|
||||||
|
const generic = fs.readFileSync(EXISTING_SMOKE_PATH, "utf8");
|
||||||
|
const sergii = fs.readFileSync(SCRIPT_PATH, "utf8");
|
||||||
|
// Generic covers login + /api/ping + /healthz; Sergii covers the Sergii
|
||||||
|
// Review Batch surfaces. They are intentionally different sets.
|
||||||
|
expect(generic).toMatch(/\/login/);
|
||||||
|
expect(sergii).toMatch(/\/admin\/customer/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits non-zero when any Sergii-touch surface fails, so auto-rollback can fire", () => {
|
||||||
|
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
|
||||||
|
expect(contents).toMatch(/FAIL=1/);
|
||||||
|
expect(contents).toMatch(/exit 1/);
|
||||||
|
expect(contents).toMatch(/Sergii Review Batch smoke test FAILED/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to https://staging.truckwash.io so it is safe to run unattended", () => {
|
||||||
|
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
|
||||||
|
expect(contents).toMatch(/SMOKE_BASE_URL:-\$\{1:-https:\/\/staging\.truckwash\.io\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -577,8 +577,11 @@ describe("Periode tab contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("uses the selected period while keeping Selvvask results server-paginated", () => {
|
it("uses the selected period while keeping Selvvask results server-paginated", () => {
|
||||||
expect(periodViewSelfWashSource).toContain(':initial-date-from="dates.computed.formattedStartDate.value"');
|
expect(periodViewSelfWashSource).toContain(':initial-date-from="initialDateFrom"');
|
||||||
expect(periodViewSelfWashSource).toContain(':initial-date-to="dates.computed.formattedEndDate.value"');
|
expect(periodViewSelfWashSource).toContain(':initial-date-to="initialDateTo"');
|
||||||
|
expect(periodViewSelfWashSource).toContain("dates.computed.formattedStartDate.value");
|
||||||
|
expect(periodViewSelfWashSource).toContain("dates.computed.formattedEndDate.value");
|
||||||
|
expect(periodViewSelfWashSource).toContain("route.query.xlvaskUsageLogStartTime");
|
||||||
expect(periodViewSelfWashSource).toContain(':inherit-period-filters="true"');
|
expect(periodViewSelfWashSource).toContain(':inherit-period-filters="true"');
|
||||||
expect(periodViewSelfWashSource).toContain(':load-all-at-once="false"');
|
expect(periodViewSelfWashSource).toContain(':load-all-at-once="false"');
|
||||||
expect(xlvaskUsagePaginationSource).toContain("inheritPeriodFilters");
|
expect(xlvaskUsagePaginationSource).toContain("inheritPeriodFilters");
|
||||||
@@ -597,6 +600,21 @@ describe("Periode tab contract", () => {
|
|||||||
expect(xlvaskUsagePaginationSource).toContain("buildUsagePaginationParams()");
|
expect(xlvaskUsagePaginationSource).toContain("buildUsagePaginationParams()");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("widens the date window around a flagged wash start time to ±7 days (TRU-10)", () => {
|
||||||
|
// The original spec called for a [start_time - 7d, start_time + 7d] window
|
||||||
|
// so the highlighted wash is always visible even when it spans midnight
|
||||||
|
// or falls on the edge of the calendar day.
|
||||||
|
expect(periodViewSelfWashSource).toContain("FLAGGED_WASH_DATE_WINDOW_DAYS");
|
||||||
|
expect(periodViewSelfWashSource).toContain("FLAGGED_WASH_DATE_WINDOW_DAYS = 7");
|
||||||
|
expect(periodViewSelfWashSource).toContain("const shiftDateOnly");
|
||||||
|
expect(periodViewSelfWashSource).toContain(
|
||||||
|
"shiftDateOnly(flaggedWashStartDate.value, -FLAGGED_WASH_DATE_WINDOW_DAYS)"
|
||||||
|
);
|
||||||
|
expect(periodViewSelfWashSource).toContain(
|
||||||
|
"shiftDateOnly(flaggedWashStartDate.value, FLAGGED_WASH_DATE_WINDOW_DAYS)"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("loads Selvvask selector counts and progress from the selected period", () => {
|
it("loads Selvvask selector counts and progress from the selected period", () => {
|
||||||
expect(periodRightSource).toContain("normalizeXlvaskAutopilotSummary");
|
expect(periodRightSource).toContain("normalizeXlvaskAutopilotSummary");
|
||||||
expect(periodRightSource).toContain("const selfWashCounts = ref(emptySelectorCounts())");
|
expect(periodRightSource).toContain("const selfWashCounts = ref(emptySelectorCounts())");
|
||||||
@@ -684,6 +702,27 @@ describe("Periode tab contract", () => {
|
|||||||
expect(xlvaskUsageOrdersTableSource).not.toContain("'preview'");
|
expect(xlvaskUsageOrdersTableSource).not.toContain("'preview'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a friendly notice when the orders endpoint returns 404", () => {
|
||||||
|
// The pagination must distinguish a 404 ("API endpoint not implemented
|
||||||
|
// yet") from other failures and surface a friendly notice instead of
|
||||||
|
// the generic error popper every other paginated surface uses.
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain("apiEndpointNotImplemented");
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain("lastError");
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain("404");
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain("xlvask_review.errors.api_endpoint_not_implemented");
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain("xlvask-api-not-implemented");
|
||||||
|
expect(xlvaskUsagePaginationSource).toContain('removeError("paginatedGetRequest")');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ships the API-not-implemented notice in every supported locale", () => {
|
||||||
|
// The friendly notice must be present in all five source locales so
|
||||||
|
// the operator reads a real translation rather than a raw key.
|
||||||
|
for (const locale of localeMessages) {
|
||||||
|
const notice = locale.messages?.invoicing_period?.xlvask_review?.errors?.api_endpoint_not_implemented;
|
||||||
|
expect(notice, `locale ${locale.locale} missing api_endpoint_not_implemented`).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps period subview navigation on valid keys", () => {
|
it("keeps period subview navigation on valid keys", () => {
|
||||||
expect(periodViewInvoicePerOrderSource).toContain("view.functions.setCurrentView('vehicle_subscriptions')");
|
expect(periodViewInvoicePerOrderSource).toContain("view.functions.setCurrentView('vehicle_subscriptions')");
|
||||||
expect(periodViewSpecialArrangementsSource).toContain("view.functions.setCurrentView('vehicle_subscriptions')");
|
expect(periodViewSpecialArrangementsSource).toContain("view.functions.setCurrentView('vehicle_subscriptions')");
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const readSource = (relativePath) => readFileSync(join(root, relativePath), "utf8");
|
||||||
|
|
||||||
|
describe("xlvask usage pagination department selector propagation", () => {
|
||||||
|
it("declares a departmentId prop on XLVaskUsagePagination", () => {
|
||||||
|
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||||
|
|
||||||
|
expect(source).toMatch(/departmentId:\s*\{\s*type:\s*Number,\s*default:\s*0\s*\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies the HallId filter when the departmentId prop is provided", () => {
|
||||||
|
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||||
|
|
||||||
|
// `effectiveDepartmentId` is now a computed (so the value is `.value`)
|
||||||
|
// and is wired into a watcher that re-applies the HallId filter
|
||||||
|
// whenever the department changes. The initial setup also seeds
|
||||||
|
// the filter from the computed.
|
||||||
|
expect(source).toMatch(/effectiveDepartmentId\.value\s*>\s*0/);
|
||||||
|
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*effectiveDepartmentId\.value\s*,\s*false\s*\)/);
|
||||||
|
expect(source).toMatch(/watch\(\s*effectiveDepartmentId\s*,/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the departmentId route param when the prop is not provided", () => {
|
||||||
|
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||||
|
|
||||||
|
expect(source).toMatch(/router\.currentRoute\.value\.params\.departmentId/);
|
||||||
|
expect(source).toMatch(/Number\.parseInt\(\s*String\(router\.currentRoute\.value\.params\.departmentId/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply the HallId filter when no departmentId is provided", () => {
|
||||||
|
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||||
|
|
||||||
|
// `effectiveDepartmentId` is a computed (not a plain const) so the
|
||||||
|
// value is `.value`, and it is read through `routeDepartmentId.value`
|
||||||
|
// for the route-param fallback.
|
||||||
|
expect(source).toMatch(
|
||||||
|
/const\s+effectiveDepartmentId\s*=\s*computed\(\s*\(\)\s*=>\s*[\s\S]*?props\.departmentId\s*>\s*0[\s\S]*?routeDepartmentId\.value\s*>\s*0[\s\S]*?:\s*0\s*\)/
|
||||||
|
);
|
||||||
|
expect(source).toMatch(/if\s*\(effectiveDepartmentId\.value\s*>\s*0\)\s*\{\s*setFilter\(\s*["']HallId["']/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DepartmentPosSync forwards the URL departmentId to XLVaskUsagePagination", () => {
|
||||||
|
const source = readSource("src/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSync.vue");
|
||||||
|
|
||||||
|
expect(source).toMatch(
|
||||||
|
/<XLVaskUsagePagination[^>]*:department-id="SessionUser\.functions\.getDepartmentIdFromUrl\(\)"/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("InvoicingBillingPeriodViewSelfWash does not pass a departmentId", () => {
|
||||||
|
const source = readSource(
|
||||||
|
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewSelfWash.vue"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(source).not.toMatch(/department-id[\s=]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import axios from "axios";
|
||||||
|
import { usePaginatedList } from "@/components/pagination/paginatedList.vue";
|
||||||
|
|
||||||
|
vi.mock("axios", () => ({
|
||||||
|
default: {
|
||||||
|
get: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("paginatedList tracks the last error so consumers can detect 404", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem("token", "test-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a reactive lastError ref and clears it on success", async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: { data: [{ id: 1 }], meta: { pagination: { page: 1, per_page: 100, total: 1 } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = usePaginatedList();
|
||||||
|
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||||
|
expect(list.lastError.value).toBeNull();
|
||||||
|
|
||||||
|
await list.paginatedGetRequest();
|
||||||
|
|
||||||
|
expect(list.lastError.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures the HTTP status of a failed request for the current endpoint", async () => {
|
||||||
|
axios.get.mockRejectedValueOnce({
|
||||||
|
response: { status: 404, data: { data: { message: "Not Found" } } },
|
||||||
|
message: "Request failed with status code 404",
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = usePaginatedList();
|
||||||
|
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||||
|
|
||||||
|
const result = await list.paginatedGetRequest();
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(list.lastError.value).toEqual({
|
||||||
|
status: 404,
|
||||||
|
endpoint: "/modules/xlvask/services/usage/orders",
|
||||||
|
message: "Not Found",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets lastError at the start of every request", async () => {
|
||||||
|
axios.get
|
||||||
|
.mockRejectedValueOnce({
|
||||||
|
response: { status: 500, data: { data: { message: "Server Error" } } },
|
||||||
|
message: "Request failed with status code 500",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: { data: [], meta: { pagination: { page: 1, per_page: 100, total: 0 } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = usePaginatedList();
|
||||||
|
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||||
|
|
||||||
|
await list.paginatedGetRequest();
|
||||||
|
expect(list.lastError.value?.status).toBe(500);
|
||||||
|
|
||||||
|
await list.paginatedGetRequest();
|
||||||
|
expect(list.lastError.value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set lastError when the request was cancelled", async () => {
|
||||||
|
axios.get.mockRejectedValueOnce({
|
||||||
|
name: "CanceledError",
|
||||||
|
code: "ERR_CANCELED",
|
||||||
|
message: "canceled",
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = usePaginatedList();
|
||||||
|
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||||
|
|
||||||
|
await list.paginatedGetRequest();
|
||||||
|
|
||||||
|
expect(list.lastError.value).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
|
||||||
|
|
||||||
|
describe("XLVaskUsagePagination department (HallId) propagation", () => {
|
||||||
|
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
|
||||||
|
|
||||||
|
it("reacts to department changes via a computed effectiveDepartmentId", () => {
|
||||||
|
// The department must be derived reactively from the prop and the
|
||||||
|
// route param, not captured once at setup time.
|
||||||
|
expect(source).toMatch(
|
||||||
|
/const\s+effectiveDepartmentId\s*=\s*computed\(\s*\(\)\s*=>\s*[\s\S]*?props\.departmentId[\s\S]*?routeDepartmentId\.value[\s\S]*?\)\s*\)/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("watches the effective department and re-applies the HallId filter", () => {
|
||||||
|
// The component must watch the computed effective department so
|
||||||
|
// changing the department selector re-issues the query with the
|
||||||
|
// new department in the filter.
|
||||||
|
expect(source).toMatch(/watch\(\s*effectiveDepartmentId\s*,/);
|
||||||
|
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*nextDepartmentId\s*,\s*false\s*\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-issues the usage query when the department changes", () => {
|
||||||
|
// The watch handler must trigger both loadList and loadSummary so
|
||||||
|
// the visible orders and the summary cards both reflect the new
|
||||||
|
// department.
|
||||||
|
const watchBlockMatch = source.match(/watch\(\s*effectiveDepartmentId\s*,[\s\S]*?\n\)\s*;/);
|
||||||
|
expect(watchBlockMatch).not.toBeNull();
|
||||||
|
const watchBlock = watchBlockMatch?.[0] ?? "";
|
||||||
|
expect(watchBlock).toMatch(/loadList\s*\(\s*\)/);
|
||||||
|
expect(watchBlock).toMatch(/loadSummary\s*\(\s*\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the HallId filter when the department is unset", () => {
|
||||||
|
// When the new department is 0 / unset, the filter must be cleared
|
||||||
|
// (setFilter with '*' removes the key) so the query is not bound
|
||||||
|
// to a stale department.
|
||||||
|
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*["']\*["']\s*,\s*false\s*\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes the active department in the summary query params", () => {
|
||||||
|
// The summary endpoint must also be re-issued with the new
|
||||||
|
// department; otherwise the summary cards show stale counts.
|
||||||
|
expect(source).toMatch(/HallId:\s*effectiveDepartmentId\.value/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>AUT-1 primary-button hover preview</title>
|
||||||
|
<!--
|
||||||
|
Bulma 1.x stylesheet from the project's node_modules so the preview
|
||||||
|
matches the real app's default theme. The :root override is the same
|
||||||
|
--bulma-primary-h override that lives in src/assets/main.css so the
|
||||||
|
primary colour renders as the brand teal-blue (#0787BB ≈ HSL 197deg).
|
||||||
|
-->
|
||||||
|
<link rel="stylesheet" href="../../../node_modules/bulma/css/bulma.min.css" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bulma-primary-h: 197deg;
|
||||||
|
--bulma-primary-s: 92%;
|
||||||
|
--bulma-primary-l: 38%;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: #f5f5f5;
|
||||||
|
margin: 0;
|
||||||
|
padding: 48px 64px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
.stage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 48px;
|
||||||
|
background: white;
|
||||||
|
padding: 40px 56px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(10, 10, 10, 0.12);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #7a7a7a;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.button.is-primary {
|
||||||
|
min-width: 168px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="stage-idle" class="stage" data-state="idle">
|
||||||
|
<div class="label">Primary button — default</div>
|
||||||
|
<button class="button is-primary" data-testid="primary-button-idle">
|
||||||
|
Confirm booking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="height: 32px"></div>
|
||||||
|
<div id="stage-hover" class="stage" data-state="hover">
|
||||||
|
<div class="label">Primary button — hover</div>
|
||||||
|
<button class="button is-primary" data-testid="primary-button-hover">
|
||||||
|
Confirm booking
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||