Compare commits

..
469 changed files with 9905 additions and 44788 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Qodana Configuration Upload
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
upload-qodana-config:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Run Qodana Configuration Uploader
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: |
docker run --rm \
-v $(pwd):/workspace \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
+4 -8
View File
@@ -7,13 +7,9 @@ on:
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
upload-qodana-config: upload-qodana-config:
runs-on: [self-hosted, Linux, X64, default] runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -24,9 +20,9 @@ jobs:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }} QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
run: | run: |
docker run --rm \ docker run --rm \
-v "$(pwd):/workspace" \ -v $(pwd):/workspace \
-w /workspace \ -w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN \ -e QODANA_CONFIGURATIONS_TOKEN=$QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \ jetbrains/qodana-configuration-uploader:latest \
--global-configs-file qodana-global-configurations.yaml \ --global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud --qodana-host https://qodana.cloud
+32 -85
View File
@@ -1,151 +1,105 @@
name: Frontend Release name: Frontend Release
on: on:
workflow_run: push:
workflows:
- Automated Tests
types:
- completed
branches: branches:
- master - master
workflow_dispatch:
permissions: permissions:
contents: read contents: read
actions: read actions: read
concurrency: concurrency:
group: frontend-release-${{ github.event.workflow_run.head_branch }} group: frontend-release-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: false
jobs: jobs:
build-upload-and-verify: build-upload-and-verify:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, default]
env: env:
RELEASE_BASE_URL: https://api-v2.truckwash.io/master/frontend RELEASE_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_BASE_URL: https://dev.truckwash.io PLAYWRIGHT_BASE_URL: https://dev.truckwash.io
PLAYWRIGHT_RELEASE_STATIC_BASE_URL: https://api-v2.truckwash.io/master/frontend
PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io PLAYWRIGHT_RELEASE_API_BASE_URL: https://api-v2.truckwash.io
PLAYWRIGHT_RELEASE_API_PING_PATHS: /master/api/ping PLAYWRIGHT_RELEASE_API_PING_PATHS: /ping,/master/api/ping,/canary/api/ping,/stable/api/ping
RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }} RELEASE_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }} RELEASE_EXPECTED_BUILD_ID: ${{ github.run_id }}-${{ github.run_attempt }}
RELEASE_EXPECTED_COMMIT: ${{ github.event.workflow_run.head_sha }} RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WAIT_INITIAL_SECONDS: 45 RELEASE_WAIT_INITIAL_SECONDS: 30
RELEASE_WAIT_TIMEOUT_SECONDS: 600 RELEASE_WAIT_TIMEOUT_SECONDS: 300
RELEASE_POLL_INTERVAL_SECONDS: 10 RELEASE_POLL_INTERVAL_SECONDS: 10
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Check release commit is current
id: branch-head
run: |
latest_sha="$(git ls-remote origin "refs/heads/$RELEASE_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$RELEASE_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$RELEASE_EXPECTED_COMMIT" ]]; then
echo "current=false" >> "$GITHUB_OUTPUT"
echo "Skipping stale release for $RELEASE_EXPECTED_COMMIT; origin/$RELEASE_BRANCH is $latest_sha."
exit 0
fi
echo "current=true" >> "$GITHUB_OUTPUT"
echo "Release commit is current for $RELEASE_BRANCH."
env:
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
- name: Setup Node.js - name: Setup Node.js
if: steps.branch-head.outputs.current == 'true'
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
- name: Install dependencies - name: Install dependencies
if: steps.branch-head.outputs.current == 'true'
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Check AI workflow sync - name: Check AI workflow sync
if: steps.branch-head.outputs.current == 'true'
run: node scripts/sync-ai-workflow.mjs --check run: node scripts/sync-ai-workflow.mjs --check
- name: Source and i18n checks - name: Source and i18n checks
if: steps.branch-head.outputs.current == 'true'
run: | run: |
npm run text:check-encoding npm run text:check-encoding
npm run i18n:v2:source-check npm run i18n:v2:source-check
- name: Unit tests - name: Unit tests
if: steps.branch-head.outputs.current == 'true'
run: npm run test:unit run: npm run test:unit
env: env:
VITEST_BATCH_SIZE: 5 VITEST_BATCH_SIZE: 5
- name: Build release artifact - name: Build release artifact
if: steps.branch-head.outputs.current == 'true'
run: npm run build run: npm run build
- name: Install Playwright Chromium - name: Install Playwright Chromium
if: steps.branch-head.outputs.current == 'true' run: npx playwright install --with-deps chromium
run: node scripts/install-playwright-browsers.mjs chromium
- name: Production Playwright gate - name: Production Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:prod run: npm run test:e2e:prod
- name: Upload dist artifact - name: Upload dist artifact
if: steps.branch-head.outputs.current == 'true'
continue-on-error: true
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: frontend-dist-${{ env.RELEASE_BUILD_ID }} name: frontend-dist-${{ env.RELEASE_BUILD_ID }}
path: dist path: dist
retention-days: 3 retention-days: 14
- name: Request Release Manager auto sync - name: Install lftp
if: steps.branch-head.outputs.current == 'true'
run: | run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1) if ! command -v lftp >/dev/null 2>&1; then
response_file="$(mktemp)" sudo apt-get update
status_code="$(curl --show-error --silent \ sudo apt-get install -y lftp
--output "$response_file" \
--write-out "%{http_code}" \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
if [[ "$status_code" =~ ^2 ]]; then
cat "$response_file"
elif [[ "$status_code" == "504" ]]; then
echo "Release Manager auto sync request reached the gateway timeout; continuing to artifact wait."
else
cat "$response_file" >&2
echo "Release Manager auto sync request failed with HTTP $status_code." >&2
exit 1
fi fi
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Wait for Coolify release artifact - name: Upload hashed assets before release metadata
if: steps.branch-head.outputs.current == 'true' run: |
test -n "$RELEASE_DEPLOY_HOST" || (echo "RELEASE_DEPLOY_HOST is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_USER" || (echo "RELEASE_DEPLOY_USER is required" >&2; exit 1)
test -n "$RELEASE_DEPLOY_PASSWORD" || (echo "RELEASE_DEPLOY_PASSWORD is required" >&2; exit 1)
npm run release:upload:lftp
env:
RELEASE_DEPLOY_HOST: ${{ secrets.RELEASE_DEPLOY_HOST }}
RELEASE_DEPLOY_USER: ${{ secrets.RELEASE_DEPLOY_USER }}
RELEASE_DEPLOY_PASSWORD: ${{ secrets.RELEASE_DEPLOY_PASSWORD }}
RELEASE_DEPLOY_REMOTE_ROOT: ${{ secrets.RELEASE_DEPLOY_REMOTE_ROOT }}
- name: Wait for exact uploaded build
run: npm run release:verify-upload run: npm run release:verify-upload
- name: Public live Playwright gate - name: Public live Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:live:public run: npm run test:e2e:live:public
env: env:
NODE_OPTIONS: --use-system-ca NODE_OPTIONS: --use-system-ca
- name: Credentialed live Playwright gate - name: Credentialed live Playwright gate
if: steps.branch-head.outputs.current == 'true'
run: npm run test:e2e:live:roles run: npm run test:e2e:live:roles
env: env:
NODE_OPTIONS: --use-system-ca NODE_OPTIONS: --use-system-ca
@@ -158,35 +112,28 @@ jobs:
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }} PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Record Release Manager gate - name: Record Release Manager gate
if: steps.branch-head.outputs.current == 'true'
run: | run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1) test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
release_gate_build_id="${RELEASE_VERIFIED_BUILD_ID:-$RELEASE_EXPECTED_BUILD_ID}"
curl --fail --show-error --silent \ curl --fail --show-error --silent \
-X POST "$RELEASE_MANAGER_GATE_URL" \ -X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \ -H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
--data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"app\":\"frontend\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$release_gate_build_id\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}" --data "{\"environment_url\":\"$RELEASE_BASE_URL\",\"channel_slug\":\"stable\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"build_id\":\"$RELEASE_EXPECTED_BUILD_ID\",\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"static_artifact\",\"api_gateway\"]}"
env: env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }} RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }} RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: Update server version after verification - name: Update server version after verification
if: steps.branch-head.outputs.current == 'true'
run: npm run release:update-server-version run: npm run release:update-server-version
env: env:
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }} SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }} RELEASE_VERSION: ${{ github.sha }}
- name: Upload Playwright report - name: Upload Playwright report
if: failure() && steps.branch-head.outputs.current == 'true' if: failure()
continue-on-error: true
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }} name: frontend-release-playwright-${{ env.RELEASE_BUILD_ID }}
path: output/playwright path: output/playwright
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 3 retention-days: 14
+25 -141
View File
@@ -3,8 +3,6 @@ name: Automated Tests
on: on:
pull_request: pull_request:
push: push:
branches:
- master
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: "0 2 * * *" - cron: "0 2 * * *"
@@ -13,14 +11,13 @@ permissions:
contents: read contents: read
concurrency: concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.head_ref || github.ref_name }} group: frontend-tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
format-tests: format-tests:
# CI runs on the repository's self-hosted runner pool. # Match the labels exposed by the Coolify-managed GitHub runner.
runs-on: [self-hosted, Linux, X64, pleno, frontend] runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -29,6 +26,7 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Check AI workflow sync - name: Check AI workflow sync
run: node scripts/sync-ai-workflow.mjs --check run: node scripts/sync-ai-workflow.mjs --check
@@ -41,8 +39,7 @@ jobs:
build-and-unit: build-and-unit:
needs: format-tests needs: format-tests
runs-on: [self-hosted, Linux, X64, pleno, frontend] runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 30
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
@@ -51,16 +48,11 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Check i18n source consistency
run: npm run i18n:v2:check
- name: Build sanity check - name: Build sanity check
run: npm run build run: npm run build
@@ -72,122 +64,47 @@ jobs:
e2e-pr: e2e-pr:
if: github.event_name != 'schedule' if: github.event_name != 'schedule'
needs: build-and-unit needs: build-and-unit
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }} runs-on: [self-hosted, Linux, X64, default]
runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 30
strategy:
fail-fast: false
max-parallel: 4
matrix:
suite: [core, changed]
project: [chromium-desktop, chromium-mobile]
env: env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }} PLAYWRIGHT_PR_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
PLAYWRIGHT_REPORTER_MODE: line-html PLAYWRIGHT_PR_HEAD: ${{ github.sha }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Resolve Playwright diff refs
id: playwright-diff
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EVENT_NAME: ${{ github.event_name }}
HEAD_SHA: ${{ github.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
zero_sha="0000000000000000000000000000000000000000"
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
base_ref="$PR_BASE_SHA"
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
base_ref="origin/$DEFAULT_BRANCH"
else
base_ref="$PUSH_BEFORE_SHA"
fi
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Install Playwright browsers - name: Install Playwright browsers
run: node scripts/install-playwright-browsers.mjs chromium run: npx playwright install --with-deps chromium
- name: Set Playwright dev server port - name: Run Playwright PR tests
shell: bash run: npm run test:e2e:pr -- --base="$PLAYWRIGHT_PR_BASE" --head="$PLAYWRIGHT_PR_HEAD"
env:
MATRIX_SUITE: ${{ matrix.suite }}
MATRIX_PROJECT: ${{ matrix.project }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
workflow_offset=$(( (RUN_ID % 90) * 600 ))
case "$MATRIX_SUITE" in
core) suite_offset=0 ;;
changed) suite_offset=10 ;;
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
esac
case "$MATRIX_PROJECT" in
chromium-desktop) project_offset=1 ;;
chromium-mobile) project_offset=2 ;;
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
echo "PLAYWRIGHT_DEV_PORT=$((10000 + workflow_offset + suite_offset + project_offset))" >> "$GITHUB_ENV"
- name: Run Playwright smoke tests
if: matrix.suite == 'core'
run: |
ulimit -n 16384 || true
npx playwright test --grep @smoke --project="${{ matrix.project }}"
- name: Run Playwright PR core tests
if: matrix.suite == 'core'
run: |
ulimit -n 16384 || true
npm run test:e2e:pr -- --core-only --project="${{ matrix.project }}"
- name: Run Playwright changed-area tests
if: matrix.suite == 'changed'
run: |
ulimit -n 16384 || true
npm run test:e2e:pr -- --changed-only --project="${{ matrix.project }}" --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
- name: Upload Playwright report - name: Upload Playwright report
if: failure() || cancelled() if: failure()
continue-on-error: true
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: playwright-report-pr-${{ matrix.suite }}-${{ matrix.project }} name: playwright-report-pr
path: | path: output/playwright
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 3 retention-days: 7
e2e-full: e2e-full:
if: > if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
always() && needs: build-and-unit
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
needs.build-and-unit.result == 'success' &&
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr]
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }} name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: [self-hosted, Linux, X64, pleno, frontend] runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 60
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 4
matrix: matrix:
role: [customer, subuser, admin, superuser] role: [customer, subuser, admin, superuser]
browser: [chromium, firefox, webkit] browser: [chromium, firefox, webkit]
@@ -213,52 +130,19 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Install Playwright browsers - name: Install Playwright browsers
run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }} run: npx playwright install --with-deps ${{ matrix.browser_install }}
- name: Set Playwright dev server port
shell: bash
env:
MATRIX_ROLE: ${{ matrix.role }}
MATRIX_BROWSER: ${{ matrix.browser }}
MATRIX_DEVICE: ${{ matrix.device }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
workflow_offset=$(( (RUN_ID % 90) * 600 ))
case "$MATRIX_ROLE" in
customer) role_offset=0 ;;
subuser) role_offset=100 ;;
admin) role_offset=200 ;;
superuser) role_offset=300 ;;
*) echo "Unsupported Playwright role: $MATRIX_ROLE" >&2; exit 1 ;;
esac
case "$MATRIX_BROWSER" in
chromium) browser_offset=0 ;;
firefox) browser_offset=30 ;;
webkit) browser_offset=60 ;;
*) echo "Unsupported Playwright browser: $MATRIX_BROWSER" >&2; exit 1 ;;
esac
case "$MATRIX_DEVICE" in
mobile) device_offset=1 ;;
tablet) device_offset=2 ;;
desktop) device_offset=3 ;;
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
echo "PLAYWRIGHT_DEV_PORT=$((10000 + workflow_offset + role_offset + browser_offset + device_offset))" >> "$GITHUB_ENV"
- name: Run full Playwright slice - name: Run full Playwright slice
run: | run: npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
ulimit -n 16384 || true
npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
- name: Upload Playwright report - name: Upload Playwright report
if: failure() || cancelled() if: failure()
continue-on-error: true
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }} name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
@@ -267,4 +151,4 @@ jobs:
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
if-no-files-found: ignore if-no-files-found: ignore
retention-days: 3 retention-days: 14
-4
View File
@@ -1,4 +0,0 @@
{
"version": 1,
"setupCompletedAt": "2026-06-07T11:37:04.444Z"
}
+10 -8
View File
@@ -1,6 +1,16 @@
FROM node:24-alpine AS build FROM node:24-alpine AS build
WORKDIR /app WORKDIR /app
ARG RELEASE_COMMIT_SHA=""
ARG COMMIT_SHA=""
ARG GITHUB_SHA=""
ARG SOURCE_COMMIT=""
ARG VITE_BASE_PATH=""
ENV RELEASE_COMMIT_SHA="${RELEASE_COMMIT_SHA}"
ENV COMMIT_SHA="${COMMIT_SHA}"
ENV GITHUB_SHA="${GITHUB_SHA}"
ENV SOURCE_COMMIT="${SOURCE_COMMIT}"
ENV VITE_BASE_PATH="${VITE_BASE_PATH}"
RUN apk add --no-cache git RUN apk add --no-cache git
@@ -8,14 +18,6 @@ COPY package*.json ./
RUN npm ci --ignore-scripts RUN npm ci --ignore-scripts
COPY . . COPY . .
ARG SOURCE_COMMIT
ARG RELEASE_COMMIT_SHA
ARG COMMIT_SHA
ARG GITHUB_SHA
ENV SOURCE_COMMIT=$SOURCE_COMMIT
ENV RELEASE_COMMIT_SHA=$RELEASE_COMMIT_SHA
ENV COMMIT_SHA=$COMMIT_SHA
ENV GITHUB_SHA=$GITHUB_SHA
RUN npm run build RUN npm run build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
-30
View File
@@ -22,36 +22,6 @@ npm install
npm run dev npm run dev
``` ```
By default, the Vite dev server proxies `/api/*` to the remote stable API at
`https://api-v2.truckwash.io/master/api`. This lets the Vue app run locally
without a local PHP API container.
To develop against a local PHP API instead:
```powershell
$env:VITE_API_PROXY_TARGET="http://localhost"; npm run dev
```
To use another remote API route:
```powershell
$env:VITE_API_PROXY_BASE_PATH="/canary/api"; npm run dev
```
TLS certificate validation is enabled for proxied HTTPS APIs by default. If you
are using a trusted local HTTPS API with a self-signed certificate, you can opt
out explicitly:
```powershell
$env:VITE_API_PROXY_TARGET="https://local-api.test"; $env:VITE_API_PROXY_SECURE="false"; npm run dev
```
For compatible local gateways that expect the `/api` prefix to be preserved:
```powershell
$env:VITE_API_PROXY_TARGET="http://localhost"; $env:VITE_API_PROXY_STRIP_PREFIX="false"; npm run dev
```
### Compile and Minify for Production ### Compile and Minify for Production
```sh ```sh
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig { defaultConfig {
applicationId "io.truckwash.twa" applicationId "io.truckwash.twa"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 36 targetSdkVersion 35
versionCode 7 versionCode 6
versionName "7" versionName "6"
// The name for the application // The name for the application
resValue "string", "appName", twaManifest.name resValue "string", "appName", twaManifest.name
+1 -1
View File
@@ -1 +1 @@
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"} {"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
@@ -1,33 +0,0 @@
# User wash start production-readiness QA matrix
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
## Unit matrix
| Area | Required scenario | Coverage |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
## E2E mocked matrix
| Required scenario | Coverage |
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
## Optional live smoke
| Optional scenario | Coverage |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
-139
View File
@@ -1,139 +0,0 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import vue from "eslint-plugin-vue";
const commonGlobals = {
...globals.browser,
...globals.node,
...globals.es2024,
grecaptcha: "readonly",
};
const vitestGlobals = {
...globals.vitest,
};
const lintTargets = ["**/*.{js,mjs,cjs,ts,tsx,vue}"];
const tsTargets = ["**/*.{ts,tsx,mts,cts}"];
function warningRules(rules = {}) {
return Object.fromEntries(
Object.entries(rules).map(([name, value]) => {
if (value === "off" || value === 0) {
return [name, value];
}
if (Array.isArray(value)) {
return [name, ["warn", ...value.slice(1)]];
}
return [name, "warn"];
}),
);
}
function warningConfig(config, files) {
return {
...config,
files: config.files ?? files,
rules: warningRules(config.rules),
};
}
const jsRecommended = warningConfig(js.configs.recommended, lintTargets);
const tsRecommended = tseslint.configs.recommended.map((config) =>
warningConfig(config, tsTargets),
);
const vueEssential = vue.configs["flat/essential"].map((config) =>
warningConfig(config, ["**/*.vue"]),
);
const ignoredPaths = [
"app/build/**",
"coverage/**",
"dist/**",
"node_modules/**",
"node_modules.codex-backup/**",
"output/**",
"public/build/**",
"src/assets/test/**",
"test/**",
"vendor/**",
"*.timestamp-*.mjs",
"**/*.timestamp-*.mjs",
];
const commonUnusedOptions = {
argsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
varsIgnorePattern: "^_",
};
export default [
{
ignores: ignoredPaths,
},
jsRecommended,
...tsRecommended,
...vueEssential,
{
files: ["**/*.vue"],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
extraFileExtensions: [".vue"],
ecmaVersion: "latest",
sourceType: "module",
},
},
},
{
files: lintTargets,
plugins: {
vue,
},
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: commonGlobals,
},
rules: {
"no-console": "off",
"no-debugger": "warn",
"no-empty": "warn",
"no-undef": "warn",
"no-unused-vars": ["warn", commonUnusedOptions],
"no-useless-assignment": "warn",
"vue/multi-word-component-names": "off",
"vue/no-mutating-props": "warn",
"vue/no-unused-components": "warn",
"vue/no-unused-vars": "warn",
"vue/no-v-html": "off",
},
},
{
files: ["**/*.{ts,tsx}"],
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["warn", commonUnusedOptions],
},
},
{
files: ["tests/**/*.{js,ts}", "playwright*.ts", "scripts/**/*.{js,mjs}"],
languageOptions: {
globals: {
...commonGlobals,
...globals.node,
...vitestGlobals,
},
},
rules: {
"no-empty": "warn",
},
},
];
+4 -13
View File
@@ -2,19 +2,10 @@
<html lang="" class="theme-light" data-theme="light"> <html lang="" class="theme-light" data-theme="light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<script> <link rel="icon" type="image/png" href="/assets/favicons/favicon-96x96.png" sizes="96x96" />
(function () { <link rel="icon" type="image/svg+xml" href="/assets/favicons/favicon.svg" />
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/); <link rel="shortcut icon" href="/assets/favicons/favicon.ico" />
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/'; <link rel="apple-touch-icon" sizes="180x180" href="/assets/favicons/apple-touch-icon.png" />
var base = document.createElement('base');
base.href = href;
document.currentScript.after(base);
})();
</script>
<link rel="icon" type="image/png" href="%BASE_URL%assets/favicons/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="%BASE_URL%assets/favicons/favicon.svg" />
<link rel="shortcut icon" href="%BASE_URL%assets/favicons/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="%BASE_URL%assets/favicons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" /> <meta name="apple-mobile-web-app-title" content="Truck Wash Kundeportal" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
+1 -22
View File
@@ -5,39 +5,22 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
location ~ ^/(?:.+/)?(?<webmanifest_path>(?:assets/)?manifest\.webmanifest)$ {
types { application/manifest+json webmanifest; }
default_type application/manifest+json;
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$webmanifest_path =404;
}
location ~ ^/(release-entry|release-manifest)\.json$ { location ~ ^/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404; try_files $uri =404;
} }
location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ { location ~ ^/(master|beta|canary|internal)/frontend/(release-entry|release-manifest)\.json$ {
add_header Cache-Control "no-store"; add_header Cache-Control "no-store";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$2.json =404; try_files /$2.json =404;
} }
location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ { location ~ ^/(?:.+/)?(?<static_asset_path>(?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files /$static_asset_path =404; try_files /$static_asset_path =404;
} }
location ~ ^/(?:.+/)?(?<static_file_path>index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ { location ~ ^/(?:.+/)?(?<static_file_path>manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ {
try_files /$static_file_path =404; try_files /$static_file_path =404;
} }
@@ -47,15 +30,11 @@ server {
location /assets/ { location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
try_files $uri =404; try_files $uri =404;
} }
location ~ ^/(master|beta|canary|internal)/frontend/assets/ { location ~ ^/(master|beta|canary|internal)/frontend/assets/ {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
add_header Content-Security-Policy "frame-ancestors 'self'" always;
add_header X-Frame-Options "SAMEORIGIN" always;
rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break; rewrite ^/(master|beta|canary|internal)/frontend/(.*)$ /$2 break;
try_files $uri =404; try_files $uri =404;
} }
+19 -115
View File
@@ -3056,8 +3056,6 @@ paths:
get: get:
tags: tags:
- Orders - Orders
x-api-coverage:
happy: true
summary: List orders summary: List orders
description: Retrieve a paginated list of orders description: Retrieve a paginated list of orders
operationId: listOrders operationId: listOrders
@@ -4624,7 +4622,7 @@ paths:
tags: tags:
- Self-Serve - Self-Serve
summary: Add vehicle condition summary: Add vehicle condition
description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow. description: Add a new vehicle condition (answer to a question). Customers can only add conditions for their own vehicles.
operationId: addSelfserveVehicleCondition operationId: addSelfserveVehicleCondition
requestBody: requestBody:
required: true required: true
@@ -4659,6 +4657,14 @@ paths:
type: integer type: integer
nullable: true nullable: true
description: Alias for vehicle_type. description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay. User wash-start saves answers with false.
sync_relay_state:
type: boolean
default: true
description: Whether the answer mutation should synchronize live relay state.
responses: responses:
'200': '200':
description: Successfully added vehicle condition description: Successfully added vehicle condition
@@ -4675,7 +4681,7 @@ paths:
tags: tags:
- Self-Serve - Self-Serve
summary: Update vehicle condition summary: Update vehicle condition
description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles. This answer mutation does not activate machines or synchronize live relay state; hardware changes are handled only by the explicit wash start flow. description: Update an existing vehicle condition. Customers can only update conditions for their own vehicles.
operationId: updateSelfserveVehicleCondition operationId: updateSelfserveVehicleCondition
parameters: parameters:
- name: id - name: id
@@ -4711,6 +4717,14 @@ paths:
type: integer type: integer
nullable: true nullable: true
description: Alias for vehicle_type. description: Alias for vehicle_type.
activate_machine:
type: boolean
default: true
description: Whether the session synchronization may enable the machine relay.
sync_relay_state:
type: boolean
default: true
description: Whether the mutation should synchronize live relay state.
responses: responses:
'200': '200':
description: Successfully updated vehicle condition description: Successfully updated vehicle condition
@@ -5386,7 +5400,7 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
required: [department, gateway_id, action, confirm] required: [department, gateway_id, action]
properties: properties:
department: { type: integer } department: { type: integer }
gateway_id: { type: integer } gateway_id: { type: integer }
@@ -8688,116 +8702,6 @@ paths:
schema: schema:
$ref: '#/components/schemas/SelfServeLaneStatus' $ref: '#/components/schemas/SelfServeLaneStatus'
/modules/self-serve/lane/wash/my-active-wash:
get:
tags:
- Modules
summary: Get the authenticated customer's active self-serve wash
description: |
Returns the current authenticated customer's open self-serve wash session,
if one exists. Regular customers must only receive their own active wash
details from this endpoint.
operationId: getMyActiveSelfServeWash
responses:
'200':
description: Authenticated customer's active wash details resolved
content:
application/json:
schema:
type: object
properties:
lane_id:
type: integer
nullable: true
in_progress:
type: boolean
session:
type: object
nullable: true
properties:
id:
type: integer
lane_id:
type: integer
nullable: true
department_id:
type: integer
nullable: true
status:
type: string
reg:
type: string
customer_number:
type: integer
nullable: true
vehicle_id:
type: integer
nullable: true
vehicle_type_id:
type: integer
nullable: true
included_minutes:
type: integer
nullable: true
machine_type_id:
type: integer
nullable: true
machine_relay_enabled:
type: boolean
machine_relay_enabled_at:
type: string
nullable: true
machine_start_triggered:
type: boolean
machine_start_triggered_at:
type: string
nullable: true
wash_started_at:
type: string
nullable: true
created_at:
type: string
updated_at:
type: string
nullable: true
customer:
type: object
nullable: true
properties:
id:
type: integer
nullable: true
customer_number:
type: integer
nullable: true
display_name:
type: string
nullable: true
email:
type: string
nullable: true
phone_country_code:
type: integer
nullable: true
phone:
type: string
nullable: true
vehicle:
type: object
nullable: true
properties:
id:
type: integer
customer_id:
type: integer
type:
type: integer
reg:
type: string
reference:
type: string
nullable: true
/modules/self-serve/lane/wash/in-progress: /modules/self-serve/lane/wash/in-progress:
get: get:
tags: tags:
+3 -3
View File
@@ -56,9 +56,9 @@ android {
defaultConfig { defaultConfig {
applicationId "io.truckwash.twa.staging" applicationId "io.truckwash.twa.staging"
minSdkVersion 21 minSdkVersion 21
targetSdkVersion 36 targetSdkVersion 35
versionCode 6 versionCode 5
versionName "6" versionName "5"
// The name for the application // The name for the application
resValue "string", "appName", twaManifest.name resValue "string", "appName", twaManifest.name
@@ -1 +1 @@
{"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"/"} {"name":"Truck Wash Kundeportal","short_name":"Truck Wash","description":"Access your Truck Wash accounts and transactions from anywhere.","start_url":"/","display":"standalone","background_color":"#0787bb","theme_color":"#063651","lang":"en","scope":"/","orientation":"portrait","launch_handler":{"client_mode":"navigate-existing"},"icons":[{"src":"favicons/web-app-manifest-192x192.png","sizes":"192x192","type":"image/png"},{"src":"favicons/web-app-manifest-512x512.png","sizes":"512x512","type":"image/png"}],"id":"pleno-pwa-test"}
+41 -1119
View File
File diff suppressed because it is too large Load Diff
+1 -9
View File
@@ -7,13 +7,11 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"build:dev": "vite build --mode development", "build:dev": "vite build --mode development",
"lint": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts --quiet",
"lint:strict": "npm run lint:report -- --max-warnings=0",
"lint:report": "eslint eslint.config.js src tests scripts vite.config.js vitest.config.js playwright*.ts",
"format:tests": "prettier --write \"tests/**/*.{js,ts}\"", "format:tests": "prettier --write \"tests/**/*.{js,ts}\"",
"format:tests:commit": "node scripts/pre-commit-format-tests.mjs", "format:tests:commit": "node scripts/pre-commit-format-tests.mjs",
"format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"", "format:tests:check": "prettier --check \"tests/**/*.{js,ts}\"",
"prepare": "node scripts/prepare-husky.mjs", "prepare": "node scripts/prepare-husky.mjs",
"postinstall": "node scripts/postinstall-sync-playwright-root-links.mjs",
"preview": "vite preview", "preview": "vite preview",
"preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173", "preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173",
"text:fix-encoding": "node scripts/text-encoding.mjs fix", "text:fix-encoding": "node scripts/text-encoding.mjs fix",
@@ -111,23 +109,17 @@
}, },
"devDependencies": { "devDependencies": {
"@creativebulma/bulma-divider": "^1.1.0", "@creativebulma/bulma-divider": "^1.1.0",
"@eslint/js": "^10.0.1",
"@event-calendar/core": "^4.1.0", "@event-calendar/core": "^4.1.0",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@types/event-calendar__core": "^3.7.0", "@types/event-calendar__core": "^3.7.0",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.5", "@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/test-utils": "^2.4.6", "@vue/test-utils": "^2.4.6",
"eslint": "^10.4.1",
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"husky": "^9.1.7", "husky": "^9.1.7",
"jsdom": "^29.0.0", "jsdom": "^29.0.0",
"otpauth": "^9.5.0", "otpauth": "^9.5.0",
"prettier": "2.8.8", "prettier": "2.8.8",
"sass-embedded": "^1.81.0", "sass-embedded": "^1.81.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "7.1.11", "vite": "7.1.11",
"vite-plugin-pwa": "^1.0.2", "vite-plugin-pwa": "^1.0.2",
"vite-plugin-vue-devtools": "^7.5.4", "vite-plugin-vue-devtools": "^7.5.4",
-4
View File
@@ -38,14 +38,10 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
testIgnore: ["**/release/**"], testIgnore: ["**/release/**"],
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
timeout: 60_000, timeout: 60_000,
fullyParallel: true, fullyParallel: true,
forbidOnly: isCI, forbidOnly: isCI,
retries: isCI ? 2 : 0, retries: isCI ? 2 : 0,
expect: {
timeout: 15_000,
},
workers, workers,
...(process.env.PLAYWRIGHT_BASE_URL ...(process.env.PLAYWRIGHT_BASE_URL
? {} ? {}
+29 -40
View File
@@ -1,5 +1,4 @@
import { execFile, spawn } from "node:child_process"; import { execFile, spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import readline from "node:readline"; import readline from "node:readline";
@@ -12,26 +11,15 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${devPort}
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`) const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
.trim() .trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-"); .replace(/[^a-zA-Z0-9._-]+/g, "-");
const serverLogDir = path.resolve(process.cwd(), "output/playwright"); const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
const pidFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.json`);
const stdoutLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stdout.log`);
const stderrLogFile = path.resolve(serverLogDir, `dev-server-${runtimeNamespace}.stderr.log`);
const viteCliPath = path.resolve(process.cwd(), "node_modules/vite/bin/vite.js");
const serverOutputLimit = 80; const serverOutputLimit = 80;
const activeOutputReaders = []; const activeOutputReaders = [];
const viteServerEnv = {
...process.env,
PLAYWRIGHT: "1",
...(process.env.CI
? {
CHOKIDAR_INTERVAL: process.env.CHOKIDAR_INTERVAL || "300",
CHOKIDAR_USEPOLLING: process.env.CHOKIDAR_USEPOLLING || "true",
}
: {}),
};
// Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot. // Hardlinked Windows worktrees can break Vite's bundled config temp paths during Playwright boot.
const viteDevArgs = [ const viteDevArgs = [
...(process.env.PLAYWRIGHT_VITE_FORCE === "1" ? ["--force"] : []), "run",
"dev",
"--",
"--force",
...(process.platform === "win32" ? ["--configLoader", "runner"] : []), ...(process.platform === "win32" ? ["--configLoader", "runner"] : []),
"--host", "--host",
devHost, devHost,
@@ -188,18 +176,14 @@ async function killProcessTree(pid) {
} }
} }
function captureProcessOutput(stream, lines, logStream) { function captureProcessOutput(stream, lines) {
const reader = readline.createInterface({ input: stream }); const reader = readline.createInterface({ input: stream });
reader.on("line", (line) => { reader.on("line", (line) => {
lines.push(line); lines.push(line);
logStream.write(`${line}\n`);
if (lines.length > serverOutputLimit) { if (lines.length > serverOutputLimit) {
lines.splice(0, lines.length - serverOutputLimit); lines.splice(0, lines.length - serverOutputLimit);
} }
}); });
reader.on("close", () => {
logStream.end();
});
return reader; return reader;
} }
@@ -384,10 +368,6 @@ async function warmModuleGraph(entryUrl, { depth = 2, timeoutMs = 120_000 } = {}
} }
for (const specifier of extractModuleImports(source)) { for (const specifier of extractModuleImports(source)) {
if (specifier.startsWith("/node_modules/.vite/deps/")) {
continue;
}
const expectedContentType = resolveExpectedContentType(specifier); const expectedContentType = resolveExpectedContentType(specifier);
const importUrl = new URL(specifier, current.url).toString(); const importUrl = new URL(specifier, current.url).toString();
@@ -456,22 +436,31 @@ export default async function globalSetup() {
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
} }
const serverProcess = spawn(process.execPath, [viteCliPath, ...viteDevArgs], { const serverProcess =
cwd: process.cwd(), process.platform === "win32"
detached: true, ? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd ${viteDevArgs.join(" ")}`], {
env: viteServerEnv, cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"], detached: true,
windowsHide: true, env: {
}); ...process.env,
PLAYWRIGHT: "1",
},
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
})
: spawn("npm", viteDevArgs, {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: ["ignore", "pipe", "pipe"],
});
const stdoutLines = []; const stdoutLines = [];
const stderrLines = []; const stderrLines = [];
const stdoutLogStream = createWriteStream(stdoutLogFile, { flags: "w" }); const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines);
const stderrLogStream = createWriteStream(stderrLogFile, { flags: "w" }); const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines);
serverProcess.on("exit", (code, signal) => {
stderrLogStream.write(`[playwright-global-setup] vite exited with code ${code ?? "null"} signal ${signal ?? "null"}\n`);
});
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines, stdoutLogStream);
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines, stderrLogStream);
activeOutputReaders.push(stdoutReader, stderrReader); activeOutputReaders.push(stdoutReader, stderrReader);
serverProcess.unref(); serverProcess.unref();
+21 -52
View File
@@ -1,4 +1,3 @@
import fs from "node:fs";
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://127.0.0.1:4173"; const baseURL = "http://127.0.0.1:4173";
@@ -6,55 +5,6 @@ const isCI = !!process.env.CI;
process.env.PLAYWRIGHT_BASE_URL = baseURL; process.env.PLAYWRIGHT_BASE_URL = baseURL;
const osReleaseValue = (key: string) => {
try {
const body = fs.readFileSync("/etc/os-release", "utf8");
const match = body.match(new RegExp(`^${key}=(.*)$`, "m"));
return String(match?.[1] || "").replace(/^"|"$/g, "");
} catch {
return "";
}
};
const isUnsupportedWebKitHost = () => {
if (process.platform !== "linux") {
return false;
}
const id = osReleaseValue("ID").toLowerCase();
const version = Number.parseFloat(osReleaseValue("VERSION_ID"));
return id === "ubuntu" && Number.isFinite(version) && version >= 26.04;
};
const webKitOverride = String(process.env.PLAYWRIGHT_PROD_WEBKIT || "")
.trim()
.toLowerCase();
const includeWebKit =
webKitOverride === "1" || (webKitOverride !== "0" && webKitOverride !== "false" && !isUnsupportedWebKitHost());
const projects = [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
];
if (includeWebKit) {
projects.push({
name: "webkit-desktop",
use: {
...devices["Desktop Safari"],
},
});
}
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e/release", testDir: "./tests/e2e/release",
testMatch: /.*\.local-prod\.spec\.ts/, testMatch: /.*\.local-prod\.spec\.ts/,
@@ -62,7 +12,7 @@ export default defineConfig({
fullyParallel: true, fullyParallel: true,
forbidOnly: isCI, forbidOnly: isCI,
retries: isCI ? 2 : 0, retries: isCI ? 2 : 0,
workers: isCI ? 2 : 3, workers: isCI ? 2 : 1,
reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]], reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]],
outputDir: "output/playwright/prod/test-results", outputDir: "output/playwright/prod/test-results",
use: { use: {
@@ -78,5 +28,24 @@ export default defineConfig({
timeout: 240_000, timeout: 240_000,
reuseExistingServer: !isCI, reuseExistingServer: !isCI,
}, },
projects, projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
{
name: "webkit-desktop",
use: {
...devices["Desktop Safari"],
},
},
],
}); });
+4 -8
View File
@@ -2,10 +2,6 @@
Options -MultiViews Options -MultiViews
</IfModule> </IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_rewrite.c> <IfModule mod_rewrite.c>
RewriteEngine On RewriteEngine On
@@ -27,17 +23,17 @@
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L] RewriteRule ^.+/((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L] RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L] RewriteRule ^(?:.*?/)?((?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
@@ -45,7 +41,7 @@
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L] RewriteRule ^(?:.*?/)?(?:manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d RewriteCond %{REQUEST_FILENAME} !-d
-1
View File
@@ -2,7 +2,6 @@
"name": "Truck Wash Kundeportal", "name": "Truck Wash Kundeportal",
"short_name": "Truck Wash", "short_name": "Truck Wash",
"description": "Access your Truck Wash accounts and transactions from anywhere.", "description": "Access your Truck Wash accounts and transactions from anywhere.",
"id": "/",
"icons": [ "icons": [
{ {
"src": "assets/favicons/web-app-manifest-192x192.png", "src": "assets/favicons/web-app-manifest-192x192.png",
-90
View File
@@ -1,90 +0,0 @@
import { spawnSync } from "node:child_process";
const browsers = process.argv.slice(2);
const requestedBrowsers = browsers.length > 0 ? browsers : ["chromium"];
const fallbackHostPlatformByUnsupportedPlatform = (platform) => {
const ubuntuMatch = platform.match(/^ubuntu(\d+\.\d+)-(x64|arm64)$/);
if (ubuntuMatch && Number.parseInt(ubuntuMatch[1], 10) >= 26) {
return `ubuntu24.04-${ubuntuMatch[2]}`;
}
return null;
};
const outputText = (result) => `${result.stdout || ""}\n${result.stderr || ""}`;
const unsupportedHostPlatform = (result) => {
const output = outputText(result);
return (
output.match(/Cannot install dependencies for (?<platform>\S+) with Playwright/i)?.groups?.platform ||
output.match(/Playwright does not support \S+ on (?<platform>\S+)/i)?.groups?.platform ||
null
);
};
const runPlaywrightInstall = (args, env = {}) =>
spawnSync("npx", ["playwright", "install", ...args], {
cwd: process.cwd(),
env: {
...process.env,
...env,
},
encoding: "utf8",
});
const writeOutput = (result) => {
if (result.stdout) {
process.stdout.write(result.stdout);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
};
const hasUnsupportedHostPlatformFailure = (result) => {
const output = outputText(result);
return (
result.status !== 0 &&
/Playwright does not support .* on /i.test(output)
);
};
const withDepsResult = runPlaywrightInstall(["--with-deps", ...requestedBrowsers]);
writeOutput(withDepsResult);
if (withDepsResult.status === 0) {
process.exit(0);
}
if (!hasUnsupportedHostPlatformFailure(withDepsResult)) {
process.exit(withDepsResult.status ?? 1);
}
const unsupportedPlatform = unsupportedHostPlatform(withDepsResult);
const fallbackHostPlatform = unsupportedPlatform
? fallbackHostPlatformByUnsupportedPlatform(unsupportedPlatform)
: null;
if (!fallbackHostPlatform) {
console.error(
`Playwright dependency install is unsupported for ${unsupportedPlatform || "this platform"}, ` +
"and this script does not have a safe browser archive fallback for it."
);
process.exit(withDepsResult.status ?? 1);
}
console.warn(
[
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
`Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
"The self-hosted runner image must provide the required browser system libraries.",
].join("\n")
);
const fallbackResult = runPlaywrightInstall(requestedBrowsers, {
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform,
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1",
});
writeOutput(fallbackResult);
process.exit(fallbackResult.status ?? 1);
-6
View File
@@ -34,12 +34,6 @@ export const sourceMappings = [
], ],
projects: chromiumProjects, projects: chromiumProjects,
}, },
{
name: "user-vehicles",
patterns: [/^src\/components\/displays\/user\/vehicles\//u, /^src\/views\/dashboards\/userDashboard\/vehicles\//u],
specs: ["tests/e2e/userVehicles.spec.ts"],
projects: chromiumProjects,
},
{ {
name: "pos", name: "pos",
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u], patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
@@ -0,0 +1,22 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const helperScriptPath = path.resolve(process.cwd(), "..", "scripts", "sync-playwright-root-links.mjs");
if (!fs.existsSync(helperScriptPath)) {
console.log(
`Skipping root Playwright link sync: helper script not found at ${helperScriptPath}.`
);
process.exit(0);
}
const result = spawnSync(process.execPath, [helperScriptPath], {
stdio: "inherit",
});
if (typeof result.status === "number") {
process.exit(result.status);
}
process.exit(1);
+3 -35
View File
@@ -23,52 +23,20 @@ if [[ -z "$DEPLOY_URL" ]]; then
echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2 echo "Set RELEASE_DEPLOY_URL or RELEASE_DEPLOY_HOST, RELEASE_DEPLOY_USER, and RELEASE_DEPLOY_PASSWORD." >&2
exit 1 exit 1
fi fi
DEPLOY_URL="ftp://${HOST}" DEPLOY_URL="ftp://${USER_NAME}:${PASSWORD}@${HOST}"
elif [[ -n "$USER_NAME" || -n "$PASSWORD" ]]; then
if [[ -z "$USER_NAME" || -z "$PASSWORD" ]]; then
echo "Set both RELEASE_DEPLOY_USER and RELEASE_DEPLOY_PASSWORD when providing deploy credentials separately." >&2
exit 1
fi
fi fi
lftp_quote() {
local value="${1//\'/\'\\\'\'}"
printf "'%s'" "$value"
}
run_lftp() {
local transfer_command="$1"
{
printf 'set ftp:ssl-allow true\n'
printf 'set ftp:ssl-force true\n'
printf 'set ftp:ssl-protect-data true\n'
printf 'set net:max-retries 3\n'
printf 'set net:timeout 20\n'
if [[ -n "$USER_NAME" && -n "$PASSWORD" ]]; then
printf 'open -u %s,%s %s\n' "$(lftp_quote "$USER_NAME")" "$(lftp_quote "$PASSWORD")" "$(lftp_quote "$DEPLOY_URL")"
else
printf 'open %s\n' "$(lftp_quote "$DEPLOY_URL")"
fi
printf 'cd %s\n' "$(lftp_quote "$REMOTE_ROOT")"
printf '%s\n' "$transfer_command"
printf 'bye\n'
} | lftp -f /dev/stdin
}
upload_file() { upload_file() {
local source_file="$1" local source_file="$1"
local remote_file="$2" local remote_file="$2"
if [[ -f "$source_file" ]]; then if [[ -f "$source_file" ]]; then
run_lftp "put -O $(lftp_quote "$(dirname "$remote_file")") $(lftp_quote "$source_file") -o $(lftp_quote "$(basename "$remote_file")")" lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; put -O \"$(dirname "$remote_file")\" \"$source_file\" -o \"$(basename "$remote_file")\"; bye"
fi fi
} }
for directory in assets resources favicons icons img sounds .well-known; do for directory in assets resources favicons icons img sounds .well-known; do
if [[ -d "$DIST_DIR/$directory" ]]; then if [[ -d "$DIST_DIR/$directory" ]]; then
run_lftp "mirror -R --only-newer --parallel=4 $(lftp_quote "$DIST_DIR/$directory") $(lftp_quote "$directory")" lftp "$DEPLOY_URL" -e "set ftp:ssl-allow true; set net:max-retries 3; set net:timeout 20; cd \"$REMOTE_ROOT\"; mirror -R --only-newer --parallel=4 \"$DIST_DIR/$directory\" \"$directory\"; bye"
fi fi
done done
+1 -25
View File
@@ -1,5 +1,4 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -8,24 +7,6 @@ function numberEnv(name, fallback) {
return Number.isFinite(value) && value >= 0 ? value : fallback; return Number.isFinite(value) && value >= 0 ? value : fallback;
} }
function booleanEnv(name) {
return /^(1|true|yes)$/i.test(process.env[name] || "");
}
function appendGithubEnv(values) {
const envFile = process.env.GITHUB_ENV;
if (!envFile) {
return;
}
const lines = Object.entries(values)
.filter(([, value]) => value)
.map(([key, value]) => `${key}=${String(value).replace(/\r?\n/g, "")}`);
if (lines.length > 0) {
fs.appendFileSync(envFile, `${lines.join("\n")}\n`);
}
}
function requiredUrl() { function requiredUrl() {
const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL; const value = process.env.RELEASE_BASE_URL || process.env.PLAYWRIGHT_BASE_URL;
if (!value) { if (!value) {
@@ -140,7 +121,6 @@ async function verifyShell(baseUrl, shellPath) {
async function verifyRelease(baseUrl) { async function verifyRelease(baseUrl) {
const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || ""; const expectedCommit = process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || "";
const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || ""; const expectedBuildId = process.env.RELEASE_EXPECTED_BUILD_ID || process.env.RELEASE_BUILD_ID || "";
const strictBuildId = booleanEnv("RELEASE_STRICT_BUILD_ID");
const manifest = await fetchJson(baseUrl, "release-manifest.json"); const manifest = await fetchJson(baseUrl, "release-manifest.json");
const releaseEntry = await fetchJson(baseUrl, "release-entry.json"); const releaseEntry = await fetchJson(baseUrl, "release-entry.json");
@@ -150,7 +130,7 @@ async function verifyRelease(baseUrl) {
if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) { if (!compareCommit(String(manifest.commit_sha || ""), expectedCommit)) {
throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`); throw new Error(`release-manifest.json commit_sha ${manifest.commit_sha || "(missing)"} did not match ${expectedCommit}`);
} }
if (strictBuildId && expectedBuildId && manifest.build_id !== expectedBuildId) { if (expectedBuildId && manifest.build_id !== expectedBuildId) {
throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`); throw new Error(`release-manifest.json build_id ${manifest.build_id} did not match ${expectedBuildId}`);
} }
if (releaseEntry.entry !== manifest.entry) { if (releaseEntry.entry !== manifest.entry) {
@@ -211,10 +191,6 @@ async function main() {
attempt += 1; attempt += 1;
try { try {
const result = await verifyRelease(baseUrl); const result = await verifyRelease(baseUrl);
appendGithubEnv({
RELEASE_VERIFIED_BUILD_ID: result.build_id,
RELEASE_VERIFIED_COMMIT: result.commit_sha,
});
console.log( console.log(
`Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}` `Release upload verified after ${attempt} attempt(s): build_id=${result.build_id}, commit_sha=${result.commit_sha}, assets=${result.assets}`
); );
+8 -44
View File
@@ -1,41 +1,31 @@
import { execFile, spawn } from "node:child_process"; import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util"; import { promisify } from "node:util";
const workingDirectory = process.cwd(); const workingDirectory = process.cwd();
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js"); const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
export const roles = ["customer", "subuser", "admin", "superuser"]; const roles = ["customer", "subuser", "admin", "superuser"];
const listEntryPattern = /^\s+\[[^\]]+\]\s+\s+(.+?):(\d+):(\d+)\s+\s+(.+)\s*$/u; const listEntryPattern = /^\s+\[[^\]]+\]\s+\s+(.+?):(\d+):(\d+)\s+\s+(.+)\s*$/u;
export const ownedFilesByRole = { const ownedFilesByRole = {
customer: [ customer: [
"auth.smoke.spec.js", "auth.smoke.spec.js",
"booking-selfserve.smoke.spec.js", "booking-selfserve.smoke.spec.js",
"connectivityIssue.spec.ts", "connectivityIssue.spec.ts",
"example.spec.ts", "example.spec.ts",
"guest-book-wash-mobile.spec.ts",
"i18n-catalog-switch.spec.ts",
"i18n-v2-integrity.spec.ts",
"i18n.smoke.spec.ts", "i18n.smoke.spec.ts",
"i18n.views.spec.ts", "i18n.views.spec.ts",
"navigation.smoke.spec.js", "navigation.smoke.spec.js",
"qr-new-customer-layout.spec.ts", "qr-new-customer-layout.spec.ts",
"release-bootstrap.spec.js",
"release-channel-switched.spec.js",
"release-channel-unavailable.spec.js",
"release-update-widget.spec.js",
"self-serve-wash.spec.js", "self-serve-wash.spec.js",
"session-release-runtime.spec.ts",
"user-orders.spec.ts", "user-orders.spec.ts",
"userBookings.spec.ts", "userBookings.spec.ts",
"userBookWash.spec.ts", "userBookWash.spec.ts",
"userHome.spec.ts", "userHome.spec.ts",
"userInvoices.spec.ts", "userInvoices.spec.ts",
"userMyWashStart.spec.ts", "userMyWashStart.spec.ts",
"userMyWashStartFlow.spec.ts",
"userProfileInvoicing.spec.ts", "userProfileInvoicing.spec.ts",
"userProfileNotifications.spec.ts", "userProfileNotifications.spec.ts",
"userProfileSecurity.spec.ts", "userProfileSecurity.spec.ts",
@@ -63,7 +53,6 @@ export const ownedFilesByRole = {
"assign-draft-order-modal-layout.spec.ts", "assign-draft-order-modal-layout.spec.ts",
"change-invoice-collection.spec.ts", "change-invoice-collection.spec.ts",
"change-customer.spec.ts", "change-customer.spec.ts",
"default-mobile-redirect.spec.ts",
"economic-queue-workflow.spec.js", "economic-queue-workflow.spec.js",
"pos-customer-rules.spec.js", "pos-customer-rules.spec.js",
"pos-desktop-card-payments.spec.js", "pos-desktop-card-payments.spec.js",
@@ -73,24 +62,18 @@ export const ownedFilesByRole = {
"pos.visual.spec.js", "pos.visual.spec.js",
], ],
superuser: [ superuser: [
"coolify-infrastructure.spec.js",
"edge-gateways.fleet-outline.spec.js", "edge-gateways.fleet-outline.spec.js",
"edge-gateways.routes.spec.js", "edge-gateways.routes.spec.js",
"edge-gateways.smoke.spec.js", "edge-gateways.smoke.spec.js",
"edge-gateways.visual.spec.js", "edge-gateways.visual.spec.js",
"errorReports.spec.ts",
"failover-config.source.spec.ts",
"collected-invoice-move-customer.spec.ts",
"invoice-distribution.smoke.spec.js", "invoice-distribution.smoke.spec.js",
"invoice-transfer-monitor.spec.ts", "invoice-transfer-monitor.spec.ts",
"invoice-transfer-queue-history.spec.js", "invoice-transfer-queue-history.spec.js",
"invoicing-period.smoke.spec.js", "invoicing-period.smoke.spec.js",
"issue-repro-duplicates-date.spec.js", "issue-repro-duplicates-date.spec.js",
"release-manager.spec.js",
"self-serve-sessions.spec.js", "self-serve-sessions.spec.js",
"self-serve-studio-audit-navigation.spec.js", "self-serve-studio-audit-navigation.spec.js",
"self-serve-studio-flow.spec.js", "self-serve-studio-flow.spec.js",
"session-bootstrap.spec.ts",
"superuser-bookings.spec.ts", "superuser-bookings.spec.ts",
"superuser-customer-complaints.spec.ts", "superuser-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts", "superuser-customers-mass-import.spec.ts",
@@ -101,13 +84,12 @@ export const ownedFilesByRole = {
"superuser-drafts.spec.ts", "superuser-drafts.spec.ts",
"superuser-products-layout.spec.ts", "superuser-products-layout.spec.ts",
"superuser-system-status.smoke.spec.js", "superuser-system-status.smoke.spec.js",
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js", "superuser-vehicles.smoke.spec.js",
"workfeed-config.smoke.spec.js", "workfeed-config.smoke.spec.js",
], ],
}; };
export const titleRules = [ const titleRules = [
{ role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] }, { role: "customer", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[User\]/u] },
{ role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] }, { role: "subuser", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Subuser\]/u] },
{ role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] }, { role: "admin", file: "userAuth.spec.ts", patterns: [/\[AUTH\]\[Operator\]/u] },
@@ -134,11 +116,6 @@ export const titleRules = [
file: "subuser-management.spec.ts", file: "subuser-management.spec.ts",
patterns: [/^authorized subuser managers/i, /^subuser self-service/i, /^subusers without /i], patterns: [/^authorized subuser managers/i, /^subuser self-service/i, /^subusers without /i],
}, },
{
role: "superuser",
file: "subuser-management.spec.ts",
patterns: [/^superusers can list and invite chauffeurs/i],
},
{ role: "subuser", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[Subuser\]\[Visibility\]/u] }, { role: "subuser", file: "userProfileVisibility.spec.ts", patterns: [/\[PROFILE\]\[Subuser\]\[Visibility\]/u] },
]; ];
@@ -223,7 +200,7 @@ function toBaseName(filePath) {
return filePath.split(/[\\/]/u).pop() || filePath; return filePath.split(/[\\/]/u).pop() || filePath;
} }
export function parseListedTests(listOutput) { function parseListedTests(listOutput) {
return listOutput return listOutput
.split(/\r?\n/u) .split(/\r?\n/u)
.map((line) => { .map((line) => {
@@ -245,7 +222,7 @@ export function parseListedTests(listOutput) {
.filter(Boolean); .filter(Boolean);
} }
export function classifyTest(testEntry) { function classifyTest(testEntry) {
const matches = new Set(); const matches = new Set();
const directOwner = ownedFileToRole.get(testEntry.fileName); const directOwner = ownedFileToRole.get(testEntry.fileName);
@@ -332,8 +309,8 @@ async function runPlaywright(project, testListPath, forwardedArgs) {
}); });
} }
export async function main(argv = process.argv.slice(2)) { async function main() {
const { options, forwardedArgs } = parseCliArgs(argv); const { options, forwardedArgs } = parseCliArgs(process.argv.slice(2));
validateOptions(options, forwardedArgs); validateOptions(options, forwardedArgs);
const listOutput = await listProjectTests(options.project, forwardedArgs); const listOutput = await listProjectTests(options.project, forwardedArgs);
@@ -365,17 +342,4 @@ export async function main(argv = process.argv.slice(2)) {
await runPlaywright(options.project, testListPath, forwardedArgs); await runPlaywright(options.project, testListPath, forwardedArgs);
} }
async function isDirectRun() { await main();
if (!process.argv[1]) {
return false;
}
const currentPath = await fs.realpath(fileURLToPath(import.meta.url));
const invokedPath = await fs.realpath(process.argv[1]).catch(() => path.resolve(process.argv[1]));
return currentPath === invokedPath;
}
if (await isDirectRun()) {
await main();
}
+17 -52
View File
@@ -22,7 +22,6 @@ for (const signal of ["SIGINT", "SIGTERM"]) {
function parseArgs(rawArgs) { function parseArgs(rawArgs) {
const parsed = { const parsed = {
help: false, help: false,
coreOnly: false,
changedOnly: false, changedOnly: false,
listOnly: false, listOnly: false,
base: "", base: "",
@@ -44,11 +43,6 @@ function parseArgs(rawArgs) {
continue; continue;
} }
if (value === "--core-only") {
parsed.coreOnly = true;
continue;
}
if (value === "--changed-only") { if (value === "--changed-only") {
parsed.changedOnly = true; parsed.changedOnly = true;
continue; continue;
@@ -109,14 +103,12 @@ Options:
--base <ref> Base ref for changed-area detection --base <ref> Base ref for changed-area detection
--head <ref> Head ref for changed-area detection. Default: HEAD --head <ref> Head ref for changed-area detection. Default: HEAD
--project <name> Restrict to one Chromium Playwright project. Can be repeated. --project <name> Restrict to one Chromium Playwright project. Can be repeated.
--core-only Run only the core @pr gate
--changed-only Run changed-area selection without the core @pr gate --changed-only Run changed-area selection without the core @pr gate
--list-only List selected tests instead of running them --list-only List selected tests instead of running them
-h, --help Show help -h, --help Show help
Examples: Examples:
npm run test:e2e:pr npm run test:e2e:pr
npm run test:e2e:pr -- --core-only --project=chromium-desktop
npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD
`); `);
} }
@@ -167,7 +159,6 @@ async function runPlaywright({ label, commandArgs, artifactSuffix }) {
PLAYWRIGHT: "1", PLAYWRIGHT: "1",
PLAYWRIGHT_ARTIFACT_NAMESPACE: getArtifactNamespace(artifactSuffix), PLAYWRIGHT_ARTIFACT_NAMESPACE: getArtifactNamespace(artifactSuffix),
PLAYWRIGHT_REPORTER_MODE: "line-html", PLAYWRIGHT_REPORTER_MODE: "line-html",
PLAYWRIGHT_WORKERS: process.env.PLAYWRIGHT_WORKERS || "1",
}, },
stdio: "inherit", stdio: "inherit",
windowsHide: true, windowsHide: true,
@@ -327,19 +318,11 @@ function groupSpecsByProjects(specProjects) {
async function runCorePrGate() { async function runCorePrGate() {
const projects = getSelectedProjects(); const projects = getSelectedProjects();
for (const project of projects) { return runPlaywright({
const code = await runPlaywright({ label: `core ${prGrep} gate`,
label: `core ${prGrep} gate (${project})`, artifactSuffix: "core",
artifactSuffix: `core-${project}`, commandArgs: ["--grep", prGrep, ...buildProjectArgs(projects)],
commandArgs: ["--grep", prGrep, "--project", project], });
});
if (code !== 0) {
return code;
}
}
return 0;
} }
async function runChangedSelection(selection) { async function runChangedSelection(selection) {
@@ -349,19 +332,11 @@ async function runChangedSelection(selection) {
console.log( console.log(
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}` `[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}`
); );
for (const project of projects) { return runPlaywright({
const code = await runPlaywright({ label: `fallback ${smokeGrep} gate`,
label: `fallback ${smokeGrep} gate (${project})`, artifactSuffix: "smoke-fallback",
artifactSuffix: `smoke-fallback-${project}`, commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, ...buildProjectArgs(projects)],
commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, "--project", project], });
});
if (code !== 0) {
return code;
}
}
return 0;
} }
const groups = groupSpecsByProjects(selection.specProjects); const groups = groupSpecsByProjects(selection.specProjects);
@@ -371,16 +346,14 @@ async function runChangedSelection(selection) {
} }
for (const [index, group] of groups.entries()) { for (const [index, group] of groups.entries()) {
for (const project of group.projects) { const code = await runPlaywright({
const code = await runPlaywright({ label: `changed-area specs ${index + 1}/${groups.length}`,
label: `changed-area specs ${index + 1}/${groups.length} (${project})`, artifactSuffix: `changed-${index + 1}`,
artifactSuffix: `changed-${index + 1}-${project}`, commandArgs: [...group.specs, ...buildProjectArgs(group.projects)],
commandArgs: [...group.specs, "--project", project], });
});
if (code !== 0) { if (code !== 0) {
return code; return code;
}
} }
} }
@@ -426,10 +399,6 @@ async function main() {
return; return;
} }
if (args.coreOnly && args.changedOnly) {
throw new Error("--core-only and --changed-only cannot be used together.");
}
await fs.access(playwrightCliPath); await fs.access(playwrightCliPath);
if (!args.changedOnly) { if (!args.changedOnly) {
@@ -440,10 +409,6 @@ async function main() {
} }
} }
if (args.coreOnly) {
return;
}
const changed = await getChangedFiles(); const changed = await getChangedFiles();
if (changed.unavailable) { if (changed.unavailable) {
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`); console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
+2 -2
View File
@@ -18,9 +18,9 @@ const { t, te, locale } = useI18n({ useScope: "global" });
const APP_TITLE = "Truck Wash"; const APP_TITLE = "Truck Wash";
const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue")); const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue"));
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue")); const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
const VersionCheck = defineAsyncComponent(() => import("@/components/global/VersionCheck.vue"));
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue")); const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue")); const ErrorReportLauncher = defineAsyncComponent(() => import("@/components/global/ErrorReportLauncher.vue"));
const FrontendMaintenanceMenu = defineAsyncComponent(() => import("@/components/global/FrontendMaintenanceMenu.vue"));
const ReleaseChannelUnavailable = defineAsyncComponent(() => const ReleaseChannelUnavailable = defineAsyncComponent(() =>
import("@/components/release/ReleaseChannelUnavailable.vue") import("@/components/release/ReleaseChannelUnavailable.vue")
); );
@@ -123,13 +123,13 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
<header></header> <header></header>
<main> <main>
<DefaultPageWrapper> <DefaultPageWrapper>
<VersionCheck />
<router-view /> <router-view />
</DefaultPageWrapper> </DefaultPageWrapper>
</main> </main>
</template> </template>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" /> <RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" /> <ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<FrontendMaintenanceMenu />
</template> </template>
<style scoped></style> <style scoped></style>
@@ -1,10 +1,18 @@
<script setup> <script setup>
import CustomerComplaintsPagination from "@/components/displays/pagination/models/SuperUserDashboard/CustomerComplaintsPagination.vue"; import CustomerComplaintsPagination from "@/components/displays/pagination/models/SuperUserDashboard/CustomerComplaintsPagination.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
</script> </script>
<template> <template>
<div data-testid="superuser-complaints-page"> <div data-testid="superuser-complaints-page">
<CustomerComplaintsPagination :auto-load="true" /> <PageTitle
:title="t('superuser.pages.complaints.title')"
:subtitle="t('superuser.pages.complaints.subtitle')"
/>
<CustomerComplaintsPagination auto-load="true" />
</div> </div>
</template> </template>
+12 -1
View File
@@ -1,10 +1,21 @@
<script setup> <script setup>
import { ref } from 'vue'
import { getDepartmentListData } from "@/components/session/Session.vue";
import { showCreateDepartmentForm } from "@/components/forms/superUser/createDepartmentForm.vue"; import { showCreateDepartmentForm } from "@/components/forms/superUser/createDepartmentForm.vue";
import PageTitle from "@/components/global/PageTitle.vue"; import PageTitle from "@/components/global/PageTitle.vue";
import DepartmentsPagination from "@/components/displays/pagination/models/SuperUserDashboard/DepartmentsPagination.vue"; import DepartmentsPagination from "@/components/displays/pagination/models/SuperUserDashboard/DepartmentsPagination.vue";
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const tableData = ref([]);
getDepartmentListData().then((response) => {
tableData.value = response.data.data;
});
const redirect = (path) => {
window.location = path;
}
</script> </script>
<template> <template>
@@ -25,4 +36,4 @@ const { t } = useI18n();
<style scoped> <style scoped>
</style> </style>
+7 -33
View File
@@ -6,35 +6,15 @@ import SubusersPagination from "@/components/displays/pagination/models/SuperUse
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue"; import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
const props = defineProps({
endpoint: {
type: String,
default: "/subusers",
},
showCustomer: {
type: Boolean,
default: false,
},
superuserPage: {
type: Boolean,
default: false,
},
});
const { t } = useI18n(); const { t } = useI18n();
const canInvite = computed(() => const canInvite = computed(() => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD"));
props.superuserPage
? SessionUser.canAccessSuperUser()
: SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_ADD")
);
const requiresGrantSelection = computed( const requiresGrantSelection = computed(
() => !props.superuserPage && SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value () => SessionUser.isSubuser.value && !SessionUser.subuser.selectedGrantCustomerNumber.value
); );
const paginationVersion = ref(0); const paginationVersion = ref(0);
const paginationKey = computed(() => const paginationKey = computed(() =>
props.superuserPage SessionUser.isSubuser.value
? `subusers-superuser-${paginationVersion.value}`
: SessionUser.isSubuser.value
? `subusers-${SessionUser.subuser.selectedGrantCustomerNumber.value || "none"}-${paginationVersion.value}` ? `subusers-${SessionUser.subuser.selectedGrantCustomerNumber.value || "none"}-${paginationVersion.value}`
: `subusers-user-${paginationVersion.value}` : `subusers-user-${paginationVersion.value}`
); );
@@ -42,13 +22,13 @@ const paginationKey = computed(() =>
const onInviteClick = async () => { const onInviteClick = async () => {
await SessionUser.objects.subusers.functions.showInviteForm(() => { await SessionUser.objects.subusers.functions.showInviteForm(() => {
paginationVersion.value += 1; paginationVersion.value += 1;
}, { superuser: props.superuserPage }); });
}; };
</script> </script>
<template> <template>
<div> <div>
<PageTitle :title="SessionUser.objects.subusers.meta.title" :subtitle="t('superuser.pages.subusers.subtitle')"> <PageTitle :title="t('superuser.pages.subusers.title')" :subtitle="t('superuser.pages.subusers.subtitle')">
<template #buttons> <template #buttons>
<button v-if="canInvite" class="button is-dark" type="button" @click="onInviteClick"> <button v-if="canInvite" class="button is-dark" type="button" @click="onInviteClick">
<span class="icon"> <span class="icon">
@@ -59,7 +39,7 @@ const onInviteClick = async () => {
</template> </template>
</PageTitle> </PageTitle>
<div v-if="SessionUser.isSubuser.value && !superuserPage" class="mb-5"> <div v-if="SessionUser.isSubuser.value" class="mb-5">
<SubuserGrantSelector /> <SubuserGrantSelector />
<p class="help"> <p class="help">
Vælg den kunde, du vil administrere chauffører for. Listen og rettighederne følger det valgte kundenummer. Vælg den kunde, du vil administrere chauffører for. Listen og rettighederne følger det valgte kundenummer.
@@ -70,13 +50,7 @@ const onInviteClick = async () => {
Vælg først en kunde for at se og administrere chauffører. Vælg først en kunde for at se og administrere chauffører.
</div> </div>
<SubusersPagination <SubusersPagination v-else :key="paginationKey" auto-load="true" />
v-else
:key="paginationKey"
:endpoint="endpoint"
:show-customer="showCustomer"
auto-load="true"
/>
</div> </div>
</template> </template>
@@ -63,10 +63,6 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
reg_3: {
type: String,
default: null,
},
order_booking_id: { order_booking_id: {
type: Number, type: Number,
default: null, default: null,
@@ -97,10 +93,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
allowBookingDeletion: {
type: Boolean,
default: false,
},
department_lane_id: { department_lane_id: {
type: Number, type: Number,
default: null, default: null,
@@ -847,38 +839,6 @@ watch(isDropdownOpen, async (isOpen) => {
const attachmentsFromOrder = ref([]); const attachmentsFromOrder = ref([]);
const attachmentsFromOrderError = ref(null); const attachmentsFromOrderError = ref(null);
const SELF_SERVE_WASH_ATTACHMENT_TYPE = "SELF_SERVE_WASH";
const normalizePositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getAttachmentOtherPayload = (attachment) => attachment?.content?.other ?? null;
const isSelfServeWashAttachment = (attachment) => {
const other = getAttachmentOtherPayload(attachment);
return Boolean(other && typeof other === "object" && other.type === SELF_SERVE_WASH_ATTACHMENT_TYPE);
};
const getSelfServeWashAttachment = computed(() =>
attachmentsFromOrder.value.find((attachment) => isSelfServeWashAttachment(attachment)) || null
);
const getSelfServeWashPayload = computed(() => getAttachmentOtherPayload(getSelfServeWashAttachment.value));
const getSelfServeWashCustomerNumber = computed(() =>
normalizePositiveInteger(getSelfServeWashPayload.value?.customer_number)
);
const canAcceptSelfServeWashDraft = computed(() =>
Boolean(
props.order_id
&& getSelfServeWashCustomerNumber.value
&& normalizePositiveInteger(props.customer_number) !== getSelfServeWashCustomerNumber.value
&& (SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
)
);
const isObjectUrl = (value) => typeof value === "string" && value.startsWith("blob:"); const isObjectUrl = (value) => typeof value === "string" && value.startsWith("blob:");
@@ -903,39 +863,16 @@ const clearAttachmentPreviewState = () => {
}; };
const getAttachmentLabel = (attachment) => { const getAttachmentLabel = (attachment) => {
if (isSelfServeWashAttachment(attachment)) {
const customerNumber = normalizePositiveInteger(attachment?.content?.other?.customer_number);
return customerNumber
? t("admin.pos.settings_wheel.self_serve_wash_attachment_for_customer", { customerNumber })
: t("admin.pos.settings_wheel.self_serve_wash_attachment");
}
const other = getAttachmentOtherPayload(attachment);
const otherLabel = typeof other === "string"
? other
: other && typeof other === "object"
? (other.label || other.type || JSON.stringify(other))
: null;
return ( return (
attachment?.content?.document || attachment?.content?.document ||
attachment?.content?.image || attachment?.content?.image ||
otherLabel || attachment?.content?.other ||
`Attachment ${attachment?.id ?? ""}`.trim() `Attachment ${attachment?.id ?? ""}`.trim()
); );
}; };
const isWashCertificateAttachment = (attachment) => {
const marker = String(getAttachmentOtherPayload(attachment) || "").trim().toUpperCase();
if (marker === "WASH_CERTIFICATE") {
return true;
}
return /(?:^|[/\\])wash[_-]?certificate.*\.pdf$/i.test(getAttachmentLabel(attachment));
};
const getAttachmentExtension = (attachment) => { const getAttachmentExtension = (attachment) => {
const match = String(getAttachmentLabel(attachment)) const match = getAttachmentLabel(attachment)
.toLowerCase() .toLowerCase()
.match(/(\.[a-z0-9]+)$/); .match(/(\.[a-z0-9]+)$/);
@@ -957,51 +894,17 @@ const getAttachmentPreviewKind = (attachment) => {
return "office"; return "office";
} }
const other = getAttachmentOtherPayload(attachment); if (String(attachment?.content?.other || "").startsWith("http")) {
if (typeof other === "string" && other.startsWith("http")) {
return "link"; return "link";
} }
if (other) { if (attachment?.content?.other) {
return "text"; return "text";
} }
return "none"; return "none";
}; };
const formatAttachmentText = (attachment) => {
const other = getAttachmentOtherPayload(attachment);
if (isSelfServeWashAttachment(attachment)) {
const parts = [
t("admin.pos.settings_wheel.self_serve_wash_attachment"),
other?.customer_number
? `${t("admin.pos.settings_wheel.self_serve_customer")}: #${other.customer_number}`
: null,
other?.subuser?.name || other?.subuser?.username || other?.subuser_id
? `${t("admin.pos.settings_wheel.self_serve_driver")}: ${other?.subuser?.name || other?.subuser?.username || `#${other.subuser_id}`}`
: null,
other?.license_plate
? `${t("pos.license_plate")}: ${other.license_plate}`
: null,
other?.elapsed_wash_time_seconds
? `${t("admin.pos.settings_wheel.self_serve_elapsed")}: ${Math.ceil(Number(other.elapsed_wash_time_seconds) / 60)} min`
: null,
].filter(Boolean);
return parts.join("\n");
}
if (typeof other === "string") {
return other;
}
if (other && typeof other === "object") {
return JSON.stringify(other, null, 2);
}
return "";
};
const getAttachmentPreviewPlaceholderIcon = (attachment) => { const getAttachmentPreviewPlaceholderIcon = (attachment) => {
const previewKind = getAttachmentPreviewKind(attachment); const previewKind = getAttachmentPreviewKind(attachment);
@@ -1062,10 +965,6 @@ const activeAttachmentPreviewSource = computed(() => {
return previewSourcesById.value[activeAttachment.value.id] ?? null; return previewSourcesById.value[activeAttachment.value.id] ?? null;
}); });
const hasWashCertificateAttachment = computed(() =>
attachmentsFromOrder.value.some((attachment) => isWashCertificateAttachment(attachment))
);
const hasCachedPreviewSource = (attachmentId) => { const hasCachedPreviewSource = (attachmentId) => {
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId); return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
}; };
@@ -1630,137 +1529,6 @@ const showCompleteOrderBookingConfirmation = async () => {
} }
}; };
const showEmailNotificationActionResult = async (requestAction, successKey, errorKey) => {
try {
await requestAction();
await Swal.fire({
title: t(successKey),
icon: "success",
showConfirmButton: false,
timer: 2000,
heightAuto: false,
});
} catch (error) {
console.error(error);
await Swal.fire({
title: t("common.error"),
text: [t(errorKey), SessionUser.functions.parseErrorMessage?.(error)].filter(Boolean).join(": "),
icon: "error",
heightAuto: false,
});
}
};
const resendBookingConfirmation = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.order_bookings.functions.resendBookingConfirmation(props.order_booking_id),
"admin.pos.settings_wheel.resend_booking_confirmation_success",
"admin.pos.settings_wheel.resend_booking_confirmation_error"
);
const resendBookingCompletionConfirmation = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.order_bookings.functions.resendBookingCompletionConfirmation(props.order_booking_id),
"admin.pos.settings_wheel.resend_booking_completion_confirmation_success",
"admin.pos.settings_wheel.resend_booking_completion_confirmation_error"
);
const resendWashCertificate = () =>
showEmailNotificationActionResult(
() => SessionUser.objects.orders.functions.resendWashCertificate(props.order_id),
"admin.pos.settings_wheel.resend_wash_certificate_success",
"admin.pos.settings_wheel.resend_wash_certificate_error"
);
const getAvailableInvoiceCollectionsForCustomer = async (customerNumber) => {
const normalizedCustomerNumber = normalizePositiveInteger(customerNumber);
if (!normalizedCustomerNumber) {
return [];
}
const response = await SessionUser.request("/collected-invoices", "GET", {
page: 1,
limit: 100,
order: "closed_at:asc",
filters: `customer_number:${normalizedCustomerNumber},booked_invoice_id:is_null`,
});
return Array.isArray(response?.data?.data) ? response.data.data : [];
};
const ensureOpenInvoiceCollectionForCustomer = async (customerNumber) => {
const collections = await getAvailableInvoiceCollectionsForCustomer(customerNumber);
const openCollection = collections.find((collection) => collection?.closed_at === null);
const openCollectionId = normalizePositiveInteger(openCollection?.id);
if (openCollectionId) {
return openCollectionId;
}
const response = await SessionUser.objects.collectedOrderInvoices.add(
customerNumber,
t("admin.pos.drafts_assignment.new_collection_name"),
t("admin.pos.drafts_assignment.new_collection_description"),
null
);
return normalizePositiveInteger(response?.data?.data?.id ?? response?.data?.id);
};
const acceptSelfServeWashDraft = async () => {
const customerNumber = getSelfServeWashCustomerNumber.value;
const orderId = normalizePositiveInteger(props.order_id);
if (!customerNumber || !orderId) {
return;
}
const result = await Swal.fire({
title: t("admin.pos.settings_wheel.accept_self_serve_wash"),
text: t("admin.pos.settings_wheel.accept_self_serve_wash_confirm", { customerNumber }),
icon: "question",
showCancelButton: true,
confirmButtonText: t("common.confirm"),
cancelButtonText: t("common.cancel"),
});
if (!result.isConfirmed) {
return;
}
try {
const invoiceCollectionId = await ensureOpenInvoiceCollectionForCustomer(customerNumber);
if (!invoiceCollectionId) {
throw new Error(t("admin.pos.drafts_assignment.invoice_collection_empty"));
}
await SessionUser.objects.orders.functions.assignDraftCustomer({
order_id: orderId,
customer_id: customerNumber,
invoice_collection_id: invoiceCollectionId,
department_id: normalizePositiveInteger(props.department_id),
recalculate_prices: true,
});
await props.refreshFunction();
await Swal.fire({
icon: "success",
title: t("admin.pos.settings_wheel.accept_self_serve_wash_success"),
timer: 1800,
showConfirmButton: false,
});
} catch (error) {
await Swal.fire({
icon: "error",
title: t("admin.pos.drafts_assignment.error"),
text: SessionUser.functions.parseErrorMessage(error) || t("admin.pos.drafts_assignment.error"),
});
}
};
const canDeleteOrderBooking = computed(() =>
!props.order_id &&
(props.allowBookingDeletion || SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser())
);
const flatBuiltInMenuSections = computed(() => { const flatBuiltInMenuSections = computed(() => {
const sections = []; const sections = [];
@@ -1815,7 +1583,7 @@ const flatBuiltInMenuSections = computed(() => {
), ),
}) })
: null, : null,
canDeleteOrderBooking.value !props.order_id
? buildMenuAction("booking-delete", { ? buildMenuAction("booking-delete", {
icon: "fas fa-trash-alt", icon: "fas fa-trash-alt",
label: t("admin.pos.settings_wheel.delete_booking"), label: t("admin.pos.settings_wheel.delete_booking"),
@@ -1846,16 +1614,6 @@ const flatBuiltInMenuSections = computed(() => {
? redirectDepartmentOrderPage(props.order_id, true) ? redirectDepartmentOrderPage(props.order_id, true)
: SessionUser.functions.redirectTo.user("/orders/" + props.order_id, true), : SessionUser.functions.redirectTo.user("/orders/" + props.order_id, true),
}), }),
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
? canAcceptSelfServeWashDraft.value
? buildMenuAction("order-accept-self-serve-wash", {
icon: "fas fa-check-circle",
label: t("admin.pos.settings_wheel.accept_self_serve_wash"),
template: "success",
clickAction: acceptSelfServeWashDraft,
})
: null
: null,
SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser() SessionUser.canAccessAdmin() || SessionUser.canAccessSuperUser()
? buildMenuAction("order-attach-wash-certificate", { ? buildMenuAction("order-attach-wash-certificate", {
icon: "fas fa-paperclip", icon: "fas fa-paperclip",
@@ -1903,40 +1661,6 @@ const flatBuiltInMenuSections = computed(() => {
} }
} }
if (props.order_booking_id || hasWashCertificateAttachment.value) {
const emailNotificationsSection = buildMenuSection(
"email-notifications",
t("admin.pos.settings_wheel.email_notifications_section"),
[
props.order_booking_id
? buildMenuAction("email-notifications-resend-booking-confirmation", {
icon: "fas fa-envelope",
label: t("admin.pos.settings_wheel.resend_booking_confirmation"),
clickAction: resendBookingConfirmation,
})
: null,
props.order_booking_id && hasWashCertificateAttachment.value
? buildMenuAction("email-notifications-resend-booking-completion-confirmation", {
icon: "fas fa-envelope-open-text",
label: t("admin.pos.settings_wheel.resend_booking_completion_confirmation"),
clickAction: resendBookingCompletionConfirmation,
})
: null,
!props.order_booking_id && hasWashCertificateAttachment.value
? buildMenuAction("email-notifications-resend-wash-certificate", {
icon: "fas fa-file-pdf",
label: t("admin.pos.settings_wheel.resend_wash_certificate"),
clickAction: resendWashCertificate,
})
: null,
]
);
if (emailNotificationsSection) {
sections.push(emailNotificationsSection);
}
}
if (props.invoice_collection_id && SessionUser.canAccessSuperUser()) { if (props.invoice_collection_id && SessionUser.canAccessSuperUser()) {
const invoiceCollectionLinkSection = buildMenuSection( const invoiceCollectionLinkSection = buildMenuSection(
"invoice-collection-link", "invoice-collection-link",
@@ -2238,10 +1962,10 @@ const flatBuiltInMenuSections = computed(() => {
} }
} }
if (props.reg_1 || props.reg_2 || props.reg_3) { if (props.reg_1 || props.reg_2) {
const vehicleSection = buildMenuSection( const vehicleSection = buildMenuSection(
"vehicle", "vehicle",
[props.reg_1, props.reg_2, props.reg_3].filter(Boolean).length > 1 props.reg_1 && props.reg_2
? SessionUser.objects.vehicles.meta.labels.multiple ? SessionUser.objects.vehicles.meta.labels.multiple
: SessionUser.objects.vehicles.meta.labels.single, : SessionUser.objects.vehicles.meta.labels.single,
[ [
@@ -2259,13 +1983,6 @@ const flatBuiltInMenuSections = computed(() => {
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_2, true), clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_2, true),
}) })
: null, : null,
props.reg_3 && SessionUser.canAccessSuperUser()
? buildMenuAction("vehicle-reg-3", {
icon: "fas fa-car",
label: t("admin.pos.settings_wheel.view_vehicle_new_tab", { reg: props.reg_3 }),
clickAction: () => SessionUser.functions.redirectTo.superUser("/vehicles/" + props.reg_3, true),
})
: null,
] ]
); );
@@ -2535,13 +2252,11 @@ const syncDesktopFlyoutPosition = () => {
dropdownContentEl.style.top = ""; dropdownContentEl.style.top = "";
} }
const viewportInsets = getViewportInsets(); const padding = 12;
const viewportTop = viewportInsets.top;
const viewportBottom = viewportHeight - viewportInsets.bottom;
let top = triggerRect.bottom; let top = triggerRect.bottom;
if (top + contentHeight > viewportBottom) { if (top + contentHeight > viewportHeight - padding) {
top = Math.max(viewportTop, triggerRect.top - contentHeight); top = Math.max(padding, triggerRect.top - contentHeight);
} }
const nextFixedStyles = { const nextFixedStyles = {
@@ -2762,7 +2477,7 @@ const syncDesktopFlyoutPosition = () => {
{{ t("admin.pos.attachments_office_preview_unavailable") }} {{ t("admin.pos.attachments_office_preview_unavailable") }}
</span> </span>
<span v-else-if="activeAttachmentPreviewKind === 'text'" class="action-settings-wheel-attachment-panel__text"> <span v-else-if="activeAttachmentPreviewKind === 'text'" class="action-settings-wheel-attachment-panel__text">
{{ formatAttachmentText(activeAttachment) }} {{ activeAttachment.content?.other }}
</span> </span>
<span v-else class="action-settings-wheel-attachment-panel__text"> <span v-else class="action-settings-wheel-attachment-panel__text">
{{ t("admin.pos.attachments_no_preview") }} {{ t("admin.pos.attachments_no_preview") }}
@@ -3152,7 +2867,6 @@ const syncDesktopFlyoutPosition = () => {
text-align: center; text-align: center;
color: #4a5568; color: #4a5568;
overflow-wrap: anywhere; overflow-wrap: anywhere;
white-space: pre-line;
} }
.action-settings-wheel-attachment-panel__link { .action-settings-wheel-attachment-panel__link {
@@ -6,10 +6,6 @@ const props = defineProps({
icon: String, icon: String,
label: String, label: String,
disabled: Boolean, disabled: Boolean,
testId: {
type: String,
default: "",
},
template: String // The style of the button (default, danger, success, warning, info, light) template: String // The style of the button (default, danger, success, warning, info, light)
}); });
const emit = defineEmits(['selected']); const emit = defineEmits(['selected']);
@@ -135,7 +131,6 @@ const getLabelColor = () => {
@click.stop.prevent="click" @click.stop.prevent="click"
:class="{'is-disabled': isDisabled()}" :class="{'is-disabled': isDisabled()}"
:disabled="isDisabled()" :disabled="isDisabled()"
:data-testid="props.testId || undefined"
> >
<span class="icon"> <span class="icon">
<i :class="getIcon() + ' ' + getIconColor()"></i> <i :class="getIcon() + ' ' + getIconColor()"></i>
@@ -415,7 +415,7 @@ const handleShortcutSelection = (event) => {
:disabled="props.isDisabled || props.isReadonly" :disabled="props.isDisabled || props.isReadonly"
@change="handleShortcutSelection" @change="handleShortcutSelection"
> >
<option value="" disabled>Vælg periode</option> <option value="" disabled>Vaelg periode</option>
<option v-for="shortcut in shortcuts" :key="shortcut.label" :value="shortcut.label"> <option v-for="shortcut in shortcuts" :key="shortcut.label" :value="shortcut.label">
{{ shortcut.label }} {{ shortcut.label }}
</option> </option>
@@ -1,5 +1,5 @@
<script setup> <script setup>
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'; import {computed, onMounted, ref, watch} from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {BSwitch} from "buefy"; import {BSwitch} from "buefy";
@@ -18,39 +18,6 @@ const selfServeEnabled = ref(false);
const isLoadingSelfServeEnabled = ref(false); const isLoadingSelfServeEnabled = ref(false);
const isSavingSelfServeEnabled = ref(false); const isSavingSelfServeEnabled = ref(false);
const bookingsCount = ref(0); const bookingsCount = ref(0);
const selfServeLoadingMinimumMs = import.meta.env.MODE === "test" ? 0 : import.meta.env.VITE_IS_PLAYWRIGHT ? 5000 : 250;
const selfServeLoadingStartedAt = ref(0);
let selfServeLoadingTimer = null;
const clearSelfServeLoadingTimer = () => {
if (selfServeLoadingTimer !== null) {
clearTimeout(selfServeLoadingTimer);
selfServeLoadingTimer = null;
}
};
const setSelfServeLoading = (isLoading) => {
if (isLoading) {
clearSelfServeLoadingTimer();
selfServeLoadingStartedAt.value = Date.now();
isLoadingSelfServeEnabled.value = true;
return;
}
const elapsed = Date.now() - selfServeLoadingStartedAt.value;
const remaining = Math.max(0, selfServeLoadingMinimumMs - elapsed);
clearSelfServeLoadingTimer();
if (remaining === 0) {
isLoadingSelfServeEnabled.value = false;
return;
}
selfServeLoadingTimer = setTimeout(() => {
isLoadingSelfServeEnabled.value = false;
selfServeLoadingTimer = null;
}, remaining);
};
const getDepartmentId = () => { const getDepartmentId = () => {
return parseInt(router.currentRoute.value.params.departmentId); return parseInt(router.currentRoute.value.params.departmentId);
@@ -223,13 +190,13 @@ const getSelfServeStatus = async () => {
const departmentId = getDepartmentId(); const departmentId = getDepartmentId();
if (!departmentId) return; if (!departmentId) return;
setSelfServeLoading(true); isLoadingSelfServeEnabled.value = true;
try { try {
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId); selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
} catch (error) { } catch (error) {
console.error("Failed to fetch self-serve status", error); console.error("Failed to fetch self-serve status", error);
} finally { } finally {
setSelfServeLoading(false); isLoadingSelfServeEnabled.value = false;
} }
}; };
@@ -237,10 +204,6 @@ onMounted(() => {
getSelfServeStatus(); getSelfServeStatus();
}); });
onUnmounted(() => {
clearSelfServeLoadingTimer();
});
// Watch the departmentId // Watch the departmentId
watch(() => router.currentRoute.value.params.departmentId, () => { watch(() => router.currentRoute.value.params.departmentId, () => {
getTodaysBookings(); getTodaysBookings();
@@ -1,16 +1,7 @@
<script setup> <script setup>
import { watch } from 'vue'; import { watch } from 'vue';
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { import { getCurrentStep, setDepartment, getOrderId, setStep, setOrderId, searchAndSelectCustomer, loadOrderItems } from "@/components/shop/POSDepartmentProcess.vue";
clearActivePosOrderContext,
getCurrentStep,
setDepartment,
getOrderId,
setStep,
setOrderId,
searchAndSelectCustomer,
loadOrderItems,
} from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDepartmentStep1.vue"; import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDepartmentStep1.vue";
import PosDepartmentStep2 from "@/components/displays/department/pos/steps/PosDepartmentStep2.vue"; import PosDepartmentStep2 from "@/components/displays/department/pos/steps/PosDepartmentStep2.vue";
import PosDepartmentStep3 from "@/components/displays/department/pos/steps/PosDepartmentStep3.vue"; import PosDepartmentStep3 from "@/components/displays/department/pos/steps/PosDepartmentStep3.vue";
@@ -50,33 +41,20 @@ const debugVehicles = () => {
} }
); );
}; };
setDepartment();
const routeStateHandlers = { watch(() => router.currentRoute.value.params.departmentId, (nextDepartmentId) => {
if (nextDepartmentId) {
setDepartment(nextDepartmentId);
}
});
applyPosRouteSearch(window.location.search, {
setOrderId, setOrderId,
loadOrderItems, loadOrderItems,
setStep, setStep,
searchAndSelectCustomer, searchAndSelectCustomer,
clearActivePosOrderContext, });
resetMobilePos: () => pos.reset.pos(),
};
const getRouteSearch = (route) => {
const fullPath = route?.fullPath || "";
const queryIndex = fullPath.indexOf("?");
return queryIndex >= 0 ? fullPath.slice(queryIndex) : "";
};
const applyCurrentPosRoute = () => {
const currentRoute = router.currentRoute.value;
if (currentRoute.params.departmentId) {
setDepartment(currentRoute.params.departmentId);
} else {
setDepartment();
}
applyPosRouteSearch(getRouteSearch(currentRoute), routeStateHandlers);
};
watch(() => router.currentRoute.value.fullPath, applyCurrentPosRoute, { immediate: true });
/** Define the createOrder function */ /** Define the createOrder function */
</script> </script>
@@ -15,7 +15,6 @@ import {
selectPreferredStripeTerminalReaderId, selectPreferredStripeTerminalReaderId,
STRIPE_TERMINAL_STATUS, STRIPE_TERMINAL_STATUS,
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js"; } from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
const POLLING_INTERVAL_MS = 5000; const POLLING_INTERVAL_MS = 5000;
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required'; const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
@@ -88,6 +87,29 @@ const selectedTaxRate = ref(1);
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value); const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value)); const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
const normalizeStripeInvoice = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
return {
id: value.id ?? props.order_id,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: Boolean(value.paid),
status: value.status || 'unknown',
amount_due: Number(value.amount_due ?? 0),
amount_paid: Number(value.amount_paid ?? 0),
};
};
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null); const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true); const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
const isStripeEmailInvoiceTerminalState = computed(() => { const isStripeEmailInvoiceTerminalState = computed(() => {
@@ -180,7 +202,7 @@ const loadStripeInvoiceState = async () => {
try { try {
const response = await getOrder(props.order_id, true); const response = await getOrder(props.order_id, true);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id); const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders);
stripeInvoice.value = nextInvoice; stripeInvoice.value = nextInvoice;
if (nextInvoice) { if (nextInvoice) {
emailPanelState.value = 'tracking'; emailPanelState.value = 'tracking';
@@ -1,60 +0,0 @@
const STRIPE_INVOICE_PAID_STATUS = 'paid';
export const parseStripeInvoicePaidFlag = (value) => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value === 1;
}
if (typeof value === 'string') {
const normalizedValue = value.trim().toLowerCase();
if (['true', '1'].includes(normalizedValue)) {
return true;
}
if (['false', '0', ''].includes(normalizedValue)) {
return false;
}
}
return false;
};
const toFiniteNumber = (value, fallback = 0) => {
const parsedValue = Number(value ?? fallback);
return Number.isFinite(parsedValue) ? parsedValue : fallback;
};
export const normalizeStripeInvoice = (value, fallbackOrderId = null) => {
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) {
return null;
}
const invoiceId = value.invoice_id || value.id || null;
if (!invoiceId) {
return null;
}
const status = String(value.status || 'unknown').toLowerCase();
const amountDue = toFiniteNumber(value.amount_due);
const amountPaid = toFiniteNumber(value.amount_paid);
const hasCoveredAmountDue = amountDue <= 0 || amountPaid >= amountDue;
const isPaid = status === STRIPE_INVOICE_PAID_STATUS
&& parseStripeInvoicePaidFlag(value.paid)
&& hasCoveredAmountDue;
return {
id: value.id ?? fallbackOrderId,
invoice_id: invoiceId,
customer_id: value.customer_id ?? null,
url: value.url || value.hosted_invoice_url || null,
created_at: value.created_at || null,
paid: isPaid,
status,
amount_due: amountDue,
amount_paid: amountPaid,
};
};
@@ -10,16 +10,6 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"; const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"]; const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"]; const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
const safePreviewBlobTypesByKind = {
image: {
fallback: "image/png",
allowed: new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/svg+xml"]),
},
document: {
fallback: "application/pdf",
allowed: new Set(["application/pdf"]),
},
};
const props = defineProps({ const props = defineProps({
order: { order: {
@@ -334,21 +324,7 @@ const hasCachedPreviewSource = (attachmentId) => {
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId); return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId);
}; };
const createSafePreviewBlob = (fileBlob, previewKind) => { const createEmbeddablePreviewUrl = async (downloadLink) => {
const previewBlobTypes = safePreviewBlobTypesByKind[previewKind];
if (!previewBlobTypes) {
return null;
}
const normalizedBlobType = String(fileBlob.type || "").toLowerCase();
const safeBlobType = previewBlobTypes.allowed.has(normalizedBlobType)
? normalizedBlobType
: previewBlobTypes.fallback;
return new Blob([fileBlob], { type: safeBlobType });
};
const createEmbeddablePreviewUrl = async (downloadLink, previewKind) => {
if (!downloadLink) { if (!downloadLink) {
return null; return null;
} }
@@ -364,12 +340,7 @@ const createEmbeddablePreviewUrl = async (downloadLink, previewKind) => {
return null; return null;
} }
const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind); const objectUrl = URL.createObjectURL(fileBlob);
if (!safePreviewBlob) {
return null;
}
const objectUrl = URL.createObjectURL(safePreviewBlob);
generatedObjectUrls.add(objectUrl); generatedObjectUrls.add(objectUrl);
return objectUrl; return objectUrl;
} catch (error) { } catch (error) {
@@ -401,7 +372,7 @@ const ensurePreviewSource = async (attachment) => {
attachment.id, attachment.id,
false false
); );
const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind); const previewSource = await createEmbeddablePreviewUrl(downloadLink);
previewSourcesById.value = { previewSourcesById.value = {
...previewSourcesById.value, ...previewSourcesById.value,
[attachment.id]: previewSource, [attachment.id]: previewSource,
@@ -546,7 +517,6 @@ const toggleDropdown = async () => {
:src="activePreviewSource" :src="activePreviewSource"
class="order-attachments-preview-panel__document" class="order-attachments-preview-panel__document"
title="Attachment preview" title="Attachment preview"
sandbox
></iframe> ></iframe>
<a <a
v-else-if="activePreviewKind === 'link'" v-else-if="activePreviewKind === 'link'"
@@ -275,10 +275,6 @@ const getInvoiceCollectionResponseOrders = (response) => {
return response.data.orders; return response.data.orders;
} }
if (Array.isArray(response?.data?.data?.orders)) {
return response.data.data.orders;
}
if (Array.isArray(response?.includes?.orders)) { if (Array.isArray(response?.includes?.orders)) {
return response.includes.orders; return response.includes.orders;
} }
@@ -653,41 +649,6 @@ const getInvoiceCollectionDetails = (invoiceCollectionId) => {
} }
return null; return null;
}; };
const getInvoiceCollectionDetailOrders = (invoiceCollectionId) => {
const details = getInvoiceCollectionDetails(invoiceCollectionId);
return details ? getInvoiceCollectionResponseOrders(details) : [];
};
const getHiddenOrdersByInvoiceCollection = (invoiceCollectionId) => {
const visibleOrderIds = new Set(getOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id)));
return getInvoiceCollectionDetailOrders(invoiceCollectionId).filter((order) => !visibleOrderIds.has(Number(order?.id)));
};
const getHiddenOrderIdsByInvoiceCollection = (invoiceCollectionId) => (
getHiddenOrdersByInvoiceCollection(invoiceCollectionId).map((order) => Number(order?.id))
);
const getHiddenInvoiceCollectionFlags = (invoiceCollectionId) => {
const hiddenOrderIds = new Set(getHiddenOrderIdsByInvoiceCollection(invoiceCollectionId));
if (hiddenOrderIds.size === 0) {
return [];
}
return sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
if (!isActiveInvoicePeriodFlag(flag)) {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order", "order_field", "order_item", "order_item_field"].includes(targetType)) {
return false;
}
const flagOrderId = Number(
flag?.order_id
|| flag?.context?.order_id
|| (["order", "order_field"].includes(targetType) ? flag?.target_id : 0)
);
return flagOrderId > 0 && hiddenOrderIds.has(flagOrderId);
}));
};
watch( watch(
[() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection], [() => props.orders, () => props.excludedOrderIds, () => props.groupInvoiceCollection],
() => { () => {
@@ -1038,13 +999,6 @@ const formatCashierName = (order) => {
}} }}
er ikke vist, men vil muligvis blive faktureret alligevel.</span er ikke vist, men vil muligvis blive faktureret alligevel.</span
> >
<InvoicingPeriodFlagList
v-if="getHiddenInvoiceCollectionFlags(order.invoice_collection_id).length > 0"
class="mt-3"
compact
:flags="getHiddenInvoiceCollectionFlags(order.invoice_collection_id)"
@statusChanged="emitFlagStatusChanged"
/>
</div> </div>
</div> </div>
</td> </td>
@@ -1306,12 +1260,7 @@ const formatCashierName = (order) => {
v-bind:user_id="order.user_id" v-bind:user_id="order.user_id"
v-bind:order_id="order.id" v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id" v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:customer_number="order.customer_id"
v-bind:department_id="order.department_id"
v-bind:reg_1="order.reg_1" v-bind:reg_1="order.reg_1"
v-bind:reg_2="order.reg_2"
v-bind:reg_3="order.reg_3"
v-bind:order_booking_id="order.booking_id"
:refreshFunction="loadList" :refreshFunction="loadList"
@deleted="loadList()" @deleted="loadList()"
@flag-created="emitFlagCreated" @flag-created="emitFlagCreated"
@@ -1552,12 +1501,7 @@ const formatCashierName = (order) => {
v-bind:user_id="order.user_id" v-bind:user_id="order.user_id"
v-bind:order_id="order.id" v-bind:order_id="order.id"
v-bind:invoice_collection_id="order.invoice_collection_id" v-bind:invoice_collection_id="order.invoice_collection_id"
v-bind:customer_number="order.customer_id"
v-bind:department_id="order.department_id"
v-bind:reg_1="order.reg_1" v-bind:reg_1="order.reg_1"
v-bind:reg_2="order.reg_2"
v-bind:reg_3="order.reg_3"
v-bind:order_booking_id="order.booking_id"
:refreshFunction="loadList" :refreshFunction="loadList"
@deleted="loadList()" @deleted="loadList()"
@flag-created="emitFlagCreated" @flag-created="emitFlagCreated"
@@ -2048,7 +1992,6 @@ const formatCashierName = (order) => {
v-bind:user_id="selectedOrderForActionsMenu.user_id" v-bind:user_id="selectedOrderForActionsMenu.user_id"
v-bind:order_id="selectedOrderForActionsMenu.id" v-bind:order_id="selectedOrderForActionsMenu.id"
v-bind:invoice_collection_id="selectedOrderForActionsMenu.invoice_collection_id" v-bind:invoice_collection_id="selectedOrderForActionsMenu.invoice_collection_id"
v-bind:customer_number="selectedOrderForActionsMenu.customer_id"
v-bind:reg_1="selectedOrderForActionsMenu.reg_1" v-bind:reg_1="selectedOrderForActionsMenu.reg_1"
v-bind:reg_2="selectedOrderForActionsMenu.reg_2" v-bind:reg_2="selectedOrderForActionsMenu.reg_2"
v-bind:reg_3="selectedOrderForActionsMenu.reg_3" v-bind:reg_3="selectedOrderForActionsMenu.reg_3"
@@ -21,7 +21,6 @@ import {
step, step,
setDesktopStep1PreflightHandler, setDesktopStep1PreflightHandler,
clearDesktopStep1PreflightHandler, clearDesktopStep1PreflightHandler,
pushPosRouteState,
} from "@/components/shop/POSDepartmentProcess.vue"; } from "@/components/shop/POSDepartmentProcess.vue";
import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue"; import ElementTabsBox from "@/components/displays/boxes/ElementTabsBox.vue";
import { POS_STEP_1_VERSION } from "@/config.js"; import { POS_STEP_1_VERSION } from "@/config.js";
@@ -93,7 +92,6 @@ const duplicateDetailsExpanded = ref(false);
const pendingNextResolution = ref(false); const pendingNextResolution = ref(false);
const isDesktopLastWashCopying = ref(false); const isDesktopLastWashCopying = ref(false);
let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true }); let desktopStep1CoordinationPromise = Promise.resolve({ canProceed: true });
let focusOnReg1TimeoutId = null;
const isDesktopStep1Active = computed(() => getCurrentStep() === 1); const isDesktopStep1Active = computed(() => getCurrentStep() === 1);
const setTab = (tab) => { const setTab = (tab) => {
@@ -119,16 +117,7 @@ watch(
); );
const focusOnReg1 = () => { const focusOnReg1 = () => {
if (focusOnReg1TimeoutId !== null) { setTimeout(() => {
clearTimeout(focusOnReg1TimeoutId);
}
focusOnReg1TimeoutId = setTimeout(() => {
focusOnReg1TimeoutId = null;
if (typeof document === "undefined") {
return;
}
const reg1Input = document.getElementById("reg_1"); const reg1Input = document.getElementById("reg_1");
if (reg1Input) { if (reg1Input) {
reg1Input.focus(); reg1Input.focus();
@@ -278,8 +267,12 @@ const bookingSelectionObjects = computed(() => {
const contentSegments = [ const contentSegments = [
`${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`, `${t("admin.pos.order_booking_selector.customer_label")}: ${booking?.customer_name || t("admin.pos.not_found")}`,
`${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`, `${t("admin.pos.order_booking_selector.plates_label")}: ${plateText || t("admin.pos.not_found")}`,
`${t("common.reference")}: ${getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")}`, `${t("common.reference")}: ${
`${t("common.services")}: ${getOrderBookingServiceText(booking) || t("admin.pos.not_found")}`, getOrderBookingReferenceValue(booking) || t("admin.pos.not_found")
}`,
`${t("common.services")}: ${
getOrderBookingServiceText(booking) || t("admin.pos.not_found")
}`,
]; ];
return { return {
@@ -304,7 +297,7 @@ const bookingSelectionObjects = computed(() => {
const duplicateDetailsObjects = computed(() => { const duplicateDetailsObjects = computed(() => {
return duplicateOrders.value.map((order) => ({ return duplicateOrders.value.map((order) => ({
id: Number(order.id), id: Number(order.id),
label: `${t("admin.pos.order")} #${order.id} - ${formatDuplicateOrderDate(order.created_at)}`, label: `${t("common.order")} #${order.id} - ${formatDuplicateOrderDate(order.created_at)}`,
content: getDuplicateOrderContent(order), content: getDuplicateOrderContent(order),
buttons: [ buttons: [
{ {
@@ -646,7 +639,7 @@ const pushDesktopStepTwoRoute = () => {
return; return;
} }
pushPosRouteState(`id=${order_id.value}&customer_id=${customer_id.value}&step=2`); window.history.pushState({}, "", `?id=${order_id.value}&customer_id=${customer_id.value}&step=2`);
}; };
const handleDesktopLastWashCopy = async (payload = {}) => { const handleDesktopLastWashCopy = async (payload = {}) => {
@@ -688,10 +681,6 @@ onMounted(() => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (focusOnReg1TimeoutId !== null) {
clearTimeout(focusOnReg1TimeoutId);
focusOnReg1TimeoutId = null;
}
clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight); clearDesktopStep1PreflightHandler(handleDesktopStep1Preflight);
}); });
@@ -761,9 +750,13 @@ watch(
</div> </div>
<div class="pos-shell-actions"> <div class="pos-shell-actions">
<ButtonsBox class="pos-actions pos-actions--stacked"> <ButtonsBox class="pos-actions pos-actions--stacked">
<Cancel tabindex="5" class="is-fullwidth" /> <Cancel tabindex="2" class="is-fullwidth" />
</ButtonsBox> </ButtonsBox>
<div v-if="shouldShowActionRailControls" class="pos-shell-actions__rail" data-testid="pos-step-1-action-rail"> <div
v-if="shouldShowActionRailControls"
class="pos-shell-actions__rail"
data-testid="pos-step-1-action-rail"
>
<PosDesktopDuplicateWarning <PosDesktopDuplicateWarning
v-if="shouldShowDuplicateWarningInActionRail" v-if="shouldShowDuplicateWarningInActionRail"
:title="t('admin.pos.warning')" :title="t('admin.pos.warning')"
@@ -24,78 +24,69 @@ import {
} from "./objects/PosDepartmentStepMobileFlow.vue"; } from "./objects/PosDepartmentStepMobileFlow.vue";
import { PosSearchResult } from "./objects/PosSearchResult.vue"; import { PosSearchResult } from "./objects/PosSearchResult.vue";
import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue"; import RegistrationNumberSearchResult from "@/components/models/pos/step1/RegistrationNumberSearchResult.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
import KnownCustomer from "@/components/viewport/elements/icons/KnownCustomer.vue";
import CardPaymentCustomer from "@/components/viewport/elements/icons/CardPaymentCustomer.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue"; import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue"; import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
import PosDepartmentStepMobile1Debug from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue"; import PosDepartmentStepMobile1Debug from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Debug.vue";
import PosDepartmentStepMobileAttachments from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue"; import PosDepartmentStepMobileAttachments from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileAttachments.vue";
import PosDepartmentStep1MobileTransactionHistory from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue"; import PosDepartmentStep1MobileTransactionHistory from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep1MobileTransactionHistory.vue";
import { attachments } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue"; import { attachments } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import {
LPR_FRAME_CLIENT_BYTES_FIELD,
LPR_FRAME_CLIENT_CAPTURE_MS_FIELD,
LPR_FRAME_CLIENT_DRAW_MS_FIELD,
LPR_FRAME_CLIENT_ENCODE_MS_FIELD,
LPR_FRAME_CLIENT_HEIGHT_FIELD,
LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD,
LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
LPR_FRAME_CLIENT_WIDTH_FIELD,
getVisualFingerprintDistance,
type LPRFrameEncodeCandidate,
type LPRFramePayload,
type LPRFrameViewportRect,
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
// Debug mode flag // Debug mode flag
const debug_mode = ref(false); const debug_mode = ref(false);
// Debug array to store request results // Debug array to store request results
const debug_request_results = ref<unknown[]>([]); const debug_request_results = ref([]);
type ParsedFrameFingerprint = { const lastParsedImage = ref(null);
content: string | null; const setLastCapturedImage = (image: string) => {
contentFingerprintPromise: Promise<string> | null; camera.latestImage.value = image;
getContentFingerprint: (() => Promise<string>) | null;
getVisualFingerprint: (() => string | null) | null;
outcome: "pending" | "miss" | "success";
quick: string;
visual: string | null;
}; };
const lastParsedImage = ref<ParsedFrameFingerprint | null>(null);
const scannerFocusRef = ref<HTMLElement | null>(null);
type LPRResponse = { type LPRResponse = {
success: boolean; success: boolean;
license_plate_number: string; license_plate_number: string;
}; };
type LPRScanContext = {
activeVehicleIndex: number;
attachmentView: boolean;
manualInput: boolean;
registrationNumbers: string[];
transactionHistoryView: boolean;
};
const latestLPRResponse = ref<LPRResponse | null>(null); const latestLPRResponse = ref<LPRResponse | null>(null);
const isLPRFrameProcessing = ref(false); const LPR_IMAGE_MAX_WIDTH = 1280;
const isLPRRequestInFlight = ref(false); const LPR_IMAGE_MAX_HEIGHT = 720;
const isNoPlateBackoffActive = ref(false); const LPR_IMAGE_JPEG_QUALITY = 0.72;
const isDuplicateFrameBackoffActive = ref(false);
const isSuccessCooldownActive = ref(false);
let lprRequestAbortController: AbortController | null = null;
let noPlateBackoffTimerId: ReturnType<typeof window.setTimeout> | null = null;
let duplicateFrameBackoffTimerId: ReturnType<typeof window.setTimeout> | null = null;
let successCooldownTimerId: ReturnType<typeof window.setTimeout> | null = null;
const NO_PLATE_BACKOFF_DELAYS_MS = [1500, 2500, 4000]; const compressImageForLPR = (image: string): Promise<string> => {
const LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD = 4; return new Promise((resolve) => {
const LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS = 700; const img = new Image();
const LPR_ENDPOINT = "/modules/scanner/lpr"; img.onload = () => {
let consecutiveNoPlateResponses = 0; const sourceWidth = img.naturalWidth || img.width;
const sourceHeight = img.naturalHeight || img.height;
if (!sourceWidth || !sourceHeight) {
resolve(image);
return;
}
const nowMs = (): number => const scale = Math.min(1, LPR_IMAGE_MAX_WIDTH / sourceWidth, LPR_IMAGE_MAX_HEIGHT / sourceHeight);
typeof performance !== "undefined" && typeof performance.now === "function" const targetWidth = Math.max(1, Math.round(sourceWidth * scale));
? performance.now() const targetHeight = Math.max(1, Math.round(sourceHeight * scale));
: Date.now();
const canvas = document.createElement("canvas");
canvas.width = targetWidth;
canvas.height = targetHeight;
const context = canvas.getContext("2d");
if (!context) {
resolve(image);
return;
}
context.drawImage(img, 0, 0, targetWidth, targetHeight);
resolve(canvas.toDataURL("image/jpeg", LPR_IMAGE_JPEG_QUALITY));
};
img.onerror = () => resolve(image);
img.src = image;
});
};
const activeVehicleIndexNext = () => { const activeVehicleIndexNext = () => {
// Increment the active vehicle index, wrapping around if necessary // Increment the active vehicle index, wrapping around if necessary
@@ -124,518 +115,55 @@ const handleLPRResult = () => {
} }
}; };
type LPRFrameInput = string | LPRFramePayload; const parseImage = async (image: string) => {
const isLPRFramePayload = (image: LPRFrameInput): image is LPRFramePayload =>
typeof image === "object" && image !== null && image.blob instanceof Blob;
const getLPRFrameFingerprint = (image: LPRFrameInput): string =>
isLPRFramePayload(image) ? image.fingerprint : image;
const getLPRFrameContentFingerprint = (image: LPRFrameInput): (() => Promise<string>) | null =>
isLPRFramePayload(image) ? image.getContentFingerprint ?? null : null;
const getLPRFrameVisualFingerprint = (image: LPRFrameInput): string | null =>
isLPRFramePayload(image) ? image.visualFingerprint ?? null : null;
const getLPRFrameVisualFingerprintGetter = (image: LPRFrameInput): (() => string | null) | null =>
isLPRFramePayload(image) ? image.getVisualFingerprint ?? null : null;
const rememberParsedImageFingerprint = (image: LPRFrameInput) => {
const quick = getLPRFrameFingerprint(image);
const getContentFingerprint = getLPRFrameContentFingerprint(image);
const entry: ParsedFrameFingerprint = {
content: getContentFingerprint === null ? quick : null,
contentFingerprintPromise: null,
getContentFingerprint,
getVisualFingerprint: getLPRFrameVisualFingerprintGetter(image),
outcome: "pending",
quick,
visual: getLPRFrameVisualFingerprint(image),
};
lastParsedImage.value = entry;
};
const markLastParsedImageOutcome = (outcome: ParsedFrameFingerprint["outcome"]) => {
if (lastParsedImage.value !== null) {
if (outcome === "miss" && lastParsedImage.value.visual === null) {
lastParsedImage.value.visual = lastParsedImage.value.getVisualFingerprint?.() ?? null;
}
lastParsedImage.value.outcome = outcome;
}
};
const resetParsedImageFingerprint = () => {
lastParsedImage.value = null;
};
const resolveParsedFrameContentFingerprint = (entry: ParsedFrameFingerprint): Promise<string> | null => {
if (entry.content !== null) {
return Promise.resolve(entry.content);
}
if (entry.getContentFingerprint === null) {
return null;
}
entry.contentFingerprintPromise ??= entry.getContentFingerprint().then((content) => {
if (lastParsedImage.value === entry) {
entry.content = content;
}
return content;
}).catch((error) => {
if (lastParsedImage.value === entry) {
entry.contentFingerprintPromise = null;
}
throw error;
});
return entry.contentFingerprintPromise;
};
const isVisuallySimilarToLastMiss = (visualFingerprint: string | null | undefined): boolean => {
const lastParsed = lastParsedImage.value;
return lastParsed !== null
&& lastParsed.outcome === "miss"
&& getVisualFingerprintDistance(lastParsed.visual, visualFingerprint) <= LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD;
};
const hasLastMissVisualFingerprint = (): boolean =>
lastParsedImage.value !== null
&& lastParsedImage.value.outcome === "miss"
&& lastParsedImage.value.visual !== null;
const shouldBuildLPRVisualFingerprint = (): boolean =>
hasLastMissVisualFingerprint();
const shouldSkipDuplicateFrame = async (image: LPRFrameInput): Promise<boolean> => {
const quick = getLPRFrameFingerprint(image);
const lastParsed = lastParsedImage.value;
if (lastParsed === null) {
return false;
}
if (isVisuallySimilarToLastMiss(getLPRFrameVisualFingerprint(image))) {
return true;
}
if (lastParsed.quick !== quick) {
return false;
}
const currentContentFingerprint = getLPRFrameContentFingerprint(image);
const lastContentFingerprint = resolveParsedFrameContentFingerprint(lastParsed);
if (lastContentFingerprint === null || currentContentFingerprint === null) {
return true;
}
try {
const [lastContent, currentContent] = await Promise.all([
lastContentFingerprint,
currentContentFingerprint(),
]);
return lastContent === currentContent;
} catch {
return false;
}
};
const appendFiniteTimingParam = (
queryParts: string[],
field: string,
value: number | null | undefined
) => {
if (value === null || value === undefined) {
return;
}
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue < 0) {
return;
}
const roundedValue = numericValue.toFixed(3);
if (Number(roundedValue) > 0) {
queryParts.push(`${field}=${roundedValue}`);
}
};
const appendPositiveIntegerParam = (
queryParts: string[],
field: string,
value: number | null | undefined
) => {
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return;
}
queryParts.push(`${field}=${Math.round(numericValue)}`);
};
type LPRRequestPayload = Blob | { base64_image: string };
type LPRRequestBuildResult = {
headers?: Record<string, string>;
payload: LPRRequestPayload;
url: string;
};
const buildLPRRequestPayload = (
image: LPRFrameInput,
clientPreflightDurationMs: number | null = null
): LPRRequestBuildResult => {
if (!isLPRFramePayload(image)) {
return {
payload: { base64_image: image },
url: LPR_ENDPOINT,
};
}
const headers: Record<string, string> = {
"Content-Type": image.mimeType || image.blob.type || "image/jpeg",
};
const queryParts: string[] = [];
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_CAPTURE_MS_FIELD, image.captureDurationMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD, clientPreflightDurationMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_DRAW_MS_FIELD, image.captureTimings?.drawMs);
appendFiniteTimingParam(queryParts, LPR_FRAME_CLIENT_ENCODE_MS_FIELD, image.captureTimings?.encodeMs);
appendFiniteTimingParam(
queryParts,
LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD,
image.captureTimings?.visualFingerprintMs
);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_WIDTH_FIELD, image.width);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_HEIGHT_FIELD, image.height);
appendPositiveIntegerParam(queryParts, LPR_FRAME_CLIENT_BYTES_FIELD, image.blob.size);
const queryString = queryParts.join("&");
return {
headers,
payload: image.blob,
url: queryString ? `${LPR_ENDPOINT}?${queryString}` : LPR_ENDPOINT,
};
};
type LPRCurrentStateSkipOptions = {
ignoreNoPlateBackoff?: boolean;
};
const shouldEncodeLPRFrame = (candidate: LPRFrameEncodeCandidate): boolean => {
if (views.attachmentView.value) {
return true;
}
if (
shouldSkipLPRForCurrentState({ ignoreNoPlateBackoff: true })
|| isLPRFrameProcessing.value
|| isLPRRequestInFlight.value
) {
return false;
}
if (isNoPlateBackoffActive.value) {
if (!hasLastMissVisualFingerprint() || isVisuallySimilarToLastMiss(candidate.visualFingerprint)) {
return false;
}
clearNoPlateBackoff();
return true;
}
if (!isVisuallySimilarToLastMiss(candidate.visualFingerprint)) {
return true;
}
scheduleDuplicateFrameBackoff();
return false;
};
const rememberLatestCameraImage = (image: LPRFrameInput) => {
if (isLPRFramePayload(image)) {
camera.setLatestImageBlob(image.blob);
return;
}
camera.setLatestImage(image);
};
const hasRegistrationNumber = (registrationNumber: string | null | undefined): boolean =>
String(registrationNumber ?? "").trim().length > 0;
const isActiveRegistrationSlotFilled = (): boolean =>
hasRegistrationNumber(vehicles.getActiveVehicle()?.reg);
const areAllRegistrationSlotsFilled = (): boolean =>
[1, 2, 3].every((vehicleIndex) => hasRegistrationNumber(vehicles.get(vehicleIndex)?.reg));
const shouldSkipLPRForCurrentState = (options: LPRCurrentStateSkipOptions = {}): boolean =>
views.attachmentView.value
|| isActiveRegistrationSlotFilled()
|| areAllRegistrationSlotsFilled()
|| (!options.ignoreNoPlateBackoff && isNoPlateBackoffActive.value)
|| isDuplicateFrameBackoffActive.value
|| isSuccessCooldownActive.value;
const getScannerFocusViewportRect = (): LPRFrameViewportRect | null => {
if (views.attachmentView.value || scannerFocusRef.value === null) {
return null;
}
const rect = scannerFocusRef.value.getBoundingClientRect();
if (
!Number.isFinite(rect.width) ||
!Number.isFinite(rect.height) ||
rect.width <= 0 ||
rect.height <= 0
) {
return null;
}
return {
height: rect.height,
width: rect.width,
x: rect.left,
y: rect.top,
};
};
const isCameraFrameCaptureEnabled = computed(() => {
if (views.attachmentView.value) {
return true;
}
return !isLPRFrameProcessing.value
&& !isLPRRequestInFlight.value
&& (!isNoPlateBackoffActive.value || hasLastMissVisualFingerprint())
&& !isDuplicateFrameBackoffActive.value
&& !isSuccessCooldownActive.value
&& !isActiveRegistrationSlotFilled()
&& !areAllRegistrationSlotsFilled();
});
const shouldPauseScannerPreview = computed(() =>
!views.attachmentView.value
&& (
isLPRFrameProcessing.value
|| isLPRRequestInFlight.value
|| isDuplicateFrameBackoffActive.value
|| isSuccessCooldownActive.value
|| isActiveRegistrationSlotFilled()
|| areAllRegistrationSlotsFilled()
|| (isNoPlateBackoffActive.value && !hasLastMissVisualFingerprint())
)
);
const lprCameraCaptureIntervalMs = computed(() =>
!views.attachmentView.value && isNoPlateBackoffActive.value && hasLastMissVisualFingerprint()
? LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS
: null
);
const abortLPRRequest = () => {
lprRequestAbortController?.abort();
lprRequestAbortController = null;
};
const isDocumentHidden = (): boolean =>
typeof document !== "undefined" && document.visibilityState === "hidden";
const handleDocumentVisibilityChange = () => {
if (!isDocumentHidden()) {
return;
}
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
};
const clearNoPlateBackoff = () => {
if (noPlateBackoffTimerId !== null) {
window.clearTimeout(noPlateBackoffTimerId);
noPlateBackoffTimerId = null;
}
isNoPlateBackoffActive.value = false;
};
const clearDuplicateFrameBackoff = () => {
if (duplicateFrameBackoffTimerId !== null) {
window.clearTimeout(duplicateFrameBackoffTimerId);
duplicateFrameBackoffTimerId = null;
}
isDuplicateFrameBackoffActive.value = false;
};
const clearSuccessCooldown = () => {
if (successCooldownTimerId !== null) {
window.clearTimeout(successCooldownTimerId);
successCooldownTimerId = null;
}
isSuccessCooldownActive.value = false;
};
const resetNoPlateBackoff = () => {
consecutiveNoPlateResponses = 0;
clearNoPlateBackoff();
clearDuplicateFrameBackoff();
clearSuccessCooldown();
};
const scheduleNoPlateBackoff = () => {
consecutiveNoPlateResponses += 1;
const delay = NO_PLATE_BACKOFF_DELAYS_MS[
Math.min(consecutiveNoPlateResponses - 1, NO_PLATE_BACKOFF_DELAYS_MS.length - 1)
];
clearNoPlateBackoff();
isNoPlateBackoffActive.value = true;
noPlateBackoffTimerId = window.setTimeout(() => {
noPlateBackoffTimerId = null;
isNoPlateBackoffActive.value = false;
}, delay);
};
const scheduleDuplicateFrameBackoff = () => {
clearDuplicateFrameBackoff();
isDuplicateFrameBackoffActive.value = true;
duplicateFrameBackoffTimerId = window.setTimeout(() => {
duplicateFrameBackoffTimerId = null;
isDuplicateFrameBackoffActive.value = false;
}, Math.min(camera.getImageCaptureDelay(false), LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS));
};
const scheduleSuccessCooldown = () => {
clearSuccessCooldown();
isSuccessCooldownActive.value = true;
successCooldownTimerId = window.setTimeout(() => {
successCooldownTimerId = null;
isSuccessCooldownActive.value = false;
}, camera.getImageCaptureDelayAfterSuccess());
};
const isAbortError = (error: unknown): boolean => {
if (error instanceof DOMException && error.name === "AbortError") {
return true;
}
return typeof error === "object" && error !== null && (error as { name?: string; code?: string }).code === "ERR_CANCELED";
};
const isSameLPRScanContext = (first: LPRScanContext, second: LPRScanContext): boolean =>
first.activeVehicleIndex === second.activeVehicleIndex
&& first.attachmentView === second.attachmentView
&& first.manualInput === second.manualInput
&& first.transactionHistoryView === second.transactionHistoryView
&& first.registrationNumbers.length === second.registrationNumbers.length
&& first.registrationNumbers.every((registrationNumber, index) => registrationNumber === second.registrationNumbers[index]);
const parseImage = async (image: LPRFrameInput) => {
if (views.attachmentView.value) {
rememberLatestCameraImage(image);
return;
}
if (shouldSkipLPRForCurrentState() || isLPRFrameProcessing.value || isLPRRequestInFlight.value) {
return;
}
// Check if the time since the last successful parse is enough // Check if the time since the last successful parse is enough
if (!camera.hasDelayAfterSuccessPassed()) { if (!camera.hasDelayAfterSuccessPassed()) {
return; return;
} }
if (lastParsedImage.value === image) {
isLPRFrameProcessing.value = true; // If the image is the same as the last parsed one, skip parsing
try { return;
const clientPreflightStartedAt = nowMs(); }
const isDuplicateFrame = await shouldSkipDuplicateFrame(image); lastParsedImage.value = image; // Update the last parsed image
const clientPreflightDurationMs = Math.max(0, nowMs() - clientPreflightStartedAt); camera.setLatestImage(image); // Update the latest image in the camera object
// Function to parse the image data
if (shouldSkipLPRForCurrentState()) { const compressedImage = await compressImageForLPR(image);
return; SessionUser.request("/modules/scanner/lpr", "POST", {
} base64_image: compressedImage,
})
if (isDuplicateFrame) { .then((response) => {
// If the image is the same as the last parsed one, skip parsing
scheduleDuplicateFrameBackoff();
return;
}
rememberParsedImageFingerprint(image); // Update the last parsed image
isLPRRequestInFlight.value = true;
const abortController = new AbortController();
lprRequestAbortController = abortController;
const requestScanContext = getLPRScanContext();
const lprRequest = buildLPRRequestPayload(image, clientPreflightDurationMs);
try {
const response = await SessionUser.request(
lprRequest.url,
"POST",
lprRequest.payload,
null,
null,
{
...(lprRequest.headers ? { headers: lprRequest.headers } : {}),
signal: abortController.signal,
transport: "fetch",
}
);
if (debug_mode.value) { if (debug_mode.value) {
debug_request_results.value.push(response); debug_request_results.value.push(response);
} }
if (!isSameLPRScanContext(requestScanContext, getLPRScanContext())) {
return;
}
// If the response is not successful, stop here. // If the response is not successful, stop here.
if (!response.data.success) { if (!response.data.success) {
markLastParsedImageOutcome("miss");
scheduleNoPlateBackoff();
return; return;
} }
resetNoPlateBackoff();
markLastParsedImageOutcome("success");
rememberLatestCameraImage(image);
latestLPRResponse.value = response.data.data as LPRResponse; latestLPRResponse.value = response.data.data as LPRResponse;
// Set the last successful capture time // Set the last successful capture time
camera.setLastSuccess(); camera.setLastSuccess();
scheduleSuccessCooldown();
// Handle parsed result. // Handle parsed result.
handleLPRResult(); handleLPRResult();
} catch (error) { })
if (isAbortError(error)) { .catch((error) => {
return;
}
if (debug_mode.value) { if (debug_mode.value) {
debug_request_results.value.push(error); debug_request_results.value.push(error);
} }
markLastParsedImageOutcome("miss");
scheduleNoPlateBackoff();
//console.error("Error parsing image:", error); //console.error("Error parsing image:", error);
} finally { });
if (lprRequestAbortController === abortController) {
lprRequestAbortController = null;
}
isLPRRequestInFlight.value = false;
}
} finally {
isLPRFrameProcessing.value = false;
}
}; };
watch(manualInput, (newValue) => { watch(manualInput, (newValue) => {
// Update the header transparency when manualInput changes // Update the header transparency when manualInput changes
setTransparency(!newValue); setTransparency(!newValue);
}); });
type statusIcon =
| typeof VerifiedCustomer
| typeof KnownCustomer
| typeof UnknownCustomer
| typeof CardPaymentCustomer
| typeof BookedCustomer;
const registrationNumbers = computed(() => { const registrationNumbers = computed(() => {
// Return the registration numbers of all vehicles // Return the registration numbers of all vehicles
return [ return [
@@ -645,14 +173,6 @@ const registrationNumbers = computed(() => {
]; ];
}); });
const getLPRScanContext = (): LPRScanContext => ({
activeVehicleIndex: vehicles.activeVehicleIndex.value,
attachmentView: views.attachmentView.value,
manualInput: manualInput.value,
registrationNumbers: [...registrationNumbers.value],
transactionHistoryView: transactionHistoryView.value,
});
function getSearchResultIndex(object: PosSearchResult) { function getSearchResultIndex(object: PosSearchResult) {
let targetIndex = vehicles.activeVehicleIndex.value; // Default to the current active vehicle index let targetIndex = vehicles.activeVehicleIndex.value; // Default to the current active vehicle index
// Check if the registration number already exists in the vehicles (And update the vehicle if it does) // Check if the registration number already exists in the vehicles (And update the vehicle if it does)
@@ -708,49 +228,27 @@ const getQuery = computed(() => {
return activeVehicle ? activeVehicle.reg : ""; return activeVehicle ? activeVehicle.reg : "";
}); });
const activeSelectionHasRegistrationNumber = computed(() => {
return vehicles.getActiveVehicle()?.reg && vehicles.getActiveVehicle()?.reg.length > 0;
});
const shouldCustomerBeModified = computed(() => { const shouldCustomerBeModified = computed(() => {
// Check if the customer should be modified based on the active vehicle index // Check if the customer should be modified based on the active vehicle index
return vehicles.activeVehicleIndex.value === 1; return vehicles.activeVehicleIndex.value === 1;
}); });
onMounted(() => { onMounted(() => {
document.addEventListener("visibilitychange", handleDocumentVisibilityChange);
// Set the header to be transparent // Set the header to be transparent
setTransparency(!views.isAnyActive.value); setTransparency(!views.isAnyActive.value);
setBackgroundColor(backgroundColors.default); // Set the default background color setBackgroundColor(backgroundColors.default); // Set the default background color
setOverflow(false); // Prevent scrolling setOverflow(false); // Prevent scrolling
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("visibilitychange", handleDocumentVisibilityChange);
abortLPRRequest();
clearNoPlateBackoff();
clearDuplicateFrameBackoff();
clearSuccessCooldown();
// Reset the header settings when the component is unmounted // Reset the header settings when the component is unmounted
setTransparency(false); // Reset transparency setTransparency(false); // Reset transparency
setBackgroundColor(backgroundColors.default); // Reset to default background color setBackgroundColor(backgroundColors.default); // Reset to default background color
setOverflow(true); // Allow scrolling again setOverflow(true); // Allow scrolling again
}); });
watch(
() => [manualInput.value, transactionHistoryView.value, views.attachmentView.value],
([isManualInputActive, isTransactionHistoryActive, isAttachmentViewActive]) => {
if (isManualInputActive || isTransactionHistoryActive || isAttachmentViewActive) {
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
}
}
);
watch(
() => [vehicles.activeVehicleIndex.value, ...registrationNumbers.value],
() => {
abortLPRRequest();
resetParsedImageFingerprint();
resetNoPlateBackoff();
}
);
</script> </script>
<template> <template>
@@ -765,16 +263,7 @@ watch(
<!-- Default: Scanner view --> <!-- Default: Scanner view -->
<template v-else> <template v-else>
<div class="background-fixed"> <div class="background-fixed">
<ScannerCamera <ScannerCamera @update:frame="parseImage" />
:capture-enabled="isCameraFrameCaptureEnabled"
:capture-interval-ms="lprCameraCaptureIntervalMs"
:capture-mode="views.attachmentView.value ? 'preview' : 'lpr'"
:get-focus-viewport-rect="getScannerFocusViewportRect"
:pause-preview="shouldPauseScannerPreview"
:should-build-visual-fingerprint="shouldBuildLPRVisualFingerprint"
:should-encode-frame="shouldEncodeLPRFrame"
@update:frame="parseImage"
/>
</div> </div>
<div class="custom-content" data-testid="pos-mobile-step-1"> <div class="custom-content" data-testid="pos-mobile-step-1">
<!-- Meta objects, registration number auto-lookup --> <!-- Meta objects, registration number auto-lookup -->
@@ -800,13 +289,7 @@ watch(
/> />
<!-- Scanner outline object --> <!-- Scanner outline object -->
<div class="is-align-content-center is-flex is-justify-content-center"> <div class="is-align-content-center is-flex is-justify-content-center">
<div <ScannerOutline :loading="false" v-if="!views.attachmentView.value" />
v-if="!views.attachmentView.value"
ref="scannerFocusRef"
class="scanner-focus-target"
>
<ScannerOutline :loading="false" />
</div>
</div> </div>
<!-- Reg. 1, Reg. 2, Reg. 3 --> <!-- Reg. 1, Reg. 2, Reg. 3 -->
<div <div
@@ -820,10 +303,7 @@ watch(
<!-- Location --> <!-- Location -->
<PosDepartmentStepMobile1Location /> <PosDepartmentStepMobile1Location />
<!-- Buttons --> <!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl <PosDepartmentStepMobileFixedBottomControl variant="pos-step">
variant="pos-step"
:use-backdrop-blur="views.attachmentView.value"
>
<!-- Attachments --> <!-- Attachments -->
<div class="is-flex is-justify-content-center"> <div class="is-flex is-justify-content-center">
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value" /> <PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value" />
@@ -949,10 +429,4 @@ watch(
.custom-content > * { .custom-content > * {
width: min(100%, 48rem); width: min(100%, 48rem);
} }
.scanner-focus-target {
display: inline-flex;
max-width: 100%;
width: fit-content;
}
</style> </style>
@@ -53,9 +53,6 @@ import PosDepartmentStepMobile2AdditionalItems from "@/components/displays/depar
import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue"; import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js"; import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { useHoldToTrigger } from "@/composables/useHoldToTrigger"; import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
onMounted(() => { onMounted(() => {
// Set the header to be transparent // Set the header to be transparent
@@ -480,8 +477,6 @@ const updateLastOrderItemPrices = (items: PosOrderItem[]) => {
const layout = { const layout = {
classes: <string[]>[], classes: <string[]>[],
}; };
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const onCopyLastOrder = (vehicleIndex: number) => { const onCopyLastOrder = (vehicleIndex: number) => {
lastOrders.select(vehicleIndex); lastOrders.select(vehicleIndex);
@@ -782,18 +777,15 @@ const buildDesiredOrderItemShapes = () => {
const addonShapes = (transactionItems.primaryItem.value.addons || []) const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0) .filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => { .map((addon: any) => ({
const addonProduct = addon?.product ?? addon; kind: "addon",
return { relatedKey: "primary",
kind: "addon", product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
relatedKey: "primary", quantity: Number(addon?.quantity ?? 0),
product_id: Number(addonProduct?.id ?? addon?.id ?? 0), related_item_id: "__PRIMARY__",
quantity: Number(addon?.quantity ?? 0), price: Number(addon?.product?.price ?? addon?.price ?? 0),
related_item_id: "__PRIMARY__", notes: String(addon?.product?.notes ?? ""),
price: Number(addonProduct?.price ?? addon?.price ?? 0), }));
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
};
});
const additionalShapes = (transactionItems.additionalItems.value || []) const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0) .filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -960,17 +952,16 @@ const syncCurrentTransactionToOrder = async () => {
const addonPromises = (transactionItems.primaryItem.value.addons || []) const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0) .filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.map((addon: any) => { .map((addon: any) =>
const addonProduct = addon?.product ?? addon; createOrderItem(
return createOrderItem(
normalizedOrderId, normalizedOrderId,
addonProduct.id, addon.product.id,
Number(addon.quantity), Number(addon.quantity),
createdPrimaryItemId, createdPrimaryItemId,
addonProduct?.notes || addon?.notes || "", addon.product?.notes || "",
addonProduct.price ?? addon.price addon.product.price
); )
}); );
const additionalPromises = (transactionItems.additionalItems.value || []) const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0) .filter((item: any) => Number(item?.quantity ?? 0) > 0)
@@ -982,146 +973,11 @@ const syncCurrentTransactionToOrder = async () => {
return true; return true;
}; };
const normalizeText = (value: unknown) => String(value ?? "").trim();
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
const getProductId = (product: any) =>
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
const productRequiresOrderItemNote = (product: any) => {
if (!product) {
return false;
}
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
};
const productHasOrderItemNote = (product: any) =>
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const getSelectedProductsMissingRequiredNotes = () => {
const missingProducts: any[] = [];
const primaryProduct = transactionItems.primaryItem.value;
if (productRequiresOrderItemNote(primaryProduct) && !productHasOrderItemNote(primaryProduct)) {
missingProducts.push(primaryProduct);
}
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.forEach((addon: any) => {
const addonProduct = addon?.product ?? addon;
if (
(productRequiresOrderItemNote(addonProduct) || productRequiresOrderItemNote(addon)) &&
!productHasOrderItemNote(addonProduct) &&
!productHasOrderItemNote(addon)
) {
missingProducts.push(addonProduct);
}
});
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
.forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item);
}
});
return missingProducts;
};
const promptForRequiredProductNote = (product: any) =>
new Promise<boolean>((resolve) => {
const hadOriginalNote = Object.prototype.hasOwnProperty.call(product, "notes");
const originalNote = product?.notes;
const resolveAndClose = (didConfirm: boolean) => {
if (!didConfirm) {
if (hadOriginalNote) {
product.notes = originalNote;
} else {
delete product.notes;
}
}
if (popups.get()?.id === "add_product_note") {
popups.clear();
}
resolve(didConfirm);
};
popups.select("add_product_note", {
title: `${t("common.note")}: ${getProductName(product) || `#${getProductId(product)}`}`,
message: t("objects.products.columns.requires_note"),
component: "add_product_note",
hideHeader: true,
style: { maxHeight: "40vh" },
props: {
product,
validationMessage: "",
},
actionButtons: [
{
label: t("common.confirm"),
description: t("common.confirm"),
color: "primary",
testId: "pos-mobile-product-note-confirm",
onClick: () => {
const activePopup = popups.get();
const normalizedNote = normalizeText(activePopup?.props?.product?.notes);
if (!normalizedNote) {
if (activePopup?.props) {
activePopup.props.validationMessage = t("objects.products.columns.requires_note");
}
return;
}
product.notes = normalizedNote;
resolveAndClose(true);
},
},
{
label: t("common.cancel"),
description: t("common.cancel"),
color: "light",
testId: "pos-mobile-product-note-cancel",
onClick: () => resolveAndClose(false),
},
],
});
});
const ensureRequiredOrderItemNotes = async () => {
const missingProducts = getSelectedProductsMissingRequiredNotes();
for (const product of missingProducts) {
if (productHasOrderItemNote(product)) {
continue;
}
const didConfirm = await promptForRequiredProductNote(product);
if (!didConfirm) {
return false;
}
}
return true;
};
const onBeforeComplete = async () => { const onBeforeComplete = async () => {
if (!transactionItems.primaryItem.value) { if (!transactionItems.primaryItem.value) {
throw new Error("No primary item selected"); throw new Error("No primary item selected");
} }
if (!(await ensureRequiredOrderItemNotes())) {
return false;
}
await syncCurrentTransactionToOrder(); await syncCurrentTransactionToOrder();
return true; return true;
}; };
@@ -10,7 +10,6 @@ import {
reg_1, reg_1,
reset_all_values, reset_all_values,
setStep, setStep,
pushPosRouteState,
} from "@/components/shop/POSDepartmentProcess.vue"; } from "@/components/shop/POSDepartmentProcess.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { StripeModule } from "@/components/stripe/StripeModule.vue"; import { StripeModule } from "@/components/stripe/StripeModule.vue";
@@ -71,10 +70,10 @@ const formatCurrency = (amount) => {
const navigateToItems = () => { const navigateToItems = () => {
setStep(2); setStep(2);
if (normalizedOrderId.value) { if (normalizedOrderId.value) {
pushPosRouteState(`id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`); window.history.pushState({}, '', `?id=${normalizedOrderId.value}&customer_id=${customer_id.value}&step=2`);
return; return;
} }
pushPosRouteState("step=2"); window.history.pushState({}, '', `?step=2`);
}; };
const clearSuccessfulMobileCardFlow = () => { const clearSuccessfulMobileCardFlow = () => {
@@ -2,43 +2,25 @@
import { useGeolocation } from "@vueuse/core"; import { useGeolocation } from "@vueuse/core";
import { watch } from "vue"; import { watch } from "vue";
import { locations } from "../objects/PosDepartmentStepMobileFlow.vue"; import { locations } from "../objects/PosDepartmentStepMobileFlow.vue";
const emits = defineEmits<{
const props = withDefaults(defineProps<{ (e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
enableHighAccuracy?: boolean; }>();
maximumAge?: number; const { coords, locatedAt, error, resume, pause } = useGeolocation({
timeout?: number;
}>(), {
enableHighAccuracy: true, enableHighAccuracy: true,
maximumAge: 30000, maximumAge: 30000,
timeout: 27000, timeout: 27000,
}); });
const emits = defineEmits<{
(e: 'location-updated', coords: { latitude: number | null; longitude: number | null }): void;
}>();
const { coords, locatedAt, error, resume, pause } = useGeolocation({
enableHighAccuracy: props.enableHighAccuracy,
maximumAge: props.maximumAge,
timeout: props.timeout,
});
const getLocationTimestamp = () => {
const timestamp = Number(locatedAt.value);
return Number.isFinite(timestamp) ? timestamp : Date.now();
};
const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => { const onUpdate = (newCoords: { latitude: number | null; longitude: number | null }) => {
const normalizedCoords = locations.normalizeCoordinatePair(newCoords); if (newCoords.latitude && newCoords.longitude) {
if (normalizedCoords) {
const timestamp = getLocationTimestamp();
locations.set({ locations.set({
coords: normalizedCoords, coords: {
timestamp: new Date(timestamp), latitude: newCoords.latitude,
locatedAt: timestamp, longitude: newCoords.longitude,
},
timestamp: new Date(),
errorMessage: error.value ? error.value.message : null, errorMessage: error.value ? error.value.message : null,
}) })
emits('location-updated', normalizedCoords); emits('location-updated', newCoords);
} }
}; };
watch(coords, (newCoords) => { watch(coords, (newCoords) => {
@@ -14,7 +14,7 @@ type attachment = {
image: string | null; image: string | null;
document: string | null; document: string | null;
relation: string | null; relation: string | null;
other: unknown; other: string | null;
src: string | null; // For document preview (e.g., PDF URL) // THIS IS NEVER STORED, JUST FOR PREVIEW PURPOSES src: string | null; // For document preview (e.g., PDF URL) // THIS IS NEVER STORED, JUST FOR PREVIEW PURPOSES
}; };
created_at: string; created_at: string;
@@ -22,8 +22,6 @@ type attachment = {
deleted_at: string | null; deleted_at: string | null;
} }
const SELF_SERVE_WASH_ATTACHMENT_TYPE = 'SELF_SERVE_WASH';
const getAttachmentContent = (attachmentEntry: attachment) => { const getAttachmentContent = (attachmentEntry: attachment) => {
if (!attachmentEntry.content) { if (!attachmentEntry.content) {
return { return {
@@ -45,19 +43,7 @@ const getAttachmentContent = (attachmentEntry: attachment) => {
}; };
const getAttachmentOtherText = (attachmentEntry: attachment) => { const getAttachmentOtherText = (attachmentEntry: attachment) => {
const other = getAttachmentContent(attachmentEntry).other; return getAttachmentContent(attachmentEntry).other || '';
if (typeof other === 'string') {
return other;
}
if (isSelfServeWashAttachment(attachmentEntry)) {
const customerNumber = getSelfServeWashPayload(attachmentEntry)?.customer_number;
return customerNumber
? `${t('admin.pos.settings_wheel.self_serve_wash_attachment')} #${customerNumber}`
: t('admin.pos.settings_wheel.self_serve_wash_attachment');
}
return other && typeof other === 'object' ? JSON.stringify(other) : '';
}; };
const props = defineProps({ const props = defineProps({
attachments: { attachments: {
@@ -93,26 +79,6 @@ const determineAttachmentType = (attachment: attachment): 'image' | 'document' |
return 'unknown'; return 'unknown';
}; };
const getSelfServeWashPayload = (attachmentEntry: attachment): Record<string, any> | null => {
const other = getAttachmentContent(attachmentEntry).other;
return other && typeof other === 'object' && (other as Record<string, any>).type === SELF_SERVE_WASH_ATTACHMENT_TYPE
? other as Record<string, any>
: null;
};
const isSelfServeWashAttachment = (attachmentEntry: attachment): boolean => {
return getSelfServeWashPayload(attachmentEntry) !== null;
};
const formatSelfServeDriver = (payload: Record<string, any>): string => {
return payload.subuser?.name || payload.subuser?.username || (payload.subuser_id ? `#${payload.subuser_id}` : '-');
};
const formatElapsedMinutes = (seconds: unknown): string => {
const parsed = Number(seconds);
return Number.isFinite(parsed) && parsed > 0 ? `${Math.ceil(parsed / 60)} min` : '-';
};
const getAttachmentTypeIcon = (attachment: attachment): string => { const getAttachmentTypeIcon = (attachment: attachment): string => {
const type = determineAttachmentType(attachment); const type = determineAttachmentType(attachment);
switch (type) { switch (type) {
@@ -343,35 +309,14 @@ const onClickAttachWashCertificate = () => {
</template> </template>
<!-- OTHER PREVIEW --> <!-- OTHER PREVIEW -->
<template v-else-if="determineAttachmentType(attachment) === 'other' && getAttachmentContent(attachment).other"> <template v-else-if="determineAttachmentType(attachment) === 'other' && getAttachmentContent(attachment).other">
<template v-if="isSelfServeWashAttachment(attachment)">
<div class="content is-size-7">
<p class="has-text-weight-semibold">{{ t('admin.pos.settings_wheel.self_serve_wash_attachment') }}</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_customer') }}:</strong>
#{{ getSelfServeWashPayload(attachment)?.customer_number || '-' }}
</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_driver') }}:</strong>
{{ formatSelfServeDriver(getSelfServeWashPayload(attachment) || {}) }}
</p>
<p>
<strong>{{ t('pos.license_plate') }}:</strong>
{{ getSelfServeWashPayload(attachment)?.license_plate || '-' }}
</p>
<p>
<strong>{{ t('admin.pos.settings_wheel.self_serve_elapsed') }}:</strong>
{{ formatElapsedMinutes(getSelfServeWashPayload(attachment)?.elapsed_wash_time_seconds) }}
</p>
</div>
</template>
<!-- If the other type is a URL, you can create a link --> <!-- If the other type is a URL, you can create a link -->
<template v-else-if="typeof getAttachmentContent(attachment).other === 'string' && getAttachmentContent(attachment).other.startsWith('http')"> <template v-if="getAttachmentContent(attachment).other.startsWith('http')">
<a :href="String(getAttachmentContent(attachment).other)" target="_blank" rel="noopener noreferrer"> <a :href="getAttachmentContent(attachment).other" target="_blank" rel="noopener noreferrer">
{{ getAttachmentContent(attachment).other }} {{ getAttachmentContent(attachment).other }}
</a> </a>
</template> </template>
<template v-else> <template v-else>
<span>{{ getAttachmentOtherText(attachment) }}</span> <span>{{ getAttachmentContent(attachment).other }}</span>
</template> </template>
</template> </template>
<!-- NO PREVIEW --> <!-- NO PREVIEW -->
@@ -1,27 +1,34 @@
<script setup lang="ts"> <script setup lang="ts">
import { import {
reset_all_values, reset_all_values,
customer_name,
nextStep,
searchAndSelectCustomer,
isCustomerSelected,
order_id, order_id,
getStoredPosOrderId, customer_id,
step,
reg_1,
reg_2,
reg_3,
reference,
order_notes,
setDepartment,
getDepartment,
} from "@/components/shop/POSDepartmentProcess.vue"; } from "@/components/shop/POSDepartmentProcess.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import { resetPos } from "../objects/PosDepartmentStepMobileFlow.vue"; import { resetPos } from "../objects/PosDepartmentStepMobileFlow.vue";
import SessionUser from "@/components/session/token/SessionUser.vue"; import SessionUser from "@/components/session/token/SessionUser.vue";
const toPositiveInteger = (value: unknown) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const onClickClearAll = async () => { const onClickClearAll = async () => {
const storedOrderId = getStoredPosOrderId(); // 1. Delete the order (If any)
const currentOrderId = toPositiveInteger(order_id.value); // Check localstorage for pos_order_id
const activeDraftOrderId = storedOrderId && currentOrderId === storedOrderId ? storedOrderId : null; if (localStorage.getItem("pos_order_id")) {
order_id.value = parseInt(localStorage.getItem("pos_order_id") || "0");
// 1. Delete only the active mobile draft order, never a historical route-loaded order. }
if (activeDraftOrderId) { if (order_id.value) {
try { try {
order_id.value = activeDraftOrderId; const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(order_id.value);
const deleted = await SessionUser.objects.orders.functions.deleteWithConfirmation(activeDraftOrderId);
if (!deleted) { if (!deleted) {
return; return;
} }
@@ -299,12 +299,14 @@ const getSelectedBookingId = () => {
const completeStep2Order = async ({ const completeStep2Order = async ({
bookingSafetySeal = null, bookingSafetySeal = null,
markOrderCompleted = false,
}: { }: {
bookingSafetySeal?: string | null; bookingSafetySeal?: string | null;
markOrderCompleted?: boolean;
} = {}) => { } = {}) => {
await finalizeCurrentMobileOrder({ await finalizeCurrentMobileOrder({
bookingSafetySeal, bookingSafetySeal,
markOrderCompleted: true, markOrderCompleted,
}); });
popups.select("completed_transaction", { popups.select("completed_transaction", {
message: `Order #${order_id.value} successfully created.`, message: `Order #${order_id.value} successfully created.`,
@@ -325,12 +327,14 @@ const step2 = async () => {
const resolvedSafetySeal = getResolvedMobileSafetySeal(); const resolvedSafetySeal = getResolvedMobileSafetySeal();
const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal); const hasResolvedSafetySeal = isNonEmptyString(resolvedSafetySeal);
const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket); const requiresBookingCompletionPopup = Boolean(selectedBookingId && hasWashCertificateInBasket);
const shouldMarkOrderAsCompleted = !selectedBookingId && hasWashCertificateInBasket;
try { try {
if (requiresBookingCompletionPopup && hasResolvedSafetySeal) { if (requiresBookingCompletionPopup && hasResolvedSafetySeal) {
syncMobileSafetySealState(resolvedSafetySeal); syncMobileSafetySealState(resolvedSafetySeal);
await completeStep2Order({ await completeStep2Order({
bookingSafetySeal: resolvedSafetySeal, bookingSafetySeal: resolvedSafetySeal,
markOrderCompleted: false,
}); });
return; return;
} }
@@ -340,6 +344,7 @@ const step2 = async () => {
syncMobileSafetySealState(safetySeal); syncMobileSafetySealState(safetySeal);
await completeStep2Order({ await completeStep2Order({
bookingSafetySeal: String(safetySeal ?? ""), bookingSafetySeal: String(safetySeal ?? ""),
markOrderCompleted: false,
}); });
}; };
@@ -353,7 +358,9 @@ const step2 = async () => {
return; return;
} }
await completeStep2Order(); await completeStep2Order({
markOrderCompleted: shouldMarkOrderAsCompleted,
});
} catch (error: any) { } catch (error: any) {
errors.value.push(error); errors.value.push(error);
console.warn("An error occurred while completing the mobile order:", error); console.warn("An error occurred while completing the mobile order:", error);
@@ -405,10 +412,7 @@ const onClick = async () => {
isProcessingClick.value = true; isProcessingClick.value = true;
try { try {
const beforeStepResult = await props.onBeforeStep(); await props.onBeforeStep();
if (beforeStepResult === false) {
return;
}
// Proceed to the next step // Proceed to the next step
switch (step.value) { switch (step.value) {
case 1: case 1:
@@ -11,10 +11,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
useBackdropBlur: {
type: Boolean,
default: true,
},
variant: { variant: {
type: String, type: String,
default: 'default', default: 'default',
@@ -29,13 +25,6 @@ const props = defineProps({
const FIXED_BOTTOM_HEIGHT_CSS_VAR = '--pos-mobile-fixed-bottom-height'; const FIXED_BOTTOM_HEIGHT_CSS_VAR = '--pos-mobile-fixed-bottom-height';
const style = computed(() => { const style = computed(() => {
const background = `linear-gradient(to top, rgba(255, 255, 255, ${props.blurAmount * 0.8}) 0%, rgba(255, 255, 255, ${props.blurAmount * 0.4}) 100%)`;
if (!props.useBackdropBlur) {
return {
background,
};
}
// Smooth blur transition // Smooth blur transition
if (props.smoothBlur) { if (props.smoothBlur) {
return { return {
@@ -43,13 +32,12 @@ const style = computed(() => {
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`, WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`,
transition: 'backdrop-filter 0.3s ease, -webkit-backdrop-filter 0.3s ease', transition: 'backdrop-filter 0.3s ease, -webkit-backdrop-filter 0.3s ease',
// Add gradient from bottom // Add gradient from bottom
background, background: `linear-gradient(to top, rgba(255, 255, 255, ${props.blurAmount * 0.8}) 0%, rgba(255, 255, 255, ${props.blurAmount * 0.4}) 100%)`,
// Add gradient from top // Add gradient from top
} }
} }
return { return {
background,
backdropFilter: `blur(${props.blurAmount * 10}px)`, backdropFilter: `blur(${props.blurAmount * 10}px)`,
WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)` WebkitBackdropFilter: `blur(${props.blurAmount * 10}px)`
} }
@@ -9,9 +9,6 @@ const product = props.product;
const note = ref(product.notes || ""); const note = ref(product.notes || "");
watch(note, (newNote) => { watch(note, (newNote) => {
product.notes = newNote; product.notes = newNote;
if (props) {
props.validationMessage = "";
}
// If the note is empty, remove it from the product // If the note is empty, remove it from the product
if (newNote === "") { if (newNote === "") {
delete product.notes; delete product.notes;
@@ -28,13 +25,9 @@ watch(note, (newNote) => {
class="input is-searched" class="input is-searched"
v-model="note" v-model="note"
type="text" type="text"
data-testid="pos-mobile-product-note-input"
placeholder="Indtast note" placeholder="Indtast note"
/> />
</div> </div>
<p v-if="props?.validationMessage" class="help is-danger mt-2">
{{ props.validationMessage }}
</p>
</div> </div>
</div> </div>
</template> </template>
@@ -132,4 +125,4 @@ input.is-searched {
flex-grow: 0; flex-grow: 0;
} }
</style> </style>
@@ -100,57 +100,18 @@ const getLocation = () => {
const clearLocation = () => { const clearLocation = () => {
location.value = null; location.value = null;
}; };
type CoordinatePair = {
latitude?: number | string | null;
longitude?: number | string | null;
};
export const normalizeCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
{ allowZeroPair = true }: { allowZeroPair?: boolean } = {}
): { latitude: number; longitude: number } | null => {
const latitude = Number(coordinates?.latitude);
const longitude = Number(coordinates?.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
return null;
}
if (!allowZeroPair && latitude === 0 && longitude === 0) {
return null;
}
return { latitude, longitude };
};
export const hasValidCoordinatePair = (
coordinates: CoordinatePair | null | undefined,
options: { allowZeroPair?: boolean } = {}
): boolean => normalizeCoordinatePair(coordinates, options) !== null;
// Get distance in kilometers between two locations // Get distance in kilometers between two locations
const getDistance = ( const getDistance = (
from: CoordinatePair, from: { latitude: number; longitude: number },
to: CoordinatePair to: { latitude: number; longitude: number }
): number => { ): number => {
const normalizedFrom = normalizeCoordinatePair(from);
const normalizedTo = normalizeCoordinatePair(to);
if (!normalizedFrom || !normalizedTo) {
return Number.POSITIVE_INFINITY;
}
const toRad = (value: number) => (value * Math.PI) / 180; const toRad = (value: number) => (value * Math.PI) / 180;
const R = 6371; // Radius of the Earth in kilometers const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(normalizedTo.latitude - normalizedFrom.latitude); const dLat = toRad(to.latitude - from.latitude);
const dLon = toRad(normalizedTo.longitude - normalizedFrom.longitude); const dLon = toRad(to.longitude - from.longitude);
const lat1 = toRad(normalizedFrom.latitude); const lat1 = toRad(from.latitude);
const lat2 = toRad(normalizedTo.latitude); const lat2 = toRad(to.latitude);
const a = const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2);
@@ -163,8 +124,6 @@ const locations = {
set: setLocation, set: setLocation,
get: getLocation, get: getLocation,
clear: clearLocation, clear: clearLocation,
normalizeCoordinatePair,
hasValidCoordinatePair,
getDistance, getDistance,
defaultTimeout: locationTimeout, defaultTimeout: locationTimeout,
}; };
@@ -829,15 +788,7 @@ const promptForNotesIfRequired = (product: PosProduct, callback: (notes: string)
label: "Bekræft", label: "Bekræft",
description: "Bekræft noten og fortsæt", description: "Bekræft noten og fortsæt",
onClick: () => { onClick: () => {
const activePopup = popups.get(); callback(popups.get()?.props?.product?.notes || "");
const note = String(activePopup?.props?.product?.notes || "").trim();
if (!note) {
if (activePopup?.props) {
activePopup.props.validationMessage = "Note er påkrævet for dette produkt";
}
return;
}
callback(note);
clearPopup(); clearPopup();
}, },
color: "primary", color: "primary",
@@ -1065,9 +1016,9 @@ const getTotalAttachmentsCount = () => {
); );
}; };
// Function to take a picture as a base64 attachment // Function to take a picture as a base64 attachment
const takePicture = async () => { const takePicture = () => {
// Save the last picture to the base64 attachments // Save the last picture to the base64 attachments
const lastPicture = await getLatestImage(); const lastPicture = latestImage.value;
if (lastPicture) { if (lastPicture) {
addAttachmentBase64({ addAttachmentBase64({
filename: "last_picture.jpg", filename: "last_picture.jpg",
@@ -1118,7 +1069,6 @@ watch(
/** Camera */ /** Camera */
// Define the reactive properties // Define the reactive properties
const latestImage = ref<string | null>(null); const latestImage = ref<string | null>(null);
const latestImageBlob = ref<Blob | null>(null);
const isCameraMounted = ref<boolean>(false); const isCameraMounted = ref<boolean>(false);
const cameraImageCaptureDelayInitial = ref<number>(500); // Initial delay for camera image capture in milliseconds (First capture) const cameraImageCaptureDelayInitial = ref<number>(500); // Initial delay for camera image capture in milliseconds (First capture)
const cameraImageCaptureDelaySubsequent = ref<number>(1005); // Further captures delay in milliseconds (Every capture after the first one) const cameraImageCaptureDelaySubsequent = ref<number>(1005); // Further captures delay in milliseconds (Every capture after the first one)
@@ -1160,34 +1110,13 @@ const hasCameraImageCaptureDelayAfterSuccessPassed = (): boolean => {
const currentTime = Date.now(); const currentTime = Date.now();
return currentTime - cameraImageCaptureLastSuccess.value > cameraImageCaptureDelayAfterSuccess.value; return currentTime - cameraImageCaptureLastSuccess.value > cameraImageCaptureDelayAfterSuccess.value;
}; };
const blobToDataUrl = (blob: Blob): Promise<string | null> => new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
resolve(typeof reader.result === "string" ? reader.result : null);
};
reader.onerror = () => {
resolve(null);
};
reader.readAsDataURL(blob);
});
// Function to retrieve the latest image frame. // Function to retrieve the latest image frame.
const getLatestImage = async () => { const getLatestImage = async () => {
if (!latestImage.value && latestImageBlob.value) {
latestImage.value = await blobToDataUrl(latestImageBlob.value);
}
return latestImage.value; return latestImage.value;
}; };
// Function to set the latest image frame. // Function to set the latest image frame.
const setLatestImage = (image: string | null) => { const setLatestImage = (image: string | null) => {
latestImage.value = image; latestImage.value = image;
latestImageBlob.value = null;
};
// Function to set the latest image frame as a Blob.
const setLatestImageBlob = (image: Blob | null) => {
latestImage.value = null;
latestImageBlob.value = image;
}; };
// Function to set the camera mounted state. // Function to set the camera mounted state.
const setCameraMounted = (mounted: boolean) => { const setCameraMounted = (mounted: boolean) => {
@@ -1196,7 +1125,6 @@ const setCameraMounted = (mounted: boolean) => {
// Function to clear the latest image. // Function to clear the latest image.
const clearLatestImage = () => { const clearLatestImage = () => {
latestImage.value = null; latestImage.value = null;
latestImageBlob.value = null;
}; };
// Function to clear the camera mounted state. // Function to clear the camera mounted state.
const clearCameraMounted = () => { const clearCameraMounted = () => {
@@ -1217,12 +1145,10 @@ const setCameraImageCaptureDelay = (isFirstCapture: boolean, delay: number) => {
const camera = { const camera = {
latestImage, latestImage,
latestImageBlob,
get: getLatestImage, get: getLatestImage,
mounted: isCameraMounted, mounted: isCameraMounted,
setMounted: setCameraMounted, setMounted: setCameraMounted,
setLatestImage, setLatestImage,
setLatestImageBlob,
clearLatestImage, clearLatestImage,
clearMounted: clearCameraMounted, clearMounted: clearCameraMounted,
getImageCaptureDelay: getCameraImageCaptureDelay, getImageCaptureDelay: getCameraImageCaptureDelay,
@@ -18,9 +18,7 @@ export type PosLocation = {
coords?: PosLocationCoords | null; coords?: PosLocationCoords | null;
/** Timestamp */ /** Timestamp */
timestamp?: Date | null; timestamp?: Date | null;
/** Browser geolocation timestamp in milliseconds */
locatedAt?: number | null;
/** Error message */ /** Error message */
errorMessage?: string | null; errorMessage?: string | null;
}; };
</script> </script>
@@ -3,45 +3,6 @@ export const XLVASK_USAGE_AMOUNT_CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_PREFIX = "xlvask-usage-amount:"; const CACHE_PREFIX = "xlvask-usage-amount:";
const memoryCache = new Map(); const memoryCache = new Map();
const getStorageValue = (key) => {
try {
if (typeof window === "undefined" || !window.localStorage) {
return "";
}
return window.localStorage.getItem(key) || "";
} catch {
return "";
}
};
const hashCacheScopePart = (value) => {
let hash = 5381;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(index);
}
return (hash >>> 0).toString(36);
};
const getAuthenticatedCacheScope = () => {
const token = getStorageValue("token");
if (!token) {
return "";
}
return [
hashCacheScopePart(token),
getStorageValue("is_subuser") === "true" ? "subuser" : "user",
getStorageValue("selected_customer_number"),
]
.map((part) => encodeURIComponent(String(part ?? "")))
.join("|");
};
const getScopedCacheKey = (cacheKey) => {
const scope = getAuthenticatedCacheScope();
return scope ? `${scope}:${cacheKey}` : "";
};
const safeSessionStorage = () => { const safeSessionStorage = () => {
try { try {
if (typeof window === "undefined" || !window.sessionStorage) { if (typeof window === "undefined" || !window.sessionStorage) {
@@ -54,7 +15,9 @@ const safeSessionStorage = () => {
}; };
const normalizeUsageLogId = (objectOrId) => { const normalizeUsageLogId = (objectOrId) => {
const value = typeof objectOrId === "object" ? objectOrId?.usage_log_id ?? objectOrId?.id : objectOrId; const value = typeof objectOrId === "object"
? objectOrId?.usage_log_id ?? objectOrId?.id
: objectOrId;
const parsed = Number.parseInt(String(value ?? ""), 10); const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0; return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
}; };
@@ -71,18 +34,17 @@ export const buildXlvaskUsageAmountCacheKey = (objectOrId) => {
export const getCachedXlvaskUsageAmount = (objectOrId) => { export const getCachedXlvaskUsageAmount = (objectOrId) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId); const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : ""; if (!cacheKey) {
if (!scopedCacheKey) {
return null; return null;
} }
const memoryEntry = memoryCache.get(scopedCacheKey); const memoryEntry = memoryCache.get(cacheKey);
if (isFreshEntry(memoryEntry)) { if (isFreshEntry(memoryEntry)) {
return memoryEntry.data; return memoryEntry.data;
} }
if (memoryEntry) { if (memoryEntry) {
memoryCache.delete(scopedCacheKey); memoryCache.delete(cacheKey);
} }
const storage = safeSessionStorage(); const storage = safeSessionStorage();
@@ -90,14 +52,14 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
return null; return null;
} }
const storageKey = `${CACHE_PREFIX}${scopedCacheKey}`; const storageKey = `${CACHE_PREFIX}${cacheKey}`;
try { try {
const parsed = JSON.parse(storage.getItem(storageKey) || "null"); const parsed = JSON.parse(storage.getItem(storageKey) || "null");
if (!isFreshEntry(parsed)) { if (!isFreshEntry(parsed)) {
storage.removeItem(storageKey); storage.removeItem(storageKey);
return null; return null;
} }
memoryCache.set(scopedCacheKey, parsed); memoryCache.set(cacheKey, parsed);
return parsed.data; return parsed.data;
} catch { } catch {
storage.removeItem(storageKey); storage.removeItem(storageKey);
@@ -107,8 +69,7 @@ export const getCachedXlvaskUsageAmount = (objectOrId) => {
export const setCachedXlvaskUsageAmount = (objectOrId, data) => { export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId); const cacheKey = buildXlvaskUsageAmountCacheKey(objectOrId);
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : ""; if (!cacheKey) {
if (!scopedCacheKey) {
return; return;
} }
@@ -116,7 +77,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
storedAt: Date.now(), storedAt: Date.now(),
data, data,
}; };
memoryCache.set(scopedCacheKey, entry); memoryCache.set(cacheKey, entry);
const storage = safeSessionStorage(); const storage = safeSessionStorage();
if (!storage) { if (!storage) {
@@ -124,7 +85,7 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
} }
try { try {
storage.setItem(`${CACHE_PREFIX}${scopedCacheKey}`, JSON.stringify(entry)); storage.setItem(`${CACHE_PREFIX}${cacheKey}`, JSON.stringify(entry));
} catch { } catch {
// Best-effort cache. Quota errors should not block XL Vask usage rows. // Best-effort cache. Quota errors should not block XL Vask usage rows.
} }
@@ -133,12 +94,11 @@ export const setCachedXlvaskUsageAmount = (objectOrId, data) => {
export const clearCachedXlvaskUsageAmount = (objectOrId = null) => { export const clearCachedXlvaskUsageAmount = (objectOrId = null) => {
const storage = safeSessionStorage(); const storage = safeSessionStorage();
const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId); const cacheKey = objectOrId === null ? "" : buildXlvaskUsageAmountCacheKey(objectOrId);
const scopedCacheKey = cacheKey ? getScopedCacheKey(cacheKey) : "";
if (scopedCacheKey) { if (cacheKey) {
memoryCache.delete(scopedCacheKey); memoryCache.delete(cacheKey);
try { try {
storage?.removeItem(`${CACHE_PREFIX}${scopedCacheKey}`); storage?.removeItem(`${CACHE_PREFIX}${cacheKey}`);
} catch { } catch {
// Best-effort cache cleanup. // Best-effort cache cleanup.
} }
@@ -229,7 +229,7 @@ const clearAnswers = async () => {
const confirmation = await Swal.fire({ const confirmation = await Swal.fire({
title: "Ryd besvarelser?", title: "Ryd besvarelser?",
text: `Registreringsnummer ${normalizedReg.value} på bane ${selectedLaneId.value} bliver ryddet.`, text: `Registreringsnummer ${normalizedReg.value} pa bane ${selectedLaneId.value} bliver ryddet.`,
icon: "warning", icon: "warning",
showCancelButton: true, showCancelButton: true,
confirmButtonText: "Ja, ryd besvarelser", confirmButtonText: "Ja, ryd besvarelser",
@@ -284,7 +284,7 @@ watch(dynamicImageUrl, () => {
<div class="modal-background" @click="closeModal"></div> <div class="modal-background" @click="closeModal"></div>
<div class="modal-card" style="width: 95%; max-width: 1200px;"> <div class="modal-card" style="width: 95%; max-width: 1200px;">
<header class="modal-card-head"> <header class="modal-card-head">
<p class="modal-card-title">Forhåndsvisning af selvvask</p> <p class="modal-card-title">Self-serve preview</p>
<button class="delete" aria-label="close" @click="closeModal"></button> <button class="delete" aria-label="close" @click="closeModal"></button>
</header> </header>
@@ -301,7 +301,7 @@ watch(dynamicImageUrl, () => {
<label class="label">Bane</label> <label class="label">Bane</label>
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select v-model="selectedLaneId" data-testid="self-serve-try-lane"> <select v-model="selectedLaneId" data-testid="self-serve-try-lane">
<option :value="null">Vælg bane</option> <option :value="null">Vaelg bane</option>
<option v-for="entry in availableLanes" :key="entry.id" :value="entry.id"> <option v-for="entry in availableLanes" :key="entry.id" :value="entry.id">
{{ entry.name }} (ID: {{ entry.id }}) {{ entry.name }} (ID: {{ entry.id }})
</option> </option>
@@ -313,7 +313,7 @@ watch(dynamicImageUrl, () => {
<input class="input" :value="selectedLaneId" type="text" disabled> <input class="input" :value="selectedLaneId" type="text" disabled>
</div> </div>
<div class="column is-3"> <div class="column is-3">
<label class="label">Køretøjstype</label> <label class="label">Koretojstype</label>
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select v-model="selectedVehicleTypeId" data-testid="self-serve-try-vehicle-type"> <select v-model="selectedVehicleTypeId" data-testid="self-serve-try-vehicle-type">
<option :value="null">Auto (fra registreringsnummer)</option> <option :value="null">Auto (fra registreringsnummer)</option>
@@ -325,7 +325,7 @@ watch(dynamicImageUrl, () => {
</div> </div>
<div class="column is-3 is-flex is-align-items-flex-end"> <div class="column is-3 is-flex is-align-items-flex-end">
<button class="button is-link is-fullwidth" data-testid="self-serve-try-refresh" :class="{ 'is-loading': loading }" @click="refresh"> <button class="button is-link is-fullwidth" data-testid="self-serve-try-refresh" :class="{ 'is-loading': loading }" @click="refresh">
Hent forhåndsvisning Hent preview
</button> </button>
</div> </div>
</div> </div>
@@ -335,10 +335,10 @@ watch(dynamicImageUrl, () => {
Tilladt: {{ allowed ? "Ja" : "Nej" }} Tilladt: {{ allowed ? "Ja" : "Nej" }}
</span> </span>
<span class="tag" :class="machineAvailable ? 'is-success' : 'is-light'"> <span class="tag" :class="machineAvailable ? 'is-success' : 'is-light'">
Maskine tilgængelig: {{ machineAvailable ? "Ja" : "Nej" }} Maskine tilgaengelig: {{ machineAvailable ? "Ja" : "Nej" }}
</span> </span>
<span class="tag" :class="allDisplayQuestionsAnswered ? 'is-success' : 'is-warning'"> <span class="tag" :class="allDisplayQuestionsAnswered ? 'is-success' : 'is-warning'">
Alle synlige spørgsmål besvaret: {{ allDisplayQuestionsAnswered ? "Ja" : "Nej" }} Alle synlige sporgsmal besvaret: {{ allDisplayQuestionsAnswered ? "Ja" : "Nej" }}
</span> </span>
<span v-if="session" class="tag is-info"> <span v-if="session" class="tag is-info">
Session: {{ session.status }} (#{{ session.id }}) Session: {{ session.status }} (#{{ session.id }})
@@ -347,10 +347,10 @@ watch(dynamicImageUrl, () => {
Maskintype: {{ machineType.name }} Maskintype: {{ machineType.name }}
</span> </span>
<span v-if="lane" class="tag is-light"> <span v-if="lane" class="tag is-light">
Bane: {{ lane.name || lane.id }} Lane: {{ lane.name || lane.id }}
</span> </span>
<span v-if="configVersionId" class="tag is-dark"> <span v-if="configVersionId" class="tag is-dark">
Konfigurationsversion: #{{ configVersionId }} Config version: #{{ configVersionId }}
</span> </span>
</div> </div>
@@ -364,10 +364,10 @@ watch(dynamicImageUrl, () => {
<div class="column is-6"> <div class="column is-6">
<div class="box" style="height: 100%"> <div class="box" style="height: 100%">
<h4 class="title is-5">Spørgsmål</h4> <h4 class="title is-5">Sporgsmal</h4>
<div v-if="displayQuestions.length === 0" class="notification is-success is-light"> <div v-if="displayQuestions.length === 0" class="notification is-success is-light">
<p>Ingen synlige spørgsmål for denne forhåndsvisning.</p> <p>Ingen synlige sporgsmal for denne preview.</p>
</div> </div>
<SelfServeQuestionCards <SelfServeQuestionCards
@@ -375,18 +375,18 @@ watch(dynamicImageUrl, () => {
:answers="answers" :answers="answers"
@answer-question="submitAnswer" @answer-question="submitAnswer"
/> />
<p v-if="questions.length === 0" class="has-text-centered is-italic">Ingen spørgsmål fundet.</p> <p v-if="questions.length === 0" class="has-text-centered is-italic">Ingen sporgsmal fundet.</p>
</div> </div>
</div> </div>
<div class="column is-6"> <div class="column is-6">
<div class="box" style="height: 100%"> <div class="box" style="height: 100%">
<h4 class="title is-5">Opgaver og session</h4> <h4 class="title is-5">Tasks og session</h4>
<div v-if="displayedDynamicImageUrl" class="mb-4"> <div v-if="displayedDynamicImageUrl" class="mb-4">
<img <img
:src="displayedDynamicImageUrl" :src="displayedDynamicImageUrl"
alt="Forhåndsvisning af maskinstatus" alt="Machine status preview"
data-testid="self-serve-try-dynamic-image" data-testid="self-serve-try-dynamic-image"
style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;" style="max-width: 100%; height: auto; border-radius: 4px; display: block; margin-left: auto; margin-right: auto;"
@error="onDynamicImageError" @error="onDynamicImageError"
@@ -401,11 +401,11 @@ watch(dynamicImageUrl, () => {
@download-attachment="downloadAttachment" @download-attachment="downloadAttachment"
/> />
<p v-if="activeTasks.length === 0" class="is-italic">Ingen aktive opgaver.</p> <p v-if="activeTasks.length === 0" class="is-italic">Ingen aktive tasks.</p>
<hr /> <hr />
<h5 class="subtitle is-6">Seneste hændelser</h5> <h5 class="subtitle is-6">Seneste haendelser</h5>
<ul> <ul>
<li v-for="event in events" :key="event.id" class="mb-2"> <li v-for="event in events" :key="event.id" class="mb-2">
<strong>{{ event.type }}</strong> <strong>{{ event.type }}</strong>
@@ -416,16 +416,16 @@ watch(dynamicImageUrl, () => {
<hr /> <hr />
<h5 class="subtitle is-6">Evalueringsspor</h5> <h5 class="subtitle is-6">Evaluation trace</h5>
<div v-if="evaluationTrace" class="content is-small"> <div v-if="evaluationTrace" class="content is-small">
<pre>{{ JSON.stringify(evaluationTrace, null, 2) }}</pre> <pre>{{ JSON.stringify(evaluationTrace, null, 2) }}</pre>
</div> </div>
<p v-else class="is-italic">Ingen sporingsdata returneret.</p> <p v-else class="is-italic">Ingen trace-data returneret.</p>
<hr /> <hr />
<div class="is-flex is-align-items-center is-justify-content-space-between mb-2"> <div class="is-flex is-align-items-center is-justify-content-space-between mb-2">
<h5 class="subtitle is-6 mb-0">Besvarede spørgsmål</h5> <h5 class="subtitle is-6 mb-0">Besvarede sporgsmal</h5>
<button <button
class="button is-small is-light" class="button is-small is-light"
data-testid="self-serve-try-clear-answers" data-testid="self-serve-try-clear-answers"
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue"; import { computed, inject, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from "vue-i18n"; import { useI18n } from 'vue-i18n';
import * as paginatedListModule from "@/components/pagination/paginatedList.vue"; import * as paginatedListModule from "@/components/pagination/paginatedList.vue";
const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule); const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule);
const { search, metaSearch, isLoading, loadList } = paginatedList; const { search, metaSearch, isLoading, loadList } = paginatedList;
@@ -13,15 +13,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
searchPlaceholder: {
type: String,
default: "",
},
}); });
const canExport = computed(() => typeof paginatedList?.exportToExcel === "function"); const canExport = computed(() => typeof paginatedList?.exportToExcel === 'function');
const isExporting = computed(() => Boolean(paginatedList?.isExporting?.value)); const isExporting = computed(() => Boolean(paginatedList?.isExporting?.value));
const resolvedSearchPlaceholder = computed(() => props.searchPlaceholder || t("global.search_placeholder"));
const isActionDropdownOpen = ref(false); const isActionDropdownOpen = ref(false);
const actionDropdownRef = ref<HTMLElement | null>(null); const actionDropdownRef = ref<HTMLElement | null>(null);
@@ -66,11 +61,11 @@ const handleDocumentClick = (event: MouseEvent) => {
}; };
onMounted(() => { onMounted(() => {
document.addEventListener("click", handleDocumentClick); document.addEventListener('click', handleDocumentClick);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener("click", handleDocumentClick); document.removeEventListener('click', handleDocumentClick);
}); });
</script> </script>
@@ -79,12 +74,12 @@ onBeforeUnmount(() => {
<div class="columns is-vcentered is-multiline pagination-general-search-reload"> <div class="columns is-vcentered is-multiline pagination-general-search-reload">
<div v-if="!props.hideSearch" class="column pagination-general-search-reload__search-column"> <div v-if="!props.hideSearch" class="column pagination-general-search-reload__search-column">
<input <input
data-testid="pagination-search-input" data-testid="pagination-search-input"
class="input" class="input"
type="text" type="text"
:placeholder="resolvedSearchPlaceholder" :placeholder="$t('global.search_placeholder')"
v-model="metaSearch" v-model="metaSearch"
@input="search($event.target.value)" @input="search($event.target.value)"
/> />
</div> </div>
<div class="column is-narrow pagination-general-search-reload__buttons-column" v-if="$slots.buttons"> <div class="column is-narrow pagination-general-search-reload__buttons-column" v-if="$slots.buttons">
@@ -139,7 +134,7 @@ onBeforeUnmount(() => {
@click.prevent="handleExportExcel" @click.prevent="handleExportExcel"
> >
<i class="fas fa-file-excel" aria-hidden="true"></i> <i class="fas fa-file-excel" aria-hidden="true"></i>
<span>{{ t("pagination.download_excel") }}</span> <span>{{ t('pagination.download_excel') }}</span>
</a> </a>
</div> </div>
</div> </div>
@@ -1,12 +1,20 @@
<script setup> <script setup>
import { ref, inject } from "vue"; import { ref, inject } from 'vue';
import * as paginatedListModule from "@/components/pagination/paginatedList.vue"; import * as paginatedListModule from "@/components/pagination/paginatedList.vue";
const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule); const paginatedList = inject(paginatedListModule.PaginatedListKey, paginatedListModule);
const { isLoading, loadList, metaCurrentPage, metaItemsPerPage, metaTotalItems, setMetaItemsPerPage, setPage } = const {
paginatedList; isLoading,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setMetaItemsPerPage,
setPage,
} = paginatedList;
import PaginationDisplayGeneralSearchReload from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue"; import PaginationDisplayGeneralSearchReload
from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue"; import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue"; import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
@@ -23,10 +31,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
searchPlaceholder: {
type: String,
default: "",
},
hidePagination: { hidePagination: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -44,7 +48,6 @@ const isSmall = ref(window.innerWidth < 1024);
<PaginationDisplayGeneralSearchReload <PaginationDisplayGeneralSearchReload
v-if="!props.hideSearch || $slots.buttons" v-if="!props.hideSearch || $slots.buttons"
:hide-search="props.hideSearch" :hide-search="props.hideSearch"
:search-placeholder="props.searchPlaceholder"
> >
<template #buttons="{ loadList }" v-if="$slots.buttons"> <template #buttons="{ loadList }" v-if="$slots.buttons">
<slot name="buttons" :loadList="loadList"></slot> <slot name="buttons" :loadList="loadList"></slot>
@@ -63,7 +66,7 @@ const isSmall = ref(window.innerWidth < 1024);
<template #paginationColumns> <template #paginationColumns>
<slot name="paginationDisplayFiltersElement"></slot> <slot name="paginationDisplayFiltersElement"></slot>
<slot name="leftPaginationColumns"></slot> <slot name="leftPaginationColumns"></slot>
<div class="column is-auto-fill my-3" v-if="!isSmall" /> <div class="column is-auto-fill my-3" v-if="!isSmall"/>
<div class="columns is-multiline"> <div class="columns is-multiline">
<div class="column is-12 p-1 m-0"></div> <div class="column is-12 p-1 m-0"></div>
<slot name="rightPaginationColumns"></slot> <slot name="rightPaginationColumns"></slot>
@@ -86,4 +89,5 @@ const isSmall = ref(window.innerWidth < 1024);
</div> </div>
</template> </template>
<style scoped></style> <style scoped>
</style>
@@ -1,30 +1,30 @@
<script setup> <script setup>
import { provide, ref } from "vue"; import { provide, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue"; import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import CustomerComplaintsTable from "@/components/displays/superuser/tables/customerComplaintsTable.vue"; import CustomerComplaintsTable from "@/components/displays/superuser/tables/customerComplaintsTable.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue"; import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps({ const props = defineProps(["hideSearch", "autoLoad"]);
hideSearch: {
type: Boolean,
default: false,
},
autoLoad: {
type: Boolean,
default: false,
},
});
const { t } = useI18n(); const { t } = useI18n();
const paginatedList = usePaginatedList(); const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList); provide(PaginatedListKey, paginatedList);
const { const {
isLoading,
list, list,
loadList, loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint, setEndpoint,
setMetaItemsPerPage,
setPage,
search,
setFilter, setFilter,
setOrder, setOrder,
hideSearchField, hideSearchField,
@@ -54,11 +54,6 @@ const onDepartmentFilterChange = (value) => {
setFilter("department_id", parsedDepartmentId, true); setFilter("department_id", parsedDepartmentId, true);
}; };
const onOrderDirectionChange = (value) => {
setOrder("created_at", value);
loadList();
};
setEndpoint("/departments/daily-reports/complaints", false); setEndpoint("/departments/daily-reports/complaints", false);
setOrder("created_at", "desc"); setOrder("created_at", "desc");
@@ -74,15 +69,22 @@ if (props.autoLoad) {
</script> </script>
<template> <template>
<TableLabeledPagination <input
:label="t('superuser.pages.complaints.title')" v-if="!hideSearchField"
:hide-search="hideSearchField" class="input"
> data-testid="superuser-complaints-search"
<template #description> type="text"
<p class="is-size-6 mb-4">{{ t('superuser.pages.complaints.subtitle') }}</p> :placeholder="t('superuser.pages.complaints.search_placeholder')"
</template> @input="search($event.target.value)"
/>
<template #paginationDisplayFiltersElement> <PaginationDisplay
:metaItemsPerPage="metaItemsPerPage"
:loadFunction="loadList"
:isLoading="isLoading"
:setMetaItemsPerPage="setMetaItemsPerPage"
>
<template #paginationColumns>
<div class="column is-narrow my-3"> <div class="column is-narrow my-3">
<label class="label is-small">{{ t('superuser.pages.complaints.department_filter') }}</label> <label class="label is-small">{{ t('superuser.pages.complaints.department_filter') }}</label>
<div class="control"> <div class="control">
@@ -110,7 +112,7 @@ if (props.autoLoad) {
<div class="select"> <div class="select">
<select <select
data-testid="superuser-complaints-order-direction" data-testid="superuser-complaints-order-direction"
@change="onOrderDirectionChange($event.target.value)" @change="setOrder('created_at', $event.target.value); loadList();"
> >
<option value="desc" selected>{{ t('pagination.descending') }}</option> <option value="desc" selected>{{ t('pagination.descending') }}</option>
<option value="asc">{{ t('pagination.ascending') }}</option> <option value="asc">{{ t('pagination.ascending') }}</option>
@@ -119,11 +121,26 @@ if (props.autoLoad) {
</div> </div>
</div> </div>
</template> </template>
</PaginationDisplay>
<template #default> <CustomerComplaintsTable :objects="list" />
<CustomerComplaintsTable :objects="list" />
</template> <PaginationNavigation
</TableLabeledPagination> :currentPage="metaCurrentPage"
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
:loadFunction="loadList"
:setPage="setPage"
:isLoading="isLoading"
/>
<LoadButtonWhileAwait
class="is-dark"
:isLoading="isLoading"
:loadFunction="loadList"
icon="fas fa-sync-alt"
>
{{ t('pagination.reload') }}
</LoadButtonWhileAwait>
</template> </template>
<style scoped> <style scoped>
@@ -1,27 +1,45 @@
<script setup> <script setup>
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
import { useRouter } from "vue-router";
import {
usePaginatedList,
PaginatedListKey
} from "@/components/pagination/paginatedList.vue";
import { provide } from "vue"; import { provide } from "vue";
import CustomersTable from "@/components/displays/superuser/tables/customersTable.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
const props = defineProps({
hideSearch: {
type: Boolean,
default: false,
},
autoLoad: {
type: Boolean,
default: false,
},
});
const paginatedList = usePaginatedList(); const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList); provide(PaginatedListKey, paginatedList);
const {
isLoaded,
isLoading,
list,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setPage,
metaSearch,
search,
setFilter,
setOrder,
hideSearchField,
setHideSearchField,
} = paginatedList;
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
const { list, loadList, setEndpoint, hideSearchField, setHideSearchField } = paginatedList; import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
import CustomersTable from "@/components/displays/superuser/tables/customersTable.vue";
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter();
setEndpoint("/customers", false); setEndpoint("/customers", false);
// Hide the search field, if the hideSearch prop is set
if (props.hideSearch) { if (props.hideSearch) {
setHideSearchField(true); setHideSearchField(true);
} }
@@ -29,12 +47,20 @@ if (props.hideSearch) {
if (props.autoLoad) { if (props.autoLoad) {
loadList(); loadList();
} }
</script> </script>
<template> <template>
<TableLabeledPagination :label="$t('customers.title')" :hide-search="hideSearchField"> <input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_customers')" v-if="!hideSearchField"/>
<CustomersTable :objects="list" /> <PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
</TableLabeledPagination> <template #paginationColumns>
</template>
</PaginationDisplay>
<CustomersTable :objects="list" />
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
</template> </template>
<style scoped></style> <style scoped>
</style>
@@ -3,7 +3,9 @@ import { computed, provide, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import DepartmentsTable from "@/components/displays/superuser/tables/departmentsTable.vue"; import DepartmentsTable from "@/components/displays/superuser/tables/departmentsTable.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue"; import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import PaginationDisplayGeneralSearchReload from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue"; import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]); const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
@@ -12,9 +14,15 @@ const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList); provide(PaginatedListKey, paginatedList);
const { const {
isLoading,
list, list,
loadList, loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint, setEndpoint,
setMetaItemsPerPage,
setPage,
setFilter, setFilter,
setOrder, setOrder,
hideSearchField, hideSearchField,
@@ -46,11 +54,14 @@ if (props.autoLoad) {
</script> </script>
<template> <template>
<TableLabeledPagination <PaginationDisplayGeneralSearchReload v-if="!hideSearchField" />
:label="t('common.departments')" <PaginationDisplay
:hide-search="hideSearchField" :metaItemsPerPage="metaItemsPerPage"
:loadFunction="loadList"
:isLoading="isLoading"
:setMetaItemsPerPage="setMetaItemsPerPage"
> >
<template #paginationDisplayFiltersElement> <template #paginationColumns>
<div class="column is-narrow my-3 department-status-filter"> <div class="column is-narrow my-3 department-status-filter">
<label class="label is-small" for="department-archive-filter">{{ <label class="label is-small" for="department-archive-filter">{{
t("common.status") t("common.status")
@@ -70,8 +81,23 @@ if (props.autoLoad) {
</div> </div>
</div> </div>
</template> </template>
<DepartmentsTable :objects="sortedList" /> </PaginationDisplay>
</TableLabeledPagination> <div v-if="hideSearchField" class="mb-3">
<button class="button is-dark" :class="{ 'is-loading': isLoading }" :disabled="isLoading" @click="loadList">
<span class="icon is-small">
<i class="fas fa-sync-alt" aria-hidden="true"></i>
</span>
<span>{{ t("pagination.reload") }}</span>
</button>
</div>
<DepartmentsTable :objects="sortedList" />
<PaginationNavigation
:currentPage="metaCurrentPage"
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
:loadFunction="loadList"
:setPage="setPage"
:isLoading="isLoading"
/>
</template> </template>
<style scoped> <style scoped>
@@ -7,24 +7,7 @@ import PaginationDisplay from "@/components/displays/pagination/PaginationDispla
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"; import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue"; import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
const props = defineProps({ const props = defineProps(["hideSearch", "autoLoad"]);
hideSearch: {
type: Boolean,
default: false,
},
autoLoad: {
type: [Boolean, String],
default: false,
},
endpoint: {
type: String,
default: "/subusers",
},
showCustomer: {
type: Boolean,
default: false,
},
});
const { t } = useI18n(); const { t } = useI18n();
const paginatedList = usePaginatedList(); const paginatedList = usePaginatedList();
@@ -47,7 +30,7 @@ const {
setAdditionalQueryParameters, setAdditionalQueryParameters,
} = paginatedList; } = paginatedList;
setEndpoint(props.endpoint, false); setEndpoint("/subusers", false);
setAdditionalQueryParameters({ include_non_enabled: true }); setAdditionalQueryParameters({ include_non_enabled: true });
setOrder("created_at", "desc"); setOrder("created_at", "desc");
@@ -99,7 +82,7 @@ if (props.autoLoad) {
</template> </template>
</PaginationDisplay> </PaginationDisplay>
<SubusersTable :objects="list" :show-customer="showCustomer" /> <SubusersTable :objects="list" />
<PaginationNavigation <PaginationNavigation
:currentPage="metaCurrentPage" :currentPage="metaCurrentPage"
@@ -1,21 +1,45 @@
<script setup> <script setup>
import { provide } from "vue";
import { useI18n } from "vue-i18n";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import UsersTable from "@/components/displays/superuser/tables/usersTable.vue"; import UsersTable from "@/components/displays/superuser/tables/usersTable.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]); let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
import { useRouter } from "vue-router";
import {
usePaginatedList,
PaginatedListKey
} from "@/components/pagination/paginatedList.vue";
import { provide } from "vue";
const paginatedList = usePaginatedList(); const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList); provide(PaginatedListKey, paginatedList);
const { list, loadList, setEndpoint, setFilter, setOrder, hideSearchField, setHideSearchField } = paginatedList; const {
isLoaded,
isLoading,
list,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setPage,
metaSearch,
search,
setFilter,
setOrder,
hideSearchField,
setHideSearchField,
} = paginatedList;
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
const { t } = useI18n(); import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter();
setEndpoint("/users", false); setEndpoint("/users", false);
setFilter("customer_number", 0, false); setFilter("customer_number", 0, false);
setOrder("created_at", "desc");
// Hide the search field, if the hideSearch prop is set // Hide the search field, if the hideSearch prop is set
if (props.hideSearch) { if (props.hideSearch) {
@@ -25,35 +49,32 @@ if (props.hideSearch) {
if (props.autoLoad) { if (props.autoLoad) {
loadList(); loadList();
} }
</script> </script>
<template> <template>
<TableLabeledPagination <input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_user')" v-if="!hideSearchField"/>
:label="t('superuser.pages.employees.title')" <PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
:hide-search="hideSearchField" <template #paginationColumns>
:search-placeholder="t('global.search_user')"
>
<template #paginationDisplayFiltersElement>
<!-- Sort by created_at --> <!-- Sort by created_at -->
<div class="column is-narrow my-3"> <div class="column is-narrow my-3">
<label class="label is-small">{{ t("pagination.order_direction") }}</label> <label class="label is-small">{{ t('pagination.order_direction') }}</label>
<div class="control"> <div class="control">
<div class="select"> <div class="select">
<select <select @change="setOrder('created_at', $event.target.value); loadList();">
@change=" <option value="asc">{{ t('pagination.ascending') }}</option>
setOrder('created_at', $event.target.value); <option value="desc" selected>{{ t('pagination.descending') }}</option>
loadList();
"
>
<option value="asc">{{ t("pagination.ascending") }}</option>
<option value="desc" selected>{{ t("pagination.descending") }}</option>
</select> </select>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<UsersTable :objects="list" /> </PaginationDisplay>
</TableLabeledPagination> <UsersTable :objects="list" />
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
</template> </template>
<style scoped></style> <style scoped>
</style>
@@ -1,21 +1,25 @@
<script setup> <script setup>
import { list, loadList, setEndpoint, setFilter, setOrder } from "@/components/pagination/paginatedList.vue"; import {
import { SessionUser } from "@/components/session/token/SessionUser.vue"; list,
loadList,
setEndpoint,
setFilter,
setOrder
} from "@/components/pagination/paginatedList.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { computed, onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue"; import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displays/tables/OrderBookingsTable.vue"; import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displays/tables/OrderBookingsTable.vue";
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue"; import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
import { Colors } from "@/ThemeConfig.vue"; import { Colors } from "@/ThemeConfig.vue";
import { useI18n } from "vue-i18n"; import { useI18n } from 'vue-i18n'
const { t } = useI18n(); const { t } = useI18n()
/** /**
* Router * Router
*/ */
const router = useRouter(); const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const isAdminRoute = computed(() => router.currentRoute.value.path.startsWith("/admin"));
/** /**
* Props * Props
*/ */
@@ -23,40 +27,41 @@ const props = defineProps({
filters: { filters: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
required: false, required: false
}, },
}); })
const orderIdFilter = ref("*"); const orderIdFilter = ref('*');
const onOrderIdFilterChange = (event) => { const onOrderIdFilterChange = (event) => {
const val = event.target.value; const val = event.target.value;
orderIdFilter.value = val; orderIdFilter.value = val;
// Apply filter: '*' clears, 'is null' shows without order, 'not null' shows with order // Apply filter: '*' clears, 'is null' shows without order, 'not null' shows with order
setFilter("order_id", val); setFilter('order_id', val);
}; };
// Department filter // Department filter
const departmentFilter = ref("*"); const departmentFilter = ref('*');
const onDepartmentFilterChange = (event) => { const onDepartmentFilterChange = (event) => {
const val = event.target.value; const val = event.target.value;
departmentFilter.value = val; departmentFilter.value = val;
setFilter("department", val); setFilter('department', val);
}; };
// Only today filter // Only today filter
const onlyTodayFilter = ref("*"); const onlyTodayFilter = ref('*');
const onOnlyTodayFilterChange = (event, autoLoadList = true) => { const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
const val = event.target.value; const val = event.target.value;
onlyTodayFilter.value = val; onlyTodayFilter.value = val;
if (val === "*") { if (val === '*') {
setFilter("datetime", val, false); // Clear filter setFilter('datetime', val, false); // Clear filter
setFilter("datetime-date_from", null, false); setFilter('datetime-date_from', null, false);
setFilter("datetime-date_to", null, false); setFilter('datetime-date_to', null, false);
} else { } else {
const startOfDay = new Date().setHours(0, 0, 0, 0); const startOfDay = new Date().setHours(0, 0, 0, 0);
const endOfDay = new Date().setHours(23, 59, 59, 999); const endOfDay = new Date().setHours(23, 59, 59, 999);
//setFilter('datetime', null, false); //setFilter('datetime', null, false);
setFilter("datetime-date_from", new Date(startOfDay).toISOString(), false); setFilter('datetime-date_from', new Date(startOfDay).toISOString(), false);
setFilter("datetime-date_to", new Date(endOfDay).toISOString(), false); setFilter('datetime-date_to', new Date(endOfDay).toISOString(), false);
} }
if (autoLoadList) { if (autoLoadList) {
loadList(); loadList();
@@ -64,38 +69,34 @@ const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
}; };
// Version selector + helpers // Version selector + helpers
const versionSelector = ref("new"); const versionSelector = ref('new');
const resetVersionToNew = () => { const resetVersionToNew = () => {
versionSelector.value = "new"; versionSelector.value = 'new';
}; };
const showLegacyOrderBookingsPortal = () => { const showLegacyOrderBookingsPortal = () => {
const departmentId = SessionUser.functions.getDepartmentIdFromUrl(); const departmentId = SessionUser.functions.getDepartmentIdFromUrl();
if (!departmentId) { if (!departmentId) {
SessionUser.functions.redirectTo.user("/bookings-legacy", true); SessionUser.functions.redirectTo.user('/bookings-legacy', true);
return; return;
} }
SessionUser.functions.redirectTo.department( SessionUser.functions.redirectTo.department(SessionUser.functions.getDepartmentIdFromUrl(), 'modules/bookings-legacy', true);
SessionUser.functions.getDepartmentIdFromUrl(),
"modules/bookings-legacy",
true
);
}; };
// Filters // Filters
onMounted(() => { onMounted(() => {
setEndpoint(SessionUser.objects.order_bookings.meta.endpoint, false); setEndpoint(SessionUser.objects.order_bookings.meta.endpoint, false);
setOrder("datetime", "desc", false); setOrder('datetime', 'desc', false);
// Apply initial filters from props // Apply initial filters from props
for (const [key, value] of Object.entries(props.filters)) { for (const [key, value] of Object.entries(props.filters)) {
let setFilterKey = true; let setFilterKey = true;
// Also set the filter controls if applicable // Also set the filter controls if applicable
if (key === "order_id") { if (key === 'order_id') {
orderIdFilter.value = value; orderIdFilter.value = value;
} else if (key === "department") { } else if (key === 'department') {
departmentFilter.value = value; departmentFilter.value = value;
} else if (key === "only_today" && value === true) { } else if (key === 'only_today' && value === true) {
setFilterKey = false; // Since the only_today filter is handled separately setFilterKey = false; // Since the only_today filter is handled separately
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split("T")[0] } }, false); onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split('T')[0] } }, false);
} }
if (setFilterKey) { if (setFilterKey) {
setFilter(key, value, false); setFilter(key, value, false);
@@ -105,218 +106,108 @@ onMounted(() => {
// Load departments for filter options // Load departments for filter options
getDepartments().catch(() => {}); getDepartments().catch(() => {});
}); });
</script> </script>
<template> <template>
<div class="order-bookings-pagination"> <div>
<TableLabeledPagination :label="t('pagination.bookings_overview')" class="order-bookings-pagination__table"> <TableLabeledPagination :label="t('pagination.bookings_overview')">
<template #paginationDisplayFiltersElement> <template #paginationDisplayFiltersElement>
<!-- Status filter --> <!-- Status filter -->
<div class="column is-narrow order-bookings-pagination__filter"> <div class="column is-narrow">
<div class="field mb-0"> <div class="field mb-0">
<label class="label is-small">{{ t("common.status") }}</label> <label class="label is-small">{{ t('common.status') }}</label>
<div class="control"> <div class="control">
<div class="select"> <div class="select">
<select :value="orderIdFilter" @change="onOrderIdFilterChange"> <select :value="orderIdFilter" @change="onOrderIdFilterChange">
<option value="*">{{ t("common.all") }}</option> <option value="*">{{ t('common.all') }}</option>
<option value="is null">{{ t("pagination.not_completed") }}</option> <option value="is null">{{ t('pagination.not_completed') }}</option>
<option value="not null">{{ t("pagination.completed") }}</option> <option value="not null">{{ t('pagination.completed') }}</option>
</select> </select>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Department filter --> </div>
<div class="column is-narrow order-bookings-pagination__filter"> <!-- Department filter -->
<div class="field mb-0"> <div class="column is-narrow">
<label class="label is-small">{{ t("pagination.department") }}</label> <div class="field mb-0">
<div class="control"> <label class="label is-small">{{ t('pagination.department') }}</label>
<div class="select"> <div class="control">
<select :value="departmentFilter" @change="onDepartmentFilterChange"> <div class="select">
<option value="*">{{ t("common.all") }}</option> <select :value="departmentFilter" @change="onDepartmentFilterChange">
<option v-for="department in departments" :key="department.id" :value="department.id"> <option value="*">{{ t('common.all') }}</option>
{{ department.name }} <option v-for="department in departments" :key="department.id" :value="department.id">{{ department.name }}</option>
</option> </select>
</select>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Version selector --> </div>
<div class="column is-narrow order-bookings-pagination__filter"> <!-- Version selector -->
<div class="field mb-0"> <div class="column is-narrow">
<label class="label is-small">{{ t("pagination.version") }}</label> <div class="field mb-0">
<div class="control"> <label class="label is-small">{{ t('pagination.version') }}</label>
<div class="select"> <div class="control">
<select <div class="select">
@change=" <select @change="showLegacyOrderBookingsPortal(); resetVersionToNew()" v-model="versionSelector">
showLegacyOrderBookingsPortal(); <option value="new">{{ t('common.new') }}</option>
resetVersionToNew(); <option value="legacy">{{ t('pagination.old') }}</option>
" </select>
v-model="versionSelector"
>
<option value="new">{{ t("common.new") }}</option>
<option value="legacy">{{ t("pagination.old") }}</option>
</select>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Only today filter --> </div>
<div class="column is-narrow order-bookings-pagination__filter" v-if="!isUserRoute"> <!-- Only today filter -->
<div class="field mb-0"> <div class="column is-narrow">
<label class="label is-small">{{ t("pagination.only_today") }}</label> <div class="field mb-0">
<div class="control"> <label class="label is-small">{{ t('pagination.only_today') }}</label>
<div class="select"> <div class="control">
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange"> <div class="select">
<option value="*">{{ t("common.all") }}</option> <select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
<option :value="new Date().toISOString().split('T')[0]">{{ t("common.yes") }}</option> <option value="*">{{ t('common.all') }}</option>
</select> <option :value="new Date().toISOString().split('T')[0]">{{ t('common.yes') }}</option>
</div> </select>
</div> </div>
</div> </div>
</div> </div>
</template> </div>
<template #leftPaginationColumns> </template> </template>
<template #rightPaginationColumns> <template #leftPaginationColumns>
<!-- Toggles and actions depending on route --> </template>
<!-- User: Only today switch + New booking button --> <template #rightPaginationColumns>
<div class="column is-narrow order-bookings-pagination__actions" v-if="isUserRoute"> <!-- Toggles and actions depending on route -->
<div class="order-bookings-pagination__actions-grid"> <!-- User: Only today switch + New booking button -->
<div class="order-bookings-pagination__today-action"> <div class="column is-narrow" v-if="router.currentRoute.value.path.startsWith('/user')">
<label class="label is-small">{{ t("pagination.show_only_today") }}</label> <label class="label is-small">{{ t('pagination.show_only_today') }}</label>
<div class="field mb-0"> <div class="field">
<input <input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { onOnlyTodayFilterChange({ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } }) }" :class="{ 'is-link': onlyTodayFilter !== '*' }" :checked="onlyTodayFilter !== '*'" />
id="today" <label for="today"></label>
type="checkbox"
class="switch is-rounded"
@change="
(event) => {
onOnlyTodayFilterChange({
target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' },
});
}
"
:class="{ 'is-link': onlyTodayFilter !== '*' }"
:checked="onlyTodayFilter !== '*'"
/>
<label for="today"></label>
</div>
</div>
<div class="order-bookings-pagination__new-booking-action">
<label class="label is-small order-bookings-pagination__desktop-spacer">&nbsp;</label>
<button
class="button is-link button-same-width"
data-testid="user-bookings-new-booking"
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
>
{{ t("pagination.new_booking") }}
</button>
</div>
</div>
</div> </div>
<!-- Admin: pending + only today combined switch --> </div>
<div class="column is-narrow" v-if="isAdminRoute"> <div class="column is-narrow is-float-right" v-if="router.currentRoute.value.path.startsWith('/user')">
<label class="label is-small">{{ t("pagination.show_only_todays_pending") }}</label> <label class="label is-small">&nbsp;</label>
<div class="field"> <button
<input class="button is-link button-same-width"
id="today-pending" @click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
type="checkbox" >
class="switch is-rounded" {{ t('pagination.new_booking') }}
@change=" </button>
(event) => { </div>
onOnlyTodayFilterChange( <!-- Admin: pending + only today combined switch -->
{ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } }, <div class="column is-narrow" v-if="router.currentRoute.value.path.startsWith('/admin')">
false <label class="label is-small">{{ t('pagination.show_only_todays_pending') }}</label>
); <div class="field">
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } }); <input id="today-pending" type="checkbox" class="switch is-rounded" @change="(event) => { onOnlyTodayFilterChange({ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } }, false); onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } }); }" :class="{ 'is-link': onlyTodayFilter !== '*' && orderIdFilter === 'is null' }" :checked="onlyTodayFilter !== '*' && orderIdFilter === 'is null'" />
} <label for="today-pending"></label>
"
:class="{ 'is-link': onlyTodayFilter !== '*' && orderIdFilter === 'is null' }"
:checked="onlyTodayFilter !== '*' && orderIdFilter === 'is null'"
/>
<label for="today-pending"></label>
</div>
</div> </div>
</template> </div>
<template #default> </template>
<OrderBookingsTable :objects="list" /> <template #default>
</template> <OrderBookingsTable :objects="list" />
</TableLabeledPagination> </template>
</TableLabeledPagination>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.order-bookings-pagination__filter .select,
.order-bookings-pagination__filter select {
width: 100%;
}
.order-bookings-pagination__actions-grid { </style>
align-items: flex-end;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.order-bookings-pagination__today-action .field {
min-height: 2.5rem;
}
.order-bookings-pagination__new-booking-action .button {
min-width: 10rem;
}
@media screen and (max-width: 768px) {
.order-bookings-pagination__table :deep([data-testid="table-labeled-pagination-filters"] > .columns) {
align-items: flex-start;
column-gap: 0.75rem;
margin-left: 0;
margin-right: 0;
row-gap: 0.85rem;
}
.order-bookings-pagination__table :deep([data-testid="table-labeled-pagination-filters"] > .columns > .column),
.order-bookings-pagination__filter,
.order-bookings-pagination__actions {
margin: 0;
padding: 0;
}
.order-bookings-pagination__filter {
flex: 1 1 calc(50% - 0.375rem);
max-width: calc(50% - 0.375rem);
}
.order-bookings-pagination__table
:deep([data-testid="table-labeled-pagination-filters"] > .columns > .columns.is-multiline) {
flex: 1 1 100%;
margin: 0;
max-width: 100%;
padding: 0;
width: 100%;
}
.order-bookings-pagination__actions {
flex: 1 1 100%;
max-width: 100%;
width: 100%;
}
.order-bookings-pagination__actions-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
justify-content: stretch;
}
.order-bookings-pagination__new-booking-action .button {
min-width: 0;
width: 100%;
}
.order-bookings-pagination__desktop-spacer {
display: none;
}
}
</style>
@@ -1,70 +0,0 @@
<!--
ErrorBanner.vue reusable self-serve error notification bar.
Props:
message error text to display (blank hidden)
type Bulma tint: is-danger | is-warning (default is-danger)
iconLeft Font Awesome icon name
:loading whether retry button is in loading state
:showRetry whether to show the retry button
Emit:
retry fired when retry button is clicked
-->
<script setup lang="ts">
interface Props {
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}
withDefaults(
defineProps<{
message: string | null;
type?: "is-danger" | "is-warning";
iconLeft?: string;
loading?: boolean;
showRetry?: boolean;
retryTestId?: string | null;
}>(),
{
type: "is-danger",
iconLeft: "sync-alt",
loading: false,
showRetry: true,
retryTestId: null,
}
);
const emit = defineEmits<{
retry: [];
}>();
</script>
<template>
<b-message v-if="message" :type="type" has-icon :closable="false" class="self-serve-error-banner">
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ message }}</span>
<b-button
v-if="showRetry"
size="is-small"
:type="`${type} is-light`"
icon-pack="fas"
:icon-left="iconLeft"
:loading="loading"
:data-testid="retryTestId || undefined"
@click="emit('retry')"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
</template>
<style scoped>
.self-serve-error-banner {
margin-bottom: 1rem;
}
</style>
@@ -1,59 +0,0 @@
<!--
LaneSelectionSection.vue bane-valg radio buttons and lane status display.
Props:
lanes array of lane objects from the department
selectedLaneId currently selected lane id
washType "Manual" | "Machine"
departmentName human-readable department name
isLaneAvailableFn function(lane) => boolean
isMachineAvailableFn function(laneId) => boolean
Emit:
update:selectedLaneId new lane id selected
update:washType "Manual" | "Machine"
-->
<script setup lang="ts">
import SelfServeLaneStep from "@/components/displays/selfServe/SelfServeLaneStep.vue";
interface Props {
lanes: any[];
selectedLaneId: number | string | null;
washType: string;
departmentName: string | null;
isLaneAvailableFn: (lane: any) => boolean;
isMachineAvailableFn: (laneId: number | string | null) => boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:selectedLaneId": [id: number];
"update:washType": [type: string];
}>();
function handleLaneUpdate(id: number) {
emit("update:selectedLaneId", id);
}
function handleWashTypeUpdate(type: string) {
emit("update:washType", type);
}
</script>
<template>
<SelfServeLaneStep
:lanes="lanes"
:selected-lane-id="selectedLaneId"
:wash-type="washType"
:department-name="departmentName"
:is-lane-available="isLaneAvailableFn"
:is-machine-available="isMachineAvailableFn"
@update:selected-lane-id="handleLaneUpdate"
@update:wash-type="handleWashTypeUpdate"
/>
</template>
<style scoped>
/* Lane selection styling is inherited from SelfServeLaneStep */
</style>
@@ -2,16 +2,13 @@
import { computed } from "vue"; import { computed } from "vue";
import { BButton, BField } from "buefy"; import { BButton, BField } from "buefy";
const props = withDefaults( const props = withDefaults(defineProps<{
defineProps<{ steps: Array<any>;
steps: Array<any>; currentStep: number;
currentStep: number; showActions?: boolean;
showActions?: boolean; }>(), {
}>(), showActions: true,
{ });
showActions: true,
}
);
const emit = defineEmits<{ const emit = defineEmits<{
(e: "update:currentStep", value: number): void; (e: "update:currentStep", value: number): void;
@@ -48,6 +45,9 @@ const goNext = () => {
<div class="guided-instructions" data-testid="self-serve-guided-instructions"> <div class="guided-instructions" data-testid="self-serve-guided-instructions">
<div class="guided-instructions__header"> <div class="guided-instructions__header">
<h3 class="title is-6">{{ $t("self_wash.follow_steps") }}</h3> <h3 class="title is-6">{{ $t("self_wash.follow_steps") }}</h3>
<span class="guided-instructions__counter">
{{ normalizedStepIndex + 1 }} / {{ steps.length }}
</span>
</div> </div>
<section <section
@@ -55,18 +55,8 @@ const goNext = () => {
class="guided-instructions__step" class="guided-instructions__step"
:data-testid="`self-serve-guided-step-${normalizedStepIndex}`" :data-testid="`self-serve-guided-step-${normalizedStepIndex}`"
> >
<b-field> <b-field :label="`${normalizedStepIndex + 1}. ${activeStep.title}`">
<template #label> <div class="guided-instructions__content">
<span class="guided-instructions__step-label" data-testid="self-serve-guided-step-label">
<span class="guided-instructions__step-title" data-testid="self-serve-guided-step-title">
{{ normalizedStepIndex + 1 }}. {{ activeStep.title }}
</span>
<span class="guided-instructions__counter" data-testid="self-serve-guided-counter">
{{ normalizedStepIndex + 1 }}/{{ steps.length }}
</span>
</span>
</template>
<div class="guided-instructions__content" data-testid="self-serve-guided-content">
<p v-if="activeStep.content" class="guided-instructions__paragraph">{{ activeStep.content }}</p> <p v-if="activeStep.content" class="guided-instructions__paragraph">{{ activeStep.content }}</p>
<template v-for="(brush, brushIndex) in activeStep.brushes || []" :key="brushIndex"> <template v-for="(brush, brushIndex) in activeStep.brushes || []" :key="brushIndex">
<p class="guided-instructions__paragraph"> <p class="guided-instructions__paragraph">
@@ -115,28 +105,14 @@ const goNext = () => {
.guided-instructions__header { .guided-instructions__header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
} }
.guided-instructions__step-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
width: 100%;
}
.guided-instructions__step-title {
min-width: 0;
}
.guided-instructions__counter { .guided-instructions__counter {
flex: 0 0 auto;
color: #566074; color: #566074;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 600; font-weight: 600;
margin-left: auto;
text-align: right;
white-space: nowrap; white-space: nowrap;
} }
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { BIcon, BMessage, BRadioButton } from "buefy"; import { BField, BIcon, BMessage, BRadioButton } from "buefy";
defineProps<{ defineProps<{
lanes: Array<any>; lanes: Array<any>;
@@ -19,23 +19,14 @@ const emit = defineEmits<{
<template> <template>
<div data-testid="self-serve-lane-step"> <div data-testid="self-serve-lane-step">
<h1 class="title has-text-centered">{{ $t("self_wash.start_wash") }}</h1> <h1 class="title has-text-centered">{{ $t("self_wash.start_wash") }}</h1>
<section class="self-serve-choice-group" data-testid="self-serve-lane-choice-group"> <b-field :label="$t('self_wash.wash_lane')">
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_lane") }}</h2> <div class="columns is-multiline is-mobile" :class="{ 'is-centered': lanes.length >= 2 }">
<div
class="self-serve-choice-grid"
:class="{ 'self-serve-choice-grid--centered': lanes.length >= 2 }"
data-testid="self-serve-lane-options"
>
<template v-for="lane in lanes" :key="lane.id"> <template v-for="lane in lanes" :key="lane.id">
<div class="self-serve-choice-grid__item"> <div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
<b-radio-button <b-radio-button
class="self-serve-choice-card"
:model-value="selectedLaneId" :model-value="selectedLaneId"
:native-value="lane.id" :native-value="lane.id"
type="is-link" type="is-link"
:disabled="!isLaneAvailable(lane)"
:title="!isLaneAvailable(lane) ? $t('self_wash.lane_unavailable') : null"
:aria-label="!isLaneAvailable(lane) ? $t('self_wash.lane_unavailable') : null"
:data-testid="`self-serve-lane-option-${lane.id}`" :data-testid="`self-serve-lane-option-${lane.id}`"
@update:model-value="emit('update:selectedLaneId', lane.id)" @update:model-value="emit('update:selectedLaneId', lane.id)"
@input="emit('update:selectedLaneId', lane.id)" @input="emit('update:selectedLaneId', lane.id)"
@@ -45,7 +36,7 @@ const emit = defineEmits<{
<span v-if="!isLaneAvailable(lane)"> <span v-if="!isLaneAvailable(lane)">
<small> <small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" /> <b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.lane_unavailable") }} {{ $t("self_wash.occupied") }}
</small> </small>
</span> </span>
<span v-else> <span v-else>
@@ -58,18 +49,18 @@ const emit = defineEmits<{
</b-radio-button> </b-radio-button>
</div> </div>
</template> </template>
<div v-if="lanes.length === 0" class="column is-12">
<b-message type="is-warning" aria-close-label="Luk besked">
Ingen vaskebaner tilgaengelige for selvvask i <b>{{ departmentName || "denne" }}</b> afdeling.
</b-message>
</div>
</div> </div>
<b-message v-if="lanes.length === 0" type="is-warning" aria-close-label="Luk besked"> </b-field>
Ingen vaskebaner tilgængelige for selvvask i <b>{{ departmentName || "denne" }}</b> afdeling.
</b-message>
</section>
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group"> <b-field label="Maskine eller manuel vask">
<h2 class="self-serve-choice-label">Maskine eller manuel vask</h2> <div class="columns is-mobile is-centered is-multiline">
<div class="self-serve-choice-grid self-serve-choice-grid--centered" data-testid="self-serve-wash-type-options"> <div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
<div class="self-serve-choice-grid__item">
<b-radio-button <b-radio-button
class="self-serve-choice-card"
:model-value="washType" :model-value="washType"
native-value="Manual" native-value="Manual"
type="is-link" type="is-link"
@@ -81,14 +72,13 @@ const emit = defineEmits<{
<span>Manuel<br /></span> <span>Manuel<br /></span>
<small> <small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" /> <b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
Tilgængelig Tilgaengelig
</small> </small>
</span> </span>
</b-radio-button> </b-radio-button>
</div> </div>
<div class="self-serve-choice-grid__item"> <div class="column is-half-mobile is-one-third-tablet is-one-quarter-desktop">
<b-radio-button <b-radio-button
class="self-serve-choice-card"
:model-value="washType" :model-value="washType"
native-value="Machine" native-value="Machine"
type="is-link" type="is-link"
@@ -115,77 +105,6 @@ const emit = defineEmits<{
</b-radio-button> </b-radio-button>
</div> </div>
</div> </div>
</section> </b-field>
</div> </div>
</template> </template>
<style scoped>
.self-serve-choice-group {
margin-bottom: 1.25rem;
}
.self-serve-choice-label {
color: #303440;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
margin: 0 0 0.75rem;
}
.self-serve-choice-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 0.75rem;
width: 100%;
}
.self-serve-choice-grid--centered {
justify-content: center;
}
.self-serve-choice-grid__item {
min-width: 0;
}
.self-serve-choice-card {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button) {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button),
.self-serve-choice-card span,
.self-serve-choice-card :deep(.button span) {
min-width: 0;
}
.self-serve-choice-card small,
.self-serve-choice-card :deep(.button small) {
display: inline-flex;
align-items: center;
line-height: 1.25;
}
@media screen and (min-width: 769px) {
.self-serve-choice-grid {
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
}
}
</style>
@@ -38,7 +38,7 @@ const emit = defineEmits<{
<div class="card-footer-item"> <div class="card-footer-item">
<button <button
class="button is-fullwidth" class="button is-fullwidth"
:class="[answers[question.id] === false ? 'is-danger' : 'is-light']" :class="[answers[question.id] === false ? 'is-danger is-light' : 'is-light']"
:data-testid="`self-serve-question-${question.id}-no`" :data-testid="`self-serve-question-${question.id}-no`"
@click="emit('answer-question', question.id, false)" @click="emit('answer-question', question.id, false)"
> >
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { BButton, BIcon } from "buefy"; import { BButton, BIcon, BTooltip } from "buefy";
import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue"; import SelfServeQuestionCards from "@/components/displays/selfServe/SelfServeQuestionCards.vue";
defineProps<{ defineProps<{
@@ -7,9 +7,18 @@ defineProps<{
visibleQuestions: Array<any>; visibleQuestions: Array<any>;
answers: Record<number, boolean | undefined>; answers: Record<number, boolean | undefined>;
editAnswers: boolean; editAnswers: boolean;
showDebug: boolean;
conditions: Array<any>;
rules: Array<any>;
evaluateCondition: (conditionId: number) => boolean;
evaluateRule: (rule: any, visited?: Set<number>) => boolean;
isQuestionVisible: (question: any) => boolean;
getConditionById: (conditionId: number) => any;
getRuleTypeLabel: (type: string) => string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "toggle-debug"): void;
(e: "toggle-edit"): void; (e: "toggle-edit"): void;
(e: "answer-question", questionId: number, value: boolean): void; (e: "answer-question", questionId: number, value: boolean): void;
}>(); }>();
@@ -26,8 +35,27 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
<p>{{ $t("self_wash.loading_data") }}</p> <p>{{ $t("self_wash.loading_data") }}</p>
</div> </div>
<div v-else> <div v-else>
<div
v-if="isLoading"
class="notification is-info is-light py-2 px-3 mb-4"
data-testid="self-serve-questions-inline-loading"
>
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<div class="is-flex is-justify-content-center is-align-items-center mb-4"> <div class="is-flex is-justify-content-center is-align-items-center mb-4">
<h1 class="title mb-0">{{ $t("self_wash.answer_questions") }}</h1> <h1 class="title mb-0">{{ $t("self_wash.answer_questions") }}</h1>
<b-button
size="is-small"
icon-left="bug"
type="is-ghost"
class="ml-2"
data-testid="self-serve-toggle-debug"
@click="emit('toggle-debug')"
>
Debug
</b-button>
<b-button <b-button
v-if="editAnswers" v-if="editAnswers"
size="is-small" size="is-small"
@@ -41,16 +69,75 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
</b-button> </b-button>
</div> </div>
<div class="self-serve-questions-status-slot" aria-live="polite"> <div v-if="showDebug" class="box mb-4 has-background-light" data-testid="self-serve-debug-panel">
<div <h5 class="subtitle is-5">Debug: Betingelser</h5>
class="notification is-info is-light py-2 px-3 mb-0" <div class="tags">
:class="{ 'is-invisible': !isLoading }" <b-tooltip
data-testid="self-serve-questions-inline-loading" v-for="condition in conditions"
:aria-hidden="!isLoading" :key="condition.id"
> position="is-top"
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" /> multilined
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span> type="is-dark"
>
<template #content>
<div class="has-text-left">
<p v-if="condition.description" class="mb-2"><i>{{ condition.description }}</i></p>
<p class="is-size-7 has-text-weight-bold mb-1">Regler:</p>
<div
v-for="rule in rules.filter(entry => parseInt(entry.condition_id as any) === parseInt(condition.id))"
:key="rule.id"
class="is-size-7"
>
<span class="icon is-small">
<i :class="evaluateRule(rule, new Set([parseInt(condition.id)])) ? 'fas fa-check has-text-success' : 'fas fa-times has-text-danger'" />
</span>
<span class="ml-1">[{{ getRuleTypeLabel(rule.type) }}] {{ rule.name }}</span>
</div>
</div>
</template>
<span class="tag" :class="evaluateCondition(condition.id) ? 'is-success' : 'is-light'">
<span class="icon is-small mr-1">
<i :class="evaluateCondition(condition.id) ? 'fas fa-check-circle' : 'fas fa-times-circle'" />
</span>
{{ condition.name }}
</span>
</b-tooltip>
</div> </div>
<hr />
<h5 class="subtitle is-5">Debug: Alle mulige sporgsmal (synlighed)</h5>
<ul>
<li v-for="question in visibleQuestions" :key="question.id" class="is-size-7">
<span class="icon is-small">
<i :class="isQuestionVisible(question) ? 'fas fa-eye has-text-success' : 'fas fa-eye-slash has-text-grey-light'" />
</span>
{{ question.question }}
<b-tooltip v-if="question.condition_id" position="is-top" multilined type="is-dark">
<template #content>
<div v-if="getConditionById(question.condition_id)" class="has-text-left">
<p v-if="getConditionById(question.condition_id).description" class="mb-2">
<i>{{ getConditionById(question.condition_id).description }}</i>
</p>
<p class="is-size-7 has-text-weight-bold mb-1">Regler:</p>
<div
v-for="rule in rules.filter(entry => parseInt(entry.condition_id as any) === parseInt(question.condition_id))"
:key="rule.id"
class="is-size-7"
>
<span class="icon is-small">
<i :class="evaluateRule(rule, new Set([parseInt(question.condition_id)])) ? 'fas fa-check has-text-success' : 'fas fa-times has-text-danger'" />
</span>
<span class="ml-1">[{{ getRuleTypeLabel(rule.type) }}] {{ rule.name }}</span>
</div>
</div>
</template>
<span class="has-text-grey is-clickable">
(Hvis: {{ getConditionById(question.condition_id)?.name || question.condition_id }})
</span>
</b-tooltip>
</li>
</ul>
</div> </div>
<SelfServeQuestionCards <SelfServeQuestionCards
@@ -61,20 +148,3 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.self-serve-questions-status-slot {
align-items: center;
display: flex;
justify-content: center;
min-height: 2.75rem;
}
.self-serve-questions-status-slot .notification {
width: 100%;
}
.self-serve-questions-status-slot .notification.is-invisible {
visibility: hidden;
}
</style>
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { BCheckbox, BField, BIcon } from "buefy"; import { BCheckbox, BField, BIcon } from "buefy";
import { getSelfServeTaskDynamicImagePresentation } from "@/services/selfServeDynamicImage.js";
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
tasks: Array<any>; tasks: Array<any>;
@@ -21,6 +20,16 @@ const isImageAttachment = (attachment: any) => (
&& attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i) && attachment.content.other.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i)
); );
const hasTaskDescription = (task: any) => {
const description = task?.description;
if (typeof description !== "string") {
return !!description;
}
const trimmedDescription = description.trim();
return trimmedDescription.length > 0 && trimmedDescription !== "-";
};
const getImageAttachments = (task: any) => { const getImageAttachments = (task: any) => {
if (!Array.isArray(task?.attachments)) { if (!Array.isArray(task?.attachments)) {
return []; return [];
@@ -65,24 +74,20 @@ const getProgramWheelSelection = (task: any) => {
if (!taskUsesProgramPicker(task)) { if (!taskUsesProgramPicker(task)) {
return null; return null;
} }
const rawValue = task?.dynamic_images_vehicle_type ?? task?.dynamic_image_vehicle_type ?? task?.dynamicImagesVehicleType;
if (rawValue === null || rawValue === undefined || rawValue === "") {
return null;
}
return getSelfServeTaskDynamicImagePresentation(task).thumbPosition; const parsed = Number.parseInt(String(rawValue), 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
}; };
const formatTaskTitle = (task: any) => { const formatTaskTitle = (task: any) => {
const title = String(task?.task || ""); const title = String(task?.task || "");
const programWheelSelection = getProgramWheelSelection(task); const programWheelSelection = getProgramWheelSelection(task);
if (programWheelSelection === null || /\bprogram\s*#\d+\b/i.test(title)) { return programWheelSelection === null ? title : `#${programWheelSelection} ${title}`;
return title;
}
const programTitle = title.match(/^(.*?\bprogram)(\b.*)$/i);
if (programTitle) {
return `${programTitle[1]} #${programWheelSelection}${programTitle[2]}`;
}
return `${title} #${programWheelSelection}`;
}; };
</script> </script>
@@ -117,11 +122,13 @@ const formatTaskTitle = (task: any) => {
type="is-success" type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`" :data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)" @update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/> />
</b-field> </b-field>
</div> </div>
<div class="self-serve-task-image-overlay-text"> <div class="self-serve-task-image-overlay-text">
<p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p> <p class="self-serve-task-title"><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="self-serve-task-description">{{ task.description }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -144,21 +151,22 @@ const formatTaskTitle = (task: any) => {
</div> </div>
</div> </div>
</template> </template>
<div v-else class="self-serve-task-row"> <div v-else class="columns is-mobile is-vcentered">
<div v-if="props.showCheckboxes" class="self-serve-task-checkbox"> <div v-if="props.showCheckboxes" class="column is-narrow">
<b-field> <b-field>
<b-checkbox <b-checkbox
class="mr-0 pr-0"
size="is-large" size="is-large"
:model-value="completedTasks[task.id] === true" :model-value="completedTasks[task.id] === true"
type="is-success" type="is-success"
:data-testid="`self-serve-task-${task.id}-toggle`" :data-testid="`self-serve-task-${task.id}-toggle`"
@update:model-value="emit('toggle-task', task.id, $event)" @update:model-value="emit('toggle-task', task.id, $event)"
@input="emit('toggle-task', task.id, $event)"
/> />
</b-field> </b-field>
</div> </div>
<div class="self-serve-task-content"> <div class="column">
<p><strong>{{ formatTaskTitle(task) }}</strong></p> <p><strong>{{ formatTaskTitle(task) }}</strong></p>
<p v-if="hasTaskDescription(task)" class="is-size-7">{{ task.description }}</p>
<div v-if="getVisibleServices(task).length > 0" class="tags mt-2"> <div v-if="getVisibleServices(task).length > 0" class="tags mt-2">
<span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span> <span v-for="service in getVisibleServices(task)" :key="service" class="tag is-success">{{ service }}</span>
</div> </div>
@@ -226,7 +234,7 @@ const formatTaskTitle = (task: any) => {
padding: 0.65rem 0.75rem; padding: 0.65rem 0.75rem;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.35rem; gap: 0.6rem;
pointer-events: auto; pointer-events: auto;
} }
@@ -234,30 +242,12 @@ const formatTaskTitle = (task: any) => {
margin-bottom: 0; margin-bottom: 0;
} }
.self-serve-task-row {
display: flex;
align-items: center;
gap: 0.35rem;
}
.self-serve-task-checkbox {
flex: 0 0 auto;
}
.self-serve-task-checkbox :deep(.field) {
margin-bottom: 0;
}
.self-serve-task-content {
flex: 1 1 auto;
min-width: 0;
}
.self-serve-task-content p {
margin-bottom: 0;
}
.self-serve-task-title { .self-serve-task-title {
margin: 0; margin: 0;
} }
.self-serve-task-description {
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
</style> </style>
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { ref, watch } from "vue";
import { BIcon } from "buefy"; import { BIcon, BSkeleton } from "buefy";
import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue"; import SelfServeTaskList from "@/components/displays/selfServe/SelfServeTaskList.vue";
const props = defineProps<{ const props = defineProps<{
@@ -19,21 +19,10 @@ const emit = defineEmits<{
}>(); }>();
const isDynamicImageLoading = ref(false); const isDynamicImageLoading = ref(false);
const failedDynamicImageUrl = ref<string | null>(null);
const hasRenderableDynamicImageUrl = computed(
() => !!props.dynamicImageUrl && failedDynamicImageUrl.value !== props.dynamicImageUrl
);
const showDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value && !isDynamicImageLoading.value);
const shouldRenderDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value);
watch( watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
() => props.dynamicImageUrl, isDynamicImageLoading.value = !!dynamicImageUrl;
(dynamicImageUrl) => { }, { immediate: true });
isDynamicImageLoading.value = !!dynamicImageUrl;
failedDynamicImageUrl.value = null;
},
{ immediate: true }
);
const emitToggleTask = (taskId: number, value: boolean) => { const emitToggleTask = (taskId: number, value: boolean) => {
emit("toggle-task", taskId, value); emit("toggle-task", taskId, value);
@@ -49,7 +38,6 @@ const onDynamicImageLoad = () => {
const onDynamicImageError = () => { const onDynamicImageError = () => {
isDynamicImageLoading.value = false; isDynamicImageLoading.value = false;
failedDynamicImageUrl.value = props.dynamicImageUrl;
emit("clear-dynamic-image"); emit("clear-dynamic-image");
}; };
</script> </script>
@@ -70,43 +58,32 @@ const onDynamicImageError = () => {
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span> <span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div> </div>
<img
v-if="dynamicImageUrl && isDynamicImageLoading"
:key="`${dynamicImageUrl}:preload`"
:src="dynamicImageUrl"
alt=""
data-testid="self-serve-dynamic-image-preload"
class="self-serve-dynamic-image-preload"
@load="onDynamicImageLoad"
@error="onDynamicImageError"
/>
<div <div
v-if="shouldRenderDynamicImageFrame" v-if="dynamicImageUrl"
class="self-serve-dynamic-image-frame mb-4" class="self-serve-dynamic-image-frame mb-4"
data-testid="self-serve-dynamic-image-frame" :class="{ 'is-loading': isDynamicImageLoading }"
> >
<div <b-skeleton
v-if="isDynamicImageLoading" v-if="isDynamicImageLoading"
class="self-serve-dynamic-image-skeleton" class="self-serve-dynamic-image-skeleton"
width="100%"
height="100%"
data-testid="self-serve-dynamic-image-skeleton" data-testid="self-serve-dynamic-image-skeleton"
> />
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-large" />
<span class="is-sr-only">{{ $t("self_wash.loading_data") }}</span>
</div>
<img <img
v-if="showDynamicImageFrame"
:key="dynamicImageUrl" :key="dynamicImageUrl"
:src="dynamicImageUrl" :src="dynamicImageUrl"
alt="Machine status" alt="Machine status"
data-testid="self-serve-dynamic-image" data-testid="self-serve-dynamic-image"
class="self-serve-dynamic-image" class="self-serve-dynamic-image"
:class="{ 'is-loading': isDynamicImageLoading }"
@load="onDynamicImageLoad" @load="onDynamicImageLoad"
@error="onDynamicImageError" @error="onDynamicImageError"
/> />
</div> </div>
<div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4"> <div v-show="allVisibleQuestionsAnswered && !editAnswers" class="notification is-info is-light mb-4">
<h1 class="title has-text-centered mb-2">{{ $t("self_wash.start_machine") }}</h1> <h1 class="title has-text-centered mb-2" v-if="activeTasks.length > 0">{{ $t("self_wash.start_machine") }}</h1>
<h1 class="title has-text-centered mb-2" v-else>{{ $t("self_wash.questions_answered") }}</h1>
<SelfServeTaskList <SelfServeTaskList
:tasks="activeTasks" :tasks="activeTasks"
:completedTasks="completedTasks" :completedTasks="completedTasks"
@@ -121,45 +98,36 @@ const onDynamicImageError = () => {
<style scoped> <style scoped>
.self-serve-dynamic-image-frame { .self-serve-dynamic-image-frame {
position: relative; position: relative;
width: 100%;
max-width: 100%; max-width: 100%;
aspect-ratio: 16 / 9;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
overflow: hidden; }
.self-serve-dynamic-image-frame.is-loading {
width: 100%;
max-width: 640px;
min-height: 180px;
aspect-ratio: 16 / 9;
} }
.self-serve-dynamic-image-skeleton { .self-serve-dynamic-image-skeleton {
align-items: center;
aspect-ratio: 16 / 9;
background: #edf2f7;
color: #112f5f;
display: flex;
height: 100%;
justify-content: center;
width: 100%;
}
.self-serve-dynamic-image-preload {
position: absolute; position: absolute;
width: 0; inset: 0;
height: 0; overflow: hidden;
opacity: 0; border-radius: 4px;
pointer-events: none;
}
@media screen and (min-width: 769px) {
.self-serve-dynamic-image-frame {
max-width: 640px;
}
} }
.self-serve-dynamic-image { .self-serve-dynamic-image {
width: 100%; max-width: 100%;
height: 100%; height: auto;
border-radius: 4px; border-radius: 4px;
display: block; display: block;
object-fit: contain; margin-left: auto;
margin-right: auto;
transition: opacity 120ms ease; transition: opacity 120ms ease;
} }
.self-serve-dynamic-image.is-loading {
opacity: 0;
}
</style> </style>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue"; import { ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy"; import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue"; import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
@@ -16,16 +16,12 @@ const props = defineProps<{
availableProductIds: number[]; availableProductIds: number[];
vehicleTypes: Array<any>; vehicleTypes: Array<any>;
vehicleStepError?: string | null; vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(e: "update:customerNumber", value: string): void; (e: "update:customerNumber", value: string): void;
(e: "update:customer-number", value: string): void;
(e: "update:registrationNumber", value: string): void; (e: "update:registrationNumber", value: string): void;
(e: "update:registration-number", value: string): void;
(e: "select-vehicle-type", selection: any): void; (e: "select-vehicle-type", selection: any): void;
(e: "selected", selection: any): void;
}>(); }>();
const extractRegistrationValue = (value: unknown): string => { const extractRegistrationValue = (value: unknown): string => {
@@ -52,19 +48,6 @@ const normalizeRegistration = (value: unknown) => extractRegistrationValue(value
const typedRegistration = ref(normalizeRegistration(props.registrationNumber)); const typedRegistration = ref(normalizeRegistration(props.registrationNumber));
const filteredRegistrationOptions = computed(() => {
const query = normalizeRegistration(typedRegistration.value);
const options = props.registrationOptions || [];
if (!query) {
return options;
}
return options.filter((option) => {
const normalizedOption = normalizeRegistration(option);
return normalizedOption !== query && normalizedOption.includes(query);
});
});
watch(() => props.registrationNumber, (newValue) => { watch(() => props.registrationNumber, (newValue) => {
typedRegistration.value = normalizeRegistration(newValue); typedRegistration.value = normalizeRegistration(newValue);
}); });
@@ -73,18 +56,6 @@ const emitRegistration = (value: unknown) => {
const normalized = normalizeRegistration(value); const normalized = normalizeRegistration(value);
typedRegistration.value = normalized; typedRegistration.value = normalized;
emit("update:registrationNumber", normalized); emit("update:registrationNumber", normalized);
emit("update:registration-number", normalized);
};
const emitCustomerNumber = (value: unknown) => {
const normalized = String(value || "");
emit("update:customerNumber", normalized);
emit("update:customer-number", normalized);
};
const emitVehicleTypeSelection = (selection: any) => {
emit("select-vehicle-type", selection);
emit("selected", selection);
}; };
</script> </script>
@@ -96,41 +67,38 @@ const emitVehicleTypeSelection = (selection: any) => {
:model-value="customerNumber || ''" :model-value="customerNumber || ''"
data-testid="self-serve-customer-number" data-testid="self-serve-customer-number"
:placeholder="$t('self_wash.enter_customer_number')" :placeholder="$t('self_wash.enter_customer_number')"
@input="emitCustomerNumber($event)" @input="emit('update:customerNumber', String($event || ''))"
@update:modelValue="emitCustomerNumber($event)"
/> />
</b-field> </b-field>
<b-field :label="$t('self_wash.registration_number')" class="self-serve-registration-field"> <b-field :label="$t('self_wash.registration_number')">
<div class="self-serve-registration-control"> <b-autocomplete
<b-autocomplete :model-value="typedRegistration"
:model-value="typedRegistration" data-testid="self-serve-registration"
data-testid="self-serve-registration" :data="registrationOptions"
:data="filteredRegistrationOptions" :placeholder="$t('self_wash.enter_registration_number')"
:placeholder="$t('self_wash.enter_registration_number')" :debounce="300"
:debounce="300" :min-length="1"
:min-length="1" :filter="(option, query) => option.toLowerCase().includes(query.toLowerCase())"
icon-pack="fas" icon-pack="fas"
icon="car-side" icon="car-side"
clearable clearable
:loading="filteredRegistrationOptions.length === 0 && isCustomerVehiclesLoading" :loading="registrationOptions.length === 0 && isCustomerVehiclesLoading"
@select="emitRegistration($event)" :selectable-header="true"
@typing="emitRegistration($event)" @select="emitRegistration($event)"
@update:modelValue="emitRegistration($event)" @typing="emitRegistration($event)"
> @update:modelValue="emitRegistration($event)"
</b-autocomplete> @select-header="emitRegistration(typedRegistration)"
<p >
v-if="typedRegistration" <template #header>
class="help self-serve-registration-guidance"
data-testid="self-serve-registration-guidance"
>
<template v-if="hasMatchingVehicle"> <template v-if="hasMatchingVehicle">
{{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }} {{ $t("self_wash.select_from_vehicles", { plate: typedRegistration }) }}
</template> </template>
<template v-else> <template v-else>
{{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }} {{ $t("self_wash.add_as_new_vehicle", { plate: typedRegistration }) }}
</template> </template>
</p> </template>
</div> <template #empty>{{ $t("self_wash.no_vehicles_found") }}</template>
</b-autocomplete>
</b-field> </b-field>
<b-message <b-message
v-if="props.vehicleStepError" v-if="props.vehicleStepError"
@@ -141,21 +109,12 @@ const emitVehicleTypeSelection = (selection: any) => {
> >
{{ props.vehicleStepError }} {{ props.vehicleStepError }}
</b-message> </b-message>
<b-message
v-else-if="props.vehicleStepGuidance"
type="is-info"
has-icon
:closable="false"
data-testid="self-serve-vehicle-step-guidance"
>
{{ props.vehicleStepGuidance }}
</b-message>
<b-field :label="$t('self_wash.select_your_vehicle')"> <b-field :label="$t('self_wash.select_your_vehicle')">
<template v-if="availableProductIds.length > 0"> <template v-if="availableProductIds.length > 0">
<SelfServeVehicleTypeSelector <SelfServeVehicleTypeSelector
:selectedVehicleTypeId="selectedVehicleTypeId" :selectedVehicleTypeId="selectedVehicleTypeId"
:restrictToProductIds="availableProductIds" :restrictToProductIds="availableProductIds"
@selected="emitVehicleTypeSelection" @selected="emit('select-vehicle-type', $event)"
/> />
</template> </template>
<template v-else-if="vehicleTypes.length === 0"> <template v-else-if="vehicleTypes.length === 0">
@@ -171,34 +130,3 @@ const emitVehicleTypeSelection = (selection: any) => {
</b-field> </b-field>
</div> </div>
</template> </template>
<style scoped>
.self-serve-registration-control {
display: block;
width: 100%;
min-width: 0;
}
.self-serve-registration-guidance {
display: block;
margin-top: 0.5rem;
line-height: 1.35;
white-space: normal;
}
.self-serve-registration-field :deep(.dropdown),
.self-serve-registration-field :deep(.autocomplete) {
width: 100%;
}
.self-serve-registration-field :deep(.dropdown-menu) {
left: 0;
right: 0;
width: 100%;
max-width: 100%;
}
.self-serve-registration-field :deep(.dropdown-item) {
white-space: normal;
}
</style>
@@ -29,7 +29,7 @@ const getLoadingVehicleTypes = (count: number): VehicleTypeTemplate[] => {
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
loadingTypes.push({ loadingTypes.push({
id: index, id: index,
name: "Indlæser...", name: "Indlaeser...",
price: 0, price: 0,
loading: true, loading: true,
}); });
@@ -146,7 +146,7 @@ watch(() => props.selectedVehicleTypeId, (newId) => {
</template> </template>
</template> </template>
<template v-else> <template v-else>
Vælg en køretøjstype ved at trykke et af ikonerne ovenfor. Vaelg venligst din koretojstype ved at klikke pa ikonet ovenfor.
</template> </template>
</p> </p>
</div> </div>
@@ -1,81 +0,0 @@
<!--
VehicleInputSection.vue customer number, license plate, and vehicle type inputs.
Props:
showCustomerNumberInput whether to render the customer number field
customerNumber current customer number string
registrationNumber current license plate string
registrationOptions autocomplete options for existing vehicles
selectedVehicleTypeId currently selected vehicle type id
selectedVehicleName human-readable name of selected vehicle type
availableProductIds allowed product ids for the department
vehicleTypes list of vehicle type options
Emit:
update:customerNumber new customer number string
update:registrationNumber new license plate string
selectVehicleType selected VehicleTypeTemplate object
-->
<script setup lang="ts">
import { computed } from "vue";
import SelfServeVehicleStep from "@/components/displays/selfServe/SelfServeVehicleStep.vue";
interface Props {
showCustomerNumberInput: boolean;
customerNumber: string | null;
registrationNumber: string | null;
registrationOptions: string[];
selectedVehicleTypeId: number | null;
selectedVehicleName: string | null;
availableProductIds: (number | string)[];
vehicleTypes: any[];
}
defineProps<Props>();
const emit = defineEmits<{
"update:customerNumber": [value: string];
"update:registrationNumber": [value: string];
selectVehicleType: [type: { id: number }];
}>();
function handleCustomerNumberUpdate(value: string) {
emit("update:customerNumber", value);
}
function handleRegistrationNumberUpdate(value: string) {
emit("update:registrationNumber", value);
}
function handleSelectVehicleType(selection: any) {
emit("selectVehicleType", selection);
}
</script>
<template>
<SelfServeVehicleStep
:customer-number="customerNumber"
:show-customer-number-input="showCustomerNumberInput"
:registration-number="registrationNumber"
:registration-options="registrationOptions"
:is-customer-vehicles-loading="false"
:has-matching-vehicle="false"
:selected-vehicle-type-id="selectedVehicleTypeId"
:selected-vehicle-name="selectedVehicleName"
:selected-vehicle-description="null"
:available-product-ids="availableProductIds"
:vehicle-types="vehicleTypes"
:vehicle-step-error="null"
:vehicle-step-guidance="null"
@update:customer-number="handleCustomerNumberUpdate"
@update:customerNumber="handleCustomerNumberUpdate"
@update:registration-number="handleRegistrationNumberUpdate"
@update:registrationNumber="handleRegistrationNumberUpdate"
@select-vehicle-type="handleSelectVehicleType"
@selected="handleSelectVehicleType"
/>
</template>
<style scoped>
/* Vehicle input section styling is inherited from SelfServeVehicleStep */
</style>
@@ -1,260 +0,0 @@
<!--
WashProgressCard.vue timer, guided instructions, and bottom actions during active wash.
Renders when a wash session is in progress (WASH_IN_PROGRESS step).
Props:
currentGuidedWashStep index into guidedWashFlowSteps
guidedWashFlowSteps array of instruction step objects
formattedElapsed human-readable elapsed time string
isCompletingWash whether the wash is finishing right now
openingPropertyAccessGate loading state for access gate button
openingPropertyExitGate loading state for exit gate button
Emit:
update:currentGuidedWashStep new step index
goPreviousGuidedWashStep navigate to previous instruction
goNextGuidedWashStep navigate to next instruction
completeWash finish the wash session
openPropertyAccessGate open the property access gate (laneId)
openPropertyExitGate open the property exit gate (laneId)
requestAssistance call for assistance
-->
<script setup lang="ts">
import { computed } from "vue";
import { BButton } from "buefy";
interface Props {
currentGuidedWashStep: number;
guidedWashFlowSteps: any[];
formattedElapsed: string;
isCompletingWash: boolean;
openingPropertyAccessGate: boolean;
openingPropertyExitGate: boolean;
showProgressActions?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showProgressActions: true,
});
const emit = defineEmits<{
"update:currentGuidedWashStep": [step: number];
goPreviousGuidedWashStep: [];
goNextGuidedWashStep: [];
completeWash: [];
openPropertyAccessGate: [];
openPropertyExitGate: [];
requestAssistance: [];
}>();
const isLastGuidedWashStep = computed(
() =>
Array.isArray(props.guidedWashFlowSteps) &&
props.guidedWashFlowSteps.length > 0 &&
props.currentGuidedWashStep >= props.guidedWashFlowSteps.length - 1
);
function handleGoPrevious() {
emit("goPreviousGuidedWashStep");
}
function handleGoNext() {
emit("goNextGuidedWashStep");
}
function handleComplete() {
emit("completeWash");
}
function handleOpenAccessGate() {
emit("openPropertyAccessGate");
}
function handleOpenExitGate() {
emit("openPropertyExitGate");
}
function handleRequestAssistance() {
emit("requestAssistance");
}
</script>
<template>
<div data-testid="self-serve-wash-progress">
<!-- Finishing screen -->
<div
v-if="isCompletingWash && showProgressActions"
class="notification is-info is-light self-serve-finishing-screen"
data-testid="self-serve-finishing-wash"
>
<p class="title is-5 mb-0">{{ $t("self_wash.finishing_wash_exit_opening") }}</p>
</div>
<!-- Guided wash instructions (stepped walkthrough) -->
<template v-if="!isCompletingWash">
<!--
Accept a callback slot for the guided-instructions component so the
parent can plug in SelfServeGuidedInstructions or its own implementation.
-->
<slot
v-if="showProgressActions"
name="guided-instructions"
:current-step="currentGuidedWashStep"
:steps="guidedWashFlowSteps"
:is-last-step="isLastGuidedWashStep"
@update:current-step="emit('update:currentGuidedWashStep', $event)"
>
<!-- Default: render nothing (parent provides via slot) -->
</slot>
<!-- Bottom action bar while wash is in progress -->
<div
v-show="showProgressActions"
class="self-serve-bottom-actions"
data-testid="self-serve-bottom-actions"
>
<!-- Help / assistance -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-session-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-right="info-circle"
data-testid="self-serve-nav-help"
@click.prevent="handleRequestAssistance"
>
{{ $t("self_wash.assistance") }}
</b-button>
</div>
<!-- Property gate controls -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-property-gate-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-in-alt"
data-testid="self-serve-nav-open-property-access-gate"
:loading="openingPropertyAccessGate"
:disabled="openingPropertyAccessGate"
@click.prevent="handleOpenAccessGate"
>
{{ $t("self_wash.open_property_access_gate") }}
</b-button>
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="sign-out-alt"
data-testid="self-serve-nav-open-property-exit-gate"
:loading="openingPropertyExitGate"
:disabled="openingPropertyExitGate"
@click.prevent="handleOpenExitGate"
>
{{ $t("self_wash.open_property_exit_gate") }}
</b-button>
</div>
<!-- Guided step navigation -->
<div class="self-serve-bottom-actions__row" data-testid="self-serve-guided-bottom-actions">
<b-button
class="self-serve-bottom-actions__button"
type="is-link is-light"
icon-pack="fas"
icon-left="arrow-left"
data-testid="self-serve-guided-prev"
:disabled="currentGuidedWashStep === 0"
@click.prevent="handleGoPrevious"
>
{{ $t("common.previous") }}
</b-button>
<b-button
v-if="!isLastGuidedWashStep"
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="arrow-right"
data-testid="self-serve-guided-next"
@click.prevent="handleGoNext"
>
{{ $t("common.next") }}
</b-button>
<b-button
v-else
class="self-serve-bottom-actions__button"
type="is-link"
icon-pack="fas"
icon-right="check"
data-testid="self-serve-nav-complete"
:loading="isCompletingWash"
:disabled="isCompletingWash"
@click.prevent="handleComplete"
>
{{ $t("common.done") }}
</b-button>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.self-serve-finishing-screen {
margin: 0 auto;
max-width: 32rem;
text-align: left;
}
.self-serve-bottom-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
justify-content: center;
margin: 0.75rem auto 0;
max-width: 30rem;
}
.self-serve-bottom-actions__row {
display: flex;
gap: 0.75rem;
justify-content: center;
width: 100%;
}
.self-serve-bottom-actions__button {
flex: 1 1 0;
font-weight: 700;
line-height: 1.15;
max-width: 14rem;
min-height: 2.75rem;
min-width: 0;
white-space: normal;
}
@media screen and (max-width: 768px) {
.self-serve-bottom-actions {
background: #ffffff;
border-top: 1px solid #dfe5f0;
bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -0.25rem 0.75rem rgba(17, 47, 95, 0.08);
left: 0;
margin: 0;
max-width: none;
padding: 0.5rem 0.75rem;
position: fixed;
right: 0;
z-index: 41;
}
.self-serve-bottom-actions__button {
font-size: 0.88rem;
max-width: none;
min-height: 2.65rem;
padding-left: 0.55rem;
padding-right: 0.55rem;
}
}
</style>
@@ -1,159 +0,0 @@
<!--
WashTypeSelector.vue manual vs machine wash toggle.
Renders two radio-style cards: "Manual" (always available) and
"Machine" (available when the lane supports it).
Props:
modelValue current wash type ("Manual" | "Machine")
isMachineAvailable whether machine wash is available for this lane
Emit:
update:modelValue new wash type string
-->
<script setup lang="ts">
import { BRadioButton } from "buefy";
interface Props {
modelValue: string;
isMachineAvailable: boolean;
}
defineProps<Props>();
const emit = defineEmits<{
"update:modelValue": [type: string];
}>();
</script>
<template>
<section class="self-serve-choice-group" data-testid="self-serve-wash-type-group">
<h2 class="self-serve-choice-label">{{ $t("self_wash.wash_type") }}</h2>
<div
class="self-serve-choice-grid self-serve-choice-grid--centered"
data-testid="self-serve-wash-type-options"
>
<!-- Manual option (always available) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Manual"
type="is-link"
data-testid="self-serve-wash-type-manual"
@update:model-value="emit('update:modelValue', 'Manual')"
@input="emit('update:modelValue', 'Manual')"
>
<span>
<span>{{ $t("self_wash.manual") }}<br /></span>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</b-radio-button>
</div>
<!-- Machine option (conditioned) -->
<div class="self-serve-choice-grid__item">
<b-radio-button
class="self-serve-choice-card"
:model-value="modelValue"
native-value="Machine"
type="is-link"
:disabled="!isMachineAvailable"
data-testid="self-serve-wash-type-machine"
@update:model-value="emit('update:modelValue', 'Machine')"
@input="emit('update:modelValue', 'Machine')"
>
<span>
<span>{{ $t("self_wash.machine") }}<br /></span>
<span v-if="!isMachineAvailable">
<small>
<b-icon icon="times-circle" type="is-danger" pack="fas" class="mr-1" />
{{ $t("self_wash.unavailable") }}
</small>
</span>
<span v-else>
<small>
<b-icon icon="check-circle" type="is-success" pack="fas" class="mr-1" />
{{ $t("self_wash.available") }}
</small>
</span>
</span>
</b-radio-button>
</div>
</div>
</section>
</template>
<style scoped>
.self-serve-choice-group {
margin-bottom: 1.25rem;
}
.self-serve-choice-label {
color: #303440;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.2;
margin: 0 0 0.75rem;
}
.self-serve-choice-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 0.75rem;
width: 100%;
}
.self-serve-choice-grid--centered {
justify-content: center;
}
.self-serve-choice-grid__item {
min-width: 0;
}
.self-serve-choice-card {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button) {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
min-height: 5rem;
padding: 0.75rem 0.5rem;
white-space: normal;
width: 100%;
}
.self-serve-choice-card :deep(.button),
.self-serve-choice-card span,
.self-serve-choice-card :deep(.button span) {
min-width: 0;
}
.self-serve-choice-card small,
.self-serve-choice-card :deep(.button small) {
display: inline-flex;
align-items: center;
line-height: 1.25;
}
@media screen and (min-width: 769px) {
.self-serve-choice-grid {
grid-template-columns: repeat(auto-fit, minmax(11rem, 15rem));
}
}
</style>
@@ -129,19 +129,6 @@ const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePe
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0); return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
})); }));
const getOrderItemFlagIconClass = (orderItem) => (
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
);
const getOrderItemFlagColorClass = (orderItem) => {
const flags = getOrderItemFlags(orderItem);
if (flags.length === 0) {
return "";
}
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
};
const displayColumnCount = computed(() => ( const displayColumnCount = computed(() => (
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0) 2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
)); ));
@@ -175,17 +162,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
<template v-if="!isOrderItemAddon(item)"> <template v-if="!isOrderItemAddon(item)">
<!-- If the item is included in the invoice, show it normally --> <!-- If the item is included in the invoice, show it normally -->
<tr v-if="item.include_in_invoice"> <tr v-if="item.include_in_invoice">
<td> <td>{{ item.product.name }}</td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
{{ item.product.name }}
</td>
<td v-if="props.displayReference">{{ item.reference }}</td> <td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td> <td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
@@ -193,17 +170,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
</tr> </tr>
<!-- If the item is not included in the invoice, show it with a strikethrough --> <!-- If the item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning-light"> <tr v-else class="has-background-warning-light">
<td> <td><span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ item.reference }}</td> <td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td> <td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity }}</td>
@@ -226,17 +193,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id"> <template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
<!-- If the add-on item is included in the invoice, show it normally --> <!-- If the add-on item is included in the invoice, show it normally -->
<tr v-if="addon_item.include_in_invoice"> <tr v-if="addon_item.include_in_invoice">
<td> <td>+ {{ addon_item.product.name }}</td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }}
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td> <td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td> <td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td> <td>{{ addon_item.quantity }}</td>
@@ -244,17 +201,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
</tr> </tr>
<!-- If the add-on item is not included in the invoice, show it with a strikethrough --> <!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning"> <tr v-else class="has-background-warning">
<td> <td>+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )</td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td> <td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td> <td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td> <td>{{ addon_item.quantity }}</td>
@@ -290,14 +237,6 @@ watch(() => props.orderId, (newValue, oldValue) => {
<!-- If the item is a primary item, show it --> <!-- If the item is a primary item, show it -->
<template v-if="!isOrderItemAddon(item)"> <template v-if="!isOrderItemAddon(item)">
<div> <div>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<strong>{{ item.product.name }}</strong> x {{ item.quantity }} <strong>{{ item.product.name }}</strong> x {{ item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span> <span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
</div> </div>
@@ -309,14 +248,6 @@ watch(() => props.orderId, (newValue, oldValue) => {
<!-- Show the add-on items --> <!-- Show the add-on items -->
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4"> <div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
<small> <small>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }} x {{ addon_item.quantity }} + {{ addon_item.product.name }} x {{ addon_item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span> <span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
</small> </small>
@@ -334,7 +265,5 @@ watch(() => props.orderId, (newValue, oldValue) => {
</template> </template>
<style scoped> <style scoped>
.invoice-period-item-flag-indicator {
vertical-align: middle;
}
</style> </style>
@@ -2,31 +2,21 @@
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue"; import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
const props = defineProps({ const props = defineProps({
objects: { objects: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
showCustomer: {
type: Boolean,
default: false,
},
}); });
const { loadList } = usePaginatedListInstance(); const { loadList } = usePaginatedListInstance();
const canEditPermissions = () => const canEditPermissions = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT");
props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"); const canDisableAccess = () => SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_DELETE");
const canDisableAccess = () =>
props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_DELETE");
const canResendInvite = (subuser) => const canResendInvite = (subuser) =>
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT")) (SessionUser.canAccessUser() || SessionUser.hasPermission("SUBUSERS_EDIT"))
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required); && Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
const hasRowActions = (subuser) =>
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
const formatDateTime = (dateString) => { const formatDateTime = (dateString) => {
if (!dateString) { if (!dateString) {
@@ -60,16 +50,6 @@ const formatEmail = (subuser) => {
return subuser?.setup_required ? "E-mail oplyses ved accept" : "-"; return subuser?.setup_required ? "E-mail oplyses ved accept" : "-";
}; };
const formatCustomer = (subuser) => {
if (!props.showCustomer) {
return "";
}
const number = subuser?.customer_number ?? "-";
const name = subuser?.customer_name || "Ukendt kunde";
return `${number} - ${name}`;
};
const permissionSummary = (subuser) => const permissionSummary = (subuser) =>
SessionUser.objects.subusers.functions.permissionSummary(subuser?.grant_permissions || []); SessionUser.objects.subusers.functions.permissionSummary(subuser?.grant_permissions || []);
@@ -181,7 +161,7 @@ const onToggleEnabled = async (subuser, enabled) => {
}; };
const onResendInvite = async (subuser) => { const onResendInvite = async (subuser) => {
await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList, { superuser: props.showCustomer }); await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList);
}; };
</script> </script>
@@ -191,7 +171,6 @@ const onResendInvite = async (subuser) => {
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th v-if="showCustomer">Kunde</th>
<th>Chauffør</th> <th>Chauffør</th>
<th>Kontakt</th> <th>Kontakt</th>
<th>Adgang</th> <th>Adgang</th>
@@ -204,17 +183,12 @@ const onResendInvite = async (subuser) => {
</thead> </thead>
<tbody> <tbody>
<tr v-if="props.objects.length === 0"> <tr v-if="props.objects.length === 0">
<td :colspan="showCustomer ? 10 : 9" class="has-text-centered has-text-grey py-6">Ingen chauffører fundet.</td> <td colspan="9" class="has-text-centered has-text-grey py-6">Ingen chauffører fundet.</td>
</tr> </tr>
<tr v-for="subuser in props.objects" :key="`${subuser.id}-${subuser.grant_id || 'none'}`"> <tr v-for="subuser in props.objects" :key="subuser.id">
<td>{{ subuser.id }}</td> <td>{{ subuser.id }}</td>
<td v-if="showCustomer">
<div class="has-text-weight-semibold">{{ formatCustomer(subuser) }}</div>
<div class="is-size-7 has-text-grey">Grant #{{ subuser.grant_id }}</div>
</td>
<td> <td>
<div class="has-text-weight-semibold">{{ subuser.name || "-" }}</div> <div class="has-text-weight-semibold">{{ subuser.name || "-" }}</div>
<div class="is-size-7 has-text-grey" :data-testid="`subuser-username-${subuser.id}`"> <div class="is-size-7 has-text-grey" :data-testid="`subuser-username-${subuser.id}`">
@@ -245,49 +219,51 @@ const onResendInvite = async (subuser) => {
<td> <td>
<div>{{ subuser.grant_note || "-" }}</div> <div>{{ subuser.grant_note || "-" }}</div>
<button
v-if="canEditPermissions() && subuser.grant_id"
class="button is-text is-small px-0 mt-1"
type="button"
@click="onEditNote(subuser)"
>
Redigér note
</button>
</td> </td>
<td>{{ formatDateTime(subuser.created_at) }}</td> <td>{{ formatDateTime(subuser.created_at) }}</td>
<td>{{ formatDateTime(subuser.updated_at) }}</td> <td>{{ formatDateTime(subuser.updated_at) }}</td>
<td> <td>
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`"> <div class="buttons is-justify-content-flex-end action-buttons">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)"> <button
<template #actions> v-if="canEditPermissions() && subuser.grant_id"
<ActionSettingsWheelItem class="button is-small"
v-if="canEditPermissions() && subuser.grant_id" type="button"
icon="fas fa-pen" :data-testid="`subuser-permissions-${subuser.id}`"
label="Redigér note" @click="onEditPermissions(subuser)"
:click-action="() => onEditNote(subuser)" >
:test-id="`subuser-note-${subuser.id}`" Tilladelser
/> </button>
<ActionSettingsWheelItem <button
v-if="canEditPermissions() && subuser.grant_id" v-if="canResendInvite(subuser)"
icon="fas fa-user-shield" class="button is-small"
label="Tilladelser" type="button"
:click-action="() => onEditPermissions(subuser)" :data-testid="`subuser-resend-${subuser.id}`"
:test-id="`subuser-permissions-${subuser.id}`" @click="onResendInvite(subuser)"
/> >
Gensend
</button>
<ActionSettingsWheelItem <button
v-if="canResendInvite(subuser)" v-if="canDisableAccess() && subuser.grant_id"
icon="fas fa-paper-plane" class="button is-small"
label="Gensend" :class="subuser.grant_enabled ? 'is-danger is-light' : 'is-success is-light'"
:click-action="() => onResendInvite(subuser)" type="button"
:test-id="`subuser-resend-${subuser.id}`" :data-testid="`subuser-toggle-${subuser.id}`"
/> @click="onToggleEnabled(subuser, !subuser.grant_enabled)"
>
<ActionSettingsWheelItem {{ subuser.grant_enabled ? "Deaktivér" : "Aktivér" }}
v-if="canDisableAccess() && subuser.grant_id" </button>
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
:template="subuser.grant_enabled ? 'danger' : 'success'"
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
:test-id="`subuser-toggle-${subuser.id}`"
/>
</template>
</ActionSettingsWheelButton>
</div> </div>
</td> </td>
</tr> </tr>
@@ -1,6 +1,4 @@
<script setup> <script setup>
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue"; import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { getDepartmentDailyReportComplaintCategoryLabel } from "@/services/departmentDailyReportComplaintCategories.js"; import { getDepartmentDailyReportComplaintCategoryLabel } from "@/services/departmentDailyReportComplaintCategories.js";
@@ -94,24 +92,29 @@ const formatCategory = (value) => (
<td class="complaint-description">{{ complaint.description }}</td> <td class="complaint-description">{{ complaint.description }}</td>
<td>{{ formatCreatedBy(complaint) }}</td> <td>{{ formatCreatedBy(complaint) }}</td>
<td class="has-text-right"> <td class="has-text-right">
<div class="buttons is-right is-justify-content-flex-end" :data-testid="`superuser-complaint-actions-${complaint.id}`"> <div class="buttons is-right is-justify-content-flex-end">
<ActionSettingsWheelButton> <button
<template #actions> class="button is-small is-dark"
<ActionSettingsWheelItem type="button"
icon="fas fa-pen" :data-testid="`superuser-complaint-edit-${complaint.id}`"
:label="$t('global.edit')" @click="SessionUser.objects.department_daily_report_complaints.functions.showEditForm(complaint, loadList)"
:test-id="`superuser-complaint-edit-${complaint.id}`" >
:click-action="() => SessionUser.objects.department_daily_report_complaints.functions.showEditForm(complaint, loadList)" <span class="icon is-small">
/> <i class="fas fa-pen"></i>
<ActionSettingsWheelItem </span>
icon="fas fa-trash" <span>{{ $t('global.edit') }}</span>
:label="$t('global.delete')" </button>
template="danger" <button
:test-id="`superuser-complaint-delete-${complaint.id}`" class="button is-small is-danger"
:click-action="() => SessionUser.objects.department_daily_report_complaints.functions.showDeleteConfirmationModal(complaint.id, loadList)" type="button"
/> :data-testid="`superuser-complaint-delete-${complaint.id}`"
</template> @click="SessionUser.objects.department_daily_report_complaints.functions.showDeleteConfirmationModal(complaint.id, loadList)"
</ActionSettingsWheelButton> >
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>
<span>{{ $t('global.delete') }}</span>
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -102,16 +102,26 @@ const parseBalance = (user) => {
{{ parseBalance(user) }}</td> {{ parseBalance(user) }}</td>
<td> <td>
<div class="buttons is-float-right"> <div class="buttons is-float-right">
<ActionSettingsWheelButton icon="fas fa-exclamation-triangle"> <!-- Disabled customer actions -->
<template #actions> <div class="dropdown is-right is-hoverable">
<ActionSettingsWheelItem <div class="dropdown-trigger">
label="Kundekonto er lukket i E-conomic" <button class="button is-small is-danger is-inverted" aria-haspopup="true" aria-controls="dropdown-menu" @click="showCustomerBarred">
icon="fas fa-exclamation-triangle" <span class="icon">
template="warning" <i class="fas fa-exclamation-triangle"></i>
:click-action="showCustomerBarred" </span>
/> </button>
</template> </div>
</ActionSettingsWheelButton> <div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
<a class="dropdown-item has-text-warning" @click="showCustomerBarred">
<span class="icon">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span class="ml-1">Kundekonto er lukket i E-conomic</span>
</a>
</div>
</div>
</div>
</div> </div>
</td> </td>
</tr> </tr>
@@ -133,4 +143,4 @@ const parseBalance = (user) => {
width: 1%; width: 1%;
white-space: nowrap; white-space: nowrap;
} }
</style> </style>
@@ -1,17 +1,15 @@
<script setup> <script setup>
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { showEditDepartmentForm } from "@/components/forms/superUser/editDepartmentForm.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps(["objects"]); const props = defineProps(["objects"]);
const { t } = useI18n(); import { ref } from "vue";
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = usePaginatedListInstance(); const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = usePaginatedListInstance();
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
// Get the departments (If the departments are not already loaded)
if (departments.value.length === 0) {
getDepartments();
}
const draggingIndex = ref(null); const draggingIndex = ref(null);
const dragOverIndex = ref(null); const dragOverIndex = ref(null);
@@ -70,6 +68,18 @@ const onDrop = async (event, newIndex) => {
} }
}; };
const parseCustomerName = (user) => {
if (user.customer_name) {
return user.customer_name;
} else {
return "-";
}
};
import { showEditDepartmentForm } from "@/components/forms/superUser/editDepartmentForm.vue";
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const redirect = (path) => { const redirect = (path) => {
window.location = path; window.location = path;
}; };
@@ -126,7 +136,7 @@ const toggleArchived = async (department) => {
<th>{{ SessionUser.objects.departments.columns.archived.label }}</th> <th>{{ SessionUser.objects.departments.columns.archived.label }}</th>
<th>{{ SessionUser.objects.departments.columns.latitude.label }}</th> <th>{{ SessionUser.objects.departments.columns.latitude.label }}</th>
<th>{{ SessionUser.objects.departments.columns.longitude.label }}</th> <th>{{ SessionUser.objects.departments.columns.longitude.label }}</th>
<th class="has-text-right">{{ $t("tables.actions") }}</th> <th>{{ $t("tables.actions") }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -202,41 +212,34 @@ const toggleArchived = async (department) => {
column="longitude" column="longitude"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm" :edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
/> />
<td class="has-text-right"> <td>
<div <div class="buttons">
class="buttons is-right is-justify-content-flex-end" <button
:data-testid="`superuser-department-actions-${department.id}`" class="button is-small"
> @click="
<ActionSettingsWheelButton> showEditDepartmentForm(
<template #actions> department.id,
<ActionSettingsWheelItem department.name,
icon="fas fa-edit" department.description,
:label="t('global.edit')" department.economic_department_id
:test-id="`superuser-department-edit-${department.id}`" )
:click-action=" "
() => >
showEditDepartmentForm( <span class="icon">
department.id, <i class="fas fa-edit"></i>
department.name, </span>
department.description, </button>
department.economic_department_id <button class="button is-small" @click="redirect('/admin/' + department.id)">
) <!-- External link icon -->
" <span class="icon">
/> <i class="fas fa-external-link-alt"></i>
<ActionSettingsWheelItem </span>
icon="fas fa-external-link-alt" </button>
:label="t('global.open')" <button class="button is-small is-dark" @click="redirect('/superuser/departments/' + department.id)">
:test-id="`superuser-department-open-${department.id}`" <span class="icon">
:click-action="() => redirect('/admin/' + department.id)" <i class="fas fa-cog"></i>
/> </span>
<ActionSettingsWheelItem </button>
icon="fas fa-cog"
:label="t('global.settings')"
:test-id="`superuser-department-settings-${department.id}`"
:click-action="() => redirect('/superuser/departments/' + department.id)"
/>
</template>
</ActionSettingsWheelButton>
</div> </div>
</td> </td>
</tr> </tr>
@@ -1,32 +1,44 @@
<script setup> <script setup>
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue"; import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue"; import { useI18n } from 'vue-i18n';
const { t } = useI18n();
defineProps(['objects']);
import { departments, getDepartments, isLoading, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
import Swal from "sweetalert2";
// Get the departments (If the departments are not already loaded)
if (departments.value.length === 0) {
getDepartments();
}
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue"; import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
const redirect = (path) => {
window.location = path;
}
defineProps({
objects: {
type: Array,
default: () => [],
},
});
const editUser = (user) => {
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id);
};
</script> </script>
<template> <template>
<table class="table is-fullwidth" data-testid="superuser-users-table"> <table class="table is-fullwidth">
<thead> <thead>
<tr> <tr>
<th>{{ $t("objects.columns.id") }}</th> <th>{{ $t('objects.columns.id') }}</th>
<th>{{ $t("tables.users.name") }}</th> <th>{{ $t('tables.users.name') }}</th>
<th>{{ $t("tables.users.role") }}</th> <th>{{ $t('tables.users.role') }}</th>
<th class="has-text-right">{{ $t("tables.actions") }}</th> <th class="has-text-right">{{ $t('tables.actions') }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="user in objects" :key="user.id" :data-testid="`superuser-users-row-${user.id}`"> <tr v-for="user in objects" :key="user.id">
<td>{{ user.id }}</td> <td>{{ user.id }}</td>
<td>{{ user.display_name }}</td> <td>{{ user.display_name }}</td>
<td>{{ user.group_id }}</td> <td>{{ user.group_id }}</td>
@@ -34,27 +46,25 @@ const editUser = (user) => {
<div class="buttons is-float-right"> <div class="buttons is-float-right">
<!-- Settings wheel --> <!-- Settings wheel -->
<ActionSettingsWheelButton <ActionSettingsWheelButton
:customer_number="user.customer_number" :customer_number="user.customer_number"
:user_id="user.id" :user_id="user.id"
:data-testid="`superuser-user-actions-${user.id}`"
> >
<template #actions> <template #actions>
<ActionSettingsWheelItem <ActionSettingsWheelItem
:click-action="() => editUser(user)" @click="showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id)"
icon="fas fa-user-edit" icon="fas fa-user-edit"
:label="$t('global.edit')" :label="$t('global.edit')"
:test-id="`superuser-user-edit-${user.id}`"
/> />
</template> </template>
</ActionSettingsWheelButton> </ActionSettingsWheelButton>
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="objects.length === 0"> </tbody>
<td colspan="4">{{ $t("global.no_data") }}</td> </table>
</tr>
</tbody>
</table>
</template> </template>
<style scoped></style> <style scoped>
</style>
@@ -1,10 +1,10 @@
<script setup> <script setup>
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue"; import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { ref } from "vue"; import { ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue"; import { departments, getDepartments} from "@/components/pagination/departmentTabs.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { loadList } from "@/components/pagination/paginatedList.vue"; import { loadList } from "@/components/pagination/paginatedList.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue"; import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
@@ -27,15 +27,16 @@ const props = defineProps({
reloadList: { reloadList: {
type: Function, type: Function,
required: false, required: false,
default: null, default: null
}, },
add_other_customer_id: { add_other_customer_id: {
type: Number, type: Number,
required: false, required: false,
default: null, default: null
}, }
}); });
const reload = () => { const reload = () => {
// Load the list of vehicles // Load the list of vehicles
if (props.reloadList) { if (props.reloadList) {
@@ -58,9 +59,13 @@ if (departments.value.length === 0) {
const onClickListAddons = (vehicleId) => { const onClickListAddons = (vehicleId) => {
// Redirect to the product addons page // Redirect to the product addons page
console.log("Fetching product addons for vehicle: " + vehicleId); console.log("Fetching product addons for vehicle: " + vehicleId);
SessionUser.request("/vehicles/addons/available", "GET", { SessionUser.request(
id: vehicleId, '/vehicles/addons/available',
}); 'GET',
{
id: vehicleId
},
)
}; };
const vehicleAddons = ref(null); const vehicleAddons = ref(null);
@@ -71,9 +76,13 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
// Get the vehicle addons from the server // Get the vehicle addons from the server
if (vehicleAddons.value === null) { if (vehicleAddons.value === null) {
vehicleAddons.value = []; vehicleAddons.value = [];
SessionUser.request("/vehicles/addons/available", "GET", { SessionUser.request(
id: vehicleId, '/vehicles/addons/available',
}).then((response) => { 'GET',
{
id: vehicleId
},
).then((response) => {
console.log("Vehicle addons: ", response.data.data); console.log("Vehicle addons: ", response.data.data);
vehicleAddons.value = response.data.data; vehicleAddons.value = response.data.data;
}); });
@@ -84,10 +93,14 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
const toggleVehicleAddon = (vehicleId, addonId) => { const toggleVehicleAddon = (vehicleId, addonId) => {
// Toggle the vehicle addon // Toggle the vehicle addon
SessionUser.request("/vehicles/addons/toggle", "POST", { SessionUser.request(
vehicle_id: vehicleId, '/vehicles/addons/toggle',
addon_id: addonId, 'POST',
}).then(() => { {
vehicle_id: vehicleId,
addon_id: addonId
},
).then(() => {
reload(); reload();
}); });
}; };
@@ -95,9 +108,9 @@ const toggleVehicleAddon = (vehicleId, addonId) => {
const getVehicleAddonToggleIcon = (vehicleAddon) => { const getVehicleAddonToggleIcon = (vehicleAddon) => {
// Check if the vehicle addon is applied // Check if the vehicle addon is applied
if (isVehicleAddonApplied(vehicleAddon)) { if (isVehicleAddonApplied(vehicleAddon)) {
return "fas fa-minus"; return 'fas fa-minus';
} else { } else {
return "fas fa-plus"; return 'fas fa-plus';
} }
}; };
@@ -124,142 +137,140 @@ const getProductOptionsLabel = (vehicle) => {
<div class="table-container" data-testid="user-vehicles-table-container"> <div class="table-container" data-testid="user-vehicles-table-container">
<table class="table is-fullwidth" data-testid="user-vehicles-table"> <table class="table is-fullwidth" data-testid="user-vehicles-table">
<thead> <thead>
<tr> <tr>
<th v-if="!props.compact">{{ $t("objects.columns.id") }}</th> <th v-if="!props.compact">{{ $t('objects.columns.id') }}</th>
<th v-if="!props.compact">{{ $t("objects.columns.customer_id") }}</th> <th v-if="!props.compact">{{ $t('objects.columns.customer_id') }}</th>
<th>{{ $t("objects.bookings.columns.reg_1") }}</th> <th>{{ $t('objects.bookings.columns.reg_1') }}</th>
<th>{{ $t("vehicles.type") }}</th> <th>{{ $t('vehicles.type') }}</th>
<th>{{ $t("objects.vehicles.columns.wash_subscription") }}</th> <th>{{ $t('objects.vehicles.columns.wash_subscription') }}</th>
<th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th> <th v-if="!props.compact">{{ SessionUser.objects.product_options.meta.title }}</th>
<th v-if="!props.compact">{{ $t("common.reference") }}</th> <th v-if="!props.compact">{{ $t('common.reference') }}</th>
<th v-if="!props.compact"></th> <th v-if="!props.compact"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="object in props.vehicles" :key="object.id"> <tr v-for="object in props.vehicles" :key="object.id">
<!-- ID --> <!-- ID -->
<EditableTableColumn v-if="!props.compact" :object="object" :loadList="reload" column="id" /> <EditableTableColumn
<!-- Customer ID --> v-if="!props.compact"
<EditableTableColumn v-if="!props.compact" :object="object" :loadList="reload" column="customer_id" /> :object="object"
<!-- Reg --> :loadList="reload"
<EditableTableColumn column="id"
:object="object" />
:loadList="reload" <!-- Customer ID -->
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm" <EditableTableColumn
column="reg" v-if="!props.compact"
/> :object="object"
<!-- Type --> :loadList="reload"
<EditableTableColumn column="customer_id"
:object="object" />
:loadList="reload" <!-- Reg -->
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm" <EditableTableColumn
column="type" :object="object"
:parse-function=" :loadList="reload"
(value) => { :editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
return SessionUser.objects.products.functions.getProductName(value, 'Ukendt'); column="reg"
} />
" <!-- Type -->
/> <EditableTableColumn
<!-- Wash Subscription --> :object="object"
<EditableTableColumn :loadList="reload"
:object="object" :editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:loadList="reload" column="type"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm" :parse-function="(value) => {
column="wash_subscription" return SessionUser.objects.products.functions.getProductName(value, 'Ukendt');
:parse-function=" }"
(value) => { />
return value ? t('common.yes') : t('common.no'); <!-- Wash Subscription -->
} <EditableTableColumn
" :object="object"
/> :loadList="reload"
<!-- Product Options, if the wash subscription is set to true --> :editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
<td v-if="!props.compact"> column="wash_subscription"
<template v-if="object.wash_subscription"> :parse-function="(value) => {
<!-- Enabled subscription --> return value ? t('common.yes') : t('common.no');
<ActionSettingsWheelButton }"
:label="getProductOptionsLabel(object)" />
:icon="SessionUser.objects.product_options.meta.icon" <!-- Product Options, if the wash subscription is set to true -->
@mouseenter="getVehicleAvailableAddons(object.id, true)" <td v-if="!props.compact">
> <template v-if="object.wash_subscription">
<template #actions> <!-- Enabled subscription -->
<!-- List addons --> <ActionSettingsWheelButton
<template v-for="vehicleAddon in vehicleAddons" :key="vehicleAddon.id"> :label="getProductOptionsLabel(object)"
<ActionSettingsWheelItem :icon="SessionUser.objects.product_options.meta.icon"
:label=" @mouseenter="getVehicleAvailableAddons(object.id, true)"
(isVehicleAddonApplied(vehicleAddon) >
? SessionUser.objects.global.language.remove <template #actions>
: SessionUser.objects.global.language.add) + <!-- List addons -->
' ' + <template v-for="vehicleAddon in vehicleAddons" :key="vehicleAddon.id">
vehicleAddon.name <ActionSettingsWheelItem
" :label="(isVehicleAddonApplied(vehicleAddon) ? SessionUser.objects.global.language.remove : SessionUser.objects.global.language.add) + ' ' + vehicleAddon.name"
:icon="getVehicleAddonToggleIcon(vehicleAddon)" :icon="getVehicleAddonToggleIcon(vehicleAddon)"
:click-action="() => toggleVehicleAddon(object.id, vehicleAddon.id)" :click-action="() => toggleVehicleAddon(object.id, vehicleAddon.id)"
:template="isVehicleAddonApplied(vehicleAddon) ? 'danger' : 'default'" :template="isVehicleAddonApplied(vehicleAddon) ? 'danger' : 'default'"
/> />
</template>
<!-- If there are no addons, show a message -->
<ActionSettingsWheelItem
v-if="vehicleAddons ? vehicleAddons.length === 0 : true"
:label="$t('global.no_data')"
icon="fas fa-list"
/>
<!-- Addons -->
</template>
</ActionSettingsWheelButton>
</template> </template>
<template v-else> <!-- If there are no addons, show a message -->
<!-- Disabled subscription --> <ActionSettingsWheelItem
{{ $t("global.no_data") }} v-if="vehicleAddons ? vehicleAddons.length === 0 : true"
</template> :label="$t('global.no_data')"
</td> icon="fas fa-list"
<!-- Reference to the vehicle --> />
<EditableTableColumn <!-- Addons -->
v-if="!props.compact" </template>
:object="object" </ActionSettingsWheelButton>
:loadList="reload" </template>
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm" <template v-else>
column="reference" <!-- Disabled subscription -->
/> {{ $t('global.no_data') }}
<!-- Actions --> </template>
<td> </td>
<!-- Actions stay grouped behind the wheel menu. --> <!-- Reference to the vehicle -->
<ActionSettingsWheelButton <EditableTableColumn
v-if="!props.compact" v-if="!props.compact"
:user_id="object.user_id" :object="object"
:reg_1="object.reg" :loadList="reload"
:displayActionsDirectly="false" :editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
> column="reference"
<template #actions> />
<!-- View (Redirect to the vehicle page) --> <!-- Actions -->
<ActionSettingsWheelItem <td>
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')" <!-- Actions -->
icon="fas fa-eye" <ActionSettingsWheelButton
:click-action="() => redirectUserVehiclePage(object.id)" v-if="!props.compact"
/> :user_id="object.user_id"
<!-- Delete --> :reg_1="object.reg"
<ActionSettingsWheelItem :displayActionsDirectly="true"
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')" >
icon="fas fa-trash" <template #actions>
:template="'danger'" <!-- View (Redirect to the vehicle page) -->
:click-action=" <ActionSettingsWheelItem
() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload()) :label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
" icon="fas fa-eye"
/> :click-action="() => redirectUserVehiclePage(object.id)"
</template> />
</ActionSettingsWheelButton> <!-- Delete -->
</td> <ActionSettingsWheelItem
</tr> :label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-trash"
:template="'danger'"
:click-action="() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload())"
/>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<td colspan="10"> <td colspan="10">{{ $t('tables.showing') }} {{ props.vehicles.length }} {{ $t('objects.vehicles.multiple') }}</td>
{{ $t("tables.showing") }} {{ props.vehicles.length }} {{ $t("objects.vehicles.multiple") }} </tr>
</td>
</tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
</div> </div>
</template> </template>
<style scoped></style> <style scoped>
</style>
@@ -10,7 +10,6 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue"; import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js"; import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const employees = ref([]); const employees = ref([]);
const { t } = useI18n(); const { t } = useI18n();
@@ -53,7 +52,6 @@ const login = async () => {
} }
// Save the token in the local storage // Save the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.token); localStorage.setItem('token', data.token);
// Redirect to the dashboard // Redirect to the dashboard
window.location.href = '/admin'; window.location.href = '/admin';
@@ -74,7 +72,6 @@ const loginWithPasskey = async () => {
const result = await authenticateWithPasskey('employee', null, recaptchaToken); const result = await authenticateWithPasskey('employee', null, recaptchaToken);
if (result.token) { if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token); localStorage.setItem('token', result.token);
window.location.href = '/admin'; window.location.href = '/admin';
return; return;
-3
View File
@@ -9,7 +9,6 @@ import { parseError, clearErrors, addError, getError } from "@/components/reques
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"; import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js"; import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue"; import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n(); const { t } = useI18n();
@@ -69,7 +68,6 @@ const login = async () => {
} }
// Save the token in the local storage // Save the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.token); localStorage.setItem('token', data.token);
// Set the success message // Set the success message
successMessage.value = "Du er nu logget ind!" successMessage.value = "Du er nu logget ind!"
@@ -105,7 +103,6 @@ const loginWithPasskey = async () => {
const result = await authenticateWithPasskey('user', customerNum, recaptchaToken); const result = await authenticateWithPasskey('user', customerNum, recaptchaToken);
console.log('Passkey authentication result:', result); console.log('Passkey authentication result:', result);
if (result.token) { if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token); localStorage.setItem('token', result.token);
successMessage.value = "Du er nu logget ind!"; successMessage.value = "Du er nu logget ind!";
setTimeout(() => { setTimeout(() => {
@@ -7,9 +7,7 @@ import { parseError, getError, addError, clearErrors } from "@/components/reques
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"; import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js"; import { isPasskeySupported, authenticateWithPasskey } from "@/services/PasskeyAuthService.js";
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue"; import TwoFactorVerify from "@/components/forms/auth/TwoFactorVerify.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n(); const { t } = useI18n();
@@ -41,12 +39,6 @@ const twoFactorToken = ref('');
const login = async () => { const login = async () => {
clearErrors(); clearErrors();
const passwordPolicyError = getSubuserPasswordPolicyError(password.value);
if (passwordPolicyError) {
addError(passwordPolicyError, 'auth');
return;
}
try { try {
let requestBody = { password: password.value }; let requestBody = { password: password.value };
@@ -70,7 +62,6 @@ const login = async () => {
// Save the session token // Save the session token
if (data.session) { if (data.session) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', data.session); localStorage.setItem('token', data.session);
localStorage.setItem('is_subuser', 'true'); localStorage.setItem('is_subuser', 'true');
window.location.reload(); window.location.reload();
@@ -99,7 +90,6 @@ const loginWithPasskey = async () => {
// Subuser login returns 'session' token, user login returns 'token' // Subuser login returns 'session' token, user login returns 'token'
const sessionToken = result.session || result.token; const sessionToken = result.session || result.token;
if (sessionToken) { if (sessionToken) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', sessionToken); localStorage.setItem('token', sessionToken);
localStorage.setItem('is_subuser', 'true'); localStorage.setItem('is_subuser', 'true');
// Reload the page to update the UI // Reload the page to update the UI
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
import { verify2FA } from "@/services/TwoFactorAuthService.js"; import { verify2FA } from "@/services/TwoFactorAuthService.js";
import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue"; import { parseError, getError, clearErrors } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"; import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
@@ -44,11 +43,9 @@ const verify = async () => {
// Handle the response based on user type // Handle the response based on user type
if (props.userType === 'subuser' && result.session) { if (props.userType === 'subuser' && result.session) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.session); localStorage.setItem('token', result.session);
localStorage.setItem('is_subuser', 'true'); localStorage.setItem('is_subuser', 'true');
} else if (result.token) { } else if (result.token) {
clearEdgeGatewayWorkspaceCache();
localStorage.setItem('token', result.token); localStorage.setItem('token', result.token);
localStorage.removeItem('is_subuser'); localStorage.removeItem('is_subuser');
} }
@@ -441,23 +441,16 @@ const addProductWithAddonsToOrder = async (product_id) => {
showCancelButton: true, showCancelButton: true,
confirmButtonText: 'Tilføj', confirmButtonText: 'Tilføj',
showLoaderOnConfirm: true, showLoaderOnConfirm: true,
inputValidator: (note) => {
if (!String(note || '').trim()) {
return 'Note er påkrævet for dette produkt';
}
return null;
},
preConfirm: (note) => { preConfirm: (note) => {
const normalizedNote = String(note || '').trim();
const confirmedOrderId = getValidOrderId(); const confirmedOrderId = getValidOrderId();
if (!confirmedOrderId) { if (!confirmedOrderId) {
Swal.showValidationMessage('Order ID is required'); Swal.showValidationMessage('Order ID is required');
return false; return false;
} }
// Show the fake create order item // Show the fake create order item
showFakeCreateOrderItem(product_id, 1, 0, 0, normalizedNote); showFakeCreateOrderItem(product_id, 1, 0, 0, note);
// Create the order item // Create the order item
return createOrderItem(confirmedOrderId, product_id, 1, 0, normalizedNote).then(async (result) => { return createOrderItem(confirmedOrderId, product_id, 1, 0, note).then(async (result) => {
let order_item_id = result.data.data.id; let order_item_id = result.data.data.id;
// Add the addons to the order // Add the addons to the order
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => { await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => {
@@ -217,54 +217,6 @@ const getVehicleReferenceValue = () => {
return String(vehicleObject.value?.reference ?? "").trim(); return String(vehicleObject.value?.reference ?? "").trim();
}; };
const getVehicleStateKey = (vehicle) => {
if (!vehicle) {
return "";
}
return [
vehicle?.id ?? "",
normalizePlateValue(vehicle?.reg),
normalizeCustomerNumber(vehicle?.customer_id) ?? "",
vehicle?.type ?? "",
vehicle?.booking_id ?? "",
String(vehicle?.reference ?? "").trim(),
].join("|");
};
const getBookingStateKey = (booking) => {
if (!booking) {
return "";
}
return [
booking?.id ?? "",
getBookingReg1Value(booking),
getBookingReg2Value(booking),
getBookingCustomerNumber(booking) ?? "",
String(booking?.reference_number ?? booking?.reference ?? "").trim(),
String(booking?.po ?? "").trim(),
].join("|");
};
const getBookingMatchesStateKey = (matches) => {
if (!Array.isArray(matches) || matches.length === 0) {
return "";
}
return matches.map((booking) => getBookingStateKey(booking)).join("||");
};
const hasBookingDetailsChanged = (currentBooking, nextBooking) => {
const currentKeys = Object.keys(currentBooking || {});
const nextKeys = Object.keys(nextBooking || {});
if (currentKeys.length !== nextKeys.length) {
return true;
}
return nextKeys.some((key) => currentBooking?.[key] !== nextBooking?.[key]);
};
const setVehicleObject = (emittedVehicleObject, options = {}) => { const setVehicleObject = (emittedVehicleObject, options = {}) => {
const normalizedOptions = { const normalizedOptions = {
preserveManualReference: true, preserveManualReference: true,
@@ -272,26 +224,17 @@ const setVehicleObject = (emittedVehicleObject, options = {}) => {
...options, ...options,
}; };
const emittedVehiclePlate = normalizePlateValue(emittedVehicleObject?.reg || reg_1.value); vehicleObject.value = emittedVehicleObject;
const emittedVehiclePlate = normalizePlateValue(vehicleObject.value?.reg || reg_1.value);
if ( if (
emittedVehiclePlate && emittedVehiclePlate &&
skippedDesktopBookingVehiclePlate.value === emittedVehiclePlate && skippedDesktopBookingVehiclePlate.value === emittedVehiclePlate &&
!isBookingMarkedVehicle(emittedVehicleObject) !isBookingMarkedVehicle(vehicleObject.value)
) { ) {
skippedDesktopBookingVehiclePlate.value = ""; skippedDesktopBookingVehiclePlate.value = "";
} }
const currentVehicleKey = getVehicleStateKey(vehicleObject.value);
const nextVehicleKey = getVehicleStateKey(emittedVehicleObject);
if (currentVehicleKey === nextVehicleKey) {
if (normalizedOptions.nextSelectionSource) {
setSelectionSource(normalizedOptions.nextSelectionSource);
}
return;
}
vehicleObject.value = emittedVehicleObject;
const matchedVehicleCustomerNumber = normalizeCustomerNumber(vehicleObject.value?.customer_id); const matchedVehicleCustomerNumber = normalizeCustomerNumber(vehicleObject.value?.customer_id);
const selectedCustomerNumber = normalizeCustomerNumber(customer_id.value); const selectedCustomerNumber = normalizeCustomerNumber(customer_id.value);
@@ -331,10 +274,6 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
...options, ...options,
}; };
if (getBookingStateKey(bookingObject.value) === getBookingStateKey(emittedBookingObject)) {
return;
}
bookingObject.value = emittedBookingObject; bookingObject.value = emittedBookingObject;
if (emittedBookingObject && emittedBookingObject.reference_number) { if (emittedBookingObject && emittedBookingObject.reference_number) {
@@ -345,12 +284,7 @@ const setBookingObject = (emittedBookingObject, options = {}) => {
}; };
const setBookingMatches = (emittedBookingMatches) => { const setBookingMatches = (emittedBookingMatches) => {
const nextBookingMatches = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : []; bookingMatches.value = Array.isArray(emittedBookingMatches) ? emittedBookingMatches : [];
if (getBookingMatchesStateKey(bookingMatches.value) === getBookingMatchesStateKey(nextBookingMatches)) {
return;
}
bookingMatches.value = nextBookingMatches;
}; };
const mergeBookingMatchDetails = (booking) => { const mergeBookingMatchDetails = (booking) => {
@@ -360,23 +294,17 @@ const mergeBookingMatchDetails = (booking) => {
} }
let didMergeBooking = false; let didMergeBooking = false;
let didChangeBooking = false; bookingMatches.value = bookingMatches.value.map((entry) => {
const nextBookingMatches = bookingMatches.value.map((entry) => {
if (normalizeBookingId(entry?.id) !== normalizedBookingId) { if (normalizeBookingId(entry?.id) !== normalizedBookingId) {
return entry; return entry;
} }
didMergeBooking = true; didMergeBooking = true;
const mergedBooking = { ...entry, ...booking }; return { ...entry, ...booking };
const hasChangedEntry = hasBookingDetailsChanged(entry, mergedBooking);
didChangeBooking = didChangeBooking || hasChangedEntry;
return hasChangedEntry ? mergedBooking : entry;
}); });
if (!didMergeBooking) { if (!didMergeBooking) {
bookingMatches.value = [...bookingMatches.value, booking]; bookingMatches.value = [...bookingMatches.value, booking];
} else if (didChangeBooking) {
bookingMatches.value = nextBookingMatches;
} }
}; };
@@ -416,21 +416,6 @@ const shouldPreserveCustomerSelection = (plateValue) => {
}; };
let pendingVehicleCustomerLookup = null; let pendingVehicleCustomerLookup = null;
const lastAutoSyncedVehicleKey = ref("");
const getVehicleAutoSyncKey = (vehicle, plateOverride = null) => {
if (!vehicle) {
return "";
}
return [
resolveBookingPlate(vehicle, plateOverride),
vehicle?.id ?? "",
normalizeCustomerNumber(vehicle?.customer_id) ?? "",
normalizeCustomerNumber(customer_id.value) ?? "",
].join("|");
};
const syncMatchedVehicleSelection = (vehicle, options = {}) => { const syncMatchedVehicleSelection = (vehicle, options = {}) => {
const normalizedOptions = { const normalizedOptions = {
plateOverride: null, plateOverride: null,
@@ -605,7 +590,6 @@ watch(reg_1, (newValue) => {
// Define the search ID for this input change // Define the search ID for this input change
const search_id = register_new_search(); const search_id = register_new_search();
console.log("reg_1 changed:", newValue); console.log("reg_1 changed:", newValue);
lastAutoSyncedVehicleKey.value = "";
const shouldKeepCustomerSelection = shouldPreserveCustomerSelection(newValue); const shouldKeepCustomerSelection = shouldPreserveCustomerSelection(newValue);
selectedDropdownItem.value = -1; selectedDropdownItem.value = -1;
// Check if the new value is empty, if so, clear the vehicles_matching array // Check if the new value is empty, if so, clear the vehicles_matching array
@@ -863,17 +847,11 @@ watch(vehicles_matching, (newValue) => {
const currentValue = reg_1.value; const currentValue = reg_1.value;
const vehicle = newValue.find((vehicle) => vehicle.reg === currentValue); const vehicle = newValue.find((vehicle) => vehicle.reg === currentValue);
if (vehicle) { if (vehicle) {
const nextAutoSyncKey = getVehicleAutoSyncKey(vehicle, currentValue); syncMatchedVehicleSelection(vehicle);
if (lastAutoSyncedVehicleKey.value !== nextAutoSyncKey) {
lastAutoSyncedVehicleKey.value = nextAutoSyncKey;
syncMatchedVehicleSelection(vehicle);
}
} else if (getBookingMatchesForSelection(null, currentValue).length > 0) { } else if (getBookingMatchesForSelection(null, currentValue).length > 0) {
lastAutoSyncedVehicleKey.value = "";
clearCustomerConflict(); clearCustomerConflict();
emitVehicleObject(null, currentValue); emitVehicleObject(null, currentValue);
} else { } else {
lastAutoSyncedVehicleKey.value = "";
clearCustomerConflict(); clearCustomerConflict();
} }
// Check if the selectedDropdownItem index is valid // Check if the selectedDropdownItem index is valid
@@ -1037,7 +1015,6 @@ const getCurrentIconColor = () => {
@blur="lostfocus" @blur="lostfocus"
@keydown="arrowKeyHandler" @keydown="arrowKeyHandler"
id="reg_1" id="reg_1"
tabindex="1"
autocomplete="off" autocomplete="off"
/> />
</template> </template>
@@ -1060,7 +1037,6 @@ const getCurrentIconColor = () => {
@blur="lostfocus" @blur="lostfocus"
@keydown="arrowKeyHandler" @keydown="arrowKeyHandler"
id="reg_1" id="reg_1"
tabindex="1"
autocomplete="off" autocomplete="off"
/> />
</div> </div>
@@ -394,16 +394,12 @@ onBeforeUnmount(() => {
v-model="typedReference" v-model="typedReference"
:data="groupedSuggestions" :data="groupedSuggestions"
field="reference" field="reference"
input-id="reference"
data-testid="pos-desktop-step-1-reference-input"
group-field="group" group-field="group"
group-options="items" group-options="items"
:keep-first="true" :keep-first="true"
open-on-focus open-on-focus
expanded expanded
:loading="isFetching" :loading="isFetching"
:tabindex="props.tabindex"
:aria-invalid="props.requiredWarning ? 'true' : undefined"
custom-class="has-sharp-edges" custom-class="has-sharp-edges"
icon-pack="fas" icon-pack="fas"
max-height="320" max-height="320"
@@ -52,7 +52,6 @@ const customer_suggestions = ref([
const isSettingCustomer = ref(false); const isSettingCustomer = ref(false);
const isSettingCustomerToInteger = ref(0); const isSettingCustomerToInteger = ref(0);
const isSettingCustomerStartTime = ref(null); const isSettingCustomerStartTime = ref(null);
let customerSuggestionsRequestId = 0;
const isSettingCustomerTo = (customer_number) => { const isSettingCustomerTo = (customer_number) => {
return isSettingCustomerToInteger.value === parseInt(customer_number); return isSettingCustomerToInteger.value === parseInt(customer_number);
@@ -89,17 +88,10 @@ const getCustomerSuggestions = () => {
if (!props.reg_1) { if (!props.reg_1) {
return; return;
} }
const requestedReg1 = props.reg_1;
const requestId = ++customerSuggestionsRequestId;
SessionUser.request("/department/vehicle/customer-suggestions", "GET", { SessionUser.request("/department/vehicle/customer-suggestions", "GET", {
reg_1: requestedReg1, reg_1: props.reg_1,
}) })
.then((response) => { .then((response) => {
if (requestId !== customerSuggestionsRequestId || requestedReg1 !== props.reg_1) {
return;
}
// Assuming the response contains an array of customer suggestions // Assuming the response contains an array of customer suggestions
console.log("Customer suggestions:", response.data.data); console.log("Customer suggestions:", response.data.data);
let suggestions = []; let suggestions = [];
@@ -134,7 +126,6 @@ watch(
if (newValue) { if (newValue) {
getCustomerSuggestions(); getCustomerSuggestions();
} else { } else {
customerSuggestionsRequestId += 1;
customer_suggestions.value = []; customer_suggestions.value = [];
} }
} }
+5 -40
View File
@@ -1,6 +1,5 @@
<script setup> <script setup>
import { computed, nextTick, reactive, ref, watch } from "vue"; import { computed, nextTick, reactive, ref } from "vue";
import { useMediaQuery } from "@vueuse/core";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
@@ -10,7 +9,6 @@ import {
releaseRuntimeState, releaseRuntimeState,
} from "@/services/releaseTimeline.js"; } from "@/services/releaseTimeline.js";
import { submitErrorReport } from "@/services/errorReports.js"; import { submitErrorReport } from "@/services/errorReports.js";
import { errorReportLaunchRequestId } from "@/services/errorReportLauncher.js";
const route = useRoute(); const route = useRoute();
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });
@@ -23,9 +21,6 @@ const FALLBACK_LABELS = {
"error_report.before_error": "What were you doing before the error occurred?", "error_report.before_error": "What were you doing before the error occurred?",
"error_report.expected": "What did you expect would happen?", "error_report.expected": "What did you expect would happen?",
"error_report.actual": "What actually happened?", "error_report.actual": "What actually happened?",
"error_report.before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"error_report.expected_placeholder": "Describe the result you expected to see.",
"error_report.actual_placeholder": "Describe what you saw instead, including any error text.",
"error_report.consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.", "error_report.consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"error_report.submit": "Submit report", "error_report.submit": "Submit report",
"error_report.submitted": "Error report submitted.", "error_report.submitted": "Error report submitted.",
@@ -51,12 +46,7 @@ const form = reactive({
data_collection_accepted: false, data_collection_accepted: false,
}); });
const isMobileReportPlacement = useMediaQuery("(max-width: 768px)");
const isDesktopNavigationReportPlacement = useMediaQuery("(min-width: 1024px)");
const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value); const isAuthenticated = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
const shouldShowFloatingButton = computed(
() => !isMobileReportPlacement.value && !isDesktopNavigationReportPlacement.value
);
const isValid = computed(() => ( const isValid = computed(() => (
form.before_error.trim().length > 0 form.before_error.trim().length > 0
&& form.expected.trim().length > 0 && form.expected.trim().length > 0
@@ -106,12 +96,6 @@ const open = () => {
void loadHtml2Canvas().catch(() => {}); void loadHtml2Canvas().catch(() => {});
}; };
watch(errorReportLaunchRequestId, () => {
if (isAuthenticated.value) {
open();
}
});
const close = () => { const close = () => {
if (isSubmitting.value) { if (isSubmitting.value) {
return; return;
@@ -316,7 +300,6 @@ const submit = async () => {
<template> <template>
<div v-if="isAuthenticated" data-error-report-exclude> <div v-if="isAuthenticated" data-error-report-exclude>
<button <button
v-if="shouldShowFloatingButton"
class="button is-danger error-report-button" class="button is-danger error-report-button"
type="button" type="button"
data-testid="error-report-button" data-testid="error-report-button"
@@ -334,7 +317,7 @@ const submit = async () => {
<h2 class="title is-4">{{ tr("error_report.title") }}</h2> <h2 class="title is-4">{{ tr("error_report.title") }}</h2>
<p class="subtitle is-6">{{ tr("error_report.subtitle") }}</p> <p class="subtitle is-6">{{ tr("error_report.subtitle") }}</p>
</div> </div>
<button class="delete" type="button" :aria-label="t('common.close')" :disabled="isSubmitting" @click="close"></button> <button class="delete" type="button" aria-label="close" :disabled="isSubmitting" @click="close"></button>
</header> </header>
<div v-if="submitted" class="notification is-success is-light" data-testid="error-report-submitted"> <div v-if="submitted" class="notification is-success is-light" data-testid="error-report-submitted">
@@ -349,35 +332,17 @@ const submit = async () => {
<label class="field"> <label class="field">
<span class="label">{{ tr("error_report.before_error") }}</span> <span class="label">{{ tr("error_report.before_error") }}</span>
<textarea <textarea v-model="form.before_error" class="textarea" maxlength="4000" required></textarea>
v-model="form.before_error"
class="textarea"
maxlength="4000"
:placeholder="tr('error_report.before_error_placeholder')"
required
></textarea>
</label> </label>
<label class="field"> <label class="field">
<span class="label">{{ tr("error_report.expected") }}</span> <span class="label">{{ tr("error_report.expected") }}</span>
<textarea <textarea v-model="form.expected" class="textarea" maxlength="4000" required></textarea>
v-model="form.expected"
class="textarea"
maxlength="4000"
:placeholder="tr('error_report.expected_placeholder')"
required
></textarea>
</label> </label>
<label class="field"> <label class="field">
<span class="label">{{ tr("error_report.actual") }}</span> <span class="label">{{ tr("error_report.actual") }}</span>
<textarea <textarea v-model="form.actual" class="textarea" maxlength="4000" required></textarea>
v-model="form.actual"
class="textarea"
maxlength="4000"
:placeholder="tr('error_report.actual_placeholder')"
required
></textarea>
</label> </label>
<label class="checkbox error-report-consent"> <label class="checkbox error-report-consent">
@@ -1,236 +0,0 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import LocalDataResetDialog from "@/components/global/LocalDataResetDialog.vue";
import { releaseUpdateState, shortReleaseCommit } from "@/services/releaseUpdate.js";
import { forceFrontendUpdateAndClearLocal } from "@/services/frontendMaintenance.js";
const SHIFT_PRESS_WINDOW_MS = 1200;
const CLOSE_EVENT = "frontend-maintenance-menu:close";
const { t } = useI18n({ useScope: "global" });
const isOpen = ref(false);
const isBusy = ref(false);
const isClearConfirmationOpen = ref(false);
const shiftPresses = ref([]);
const currentVersion = computed(() => shortReleaseCommit(releaseUpdateState.currentCommit));
const latestVersion = computed(() => shortReleaseCommit(releaseUpdateState.latestCommit || releaseUpdateState.currentCommit));
const closeMenu = () => {
if (!isBusy.value) {
isOpen.value = false;
isClearConfirmationOpen.value = false;
}
};
const handleKeydown = (event) => {
if (event.key !== "Shift" || event.repeat) {
return;
}
const now = Date.now();
shiftPresses.value = [...shiftPresses.value.filter((pressedAt) => now - pressedAt <= SHIFT_PRESS_WINDOW_MS), now];
if (shiftPresses.value.length >= 3) {
shiftPresses.value = [];
isOpen.value = true;
}
};
const openClearConfirmation = () => {
if (!isBusy.value) {
isClearConfirmationOpen.value = true;
}
};
const closeClearConfirmation = () => {
isClearConfirmationOpen.value = false;
};
const forceUpdateAndClearLocal = async () => {
if (isBusy.value) {
return;
}
isBusy.value = true;
try {
await forceFrontendUpdateAndClearLocal();
} catch (error) {
isBusy.value = false;
console.error("Failed to force frontend update and clear local data:", error);
}
};
onMounted(() => {
window.addEventListener("keydown", handleKeydown);
window.addEventListener(CLOSE_EVENT, closeMenu);
});
onBeforeUnmount(() => {
window.removeEventListener("keydown", handleKeydown);
window.removeEventListener(CLOSE_EVENT, closeMenu);
});
</script>
<template>
<Teleport to="body">
<div v-if="isOpen" class="frontend-maintenance-menu" data-testid="frontend-maintenance-menu">
<button
type="button"
class="frontend-maintenance-menu__backdrop"
:aria-label="t('common.close')"
@click="closeMenu"
></button>
<section class="frontend-maintenance-menu__panel" role="dialog" aria-modal="true" :aria-label="t('maintenance_menu.title')">
<header class="frontend-maintenance-menu__header">
<div>
<p>{{ t("maintenance_menu.title") }}</p>
<span>{{ t("maintenance_menu.version", { current: currentVersion, latest: latestVersion }) }}</span>
</div>
<button type="button" class="frontend-maintenance-menu__close" :aria-label="t('common.close')" @click="closeMenu">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</header>
<button
type="button"
class="frontend-maintenance-menu__danger"
data-testid="frontend-maintenance-force-clear"
:disabled="isBusy"
@click="openClearConfirmation"
>
<i class="fas fa-sync-alt" aria-hidden="true"></i>
<span>
<strong>{{ t("maintenance_menu.force_update_clear") }}</strong>
<small>{{ t("maintenance_menu.force_update_clear_hint") }}</small>
</span>
</button>
</section>
<LocalDataResetDialog
v-model="isClearConfirmationOpen"
:busy="isBusy"
@confirm="forceUpdateAndClearLocal"
@dismiss="closeClearConfirmation"
/>
</div>
</Teleport>
</template>
<style scoped>
.frontend-maintenance-menu {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: end center;
padding: 16px;
pointer-events: none;
}
.frontend-maintenance-menu__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(12, 22, 34, 0.32);
pointer-events: auto;
}
.frontend-maintenance-menu__panel {
position: relative;
width: min(420px, calc(100vw - 32px));
border: 1px solid #d6dde6;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
color: #111827;
pointer-events: auto;
}
.frontend-maintenance-menu__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 14px 14px 10px;
border-bottom: 1px solid #edf1f5;
}
.frontend-maintenance-menu__header p {
margin: 0;
color: #172033;
font-size: 0.96rem;
font-weight: 800;
}
.frontend-maintenance-menu__header span {
display: block;
margin-top: 3px;
color: #667085;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.72rem;
font-weight: 700;
}
.frontend-maintenance-menu__close {
width: 34px;
height: 34px;
flex: 0 0 auto;
border: 1px solid #d9e0e8;
border-radius: 6px;
background: #ffffff;
color: #334155;
cursor: pointer;
}
.frontend-maintenance-menu__danger {
width: calc(100% - 28px);
min-height: 58px;
display: flex;
align-items: center;
gap: 10px;
margin: 14px;
padding: 10px 12px;
border: 1px solid #f2b8b5;
border-radius: 6px;
background: #fff7f6;
color: #981b1b;
cursor: pointer;
text-align: left;
}
.frontend-maintenance-menu__danger:disabled {
cursor: wait;
opacity: 0.7;
}
.frontend-maintenance-menu__danger i {
width: 20px;
flex: 0 0 auto;
text-align: center;
}
.frontend-maintenance-menu__danger strong,
.frontend-maintenance-menu__danger small {
display: block;
}
.frontend-maintenance-menu__danger strong {
font-size: 0.86rem;
font-weight: 800;
}
.frontend-maintenance-menu__danger small {
margin-top: 2px;
color: #9f1c1c;
font-size: 0.72rem;
font-weight: 650;
line-height: 1.25;
}
@media (min-width: 720px) {
.frontend-maintenance-menu {
place-items: end end;
padding: 22px;
}
}
</style>
@@ -1,156 +0,0 @@
<script setup>
defineProps({
modelValue: {
type: Boolean,
default: false,
},
busy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "confirm", "dismiss"]);
const dismissDialog = () => {
emit("dismiss");
emit("update:modelValue", false);
};
const confirmDialog = () => {
emit("confirm");
};
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="local-data-reset-dialog" data-testid="local-data-reset-dialog">
<button
type="button"
class="local-data-reset-dialog__backdrop"
aria-label="Luk"
:disabled="busy"
@click="dismissDialog"
></button>
<section
class="local-data-reset-dialog__panel"
role="dialog"
aria-modal="true"
aria-labelledby="local-data-reset-dialog-title"
>
<h2 id="local-data-reset-dialog-title">Ryd lokale data?</h2>
<p>
Dette sletter login, localStorage, sessionStorage, browsercache og lokale appdata denne enhed. Du bliver
logget ud.
</p>
<footer class="local-data-reset-dialog__actions">
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--secondary"
data-testid="local-data-reset-cancel"
:disabled="busy"
@click="dismissDialog"
>
Nej
</button>
<button
type="button"
class="local-data-reset-dialog__button local-data-reset-dialog__button--danger"
data-testid="local-data-reset-confirm"
:disabled="busy"
@click="confirmDialog"
>
Ja, ryd alt
</button>
</footer>
</section>
</div>
</Teleport>
</template>
<style scoped>
.local-data-reset-dialog {
position: fixed;
inset: 0;
z-index: 11000;
display: grid;
place-items: center;
padding: 18px;
pointer-events: none;
}
.local-data-reset-dialog__backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(9, 20, 33, 0.48);
cursor: pointer;
pointer-events: auto;
}
.local-data-reset-dialog__backdrop:disabled {
cursor: wait;
}
.local-data-reset-dialog__panel {
position: relative;
width: min(430px, calc(100vw - 36px));
border: 1px solid #d5dde8;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.28);
color: #172033;
pointer-events: auto;
}
.local-data-reset-dialog__panel h2 {
margin: 0;
padding: 18px 18px 8px;
font-size: 1.05rem;
font-weight: 800;
letter-spacing: 0;
}
.local-data-reset-dialog__panel p {
margin: 0;
padding: 0 18px 16px;
color: #475467;
font-size: 0.92rem;
font-weight: 550;
line-height: 1.42;
}
.local-data-reset-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px 18px;
border-top: 1px solid #edf1f5;
}
.local-data-reset-dialog__button {
min-height: 38px;
padding: 0 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.86rem;
font-weight: 800;
}
.local-data-reset-dialog__button:disabled {
cursor: wait;
opacity: 0.68;
}
.local-data-reset-dialog__button--secondary {
border: 1px solid #cfd8e3;
background: #ffffff;
color: #334155;
}
.local-data-reset-dialog__button--danger {
border: 1px solid #b42318;
background: #b42318;
color: #ffffff;
}
</style>
+18 -215
View File
@@ -41,16 +41,9 @@ const REQUEST_INSIGHT_DEFINITIONS = Object.freeze([
iconClass: "fa-calendar-alt", iconClass: "fa-calendar-alt",
matcher: (url) => /\/order-bookings(?:[/?#]|$)/i.test(url), matcher: (url) => /\/order-bookings(?:[/?#]|$)/i.test(url),
}, },
{
key: "scanner",
label: "Scanner",
iconClass: "fa-camera",
matcher: (url) => /\/modules\/scanner\/lpr(?:[/?#]|$)/i.test(url),
},
]); ]);
const SHIFT_MULTI_PRESS_WINDOW_MS = 700; const SHIFT_MULTI_PRESS_WINDOW_MS = 700;
const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close"; const SYSTEM_SEARCH_CLOSE_EVENT = "system-search:close";
const FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT = "frontend-maintenance-menu:close";
const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault); const isExpanded = ref(REQUEST_QUEUE_CONFIG.inspector.expandedByDefault);
const isShortcutActivated = ref(false); const isShortcutActivated = ref(false);
@@ -68,7 +61,6 @@ const processedRequests = computed(() => requestQueueState.batchCompleted + requ
const missingPermissions = computed(() => requestQueueState.missingPermissions || []); const missingPermissions = computed(() => requestQueueState.missingPermissions || []);
const activeRequests = computed(() => requestQueueState.activeRequests || []); const activeRequests = computed(() => requestQueueState.activeRequests || []);
const recentRequests = computed(() => requestQueueState.recentRequests || []); const recentRequests = computed(() => requestQueueState.recentRequests || []);
const queueRequestInsights = computed(() => requestQueueState.requestInsights || {});
const errorRequests = computed(() => requestQueueState.errorRequests || []); const errorRequests = computed(() => requestQueueState.errorRequests || []);
const networkTotals = computed(() => requestQueueState.networkTotals || { const networkTotals = computed(() => requestQueueState.networkTotals || {
outgoingRequests: 0, outgoingRequests: 0,
@@ -115,7 +107,7 @@ const userTypeLabel = computed(() => (SessionUser.isSubuser.value ? "Subuser" :
const hasSuperuserToken = computed(() => { const hasSuperuserToken = computed(() => {
try { try {
return Boolean(localStorage.getItem("superuser_token")); return Boolean(localStorage.getItem("superuser_token"));
} catch (_error) { } catch (error) {
return false; return false;
} }
}); });
@@ -126,7 +118,6 @@ const impersonatedUserRoleId = computed(() => {
const canGrantMissingPermissions = computed(() => const canGrantMissingPermissions = computed(() =>
hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null hasSuperuserToken.value && !SessionUser.isSubuser.value && impersonatedUserRoleId.value !== null
); );
const canInspectReleaseRuntime = computed(() => hasSuperuserToken.value || SessionUser.canAccessAdmin?.() === true);
const userDetailRows = computed(() => { const userDetailRows = computed(() => {
if (SessionUser.isSubuser.value) { if (SessionUser.isSubuser.value) {
@@ -198,152 +189,6 @@ const formatBytes = (value) => {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}; };
const parseServerTimingDurations = (value) => {
if (typeof value !== "string" || value.trim().length === 0) {
return {};
}
return value.split(",").reduce((durations, part) => {
const [rawMetric, ...rawParams] = part.trim().split(";");
const metric = rawMetric.trim();
if (!metric) {
return durations;
}
const durationParam = rawParams.find((param) => param.trim().toLowerCase().startsWith("dur="));
if (!durationParam) {
return durations;
}
const duration = Number.parseFloat(durationParam.split("=").slice(1).join("="));
if (Number.isFinite(duration)) {
durations[metric] = duration;
}
return durations;
}, {});
};
const findServerTimingDuration = (durations, metricNames) => {
const metricName = metricNames.find((name) => Number.isFinite(durations[name]));
return metricName ? durations[metricName] : null;
};
const formatScannerFrameSize = (width, height) => {
const roundedWidth = Math.round(Number(width) || 0);
const roundedHeight = Math.round(Number(height) || 0);
if (roundedWidth <= 0 || roundedHeight <= 0) {
return null;
}
return `img ${roundedWidth}x${roundedHeight}`;
};
const formatScannerFrameBytes = (bytes) => {
const roundedBytes = Math.round(Number(bytes) || 0);
if (roundedBytes <= 0) {
return null;
}
return `bytes ${formatBytes(roundedBytes)}`;
};
const getBrowserNetworkDuration = (requestDurationMs, serverDurationMs) => {
if (serverDurationMs === null) {
return null;
}
const durationMs = Number(requestDurationMs) - Number(serverDurationMs);
if (!Number.isFinite(durationMs) || durationMs < 1) {
return null;
}
return durationMs;
};
const formatRequestLatency = (definition, request) => {
const requestDurationMs = Math.max(0, Number(request?.requestDurationMs) || 0);
if (definition.key !== "scanner") {
return formatDuration(requestDurationMs);
}
const queueDurationMs = Math.max(0, Number(request?.queueDurationMs) || 0);
const durations = parseServerTimingDurations(request?.serverTiming);
const captureDurationMs = findServerTimingDuration(durations, ["lpr_client_capture"]);
const preflightDurationMs = findServerTimingDuration(durations, ["lpr_client_preflight"]);
const visualFingerprintDurationMs = findServerTimingDuration(durations, ["lpr_client_visual_fingerprint"]);
const drawDurationMs = findServerTimingDuration(durations, ["lpr_client_draw"]);
const encodeDurationMs = findServerTimingDuration(durations, ["lpr_client_encode"]);
const clientFrameWidth = findServerTimingDuration(durations, ["lpr_client_frame_width"]);
const clientFrameHeight = findServerTimingDuration(durations, ["lpr_client_frame_height"]);
const clientFrameBytes = findServerTimingDuration(durations, ["lpr_client_frame_bytes"]);
const cacheDurationMs = findServerTimingDuration(durations, ["lpr_cache"]);
const cacheHit = findServerTimingDuration(durations, ["lpr_cache_hit"]) !== null;
const cacheMiss = findServerTimingDuration(durations, ["lpr_cache_miss"]) !== null;
const localDurationMs = findServerTimingDuration(durations, ["lpr_local"]);
const upstreamProcessingDurationMs = findServerTimingDuration(durations, ["lpr_upstream_processing"]);
const upstreamDurationMs = findServerTimingDuration(durations, ["lpr_upstream_total", "lpr_upstream"]);
const serverDurationMs = findServerTimingDuration(durations, ["lpr_total", "lpr_route_total", "lpr_request_total"]);
const browserNetworkDurationMs = getBrowserNetworkDuration(requestDurationMs, serverDurationMs);
const timingParts = [`browser ${formatDuration(requestDurationMs)}`];
const frameSize = formatScannerFrameSize(clientFrameWidth, clientFrameHeight);
const frameBytes = formatScannerFrameBytes(clientFrameBytes);
if (queueDurationMs > 0) {
timingParts.push(`queue ${formatDuration(queueDurationMs)}`);
}
if (browserNetworkDurationMs !== null) {
timingParts.push(`net ${formatDuration(browserNetworkDurationMs)}`);
}
if (captureDurationMs !== null) {
timingParts.push(`cap ${formatDuration(captureDurationMs)}`);
}
if (preflightDurationMs !== null) {
timingParts.push(`prep ${formatDuration(preflightDurationMs)}`);
}
if (visualFingerprintDurationMs !== null) {
timingParts.push(`vf ${formatDuration(visualFingerprintDurationMs)}`);
}
if (drawDurationMs !== null) {
timingParts.push(`draw ${formatDuration(drawDurationMs)}`);
}
if (encodeDurationMs !== null) {
timingParts.push(`enc ${formatDuration(encodeDurationMs)}`);
}
if (frameSize !== null) {
timingParts.push(frameSize);
}
if (frameBytes !== null) {
timingParts.push(frameBytes);
}
if (cacheHit) {
timingParts.push("cache hit");
} else if (cacheMiss) {
timingParts.push("cache miss");
}
if (cacheDurationMs !== null) {
timingParts.push(`cache ${formatDuration(cacheDurationMs)}`);
}
if (localDurationMs !== null) {
timingParts.push(`local ${formatDuration(localDurationMs)}`);
}
if (upstreamProcessingDurationMs !== null) {
timingParts.push(`proc ${formatDuration(upstreamProcessingDurationMs)}`);
}
if (upstreamDurationMs !== null) {
timingParts.push(`up ${formatDuration(upstreamDurationMs)}`);
}
if (serverDurationMs !== null) {
timingParts.push(`srv ${formatDuration(serverDurationMs)}`);
}
return timingParts.join(" / ");
};
const formatTimeAgo = (timestamp) => { const formatTimeAgo = (timestamp) => {
const value = Number(timestamp || 0); const value = Number(timestamp || 0);
if (!value || Number.isNaN(value)) { if (!value || Number.isNaN(value)) {
@@ -372,67 +217,43 @@ const getActiveRequestElapsedMs = (request) => Math.max(0, nowMs.value - Number(
const hasInsightEntryChanged = (currentEntry, nextEntry) => const hasInsightEntryChanged = (currentEntry, nextEntry) =>
Number(currentEntry?.requestDurationMs || 0) !== Number(nextEntry?.requestDurationMs || 0) Number(currentEntry?.requestDurationMs || 0) !== Number(nextEntry?.requestDurationMs || 0)
|| Number(currentEntry?.queueDurationMs || 0) !== Number(nextEntry?.queueDurationMs || 0)
|| Number(currentEntry?.completedAt || 0) !== Number(nextEntry?.completedAt || 0) || Number(currentEntry?.completedAt || 0) !== Number(nextEntry?.completedAt || 0)
|| Number(currentEntry?.startedAt || 0) !== Number(nextEntry?.startedAt || 0) || Number(currentEntry?.startedAt || 0) !== Number(nextEntry?.startedAt || 0)
|| Number(currentEntry?.queuedAt || 0) !== Number(nextEntry?.queuedAt || 0) || Number(currentEntry?.queuedAt || 0) !== Number(nextEntry?.queuedAt || 0);
|| String(currentEntry?.serverTiming || "") !== String(nextEntry?.serverTiming || "");
const getInsightEntryTimestamp = (entry) =>
Number(entry?.completedAt || entry?.startedAt || entry?.queuedAt || 0);
const selectNewestInsightEntry = (...entries) =>
entries
.filter((entry) => entry !== null && entry !== undefined)
.sort((first, second) => getInsightEntryTimestamp(second) - getInsightEntryTimestamp(first))[0] || null;
const requestInsights = computed(() => REQUEST_INSIGHT_DEFINITIONS.map((definition) => { const requestInsights = computed(() => REQUEST_INSIGHT_DEFINITIONS.map((definition) => {
const matchedRequestFromRecent = recentRequests.value.find((request) => const matchedRequestFromRecent = recentRequests.value.find((request) =>
definition.matcher(String(request?.url || "")) definition.matcher(String(request?.url || ""))
) || null; ) || null;
const matchedRequestFromQueueInsight = queueRequestInsights.value[definition.key] || null; const matchedRequest = matchedRequestFromRecent || requestInsightHistory.value[definition.key] || null;
const matchedRequest = selectNewestInsightEntry(
matchedRequestFromRecent,
matchedRequestFromQueueInsight,
requestInsightHistory.value[definition.key],
);
const hasData = matchedRequest !== null; const hasData = matchedRequest !== null;
return { return {
...definition, ...definition,
latencyText: hasData ? formatRequestLatency(definition, matchedRequest) : " ", latencyText: hasData ? `${Math.max(0, Number(matchedRequest.requestDurationMs) || 0)} ms` : " ",
serverTiming: matchedRequest?.serverTiming || "",
timeAgoText: hasData timeAgoText: hasData
? formatTimeAgo(matchedRequest.completedAt || matchedRequest.startedAt || matchedRequest.queuedAt) ? formatTimeAgo(matchedRequest.completedAt || matchedRequest.startedAt || matchedRequest.queuedAt)
: "", : "",
}; };
})); }));
watch([recentRequests, queueRequestInsights], ([requests, insights]) => { watch(recentRequests, (requests) => {
const recentRequestList = Array.isArray(requests) ? requests : []; if (!Array.isArray(requests) || requests.length === 0) {
const insightEntries = insights && typeof insights === "object" ? insights : {};
if (recentRequestList.length === 0 && Object.keys(insightEntries).length === 0) {
return; return;
} }
const nextHistory = { ...requestInsightHistory.value }; const nextHistory = { ...requestInsightHistory.value };
let hasChanges = false; let hasChanges = false;
REQUEST_INSIGHT_DEFINITIONS.forEach((definition) => { REQUEST_INSIGHT_DEFINITIONS.forEach((definition) => {
const matchedRequest = selectNewestInsightEntry( const matchedRequest = requests.find((request) =>
recentRequestList.find((request) => definition.matcher(String(request?.url || ""))
definition.matcher(String(request?.url || ""))
),
insightEntries[definition.key],
); );
if (!matchedRequest) { if (!matchedRequest) {
return; return;
} }
const nextEntry = { const nextEntry = {
serverTiming: matchedRequest.serverTiming || null,
requestDurationMs: Math.max(0, Number(matchedRequest.requestDurationMs) || 0), requestDurationMs: Math.max(0, Number(matchedRequest.requestDurationMs) || 0),
queueDurationMs: Math.max(0, Number(matchedRequest.queueDurationMs) || 0),
completedAt: matchedRequest.completedAt || null, completedAt: matchedRequest.completedAt || null,
startedAt: matchedRequest.startedAt || null, startedAt: matchedRequest.startedAt || null,
queuedAt: matchedRequest.queuedAt || null, queuedAt: matchedRequest.queuedAt || null,
@@ -463,12 +284,7 @@ const resolvePingUrl = () => {
}; };
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl()); const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
const activeApiUrlLabel = computed(() => const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
canInspectReleaseRuntime.value ? activeApiUrl.value : "Restricted to release operators"
);
const releaseSessionSummary = computed(() =>
buildReleaseSessionSummary(undefined, { includeInfrastructureDetails: canInspectReleaseRuntime.value })
);
const measurePingLatency = async () => { const measurePingLatency = async () => {
if (typeof fetch !== "function") { if (typeof fetch !== "function") {
@@ -506,7 +322,7 @@ const measurePingLatency = async () => {
queuedAt: startedAt, queuedAt: startedAt,
}, },
}; };
} catch (_error) { } catch (error) {
pingLatencyMs.value = null; pingLatencyMs.value = null;
pingIsUnavailable.value = true; pingIsUnavailable.value = true;
} finally { } finally {
@@ -544,9 +360,6 @@ const handleWindowKeydown = (event) => {
} }
if (shiftKeyPressCount.value >= 3) { if (shiftKeyPressCount.value >= 3) {
event.preventDefault?.();
event.stopImmediatePropagation?.();
window.dispatchEvent(new CustomEvent(FRONTEND_MAINTENANCE_MENU_CLOSE_EVENT));
closeSystemSearchForSuperuser(); closeSystemSearchForSuperuser();
isShortcutActivated.value = true; isShortcutActivated.value = true;
isExpanded.value = true; isExpanded.value = true;
@@ -751,7 +564,7 @@ onBeforeUnmount(() => {
{{ request.method }} {{ request.method }}
</span> </span>
<span class="request-queue-progress__endpoint" :title="request.url">{{ request.url }}</span> <span class="request-queue-progress__endpoint" :title="request.url">{{ request.url }}</span>
<span class="request-queue-progress__time" :title="request.serverTiming || ''"> <span class="request-queue-progress__time">
{{ formatDuration(request.requestDurationMs) }} {{ formatDuration(request.requestDurationMs) }}
</span> </span>
</li> </li>
@@ -772,9 +585,7 @@ onBeforeUnmount(() => {
{{ insight.label }} {{ insight.label }}
</span> </span>
<span class="request-queue-progress__bottom-request-time">{{ insight.timeAgoText }}</span> <span class="request-queue-progress__bottom-request-time">{{ insight.timeAgoText }}</span>
<span class="request-queue-progress__bottom-request-latency" :title="insight.serverTiming"> <span class="request-queue-progress__bottom-request-latency">{{ insight.latencyText }}</span>
{{ insight.latencyText }}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -835,10 +646,9 @@ onBeforeUnmount(() => {
</aside> </aside>
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box"> <aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
<template v-if="canInspectReleaseRuntime"> <div class="request-queue-progress__section-title">Session release</div>
<div class="request-queue-progress__section-title">Session release</div> <div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<div class="request-queue-progress__section-content request-queue-progress__section-content--release"> <ul class="request-queue-progress__meta-list">
<ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item"> <li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Channel</span> <span class="request-queue-progress__meta-label">Channel</span>
<span <span
@@ -961,15 +771,11 @@ onBeforeUnmount(() => {
</li> </li>
</ul> </ul>
</div> <div class="request-queue-progress__subsection-title">Runtime details</div>
</template>
<div class="request-queue-progress__section-title">Runtime details</div>
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
<ul class="request-queue-progress__meta-list"> <ul class="request-queue-progress__meta-list">
<li class="request-queue-progress__meta-item"> <li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">API URL</span> <span class="request-queue-progress__meta-label">API URL</span>
<span class="request-queue-progress__meta-value" :title="activeApiUrlLabel">{{ activeApiUrlLabel }}</span> <span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
</li> </li>
<li class="request-queue-progress__meta-item"> <li class="request-queue-progress__meta-item">
<span class="request-queue-progress__meta-label">Current host</span> <span class="request-queue-progress__meta-label">Current host</span>
@@ -1660,10 +1466,6 @@ onBeforeUnmount(() => {
.request-queue-progress__bottom-request-latency { .request-queue-progress__bottom-request-latency {
font-size: 11px; font-size: 11px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
max-width: 190px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -1683,3 +1485,4 @@ onBeforeUnmount(() => {
opacity: 0; opacity: 0;
} }
</style> </style>
+2 -2
View File
@@ -3,7 +3,7 @@ import MenuDefault from "@/components/menus/MenuDefault.vue";
import { ref, watch, onMounted } from "vue"; import { ref, watch, onMounted } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js"; import { isAccessibleVisibleDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
// Get the department ID from the URL // Get the department ID from the URL
const route = useRoute(); const route = useRoute();
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null; const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
@@ -26,7 +26,7 @@ const loadDepartments = () => {
value: department.id, value: department.id,
icon: "fas fa-building", icon: "fas fa-building",
children: [], children: [],
hidden: !isAccessibleVisibleNamedDepartment(department, SessionUser.canAccessAssignedDepartment), hidden: !isAccessibleVisibleDepartment(department, SessionUser.canAccessAssignedDepartment),
}); });
}); });
menu_items.value[1].options = options; menu_items.value[1].options = options;
@@ -86,13 +86,6 @@ const menu_items = ref([
children: [], children: [],
hidden: false hidden: false
}, },
{
label: 'Chauffører',
value: '/subusers',
icon: 'fas fa-id-card',
children: [],
hidden: false
},
{ {
label: SessionUser.objects.roles.meta.title, label: SessionUser.objects.roles.meta.title,
value: SessionUser.objects.roles.meta.endpoint, value: SessionUser.objects.roles.meta.endpoint,

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