Compare commits

..
Author SHA1 Message Date
openhands 0ec8e6ec79 docs(period): add CI/local verification results to plan
Records the final pre-merge verification for the
customer-tag-membership change. All checks across
copenhagentruckwash/api#371 and copenhagentruckwash/pleno-vue#299
are green: 9 passing + 2 skipping on the api PR; 21 passing +
3 skipping on the frontend PR.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 12:39:10 +00:00
openhands 0e33670d40 style(period): apply prettier formatting to e2e and unit specs
Auto-generated by 'npm run format:tests'. Required for the
format-tests CI check on PR #299.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 11:52:18 +00:00
openhands 43b1658cc4 feat(period): render customer indicator chips on every subpage
The backend now projects lightweight
"{customer_number, membership_only: true}" entries onto every
non-active view bucket of the period response. The
InvoicingBillingPeriodCustomerAttributes component already iterates
sharedVariables.types to compute its chip stack, but it used a
nested Array.prototype.some() call against every bucket on every
reactivity tick.

* Pre-compute a Set<customer_number> per bucket so membership lookups
  are O(1) regardless of bucket size.
* Skip entries that don't carry a positive integer customer_number
  so non-numeric or null payloads from legacy clients stay inert.
* Honour the deterministic ATTRIBUTE_DISPLAY_PRIORITY ordering across
  the chips so the surface stays stable.

e2e: the existing smoke harness now mirrors the live backend contract
through a new projectPeriodMockPagedPayload() helper that maps the
in-memory fixture to the { full cards on active bucket, lightweight
memberships elsewhere } shape. A new @smoke spec verifies that the
indicator chips for invoice_per_order / fixed_pricing / tank_cleaning
all render on the Alle tab and that single-category customers render
exactly one chip.

unit: invoicing-billing-period-customer-attributes-membership.spec.js
adds five focused jsdom tests covering the active bucket path, the
lightweight-membership path, the explicit 'all' exclusion, the
defensive numeric guard, and the deterministic ordering.

Plan: docs/invoicing-period-tag-membership-plan.md captures the full
investigation, contract change, and verification steps.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 11:46:51 +00:00
openhands d8f7b34642 test(services): add unit tests for localeFormatting helpers
Add 24 unit tests covering formatLocaleNumber, formatLocaleDate,
formatLocaleDateTime, formatLocaleMonthLabel, and formatLocaleDateRange
from src/services/localeFormatting.js, which previously had no dedicated
test coverage despite being used across multiple views.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 10:08:55 +00:00
Jeppe B a45d1aae7f Merge branch 'master' into fix/mobile-audited-addon-note-prompt 2026-08-13 11:27:39 +02:00
openhands d269513025 fix(pleno-vue): prompt for audited add-on note on mobile step 2
The mobile flow's productRequiresOrderItemNote only checked
requires_note + chemistry product 27, so audited add-ons (21, 22,
24, 25, 26, 27) silently posted without reason_comment. The server
policy then rejected POST /order/items with 400 \u201cReason comment is
required for this product\u201d and syncMobileOrderItems rolled the
partial add-on batch back via OrderItemsPartialSyncError.

Match the desktop flow: include AUDITED_ORDER_ITEM_PRODUCT_IDS so
promptForRequiredProductNote runs before POST and the existing
buildAuditedOrderItemReasonPayload falls back to notes for
reason_comment.

