Compare commits

..
Author SHA1 Message Date
Jeppe 9377e1dcf2 fix(i18n): add no_xlvask_usage_log_metadata to all 4 active locales
The first PR #313 introduced only the da translation for the
'no_xlvask_usage_log_metadata' preview key. The
i18n-v2-integrity.spec.ts test 'da v2 key set should match en v2'
failed because the en (and de, no, sv) locales were missing the
new key, leaving their generated v2 files out of sync.

This commit adds the key to all 4 active locales:
  - en: 'No metadata for XL Vask registration.'
  - de: 'Keine Metadaten für XL Vask-Registrierung.'
  - no: 'Ingen metadata for XL Vask-registrering.'
  - sv: 'Ingen metadata för XL Vask-registrering.'

Each locale delegates the registration label to the existing
xlvask_usage_log key (or glossary equivalent). Sources are now
consistent across da, de, en, no, sv.

Resolves the full master CI green-status attempt.
2026-08-16 10:28:08 +02:00
Jeppe b9e2a2038d fix(i18n): make no_xlvask_usage_log_metadata a linked reference in global fallback
PR #311 (TRU-8 / AUT-4) added a new xlvask_usage_log entry to
src/i18n/source/global/shared/invoice_period/flags.json as a literal
Danish string. The integration test in
tests/e2e/i18n-v2-integrity.spec.ts (added in PR #306 / TRU-9)
asserts that EVERY xlvask_usage_log/xlvask_missing_order_link entry
in the global fallback MUST be a '@:' linked reference, never a
literal value. The literal string broke the test, which is in turn
blocking the develop→master CI gate.

This commit:
- Adds the key to the da source as an actual i18n entry
- Converts the global entry to '@:{'phrases.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}'
- Re-runs i18n:v2:compile to regenerate src/i18n/generated/global-v2.json