Updates the e2e fixture mirror in mobilePos.js so the mock server
rejects empty notes for audited products, matching production.
Adds a product-24 e2e test alongside the product-27 test.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 08:00:04 +00:00
125 changed files with 262 additions and 3082 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
-4
View File
@@ -1,4 +0,0 @@
# AGENT MCP SMOKE
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
Safe to close.
@@ -475,3 +475,31 @@ it so future schema edits surface in CI.
* `tests/e2e/invoicing-period.smoke.spec.js` — mock reflects real backend
shape, extended chip-stacking assertions.
* `openapi.yaml` — mirror backend schema edits.
---
## 9. Verification (2026-08-13)
### Local
* `cd /workspace/api/services/nginx/app && USE_ENV=true … ./vendor/bin/pest --filter InvoicingPeriodPaginationTest` → 15 tests, 158 assertions, all green.
* `cd /workspace/pleno-vue && npx vitest run tests/unit/invoicing-billing-period-customer-attributes-membership.spec.js` → 5/5 tests pass.
* `cd /workspace/pleno-vue && npm run format:tests:check` → clean after `npm run format:tests`.
* `python -c "import yaml; yaml.safe_load(open('openapi.yaml'))"` → OK (no YAML errors after schema extension).
### CI
Both branches pushed and verified live.
| PR | URL | Passes | Skips | Fails |
| --- | --- | ---: | ---: | ---: |
| `copenhagentruckwash/api#371` | https://github.com/copenhagentruckwash/api/pull/371 | 9 | 2 | 0 |
| `copenhagentruckwash/pleno-vue#299` | https://github.com/copenhagentruckwash/pleno-vue/pull/299 | 21 | 3 | 0 |
Notable green runs:
* api PR: PHP unit, PHP integration, PHP api, PHP legacy, Edge Agent, Edge Broker, Edge Gateway Backend, Qodana, Required CI all `pass`. (Release Manager gate + Qodana for PHP = `skipping` per repo policy.)
* frontend PR: every `Automated Tests` job green — `format-tests`, `Quality-lint`, `Quality-build`, `Quality-i18n`, `Quality-unit-fast`, `Quality-unit-serial` — plus all six E2E-PR matrix jobs (`ct`/`pr`/`smoke` × desktop/mobile) and the `E2E-pr-changed-{1,2}-of-2` matrix jobs. `Qodana`, `Qodana for JS`, `App Store Readiness`, `Build and unit summary`, `Required CI` all `pass`.
The new `@smoke` e2e test, which exercises the cross-bucket membership rendering on `?periodView=all`, ran inside `E2E-pr-smoke-chromium-desktop` and `E2E-pr-smoke-chromium-mobile` and both jobs passed.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

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

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
{
"marketingVersion": "1.1.0",
"marketingVersion": "1.0.1",
"bundleId": "io.truckwash.app",
"minimumIosVersion": "15.0"
}
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env node
// AUT-1 smoke run — capture the before/after visual diff for the
// `primary-button hover state` change in `src/assets/main.css`.
//
// Renders tests/visual-previews/AUT-1/preview.html in a real Chromium
// against the project's bundled Bulma stylesheet, then takes two
// screenshots:
//
// desktop-before.png — Bulma defaults, button idle (no hover effect).
// desktop-after.png — With the AUT-1 CSS rules applied, button
// hovered (lift + brightness shift visible).
// mobile-before.png — Same as desktop-before, captured at the
// Pixel 5 viewport.
// mobile-after.png — Same as desktop-after, captured at the
// Pixel 5 viewport.
//
// Output is written to tests/visual-previews/AUT-1/. The script is
// idempotent: existing files are overwritten, not appended to.
//
// Invoke with: `node scripts/aut-1-capture-hover-preview.mjs`
// (Chromium must already be installed via `npm run test:e2e:install`.)
import { chromium } from "playwright";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..");
const PREVIEW_HTML = path.join(
REPO_ROOT,
"tests/visual-previews/AUT-1/preview.html",
);
const OUTPUT_DIR = path.join(REPO_ROOT, "tests/visual-previews/AUT-1");
// The AUT-1 hover rules — kept in lockstep with the diff in
// src/assets/main.css. We inject these via a <style> tag on the
// "after" pass and leave them off for the "before" pass so the
// screenshots show the same Bulma theme but with vs without the new
// hover effect.
const AUT1_HOVER_CSS = `
.button.is-primary {
transition:
filter 0.15s ease,
transform 0.15s ease;
}
.button.is-primary:hover,
.button.is-primary.is-hovered {
filter: brightness(1.08);
transform: translateY(-1px);
}
.button.is-primary:active,
.button.is-primary.is-active {
filter: brightness(0.95);
transform: translateY(0);
}
.button.is-primary:focus-visible {
filter: brightness(1.04);
transform: translateY(-1px);
}
`;
async function captureVariant({ browser, viewport, label, withHoverCss, hover }) {
const context = await browser.newContext({
viewport,
deviceScaleFactor: 2,
});
const page = await context.newPage();
await page.goto(pathToFileUrl(PREVIEW_HTML));
if (withHoverCss) {
await page.addStyleTag({ content: AUT1_HOVER_CSS });
}
const target = page.locator(
hover
? '[data-testid="primary-button-hover"]'
: '[data-testid="primary-button-idle"]',
);
const stage = page.locator(
hover ? "#stage-hover" : "#stage-idle",
);
if (hover) {
await target.hover();
// Wait for the 0.15s transition to settle.
await page.waitForTimeout(220);
}
const filename = `${label}-${withHoverCss ? "after" : "before"}.png`;
const destination = path.join(OUTPUT_DIR, filename);
await stage.screenshot({ path: destination, type: "png" });
await context.close();
return destination;
}
function pathToFileUrl(filePath) {
// Playwright's `file://` URLs need absolute paths. On POSIX this is
// straightforward; on Windows this helper keeps the script cross-platform
// should it ever run there.
const absolute = path.resolve(filePath);
return absolute.startsWith("/") ? `file://${absolute}` : `file:///${absolute}`;
}
async function main() {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const viewports = [
{ label: "desktop", viewport: { width: 1280, height: 720 } },
{
label: "mobile",
viewport: { width: 393, height: 851 },
},
];
for (const { label, viewport } of viewports) {
for (const withHoverCss of [false, true]) {
for (const hover of [false, true]) {
// We only want idle on the "before" pass and hover on the
// "after" pass — skip the two redundant combinations.
const isIdleShot = !hover;
const isBeforeShot = !withHoverCss;
if (isIdleShot !== isBeforeShot) continue;
const file = await captureVariant({
browser,
viewport,
label,
withHoverCss,
hover,
});
// eslint-disable-next-line no-console
console.log("wrote", path.relative(REPO_ROOT, file));
}
}
}
} finally {
await browser.close();
}
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error("aut-1 capture failed:", err);
process.exitCode = 1;
});
+2 -5
View File
@@ -7,11 +7,8 @@ const strict = argv.includes("--strict");
const failures = [];
const warnings = [];
const root = process.cwd();
// App Store Connect uses the bare `da` locale code for Danish (not `da-DK`).
// Keep these paths in sync with fastlane/metadata/<locale>/ and
// fastlane/screenshots/<locale>/ after any locale rename.
const metadataRoot = join(root, "fastlane/metadata/da");
const screenshotRoot = join(root, "fastlane/screenshots/da");
const metadataRoot = join(root, "fastlane/metadata/da-DK");
const screenshotRoot = join(root, "fastlane/screenshots/da-DK");
const fail = (message) => failures.push(message);
const warn = (message) => warnings.push(message);
-1
View File
@@ -122,7 +122,6 @@ export const ownedFilesByRole = {
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js",
"workfeed-config.smoke.spec.js",
"xlvask-flag-to-selvvash-navigation.spec.ts",
],
};
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env bash
# Sergii Review Batch smoke test (TRU-96).
#
# Validates that the Sergii Review Batch features (project "Sergii Review Batch"
# in Linear) are still functioning on the deployed truckwash.io dashboard
# after a release.
#
# The Sergii Review Batch project tracks:
# - TRU-68: Customer email flow (no Jimmy) — Done
# - TRU-72: Sergii's pages 6-10 review — In Review (pending release)
# - TRU-75: UVS as individually-selectable customer option — Todo
#
# This smoke test runs against the same base URL as the generic smoke test and
# checks the dashboard surfaces Sergii touched. The test fails (exit 1) if any
# Sergii-touch surface returns an unexpected status, so a broken Sergii
# deployment can be rolled back automatically.
#
# Usage: ./scripts/smoke-test-sergii.sh [base_url]
# Default: https://staging.truckwash.io
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-2xx}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
elif [[ "$status" == "$expected" ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Sergii Review Batch smoke test against $BASE_URL"
echo " (project: Sergii Review Batch — TRU-68, TRU-72, TRU-75)"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Sergii pages 6-10 (TRU-72) ===
# Sergii's batch touched the customer, products, and orders surfaces of the
# dashboard. Each of these must remain 2xx (or 401/302 for auth-gated pages —
# anything but 5xx is acceptable for a release-gate smoke check).
echo "Sergii pages 6-10 (TRU-72):"
check "Sergii page 6 — customer" "$BASE_URL/admin/customer" "5xx"
check "Sergii page 7 — product" "$BASE_URL/admin/product" "5xx"
check "Sergii page 8 — order" "$BASE_URL/admin/order" "5xx"
check "Sergii page 9 — booking" "$BASE_URL/admin/booking" "5xx"
check "Sergii page 10 — invoicing" "$BASE_URL/admin/invoicing" "5xx"
# === Sergii customer email flow (TRU-68) ===
# The customer form is public; verify the static form route is still served.
echo
echo "Sergii customer email flow (TRU-68):"
check "customer list (Sergii touched)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form (Sergii touched)" "$BASE_URL/kundeoprettelse" "2xx"
# === Sergii UVS program option (TRU-75) ===
# The UVS wash program must be exposed in the public self-serve program
# registry so customers can pick it individually (not bundled into FF).
echo
echo "Sergii UVS program option (TRU-75):"
check "self-serve program registry" "$BASE_URL/self-serve/program" "2xx"
check "self-serve vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Sergii Review Batch sanity — dashboard still bootstraps ===
echo
echo "Dashboard bootstrap:"
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
check "login page" "$BASE_URL/login" "2xx"
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ Sergii Review Batch smoke test passed${NC}"
exit 0
else
echo -e "${RED}✗ Sergii Review Batch smoke test FAILED${NC}"
echo " A Sergii-touch surface on $BASE_URL is broken."
echo " Consider rolling back the most recent Sergii batch."
exit 1
fi
-80
View File
@@ -1,80 +0,0 @@
#!/usr/bin/env bash
# Generic smoke test for any deployed app.
#
# Usage: ./scripts/smoke-test.sh [base_url]
# Default: https://staging.truckwash.io
#
# Required env vars (set by GitHub Action):
# SMOKE_BASE_URL - base URL to test (default: https://staging.truckwash.io)
#
# Optional env vars:
# SMOKE_TOKEN - bearer token for authenticated checks
# SMOKE_TIMEOUT - curl timeout in seconds (default: 10)
#
# Exits 0 on all-pass, 1 on any failure.
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-200}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$status" =~ ^($expected)$ ]] || [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Smoke test against $BASE_URL"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Health endpoints (universal) ===
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
# === Authentication (should NOT 500) ===
check "login page" "$BASE_URL/login" "2xx"
# === Public endpoints (api repo) ===
check "customer list (public schema)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form" "$BASE_URL/kundeoprettelse" "2xx"
# === Public endpoints (pleno-vue) ===
check "self-serve program picker" "$BASE_URL/self-serve/program" "2xx"
check "vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Custom 404 should not 500 ===
check "404 page" "$BASE_URL/this-route-does-not-exist" "404"
# === Optional authenticated check ===
if [ -n "${SMOKE_TOKEN:-}" ]; then
check "auth check" "$BASE_URL/api/me" "2xx"
fi
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ All smoke tests passed${NC}"
exit 0
else
echo -e "${RED}✗ Some smoke tests failed${NC}"
exit 1
fi
-27
View File
@@ -144,30 +144,3 @@ body:not(.pleno-large-table-headers) .table thead th {
margin-top: 30px !important;
}
}
/* AUT-1: enhanced primary-button hover state.
Bulma's default only nudges background lightness; this adds a small
lift + brightness shift so the hover is unambiguous. Transitions keep
it smooth so it doesn't feel jarring on click-heavy screens. */
.button.is-primary {
transition:
filter 0.15s ease,
transform 0.15s ease;
}
.button.is-primary:hover,
.button.is-primary.is-hovered {
filter: brightness(1.08);
transform: translateY(-1px);
}
.button.is-primary:active,
.button.is-primary.is-active {
filter: brightness(0.95);
transform: translateY(0);
}
.button.is-primary:focus-visible {
filter: brightness(1.04);
transform: translateY(-1px);
}
@@ -328,47 +328,6 @@ const getCustomerDetailItems = (scan) => {
return items;
};
const getLastWashDetailItem = (scan) => {
// TRU-78 / DRIFT 17: surface the "last washed" timestamp for a scanned
// license plate on the landing page so the operator can see at a glance
// when a trailer was last washed (DHL pick-up use case).
const lastWash = formatLastWashTimestamp(scan?.last_wash);
if (!lastWash) {
return null;
}
return {
label: SessionUser.objects.global.language.last_wash || t('admin.pos.recent_washes'),
value: lastWash,
testKey: 'last-wash',
};
};
const getLastWashEmptyStateText = () => {
return t('admin.pos.never_washed');
};
const formatLastWashTimestamp = (value) => {
if (!isMeaningfulValue(value)) {
return '';
}
const parsedDate = new Date(String(value).replace(' ', 'T'));
if (Number.isNaN(parsedDate.getTime())) {
return String(value);
}
try {
return new Intl.DateTimeFormat(locale.value || undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(parsedDate);
} catch (error) {
console.error('Unable to format last_wash timestamp', error);
return String(value);
}
};
const getVehicleDetailItems = (scan) => {
const detailState = getPlateDetailState(scan.plate);
if (detailState.status !== 'loaded') {
@@ -534,29 +493,19 @@ setTimeout(() => {
:data-testid="`pos-recent-scan-details-${scan.id}`"
>
<div
v-if="getCustomerDetailItems(scan).length > 0 || getLastWashDetailItem(scan)"
v-if="getCustomerDetailItems(scan).length > 0"
class="pos-scan-details__grid pos-scan-details__grid--customer"
>
<div
v-for="item in [...getCustomerDetailItems(scan), getLastWashDetailItem(scan)].filter(Boolean)"
v-for="item in getCustomerDetailItems(scan)"
:key="item.label"
class="pos-scan-detail-field"
:data-testid="`pos-recent-scan-detail-${scan.id}-${item.testKey || item.label}`"
>
<span class="pos-scan-detail-field__label">{{ item.label }}</span>
<span class="pos-scan-detail-field__value">{{ item.value }}</span>
</div>
</div>
<p
v-if="!getLastWashDetailItem(scan) && scan.last_wash == null"
class="pos-scan-details__state"
:data-testid="`pos-recent-scan-never-washed-${scan.id}`"
>
<i class="fas fa-info-circle" aria-hidden="true"></i>
<span>{{ getLastWashEmptyStateText() }}</span>
</p>
<div v-if="getPlateDetailState(scan.plate).status === 'loading'" class="pos-scan-details__state">
<i class="fas fa-spinner fa-spin"></i>
<span>{{ $t('admin.pos.recent_scan_details_loading') }}</span>
@@ -27,7 +27,7 @@ import productImage21 from "/src/assets/piktogrammer/21.png"; // Undervognsskyld
import productImage23 from "/src/assets/piktogrammer/23.png"; // Spotfree
import productImage24 from "/src/assets/piktogrammer/24.png"; // Spotfree
import productImage25 from "/src/assets/piktogrammer/25.png"; // Fælg flex
import productImage26 from "/src/assets/piktogrammer/26.png"; // Voksforsegling (TRU-130: brand-agnostic after wax supplier swap)
import productImage26 from "/src/assets/piktogrammer/26.png"; // Højglans
import productImage27 from "/src/assets/piktogrammer/27.png"; // 10 Minutter
import productImage39 from "/src/assets/piktogrammer/39.png"; // Trailer - Varevogn
import productImage43 from "/src/assets/piktogrammer/43.png"; // Handling of materials
@@ -3,11 +3,6 @@ import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { buildAuditedOrderItemReasonPayload, editOrderItem } from "@/components/shop/OrdersItems.vue";
import {
EXTRA_SALE_REASONS,
isExtraSaleAuditProduct,
isExtraSaleCommentRequired,
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
const props = defineProps({
modelValue: {
@@ -34,8 +29,6 @@ const form = reactive({
notes: '',
reference: '',
quantity: '1',
extraSaleReasonCode: '',
extraSaleComment: '',
});
const isSubmitting = ref(false);
@@ -52,8 +45,6 @@ const syncForm = () => {
form.notes = String(props.orderItem?.notes ?? '');
form.reference = String(props.orderItem?.reference ?? '');
form.quantity = String(props.orderItem?.quantity ?? 1);
form.extraSaleReasonCode = String(props.orderItem?.extra_sale_reason_code ?? '');
form.extraSaleComment = String(props.orderItem?.extra_sale_comment ?? '');
errorMessage.value = '';
};
@@ -69,27 +60,8 @@ const isQuantityValid = computed(() => {
return Number.isInteger(value) && value > 0;
});
const requiresExtraSaleAudit = computed(() => isExtraSaleAuditProduct(props.orderItem?.product || props.orderItem));
const isExtraSaleAuditValid = computed(() => {
if (!requiresExtraSaleAudit.value) {
return true;
}
if (!form.extraSaleReasonCode) {
return false;
}
return !isExtraSaleCommentRequired(form.extraSaleReasonCode) || form.extraSaleComment.trim().length > 0;
});
const canSubmit = computed(() => {
return Boolean(props.orderItem)
&& props.canEdit
&& !isSubmitting.value
&& isPriceValid.value
&& isQuantityValid.value
&& isExtraSaleAuditValid.value;
return Boolean(props.orderItem) && props.canEdit && !isSubmitting.value && isPriceValid.value && isQuantityValid.value;
});
const closeModal = () => {
@@ -118,11 +90,7 @@ const saveChanges = async () => {
reason_code: props.orderItem.reason_code,
reason_label_snapshot: props.orderItem.reason_label_snapshot,
reason_comment: form.notes,
}),
{
extra_sale_reason_code: form.extraSaleReasonCode || null,
extra_sale_comment: form.extraSaleComment.trim() || null,
}
})
);
emits('saved');
} catch (error) {
@@ -209,41 +177,6 @@ const saveChanges = async () => {
></textarea>
</div>
<template v-if="requiresExtraSaleAudit">
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-extra-sale-reason">Årsag</label>
<div class="select is-fullwidth">
<select
id="pos-order-item-extra-sale-reason"
v-model="form.extraSaleReasonCode"
:disabled="!canEdit"
data-testid="pos-order-item-extra-sale-reason"
>
<option value="">Vælg godkendt årsag</option>
<option
v-for="reason in EXTRA_SALE_REASONS"
:key="reason.code"
:value="reason.code"
>
{{ reason.label }}
</option>
</select>
</div>
</div>
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-extra-sale-comment">Kommentar</label>
<input
id="pos-order-item-extra-sale-comment"
v-model="form.extraSaleComment"
class="input"
type="text"
:disabled="!canEdit"
data-testid="pos-order-item-extra-sale-comment"
>
</div>
</template>
<div class="column is-12">
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
<input
@@ -64,7 +64,6 @@ import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
import { useI18n } from "vue-i18n";
import { promptExtraSaleAuditIfRequired } from "@/components/displays/department/pos/utils/extraSaleAudit.js";
const { t } = useI18n();
@@ -1,91 +0,0 @@
import Swal from "sweetalert2";
export const EXTRA_SALE_PRODUCT_ID = 27;
export const EXTRA_SALE_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
export const EXTRA_SALE_REASONS = [
{ code: "customer_request", label: "Kunde ønskede ekstra tid", commentRequired: false },
{ code: "operational_delay", label: "Driftsforsinkelse i vaskehal", commentRequired: false },
{ code: "rewash_quality", label: "Omkørsel/kvalitet", commentRequired: false },
{ code: "other", label: "Anden godkendt årsag", commentRequired: true },
];
export const isExtraSaleAuditProduct = (product) => {
if (!product || typeof product !== "object") {
return false;
}
if (product.requires_extra_sale_audit === true) {
return true;
}
if (Number(product.id ?? product.product_id ?? 0) === EXTRA_SALE_PRODUCT_ID) {
return true;
}
return String(product.name ?? "").trim() === EXTRA_SALE_PRODUCT_NAME;
};
export const isExtraSaleCommentRequired = (reasonCode) => {
return EXTRA_SALE_REASONS.some((reason) => reason.code === reasonCode && reason.commentRequired);
};
export const extraSaleAuditPayload = ({ reasonCode = null, comment = null } = {}) => ({
extra_sale_reason_code: String(reasonCode || "").trim() || null,
extra_sale_comment: String(comment || "").trim() || null,
});
export const getExtraSaleAuditFromOrderItem = (orderItem = {}) =>
extraSaleAuditPayload({
reasonCode: orderItem.extra_sale_reason_code,
comment: orderItem.extra_sale_comment,
});
export const promptExtraSaleAuditIfRequired = async (product, initial = {}) => {
if (!isExtraSaleAuditProduct(product)) {
return extraSaleAuditPayload(initial);
}
const initialReason = String(initial.reasonCode ?? initial.extra_sale_reason_code ?? "").trim();
const initialComment = String(initial.comment ?? initial.extra_sale_comment ?? "").trim();
const optionsMarkup = EXTRA_SALE_REASONS.map((reason) => {
const selected = reason.code === initialReason ? " selected" : "";
return `<option value="${reason.code}"${selected}>${reason.label}</option>`;
}).join("");
const result = await Swal.fire({
title: "Godkend 10 min ekstra",
html: `
<label class="label has-text-left" for="extra-sale-reason-code">Årsag</label>
<div class="select is-fullwidth mb-3">
<select id="extra-sale-reason-code" class="swal2-select" style="display:block;width:100%;margin:0;">
<option value="">Vælg godkendt årsag</option>
${optionsMarkup}
</select>
</div>
<label class="label has-text-left" for="extra-sale-comment">Kommentar</label>
<textarea id="extra-sale-comment" class="swal2-textarea" rows="4" style="display:block;width:100%;margin:0;" placeholder="Uddyb når årsagen kræver det">${initialComment}</textarea>
`,
showCancelButton: true,
confirmButtonText: "Godkend",
focusConfirm: false,
preConfirm: () => {
const reasonCode = document.getElementById("extra-sale-reason-code")?.value || "";
const comment = document.getElementById("extra-sale-comment")?.value || "";
if (!reasonCode) {
Swal.showValidationMessage("Vælg en godkendt årsag");
return false;
}
if (isExtraSaleCommentRequired(reasonCode) && !String(comment || "").trim()) {
Swal.showValidationMessage("Kommentar er påkrævet for denne årsag");
return false;
}
return extraSaleAuditPayload({ reasonCode, comment });
},
});
return result.isConfirmed ? result.value : null;
};
@@ -17,7 +17,6 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
import { removeError } from "@/components/request/HandleGlobalError.vue";
const props = defineProps({
autoLoad: {
@@ -38,8 +37,6 @@ const props = defineProps({
type: Boolean, default: false
}, highlightUsageLogId: {
type: Number, default: 0
}, departmentId: {
type: Number, default: 0
}
});
@@ -63,11 +60,9 @@ const {
setOrder,
hideSearchField,
setHideSearchField,
lastError,
} = paginatedList;
const XLVASK_USAGE_ORDERS_ENDPOINT = "/modules/xlvask/services/usage/orders";
setEndpoint(XLVASK_USAGE_ORDERS_ENDPOINT, false);
setEndpoint("/modules/xlvask/services/usage/orders", false);
if (props.loadAllAtOnce) {
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
}
@@ -103,24 +98,12 @@ if (props.hideSearch) {
setHideSearchField(false);
}
const routeDepartmentId = computed(() => Number.parseInt(
String(router.currentRoute.value.params.departmentId ?? ""),
10
));
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);
if (router.currentRoute.value.params.departmentId) {
setOrder("StartTime", "desc");
} else {
setOrder("StartTime", "desc");
}
setOrder("StartTime", "desc");
const extractResponseData = (response) => response?.data?.data ?? response?.data ?? {};
const summary = ref({});
@@ -132,7 +115,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 +199,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();
@@ -296,20 +253,6 @@ const visibleObjectsCount = computed(() => {
}
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>
<template>
@@ -423,13 +366,6 @@ watch(apiEndpointNotImplemented, (isNotImplemented) => {
</div>
</template>
</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"/>
<XlvaskUsageOrdersTable
:objects="list"
@@ -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"
@@ -30,10 +30,6 @@ import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ProductBox from "@/components/displays/boxes/ProductBox.vue";
import { addOrderItemAddons } from "@/components/displays/department/pos/utils/addOrderItemAddons.js";
import { getPicture } from "@/components/displays/department/pos/displays/Piktogrammer.vue";
import {
getExtraSaleAuditFromOrderItem,
promptExtraSaleAuditIfRequired,
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
const emits = defineEmits(['onAddToCartProduct', 'onAddProduct', 'onSelectProduct', 'onSelectionInvalidated']);
const route = useRoute();
@@ -390,15 +386,10 @@ const warnIfRestrictedAddonsWereSkipped = async (restrictedSelections = []) => {
await showRestrictionWarning("pos.restrictions.restricted_items_removed");
};
const resolveExtraSaleAudit = async (product, initial = {}) => {
const audit = await promptExtraSaleAuditIfRequired(product, initial);
return audit === null ? null : audit;
};
const showAddMultipleProducts = async (productId) => {
const showAddMultipleProducts = (productId) => {
const product = findProductById(productId);
const restriction = getScopedProductRestriction(product);
if (restriction.restricted) {
@@ -406,7 +397,7 @@ const showAddMultipleProducts = async (productId) => {
return;
}
const quantityResult = await Swal.fire({
Swal.fire({
title: 'Tilføj flere produkter',
input: 'number',
inputAttributes: {
@@ -414,7 +405,7 @@ const showAddMultipleProducts = async (productId) => {
},
showCancelButton: true,
confirmButtonText: 'Tilføj',
showLoaderOnConfirm: false,
showLoaderOnConfirm: true,
preConfirm: (inputValue) => {
// Check if the input number is higher than 200
if (inputValue > 200) {
@@ -432,32 +423,17 @@ const showAddMultipleProducts = async (productId) => {
Swal.showValidationMessage('Order ID is required');
return false;
}
return Number(inputValue);
return createOrderItem(orderId, productId, inputValue)
.then(async (result) => {
const createdItemId = Number(result?.data?.data?.id ?? 0);
await addAddonsToOrderMiddleware(productId, inputValue, createdItemId, orderId);
await loadOrderItems();
})
.catch((error) => handleCreateOrderItemError(error, { validationMessage: true }).then(() => false));
}
},
allowOutsideClick: () => !Swal.isLoading()
});
if (!quantityResult.isConfirmed) {
return;
}
const orderId = getValidOrderId();
if (!orderId) {
return;
}
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
return;
}
await createOrderItem(orderId, productId, quantityResult.value, null, null, null, audit)
.then(async (result) => {
const createdItemId = Number(result?.data?.data?.id ?? 0);
await addAddonsToOrderMiddleware(productId, quantityResult.value, createdItemId, orderId);
await loadOrderItems();
})
.catch((error) => handleCreateOrderItemError(error));
};
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
@@ -709,7 +685,7 @@ const addProductWithAddonsToOrder = async (product_id) => {
// Check if the product requires a note
if (productRequiresOrderItemNote(product)) {
// Show the note input
const noteResult = await Swal.fire({
await Swal.fire({
title: 'Tilføj en note',
input: 'text',
inputLabel: 'Noten kan ses af kunden. F.eks. "Fjernelse af graffiti på venstre side"',
@@ -718,32 +694,35 @@ const addProductWithAddonsToOrder = async (product_id) => {
},
showCancelButton: true,
confirmButtonText: 'Tilføj',
showLoaderOnConfirm: false,
showLoaderOnConfirm: true,
inputValidator: (note) => {
if (!String(note || '').trim()) {
return 'Note er påkrævet for dette produkt';
}
return null;
},
preConfirm: (note) => String(note || '').trim(),
preConfirm: (note) => {
const normalizedNote = String(note || '').trim();
const confirmedOrderId = getValidOrderId();
if (!confirmedOrderId) {
Swal.showValidationMessage('Order ID is required');
return false;
}
// Show the fake create order item
showPendingCreateOrderItem(product, 1, getUserProductPrice(product), 0, normalizedNote);
// Create the order item
return createOrderItem(confirmedOrderId, product_id, 1, 0, normalizedNote)
.then(async (result) => {
let order_item_id = result.data.data.id;
// Add the addons to the order
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => {
loadOrderItems();
});
})
.catch((error) => handleCreateOrderItemError(error, { validationMessage: true }).then(() => false));
},
allowOutsideClick: () => !Swal.isLoading()
});
if (!noteResult.isConfirmed) {
return;
}
const normalizedNote = noteResult.value;
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
return;
}
showPendingCreateOrderItem(product, 1, getUserProductPrice(product), 0, normalizedNote);
await createOrderItem(orderId, product_id, 1, 0, normalizedNote, null, audit)
.then(async (result) => {
let order_item_id = result.data.data.id;
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, orderId).then(() => {
loadOrderItems();
});
})
.catch((error) => handleCreateOrderItemError(error));
return;
}
// If the product requires a note, show the note input
@@ -760,12 +739,7 @@ const addProductWithAddonsToOrder = async (product_id) => {
return;
}
// Create the order item
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
clearPendingOrderItems();
return;
}
await createOrderItem(orderId, product_id, quantity, null, null, null, audit)
await createOrderItem(orderId, product_id, quantity)
.then(async (result) => {
let order_item_id = result.data.data.id;
// Add the addons to the order
@@ -842,19 +816,12 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
const discountedPrice = String(getUserProductPrice({ ...previousOrderProduct, price: basePrice }));
showPendingCreateOrderItem({ ...previousOrderProduct, price: basePrice }, quantity, discountedPrice);
const audit = await resolveExtraSaleAudit(previousOrderProduct, getExtraSaleAuditFromOrderItem(orderItem));
if (audit === null) {
clearPendingOrderItems();
continue;
}
const result = await createOrderItem(
orderId,
productId,
quantity,
null,
String(orderItem?.notes ?? ""),
null,
audit
String(orderItem?.notes ?? "")
).catch((error) => handleCreateOrderItemError(error));
const sourceItemId = getPreviousOrderItemId(orderItem);
const createdItemId = Number(result?.data?.data?.id ?? 0);
@@ -887,19 +854,12 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
String(getUserProductPrice(previousOrderProduct)),
relatedItemId
);
const audit = await resolveExtraSaleAudit(previousOrderProduct, getExtraSaleAuditFromOrderItem(orderItem));
if (audit === null) {
clearPendingOrderItems();
continue;
}
await createOrderItem(
orderId,
productId,
quantity,
relatedItemId,
String(orderItem?.notes ?? ""),
null,
audit
String(orderItem?.notes ?? "")
).catch((error) => handleCreateOrderItemError(error));
}
@@ -963,12 +923,7 @@ const addRecommendedProductToOrder = async (productId) => {
}
showPendingCreateOrderItem(product, 1, getRecommendedProductPrice(productId));
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
clearPendingOrderItems();
return;
}
const result = await createOrderItem(orderId, productId, 1, null, null, null, audit).catch((error) => handleCreateOrderItemError(error));
const result = await createOrderItem(orderId, productId, 1).catch((error) => handleCreateOrderItemError(error));
const createdItemId = Number(result?.data?.data?.id ?? 0);
if (createdItemId > 0) {
await addAddonsToOrderMiddleware(productId, 1, createdItemId, orderId);
+3 -15
View File
@@ -38,9 +38,6 @@ export function usePaginatedList() {
let activeRequestController = null;
let searchDebounceTimeout = null;
/** The last error produced by `paginatedGetRequest` (null on success). */
const lastError = ref(null);
/** Additional query parameters */
const additionalQueryParameters = ref({});
@@ -173,7 +170,6 @@ export function usePaginatedList() {
abortActiveRequest();
const requestController = typeof AbortController !== "undefined" ? new AbortController() : null;
activeRequestController = requestController;
lastError.value = null;
try {
const response = await axios.get(API_URL + endpoint.value, {
@@ -197,16 +193,10 @@ export function usePaginatedList() {
setLastUpdated();
return response;
} catch (error) {
if (isCanceledRequest(error)) {
return null;
if (!isCanceledRequest(error)) {
parseError(error, 'paginatedGetRequest');
console.log(error);
}
lastError.value = {
status: Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10) || null,
endpoint: endpoint.value,
message: error?.response?.data?.data?.message ?? error?.response?.data?.message ?? error?.message ?? null,
};
parseError(error, 'paginatedGetRequest');
console.log(error);
return null;
} finally {
if (activeRequestController === requestController) {
@@ -497,7 +487,6 @@ export function usePaginatedList() {
isLoading,
isExporting,
latestSearch,
lastError,
additionalQueryParameters,
exportTransform,
setHideSearchField,
@@ -553,7 +542,6 @@ export const hideSearchField = globalInstance.hideSearchField;
export const isLoading = globalInstance.isLoading;
export const isExporting = globalInstance.isExporting;
export const latestSearch = globalInstance.latestSearch;
export const lastError = globalInstance.lastError;
export const additionalQueryParameters = globalInstance.additionalQueryParameters;
export const exportTransform = globalInstance.exportTransform;
+6 -39
View File
@@ -3,7 +3,7 @@
import axios from 'axios'
import {API_URL} from "@/config.js";
export const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 25, 26, 27]);
export const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 24, 25, 26, 27]);
export const DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE = "customer_approved_extra_work";
export const DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL = "Kunde godkendte ekstra arbejde";
@@ -13,34 +13,12 @@ export const buildAuditedOrderItemReasonPayload = (product_id, notes = null, rea
}
const reason = reasonData && typeof reasonData === "object" ? reasonData : {};
// Server requires `reason_comment` to be present (and non-empty after trim)
// for audited products. Walk the precedence chain in order so callers can
// supply either an explicit override or fall back to the legacy `notes`
// field, and never emit an empty value.
const trimmedFirstNonEmpty = (...candidates) => {
for (const candidate of candidates) {
const trimmed = String(candidate ?? "").trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return "";
};
const reasonComment = trimmedFirstNonEmpty(
reason.reason_comment,
reason.comment,
notes,
DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL
);
const comment = String(reason.reason_comment ?? reason.comment ?? notes ?? "").trim();
return {
reason_code: String(reason.reason_code || DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE),
reason_label_snapshot: String(
reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL
),
reason_comment: reasonComment,
reason_label_snapshot: String(reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL),
reason_comment: comment,
};
};
@@ -56,16 +34,7 @@ export const getOrderItems = (order_id) => {
});
};
export const createOrderItem = (
order_id,
product_id,
quantity,
related_item_id = null,
notes = null,
forcePrice = null,
reasonData = null,
extraSaleAudit = {}
) => {
export const createOrderItem = (order_id, product_id, quantity, related_item_id = null, notes = null, forcePrice = null, reasonData = null) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
@@ -81,7 +50,6 @@ export const createOrderItem = (
related_item_id,
notes,
...buildAuditedOrderItemReasonPayload(product_id, notes, reasonData),
...extraSaleAudit
};
if (forcePrice !== null && forcePrice !== undefined) {
payload.price = forcePrice;
@@ -105,7 +73,7 @@ export const removeOrderItem = (id) => {
});
};
export const editOrderItem = (id, price, notes, reference, quantity, reasonData = null, extraSaleAudit = {}) => {
export const editOrderItem = (id, price, notes, reference, quantity, reasonData = null) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
@@ -117,7 +85,6 @@ export const editOrderItem = (id, price, notes, reference, quantity, reasonData
reference,
quantity,
...(reasonData && typeof reasonData === "object" ? reasonData : {}),
...extraSaleAudit
}, {
headers: {
Authorization: `Bearer ${token}`
+4 -44
View File
@@ -10,10 +10,6 @@ import { getAttributes } from "@/components/shop/CustomerAttributes.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { doesOrderContainWashCertificateProduct } from "@/components/displays/department/pos/utils/washCertificate.js";
import {
getExtraSaleAuditFromOrderItem,
promptExtraSaleAuditIfRequired,
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
import {
getCustomerProductRestriction,
getProductCategoryRestrictionForCustomer,
@@ -1576,43 +1572,19 @@ const copyLastWashReferenceToEmptyCurrentOrder = async (sourceReference) => {
}
};
const createCopiedOrderItem = async (targetOrderId, sourceItem, relatedItemId = null) => {
const createCopiedOrderItem = (targetOrderId, sourceItem, relatedItemId = null) => {
const productId = getOrderItemProductId(sourceItem);
const quantity = getOrderItemQuantity(sourceItem);
if (!productId || !quantity) {
return Promise.resolve(null);
}
const audit = await promptExtraSaleAuditIfRequired(
sourceItem?.product || { id: productId },
getExtraSaleAuditFromOrderItem(sourceItem)
);
if (audit === null) {
return Promise.resolve(null);
}
// Preserve audited-order-item reason metadata from the source row so the
// copied POST satisfies the server-side `reason_comment` requirement for
// products in AUDITED_ORDER_ITEM_PRODUCT_IDS. We only forward the fields
// we actually saw on the source; `buildAuditedOrderItemReasonPayload`
// still falls back to the order-item `notes` and then to the default
// label for non-audited products, so this is safe for every other case.
const sourceReasonData = sourceItem && typeof sourceItem === "object"
? {
reason_code: sourceItem.reason_code,
reason_label_snapshot: sourceItem.reason_label_snapshot,
reason_comment: sourceItem.reason_comment ?? sourceItem.comment,
}
: null;
return createOrderItem(
targetOrderId,
productId,
quantity,
relatedItemId,
getOrderItemNotes(sourceItem),
null,
sourceReasonData,
audit
getOrderItemNotes(sourceItem)
);
};
@@ -2639,19 +2611,13 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
}
const forcedPrimaryPrice = primaryProduct.price ?? null;
const primaryAudit = await promptExtraSaleAuditIfRequired(primaryProduct);
if (primaryAudit === null) {
return false;
}
const primaryItemResponse = await createOrderItem(
normalizedOrderId,
primaryProduct.id,
1,
null,
null,
forcedPrimaryPrice,
null,
primaryAudit
forcedPrimaryPrice
);
const relatedPrimaryItemId = toPositiveInteger(primaryItemResponse?.data?.data?.id);
@@ -2663,10 +2629,6 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
}
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
const secondaryAudit = await promptExtraSaleAuditIfRequired(secondaryProduct || { id: secondaryProductId });
if (secondaryAudit === null) {
return false;
}
await createOrderItem(
normalizedOrderId,
@@ -2674,9 +2636,7 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
relatedPrimaryItemId,
String(bookingItem?.notes ?? "").trim() || null,
secondaryProduct?.price ?? null,
null,
secondaryAudit
secondaryProduct?.price ?? null
);
}
-115
View File
@@ -1,115 +0,0 @@
/**
* Red-car detection utilities for the customer portal.
*
* SENERE 7 / TRU-99: Until Mads defines the canonical rule for
* identifying a "red car" (e.g. plate scan, color tag, manual
* customer flag), we use a manual customer-attribute flag as the
* default heuristic. The attribute keys that trigger the warning
* are configurable so the rule can be tightened later without
* touching call sites.
*/
/**
* Default attribute keys that mark a customer as owning a red car.
* Multiple keys are supported to give staff a few options when
* tagging accounts in the customer-attribute editor.
*/
export const DEFAULT_RED_CAR_ATTRIBUTE_KEYS = Object.freeze([
"isRedCar",
"is_red_car",
"redCar",
"red_car",
]);
const normalizeKey = (key) => String(key ?? "").trim();
const toNormalizedKeySet = (keys) => {
const set = new Set();
if (!Array.isArray(keys)) {
return set;
}
for (const key of keys) {
const normalized = normalizeKey(key).toLowerCase();
if (normalized) {
set.add(normalized);
}
}
return set;
};
/**
* Extract the attribute key string from a single customer-attribute
* row. Accepts both the API shape (object with `attribute`) and the
* plain-string shorthand used in some call sites.
*
* @param {string|object|undefined|null} entry
* @returns {string}
*/
export const extractAttributeKey = (entry) => {
if (typeof entry === "string") {
return normalizeKey(entry);
}
if (entry && typeof entry === "object") {
const candidate = entry.attribute ?? entry.key ?? entry.name;
return normalizeKey(candidate);
}
return "";
};
/**
* Extract the attribute key strings from a list of customer
* attributes. Returns an empty array for invalid input.
*
* @param {Array<string|object|undefined|null>|null|undefined} attributes
* @returns {string[]}
*/
export const extractAttributeKeys = (attributes) => {
if (!Array.isArray(attributes)) {
return [];
}
const keys = [];
for (const entry of attributes) {
const key = extractAttributeKey(entry);
if (key) {
keys.push(key);
}
}
return keys;
};
/**
* Determine whether a customer should be flagged as a "red car"
* based on their attribute list. The check is case-insensitive.
*
* @param {Array<string|object|undefined|null>|null|undefined} attributes
* @param {object} [options]
* @param {string[]} [options.attributeKeys] - attribute keys that
* mark a red car. Defaults to DEFAULT_RED_CAR_ATTRIBUTE_KEYS.
* @returns {boolean}
*/
export const isRedCarCustomer = (attributes, options = {}) => {
const keys = toNormalizedKeySet(
options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
);
if (keys.size === 0) {
return false;
}
const attributeKeys = extractAttributeKeys(attributes);
return attributeKeys.some((key) => keys.has(key.toLowerCase()));
};
/**
* Return the configured red-car attribute keys (normalized,
* deduplicated, lowercased). Useful for displaying the active
* heuristic to operators.
*
* @param {object} [options]
* @param {string[]} [options.attributeKeys]
* @returns {string[]}
*/
export const getRedCarAttributeKeys = (options = {}) => {
const keys = toNormalizedKeySet(
options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
);
return Array.from(keys);
};
-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,
};
};
+1 -8
View File
@@ -2143,7 +2143,6 @@
"customer_login_title": "@:{'templates.generated.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.chauffør'}? @.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @:{'words.generated.her'} @:{'words.generated.for'} @:{'words.generated.at'} @:{'words.generated.fa'} @:{'words.generated.adgang'} @:{'words.generated.til'} @:{'words.generated.dine'} @:{'words.generated.vaske'}.",
"driver_login_button": "@.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @.lower:{'words.generated.som'} @:{'words.generated.chauffør'}",
"driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.",
"driver_login_title": "@.capitalize:{'words.generated.chauffør'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Glemt @:{'words.generated.adgangskode'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} @:{'words.generated.digitale'} @:{'words.generated.løsninger'} @:{'words.generated.til'} @:{'words.generated.lastbilvask'}",
@@ -4239,7 +4238,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.oplysninger'}"
},
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -4876,8 +4874,7 @@
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
"redirect_order_failed": "Ordren kunne ikke åbnes.",
"load_customers_failed": "Kunderne kunne ikke hentes.",
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes.",
"api_endpoint_not_implemented": "API-endepunkt ikke implementeret endnu"
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes."
}
}
},
@@ -5448,7 +5445,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.ydelse'}, @:{'words.generated.tilvalg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.sidste'} @:{'words.generated.vask'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldrig vasket",
"new_order": "@.capitalize:{'words.generated.ny'} order",
"new_sale": "@.capitalize:{'words.generated.nyt'} @:{'words.generated.salg'}",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bookinger'} @:{'words.generated.fundet'}",
@@ -5917,9 +5913,6 @@
"open_property_exit_gate": "@:{'words.generated.abn'} udgangsport",
"perform_wash": "@:{'words.generated.udfør'} @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørgsmal'} @:{'words.generated.besvaret'}",
"red_car_warning_title": "Rød bil på pladsen",
"red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.vælg'} {plate} @:{'words.generated.fra'} @:{'words.generated.dine'} @:{'words.generated.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.type'} @:{'words.generated.af'} @:{'words.generated.køretøj'}",
"select_vehicle": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.køretøj'}",
+2 -9
View File
@@ -2260,7 +2260,6 @@
"username": "@:{'words.generated.benutzername'}",
"your_username": "@:{'words.generated.ihr'} @:{'words.generated.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
},
"book_wash": {
@@ -4341,7 +4340,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@:{'words.generated.waschzertifikat'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} angehaengt.",
"wash_certificate_item_without_certificate": "@:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'} vorhanden.",
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrierung'} @:{'words.generated.ist'} weder ignoriert noch @:{'words.generated.mit'} einer @:{'words.generated.bestellung'} im ausgewaehlten @:{'words.generated.zeitraum'} verknuepft."
"xlvask_missing_order_link": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor linked @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
},
"preview": {
"entities": {
@@ -4349,7 +4348,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'}-@.capitalize:{'words.generated.details'}"
},
"expected_price": "Erwarteter @:{'words.generated.preis'}",
"no_xlvask_usage_log_metadata": "Keine Metadaten @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@:{'templates.generated.compat.tables.common.start_time'}",
"wash_id": "Wasch-@.upper:{'words.generated.id'}",
"xlvask_usage_log": "@:{'templates.generated.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
@@ -4986,8 +4984,7 @@
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
"load_customers_failed": "Die Kunden konnten nicht geladen werden.",
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden.",
"api_endpoint_not_implemented": "API-Endpunkt ist noch nicht implementiert"
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden."
}
}
},
@@ -5558,7 +5555,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.gleiche'} @:{'words.generated.leistung'}, @:{'words.generated.zusatzoptionen'} @:{'words.generated.und'} @:{'words.generated.zusatzartikel'} @:{'words.generated.wie'} @:{'words.generated.bei'} @:{'words.generated.der'} @:{'words.generated.letzten'} @:{'words.generated.wasche'}",
"manual_entry": "@.capitalize:{'words.generated.manuell'} registrering",
"never_washed": "Noch nie gewaschen",
"new_order": "@.capitalize:{'words.generated.neuer'} @:{'words.generated.auftrag'}",
"new_sale": "Nytt salg",
"no_bookings": "@.capitalize:{'words.generated.keine'} @:{'words.generated.buchungen'} @:{'words.generated.gefunden'}",
@@ -6027,9 +6023,6 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'words.generated.oeffnen'}",
"perform_wash": "@.upper:{'words.generated.w'}?@:{'words.generated.sche'} durchf?@:{'words.generated.hren'}",
"questions_answered": "@:{'words.generated.fragen'} @:{'words.generated.beantwortet'}",
"red_car_warning_title": "Rotes Auto vor Ort",
"red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
"red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'words.generated.w'}?@:{'words.generated.hlen'} @.capitalize:{'words.generated.sie'} {plate} @:{'words.generated.aus'} @:{'words.generated.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'words.generated.fahrzeugtyp'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
"select_vehicle": "@:{'words.generated.fahrzeug'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
+1 -8
View File
@@ -1974,7 +1974,6 @@
"customer_login_title": "@.capitalize:{'words.generated.customer'} @.capitalize:{'words.generated.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.a'} @:{'words.generated.driver'}? @.capitalize:{'words.generated.log'} @:{'words.generated.in'} @:{'words.generated.here'} @:{'words.generated.to'} @:{'words.generated.register'} @:{'words.generated.a'} @:{'words.generated.wash'}.",
"driver_login_button": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"driver_login_help": "Drivers (sub-users) log in here to register a wash.",
"driver_login_title": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Forgot @.capitalize:{'words.generated.password'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} - @:{'words.generated.digital'} @:{'words.generated.solutions'} @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.washing'}",
@@ -4070,7 +4069,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.details'}"
},
"expected_price": "@.capitalize:{'words.generated.expected'} @:{'words.generated.price'}",
"no_xlvask_usage_log_metadata": "No metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@.capitalize:{'words.generated.start'} @:{'words.generated.time'}",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.registration'}"
@@ -4707,8 +4705,7 @@
"create_order_item_failed": "The wash item could not be added to the order.",
"redirect_order_failed": "The order could not be opened.",
"load_customers_failed": "The customers could not be loaded.",
"load_usage_log_failed": "The usage log could not be loaded.",
"api_endpoint_not_implemented": "API endpoint not implemented yet"
"load_usage_log_failed": "The usage log could not be loaded."
}
}
},
@@ -5279,7 +5276,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.same'} @:{'words.generated.service'}, @:{'words.generated.add'}-@:{'words.generated.ons'}, @:{'words.generated.and'} @:{'words.generated.extra'} @:{'words.generated.items'} @:{'words.generated.as'} @:{'words.replication.article.host_mention'} @:{'words.generated.previous'} @:{'words.generated.wash'}",
"manual_entry": "@:{'words.generated.manual'} @:{'words.generated.registration'}",
"never_washed": "Never washed",
"new_order": "@.capitalize:{'words.generated.new'} @:{'words.generated.order'}",
"new_sale": "@.capitalize:{'words.generated.new'} sale",
"no_bookings": "@.capitalize:{'words.generated.no'} @:{'words.generated.orders'} @:{'words.generated.found'}",
@@ -5748,9 +5744,6 @@
"open_property_exit_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.exit'} @:{'words.generated.gate'}",
"perform_wash": "@.capitalize:{'words.generated.perform'} @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.questions'} @:{'words.generated.answered'}",
"red_car_warning_title": "Red car on site",
"red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
"red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.select'} {plate} @:{'words.generated.from'} @:{'words.generated.your'} @:{'words.generated.vehicles'}",
"select_vehicle_type": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'} @:{'words.generated.type'}",
"select_vehicle": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'}",
+1 -6
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'}",
@@ -3422,7 +3421,6 @@
"expected_price": "@:{'templates.generated.compat.invoice_period.flags.preview.expected_price'}",
"no_order_items": "@:common.templates.no_entity_available",
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
"no_xlvask_usage_log_metadata": "@:{'templates.generated.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}",
"order_items": "@:{'templates.generated.compat.global_search.entity_types.order_items'}",
"price": "@:common.price",
"product": "@:common.product",
@@ -4094,8 +4092,7 @@
"create_order_item_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}",
"api_endpoint_not_implemented": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.api_endpoint_not_implemented'}"
"load_usage_log_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}"
}
}
},
@@ -4852,8 +4849,6 @@
"last_wash_matches_current_selection": "@:{'templates.generated.compat.pos.last_wash_matches_current_selection'}",
"license_plate": "@:{'templates.generated.compat.tables.common.registration_number'}",
"manual_entry": "@:{'templates.generated.compat.pos.manual_entry'}",
"last_wash": "@:{'templates.generated.compat.global.last_wash'}",
"never_washed": "@:{'templates.generated.compat.pos.never_washed'}",
"new_order": "@:{'templates.generated.compat.pos.new_order'}",
"new_sale": "@:{'templates.generated.compat.pos.new_sale'}",
"no_bookings": "@:{'templates.generated.compat.pos.no_bookings'}",
+2 -9
View File
@@ -2263,7 +2263,6 @@
"username": "@.capitalize:{'words.generated.brukernavn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
},
"book_wash": {
@@ -4344,7 +4343,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@.capitalize:{'words.generated.vaskesertifikat'} @:{'words.generated.er'} @:{'words.generated.vedlagt'} @:{'words.generated.uten'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.vaskesertifikat'}-@:{'words.generated.linje'}.",
"wash_certificate_item_without_certificate": "@.capitalize:{'words.generated.vaskesertifikat'}-@:{'words.generated.linjen'} @:{'words.generated.finnes'} @:{'words.generated.uten'} @:{'words.generated.et'} @:{'words.generated.vaskesertifikat'}.",
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-registreringen @:{'words.generated.er'} verken ignorert @:{'words.generated.eller'} knyttet @:{'words.generated.til'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.ordre'} @:{'words.generated.i'} @:{'words.replication.article.host_mention'} @:{'words.generated.valgte'} @:{'words.generated.periode'}."
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor linked @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
},
"preview": {
"entities": {
@@ -4352,7 +4351,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-opplysninger"
},
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -4989,8 +4987,7 @@
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
"redirect_order_failed": "Ordren kunne ikke åpnes.",
"load_customers_failed": "Kundene kunne ikke hentes.",
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes.",
"api_endpoint_not_implemented": "API-endepunkt er ikke implementert ennå"
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes."
}
}
},
@@ -5561,7 +5558,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samme'} @:{'words.generated.tjeneste'}, @:{'words.generated.tillegg'} @:{'words.generated.og'} @:{'words.generated.ekstra'} @:{'words.generated.varer'} @:{'words.generated.som'} @:{'words.generated.forrige'} @:{'words.generated.vask'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldri vasket",
"new_order": "@.capitalize:{'words.generated.bestillingen'}",
"new_sale": "@.capitalize:{'words.generated.nytt'} @:{'words.generated.salg'}",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
@@ -6030,9 +6026,6 @@
"open_property_exit_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} utgangsport",
"perform_wash": "Utfør @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørsmal'} @:{'words.generated.besvart'}",
"red_car_warning_title": "Rød bil på plassen",
"red_car_warning_message": "Denne kunden er markert som rød bil. Rød lakk er mer utsatt for flekker og virvler — håndter med ekstra forsiktighet.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: unngå høytrykk og harde børster, og tørk med en myk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.velg'} {plate} @:{'words.generated.fra'} @:{'words.generated.kjøretøyene'} @:{'words.generated.dine'}",
"select_vehicle_type": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøytype'}",
"select_vehicle": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøy'}",
+2 -9
View File
@@ -2313,7 +2313,6 @@
"username": "@.capitalize:{'words.generated.anvandarnamn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.anvandarnamn'}",
"driver_login_button": "Förarinloggning",
"driver_login_help": "Förare (underanvändare) loggar in här för att registrera en tvätt.",
"driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt."
},
"book_wash": {
@@ -4394,7 +4393,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} @:{'words.generated.match'} @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@.capitalize:{'words.generated.tvattcertifikat_2'} ar bifogat @:{'words.generated.utan'} @:{'words.replication.host_definite_suffix'} tvattcertifikatrad.",
"wash_certificate_item_without_certificate": "@:{'words.generated.tvattcertifikatraden'} @:{'words.generated.finns'} @:{'words.generated.utan'} @:{'words.generated.ett'} @:{'words.generated.tvattcertifikat_2'}.",
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-registreringen @:{'words.generated.ar'} varken ignorerad @:{'words.generated.eller'} kopplad @:{'words.generated.till'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.order'} @:{'words.generated.i'} @:{'words.replication.article.host_mention'} @:{'words.generated.valda'} perioden."
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor @:{'words.generated.linked'} @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
},
"preview": {
"entities": {
@@ -4402,7 +4401,6 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.uppgifter'}"
},
"expected_price": "@.capitalize:{'words.generated.forvantat'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttid",
"wash_id": "@.capitalize:{'words.generated.tvatt_2'}-@.upper:{'words.generated.id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -5039,8 +5037,7 @@
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
"redirect_order_failed": "Ordern kunde inte öppnas.",
"load_customers_failed": "Kunderna kunde inte hämtas.",
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas.",
"api_endpoint_not_implemented": "API-slutpunkt är inte implementerad ännu"
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas."
}
}
},
@@ -5611,7 +5608,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'words.generated.samma'} @:{'words.generated.tjanst'}, @:{'words.generated.tillval'} @:{'words.generated.och'} @:{'words.generated.extra'} @:{'words.generated.artiklar'} @:{'words.generated.som'} @:{'words.generated.senaste'} @:{'words.generated.tvatten'}",
"manual_entry": "Manuell @:{'words.generated.registrering'}",
"never_washed": "Aldrig tvättad",
"new_order": "@.capitalize:{'words.generated.ny'} @:{'words.generated.order'}",
"new_sale": "@.capitalize:{'words.generated.nytt'} salg",
"no_bookings": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.bestillinger'} @:{'words.generated.funnet'}",
@@ -6080,9 +6076,6 @@
"open_property_exit_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} utgangsgrind",
"perform_wash": "Perform @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.fragor'} @:{'words.generated.besvarade'}",
"red_car_warning_title": "Röd bil på plats",
"red_car_warning_message": "Denna kund är flaggad som röd bil. Röd lack är känsligare för fläckar och virvlar — hantera med extra försiktighet.",
"red_car_warning_suggestion": "Rekommendera en skonsam tvätt: undvik högtryck och hårda borstar, och torka med en mjuk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.valj'} {plate} @:{'words.generated.fran'} @:{'words.generated.dina'} @:{'words.generated.fordon'}",
"select_vehicle_type": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordonstyp'}",
"select_vehicle": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordon'}",
@@ -5,7 +5,6 @@
"customer_login_title": "@:{'phrases.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.chauffør'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @:{'terms.glossary.her'} @:{'terms.glossary.for'} @:{'terms.glossary.at'} @:{'terms.glossary.fa'} @:{'terms.glossary.adgang'} @:{'terms.glossary.til'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @.lower:{'terms.glossary.som'} @:{'terms.glossary.chauffør'}",
"driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.",
"driver_login_title": "@.capitalize:{'terms.glossary.chauffør'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Glemt @:{'terms.glossary.adgangskode'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} @:{'terms.glossary.digitale'} @:{'terms.glossary.løsninger'} @:{'terms.glossary.til'} @:{'terms.glossary.lastbilvask'}",
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.oplysninger'}"
},
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
@@ -112,8 +112,7 @@
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
"redirect_order_failed": "Ordren kunne ikke åbnes.",
"load_customers_failed": "Kunderne kunne ikke hentes.",
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes.",
"api_endpoint_not_implemented": "API-endepunkt ikke implementeret endnu"
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes."
}
} } }
}
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.ydelse'}, @:{'terms.glossary.tilvalg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.sidste'} @:{'terms.glossary.vask'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
"never_washed": "Aldrig vasket",
"new_order": "@.capitalize:{'terms.glossary.ny'} order",
"new_sale": "@.capitalize:{'terms.glossary.nyt'} @:{'terms.glossary.salg'}",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bookinger'} @:{'terms.glossary.fundet'}",
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@:{'terms.glossary.abn'} udgangsport",
"perform_wash": "@:{'terms.glossary.udfør'} @:{'terms.glossary.vask'}",
"questions_answered": "@.capitalize:{'terms.glossary.spørgsmal'} @:{'terms.glossary.besvaret'}",
"red_car_warning_title": "Rød bil på pladsen",
"red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.vælg'} {plate} @:{'terms.glossary.fra'} @:{'terms.glossary.dine'} @:{'terms.glossary.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.type'} @:{'terms.glossary.af'} @:{'terms.glossary.køretøj'}",
"select_vehicle": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.køretøj'}",
@@ -12,7 +12,6 @@
"username": "@:{'terms.glossary.benutzername'}",
"your_username": "@:{'terms.glossary.ihr'} @:{'terms.glossary.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
}
}
@@ -26,7 +26,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@:{'terms.glossary.waschzertifikat'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} angehaengt.",
"wash_certificate_item_without_certificate": "@:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'} vorhanden.",
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrierung'} @:{'terms.glossary.ist'} weder ignoriert noch @:{'terms.glossary.mit'} einer @:{'terms.glossary.bestellung'} im ausgewaehlten @:{'terms.glossary.zeitraum'} verknuepft."
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor linked @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}."
},
"preview": {
"entities": {
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'}-@.capitalize:{'terms.glossary.details'}"
},
"expected_price": "Erwarteter @:{'terms.glossary.preis'}",
"no_xlvask_usage_log_metadata": "Keine Metadaten @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@:{'phrases.compat.tables.common.start_time'}",
"wash_id": "Wasch-@.upper:{'terms.glossary.id'}",
"xlvask_usage_log": "@:{'phrases.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
@@ -55,4 +54,4 @@
}
}
}
}
}
@@ -191,8 +191,7 @@
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
"load_customers_failed": "Die Kunden konnten nicht geladen werden.",
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden.",
"api_endpoint_not_implemented": "API-Endpunkt ist noch nicht implementiert"
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden."
}
}
}
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.gleiche'} @:{'terms.glossary.leistung'}, @:{'terms.glossary.zusatzoptionen'} @:{'terms.glossary.und'} @:{'terms.glossary.zusatzartikel'} @:{'terms.glossary.wie'} @:{'terms.glossary.bei'} @:{'terms.glossary.der'} @:{'terms.glossary.letzten'} @:{'terms.glossary.wasche'}",
"manual_entry": "@.capitalize:{'terms.glossary.manuell'} registrering",
"never_washed": "Noch nie gewaschen",
"new_order": "@.capitalize:{'terms.glossary.neuer'} @:{'terms.glossary.auftrag'}",
"new_sale": "Nytt salg",
"no_bookings": "@.capitalize:{'terms.glossary.keine'} @:{'terms.glossary.buchungen'} @:{'terms.glossary.gefunden'}",
@@ -24,9 +24,6 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'terms.glossary.oeffnen'}",
"perform_wash": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.sche'} durchf?@:{'terms.glossary.hren'}",
"questions_answered": "@:{'terms.glossary.fragen'} @:{'terms.glossary.beantwortet'}",
"red_car_warning_title": "Rotes Auto vor Ort",
"red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
"red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.hlen'} @.capitalize:{'terms.glossary.sie'} {plate} @:{'terms.glossary.aus'} @:{'terms.glossary.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'terms.glossary.fahrzeugtyp'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
"select_vehicle": "@:{'terms.glossary.fahrzeug'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
@@ -5,7 +5,6 @@
"customer_login_title": "@.capitalize:{'terms.glossary.customer'} @.capitalize:{'terms.glossary.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.are'} @:{'terms.glossary.you'} @:{'terms.glossary.a'} @:{'terms.glossary.driver'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.in'} @:{'terms.glossary.here'} @:{'terms.glossary.to'} @:{'terms.glossary.register'} @:{'terms.glossary.a'} @:{'terms.glossary.wash'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"driver_login_help": "Drivers (sub-users) log in here to register a wash.",
"driver_login_title": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Forgot @.capitalize:{'terms.glossary.password'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} - @:{'terms.glossary.digital'} @:{'terms.glossary.solutions'} @:{'terms.glossary.for'} @:{'terms.glossary.truck'} @:{'terms.glossary.washing'}",
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.details'}"
},
"expected_price": "@.capitalize:{'terms.glossary.expected'} @:{'terms.glossary.price'}",
"no_xlvask_usage_log_metadata": "No metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@.capitalize:{'terms.glossary.start'} @:{'terms.glossary.time'}",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.registration'}"
@@ -55,4 +54,4 @@
}
}
}
}
}
@@ -112,8 +112,7 @@
"create_order_item_failed": "The wash item could not be added to the order.",
"redirect_order_failed": "The order could not be opened.",
"load_customers_failed": "The customers could not be loaded.",
"load_usage_log_failed": "The usage log could not be loaded.",
"api_endpoint_not_implemented": "API endpoint not implemented yet"
"load_usage_log_failed": "The usage log could not be loaded."
}
} } }
}
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.same'} @:{'terms.glossary.service'}, @:{'terms.glossary.add'}-@:{'terms.glossary.ons'}, @:{'terms.glossary.and'} @:{'terms.glossary.extra'} @:{'terms.glossary.items'} @:{'terms.glossary.as'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.previous'} @:{'terms.glossary.wash'}",
"manual_entry": "@:{'terms.glossary.manual'} @:{'terms.glossary.registration'}",
"never_washed": "Never washed",
"new_order": "@.capitalize:{'terms.glossary.new'} @:{'terms.glossary.order'}",
"new_sale": "@.capitalize:{'terms.glossary.new'} sale",
"no_bookings": "@.capitalize:{'terms.glossary.no'} @:{'terms.glossary.orders'} @:{'terms.glossary.found'}",
@@ -24,9 +24,6 @@
"open_property_exit_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.exit'} @:{'terms.glossary.gate'}",
"perform_wash": "@.capitalize:{'terms.glossary.perform'} @:{'terms.glossary.wash'}",
"questions_answered": "@.capitalize:{'terms.glossary.questions'} @:{'terms.glossary.answered'}",
"red_car_warning_title": "Red car on site",
"red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
"red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.select'} {plate} @:{'terms.glossary.from'} @:{'terms.glossary.your'} @:{'terms.glossary.vehicles'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.type'}",
"select_vehicle": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'}",
@@ -5,7 +5,6 @@
"customer_number": "@:{'phrases.compat.objects.columns.customer_number'}",
"driver_entry_intro": "@:{'phrases.compat.auth.driver_entry_intro'}",
"driver_login_button": "@:{'phrases.compat.auth.driver_login_button'}",
"driver_login_help": "@:{'phrases.compat.auth.driver_login_help'}",
"driver_login_title": "@:{'phrases.compat.auth.driver_login_title'}",
"forgot_password": "@:{'phrases.compat.auth.forgot_password'}",
"login_as_customer": "@:{'phrases.compat.global.login_as_customer'}",
@@ -36,7 +36,6 @@
"expected_price": "@:{'phrases.compat.invoice_period.flags.preview.expected_price'}",
"no_order_items": "@:common.templates.no_entity_available",
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
"no_xlvask_usage_log_metadata": "@:{'phrases.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}",
"order_items": "@:{'phrases.compat.global_search.entity_types.order_items'}",
"price": "@:common.price",
"product": "@:common.product",
@@ -196,8 +196,7 @@
"create_order_item_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}",
"api_endpoint_not_implemented": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.api_endpoint_not_implemented'}"
"load_usage_log_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}"
}
}
}
@@ -76,8 +76,6 @@
"last_wash_matches_current_selection": "@:{'phrases.compat.pos.last_wash_matches_current_selection'}",
"license_plate": "@:{'phrases.compat.tables.common.registration_number'}",
"manual_entry": "@:{'phrases.compat.pos.manual_entry'}",
"last_wash": "@:{'phrases.compat.global.last_wash'}",
"never_washed": "@:{'phrases.compat.pos.never_washed'}",
"new_order": "@:{'phrases.compat.pos.new_order'}",
"new_sale": "@:{'phrases.compat.pos.new_sale'}",
"no_bookings": "@:{'phrases.compat.pos.no_bookings'}",
@@ -12,7 +12,6 @@
"username": "@.capitalize:{'terms.glossary.brukernavn'}",
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
}
}
@@ -26,7 +26,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.vaskesertifikat'} @:{'terms.glossary.er'} @:{'terms.glossary.vedlagt'} @:{'terms.glossary.uten'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linje'}.",
"wash_certificate_item_without_certificate": "@.capitalize:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linjen'} @:{'terms.glossary.finnes'} @:{'terms.glossary.uten'} @:{'terms.glossary.et'} @:{'terms.glossary.vaskesertifikat'}.",
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-registreringen @:{'terms.glossary.er'} verken ignorert @:{'terms.glossary.eller'} knyttet @:{'terms.glossary.til'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ordre'} @:{'terms.glossary.i'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valgte'} @:{'terms.glossary.periode'}."
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor linked @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}."
},
"preview": {
"entities": {
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-opplysninger"
},
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
@@ -55,4 +54,4 @@
}
}
}
}
}
@@ -191,8 +191,7 @@
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
"redirect_order_failed": "Ordren kunne ikke åpnes.",
"load_customers_failed": "Kundene kunne ikke hentes.",
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes.",
"api_endpoint_not_implemented": "API-endepunkt er ikke implementert ennå"
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes."
}
}
}
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samme'} @:{'terms.glossary.tjeneste'}, @:{'terms.glossary.tillegg'} @:{'terms.glossary.og'} @:{'terms.glossary.ekstra'} @:{'terms.glossary.varer'} @:{'terms.glossary.som'} @:{'terms.glossary.forrige'} @:{'terms.glossary.vask'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
"never_washed": "Aldri vasket",
"new_order": "@.capitalize:{'terms.glossary.bestillingen'}",
"new_sale": "@.capitalize:{'terms.glossary.nytt'} @:{'terms.glossary.salg'}",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
@@ -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."
}
}
@@ -26,7 +26,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} @:{'terms.glossary.match'} @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.tvattcertifikat_2'} ar bifogat @:{'terms.glossary.utan'} @:{'terms.replication.host_definite_suffix'} tvattcertifikatrad.",
"wash_certificate_item_without_certificate": "@:{'terms.glossary.tvattcertifikatraden'} @:{'terms.glossary.finns'} @:{'terms.glossary.utan'} @:{'terms.glossary.ett'} @:{'terms.glossary.tvattcertifikat_2'}.",
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-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."
"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'}."
},
"preview": {
"entities": {
@@ -34,7 +34,6 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.uppgifter'}"
},
"expected_price": "@.capitalize:{'terms.glossary.forvantat'} @:{'terms.glossary.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttid",
"wash_id": "@.capitalize:{'terms.glossary.tvatt_2'}-@.upper:{'terms.glossary.id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
@@ -55,4 +54,4 @@
}
}
}
}
}
@@ -191,8 +191,7 @@
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
"redirect_order_failed": "Ordern kunde inte öppnas.",
"load_customers_failed": "Kunderna kunde inte hämtas.",
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas.",
"api_endpoint_not_implemented": "API-slutpunkt är inte implementerad ännu"
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas."
}
}
}
@@ -44,7 +44,6 @@
},
"last_wash_matches_current_selection": "@.capitalize:{'terms.glossary.samma'} @:{'terms.glossary.tjanst'}, @:{'terms.glossary.tillval'} @:{'terms.glossary.och'} @:{'terms.glossary.extra'} @:{'terms.glossary.artiklar'} @:{'terms.glossary.som'} @:{'terms.glossary.senaste'} @:{'terms.glossary.tvatten'}",
"manual_entry": "Manuell @:{'terms.glossary.registrering'}",
"never_washed": "Aldrig tvättad",
"new_order": "@.capitalize:{'terms.glossary.ny'} @:{'terms.glossary.order'}",
"new_sale": "@.capitalize:{'terms.glossary.nytt'} salg",
"no_bookings": "@.capitalize:{'terms.glossary.ingen'} @:{'terms.glossary.bestillinger'} @:{'terms.glossary.funnet'}",
@@ -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>
@@ -16,7 +16,7 @@ import XLVaskUsagePagination from "@/components/displays/pagination/models/Depar
<template #default>
<DepartmentDashboardHero />
<!-- Syncronize Orders -->
<XLVaskUsagePagination :department-id="SessionUser.functions.getDepartmentIdFromUrl()"/>
<XLVaskUsagePagination/>
</template>
</NotFoundFallBackPageWrapper>
</DepartmentDashboardPageWrapper>
@@ -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",
};
@@ -240,10 +240,9 @@ const xlvaskUsageLogHtml = (flag: any) => {
].filter(([, value]) => String(value ?? "").trim() !== "");
if (rows.length === 0) {
return escapeHtml(translate(
"invoice_period.flags.preview.no_xlvask_usage_log_metadata",
"XL Vask registration (no metadata available — see api flag-context serializer)..."
));
return escapeHtml(translate("invoice_period.flags.preview.no_xlvask_usage_log", "No XL Vask details available.", {
entity: translate("invoice_period.flags.preview.entities.xlvask_usage_log", "XL Vask details"),
}));
}
return `<table class="table is-narrow is-fullwidth">
@@ -286,18 +285,8 @@ const openOrderItem = (flag: any) => {
SessionUser.functions.redirectTo.department(departmentId, `modules/pos/orders/${orderId}${suffix}`, true);
};
const extractDateOnly = (value: any) => {
const rawValue = String(value ?? "").trim();
if (rawValue === "") {
return "";
}
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(rawValue);
return match ? `${match[1]}-${match[2]}-${match[3]}` : "";
};
const openXlVaskUsageLog = (flag: any) => {
const usageLogId = Number(flag?.xlvask_usage_log_id || flag?.context?.xlvask_usage_log_id || flag?.target_id || 0);
const startTime = extractDateOnly(flag?.context?.start_time || flag?.start_time);
const query = new URLSearchParams({
activeTab: "period",
periodView: "self_wash",
@@ -307,10 +296,6 @@ const openXlVaskUsageLog = (flag: any) => {
query.set("xlvaskUsageLogId", String(usageLogId));
}
if (startTime !== "") {
query.set("xlvaskUsageLogStartTime", startTime);
}
SessionUser.functions.redirectTo.superUser(`/invoices?${query.toString()}`, true);
};
@@ -5,86 +5,12 @@ import { computed } from "vue";
import { useRoute } from "vue-router";
import i18n from "@/i18n";
/**
* Number of days to widen the date window on either side of a flagged
* wash's `start_time` when navigating to the Selvvask view from a
* flag-link in the invoice period review.
*
* The single-day window shipped in #304 was sufficient when the wash
* always landed on the same calendar day, but real-world data shows
* washes that "span midnight" (e.g. a wash at 23:50 visible at 00:10)
* would fall outside the visible range. Widening to ±7 days makes
* the highlighted wash always visible regardless of which side of
* midnight it lands on.
*/
const FLAGGED_WASH_DATE_WINDOW_DAYS = 7;
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const parseRouteDateOnly = (value: any) => {
const rawValue = Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");
const match = DATE_ONLY_PATTERN.exec(rawValue.trim());
if (!match) {
return "";
}
return `${match[1]}-${match[2]}-${match[3]}`;
};
/**
* Pad an ISO-8601 date string (`YYYY-MM-DD`) by `±days` and return a
* new `YYYY-MM-DD` string. Invalid inputs return "".
*/
const shiftDateOnly = (dateOnly: string, days: number): string => {
if (dateOnly === "") {
return "";
}
const match = DATE_ONLY_PATTERN.exec(dateOnly);
if (!match) {
return "";
}
const utc = Date.UTC(
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10) - 1,
Number.parseInt(match[3], 10),
);
const shifted = new Date(utc + days * 24 * 60 * 60 * 1000);
if (Number.isNaN(shifted.getTime())) {
return "";
}
const yyyy = shifted.getUTCFullYear();
const mm = String(shifted.getUTCMonth() + 1).padStart(2, "0");
const dd = String(shifted.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
};
const route = useRoute();
const highlightedUsageLogId = computed(() => {
const parsedId = Number.parseInt(String(route.query.xlvaskUsageLogId || ""), 10);
return Number.isInteger(parsedId) && parsedId > 0 ? parsedId : 0;
});
const flaggedWashStartDate = computed(() => parseRouteDateOnly(route.query.xlvaskUsageLogStartTime));
/**
* Date window for the Selvvask view:
* - If a flagged wash start time is in the route, open a ±7d window
* around it so the highlighted wash is always visible.
* - Otherwise fall back to the current period date range.
*/
const initialDateFrom = computed(() => {
if (flaggedWashStartDate.value) {
return shiftDateOnly(flaggedWashStartDate.value, -FLAGGED_WASH_DATE_WINDOW_DAYS);
}
return dates.computed.formattedStartDate.value;
});
const initialDateTo = computed(() => {
if (flaggedWashStartDate.value) {
return shiftDateOnly(flaggedWashStartDate.value, FLAGGED_WASH_DATE_WINDOW_DAYS);
}
return dates.computed.formattedEndDate.value;
});
const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
</script>
@@ -92,8 +18,8 @@ const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
<section data-testid="invoicing-period-self-wash-view">
<XLVaskUsagePagination
:title="selfWashTitle"
:initial-date-from="initialDateFrom"
:initial-date-to="initialDateTo"
:initial-date-from="dates.computed.formattedStartDate.value"
:initial-date-to="dates.computed.formattedEndDate.value"
:inherit-period-filters="true"
:load-all-at-once="false"
:highlight-usage-log-id="highlightedUsageLogId"
@@ -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");
});

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