Resolves the test failure on master after PR #312.
2026-08-16 09:38:51 +02:00
51 changed files with 146 additions and 1505 deletions
+66
View File
@@ -0,0 +1,66 @@
name: Qodana
on:
workflow_dispatch:
pull_request:
branches: [master, beta, canary, internal]
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [master, beta, canary, internal]
permissions:
contents: read
checks: write
pull-requests: write
concurrency:
group: qodana-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: true
jobs:
qodana:
name: Qodana
if: >-
github.event_name != 'pull_request' ||
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login != 'dependabot[bot]'
)
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout repository
# v5.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Require Qodana project token
shell: bash
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
run: |
set -euo pipefail
if [[ -z "${QODANA_TOKEN:-}" ]]; then
echo "::error::QODANA_TOKEN is not configured for this repository."
exit 1
fi
- name: Qodana
# v2026.1.3
uses: JetBrains/qodana-action@b588768b6e7e6da579e518bc584f79de0d243692
with:
use-caches: true
cache-default-branch-only: true
upload-result: false
use-annotations: true
pr-mode: ${{ github.event_name == 'pull_request' }}
post-pr-comment: true
github-token: ${{ github.token }}
push-fixes: none
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
-112
View File
@@ -1,112 +0,0 @@
name: Deploy pleno-vue to Hetzner (staging)
on:
push:
branches: [master]
workflow_dispatch:
concurrency:
group: deploy-pleno-vue
cancel-in-progress: false
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
jobs:
test-and-deploy:
name: Build + Deploy
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'npm'
- name: Install + Build
run: |
npm ci --ignore-scripts
npm run build
- name: Setup SSH
uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Add host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Pre-deploy snapshot
id: pre
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git rev-parse HEAD > /tmp/last_deploy_sha
echo "pre_sha=$(cat /tmp/last_deploy_sha)" >> $GITHUB_OUTPUT
'
- name: Deploy
id: deploy
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git fetch origin master
git reset --hard origin/master
npm ci --ignore-scripts
npm run build
sudo systemctl reload nginx || true
sudo systemctl reload pleno-vue || true
echo "Deploy complete: $(git rev-parse --short HEAD)"
'
- name: Smoke test
id: smoke
continue-on-error: true
env:
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
run: |
bash scripts/smoke-test.sh "$SMOKE_BASE_URL"
- name: Sergii Review Batch smoke test (TRU-96)
id: smoke_sergii
continue-on-error: true
env:
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
run: |
bash scripts/smoke-test-sergii.sh "$SMOKE_BASE_URL"
- name: Auto-rollback on smoke failure
if: steps.smoke.outcome == 'failure' || steps.smoke_sergii.outcome == 'failure'
run: |
reason="$([ "${{ steps.smoke.outcome }}" = 'failure' ] && echo 'generic smoke' || echo 'Sergii Review Batch smoke')"
echo "::error::$reason test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}"
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git reset --hard ${{ steps.pre.outputs.pre_sha }}
npm ci --ignore-scripts
npm run build
sudo systemctl reload nginx || true
'
- name: Post Slack status
if: always()
uses: slackapi/slack-github-action@v4.0.0
with:
channel-id: ${{ secrets.AI_DAILY_CHANNEL }}
payload: |
{
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *pleno-vue@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Generic smoke FAILED → auto-rolled back' || steps.smoke_sergii.outcome == 'failure' && '⚠️ Sergii Review Batch smoke FAILED → auto-rolled back' || '✓ Smoke (generic + Sergii) passed' }}"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
+1 -1
View File
@@ -1,4 +1,4 @@
source "https://rubygems.org"
ruby ">= 3.2", "< 3.5"
gem "fastlane", "2.238.0"
gem "fastlane", "2.237.0"
+53 -69
View File
@@ -8,8 +8,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1281.0)
aws-sdk-core (3.254.1)
aws-partitions (1.1271.0)
aws-sdk-core (3.254.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -20,8 +20,8 @@ GEM
aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.229.0)
aws-sdk-core (~> 3, >= 3.254.1)
aws-sdk-s3 (1.228.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -35,33 +35,45 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.6)
csv (3.3.5)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
erb (6.0.7)
excon (1.7.0)
logger
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
excon (1.6.0)
logger
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.238.0)
fastlane (2.237.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
@@ -77,11 +89,9 @@ GEM
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 2.0.0)
faraday (~> 2.7)
faraday-cookie_jar (~> 0.0.8)
faraday-follow_redirects (~> 0.3)
faraday-multipart (~> 1.0)
faraday-retry (~> 2.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
@@ -91,7 +101,6 @@ GEM
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
irb (>= 1.8)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
@@ -108,7 +117,7 @@ GEM
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 4)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
@@ -117,22 +126,21 @@ GEM
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.106.0)
google-apis-androidpublisher_v3 (0.105.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (1.2.5)
addressable (~> 2.9)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
multi_json (~> 1.11)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 3.1, < 5.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.66.0)
google-apis-storage_v1 (0.65.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
@@ -151,7 +159,7 @@ GEM
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.0)
googleauth (1.17.3)
googleauth (1.17.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
@@ -162,14 +170,10 @@ GEM
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
io-console (0.9.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.21.2)
json (2.21.1)
jwt (3.2.0)
base64
logger (1.7.0)
@@ -180,38 +184,22 @@ GEM
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
net-http (0.9.1)
uri (>= 0.11.1)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
reline (0.7.0)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (4.2.0)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
@@ -222,19 +210,15 @@ GEM
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tsort (0.2.0)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
@@ -255,7 +239,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
fastlane (= 2.238.0)
fastlane (= 2.237.0)
RUBY VERSION
ruby 3.3.12p206
+1 -1
View File
@@ -45,7 +45,7 @@ platform :ios do
skip_screenshots: false,
overwrite_screenshots: true,
force: true,
submit_for_review: true,
submit_for_review: false,
automatic_release: true,
phased_release: false,
run_precheck_before_submit: false,
+1 -1
View File
@@ -1,5 +1,5 @@
{
"marketingVersion": "1.1.0",
"marketingVersion": "1.0.1",
"bundleId": "io.truckwash.app",
"minimumIosVersion": "15.0"
}
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env bash
# Sergii Review Batch smoke test (TRU-96).
#
# Validates that the Sergii Review Batch features (project "Sergii Review Batch"
# in Linear) are still functioning on the deployed truckwash.io dashboard
# after a release.
#
# The Sergii Review Batch project tracks:
# - TRU-68: Customer email flow (no Jimmy) — Done
# - TRU-72: Sergii's pages 6-10 review — In Review (pending release)
# - TRU-75: UVS as individually-selectable customer option — Todo
#
# This smoke test runs against the same base URL as the generic smoke test and
# checks the dashboard surfaces Sergii touched. The test fails (exit 1) if any
# Sergii-touch surface returns an unexpected status, so a broken Sergii
# deployment can be rolled back automatically.
#
# Usage: ./scripts/smoke-test-sergii.sh [base_url]
# Default: https://staging.truckwash.io
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-2xx}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
elif [[ "$status" == "$expected" ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Sergii Review Batch smoke test against $BASE_URL"
echo " (project: Sergii Review Batch — TRU-68, TRU-72, TRU-75)"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Sergii pages 6-10 (TRU-72) ===
# Sergii's batch touched the customer, products, and orders surfaces of the
# dashboard. Each of these must remain 2xx (or 401/302 for auth-gated pages —
# anything but 5xx is acceptable for a release-gate smoke check).
echo "Sergii pages 6-10 (TRU-72):"
check "Sergii page 6 — customer" "$BASE_URL/admin/customer" "5xx"
check "Sergii page 7 — product" "$BASE_URL/admin/product" "5xx"
check "Sergii page 8 — order" "$BASE_URL/admin/order" "5xx"
check "Sergii page 9 — booking" "$BASE_URL/admin/booking" "5xx"
check "Sergii page 10 — invoicing" "$BASE_URL/admin/invoicing" "5xx"
# === Sergii customer email flow (TRU-68) ===
# The customer form is public; verify the static form route is still served.
echo
echo "Sergii customer email flow (TRU-68):"
check "customer list (Sergii touched)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form (Sergii touched)" "$BASE_URL/kundeoprettelse" "2xx"
# === Sergii UVS program option (TRU-75) ===
# The UVS wash program must be exposed in the public self-serve program
# registry so customers can pick it individually (not bundled into FF).
echo
echo "Sergii UVS program option (TRU-75):"
check "self-serve program registry" "$BASE_URL/self-serve/program" "2xx"
check "self-serve vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Sergii Review Batch sanity — dashboard still bootstraps ===
echo
echo "Dashboard bootstrap:"
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
check "login page" "$BASE_URL/login" "2xx"
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ Sergii Review Batch smoke test passed${NC}"
exit 0
else
echo -e "${RED}✗ Sergii Review Batch smoke test FAILED${NC}"
echo " A Sergii-touch surface on $BASE_URL is broken."
echo " Consider rolling back the most recent Sergii batch."
exit 1
fi
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env bash
# Generic smoke test for any deployed app.
#
# Usage: ./scripts/smoke-test.sh [base_url]
# Default: https://staging.truckwash.io
#
# Required env vars (set by GitHub Action):
# SMOKE_BASE_URL - base URL to test (default: https://staging.truckwash.io)
#
# Optional env vars:
# SMOKE_TOKEN - bearer token for authenticated checks
# SMOKE_TIMEOUT - curl timeout in seconds (default: 10)
#
# Exits 0 on all-pass, 1 on any failure.
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-200}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$status" =~ ^($expected)$ ]] || [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Smoke test against $BASE_URL"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Health endpoints (universal) ===
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
# === Authentication (should NOT 500) ===
check "login page" "$BASE_URL/login" "2xx"
# === Public endpoints (api repo) ===
check "customer list (public schema)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form" "$BASE_URL/kundeoprettelse" "2xx"
# === Public endpoints (pleno-vue) ===
check "self-serve program picker" "$BASE_URL/self-serve/program" "2xx"
check "vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Custom 404 should not 500 ===
check "404 page" "$BASE_URL/this-route-does-not-exist" "404"
# === Optional authenticated check ===
if [ -n "${SMOKE_TOKEN:-}" ]; then
check "auth check" "$BASE_URL/api/me" "2xx"
fi
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ All smoke tests passed${NC}"
exit 0
else
echo -e "${RED}✗ Some smoke tests failed${NC}"
exit 1
fi
@@ -27,7 +27,7 @@ import productImage21 from "/src/assets/piktogrammer/21.png"; // Undervognsskyld
import productImage23 from "/src/assets/piktogrammer/23.png"; // Spotfree
import productImage24 from "/src/assets/piktogrammer/24.png"; // Spotfree
import productImage25 from "/src/assets/piktogrammer/25.png"; // Fælg flex
import productImage26 from "/src/assets/piktogrammer/26.png"; // Voksforsegling (TRU-130: brand-agnostic after wax supplier swap)
import productImage26 from "/src/assets/piktogrammer/26.png"; // Højglans
import productImage27 from "/src/assets/piktogrammer/27.png"; // 10 Minutter
import productImage39 from "/src/assets/piktogrammer/39.png"; // Trailer - Varevogn
import productImage43 from "/src/assets/piktogrammer/43.png"; // Handling of materials
@@ -103,20 +103,19 @@ if (props.hideSearch) {
setHideSearchField(false);
}
const routeDepartmentId = computed(() => Number.parseInt(
const routeDepartmentId = Number.parseInt(
String(router.currentRoute.value.params.departmentId ?? ""),
10
));
const effectiveDepartmentId = computed(() =>
);
const effectiveDepartmentId =
props.departmentId > 0
? props.departmentId
: Number.isInteger(routeDepartmentId.value) && routeDepartmentId.value > 0
? routeDepartmentId.value
: 0
);
: Number.isInteger(routeDepartmentId) && routeDepartmentId > 0
? routeDepartmentId
: 0;
if (effectiveDepartmentId.value > 0) {
setFilter("HallId", effectiveDepartmentId.value, false);
if (effectiveDepartmentId > 0) {
setFilter("HallId", effectiveDepartmentId, false);
}
setOrder("StartTime", "desc");
@@ -132,7 +131,6 @@ const buildUsagePaginationParams = (extra = {}) => ({
...extra,
dateFrom: props.initialDateFrom || formatLocalDateOnly(dateFrom.value),
dateTo: props.initialDateTo || formatLocalDateOnly(dateTo.value),
...(effectiveDepartmentId.value > 0 ? { HallId: effectiveDepartmentId.value } : {}),
});
const loadSummary = async () => {
@@ -217,31 +215,6 @@ 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(() => {
window.addEventListener("xlvask-usage-order-updated", () => {
loadSummary();
@@ -1,44 +0,0 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps({
isRedCar: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
customerNumber: {
type: [String, Number, null],
default: null,
},
});
const emit = defineEmits(["dismiss"]);
const { t } = useI18n();
const shouldRender = computed(() => Boolean(props.isRedCar));
</script>
<template>
<b-message
v-if="shouldRender"
type="is-warning"
has-icon
:closable="true"
data-testid="red-car-warning"
:title="t('self_wash.red_car_warning_title')"
@close="emit('dismiss')"
>
<p data-testid="red-car-warning-message">
{{ t("self_wash.red_car_warning_message") }}
</p>
<p class="mt-2" data-testid="red-car-warning-suggestion">
{{ t("self_wash.red_car_warning_suggestion") }}
</p>
</b-message>
</template>
@@ -2,7 +2,6 @@
import { computed, ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
import RedCarWarning from "@/components/displays/selfServe/RedCarWarning.vue";
const props = defineProps<{
customerNumber: string | number | null;
@@ -18,7 +17,6 @@ const props = defineProps<{
vehicleTypes: Array<any>;
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
isRedCar?: boolean;
}>();
const emit = defineEmits<{
@@ -143,11 +141,6 @@ const emitVehicleTypeSelection = (selection: any) => {
>
{{ props.vehicleStepError }}
</b-message>
<RedCarWarning
v-if="!props.vehicleStepError && props.isRedCar"
:is-red-car="true"
:customer-number="customerNumber"
/>
<b-message
v-else-if="props.vehicleStepGuidance"
type="is-info"
-115
View File
@@ -1,115 +0,0 @@
/**
* Red-car detection utilities for the customer portal.
*
* SENERE 7 / TRU-99: Until Mads defines the canonical rule for
* identifying a "red car" (e.g. plate scan, color tag, manual
* customer flag), we use a manual customer-attribute flag as the
* default heuristic. The attribute keys that trigger the warning
* are configurable so the rule can be tightened later without
* touching call sites.
*/
/**
* Default attribute keys that mark a customer as owning a red car.
* Multiple keys are supported to give staff a few options when
* tagging accounts in the customer-attribute editor.
*/
export const DEFAULT_RED_CAR_ATTRIBUTE_KEYS = Object.freeze([
"isRedCar",
"is_red_car",
"redCar",
"red_car",
]);
const normalizeKey = (key) => String(key ?? "").trim();
const toNormalizedKeySet = (keys) => {
const set = new Set();
if (!Array.isArray(keys)) {
return set;
}
for (const key of keys) {
const normalized = normalizeKey(key).toLowerCase();
if (normalized) {
set.add(normalized);
}
}
return set;
};
/**
* Extract the attribute key string from a single customer-attribute
* row. Accepts both the API shape (object with `attribute`) and the
* plain-string shorthand used in some call sites.
*
* @param {string|object|undefined|null} entry
* @returns {string}
*/
export const extractAttributeKey = (entry) => {
if (typeof entry === "string") {
return normalizeKey(entry);
}
if (entry && typeof entry === "object") {
const candidate = entry.attribute ?? entry.key ?? entry.name;
return normalizeKey(candidate);
}
return "";
};
/**
* Extract the attribute key strings from a list of customer
* attributes. Returns an empty array for invalid input.
*
* @param {Array<string|object|undefined|null>|null|undefined} attributes
* @returns {string[]}
*/
export const extractAttributeKeys = (attributes) => {
if (!Array.isArray(attributes)) {
return [];
}
const keys = [];
for (const entry of attributes) {
const key = extractAttributeKey(entry);
if (key) {
keys.push(key);
}
}
return keys;
};
/**
* Determine whether a customer should be flagged as a "red car"
* based on their attribute list. The check is case-insensitive.
*
* @param {Array<string|object|undefined|null>|null|undefined} attributes
* @param {object} [options]
* @param {string[]} [options.attributeKeys] - attribute keys that
* mark a red car. Defaults to DEFAULT_RED_CAR_ATTRIBUTE_KEYS.
* @returns {boolean}
*/
export const isRedCarCustomer = (attributes, options = {}) => {
const keys = toNormalizedKeySet(
options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
);
if (keys.size === 0) {
return false;
}
const attributeKeys = extractAttributeKeys(attributes);
return attributeKeys.some((key) => keys.has(key.toLowerCase()));
};
/**
* Return the configured red-car attribute keys (normalized,
* deduplicated, lowercased). Useful for displaying the active
* heuristic to operators.
*
* @param {object} [options]
* @param {string[]} [options.attributeKeys]
* @returns {string[]}
*/
export const getRedCarAttributeKeys = (options = {}) => {
const keys = toNormalizedKeySet(
options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
);
return Array.from(keys);
};
-104
View File
@@ -1,104 +0,0 @@
import { computed, ref, unref, watch } from "vue";
import {
extractCustomerAttributesData,
listCustomerAttributes,
} from "@/features/customer/customerAttributeService.js";
import {
DEFAULT_RED_CAR_ATTRIBUTE_KEYS,
isRedCarCustomer,
} from "./redCarDetector.js";
const isPositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0;
};
/**
* Composable that watches a customer number and exposes a reactive
* "is this customer flagged as a red car" state for the customer
* portal wash flow.
*
* SENERE 7 / TRU-99 — the canonical rule is still TBD by Mads; this
* defaults to a manual customer-attribute flag and lets callers
* override the attribute keys so the rule can be tightened later.
*
* @param {import("vue").Ref<number|string|null|undefined>|number|string|null|undefined} customerNumberSource
* @param {object} [options]
* @param {string[]} [options.attributeKeys] - attribute keys that
* mark a red car. Defaults to DEFAULT_RED_CAR_ATTRIBUTE_KEYS.
* @returns {{
* attributes: import("vue").Ref<Array>,
* isLoading: import("vue").Ref<boolean>,
* error: import("vue").Ref<string|null>,
* isRedCar: import("vue").ComputedRef<boolean>,
* reload: () => Promise<void>,
* reset: () => void,
* }}
*/
export const useRedCarWarning = (customerNumberSource, options = {}) => {
const attributes = ref([]);
const isLoading = ref(false);
const error = ref(null);
const loadedCustomerNumber = ref(null);
const attributeKeys = options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS;
const isRedCar = computed(() => isRedCarCustomer(attributes.value, { attributeKeys }));
const reset = () => {
attributes.value = [];
isLoading.value = false;
error.value = null;
loadedCustomerNumber.value = null;
};
const reload = async () => {
const customerNumber = unref(customerNumberSource);
if (!isPositiveInteger(customerNumber)) {
reset();
return;
}
if (loadedCustomerNumber.value === customerNumber && attributes.value.length > 0) {
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await listCustomerAttributes({ customerNumber });
const nextAttributes = extractCustomerAttributesData(response);
attributes.value = nextAttributes;
loadedCustomerNumber.value = customerNumber;
} catch (err) {
console.warn("Unable to load red-car customer attributes", err);
attributes.value = [];
loadedCustomerNumber.value = null;
error.value = err?.message || "load_failed";
} finally {
isLoading.value = false;
}
};
if (typeof customerNumberSource === "object" && customerNumberSource !== null && "value" in customerNumberSource) {
watch(
() => unref(customerNumberSource),
(next) => {
if (isPositiveInteger(next)) {
reload();
} else {
reset();
}
},
{ immediate: true }
);
}
return {
attributes,
isLoading,
error,
isRedCar,
reload,
reset,
};
};
-4
View File
@@ -2143,7 +2143,6 @@
"customer_login_title": "@:{'templates.generated.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.chauffør'}? @.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @:{'words.generated.her'} @:{'words.generated.for'} @:{'words.generated.at'} @:{'words.generated.fa'} @:{'words.generated.adgang'} @:{'words.generated.til'} @:{'words.generated.dine'} @:{'words.generated.vaske'}.",
"driver_login_button": "@.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @.lower:{'words.generated.som'} @:{'words.generated.chauffør'}",
"driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.",
"driver_login_title": "@.capitalize:{'words.generated.chauffør'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Glemt @:{'words.generated.adgangskode'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} @:{'words.generated.digitale'} @:{'words.generated.løsninger'} @:{'words.generated.til'} @:{'words.generated.lastbilvask'}",
@@ -5916,9 +5915,6 @@
"open_property_exit_gate": "@:{'words.generated.abn'} udgangsport",
"perform_wash": "@:{'words.generated.udfør'} @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørgsmal'} @:{'words.generated.besvaret'}",
"red_car_warning_title": "Rød bil på pladsen",
"red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.vælg'} {plate} @:{'words.generated.fra'} @:{'words.generated.dine'} @:{'words.generated.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.type'} @:{'words.generated.af'} @:{'words.generated.køretøj'}",
"select_vehicle": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.køretøj'}",
-4
View File
@@ -2260,7 +2260,6 @@
"username": "@:{'words.generated.benutzername'}",
"your_username": "@:{'words.generated.ihr'} @:{'words.generated.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
},
"book_wash": {
@@ -6026,9 +6025,6 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'words.generated.oeffnen'}",
"perform_wash": "@.upper:{'words.generated.w'}?@:{'words.generated.sche'} durchf?@:{'words.generated.hren'}",
"questions_answered": "@:{'words.generated.fragen'} @:{'words.generated.beantwortet'}",
"red_car_warning_title": "Rotes Auto vor Ort",
"red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
"red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'words.generated.w'}?@:{'words.generated.hlen'} @.capitalize:{'words.generated.sie'} {plate} @:{'words.generated.aus'} @:{'words.generated.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'words.generated.fahrzeugtyp'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
"select_vehicle": "@:{'words.generated.fahrzeug'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
-4
View File
@@ -1974,7 +1974,6 @@
"customer_login_title": "@.capitalize:{'words.generated.customer'} @.capitalize:{'words.generated.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.a'} @:{'words.generated.driver'}? @.capitalize:{'words.generated.log'} @:{'words.generated.in'} @:{'words.generated.here'} @:{'words.generated.to'} @:{'words.generated.register'} @:{'words.generated.a'} @:{'words.generated.wash'}.",
"driver_login_button": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"driver_login_help": "Drivers (sub-users) log in here to register a wash.",
"driver_login_title": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Forgot @.capitalize:{'words.generated.password'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} - @:{'words.generated.digital'} @:{'words.generated.solutions'} @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.washing'}",
@@ -5747,9 +5746,6 @@
"open_property_exit_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.exit'} @:{'words.generated.gate'}",
"perform_wash": "@.capitalize:{'words.generated.perform'} @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.questions'} @:{'words.generated.answered'}",
"red_car_warning_title": "Red car on site",
"red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
"red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.select'} {plate} @:{'words.generated.from'} @:{'words.generated.your'} @:{'words.generated.vehicles'}",
"select_vehicle_type": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'} @:{'words.generated.type'}",
"select_vehicle": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'}",
-1
View File
@@ -786,7 +786,6 @@
"customer_number": "@:{'templates.generated.compat.objects.columns.customer_number'}",
"driver_entry_intro": "@:{'templates.generated.compat.auth.driver_entry_intro'}",
"driver_login_button": "@:{'templates.generated.compat.auth.driver_login_button'}",
"driver_login_help": "@:{'templates.generated.compat.auth.driver_login_help'}",
"driver_login_title": "@:{'templates.generated.compat.auth.driver_login_title'}",
"forgot_password": "@:{'templates.generated.compat.auth.forgot_password'}",
"login_as_customer": "@:{'templates.generated.compat.global.login_as_customer'}",
-4
View File
@@ -2263,7 +2263,6 @@
"username": "@.capitalize:{'words.generated.brukernavn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
},
"book_wash": {
@@ -6029,9 +6028,6 @@
"open_property_exit_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} utgangsport",
"perform_wash": "Utfør @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørsmal'} @:{'words.generated.besvart'}",
"red_car_warning_title": "Rød bil på plassen",
"red_car_warning_message": "Denne kunden er markert som rød bil. Rød lakk er mer utsatt for flekker og virvler — håndter med ekstra forsiktighet.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: unngå høytrykk og harde børster, og tørk med en myk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.velg'} {plate} @:{'words.generated.fra'} @:{'words.generated.kjøretøyene'} @:{'words.generated.dine'}",
"select_vehicle_type": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøytype'}",
"select_vehicle": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøy'}",
-4
View File
@@ -2313,7 +2313,6 @@
"username": "@.capitalize:{'words.generated.anvandarnamn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.anvandarnamn'}",
"driver_login_button": "Förarinloggning",
"driver_login_help": "Förare (underanvändare) loggar in här för att registrera en tvätt.",
"driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt."
},
"book_wash": {
@@ -6079,9 +6078,6 @@
"open_property_exit_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} utgangsgrind",
"perform_wash": "Perform @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.fragor'} @:{'words.generated.besvarade'}",
"red_car_warning_title": "Röd bil på plats",
"red_car_warning_message": "Denna kund är flaggad som röd bil. Röd lack är känsligare för fläckar och virvlar — hantera med extra försiktighet.",
"red_car_warning_suggestion": "Rekommendera en skonsam tvätt: undvik högtryck och hårda borstar, och torka med en mjuk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.valj'} {plate} @:{'words.generated.fran'} @:{'words.generated.dina'} @:{'words.generated.fordon'}",
"select_vehicle_type": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordonstyp'}",
"select_vehicle": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordon'}",
@@ -5,7 +5,6 @@
"customer_login_title": "@:{'phrases.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.chauffør'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @:{'terms.glossary.her'} @:{'terms.glossary.for'} @:{'terms.glossary.at'} @:{'terms.glossary.fa'} @:{'terms.glossary.adgang'} @:{'terms.glossary.til'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @.lower:{'terms.glossary.som'} @:{'terms.glossary.chauffør'}",
"driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.",
"driver_login_title": "@.capitalize:{'terms.glossary.chauffør'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Glemt @:{'terms.glossary.adgangskode'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} @:{'terms.glossary.digitale'} @:{'terms.glossary.løsninger'} @:{'terms.glossary.til'} @:{'terms.glossary.lastbilvask'}",
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@:{'terms.glossary.abn'} udgangsport",
"perform_wash": "@:{'terms.glossary.udfør'} @:{'terms.glossary.vask'}",
"questions_answered": "@.capitalize:{'terms.glossary.spørgsmal'} @:{'terms.glossary.besvaret'}",
"red_car_warning_title": "Rød bil på pladsen",
"red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.vælg'} {plate} @:{'terms.glossary.fra'} @:{'terms.glossary.dine'} @:{'terms.glossary.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.type'} @:{'terms.glossary.af'} @:{'terms.glossary.køretøj'}",
"select_vehicle": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.køretøj'}",
@@ -12,7 +12,6 @@
"username": "@:{'terms.glossary.benutzername'}",
"your_username": "@:{'terms.glossary.ihr'} @:{'terms.glossary.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
}
}
@@ -55,4 +55,4 @@
}
}
}
}
}
@@ -24,9 +24,6 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'terms.glossary.oeffnen'}",
"perform_wash": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.sche'} durchf?@:{'terms.glossary.hren'}",
"questions_answered": "@:{'terms.glossary.fragen'} @:{'terms.glossary.beantwortet'}",
"red_car_warning_title": "Rotes Auto vor Ort",
"red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
"red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.hlen'} @.capitalize:{'terms.glossary.sie'} {plate} @:{'terms.glossary.aus'} @:{'terms.glossary.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'terms.glossary.fahrzeugtyp'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
"select_vehicle": "@:{'terms.glossary.fahrzeug'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
@@ -5,7 +5,6 @@
"customer_login_title": "@.capitalize:{'terms.glossary.customer'} @.capitalize:{'terms.glossary.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.are'} @:{'terms.glossary.you'} @:{'terms.glossary.a'} @:{'terms.glossary.driver'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.in'} @:{'terms.glossary.here'} @:{'terms.glossary.to'} @:{'terms.glossary.register'} @:{'terms.glossary.a'} @:{'terms.glossary.wash'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"driver_login_help": "Drivers (sub-users) log in here to register a wash.",
"driver_login_title": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Forgot @.capitalize:{'terms.glossary.password'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} - @:{'terms.glossary.digital'} @:{'terms.glossary.solutions'} @:{'terms.glossary.for'} @:{'terms.glossary.truck'} @:{'terms.glossary.washing'}",
@@ -55,4 +55,4 @@
}
}
}
}
}
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.exit'} @:{'terms.glossary.gate'}",
"perform_wash": "@.capitalize:{'terms.glossary.perform'} @:{'terms.glossary.wash'}",
"questions_answered": "@.capitalize:{'terms.glossary.questions'} @:{'terms.glossary.answered'}",
"red_car_warning_title": "Red car on site",
"red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
"red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.select'} {plate} @:{'terms.glossary.from'} @:{'terms.glossary.your'} @:{'terms.glossary.vehicles'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.type'}",
"select_vehicle": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'}",
@@ -5,7 +5,6 @@
"customer_number": "@:{'phrases.compat.objects.columns.customer_number'}",
"driver_entry_intro": "@:{'phrases.compat.auth.driver_entry_intro'}",
"driver_login_button": "@:{'phrases.compat.auth.driver_login_button'}",
"driver_login_help": "@:{'phrases.compat.auth.driver_login_help'}",
"driver_login_title": "@:{'phrases.compat.auth.driver_login_title'}",
"forgot_password": "@:{'phrases.compat.auth.forgot_password'}",
"login_as_customer": "@:{'phrases.compat.global.login_as_customer'}",
@@ -12,7 +12,6 @@
"username": "@.capitalize:{'terms.glossary.brukernavn'}",
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
}
}
@@ -55,4 +55,4 @@
}
}
}
}
}
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} utgangsport",
"perform_wash": "Utfør @:{'terms.glossary.vask'}",
"questions_answered": "@.capitalize:{'terms.glossary.spørsmal'} @:{'terms.glossary.besvart'}",
"red_car_warning_title": "Rød bil på plassen",
"red_car_warning_message": "Denne kunden er markert som rød bil. Rød lakk er mer utsatt for flekker og virvler — håndter med ekstra forsiktighet.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: unngå høytrykk og harde børster, og tørk med en myk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.velg'} {plate} @:{'terms.glossary.fra'} @:{'terms.glossary.kjøretøyene'} @:{'terms.glossary.dine'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøytype'}",
"select_vehicle": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøy'}",
@@ -12,7 +12,6 @@
"username": "@.capitalize:{'terms.glossary.anvandarnamn'}",
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.anvandarnamn'}",
"driver_login_button": "Förarinloggning",
"driver_login_help": "Förare (underanvändare) loggar in här för att registrera en tvätt.",
"driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt."
}
}
@@ -55,4 +55,4 @@
}
}
}
}
}
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} utgangsgrind",
"perform_wash": "Perform @:{'terms.glossary.wash'}",
"questions_answered": "@.capitalize:{'terms.glossary.fragor'} @:{'terms.glossary.besvarade'}",
"red_car_warning_title": "Röd bil på plats",
"red_car_warning_message": "Denna kund är flaggad som röd bil. Röd lack är känsligare för fläckar och virvlar — hantera med extra försiktighet.",
"red_car_warning_suggestion": "Rekommendera en skonsam tvätt: undvik högtryck och hårda borstar, och torka med en mjuk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.valj'} {plate} @:{'terms.glossary.fran'} @:{'terms.glossary.dina'} @:{'terms.glossary.fordon'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordonstyp'}",
"select_vehicle": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordon'}",
-81
View File
@@ -31,23 +31,6 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
<LoginForm />
</ConnectivityIssue>
<p>{{ $t('auth.terms_acceptance') }}</p>
<!-- Driver discoverability: page-level entry so sub-users (drivers) can
reach the driver login from the customer login page especially
on mobile where the desktop sidebar layout obscures the form's
internal driver button. -->
<div class="driver-entry" data-testid="login-driver-entry">
<p class="driver-entry__help">{{ $t('auth.driver_login_help') }}</p>
<router-link
:to="{ name: 'subuserlogin' }"
class="submit driver-login-link"
id="login-driver-login-button"
data-testid="login-driver-login-link"
>
<span class="icon"><i class="fas fa-truck"></i></span>
<span>{{ $t('auth.driver_login_button') }}</span>
</router-link>
</div>
</div>
</div>
</section>
@@ -175,55 +158,6 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
text-decoration: none;
}
/* Driver entry block — page-level, always visible on every viewport.
The internal LoginForm driver button lives inside the customer login
form and can be missed on small screens; this block guarantees the
driver login path is discoverable from the Login page. */
.login-section .wrapper .main .driver-entry {
max-width: 400px;
width: 100%;
margin-top: 24px;
padding: 16px 20px;
background-color: #F2F8FC;
border: 1px solid #BFE0EF;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.login-section .wrapper .main .driver-entry__help {
max-width: none;
margin: 0;
font-size: 14px;
color: #13324C;
text-align: center;
line-height: 1.4;
}
.login-section .wrapper .main .driver-entry .driver-login-link {
background-color: #1584BC;
color: #fff;
border: 0;
border-radius: 30px;
min-height: 43px;
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
text-decoration: none;
margin: 0;
width: auto;
padding: 0 24px;
}
.login-section .wrapper .main .driver-entry .driver-login-link:hover {
background-color: #1a99d6;
color: #fff;
}
@media (max-width: 1250px) {
.login-section .wrapper .sidebar h1{
font-size: 2.3em;
@@ -253,20 +187,5 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
padding: 70px 10px;
}
}
/* Mobile (<768px): ensure the driver entry block is always visible and
takes full width so it's impossible to miss on phones. */
@media (max-width: 767px) {
.login-section .wrapper .main .driver-entry {
margin-top: 20px;
padding: 14px 16px;
width: 100%;
}
.login-section .wrapper .main .driver-entry .driver-login-link {
width: 100%;
padding: 0 16px;
}
}
/*end login-section*/
</style>
@@ -15,11 +15,7 @@ const FALLBACK_PRODUCT_TITLES = {
24: "Spot Free (Lastbil)",
25: "Fælg flex pr. enhed",
27: "Ekstraordinær pr. 10 min inkl. kemi",
// 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",
26: "Højglans - Voksforsegling pr. enhed",
21: "Undervognsskyl pr. enhed",
22: "Tillæg for Specialsæbe - DD",
};
@@ -5,20 +5,6 @@ import { computed } from "vue";
import { useRoute } from "vue-router";
import i18n from "@/i18n";
/**
* Number of days to widen the date window on either side of a flagged
* wash's `start_time` when navigating to the Selvvask view from a
* flag-link in the invoice period review.
*
* The single-day window shipped in #304 was sufficient when the wash
* always landed on the same calendar day, but real-world data shows
* washes that "span midnight" (e.g. a wash at 23:50 visible at 00:10)
* would fall outside the visible range. Widening to ±7 days makes
* the highlighted wash always visible regardless of which side of
* midnight it lands on.
*/
const FLAGGED_WASH_DATE_WINDOW_DAYS = 7;
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const parseRouteDateOnly = (value: any) => {
@@ -30,33 +16,6 @@ const parseRouteDateOnly = (value: any) => {
return `${match[1]}-${match[2]}-${match[3]}`;
};
/**
* Pad an ISO-8601 date string (`YYYY-MM-DD`) by `±days` and return a
* new `YYYY-MM-DD` string. Invalid inputs return "".
*/
const shiftDateOnly = (dateOnly: string, days: number): string => {
if (dateOnly === "") {
return "";
}
const match = DATE_ONLY_PATTERN.exec(dateOnly);
if (!match) {
return "";
}
const utc = Date.UTC(
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10) - 1,
Number.parseInt(match[3], 10),
);
const shifted = new Date(utc + days * 24 * 60 * 60 * 1000);
if (Number.isNaN(shifted.getTime())) {
return "";
}
const yyyy = shifted.getUTCFullYear();
const mm = String(shifted.getUTCMonth() + 1).padStart(2, "0");
const dd = String(shifted.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
};
const route = useRoute();
const highlightedUsageLogId = computed(() => {
const parsedId = Number.parseInt(String(route.query.xlvaskUsageLogId || ""), 10);
@@ -65,25 +24,8 @@ const highlightedUsageLogId = computed(() => {
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 initialDateFrom = computed(() => flaggedWashStartDate.value || dates.computed.formattedStartDate.value);
const initialDateTo = computed(() => flaggedWashStartDate.value || dates.computed.formattedEndDate.value);
const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
</script>
@@ -22,7 +22,6 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
import { useWashProgress } from "@/composables/useWashProgress";
import { useWashFlowState } from "@/composables/useWashFlowState";
import { useWashSessionActions } from "@/composables/useWashSessionActions";
import { useRedCarWarning } from "@/composables/useRedCarWarning.js";
import { getSelfServeTaskDynamicImageButtons } from "@/services/selfServeDynamicImage.js";
import type { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
@@ -861,17 +860,6 @@ const applyResolvedVehicleTypeSelection = () => {
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
const effectiveCustomerNumberForRedCar = computed(() => {
const authenticated = getAuthenticatedCustomerNumber();
if (authenticated) {
return authenticated;
}
const typed = resolveEffectiveCustomerNumber(customerNumberInput.value);
return typed ?? null;
});
const { isRedCar: isRedCarCustomerFlag } = useRedCarWarning(effectiveCustomerNumberForRedCar);
const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
const canUseDepartmentHeaderSelection = computed(
@@ -2395,7 +2383,6 @@ watch(
:vehicle-types="vehicleTypes"
:vehicle-step-error="vehicleStepError"
:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"
:is-red-car="isRedCarCustomerFlag"
@update:customer-number="onUpdateCustomerNumber"
@update:registration-number="onUpdateRegistrationNumber"
@select-vehicle-type="onSelectVehicleType"
@@ -20,7 +20,6 @@ defineProps<{
vehicleTypes: any[];
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
isRedCar?: boolean;
}>();
const emit = defineEmits<{
@@ -57,7 +56,6 @@ const emitVehicleTypeSelection = (selection: VehicleTypeSelection) => {
:vehicle-types="vehicleTypes"
:vehicle-step-error="vehicleStepError"
:vehicle-step-guidance="vehicleStepGuidance"
:is-red-car="isRedCar"
@update:customer-number="emitCustomerNumber"
@update:customerNumber="emitCustomerNumber"
@update:registration-number="emitRegistrationNumber"
+3 -3
View File
@@ -64,7 +64,7 @@ const buildOverviewPayload = ({
{
product_id: 26,
slug: "hoejglans",
title: "Voksforsegling pr. enhed",
title: "Højglans - Voksforsegling pr. enhed",
state: "ready",
value: 4,
out_of: 8,
@@ -268,7 +268,7 @@ test.describe("Admin daily report", () => {
await expect(page.getByTestId("daily-report-tile-extraordinary-10-min")).toContainText(
"Ekstraordinær pr. 10 min inkl. kemi"
);
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Højglans - Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-undervognsskyl")).toContainText("Undervognsskyl pr. enhed");
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
});
@@ -667,7 +667,7 @@ test.describe("Admin daily report", () => {
await expect(page.getByTestId("daily-report-tile-extraordinary-10-min")).toContainText(
"Ekstraordinær pr. 10 min inkl. kemi"
);
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Højglans - Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-undervognsskyl")).toContainText("Undervognsskyl pr. enhed");
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
});
@@ -106,26 +106,6 @@ describe("DepartmentDashboardDailyReportProductSales behavior", () => {
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", () => {
dailyReportProductsState.value = {
24: {
-134
View File
@@ -1,134 +0,0 @@
// @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();
});
});
-150
View File
@@ -1,150 +0,0 @@
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([]);
});
});
-42
View File
@@ -1,42 +0,0 @@
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
);
}
});
}
});
-74
View File
@@ -1,74 +0,0 @@
// @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");
});
});
@@ -129,43 +129,4 @@ describe("selfServeDynamicImage", () => {
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"],
]);
});
});
-87
View File
@@ -1,87 +0,0 @@
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\}/);
});
});
@@ -600,21 +600,6 @@ describe("Periode tab contract", () => {
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", () => {
expect(periodRightSource).toContain("normalizeXlvaskAutopilotSummary");
expect(periodRightSource).toContain("const selfWashCounts = ref(emptySelectorCounts())");
@@ -15,13 +15,8 @@ describe("xlvask usage pagination department selector propagation", () => {
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*,/);
expect(source).toMatch(/effectiveDepartmentId\s*>\s*0/);
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*effectiveDepartmentId\s*,\s*false\s*\)/);
});
it("falls back to the departmentId route param when the prop is not provided", () => {
@@ -34,13 +29,10 @@ describe("xlvask usage pagination department selector propagation", () => {
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).toContain(
"const effectiveDepartmentId =\n props.departmentId > 0\n ? props.departmentId\n : Number.isInteger(routeDepartmentId) && routeDepartmentId > 0\n ? routeDepartmentId\n : 0;"
);
expect(source).toMatch(/if\s*\(effectiveDepartmentId\.value\s*>\s*0\)\s*\{\s*setFilter\(\s*["']HallId["']/);
expect(source).toMatch(/if\s*\(effectiveDepartmentId\s*>\s*0\)\s*\{\s*setFilter\(\s*["']HallId["']/);
});
it("DepartmentPosSync forwards the URL departmentId to XLVaskUsagePagination", () => {
@@ -1,51 +0,0 @@
// @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/);
});
});