Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 398045b519 Merge remote-tracking branch 'origin/master' into codex/github-hosted-test-runners
# Conflicts:
#	.github/workflows/tests.yml
#	tests/e2e/self-serve-wash.spec.js
#	tests/unit/playwright-full-workflow.spec.js
2026-07-06 22:37:52 +02:00
Jeppe BandJeppe Bundgaard acbbac588f [codex] disable invoice downloads until invoices exist (#158)
* disable invoice downloads until invoices exist

* enable github-hosted frontend ci runners

* Update POS invoice download visual expectations

* Fix full E2E i18n and self-serve coverage

* Review dynamic view i18n keys

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 22:35:58 +02:00
Jeppe Bundgaard bfb1386d49 Merge remote-tracking branch 'origin/master' into codex/github-hosted-test-runners
# Conflicts:
#	.github/workflows/tests.yml
#	src/i18n/generated/de-v2.json
#	src/i18n/generated/global-v2.json
#	src/i18n/generated/no-v2.json
#	src/i18n/generated/sv-v2.json
#	src/i18n/source/de/phrases/compat/departments/index.json
#	src/i18n/source/de/phrases/compat/objects/index.json
#	src/i18n/source/de/phrases/compat/vehicles/index.json
#	src/i18n/source/global/shared/superuser_dashboard/index.json
#	src/i18n/source/no/phrases/compat/departments/index.json
#	src/i18n/source/no/phrases/compat/objects/index.json
#	src/i18n/source/no/phrases/compat/vehicles/index.json
#	src/i18n/source/sv/phrases/compat/departments/index.json
#	src/i18n/source/sv/phrases/compat/objects/index.json
#	src/i18n/source/sv/phrases/compat/vehicles/index.json
#	tests/e2e/self-serve-wash.spec.js
#	tests/unit/playwright-full-workflow.spec.js
2026-07-06 22:35:31 +02:00
Jeppe B f75ff97042 Merge pull request #139 from copenhagentruckwash/codex/backoffice-title-department-selector
[codex] Move backoffice department selector into title
2026-07-06 22:35:13 +02:00
Jeppe Bundgaard 4aa7af9716 Merge remote-tracking branch 'origin/master' into codex/backoffice-title-department-selector
# Conflicts:
#	.github/workflows/tests.yml
2026-07-06 22:34:43 +02:00
Jeppe BandJeppe Bundgaard d2682da3cc [codex] Improve admin notifications page UI (#145)
* Improve admin notifications page UI

* Stabilize notifications page E2E bootstrap wait

* Fix PR Playwright diff head ref

* Guard MyWashStart timers after test teardown

* Stabilize self-serve wash E2E timing

* Refresh edge gateway fixture runtime state

* Run Playwright E2E on GitHub-hosted runners

* Run all frontend CI on GitHub-hosted runners

* Increase hosted full E2E parallelism

* Stabilize full E2E validation

* Preserve superuser gateway navigation label

* Add targeted Playwright dispatch workflow

* Integrate targeted Playwright dispatch into tests workflow

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 22:33:40 +02:00
Jeppe Bundgaard aa1d2ab623 Merge remote-tracking branch 'origin/master' into codex/backoffice-title-department-selector
# Conflicts:
#	.github/workflows/tests.yml
#	src/i18n/generated/de-v2.json
#	src/i18n/generated/global-v2.json
#	src/i18n/generated/no-v2.json
#	src/i18n/generated/sv-v2.json
#	src/i18n/source/de/phrases/compat/departments/index.json
#	src/i18n/source/de/phrases/compat/objects/index.json
#	src/i18n/source/de/phrases/compat/vehicles/index.json
#	src/i18n/source/global/shared/superuser_dashboard/index.json
#	src/i18n/source/no/phrases/compat/departments/index.json
#	src/i18n/source/no/phrases/compat/objects/index.json
#	src/i18n/source/no/phrases/compat/vehicles/index.json
#	src/i18n/source/sv/phrases/compat/objects/index.json
#	src/i18n/source/sv/phrases/compat/vehicles/index.json
#	tests/e2e/self-serve-wash.spec.js
#	tests/unit/playwright-full-workflow.spec.js
#	tests/unit/playwright-pr-mapping.spec.js
2026-07-06 22:33:27 +02:00
Jeppe Bundgaard e495a099e4 Gate full E2E on focused regressions 2026-07-06 22:30:39 +02:00
Jeppe Bundgaard 929333d264 Add focused E2E dispatch scope 2026-07-06 22:21:30 +02:00
Jeppe B d2cbf823d8 Fix action wheel menu placement stability
Stabilizes the action settings wheel dropdown placement so the menu does not flip above/below after opening, and adds regression coverage plus hosted full-matrix workflow support.
2026-07-06 22:15:46 +02:00
Jeppe Bundgaard ba580e43e6 Align backoffice selector with title 2026-07-06 21:34:44 +02:00
Jeppe Bundgaard c4e29a896a Stabilize release assignment E2E selection 2026-07-06 21:26:16 +02:00
Jeppe Bundgaard 3c49cec213 Review dynamic i18n view keys 2026-07-06 20:47:51 +02:00
Jeppe Bundgaard 9f7ebad5fe Fix hosted full E2E i18n failures 2026-07-06 20:16:16 +02:00
Jeppe B c237fce38d Fix null product query parameters
Fixes product detail requests so null optional query params are omitted while final_price=false is preserved.
2026-07-06 20:14:45 +02:00
Jeppe Bundgaard 8acecc61cd Fix hosted full E2E catalog failures 2026-07-06 20:12:12 +02:00
Jeppe B 63149b7597 Merge pull request #162 from copenhagentruckwash/codex/daily-report-product-targets-ui
[codex] Add daily report product target controls
2026-07-06 19:36:03 +02:00
Jeppe Bundgaard 98d0fbc8c9 Merge remote-tracking branch 'origin/master' into codex/github-hosted-test-runners
# Conflicts:
#	.github/workflows/tests.yml
2026-07-06 19:31:21 +02:00
Jeppe Bundgaard a331eeadcb Speed up hosted full E2E dispatch 2026-07-06 19:27:37 +02:00
Jeppe Bundgaard 33e109b131 Fix rebased limited backoffice home handler 2026-07-06 19:27:37 +02:00
Jeppe Bundgaard 68b4328c72 Allow manual test runs on GitHub-hosted runners 2026-07-06 19:27:35 +02:00
Jeppe Bundgaard dd8a0a1952 Move backoffice department selector into title 2026-07-06 19:27:05 +02:00
Jeppe Bundgaard fef1fc3c12 Allow hosted runners for manual frontend tests 2026-07-06 19:26:28 +02:00
Jeppe Bundgaard b58c1eb9ec Add daily report product target controls 2026-07-06 19:10:50 +02:00
Jeppe B 27994d6ac6 Merge pull request #159 from copenhagentruckwash/codex/superuser-user-overview-hub
[codex] Finish superuser user overview hub
2026-07-06 18:57:04 +02:00
Jeppe Bundgaard 9d6978e9c7 Merge remote-tracking branch 'origin/master' into codex/superuser-user-overview-hub
# Conflicts:
#	.github/workflows/tests.yml
2026-07-06 18:38:40 +02:00
Jeppe Bundgaard 62ae4bc890 [codex] Move PR E2E to hosted runners 2026-07-06 18:33:44 +02:00
Jeppe B c2ce6a859b Merge pull request #150 from copenhagentruckwash/codex/customer-product-fixed-price-overrides
Add customer product fixed price UI
2026-07-06 18:15:31 +02:00
Jeppe BandJeppe Bundgaard aa5e0a5507 Add EAN to customer registration flows (#147)
* Add EAN to economic customer registration

* Focus EAN PR Playwright mapping

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 18:12:05 +02:00
Jeppe Bundgaard 6c1c1def78 Add limited backoffice employee QR login links 2026-07-06 17:32:58 +02:00
Jeppe Bundgaard 1a232183a8 Merge master into fixed price override branch 2026-07-06 17:28:58 +02:00
Jeppe Bundgaard cfa7d5be69 Merge remote-tracking branch 'origin/master' into codex/superuser-user-overview-hub
# Conflicts:
#	.github/workflows/tests.yml
#	scripts/playwright-pr-mapping.mjs
#	src/views/dashboards/userDashboard/wash/MyWashStart.vue
#	tests/unit/playwright-pr-mapping.spec.js
2026-07-06 17:12:45 +02:00
Jeppe B bb375eb149 Merge pull request #144 from copenhagentruckwash/codex/fix-invoicing-period-selfwash
Fix invoicing period Selvvask counts
2026-07-06 17:08:06 +02:00
Jeppe B 7e5e6ce680 Enforce only tankcleaning POS product rules (#151) 2026-07-06 17:07:03 +02:00
Jeppe Bundgaard 1032f8b4f1 Show limited backoffice employees in backoffice 2026-07-06 16:59:46 +02:00
Jeppe B 744a56c8b2 Merge PR #157: Cleanup superuser dashboard tabs
Cleanup superuser dashboard tabs
2026-07-06 16:59:27 +02:00
Jeppe Bundgaard e58372a190 Merge remote-tracking branch 'origin/master' into codex/pr157-merge-resolve-692380
# Conflicts:
#	scripts/playwright-pr-mapping.mjs
#	src/i18n/generated/da-v2.json
#	src/i18n/generated/de-v2.json
#	src/i18n/generated/en-v2.json
#	src/i18n/generated/no-v2.json
#	src/i18n/generated/sv-v2.json
#	src/i18n/source/da/phrases/index.json
#	src/i18n/source/de/phrases/index.json
#	src/i18n/source/en/phrases/index.json
#	src/i18n/source/no/phrases/index.json
#	src/i18n/source/sv/phrases/index.json
#	src/views/dashboards/userDashboard/wash/MyWashStart.vue
#	tests/unit/playwright-pr-mapping.spec.js
2026-07-06 16:59:00 +02:00
Jeppe Bundgaard 19d44b8666 Add limited backoffice user ID translations 2026-07-06 16:53:41 +02:00
Jeppe Bundgaard 92a52d6194 Gate subuser customer navigation and actions 2026-07-06 16:48:24 +02:00
Jeppe BandJeppe Bundgaard db1c0e25c0 Move limited backoffice shortcut into header (#140)
* Move limited backoffice shortcut into header

* Use shared Playwright port locks in CI

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 16:47:33 +02:00
Jeppe Bundgaard 04860a7304 Deduplicate limited backoffice price products 2026-07-06 16:34:10 +02:00
Jeppe B d8d67301ed Add autosave for limited backoffice prices (#143) 2026-07-06 16:26:46 +02:00
Jeppe Bundgaard fa1e915b9c Merge origin/master into Selvvask period fix 2026-07-06 16:07:05 +02:00
Jeppe BandJeppe Bundgaard da86b69b2c Warn before invoicing multiple months together (#155)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 16:02:10 +02:00
Jeppe B 44e3167278 Build superuser department overview page (#156)
Build the superuser department overview page and align PR changed-E2E selection with the PR head ref.
2026-07-06 16:01:26 +02:00
Jeppe B 5cc22f14bc Fix date-only selection handling
Use local date-only parsing and formatting across date inputs and filters so clicked dates persist exactly.\n\nVerified with local format, lint, i18n, build, full unit tests, PR E2E, focused date E2E, broad smoke reruns, and GitHub PR checks.
2026-07-06 15:52:19 +02:00
Jeppe B 8519af5cbb Merge pull request #141 from copenhagentruckwash/codex/custom-pricing-only-departments
Add custom-only department pricing toggle
2026-07-06 15:31:41 +02:00
Jeppe BandJeppe Bundgaard 3fc4be8335 Update limited backoffice employee form (#146)
* Update limited backoffice employee form

* Map limited backoffice changes to focused E2E

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 15:31:16 +02:00
Jeppe Bundgaard 5a5cc0bed3 Guard MyWashStart timers after teardown 2026-07-06 15:02:57 +02:00
Jeppe Bundgaard 1a9e7503ef Stabilize PR Playwright runner load 2026-07-06 15:01:01 +02:00
Jeppe Bundgaard d84e9e3406 Map superuser dashboard changes to E2E 2026-07-06 14:56:18 +02:00
Jeppe Bundgaard c41954993c Fix PR E2E changed diff head 2026-07-06 14:54:41 +02:00
Jeppe Bundgaard a7c8fc3bff Reduce PR E2E runner pressure 2026-07-06 14:52:30 +02:00
Jeppe Bundgaard 6002f97fb6 Finish superuser user overview hub 2026-07-06 14:42:23 +02:00
Jeppe Bundgaard 4378dad2b9 Stabilize PR E2E runner checks 2026-07-06 14:39:06 +02:00
Jeppe BandJeppe Bundgaard 278298ecd2 Submit error reports without screenshots (#153)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:28:24 +02:00
Jeppe Bundgaard b8959cb7bb Cleanup superuser dashboard tabs 2026-07-06 14:07:36 +02:00
Jeppe BandJeppe Bundgaard dec90bbbbf Block restricted mobile additional items (#148)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 14:07:04 +02:00
Jeppe BandJeppe Bundgaard a4b9d2f04d Add superuser vehicle creation flow (#152)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 13:59:40 +02:00
Jeppe Bundgaard 3f42b77d13 Stabilize invoice distribution smoke session setup 2026-07-06 13:48:48 +02:00
Jeppe Bundgaard f2b66ff19f Stabilize recent wash refresh timers in tests 2026-07-06 13:05:01 +02:00
Jeppe Bundgaard 2fd0893773 Add customer product fixed price UI 2026-07-06 12:52:31 +02:00
Jeppe Bundgaard ce844137a0 Harden POS order detail E2E wait 2026-07-06 12:18:20 +02:00
Jeppe Bundgaard 1a1c3db8e5 Fix invoicing period selfwash counts 2026-07-06 10:33:48 +02:00
226 changed files with 11407 additions and 1483 deletions
+256 -17
View File
@@ -6,6 +6,39 @@ on:
branches:
- master
workflow_dispatch:
inputs:
mode:
description: "What to run for a manual dispatch."
required: true
type: choice
default: full
options:
- full
- targeted
- targeted-then-full
target_specs:
description: "Comma- or newline-separated Playwright spec paths under tests/e2e."
required: false
type: string
default: "tests/e2e/superuser-department-overview.spec.js"
target_projects:
description: "JSON array of Playwright projects for targeted mode."
required: false
type: string
default: '["chromium-desktop","chromium-mobile","chromium-tablet","webkit-mobile","webkit-desktop"]'
target_grep:
description: "Optional Playwright grep pattern for targeted mode."
required: false
type: string
default: ""
runner:
description: "Runner pool for this manually dispatched test run"
required: false
default: "self-hosted"
type: choice
options:
- self-hosted
- github-hosted
schedule:
- cron: "0 2 * * *"
@@ -13,16 +46,22 @@ permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.head_ref || github.ref_name }}
cancel-in-progress: true
# Repository variables used as CI runner and credit controls:
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
# - FRONTEND_CI_E2E_RUNNER: JSON runs-on value for Playwright jobs.
# - FRONTEND_CI_PR_E2E_MAX_PARALLEL: numeric Playwright PR job parallelism.
# - FRONTEND_CI_FULL_E2E_MAX_PARALLEL: numeric full-suite job parallelism.
# GitHub-hosted example: ["ubuntu-22.04"], with PR parallelism 2 and full parallelism 1.
jobs:
format-tests:
# CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, pleno, frontend]
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
timeout-minutes: 15
steps:
- name: Repair self-hosted workspace permissions
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
@@ -55,10 +94,11 @@ jobs:
build-and-unit:
needs: format-tests
runs-on: [self-hosted, Linux, X64, pleno, frontend]
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
timeout-minutes: 30
steps:
- name: Repair self-hosted workspace permissions
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
@@ -97,15 +137,195 @@ jobs:
env:
VITEST_BATCH_SIZE: 5
e2e-pr:
if: github.event_name != 'schedule'
e2e-targeted:
if: >
github.event_name == 'workflow_dispatch' &&
(inputs.mode == 'targeted' || inputs.mode == 'targeted-then-full')
needs: build-and-unit
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
timeout-minutes: 30
name: E2E-targeted-${{ matrix.project }}
# Use GitHub-hosted runners to avoid self-hosted desktop contention and sleep/power events.
runs-on: ubuntu-24.04
timeout-minutes: 35
strategy:
fail-fast: false
max-parallel: 4
matrix:
project: ${{ fromJSON(inputs.target_projects || '["chromium-desktop"]') }}
env:
MATRIX_PROJECT: ${{ matrix.project }}
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-targeted-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
PLAYWRIGHT_WORKERS: 1
PLAYWRIGHT_VIDEO_MODE: on-first-retry
TARGET_GREP: ${{ inputs.target_grep }}
TARGET_SPECS: ${{ inputs.target_specs }}
RUN_ID: ${{ github.run_id }}
steps:
- name: Normalize workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
- name: Run targeted Playwright specs in container
shell: bash
run: |
set -euo pipefail
case "$MATRIX_PROJECT" in
chromium-mobile) project_offset=1 ;;
chromium-desktop) project_offset=2 ;;
chromium-tablet) project_offset=3 ;;
webkit-mobile) project_offset=31 ;;
webkit-desktop) project_offset=32 ;;
webkit-tablet) project_offset=33 ;;
firefox-mobile) project_offset=61 ;;
firefox-desktop) project_offset=62 ;;
firefox-tablet) project_offset=63 ;;
*) echo "Unsupported Playwright project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + project_offset))
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
for ((candidate = port_seed; candidate < port_seed + 1000; candidate += 1)); do
lock_dir="${lock_root}/${candidate}.lock"
if ! mkdir "$lock_dir" 2>/dev/null; then
continue
fi
if ss -H -ltn "sport = :${candidate}" 2>/dev/null | grep -q .; then
rmdir "$lock_dir" || true
continue
fi
playwright_port_lock="$lock_dir"
playwright_dev_port="$candidate"
break
done
if [[ -z "$playwright_dev_port" ]]; then
echo "Unable to find a free Playwright dev-server port." >&2
exit 1
fi
trap 'if [[ -n "${playwright_port_lock:-}" ]]; then rmdir "$playwright_port_lock" 2>/dev/null || true; fi' EXIT
if docker info >/dev/null 2>&1; then
docker_cmd=(docker)
elif sudo -n docker info >/dev/null 2>&1; then
docker_cmd=(sudo docker)
else
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
exit 1
fi
mkdir -p output/playwright
scripts/ci/runner-diagnostics.sh "before targeted Playwright ${MATRIX_PROJECT}" -- "${docker_cmd[@]}"
SYSTEMD_INHIBIT_REASON="Frontend targeted Playwright ${MATRIX_PROJECT}" \
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
--volume "$PWD:/source:ro" \
--volume "$PWD/output/playwright:/work/output/playwright" \
--workdir /work \
--env HOME=/tmp \
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
--env TARGET_GREP="$TARGET_GREP" \
--env TARGET_SPECS="$TARGET_SPECS" \
mcr.microsoft.com/playwright:v1.58.2-noble \
bash -lc '
set -euo pipefail
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
git config --global --add safe.directory /work
install_dependencies() {
local attempt
for attempt in 1 2 3; do
if npm ci --legacy-peer-deps --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000; then
return 0
fi
if [[ "$attempt" == "3" ]]; then
return 1
fi
echo "npm ci failed on attempt ${attempt}; retrying..." >&2
sleep 20
done
}
install_dependencies
ulimit -n 16384 || true
mapfile -t spec_args < <(printf "%s\n" "$TARGET_SPECS" | tr "," "\n" | sed "s/^[[:space:]]*//;s/[[:space:]]*$//;/^$/d")
if [[ "${#spec_args[@]}" -eq 0 && -z "${TARGET_GREP:-}" ]]; then
echo "Provide at least one spec path or grep pattern." >&2
exit 1
fi
for spec_path in "${spec_args[@]}"; do
if [[ "$spec_path" == /* || "$spec_path" == *".."* || "$spec_path" != tests/e2e/* ]]; then
echo "Targeted spec must stay under tests/e2e: $spec_path" >&2
exit 1
fi
if [[ ! -f "$spec_path" ]]; then
echo "Targeted spec does not exist: $spec_path" >&2
exit 1
fi
done
args=("${spec_args[@]}")
if [[ -n "${TARGET_GREP:-}" ]]; then
args+=(--grep "$TARGET_GREP")
fi
args+=(--project="$MATRIX_PROJECT")
npx playwright test "${args[@]}"
'
- name: Runner diagnostics after Playwright failure
if: failure() || cancelled()
continue-on-error: true
run: scripts/ci/runner-diagnostics.sh "after targeted Playwright ${{ matrix.project }}"
- name: Upload Playwright report
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-targeted-${{ matrix.project }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
if-no-files-found: ignore
retention-days: 1
e2e-pr:
if: >
always() &&
github.event_name != 'schedule' &&
needs.build-and-unit.result == 'success' &&
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
(
github.event_name != 'workflow_dispatch' ||
inputs.mode == 'full' ||
needs.e2e-targeted.result == 'success'
)
needs: [build-and-unit, e2e-targeted]
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
timeout-minutes: 45
strategy:
fail-fast: false
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
matrix:
suite: [core, changed]
project: [chromium-desktop, chromium-mobile]
@@ -116,6 +336,7 @@ jobs:
PLAYWRIGHT_VIDEO_MODE: on-first-retry
steps:
- name: Repair self-hosted workspace permissions
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
@@ -142,20 +363,29 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
HEAD_SHA: ${{ github.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.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"
head_ref="$PR_HEAD_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"
head_ref="$HEAD_SHA"
else
base_ref="$PUSH_BEFORE_SHA"
head_ref="$HEAD_SHA"
fi
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_HEAD_SHA" ]]; then
head_ref="$PR_HEAD_SHA"
else
head_ref="$HEAD_SHA"
fi
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v5
@@ -183,8 +413,9 @@ jobs:
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
@@ -281,15 +512,21 @@ jobs:
if: >
always() &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
needs.build-and-unit.result == 'success' &&
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr]
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') &&
(
github.event_name != 'workflow_dispatch' ||
inputs.mode == 'full' ||
needs.e2e-targeted.result == 'success'
)
needs: [build-and-unit, e2e-pr, e2e-targeted]
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 2
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '1') }}
matrix:
browser: [chromium, webkit, firefox]
device: [mobile, desktop, tablet]
@@ -311,6 +548,7 @@ jobs:
PLAYWRIGHT_VIDEO_MODE: off
steps:
- name: Repair self-hosted workspace permissions
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
@@ -361,8 +599,9 @@ jobs:
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
+12
View File
@@ -2415,6 +2415,12 @@ paths:
type: string
description: Contact person name
example: "Mikkel"
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
g_recaptcha_response:
type: string
description: reCAPTCHA verification token
@@ -8189,6 +8195,12 @@ paths:
email: {type: string}
phone: {type: integer}
name: {type: string}
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
responses:
'200':
description: Success
+70 -2
View File
@@ -28,6 +28,12 @@ export const sourceMappings = [
specs: ["tests/e2e/navigation.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\//u, /^src\/services\/limitedBackoffice\.js$/u],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-roles-permissions",
patterns: [
@@ -37,6 +43,40 @@ export const sourceMappings = [
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-users",
patterns: [/^src\/views\/dashboards\/superUserDashboard\/user\//u],
specs: ["tests/e2e/superuser-users.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-dashboard",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/SuperUserDashboard(?:Navigation)?\.vue$/u,
/^src\/components\/displays\/superuser\/system\//u,
],
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "superuser-department-overview",
patterns: [
/^src\/services\/superuserDepartmentOverview\.js$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/Department\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/SuperUserDashboardDepartmentNavigation\.vue$/u,
],
specs: ["tests/e2e/superuser-department-overview.spec.js"],
projects: chromiumProjects,
},
{
name: "header-navigation",
patterns: [
/^src\/components\/viewport\/page\/headers\//u,
/^src\/components\/models\/navigation\/items\/NavigationMenuItemsGlobal\.vue$/u,
],
specs: ["tests/e2e/navigation.smoke.spec.js", "tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "booking",
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
@@ -55,8 +95,20 @@ export const sourceMappings = [
},
{
name: "pos",
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"],
patterns: [
/(?:^|[/_.-])pos(?:[/_.-]|$)/iu,
/(?:^|\/)(?:POS|Pos)[A-Z][^/]*\.(?:vue|js|ts)$/u,
/^src\/assets\/pos\.css$/u,
/^src\/components\/displays\/boxes\/ProductBox\.vue$/u,
/^src\/features\/customer\/customerProductRules\.js$/u,
/^src\/services\/economicCustomerIdentifiers\.js$/u,
],
specs: [
"tests/e2e/pos-flow.spec.js",
"tests/e2e/pos-mobile-order-flow.spec.js",
"tests/e2e/admin-pos-orders.spec.ts",
"tests/e2e/pos-customer-rules.spec.js",
],
projects: chromiumProjects,
},
{
@@ -68,12 +120,28 @@ export const sourceMappings = [
{
name: "admin-department-notifications",
patterns: [
/^src\/views\/dashboards\/departmentDashboard\/modules\/notifications\/DepartmentNotifications\.vue$/u,
/^src\/components\/displays\/department\/notifications\//u,
/^src\/components\/displays\/pagination\/models\/DepartmentPos\/NotificationsPhonePagination\.vue$/u,
],
specs: ["tests/e2e/admin-department-notifications.spec.ts"],
projects: chromiumProjects,
},
{
name: "admin-daily-report",
patterns: [
/^src\/views\/dashboards\/departmentDashboard\/modules\/daily-report\//u,
/^src\/components\/session\/token\/SessionUser\/Objects\/DepartmentDailyReports\.vue$/u,
],
specs: ["tests/e2e/admin-daily-report.spec.ts"],
projects: chromiumProjects,
},
{
name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\/LimitedBackoffice/u],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "invoicing",
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
+1
View File
@@ -101,6 +101,7 @@ export const ownedFilesByRole = {
"superuser-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts",
"superuser-department-branding.spec.js",
"superuser-department-overview.spec.js",
"superuser-department-gates.spec.ts",
"superuser-department-lanes.spec.ts",
"superuser-department-pricing-custom-only.spec.ts",
+11 -45
View File
@@ -6,6 +6,7 @@ import Swal from "sweetalert2";
import CustomerProductDiscountDisplay
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
import { useI18n } from 'vue-i18n';
import { isProductRestrictedForCustomer } from "@/features/customer/customerProductRules.js";
const { t } = useI18n();
const expandIcon = ref(null);
@@ -83,23 +84,6 @@ const hasRestrictAdditionalServices = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
};
const hasRestrictSpotFree = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictSpotFree');
};
const hasRestrictInteriorCleaning = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictInteriorCleaning');
};
const hasRestrictTankCleaning = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictTankCleaning');
};
const hasOnlyTankCleaning = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'onlyTankCleaning');
};
/**
* On before add product addon
* This is used to check if adding a product has any requirements.
@@ -162,37 +146,19 @@ const doesAddonHaveNoteRequirement = (addon) => {
};
const isAddonRestricted = (addon) => {
const addonName = getAddonName(addon).toLowerCase();
// Check if the addon has "Spot Free" in the name
if (hasRestrictSpotFree() && addonName.includes('spot free')) {
return true;
}
// Check if the addon has "Indvendig vask" in the name
if (hasRestrictInteriorCleaning() && addonName.includes('indvendig vask')) {
return true;
}
const addonProduct = getAddonProduct(addon);
return isProductRestrictedForCustomer({
...addonProduct,
name: getAddonName(addon) || addonProduct.name,
category: addonProduct.category ?? addon?.category,
}, customerAttributesSafe.value);
};
const isProductRestricted = () => {
const productName = String(props.name || '').toLowerCase();
// Check if the product has "Spot Free" in the name
if (hasRestrictSpotFree() && productName.includes('spot free')) {
return true;
}
// Check if the product has "Indvendig vask" in the name
if (hasRestrictInteriorCleaning() && productName.includes('indvendig vask')) {
return true;
}
// Check if the product category is 5 (Tank Cleaning)
if (props.product?.category === 5) {
if (hasRestrictTankCleaning()) {
return true;
}
if (!hasOnlyTankCleaning()) {
return true;
}
}
return isProductRestrictedForCustomer({
...props.product,
name: props.name || props.product?.name,
}, customerAttributesSafe.value);
};
const orderByOrderPriority = (addons) => {
@@ -126,6 +126,8 @@ const dropdownRoot = ref(null);
const dropdownContent = ref(null);
const isDropdownOpen = ref(false);
const dropdownInstanceId = Math.random().toString(36).substring(2, 15);
const dropdownPlacement = ref("bottom");
const isDropdownPlacementLocked = ref(false);
const shouldOpenDropdownUp = ref(false);
const dropdownMaxHeight = ref(null);
const isDesktopFlyoutLayout = ref(false);
@@ -140,6 +142,7 @@ const previewRequestsInFlight = new Set();
const generatedObjectUrls = new Set();
let previewStateGeneration = 0;
let contentResizeObserver = null;
let dropdownLayoutUpdateId = 0;
const desktopFlyoutMinViewportWidth = 1400;
const desktopFlyoutRootPanelWidthRem = 15;
const desktopFlyoutSubmenuWidthRem = 17;
@@ -230,6 +233,8 @@ const dropdownContentStyle = computed(() => {
});
const resetDropdownLayout = () => {
dropdownPlacement.value = "bottom";
isDropdownPlacementLocked.value = false;
shouldOpenDropdownUp.value = false;
dropdownMaxHeight.value = null;
isFixedPosition.value = false;
@@ -291,7 +296,35 @@ const getViewportInsets = () => {
};
};
const getDropdownPlacementSpaces = (triggerRect, viewportInsets = getViewportInsets()) => ({
bottom: Math.max(window.innerHeight - triggerRect.bottom - viewportInsets.bottom, 0),
top: Math.max(triggerRect.top - viewportInsets.top, 0),
});
const resolveDropdownPlacement = (triggerRect, menuHeight, viewportInsets = getViewportInsets()) => {
const spaces = getDropdownPlacementSpaces(triggerRect, viewportInsets);
return menuHeight > spaces.bottom && spaces.top > spaces.bottom ? "top" : "bottom";
};
const getDropdownAvailableHeight = (triggerRect, viewportInsets = getViewportInsets()) => {
const spaces = getDropdownPlacementSpaces(triggerRect, viewportInsets);
return dropdownPlacement.value === "top" ? spaces.top : spaces.bottom;
};
const lockDropdownPlacement = (triggerRect, menuHeight, viewportInsets = getViewportInsets()) => {
if (!isDropdownPlacementLocked.value) {
dropdownPlacement.value = resolveDropdownPlacement(triggerRect, menuHeight, viewportInsets);
isDropdownPlacementLocked.value = true;
}
shouldOpenDropdownUp.value = dropdownPlacement.value === "top";
};
const updateDropdownLayout = async () => {
const updateId = ++dropdownLayoutUpdateId;
if (!isDropdownOpen.value || !dropdownRoot.value || !dropdownContent.value) {
resetDropdownLayout();
return;
@@ -299,35 +332,41 @@ const updateDropdownLayout = async () => {
await nextTick();
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownRoot.value || !dropdownContent.value) {
return;
}
const triggerElement = dropdownRoot.value.querySelector(".dropdown-trigger");
const triggerRect = (triggerElement ?? dropdownRoot.value).getBoundingClientRect();
syncDesktopFlyoutState(triggerRect);
syncDesktopFlyoutPosition();
await nextTick();
if (!dropdownContent.value) {
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownContent.value) {
return;
}
const viewportInsets = getViewportInsets();
const viewportTop = viewportInsets.top;
const viewportBottom = window.innerHeight - viewportInsets.bottom;
const spaceBelow = Math.max(window.innerHeight - triggerRect.bottom - viewportInsets.bottom, 0);
const spaceAbove = Math.max(triggerRect.top - viewportInsets.top, 0);
const menuHeight = Math.ceil(dropdownContent.value.scrollHeight);
const openUpward = menuHeight > spaceBelow && spaceAbove > spaceBelow;
const availableHeight = openUpward ? spaceAbove : spaceBelow;
lockDropdownPlacement(triggerRect, menuHeight, viewportInsets);
const availableHeight = getDropdownAvailableHeight(triggerRect, viewportInsets);
const nextMaxHeight = availableHeight > 0 && menuHeight > availableHeight ? Math.floor(availableHeight) : null;
shouldOpenDropdownUp.value = openUpward;
dropdownMaxHeight.value = nextMaxHeight;
await nextTick();
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownContent.value) {
return;
}
syncDesktopFlyoutPosition();
await nextTick();
if (!dropdownContent.value) {
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownContent.value) {
return;
}
@@ -342,6 +381,12 @@ const updateDropdownLayout = async () => {
const boundedHeight = Math.floor(renderedMenuRect.height - overflowAbove - overflowBelow);
dropdownMaxHeight.value = boundedHeight > 0 ? boundedHeight : 1;
await nextTick();
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value) {
return;
}
syncDesktopFlyoutPosition();
};
@@ -1465,7 +1510,7 @@ onMounted(() => {
contentResizeObserver = new ResizeObserver(() => {
if (isDropdownOpen.value) {
syncDesktopFlyoutPosition();
void updateDropdownLayout();
}
});
@@ -2399,7 +2444,7 @@ watch(
() => {
if (isDropdownOpen.value) {
void nextTick(() => {
syncDesktopFlyoutPosition();
void updateDropdownLayout();
});
}
}
@@ -2507,7 +2552,6 @@ const syncDesktopFlyoutPosition = () => {
const triggerRect = triggerEl.getBoundingClientRect();
const contentHeight = dropdownContentEl.scrollHeight;
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const contentRect = dropdownContentEl.getBoundingClientRect();
@@ -2537,12 +2581,13 @@ const syncDesktopFlyoutPosition = () => {
const viewportInsets = getViewportInsets();
const viewportTop = viewportInsets.top;
const viewportBottom = viewportHeight - viewportInsets.bottom;
let top = triggerRect.bottom;
if (top + contentHeight > viewportBottom) {
top = Math.max(viewportTop, triggerRect.top - contentHeight);
}
const effectiveContentHeight = dropdownMaxHeight.value
? Math.min(contentHeight, dropdownMaxHeight.value)
: contentHeight;
const top =
dropdownPlacement.value === "top"
? Math.max(viewportTop, triggerRect.top - effectiveContentHeight)
: triggerRect.bottom;
const nextFixedStyles = {
top: `${top}px`,
@@ -2558,18 +2603,8 @@ const syncDesktopFlyoutPosition = () => {
fixedPositionStyles.value = nextFixedStyles;
}
} else {
const contentRect = dropdownContentEl.getBoundingClientRect();
const contentBottom = contentRect.top + contentRect.height;
if (contentBottom > viewportHeight) {
const nextTop = `${triggerRect.top - contentRect.height}px`;
if (dropdownContentEl.style.top !== nextTop) {
dropdownContentEl.style.top = nextTop;
}
} else {
if (dropdownContentEl.style.top !== "") {
dropdownContentEl.style.top = "";
}
if (dropdownContentEl.style.top !== "") {
dropdownContentEl.style.top = "";
}
}
};
@@ -5,6 +5,7 @@ import { useWindowSize } from "@vueuse/core";
import { BMessage } from "buefy";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
type DateRange = {
startDate: Date,
@@ -115,23 +116,11 @@ const availableYears = computed(() => (
));
const formatDateInputValue = (date: Date) => {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
return "";
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
return formatLocalDateOnly(date);
};
const parseDateInputValue = (value: string) => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return new Date(value);
}
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
return parseLocalDateOnly(value);
};
const isSameDateInputValue = (left: Date, right: Date) => (
@@ -24,8 +24,24 @@ const onCustomerChange = () => {
isCustomerSelected.value = false;
}
const parseValue = (value) => {
let tmp_value = parseFloat(value).toFixed(2);
const parseFixedPriceValue = (value) => {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? parsed : null;
}
const formatNumber = (value) => Number.isInteger(value) ? String(value) : value.toFixed(2);
const parseValue = (discount) => {
const fixedPrice = parseFixedPriceValue(discount.fixed_price);
if (fixedPrice !== null) {
return `${formatNumber(fixedPrice)} Kr.`;
}
let tmp_value = parseFloat(discount.percentage).toFixed(2);
// Add the percentage sign
return `${tmp_value}%`;
}
@@ -78,7 +94,7 @@ watch(customer_id, onCustomerChange, { immediate: true });
<!--<span class="icon is-small mr-1">
<i class="fas fa-percent" aria-hidden="true"></i>
</span> -->
<span>{{ parseValue(discount.percentage) }}</span>
<span>{{ parseValue(discount) }}</span>
</div>
</div>
</div>
@@ -86,4 +102,4 @@ watch(customer_id, onCustomerChange, { immediate: true });
<style scoped>
</style>
</style>
@@ -29,6 +29,8 @@ const discount_product = ref(null);
const discount_category = ref(null);
// The global discount (if any)
const discount_global = ref(null);
// The fixed product price (if any)
const fixed_product_price = ref(null);
// Show the discounts dropdown
const showDiscountsDropdown = ref(false);
@@ -49,6 +51,19 @@ const hasGlobalDiscount = () => {
return discount_global.value > 0;
}
const hasFixedProductPrice = () => {
return fixed_product_price.value !== null;
}
const parseFixedPriceValue = (value) => {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? parsed : null;
}
// Set the product discount
const setProductDiscount = (percentage) => {
// Check if the percentage is a number
@@ -83,6 +98,16 @@ const setGlobalDiscount = (percentage) => {
discount_global.value = percentage;
}
const setFixedProductPrice = (fixedPrice) => {
const parsedFixedPrice = parseFixedPriceValue(fixedPrice);
if (parsedFixedPrice === null) {
fixed_product_price.value = null;
return;
}
fixed_product_price.value = parsedFixedPrice;
}
// Debugging function
const getDiscountDebug = () => {
@@ -101,9 +126,15 @@ const getDiscountDebug = () => {
// Parse the customer discounts
const parseCustomerDiscounts = () => {
const tmp_discount_product = props.customer_discounts.find((discount) => discount.product_or_category_id === props.product.id && !discount.is_category)
const tmp_discount_category = props.customer_discounts.find((discount) => discount.product_or_category_id === props.product.category && discount.is_category);
const tmp_discount_global = props.customer_discounts.find((discount) => discount.id === 999999 && discount.is_category);
discount_product.value = null;
discount_category.value = null;
discount_global.value = null;
fixed_product_price.value = null;
const customerDiscounts = Array.isArray(props.customer_discounts) ? props.customer_discounts : [];
const tmp_discount_product = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.id && Number(discount.is_category) === 0)
const tmp_discount_category = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.category && Number(discount.is_category) === 1);
const tmp_discount_global = customerDiscounts.find((discount) => discount.id === 999999 && Number(discount.is_category) === 1);
//console.log("Product discount: ", tmp_discount_product);
//console.log("Category discount: ", tmp_discount_category);
@@ -112,6 +143,7 @@ const parseCustomerDiscounts = () => {
// Set the product discount
if (tmp_discount_product) {
setProductDiscount(tmp_discount_product.percentage);
setFixedProductPrice(tmp_discount_product.fixed_price);
}
// Set the category discount
if (tmp_discount_category) {
@@ -125,6 +157,10 @@ const parseCustomerDiscounts = () => {
// Get the best discount for the customer
const getBestDiscount = () => {
if (hasFixedProductPrice()) {
highestEligibleDiscount.value = 0;
return 0;
}
// Set the best discount to 0
let tmp_best_discount = 0;
// Check if the product discount is higher than the current best discount
@@ -182,13 +218,32 @@ watch(() => props.customer_discounts, () => {
<span
aria-haspopup="true"
aria-controls="dropdown-menu"
v-if="highestEligibleDiscount > 0"
v-if="hasFixedProductPrice()"
class="tag is-success is-light is-text text-can-not-select"
>{{ fixed_product_price }} Kr.</span>
<span
aria-haspopup="true"
aria-controls="dropdown-menu"
v-else-if="highestEligibleDiscount > 0"
class="tag is-warning is-light is-text text-can-not-select"
> -{{ highestEligibleDiscount }}%</span>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content py-0">
<div class="list has-overflow-ellipsis" style="width: 340px">
<template v-if="hasFixedProductPrice()">
<a class="list-item">
<div class="list-item-content">
<div class="list-item-title">Fast pris</div>
</div>
<div class="list-item-controls list-item-controls-force-visible">
<div class="tags has-addons">
<span class="tag is-success is-light">{{ fixed_product_price }} Kr.</span>
</div>
</div>
</a>
</template>
<template v-else>
<!-- Best discount -->
<a class="list-item">
<div class="list-item-content">
@@ -239,6 +294,7 @@ watch(() => props.customer_discounts, () => {
</div>
</div>
</a>
</template>
</div>
</div>
</div>
@@ -254,4 +310,4 @@ watch(() => props.customer_discounts, () => {
.text-can-not-select {
user-select: none;
}
</style>
</style>
@@ -817,6 +817,10 @@ const isDraftAssignmentModalOpen = computed(() => {
});
const canEditTransaction = (order) => {
if (props.isCustomerView) {
return SessionUser.canAccessCustomerFeature("orders", "edit");
}
return SessionUser.canAccessAdmin() || SessionUser.canAccessDepartment(order.department_id);
};
@@ -1259,7 +1263,7 @@ const formatCashierName = (order) => {
:hover-text="order.reference"
:parse-function="(value) => truncateOrderField(value, 10)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => true"
:permission-check-function="() => canEditTransaction(order)"
/>
<EditableTableColumn
:object="order"
@@ -1278,7 +1282,7 @@ const formatCashierName = (order) => {
:hover-text="order.po"
:parse-function="(value) => truncateOrderField(value, 10)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => canSeePoField() || SessionUser.canAccessAdmin()"
:permission-check-function="() => canEditTransaction(order)"
/>
<td>{{ order.created_at }}</td>
<td>{{ SessionUser.functions.currency.toLocal(order.total_net_amount) }}</td>
@@ -1774,7 +1778,7 @@ const formatCashierName = (order) => {
:hover-text="order.reference"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => true"
:permission-check-function="() => canEditTransaction(order)"
/>
</small>
</div>
@@ -1818,7 +1822,7 @@ const formatCashierName = (order) => {
:hover-text="order.po"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => canSeePoField() || SessionUser.canAccessAdmin()"
:permission-check-function="() => canEditTransaction(order)"
/>
</small>
</div>
@@ -1863,7 +1867,7 @@ const formatCashierName = (order) => {
:hover-text="order.reg_1"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => true"
:permission-check-function="() => canEditTransaction(order)"
/>
</small>
</div>
@@ -1895,7 +1899,7 @@ const formatCashierName = (order) => {
:hover-text="order.reg_2"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => true"
:permission-check-function="() => canEditTransaction(order)"
/>
</small>
</div>
@@ -1927,7 +1931,7 @@ const formatCashierName = (order) => {
:hover-text="order.reg_3"
:parse-function="(value) => truncateOrderField(value, 20)"
:edit-function="SessionUser.objects.orders.showEditObjectFieldForm"
:permission-check-function="() => true"
:permission-check-function="() => canEditTransaction(order)"
/>
</small>
</div>
@@ -30,6 +30,7 @@ import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
import {
@@ -377,8 +378,8 @@ const fetchDuplicateOrdersForContext = async (context) => {
try {
const response = await SessionUser.request(SessionUser.objects.orders.meta.endpoint, "GET", {
filters: `reg_1:${normalizedContext.reg1},department_id:${department_id.value},created_at-date_from:${
new Date().toISOString().split("T")[0]
},created_at-date_to:${new Date().toISOString().split("T")[0]}`,
todayLocalDateOnly()
},created_at-date_to:${todayLocalDateOnly()}`,
limit: 5,
});
@@ -22,7 +22,7 @@ import PosDepartmentStep2MobileVehicleSelection
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import PosDepartmentStepMobile2FloatingCart
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue";
import { isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue";
import { isAddonRestricted, canBuyAdditionalServices, isProductRestricted } from "@/components/shop/POSDepartmentProcess.vue";
const props = defineProps({
label: {
@@ -46,6 +46,7 @@ const props = defineProps({
},
});
const canSelectAdditionalItems = computed(() => canBuyAdditionalServices());
const checked = ref(props.defaultChecked);
// Function to generate a summary from the last order
function generateSummary(order: PosOrder): string {
@@ -72,6 +73,12 @@ const displaySubtitle = computed(() => {
});
// Emit event on toggle
function onToggle(isOpen: boolean) {
if (isOpen && !canSelectAdditionalItems.value) {
checked.value = false;
pos.views.additionalItemSelection.value = false;
return;
}
checked.value = isOpen;
// Open the additional item selection view if toggled open
if (isOpen) {
@@ -118,17 +125,45 @@ const getAvailableAdditionalItems = () => {
}));
}
const availableAdditionalItems = ref<Addon[]>(getAvailableAdditionalItems());
const isAdditionalItemRestricted = (product: PosProduct) => {
if (!canSelectAdditionalItems.value) {
return true;
}
if (isProductRestricted(product)) {
return true;
}
return isAddonRestricted(convertProductToAddon(product));
}
watch(canSelectAdditionalItems, (canSelect) => {
if (!canSelect) {
checked.value = false;
pos.views.additionalItemSelection.value = false;
}
});
// Computed property to filter out restricted additional items based on customer attributes
const filteredAdditionalItems = computed(() => {
// If additional services are restricted, return empty array
if (!canBuyAdditionalServices()) {
if (!canSelectAdditionalItems.value) {
return [];
}
// Filter out individually restricted addons
return availableAdditionalItems.value.filter((addon: Addon) => !isAddonRestricted(addon));
return availableAdditionalItems.value.filter((addon: Addon) => {
if (isAddonRestricted(addon)) {
return false;
}
if (addon.product && isProductRestricted(addon.product)) {
return false;
}
return true;
});
});
const onClickAddOtherProduct = () => {
if (!canSelectAdditionalItems.value) {
pos.views.additionalItemSelection.value = false;
return;
}
pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value;
}
@@ -159,7 +194,9 @@ watch(() => pos.transactionItems.additionalItems.value, (newVal) => {
}, { deep: true });
const onClickAddProduct = async (product: PosProduct) => {
// If the product requires note, open note input.
if (isAdditionalItemRestricted(product)) {
return;
}
pos.transactionItems.addAdditionalItem(product);
// If the view is fullscreen, close it after adding
//if (pos.views.additionalItemSelection.value) {
@@ -171,7 +208,7 @@ const onClickAddProduct = async (product: PosProduct) => {
<template>
<div data-testid="pos-mobile-additional-items">
<!-- Minimal view, when not set as fullscreen view -->
<WhiteBoxCard :toggleable="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
<WhiteBoxCard :toggleable="canSelectAdditionalItems && pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
@toggle="onToggle"
:forceState="(pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0) ? true : (pos.views.additionalItemSelection.value)"
@@ -201,7 +238,7 @@ const onClickAddProduct = async (product: PosProduct) => {
</div>
</template>
<!-- Footer -->
<template #footer>
<template #footer v-if="canSelectAdditionalItems">
<!-- Select other product button -->
<a class="card-footer-item" data-testid="pos-mobile-additional-items-open" @click="onClickAddOtherProduct">
<span class="icon">
@@ -212,7 +249,7 @@ const onClickAddProduct = async (product: PosProduct) => {
</template>
</WhiteBoxCard>
<!-- Fullscreen view, when selecting other products -->
<template v-else>
<template v-else-if="canSelectAdditionalItems">
<div data-testid="pos-mobile-additional-items-selection">
<!-- Categories of products -->
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
@@ -62,6 +62,7 @@ watch(
form.invoiceEmail,
form.contactEmail,
form.contactPhone,
form.ean,
canCreateCustomer.value,
searchResult.value,
errorMessage.value,
@@ -124,6 +125,24 @@ watch(
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.ean") }}</small></label
>
<div class="control">
<input
v-model="form.ean"
class="input"
type="text"
inputmode="numeric"
maxlength="13"
:placeholder="$t('admin.pos.ean')"
:disabled="searchResult === null || submitting"
data-testid="pos-mobile-add-customer-ean"
@input="handleAutofillFieldInput('ean')"
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.invoice_email") }}</small></label
@@ -3,6 +3,7 @@ export const POS_ADD_CUSTOMER_AUTOFILL_FIELDS = [
"invoiceEmail",
"contactEmail",
"contactPhone",
"ean",
];
export function normalizePosAddCustomerValue(value) {
@@ -20,6 +21,7 @@ export function createPosAddCustomerFields(overrides = {}) {
invoiceEmail: "",
contactEmail: "",
contactPhone: "",
ean: "",
...overrides,
};
}
@@ -30,6 +32,7 @@ export function createPosAddCustomerManualOverrides(overrides = {}) {
invoiceEmail: false,
contactEmail: false,
contactPhone: false,
ean: false,
...overrides,
};
}
@@ -93,6 +96,7 @@ export function buildPosAddCustomerAutofillValues(searchResult) {
invoiceEmail: normalizePosAddCustomerValue(searchResult?.email),
contactEmail: normalizePosAddCustomerValue(searchResult?.email),
contactPhone: normalizePosAddCustomerValue(searchResult?.phone),
ean: normalizePosAddCustomerValue(searchResult?.ean),
};
}
@@ -12,6 +12,7 @@ import PosDepartmentStepMobileButtonNextStep from "@/components/displays/departm
import SessionUser from "@/components/session/token/SessionUser.vue";
import { PosOrder } from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
// Define the close event to emit when the component is closed
const emit = defineEmits(["close"]);
/** Display variables */
@@ -126,7 +127,7 @@ const liveTransactions = ref(null);
const isLoading = ref(true);
const syncListTransactionHistory = async () => {
let dateToday = new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format
let dateToday = todayLocalDateOnly(); // Get today's date in YYYY-MM-DD format
isLoading.value = true;
// Fetch the list of orders created today for the current department
SessionUser.objects.orders.get
@@ -22,7 +22,7 @@ const props = defineProps({
import {computed, ref, watch} from 'vue';
import { departments, getDepartments, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {orderBy, orderDirection, setOrder, loadList} from "@/components/pagination/paginatedList.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import Swal from "sweetalert2";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
@@ -38,6 +38,8 @@ import {
setCachedXlvaskUsageAmount
} from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();
const redirectDepartmentOrderPage = (orderId, departmentId) => {
// Send the user to the order page (In a new tab)
// `/admin/${departmentId}/modules/pos/orders/${orderId}`
@@ -0,0 +1 @@
export const SELFWASH_PERIOD_ALL_LIMIT = 10000;
@@ -1,6 +1,7 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatLocalDateOnly } from '@/services/dateOnly.js';
type startEndDate = {
start: Date; // The start date of the range
end: Date; // The end date of the range
@@ -27,7 +28,7 @@ const datePresetFunctions = {
month: {
// Get the first day of the month based on the provided date first day at 00:00:01
firstDayOfMonth: (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth(), 2, 0, 0, 1, 0); // Set to the first day of the month at 00:00:01
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0); // Set to the first day of the month at 00:00:00
},
// Get the last day of the month based on the provided date last day at 23:59:59
lastDayOfMonth: (date: Date): Date => {
@@ -116,7 +117,7 @@ export const datePresets = <datePreset[]>[
*/
const convertToISO = (date: Date): string => {
return date.toISOString().split('T')[0]; // Convert to YYYY-MM-DD format
return formatLocalDateOnly(date); // Convert to YYYY-MM-DD format
};
export const dateFunctions = {
@@ -150,4 +151,4 @@ export default defineComponent({
},
});
</script>
<template></template>
<template></template>
@@ -38,7 +38,8 @@ if (router.currentRoute.value.params.departmentId) {
<TableLabeledPagination :label="SessionUser.objects.department_notification_sms.meta.title">
<template #buttons="{ loadList }">
<button
class="button is-info is-small"
class="button is-primary is-small"
data-testid="department-notification-sms-add-button"
@click="SessionUser.objects.department_notification_sms.showCreateObjectForm(() => loadList(), {department: parseInt(router.currentRoute.value.params.departmentId)})"
>
<span class="icon is-small">
@@ -21,9 +21,32 @@ const props = defineProps({
type: Number, default: 0
}
})
import {computed, ref, watch } from "vue";
import {computed, provide, ref, watch } from "vue";
import { useRouter } from "vue-router";
import {
PaginatedListKey,
usePaginatedList,
} from "@/components/pagination/paginatedList.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 XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
import PaginationDisplayTemplateDates
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { useI18n } from 'vue-i18n'
import { BSwitch } from "buefy";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoading,
list,
loadList,
@@ -38,22 +61,8 @@ import {
setOrder,
hideSearchField,
setHideSearchField,
} from "@/components/pagination/paginatedList.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
} = paginatedList;
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
import PaginationDisplayTemplateDates
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { useI18n } from 'vue-i18n'
import { BSwitch } from "buefy";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
const { t } = useI18n()
const router = useRouter();
const SELFWASH_PERIOD_ALL_LIMIT = 10000;
setEndpoint("/modules/xlvask/services/usage/orders", false);
if (props.loadAllAtOnce) {
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
@@ -63,7 +72,7 @@ const parseInitialDate = (value) => {
return new Date();
}
const parsed = new Date(`${value}T00:00:00`);
const parsed = parseLocalDateOnly(value);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
};
@@ -129,7 +138,7 @@ const dateFrom = ref(parseInitialDate(props.initialDateFrom));
const dateTo = ref(parseInitialDate(props.initialDateTo));
const parsedDate = (date) => {
return new Date(date);
return parseLocalDateOnly(date);
};
const reloadScheduled = ref(false);
@@ -148,14 +157,14 @@ const actions = {
from: {
select: (date) => {
dateFrom.value = parsedDate(date);
setFilter("StartTime-date_from", date.toISOString().split("T")[0], false);
setFilter("StartTime-date_from", formatLocalDateOnly(date), false);
scheduleReload();
}
},
to: {
select: (date) => {
dateTo.value = parsedDate(date);
setFilter("StartTime-date_to", date.toISOString().split("T")[0], false);
setFilter("StartTime-date_to", formatLocalDateOnly(date), false);
scheduleReload();
}
},
@@ -109,8 +109,8 @@ import OrdersTable from "@/components/displays/department/pos/orders/ordersTable
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
import {now} from "@vueuse/core";
import { useI18n } from 'vue-i18n'
import { formatLocalDateOnly, parseLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
@@ -176,8 +176,8 @@ if (props.autoLoad) {
const date_from = ref(null);
const date_to = ref(null);
const onDateRangeSelected = (newSelectionStartDate, newSelectionToDate) => {
const formattedStartDate = newSelectionStartDate.toISOString().split("T")[0];
const formattedEndDate = newSelectionToDate.toISOString().split("T")[0];
const formattedStartDate = formatLocalDateOnly(newSelectionStartDate);
const formattedEndDate = formatLocalDateOnly(newSelectionToDate);
date_from.value = formattedStartDate;
date_to.value = formattedEndDate;
setFilter("created_at-date_from", formattedStartDate, true);
@@ -361,7 +361,7 @@ const doesEndpointMatch = (matcher) => {
<!-- Shortcuts for date filters -->
<DatePeriodSelector :on-selection-change="onDateRangeSelected"
:visibility="{ showDailySelector: false, showWeeklySelector: false, showMultipleMonthWarning: false, showUpdateButton: false, showMonthSelector: false, showStartDate: false, showEndDate: false, showSelectionValidity: false, showYearSelector: false, showToLabel: false }"
v-bind:selection="{ startDate: date_from ? new Date(date_from) : new Date(now()), endDate: date_to ? new Date(date_to) : new Date(now()) }"/>
v-bind:selection="{ startDate: parseLocalDateOnly(date_from || todayLocalDateOnly()), endDate: parseLocalDateOnly(date_to || todayLocalDateOnly()) }"/>
</div>
</template>
<template #default>
@@ -10,11 +10,21 @@ const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const { list, loadList, setEndpoint, setFilter, setOrder, hideSearchField, setHideSearchField } = paginatedList;
const {
list,
loadList,
setAdditionalQueryParameters,
setEndpoint,
setFilter,
setOrder,
hideSearchField,
setHideSearchField,
} = paginatedList;
const { t } = useI18n();
setEndpoint("/users", false);
setFilter("customer_number", 0, false);
setAdditionalQueryParameters({ include_limited_backoffice_employees: "true" });
setOrder("created_at", "desc");
// Hide the search field, if the hideSearch prop is set
@@ -24,13 +24,16 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import BookingsTable from "@/components/displays/user/bookings/bookingsTable.vue";
import {departments} from "@/components/pagination/departmentTabs.vue";
import {ref, watch} from "vue";
import {computed, ref, watch} from "vue";
import { Colors } from "@/ThemeConfig.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from 'vue-i18n'
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const canCreateBooking = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
// Set the selected status to all
const selectedStatus = ref("*");
@@ -52,7 +55,7 @@ watch(() => getFilter("status"), (value) => {
// If the route starts with /user, set the endpoint to /user/bookings
if (router.currentRoute.value.path.startsWith("/user")) {
setEndpoint("/user/bookings", false);
setFilter("date", new Date().toISOString().split("T")[0], false);
setFilter("date", todayLocalDateOnly(), false);
setOrder("created_at", "desc");
}
// If the route starts with /superuser, set the endpoint to /bookings
@@ -81,7 +84,7 @@ loadList();
const showingToday = ref(true);
watch(() => getFilter("date"), (value) => {
showingToday.value = value === new Date().toISOString().split("T")[0];
showingToday.value = value === todayLocalDateOnly();
});
// If the screen is mobile, set the is small variable to true
@@ -147,17 +150,17 @@ const showNewOrderBookingsPortal = () => {
</div>
<div class="column is-auto-fill my-3" v-if="!isSmall"/>
<!-- Only show the bookings for today (Switch, if the route is /user) -->
<div class="column is-narrow my-3" v-if="router.currentRoute.value.path.startsWith('/user')" :class="{ 'has-text-right': !isSmall }">
<div class="column is-narrow my-3" v-if="isUserRoute" :class="{ 'has-text-right': !isSmall }">
<label class="label">{{ isSmall ? t('common.today') : t('pagination.show_only_today') }}</label>
<div class="control">
<div class="field">
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? new Date().toISOString().split('T')[0] : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? todayLocalDateOnly() : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
<label for="today"></label>
</div>
</div>
</div>
<!-- Create a new booking, if the route is /user -->
<div class="column is-narrow my-3" v-if="router.currentRoute.value.path.startsWith('/user')" :class="{ 'has-text-right': !isSmall }">
<div class="column is-narrow my-3" v-if="isUserRoute && canCreateBooking" :class="{ 'has-text-right': !isSmall }">
<button
class="button is-link"
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
@@ -188,4 +191,4 @@ const showNewOrderBookingsPortal = () => {
<style scoped>
</style>
</style>
@@ -8,6 +8,7 @@ import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displa
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
import { Colors } from "@/ThemeConfig.vue";
import { useI18n } from "vue-i18n";
import { endOfLocalDate, startOfLocalDate, todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n();
/**
@@ -16,6 +17,7 @@ const { t } = useI18n();
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const isAdminRoute = computed(() => router.currentRoute.value.path.startsWith("/admin"));
const canCreateBooking = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
/**
* Props
*/
@@ -52,11 +54,9 @@ const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
setFilter("datetime-date_from", null, false);
setFilter("datetime-date_to", null, false);
} else {
const startOfDay = new Date().setHours(0, 0, 0, 0);
const endOfDay = new Date().setHours(23, 59, 59, 999);
//setFilter('datetime', null, false);
setFilter("datetime-date_from", new Date(startOfDay).toISOString(), false);
setFilter("datetime-date_to", new Date(endOfDay).toISOString(), false);
setFilter("datetime-date_from", startOfLocalDate(val).toISOString(), false);
setFilter("datetime-date_to", endOfLocalDate(val).toISOString(), false);
}
if (autoLoadList) {
loadList();
@@ -95,7 +95,7 @@ onMounted(() => {
departmentFilter.value = value;
} else if (key === "only_today" && value === true) {
setFilterKey = false; // Since the only_today filter is handled separately
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split("T")[0] } }, false);
onOnlyTodayFilterChange({ target: { value: todayLocalDateOnly() } }, false);
}
if (setFilterKey) {
setFilter(key, value, false);
@@ -170,7 +170,7 @@ onMounted(() => {
<div class="select">
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
<option value="*">{{ t("common.all") }}</option>
<option :value="new Date().toISOString().split('T')[0]">{{ t("common.yes") }}</option>
<option :value="todayLocalDateOnly()">{{ t("common.yes") }}</option>
</select>
</div>
</div>
@@ -193,7 +193,7 @@ onMounted(() => {
@change="
(event) => {
onOnlyTodayFilterChange({
target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' },
target: { value: event.target.checked ? todayLocalDateOnly() : '*' },
});
}
"
@@ -203,7 +203,7 @@ onMounted(() => {
<label for="today"></label>
</div>
</div>
<div class="order-bookings-pagination__new-booking-action">
<div class="order-bookings-pagination__new-booking-action" v-if="canCreateBooking">
<label class="label is-small order-bookings-pagination__desktop-spacer">&nbsp;</label>
<button
class="button is-link button-same-width"
@@ -226,7 +226,7 @@ onMounted(() => {
@change="
(event) => {
onOnlyTodayFilterChange(
{ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } },
{ target: { value: event.target.checked ? todayLocalDateOnly() : '*' } },
false
);
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } });
@@ -8,6 +8,7 @@ import {
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useRouter } from "vue-router";
import VehiclesTable from "@/components/displays/user/vehicles/vehiclesTable.vue";
import { computed } from "vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import {Colors} from "@/ThemeConfig.vue";
@@ -20,6 +21,8 @@ const { t } = useI18n();
* Router
*/
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const canAddVehicle = computed(() => SessionUser.canAccessCustomerFeature("vehicles", "add"));
const props = defineProps({
customer_id: {
@@ -52,9 +55,13 @@ loadList();
:columnLabels="{ type: SessionUser.objects.vehicles.columns.type.label }"
/>
</div>
<div v-if="$slots.actions" class="vehicles-pagination__add-action">
<label class="label is-small">&nbsp;</label>
<slot name="actions" />
</div>
<!-- Create a new vehicle, if the route is /user -->
<div class="vehicles-pagination__add-action"
v-if="router.currentRoute.value.path.startsWith('/user')">
v-if="isUserRoute && canAddVehicle">
<label class="label is-small">&nbsp;</label>
<button
class="button is-link button-same-width vehicles-pagination__add-button"
@@ -1,6 +1,7 @@
<script setup lang="ts">
import PaginationDisplayItemColumn from "@/components/displays/pagination/PaginationDisplayItemColumn.vue";
import { ref, computed } from "vue";
import { computed } from "vue";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const props = defineProps({
label: {
type: String,
@@ -19,7 +20,7 @@ defineExpose({
});
const formattedDate = computed(() => {
return date.value.toISOString().split('T')[0];
return formatLocalDateOnly(date.value);
});
// This component is used to display a date input in a pagination display item column.
@@ -36,8 +37,7 @@ const formattedDate = computed(() => {
class="input"
:value="formattedDate"
@input="(e) => {
const newDate = new Date(e.target.value);
console.log('Selected date:', newDate);
const newDate = parseLocalDateOnly(e.target.value);
emit('update:date', newDate);
}"
/>
@@ -47,4 +47,4 @@ const formattedDate = computed(() => {
<style scoped>
</style>
</style>
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
@@ -10,7 +9,7 @@ import PaginationDisplayTemplateButton
from "@/components/displays/pagination/templates/PaginationDisplayTemplateButton.vue";
import { datePresets , dateFunctions} from '@/components/displays/pagination/PaginationDisplayDates.vue';
const props = defineProps({
defineProps({
startDate: {
type: Date,
required: true,
@@ -46,6 +45,7 @@ function updateStartDate(date: Date) {
// Update the end date model when the date is changed
function updateEndDate(date: Date) {
endDateModel.value = date;
emits('update:endDate', date);
}
</script>
@@ -79,4 +79,4 @@ function updateEndDate(date: Date) {
<style scoped>
</style>
</style>
@@ -1,5 +1,5 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink } from "vue-router";
import { useI18n } from "vue-i18n";
import {
@@ -16,6 +16,7 @@ import {
const { t, te, locale } = useI18n();
const MAX_GATEWAY_CARDS = 8;
const DEFAULT_DASHBOARD_TAB = "infrastructure";
const gatewayStatusPriority = Object.freeze({
OFFLINE: 0,
DEGRADED: 1,
@@ -77,7 +78,14 @@ const gatewayFleetUsage = ref(createEmptyGatewayFleetUsage());
const gatewaySectionSuppressed = ref(false);
const gatewayDepartments = ref({});
const gatewayDepartmentsLoaded = ref(false);
const activeDashboardTab = ref(DEFAULT_DASHBOARD_TAB);
const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value);
const dashboardTabKeys = computed(() => [
DEFAULT_DASHBOARD_TAB,
...(showGatewaySection.value ? ["gateways"] : []),
"modules",
"sessions",
]);
const gatewaySummaryCards = computed(() => [
{
id: "total",
@@ -187,6 +195,16 @@ const isStale = computed(() => {
return nowTick.value - lastLoadedAt.value.getTime() > (refreshAfterSeconds.value * 2000);
});
watch(
() => [activeDashboardTab.value, dashboardTabKeys.value.join("|")],
() => {
if (!dashboardTabKeys.value.includes(activeDashboardTab.value)) {
activeDashboardTab.value = DEFAULT_DASHBOARD_TAB;
}
},
{ immediate: true }
);
const loadStatus = async ({ force = false } = {}) => {
const [snapshotValue] = await Promise.all([
getSuperuserSystemStatus({ force }),
@@ -707,203 +725,250 @@ function modulePath(key) {
</ul>
</div>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
<RouterLink class="section-link" to="/superuser/system/replication">
{{ $t("system_status.actions.open_replication") }}
</RouterLink>
</div>
<div class="status-card-grid">
<article
v-for="card in statusCards"
:key="card.id"
class="status-card"
:class="statusClass(card.status)"
:data-testid="`status-card-${card.id}`"
>
<div class="status-card__top">
<p class="status-card__title">{{ card.title }}</p>
<span class="status-pill" :class="statusClass(card.status)">{{ statusLabel(card.status) }}</span>
</div>
<strong class="status-card__primary">{{ card.primary }}</strong>
<p class="status-card__secondary">{{ card.secondary }}</p>
<small class="status-card__detail">{{ card.detail }}</small>
<small
v-if="card.replicationLabel"
class="status-card__detail status-card__replication"
:data-testid="`status-card-${card.id}-replication`"
>
{{ card.replicationLabel }}
</small>
</article>
</div>
</section>
<section
v-if="showGatewaySection"
class="system-status-section"
data-testid="system-status-gateways"
<b-tabs
v-model="activeDashboardTab"
expanded
type="is-boxed"
class="system-status-tabs"
data-testid="system-status-dashboard-tabs"
>
<div class="section-heading">
<div>
<h3>{{ $t("system_status.sections.gateways") }}</h3>
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
</div>
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
{{ $t("system_status.gateways.actions.open_fleet") }}
</RouterLink>
</div>
<b-tab-item value="infrastructure">
<template #header>
<b-icon pack="fas" icon="server" />
<span data-testid="system-status-tab-infrastructure">
{{ $t("system_status.sections.infrastructure") }}
</span>
</template>
<div class="gateway-summary-grid">
<article
v-for="card in gatewaySummaryCards"
:key="card.id"
class="summary-card gateway-summary-card"
:class="card.toneClass"
:data-testid="`gateway-summary-card-${card.id}`"
<section class="system-status-section" data-testid="system-status-panel-infrastructure">
<div class="section-heading">
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
<RouterLink class="section-link" to="/superuser/system/replication">
{{ $t("system_status.actions.open_replication") }}
</RouterLink>
</div>
<div class="status-card-grid">
<article
v-for="card in statusCards"
:key="card.id"
class="status-card"
:class="statusClass(card.status)"
:data-testid="`status-card-${card.id}`"
>
<div class="status-card__top">
<p class="status-card__title">{{ card.title }}</p>
<span class="status-pill" :class="statusClass(card.status)">{{ statusLabel(card.status) }}</span>
</div>
<strong class="status-card__primary">{{ card.primary }}</strong>
<p class="status-card__secondary">{{ card.secondary }}</p>
<small class="status-card__detail">{{ card.detail }}</small>
<small
v-if="card.replicationLabel"
class="status-card__detail status-card__replication"
:data-testid="`status-card-${card.id}-replication`"
>
{{ card.replicationLabel }}
</small>
</article>
</div>
</section>
</b-tab-item>
<b-tab-item v-if="showGatewaySection" value="gateways">
<template #header>
<b-icon pack="fas" icon="network-wired" />
<span data-testid="system-status-tab-gateways">
{{ $t("system_status.sections.gateways") }}
</span>
</template>
<section
class="system-status-section"
data-testid="system-status-gateways"
>
<p class="summary-label">{{ card.title }}</p>
<strong>{{ card.value }}</strong>
</article>
</div>
<div
v-if="gatewayError"
class="notification is-warning is-light gateway-notification"
data-testid="gateway-section-error"
>
{{ $t("system_status.gateways.error") }}
</div>
<div
v-if="gatewayLoading && !gatewayCards.length"
class="notification is-light gateway-notification"
>
{{ $t("system_status.gateways.loading") }}
</div>
<div v-if="gatewayCards.length" class="gateway-grid">
<article
v-for="gateway in gatewayCards"
:key="gateway.id"
class="gateway-card"
:class="gateway.toneClass"
:data-testid="`gateway-card-${gateway.id}`"
>
<div class="gateway-card__top">
<div class="section-heading">
<div>
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
<h3>{{ $t("system_status.sections.gateways") }}</h3>
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
</div>
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
{{ $t("system_status.gateways.actions.open_fleet") }}
</RouterLink>
</div>
<div class="gateway-card__meta">
<span>
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
</span>
<span>
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
</span>
<span v-if="gateway.activeOperationLabel">
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
</span>
<div class="gateway-summary-grid">
<article
v-for="card in gatewaySummaryCards"
:key="card.id"
class="summary-card gateway-summary-card"
:class="card.toneClass"
:data-testid="`gateway-summary-card-${card.id}`"
>
<p class="summary-label">{{ card.title }}</p>
<strong>{{ card.value }}</strong>
</article>
</div>
<p v-if="gateway.message" class="gateway-card__message">
{{ gateway.message }}
</p>
<RouterLink :to="gateway.link" class="module-card__link">
{{ $t("system_status.gateways.actions.open_gateway") }}
</RouterLink>
</article>
</div>
<div
v-else-if="!gatewayLoading && !gatewayError"
class="gateway-empty"
data-testid="gateway-empty-state"
>
{{ $t("system_status.gateways.empty") }}
</div>
</section>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.modules") }}</h3>
</div>
<div class="module-grid">
<article
v-for="module in modules"
:key="module.key"
class="module-card"
:class="statusClass(module.status)"
:data-testid="`module-card-${module.key}`"
>
<div class="module-card__top">
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
<div
v-if="gatewayError"
class="notification is-warning is-light gateway-notification"
data-testid="gateway-section-error"
>
{{ $t("system_status.gateways.error") }}
</div>
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
{{ moduleReasonText(module) }}
</p>
<div class="module-card__meta">
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
<div
v-if="gatewayLoading && !gatewayCards.length"
class="notification is-light gateway-notification"
>
{{ $t("system_status.gateways.loading") }}
</div>
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
{{ $t("system_status.actions.open_config") }}
</RouterLink>
</article>
</div>
</section>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.sessions") }}</h3>
<p>{{ $t("system_status.labels.activity_window", { minutes: sessions.active_window_minutes }) }}</p>
</div>
<div v-if="gatewayCards.length" class="gateway-grid">
<article
v-for="gateway in gatewayCards"
:key="gateway.id"
class="gateway-card"
:class="gateway.toneClass"
:data-testid="`gateway-card-${gateway.id}`"
>
<div class="gateway-card__top">
<div>
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
</div>
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
</div>
<div class="table-container sessions-table">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>{{ $t("system_status.table.active") }}</th>
<th>{{ $t("system_status.table.name") }}</th>
<th>{{ $t("system_status.table.type") }}</th>
<th>{{ $t("system_status.table.device") }}</th>
<th>{{ $t("system_status.table.route") }}</th>
<th>{{ $t("system_status.table.first_seen") }}</th>
<th>{{ $t("system_status.table.last_seen") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
<td>
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
<div class="gateway-card__meta">
<span>
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
</span>
</td>
<td>
<strong>{{ sessionDisplayName(session) }}</strong>
<div class="session-context">{{ sessionContextLabel(session) }}</div>
</td>
<td>{{ formatSessionKind(session.session_kind) }}</td>
<td>{{ formatDeviceType(session.device_type) }}</td>
<td class="session-route">{{ session.last_route || "--" }}</td>
<td>{{ formatDate(session.first_seen_at) }}</td>
<td>{{ formatDate(session.last_seen_at) }}</td>
</tr>
<tr v-if="!sessions.recent_sessions?.length">
<td colspan="7">{{ $t("system_status.states.no_sessions") }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<span>
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
</span>
<span v-if="gateway.activeOperationLabel">
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
</span>
</div>
<p v-if="gateway.message" class="gateway-card__message">
{{ gateway.message }}
</p>
<RouterLink :to="gateway.link" class="module-card__link">
{{ $t("system_status.gateways.actions.open_gateway") }}
</RouterLink>
</article>
</div>
<div
v-else-if="!gatewayLoading && !gatewayError"
class="gateway-empty"
data-testid="gateway-empty-state"
>
{{ $t("system_status.gateways.empty") }}
</div>
</section>
</b-tab-item>
<b-tab-item value="modules">
<template #header>
<b-icon pack="fas" icon="puzzle-piece" />
<span data-testid="system-status-tab-modules">
{{ $t("system_status.sections.modules") }}
</span>
</template>
<section class="system-status-section" data-testid="system-status-panel-modules">
<div class="section-heading">
<h3>{{ $t("system_status.sections.modules") }}</h3>
</div>
<div class="module-grid">
<article
v-for="module in modules"
:key="module.key"
class="module-card"
:class="statusClass(module.status)"
:data-testid="`module-card-${module.key}`"
>
<div class="module-card__top">
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
</div>
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
{{ moduleReasonText(module) }}
</p>
<div class="module-card__meta">
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
</div>
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
{{ $t("system_status.actions.open_config") }}
</RouterLink>
</article>
</div>
</section>
</b-tab-item>
<b-tab-item value="sessions">
<template #header>
<b-icon pack="fas" icon="users" />
<span data-testid="system-status-tab-sessions">
{{ $t("system_status.sections.sessions") }}
</span>
</template>
<section class="system-status-section" data-testid="system-status-panel-sessions">
<div class="section-heading">
<h3>{{ $t("system_status.sections.sessions") }}</h3>
<p>{{ $t("system_status.labels.activity_window", { minutes: sessions.active_window_minutes }) }}</p>
</div>
<div class="table-container sessions-table">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>{{ $t("system_status.table.active") }}</th>
<th>{{ $t("system_status.table.name") }}</th>
<th>{{ $t("system_status.table.type") }}</th>
<th>{{ $t("system_status.table.device") }}</th>
<th>{{ $t("system_status.table.route") }}</th>
<th>{{ $t("system_status.table.first_seen") }}</th>
<th>{{ $t("system_status.table.last_seen") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
<td>
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
</span>
</td>
<td>
<strong>{{ sessionDisplayName(session) }}</strong>
<div class="session-context">{{ sessionContextLabel(session) }}</div>
</td>
<td>{{ formatSessionKind(session.session_kind) }}</td>
<td>{{ formatDeviceType(session.device_type) }}</td>
<td class="session-route">{{ session.last_route || "--" }}</td>
<td>{{ formatDate(session.first_seen_at) }}</td>
<td>{{ formatDate(session.last_seen_at) }}</td>
</tr>
<tr v-if="!sessions.recent_sessions?.length">
<td colspan="7">{{ $t("system_status.states.no_sessions") }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</b-tab-item>
</b-tabs>
</div>
</template>
@@ -924,10 +989,10 @@ function modulePath(key) {
.module-card,
.gateway-card {
border: 1px solid #d7dde7;
border-radius: 18px;
border-radius: 8px;
padding: 1rem 1.1rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.06);
background: #ffffff;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
}
.summary-card--overall {
@@ -991,6 +1056,23 @@ function modulePath(key) {
color: #0f4c81;
}
.system-status-tabs {
min-width: 0;
}
.system-status-tabs :deep(.tab-content) {
padding: 1rem 0 0;
}
.system-status-tabs :deep(.tabs ul) {
align-items: stretch;
}
.system-status-tabs :deep(.tabs a) {
min-height: 2.75rem;
gap: 0.35rem;
}
.status-card__top,
.module-card__top {
display: flex;
@@ -1107,7 +1189,7 @@ function modulePath(key) {
.gateway-empty {
border: 1px dashed #cbd5e1;
border-radius: 18px;
border-radius: 8px;
padding: 1rem 1.1rem;
color: #475569;
background: rgba(248, 250, 252, 0.8);
@@ -12,6 +12,11 @@ import UserOtherVaskeabonnement
import Swal from "sweetalert2";
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
import {
buildMultiMonthInvoiceContext,
MULTI_MONTH_INVOICE_ACTION,
promptMultiMonthInvoiceWarning,
} from "@/services/invoiceMonthSplitWarning.js";
const props = defineProps({
orders: {
@@ -126,32 +131,34 @@ const isAnyOrderSelected = () => {
return selectedInvoiceCollections.value.length > 0;
}
const getSelectedOrders = () => {
return props.orders.filter((order) => selectedInvoiceCollections.value.includes(order.invoice_collection_id));
}
/** Invoice collections */
const onInvoiceCollections = async () => {
// Check if any orders are selected
if (selectedInvoiceCollections.value.length === 0) {
return;
}
/** Create the invoice */
const onCreateInvoiceDraft = async () => {
// Create the invoice
console.log('Create invoice');
await SessionUser.objects.collectedOrderInvoices.functions.economic.invoice(parseInt(props.collectedOrderInvoice.id)).then((response) => {
console.log('Invoice created successfully', response);
Swal.fire({
title: 'Fakturaen er oprettet',
text: 'Fakturaen er oprettet i E-conomic',
icon: 'success',
showConfirmButton: false,
timer: 2000
}).then(() => {
location.reload();
});
}).catch((error) => {
console.log('Error creating invoice', error);
}
)
const selectedOrders = getSelectedOrders();
const invoiceWarningContext = buildMultiMonthInvoiceContext(selectedOrders, {
getDate: (order) => order?.created_at ?? order?.date,
getInvoiceCollectionId: (order) => order?.invoice_collection_id,
});
const invoiceWarningAction = await promptMultiMonthInvoiceWarning({
context: invoiceWarningContext,
splitByMonth: SessionUser.objects.collectedOrderInvoices.functions.split_by_month,
parseErrorMessage: SessionUser.functions.parseErrorMessage,
});
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.SPLIT) {
location.reload();
return;
}
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.CANCEL) {
return;
}
for (let i = 0; i < selectedInvoiceCollections.value.length; i++) {
const selectedInvoiceCollectionId = selectedInvoiceCollections.value[i];
// Check if the invoice collection is already booked
@@ -210,6 +217,7 @@ const isOrderContentVisible = (order) => {
class="button is-small"
@click="onInvoiceCollections()"
:disabled="!isAnyOrderSelected()"
data-testid="invoice-order-table-invoice-button"
>
{{ $t('global.invoice_now') }}
</button>
@@ -274,4 +282,4 @@ const isOrderContentVisible = (order) => {
<style scoped>
</style>
</style>
@@ -11,7 +11,9 @@ defineProps({
});
const editUser = (user) => {
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id);
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id, {
limitedBackofficeManaged: Boolean(user.limited_backoffice_managed),
});
};
</script>
@@ -13,6 +13,7 @@ import { showDownloadWashCertificate } from "@/components/shop/DownloadWashCerti
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import Swal from 'sweetalert2';
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { todayLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
const redirectBookingObjectPage = (objectId) => {
// Send the user to the object page
window.location.href = `/user/bookings/${objectId}`;
@@ -237,7 +238,8 @@ window.addEventListener('resize', () => {
* To organize the bookings, we want to show the bookings that are created today first, then yesterday, then all other days
* @type {string}
*/
const currentDate = new Date().toISOString().split("T")[0];
const currentDate = todayLocalDateOnly();
const previousDate = yesterdayLocalDateOnly();
/**
* Sort the bookings by date
@@ -362,7 +364,7 @@ const canUserEditObject = (object) => {
</span>
<span class="has-text-grey">
<!-- Human readable date (Today, Yesterday, etc.) -->
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split("T")[0] ? $t('tables.bookings.yesterday') : object.date }}
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === previousDate ? $t('tables.bookings.yesterday') : object.date }}
<!-- Number of bookings on the date -->
({{ $t('tables.bookings.bookings_count', { count: objects.filter((booking) => booking.date === object.date).length }) }})
</span>
@@ -45,6 +45,11 @@ const reload = () => {
}
};
const canViewVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "list");
const canEditVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "edit");
const canDeleteVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "delete");
const canShowVehicleRowActions = () => canViewVehicle() || canDeleteVehicle();
const redirectUserVehiclePage = (vehicleId) => {
// Send the user to the vehicle page
window.location.href = `/user/vehicles/${vehicleId}`;
@@ -146,6 +151,7 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="reg"
/>
<!-- Type -->
@@ -153,6 +159,7 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="type"
:parse-function="
(value) => {
@@ -165,6 +172,7 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="wash_subscription"
:parse-function="
(value) => {
@@ -174,7 +182,7 @@ const getProductOptionsLabel = (vehicle) => {
/>
<!-- Product Options, if the wash subscription is set to true -->
<td v-if="!props.compact">
<template v-if="object.wash_subscription">
<template v-if="object.wash_subscription && canEditVehicle()">
<!-- Enabled subscription -->
<ActionSettingsWheelButton
:label="getProductOptionsLabel(object)"
@@ -218,13 +226,14 @@ const getProductOptionsLabel = (vehicle) => {
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:permission-check-function="canEditVehicle"
column="reference"
/>
<!-- Actions -->
<td>
<td v-if="!props.compact">
<!-- Actions stay grouped behind the wheel menu. -->
<ActionSettingsWheelButton
v-if="!props.compact"
v-if="canShowVehicleRowActions()"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
@@ -232,12 +241,14 @@ const getProductOptionsLabel = (vehicle) => {
<template #actions>
<!-- View (Redirect to the vehicle page) -->
<ActionSettingsWheelItem
v-if="canViewVehicle()"
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-eye"
:click-action="() => redirectUserVehiclePage(object.id)"
/>
<!-- Delete -->
<ActionSettingsWheelItem
v-if="canDeleteVehicle()"
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-trash"
:template="'danger'"
@@ -4,11 +4,13 @@ import axios from "axios";
import { API_URL } from "@/config.js";
import { getError, parseError, removeError } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { economicEanPayloadValue, normalizeEconomicEan } from "@/services/economicCustomerIdentifiers.js";
const customerFormData = reactive({
cvr: null,
contactEmail: null,
contactPhone: null
contactPhone: null,
ean: null
});
const deliverSuccess = ref(false);
@@ -17,17 +19,24 @@ let deliverTimeoutId;
const submitCustomer = async () => {
removeError("authRegisterCvr");
try {
await axios.post(API_URL + "/auth/register/cvr", {
const payload = {
cvr: customerFormData.cvr,
invoiceEmail: customerFormData.contactEmail,
contactEmail: customerFormData.contactEmail,
contactPhone: customerFormData.contactPhone,
companyPhone: customerFormData.contactPhone
});
};
const ean = economicEanPayloadValue(customerFormData.ean);
if (ean !== undefined) {
payload.ean = ean;
}
await axios.post(API_URL + "/auth/register/cvr", payload);
customerFormData.contactPhone = null;
customerFormData.contactEmail = null;
customerFormData.cvr = null;
customerFormData.ean = null;
deliverSuccess.value = true;
deliverTimeoutId = setTimeout(() => {
@@ -59,6 +68,20 @@ onBeforeUnmount(() => {
>
</div>
<div class="form-field">
<label for="customer_ean">{{ $t("customer_creation.customer.ean_label") }}</label>
<input
id="customer_ean"
:value="customerFormData.ean"
type="text"
inputmode="numeric"
maxlength="13"
autocomplete="off"
placeholder="5790001234567"
@input="customerFormData.ean = normalizeEconomicEan($event.target.value)"
>
</div>
<div class="form-field">
<label for="email">{{ $t("customer_creation.customer.email_label") }}</label>
<input id="email" v-model="customerFormData.contactEmail" type="email" name="email" autocomplete="email">
@@ -5,15 +5,17 @@ import {
getProductsCategory,
loadOrderItems,
productCategoryAllowed,
isProductRestricted,
setProductsCategory,
getUserProductDiscount,
getUserProductPrice,
showFakeCreateOrderItem,
department_id,
user_discounts,
getUserGlobalDiscount,
getUserDiscounts,
customer_id,
customer_attributes
customer_attributes,
loadCustomerAttributes
} from "@/components/shop/POSDepartmentProcess.vue";
import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
import {createOrderItem} from "@/components/shop/OrdersItems.vue";
@@ -151,6 +153,10 @@ const fetchProductsForCategory = async ({
products.value = [];
try {
if (customerNumber) {
await loadCustomerAttributes(customerNumber);
}
const response = normalizedCategory === null
? await getProducts(departmentId, props.finalPrice, customerNumber)
: await getProductCategory(normalizedCategory, departmentId, props.finalPrice, customerNumber);
@@ -549,10 +555,9 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
continue;
}
const previousOrderProduct = getPreviousOrderProduct(orderItem);
const discount = getUserProductDiscount(previousOrderProduct || {});
const previousOrderProduct = getPreviousOrderProduct(orderItem) || { id: productId, price: getPreviousOrderProductPrice(orderItem) };
const basePrice = getPreviousOrderProductPrice(orderItem);
const discountedPrice = (basePrice - (basePrice * (discount / 100))).toFixed(0);
const discountedPrice = String(getUserProductPrice({ ...previousOrderProduct, price: basePrice }));
showFakeCreateOrderItem(productId, quantity, discountedPrice);
await createOrderItem(orderId, productId, quantity);
@@ -586,14 +591,12 @@ const getRecommendedProductPrice = (productId) => {
return '0';
}
const discount = getUserProductDiscount(product);
const price = Number(product.price ?? 0);
return (price - (price * (discount / 100))).toFixed(0);
return String(getUserProductPrice(product));
};
const isRecommendedProductCategoryAllowed = (productId) => {
const product = getProductById(productId);
return !!product && productCategoryAllowed(product.category);
return !!product && !isProductRestricted(product);
};
const addRecommendedProductToOrder = async (productId) => {
@@ -405,6 +405,24 @@ watch(activeQuickAction, (nextMode, previousMode) => {
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.ean") }}</small></label
>
<div class="control">
<input
v-model="addCustomerForm.ean"
class="input"
type="text"
inputmode="numeric"
maxlength="13"
:placeholder="$t('admin.pos.ean')"
:disabled="addCustomerSearchResult === null || isAddCustomerSubmitting"
data-testid="pos-desktop-add-customer-ean"
@input="handleAddCustomerFieldInput('ean')"
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.invoice_email") }}</small></label
@@ -0,0 +1,241 @@
<script setup>
import { computed, nextTick, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import CustomerSearchSelect from "@/components/search/economic/CustomerSearchSelect.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const emit = defineEmits(["close", "created"]);
const { t } = useI18n();
const selectedCustomer = ref(null);
const registrationNumber = ref("");
const vehicleType = ref("");
const washSubscription = ref(false);
const reference = ref("");
const vehicleTypeOptions = ref([]);
const isLoadingVehicleTypes = ref(false);
const isSubmitting = ref(false);
const errorMessage = ref("");
const vehicleTypeError = ref("");
const getCustomerNumber = (customer) => {
const parsedValue = Number.parseInt(
String(customer?.customerNumber ?? customer?.customer_number ?? customer?.customer_id ?? customer?.id ?? ""),
10
);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const selectedCustomerNumber = computed(() => getCustomerNumber(selectedCustomer.value));
const normalizedVehicleType = computed(() => {
if (vehicleType.value === "") {
return null;
}
const parsedValue = Number.parseInt(String(vehicleType.value), 10);
return Number.isInteger(parsedValue) && parsedValue >= 0 ? parsedValue : null;
});
const normalizedRegistrationNumber = computed(() => {
return registrationNumber.value.trim().toUpperCase();
});
const canSubmit = computed(() => {
return (
!isSubmitting.value &&
!isLoadingVehicleTypes.value &&
selectedCustomerNumber.value !== null &&
normalizedRegistrationNumber.value.length > 0 &&
normalizedVehicleType.value !== null
);
});
const parseErrorMessage = (error) => {
return SessionUser.functions.parseErrorMessage(error) || t("vehicles.add_modal.error");
};
const loadVehicleTypes = async () => {
isLoadingVehicleTypes.value = true;
vehicleTypeError.value = "";
try {
vehicleTypeOptions.value = await SessionUser.objects.vehicles.columns.type.options();
} catch (error) {
vehicleTypeError.value = parseErrorMessage(error) || t("vehicles.add_modal.type_load_error");
} finally {
isLoadingVehicleTypes.value = false;
}
};
const closeModal = () => {
if (!isSubmitting.value) {
emit("close");
}
};
const submitVehicle = async () => {
if (!canSubmit.value) {
errorMessage.value = t("vehicles.add_modal.validation_error");
return;
}
isSubmitting.value = true;
errorMessage.value = "";
try {
const response = await SessionUser.objects.vehicles.add(
normalizedVehicleType.value,
normalizedRegistrationNumber.value,
washSubscription.value,
selectedCustomerNumber.value,
reference.value.trim() || null
);
emit("created", response);
} catch (error) {
errorMessage.value = parseErrorMessage(error);
} finally {
isSubmitting.value = false;
}
};
onMounted(async () => {
await loadVehicleTypes();
await nextTick();
document.getElementById("superuser-add-vehicle-customer-search")?.focus();
});
</script>
<template>
<div class="modal is-active" data-testid="superuser-add-vehicle-modal">
<div class="modal-background" @click="closeModal"></div>
<div class="modal-card superuser-add-vehicle-modal">
<header class="modal-card-head">
<p class="modal-card-title">{{ t("vehicles.add_modal.title") }}</p>
<button
class="delete"
type="button"
:aria-label="t('common.close')"
data-testid="superuser-add-vehicle-close"
@click="closeModal"
></button>
</header>
<section class="modal-card-body">
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="superuser-add-vehicle-error">
{{ errorMessage }}
</div>
<CustomerSearchSelect
v-model="selectedCustomer"
input-id="superuser-add-vehicle-customer-search"
test-id-prefix="superuser-add-vehicle-customer"
:disabled="isSubmitting"
:placeholder="t('vehicles.add_modal.customer_placeholder')"
/>
<div class="field">
<label class="label" for="superuser-add-vehicle-registration">{{ t("vehicles.form.license_plate") }}</label>
<div class="control">
<input
id="superuser-add-vehicle-registration"
v-model="registrationNumber"
class="input"
type="text"
autocomplete="off"
:placeholder="t('vehicles.add_modal.registration_placeholder')"
:disabled="isSubmitting"
data-testid="superuser-add-vehicle-registration"
/>
</div>
</div>
<div class="field">
<label class="label" for="superuser-add-vehicle-type">{{ t("vehicles.form.type") }}</label>
<div class="control" :class="{ 'is-loading': isLoadingVehicleTypes }">
<div class="select is-fullwidth">
<select
id="superuser-add-vehicle-type"
v-model="vehicleType"
:disabled="isSubmitting || isLoadingVehicleTypes || vehicleTypeOptions.length === 0"
data-testid="superuser-add-vehicle-type"
>
<option disabled value="">{{ t("vehicles.add_modal.type_placeholder") }}</option>
<option v-for="option in vehicleTypeOptions" :key="option.id" :value="option.id">
{{ option.name }}
</option>
</select>
</div>
</div>
<p v-if="vehicleTypeError" class="help is-danger" data-testid="superuser-add-vehicle-type-error">
{{ vehicleTypeError }}
</p>
</div>
<div class="field">
<label class="checkbox">
<input
v-model="washSubscription"
type="checkbox"
:disabled="isSubmitting"
data-testid="superuser-add-vehicle-wash-subscription"
/>
{{ t("objects.vehicles.columns.wash_subscription") }}
</label>
</div>
<div class="field">
<label class="label" for="superuser-add-vehicle-reference">{{ t("common.reference") }}</label>
<div class="control">
<input
id="superuser-add-vehicle-reference"
v-model="reference"
class="input"
type="text"
autocomplete="off"
:placeholder="t('vehicles.add_modal.reference_placeholder')"
:disabled="isSubmitting"
data-testid="superuser-add-vehicle-reference"
/>
</div>
</div>
</section>
<footer class="modal-card-foot is-justify-content-flex-end">
<button
class="button"
type="button"
:disabled="isSubmitting"
data-testid="superuser-add-vehicle-cancel"
@click="closeModal"
>
{{ t("common.cancel") }}
</button>
<button
class="button is-link"
type="button"
:class="{ 'is-loading': isSubmitting }"
:disabled="!canSubmit"
data-testid="superuser-add-vehicle-submit"
@click="submitVehicle"
>
{{ t("vehicles.add_modal.submit") }}
</button>
</footer>
</div>
</div>
</template>
<style scoped>
.superuser-add-vehicle-modal {
max-width: min(44rem, calc(100vw - 2rem));
width: 100%;
}
.modal-card-body {
overflow: visible;
}
</style>
@@ -5,7 +5,9 @@ import Swal from "sweetalert2";
//
export const showEditUserForm = async (id, customer_number, display_name, role) => {
export const showEditUserForm = async (id, customer_number, display_name, role, options = {}) => {
const lockedRoleFields = options.limitedBackofficeManaged ? 'disabled' : '';
await Swal.fire({
title: 'Edit User',
html: `
@@ -13,7 +15,7 @@ export const showEditUserForm = async (id, customer_number, display_name, role)
<div class="field">
<label class="label">Customer Number</label>
<div class="control">
<input class="input" type="text" id="customer_number" value="${customer_number}" />
<input class="input" type="text" id="customer_number" value="${customer_number}" ${lockedRoleFields} />
</div>
</div>
<div class="field">
@@ -25,7 +27,7 @@ export const showEditUserForm = async (id, customer_number, display_name, role)
<div class="field">
<label class="label">Role</label>
<div class="control">
<input class="input" type="text" id="role" value="${role}" />
<input class="input" type="text" id="role" value="${role}" ${lockedRoleFields} />
</div>
</div>
<div class="field">
@@ -50,4 +52,4 @@ export const showEditUserForm = async (id, customer_number, display_name, role)
});
}
</script>
</script>
@@ -19,17 +19,17 @@ const SENSITIVE_QUERY_KEYS = /authorization|password|passwd|secret|token|api[_-]
const FALLBACK_LABELS = {
"error_report.button": "Report error",
"error_report.title": "Report error",
"error_report.subtitle": "Send the current screen and recent error details to support.",
"error_report.subtitle": "Send recent error details to support. A screenshot is attached when available.",
"error_report.before_error": "What were you doing before the error occurred?",
"error_report.expected": "What did you expect would happen?",
"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 recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
"error_report.submit": "Submit report",
"error_report.submitted": "Error report submitted.",
"error_report.capture_failed": "The screen capture failed. Please try again.",
"error_report.capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
"error_report.submit_failed": "The error report could not be submitted.",
"error_report.required": "All fields and data collection acceptance are required.",
};
@@ -277,15 +277,18 @@ const submit = async () => {
isSubmitting.value = true;
try {
let screenshot = "";
let screenshot = null;
try {
screenshot = await captureScreenshot();
} catch {
captureError.value = tr("error_report.capture_failed");
return;
}
const context = buildContext();
context.screenshot_attachment = {
status: screenshot ? "stored" : "capture_failed",
attached: Boolean(screenshot),
};
await submitErrorReport({
before_error: form.before_error.trim(),
expected: form.expected.trim(),
@@ -340,7 +343,7 @@ const submit = async () => {
<div v-if="submitted" class="notification is-success is-light" data-testid="error-report-submitted">
{{ tr("error_report.submitted") }}
</div>
<div v-if="captureError" class="notification is-danger is-light">
<div v-if="captureError" class="notification is-warning is-light" data-testid="error-report-capture-warning">
{{ captureError }}
</div>
<div v-if="submitError" class="notification is-danger is-light">
+4 -3
View File
@@ -4,6 +4,7 @@ import { ref, watch, onMounted } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
// Get the department ID from the URL
const route = useRoute();
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
@@ -73,9 +74,9 @@ const emphasis = ref({
"department:" +
department_id +
",status:pending,date-date_from:" +
new Date().toISOString().split("T")[0] +
todayLocalDateOnly() +
",date-date_to:" +
new Date().toISOString().split("T")[0],
todayLocalDateOnly(),
limit: 1, // Limit to 1 booking, we only need to know if there are any bookings or not
page: 1,
})
@@ -97,7 +98,7 @@ const emphasis = ref({
* Check if the department has a daily report for today
*/
SessionUser.request(SessionUser.objects.department_daily_reports.meta.endpoint + "/get", "GET", {
date: new Date().toISOString().split("T")[0],
date: todayLocalDateOnly(),
id: department_id, // This refers to the department ID
})
.then((response) => {
@@ -75,19 +75,11 @@ const items = computed<NavigationItemProps[]>(() => [
* ]
* }
*/
{
label: t('templates.limited_backoffice.title'),
to: `/backoffice`,
type: 'link',
permissions: ['limited_backoffice_access'],
hidden: hasPermission('superuser'),
},
{
label: t('common.settings'),
to: `/user/profile`,
type: 'link',
permissions: ['user'],
hidden: !window.location.pathname.startsWith('/user')
hidden: !window.location.pathname.startsWith('/user') || !SessionUser.authenticated.value
},
{
label: t('common.logout'),
@@ -51,7 +51,7 @@ const items = computed<NavigationItemProps[]>(() => [
to: `/user`,
type: 'link',
// Dashboard visible to regular users and all subusers (subusers always have at least one grant)
permissions: ['user', 'BOOKINGS_LIST', 'ORDERS_LIST', 'VEHICLES_LIST', 'SELFSERVE_LIST', 'SUBUSERS_LIST'],
permissions: ['user', 'BOOKINGS_LIST', 'BOOKINGS_ADD', 'ORDERS_LIST', 'VEHICLES_LIST', 'VEHICLES_ADD', 'SELFSERVE_LIST', 'SUBUSERS_LIST'],
},
{
label: t('common.vehicles'),
@@ -0,0 +1,288 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { isSearching, searchCustomer, searchCustomerResults } from "@/components/search/economic/customerSearch.vue";
const props = defineProps({
modelValue: {
type: Object,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
inputId: {
type: String,
default: "customer-search-select-input",
},
testIdPrefix: {
type: String,
default: "customer-search-select",
},
placeholder: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue", "selected", "cleared"]);
const { t } = useI18n();
const searchQuery = ref("");
const showResults = ref(false);
const selectedResultIndex = ref(-1);
const getCustomerNumber = (customer) => {
const parsedValue = Number.parseInt(
String(customer?.customerNumber ?? customer?.customer_number ?? customer?.customer_id ?? customer?.id ?? ""),
10
);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const getCustomerName = (customer) => {
return String(customer?.name ?? customer?.customer_name ?? customer?.customerName ?? "").trim();
};
const getCustomerCity = (customer) => {
return String(customer?.city ?? customer?.address_city ?? "").trim();
};
const formatCustomer = (customer) => {
if (!customer) {
return "";
}
const name = getCustomerName(customer);
const customerNumber = getCustomerNumber(customer);
return [name, customerNumber ? `#${customerNumber}` : null].filter(Boolean).join(" - ");
};
const selectedCustomerNumber = computed(() => getCustomerNumber(props.modelValue));
const selectedCustomerName = computed(() => getCustomerName(props.modelValue));
const selectedCustomerCity = computed(() => getCustomerCity(props.modelValue));
const hasResults = computed(() => searchCustomerResults.value.length > 0);
const placeholderText = computed(() => props.placeholder || t("vehicles.add_modal.customer_placeholder"));
const resetSearchResults = () => {
searchCustomer(null);
selectedResultIndex.value = -1;
};
const setSelectedCustomer = (customer) => {
emit("update:modelValue", customer);
emit("selected", customer);
searchQuery.value = formatCustomer(customer);
showResults.value = false;
selectedResultIndex.value = -1;
};
const clearSelectedCustomer = async () => {
emit("update:modelValue", null);
emit("cleared");
searchQuery.value = "";
showResults.value = false;
resetSearchResults();
await nextTick();
document.getElementById(props.inputId)?.focus();
};
const handleSearchInput = () => {
if (props.modelValue) {
emit("update:modelValue", null);
}
const query = searchQuery.value.trim();
selectedResultIndex.value = -1;
if (!query) {
showResults.value = false;
resetSearchResults();
return;
}
showResults.value = true;
searchCustomer(query);
};
const handleFocus = () => {
if (searchQuery.value.trim() && hasResults.value) {
showResults.value = true;
}
};
const handleBlur = () => {
window.setTimeout(() => {
showResults.value = false;
selectedResultIndex.value = -1;
}, 150);
};
const handleKeydown = (event) => {
if (!showResults.value || !hasResults.value) {
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
selectedResultIndex.value = Math.min(selectedResultIndex.value + 1, searchCustomerResults.value.length - 1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
selectedResultIndex.value = Math.max(selectedResultIndex.value - 1, 0);
} else if (event.key === "Enter") {
event.preventDefault();
const selectedCustomer = searchCustomerResults.value[selectedResultIndex.value] || searchCustomerResults.value[0];
if (selectedCustomer) {
setSelectedCustomer(selectedCustomer);
}
} else if (event.key === "Escape") {
showResults.value = false;
selectedResultIndex.value = -1;
}
};
watch(
() => props.modelValue,
(customer) => {
if (customer) {
searchQuery.value = formatCustomer(customer);
} else if (!document.activeElement || document.activeElement.id !== props.inputId) {
searchQuery.value = "";
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
resetSearchResults();
});
</script>
<template>
<div class="customer-search-select">
<div class="field">
<label class="label" :for="inputId">{{ t("vehicles.add_modal.customer_label") }}</label>
<div class="control has-icons-left" :class="{ 'is-loading': isSearching }">
<input
:id="inputId"
v-model="searchQuery"
class="input"
type="text"
autocomplete="off"
:placeholder="placeholderText"
:disabled="disabled"
:aria-expanded="showResults && hasResults"
:data-testid="`${testIdPrefix}-input`"
@input="handleSearchInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown="handleKeydown"
/>
<span class="icon is-left">
<i class="fas fa-search"></i>
</span>
</div>
</div>
<div
v-if="showResults && hasResults"
class="dropdown is-active customer-search-select__dropdown"
:data-testid="`${testIdPrefix}-results`"
>
<div class="dropdown-menu customer-search-select__menu" role="listbox">
<div class="dropdown-content">
<button
v-for="(customer, index) in searchCustomerResults"
:key="getCustomerNumber(customer) || index"
type="button"
class="dropdown-item customer-search-select__option"
:class="{ 'is-active': selectedResultIndex === index }"
:data-testid="`${testIdPrefix}-option-${index}`"
@mousedown.prevent="setSelectedCustomer(customer)"
>
<span class="customer-search-select__option-main">{{ getCustomerName(customer) }}</span>
<span class="customer-search-select__option-meta">
#{{ getCustomerNumber(customer) }}
<template v-if="getCustomerCity(customer)"> · {{ getCustomerCity(customer) }}</template>
</span>
</button>
</div>
</div>
</div>
<div v-if="modelValue" class="customer-search-select__selected" :data-testid="`${testIdPrefix}-selected`">
<div>
<p class="has-text-weight-semibold">{{ t("vehicles.add_modal.selected_customer") }}</p>
<p>{{ selectedCustomerName }}</p>
<p class="is-size-7 has-text-grey">
#{{ selectedCustomerNumber }}
<span v-if="selectedCustomerCity"> · {{ selectedCustomerCity }}</span>
</p>
</div>
<button
type="button"
class="button is-small is-light"
:disabled="disabled"
:data-testid="`${testIdPrefix}-clear`"
@click="clearSelectedCustomer"
>
{{ t("common.clear") }}
</button>
</div>
</div>
</template>
<style scoped>
.customer-search-select {
position: relative;
}
.customer-search-select__dropdown,
.customer-search-select__menu {
width: 100%;
}
.customer-search-select__dropdown {
left: 0;
position: absolute;
right: 0;
top: 4.75rem;
z-index: 40;
}
.customer-search-select__option {
align-items: flex-start;
background: transparent;
border: 0;
display: flex;
flex-direction: column;
text-align: left;
white-space: normal;
width: 100%;
}
.customer-search-select__option-main {
font-weight: 600;
}
.customer-search-select__option-meta {
color: #6b7280;
font-size: 0.8rem;
}
.customer-search-select__selected {
align-items: flex-start;
background: #f5f8fc;
border: 1px solid #d8e2ef;
border-radius: 6px;
display: flex;
gap: 1rem;
justify-content: space-between;
margin-bottom: 1rem;
padding: 0.85rem 1rem;
}
</style>
@@ -1,17 +1,25 @@
<script setup>
const props = defineProps(['invoice_id'])
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { handleEconomicError } from "@/components/request/HandleEconomicError.vue";
import { ref } from "vue";
import { computed } from "vue";
const props = defineProps(["invoice_id"]);
const hasInvoice = computed(() => {
return props.invoice_id !== null && props.invoice_id !== undefined && String(props.invoice_id).trim() !== "";
});
const getOrderPDF = async () => {
await authenticatedRequest('/invoices/pdf?id=' + props.invoice_id,
'GET',{
}) .then((response) => {
if (!hasInvoice.value) {
return;
}
await authenticatedRequest("/invoices/pdf", "GET", {
id: props.invoice_id,
}).then((response) => {
// Open the PDF in a new tab
window.open(response.data.data.url, '_blank');
window.open(response.data.data.url, "_blank");
}).catch((error) => {
handleEconomicError(error);
});
@@ -19,9 +27,17 @@ const getOrderPDF = async () => {
</script>
<template>
<LoadButtonWhileAwait class="is-dark" :loadFunction="getOrderPDF" icon="fas fa-file-pdf"> Hent faktura PDF </LoadButtonWhileAwait>
<LoadButtonWhileAwait
class="is-dark"
:loadFunction="getOrderPDF"
:disabled="!hasInvoice"
actionKey="economic-invoice-pdf-download"
icon="fas fa-file-pdf"
>
Hent faktura PDF
</LoadButtonWhileAwait>
</template>
<style scoped>
</style>
</style>
@@ -89,6 +89,66 @@ const SUBUSER_MANAGEMENT_PERMISSIONS = {
},
};
const CUSTOMER_FEATURE_PERMISSIONS = {
vehicles: {
list: {
customer: ["user", "list_own_vehicles"],
subuser: ["VEHICLES_LIST"],
},
add: {
customer: ["user", "add_vehicle"],
subuser: ["VEHICLES_ADD"],
},
edit: {
customer: ["edit_vehicle", "add_vehicle"],
subuser: ["VEHICLES_EDIT"],
},
delete: {
customer: ["delete_vehicle"],
subuser: ["VEHICLES_DELETE"],
},
},
bookings: {
list: {
customer: ["user", "list_own_bookings"],
subuser: ["BOOKINGS_LIST"],
},
add: {
customer: ["user"],
subuser: ["BOOKINGS_ADD"],
},
edit: {
customer: ["edit_own_bookings"],
subuser: ["BOOKINGS_EDIT"],
},
delete: {
customer: ["delete_own_bookings"],
subuser: ["BOOKINGS_DELETE"],
},
},
orders: {
list: {
customer: ["user", "list_own_orders"],
subuser: ["ORDERS_LIST"],
},
edit: {
customer: ["edit_own_orders"],
subuser: ["ORDERS_EDIT"],
},
},
selfserve: {
list: {
customer: ["user", "list_own_department_selfserve_vehicle_conditions"],
subuser: ["SELFSERVE_LIST"],
},
add: {
customer: ["add_own_department_selfserve_vehicle_conditions"],
subuser: ["SELFSERVE_ADD"],
},
},
subusers: SUBUSER_MANAGEMENT_PERMISSIONS,
};
const getStoredSessionSnapshot = () => {
if (typeof window === "undefined") {
return null;
@@ -858,6 +918,13 @@ export const SessionUser = {
}
return SessionUser.permissions.value.includes(permission) || SessionUser.permissions.value.includes("superuser");
},
hasAnyPermission: (permissions = []) => {
const permissionList = Array.isArray(permissions) ? permissions : [permissions].filter(Boolean);
if (permissionList.length === 0) {
return true;
}
return permissionList.some((permission) => SessionUser.hasPermission(permission));
},
/** Can access shortcuts */
canAccessAdmin: () => {
return SessionUser.hasPermission("admin") || SessionUser.hasPermission("superuser");
@@ -877,6 +944,15 @@ export const SessionUser = {
? SessionUser.hasPermission(permissions.subuser)
: SessionUser.hasPermission(permissions.customer);
},
canAccessCustomerFeature: (feature, action = "list") => {
const permissions = CUSTOMER_FEATURE_PERMISSIONS[feature]?.[action];
if (!permissions) {
return false;
}
const permissionList = SessionUser.isSubuser.value ? permissions.subuser : permissions.customer;
return SessionUser.hasAnyPermission(permissionList);
},
canAccessDepartment: (id = 0) => {
return SessionUser.hasPermission("department_access_" + parseInt(id)) || SessionUser.canAccessSuperUser();
},
@@ -5,6 +5,7 @@ import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/Ob
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {ref} from "vue";
import i18n from '@/i18n';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const t = (key) => i18n.global.t(key);
@@ -241,7 +242,7 @@ export const Bookings = {
'/bookings',
'GET',
{
filters: `department:${department},status:pending,date-date_from:${new Date().toISOString().split('T')[0]},date-date_to:${new Date().toISOString().split('T')[0]}`,
filters: `department:${department},status:pending,date-date_from:${todayLocalDateOnly()},date-date_to:${todayLocalDateOnly()}`,
limit: 100,
page: 1
}
@@ -289,4 +290,4 @@ export const Bookings = {
);
}
};
</script>
</script>
@@ -444,10 +444,14 @@ export const CollectedOrderInvoices = {
});
},
split_by_month: async (dateFrom, dateTo, options = {}) => {
const invoiceCollectionIds = Array.isArray(options.invoiceCollectionIds)
? options.invoiceCollectionIds
: options.invoice_collection_ids;
return authenticatedRequest('/collected-invoices/split-by-month', 'POST', {
dateFrom,
dateTo,
...(options.preview !== undefined ? { preview: !!options.preview } : {}),
...(Array.isArray(invoiceCollectionIds) ? { invoice_collection_ids: invoiceCollectionIds } : {}),
}).then((response) => {
console.log(response);
return response;
@@ -5,6 +5,7 @@ import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/Ob
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {ref} from "vue";
import i18n from '@/i18n';
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const t = (key) => i18n.global.t(key);
@@ -80,7 +81,7 @@ const t = (key) => i18n.global.t(key);
water_usage_morning: parseInt(water_usage_morning),
water_usage: parseInt(water_usage),
notes: notes ? notes : 'Daily report',
date: date ? date : new Date().toISOString().split('T')[0]
date: date ? date : todayLocalDateOnly()
}
);
},
@@ -163,6 +164,21 @@ const t = (key) => i18n.global.t(key);
}
);
},
setProductTarget: async ({ department_id, product_id, target_percentage }) => {
const normalizedTarget = target_percentage === null || target_percentage === undefined || target_percentage === ""
? null
: Number(target_percentage);
return authenticatedRequest(
"/departments/daily-reports/product-targets",
"PUT",
{
department_id: parseInt(department_id, 10),
product_id: parseInt(product_id, 10),
target_percentage: normalizedTarget,
}
);
},
createComplaint: async ({
department_id,
customer_number = null,
@@ -1,6 +1,7 @@
<script>
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { formatLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
/**
* The DepartmentTimeBookingsEntries object
@@ -230,10 +231,10 @@ export const DepartmentTimeBookingsEntries = {
if (!dateFrom) {
const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
dateFrom = lastWeek.toISOString().split('T')[0]; // Format as YYYY-MM-DD
dateFrom = formatLocalDateOnly(lastWeek); // Format as YYYY-MM-DD
}
if (!dateTo) {
dateTo = new Date().toISOString().split('T')[0]; // Format as YYYY-MM-DD
dateTo = todayLocalDateOnly(); // Format as YYYY-MM-DD
}
console.log("Fetching public available times for department:", departmentId, "from", dateFrom, "to", dateTo);
// Make the request to fetch public available times for the department
@@ -279,4 +280,4 @@ export const DepartmentTimeBookingsEntries = {
);
}
};
</script>
</script>
@@ -32,6 +32,23 @@ const normalizeDateTimeLocalValue = (value) => {
return `${datePart}T${timePart.slice(0, 5)}`;
};
const sanitizeQueryParams = (params = {}) => {
if (!params || typeof params !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== null && value !== undefined)
);
};
const buildQueryString = (params = {}) => {
const queryParams = sanitizeQueryParams(params);
const queryString = new URLSearchParams(queryParams).toString();
return queryString ? `?${queryString}` : '';
};
/**
* The Global Objects object, which contains global functions for objects.
*/
@@ -431,20 +448,15 @@ export const ObjectsGlobal = {
* @returns {Promise} The promise
*/
object: async (endpoint, id, optionsObject = {}) => {
const cacheKey = JSON.stringify({endpoint, id, optionsObject});
const requestParams = typeof id === 'object' && id !== null
? sanitizeQueryParams(id)
: {id};
const cacheKey = JSON.stringify({endpoint, id: requestParams, optionsObject});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
let requestEndpoint = endpoint;
if (typeof id === 'object') {
// If the id is an object, we need to convert it to a query string
const params = new URLSearchParams(id).toString();
requestEndpoint = `${endpoint}?${params}`;
} else {
// If the id is a number, we can just append it to the endpoint
requestEndpoint = `${endpoint}?id=${id}`;
}
const requestEndpoint = `${endpoint}${buildQueryString(requestParams)}`;
let options = ObjectsGlobal.requestOptions.applyOptions(optionsObject);
let promise;
if (options.authenticated) {
@@ -499,23 +511,24 @@ export const ObjectsGlobal = {
* @returns {Promise} The promise
*/
objects: async (endpoint, data = {}) => {
const cacheKey = JSON.stringify({endpoint, data});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
if (data.filters && typeof data.filters === 'object') {
data.filters = Object.entries(data.filters).flatMap(([key, value]) => {
const requestData = sanitizeQueryParams(data);
if (requestData.filters && typeof requestData.filters === 'object') {
requestData.filters = Object.entries(requestData.filters).flatMap(([key, value]) => {
if (Array.isArray(value)) {
return value.map(v => `${key}:${v}`);
}
return `${key}:${value}`;
}).join(',');
}
const cacheKey = JSON.stringify({endpoint, data: requestData});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
const promise = authenticatedRequest(
endpoint,
"GET",
data
requestData
).then((response) => {
return response.data.data;
}).catch((error) => {
@@ -8,6 +8,17 @@ import i18n from '@/i18n';
const t = (key) => i18n.global.t(key);
const normalizeProductQueryOptions = (options = {}) => {
const queryOptions = { ...options };
if (!Object.prototype.hasOwnProperty.call(queryOptions, "final_price")) {
queryOptions.final_price = false;
}
return Object.fromEntries(
Object.entries(queryOptions).filter(([, value]) => value !== null && value !== undefined)
);
};
/**
* Local products cache
* @type {ref<null>}
@@ -269,19 +280,19 @@ export const Products = {
},
},
get: {
all: async (options = {department_id: null, customer_id: null, category_id: null, final_price: false}) => {
return ObjectsGlobal.get.objects(Products.meta.endpoint, {...options});
all: async (options = {}) => {
return ObjectsGlobal.get.objects(Products.meta.endpoint, normalizeProductQueryOptions(options));
},
single: async (id, options = {department_id: null, customer_id: null, category_id: null, final_price: false}) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {id: id, ...options});
single: async (id, options = {}) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {id: id, ...normalizeProductQueryOptions(options)});
},
category: async (category_id, department_id, customer_id = null, final_price = false) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {
return ObjectsGlobal.get.object(Products.meta.endpoint, normalizeProductQueryOptions({
category: category_id,
department_id: department_id,
...(customer_id !== null ? {customer_id: customer_id} : {}),
...{final_price: final_price}
});
}));
},
},
delete: async (id) => {
@@ -324,4 +335,4 @@ export const Products = {
);
},
};
</script>
</script>
+59 -54
View File
@@ -10,6 +10,10 @@ import { getAttributes } from "@/components/shop/CustomerAttributes.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { doesOrderContainWashCertificateProduct } from "@/components/displays/department/pos/utils/washCertificate.js";
import {
isProductCategoryRestrictedForCustomer,
isProductRestrictedForCustomer,
} from "@/features/customer/customerProductRules.js";
import Swal from "sweetalert2";
/**
@@ -153,6 +157,8 @@ export const nextStep = async (options = { isMobile: false, orderCreation: true
clearErrors();
// If the current step is 1, create the order
if (step.value === 1) {
await loadCustomerAttributes(customer_id.value);
if (normalizedOptions.orderCreation === false) {
step.value++;
return;
@@ -1613,24 +1619,7 @@ export const customerUsesPONumbers = () => {
/** Check if the product category is allowed */
export const productCategoryAllowed = (category) => {
// Check if the product category is the addons category
if (category === "addons") {
// Check if the customer has the attribute restrictAdditionalServices
return !hasAttribute("restrictAdditionalServices");
}
if (category === "spot_free") {
// Check if the customer has the attribute restrictSpotFree
return !hasAttribute("restrictSpotFree");
}
if (category === "interior_cleaning") {
// Check if the customer has the attribute restrictInteriorCleaning
return !hasAttribute("restrictInteriorCleaning");
}
if (category === "tank_cleaning") {
// Check if the customer has the attribute restrictTankCleaning
return !hasAttribute("restrictTankCleaning");
}
return true;
return !isProductCategoryRestrictedForCustomer(category, customer_attributes.value);
};
/** Check if the customer should be billed per order, or per billing period */
@@ -1676,43 +1665,12 @@ export const hasOnlyTankCleaning = () => {
/** Check if a product is restricted based on customer attributes */
export const isProductRestricted = (product) => {
if (!product) return false;
const productName = (product.name || "").toLowerCase();
// Check if the product has "Spot Free" in the name
if (!canBuySpotFree() && productName.includes("spot free")) {
return true;
}
// Check if the product has "Indvendig vask" in the name
if (!canBuyInteriorCleaning() && productName.includes("indvendig vask")) {
return true;
}
// Check if the product category is 5 (Tank Cleaning)
if (product.category === 5) {
if (!canBuyTankCleaning()) {
return true;
}
if (!hasOnlyTankCleaning()) {
return true;
}
}
return false;
return isProductRestrictedForCustomer(product, customer_attributes.value);
};
/** Check if an addon is restricted based on customer attributes */
export const isAddonRestricted = (addon) => {
if (!addon) return false;
const addonName = (addon.name || "").toLowerCase();
// Check if the addon has "Spot Free" in the name
if (!canBuySpotFree() && addonName.includes("spot free")) {
return true;
}
// Check if the addon has "Indvendig vask" in the name
if (!canBuyInteriorCleaning() && addonName.includes("indvendig vask")) {
return true;
}
return false;
return isProductRestrictedForCustomer(addon, customer_attributes.value);
};
export function loadOrderDetails(onAfterSuccess = null, options = {}) {
@@ -1844,13 +1802,30 @@ export const getUserDiscounts = async () => {
});
};
/** Get user product discount */
export const getUserProductDiscount = (product, allowCategory = null, onlyCategory = null) => {
// Check if the user discounts are loaded
const ensureUserDiscountsLoaded = () => {
if (!has_user_discounts_loaded.value) {
has_user_discounts_loaded.value = true;
getUserDiscounts().then(() => {});
}
};
const isDirectCustomerProductDiscount = (discount, productId) => {
return discount?.product_or_category_id == productId && Number(discount?.is_category) === 0;
};
const parseFixedPriceValue = (value) => {
if (value === null || value === undefined || value === '') {
return null;
}
const parsed = Number.parseInt(String(value), 10);
return Number.isFinite(parsed) ? parsed : null;
};
/** Get user product discount */
export const getUserProductDiscount = (product, allowCategory = null, onlyCategory = null) => {
// Check if the user discounts are loaded
ensureUserDiscountsLoaded();
// Get the product's id
const productId = product.id;
@@ -1948,6 +1923,36 @@ export const getUserProductDiscount = (product, allowCategory = null, onlyCatego
return appliedDiscount;
};
export const getUserProductFixedPrice = (product) => {
ensureUserDiscountsLoaded();
const productId = product?.id;
if (productId === null || productId === undefined) {
return null;
}
const fixedPriceDiscount = (Array.isArray(user_discounts.value) ? user_discounts.value : []).find((discount) => (
isDirectCustomerProductDiscount(discount, productId) && parseFixedPriceValue(discount.fixed_price) !== null
));
return fixedPriceDiscount ? parseFixedPriceValue(fixedPriceDiscount.fixed_price) : null;
};
export const getUserProductPrice = (product) => {
const fixedPrice = getUserProductFixedPrice(product);
if (fixedPrice !== null) {
return fixedPrice;
}
const price = Number(product?.price ?? 0);
const discount = Number(getUserProductDiscount(product) || 0);
if (!Number.isFinite(price)) {
return 0;
}
return Number((price - (price * (discount / 100))).toFixed(0));
};
export const showFakeCreateOrderItem = (product, quantity, price) => {
order_items.value.push({
id: Math.floor(Math.random() * 1000),
@@ -6,9 +6,8 @@ import CollectedInvoiceQueueMonitor from "@/components/viewport/page/headers/men
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
const route = useRoute();
import HeaderAccessShortcuts from "@/components/viewport/page/headers/HeaderAccessShortcuts.vue";
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
@@ -41,20 +40,7 @@ const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() &&
<!-- User account name and avatar (Desktop) -->
<div class="navbar-item mr-1">
<div class="buttons">
<!-- Back-Office button -->
<router-link class="button is-white" to="/superuser" v-if="SessionUser.canAccessSuperUser() && !route.path.includes('/superuser')">
<span class="icon">
<i class="fas fa-tools" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('header.backoffice') }}</span>
</router-link>
<!-- Department button -->
<router-link class="button is-white" to="/admin" v-if="SessionUser.canAccessAdmin() && !route.path.includes('/admin')">
<span class="icon">
<i class="fas fa-building" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('common.departments') }}</span>
</router-link>
<HeaderAccessShortcuts />
<!-- Return to own account if impersonating -->
<template v-if="SessionUser.hasSuperUserToken()">
<router-link class="button is-white" to="/auth/return">
@@ -63,11 +63,7 @@ const navigationItems = computed<NavigationMenuItem[]>(() => {
});
const hasPermission = (item: NavigationMenuItem) => {
if (!item.permissions?.length) {
return true;
}
return item.permissions.some((permission) => SessionUser.hasPermission(permission));
return SessionUser.hasAnyPermission(item.permissions ?? []);
};
const isItemVisible = (item: NavigationMenuItem) => !item.hidden && hasPermission(item);
@@ -0,0 +1,99 @@
<script setup>
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { IS_DEV } from "@/config.js";
import SessionUser from "@/components/session/token/SessionUser.vue";
import {
getAdminDepartmentIdFromRoute,
getAdminDepartmentRoute,
getLimitedBackofficeDepartmentIdFromRoute,
getLimitedBackofficeDepartmentRoute,
} from "@/components/viewport/page/headers/headerShortcuts.js";
const route = useRoute();
const { t } = useI18n({ useScope: "global" });
const adminDepartmentId = computed(() => getAdminDepartmentIdFromRoute(route));
const backofficeDepartmentId = computed(() => getLimitedBackofficeDepartmentIdFromRoute(route));
const canAccessDepartment = (departmentId) =>
Number.isInteger(departmentId) && departmentId > 0 && SessionUser.canAccessDepartment(departmentId);
const canShowSuperuserBackoffice = computed(
() => SessionUser.canAccessSuperUser() && !route.path.includes("/superuser")
);
const canShowLimitedBackoffice = computed(() => {
const departmentId = adminDepartmentId.value;
return (
canAccessDepartment(departmentId) &&
SessionUser.hasPermission("limited_backoffice_access") &&
!SessionUser.canAccessSuperUser()
);
});
const limitedBackofficeTarget = computed(() => getLimitedBackofficeDepartmentRoute(adminDepartmentId.value));
const adminShortcutTarget = computed(() =>
backofficeDepartmentId.value ? getAdminDepartmentRoute(backofficeDepartmentId.value) : "/admin"
);
const canShowAdminShortcut = computed(() => {
if (!SessionUser.canAccessAdmin() || route.path.includes("/admin")) {
return false;
}
const departmentId = backofficeDepartmentId.value;
return departmentId === null || canAccessDepartment(departmentId);
});
</script>
<template>
<router-link
v-if="canShowSuperuserBackoffice"
class="button is-white"
to="/superuser"
data-testid="superuser-backoffice-header-button"
>
<span class="icon">
<i class="fas fa-tools" :class="{ 'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV }"></i>
</span>
<span>{{ t("header.backoffice") }}</span>
</router-link>
<router-link
v-if="canShowLimitedBackoffice"
class="button is-white"
:to="limitedBackofficeTarget"
data-testid="limited-backoffice-header-button"
>
<span class="icon">
<i class="fas fa-tools" :class="{ 'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV }"></i>
</span>
<span>{{ t("templates.limited_backoffice.title") }}</span>
</router-link>
<router-link
v-if="canShowAdminShortcut"
class="button is-white"
:to="adminShortcutTarget"
data-testid="admin-header-button"
>
<span class="icon">
<i class="fas fa-building" :class="{ 'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV }"></i>
</span>
<span>{{ t("common.departments") }}</span>
</router-link>
</template>
<style scoped>
.transform-color-black {
filter: invert(100%) grayscale(100%) brightness(0) contrast(100%);
}
.transform-color-red {
filter: invert(27%) sepia(96%) saturate(7493%) hue-rotate(357deg) brightness(103%) contrast(101%);
}
</style>
@@ -5,9 +5,8 @@ import NavigationMenuGlobalSearch from "@/components/viewport/page/headers/menu/
import CollectedInvoiceQueueMonitor from "@/components/viewport/page/headers/menu/CollectedInvoiceQueueMonitor.vue";
import { IS_DEV } from '@/config.js';
import SessionUser from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import { computed } from "vue";
const route = useRoute();
import HeaderAccessShortcuts from "@/components/viewport/page/headers/HeaderAccessShortcuts.vue";
const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() && SessionUser.authenticated.value);
</script>
@@ -41,20 +40,7 @@ const isAuthenticatedSessionReady = computed(() => SessionUser.isInitiated() &&
<!-- User account name and avatar (Desktop) -->
<div class="navbar-item mr-1">
<div class="buttons">
<!-- Back-Office button -->
<router-link class="button is-white" to="/superuser" v-if="SessionUser.canAccessSuperUser() && !route.path.includes('/superuser')">
<span class="icon">
<i class="fas fa-tools" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('header.backoffice') }}</span>
</router-link>
<!-- Department button -->
<router-link class="button is-white" to="/admin" v-if="SessionUser.canAccessAdmin() && !route.path.includes('/admin')">
<span class="icon">
<i class="fas fa-building" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
<span>{{ $t('common.departments') }}</span>
</router-link>
<HeaderAccessShortcuts />
<!-- Return to own account if impersonating -->
<template v-if="SessionUser.hasSuperUserToken()">
<router-link class="button is-white" to="/auth/return">
@@ -0,0 +1,31 @@
const parsePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const getPath = (route) => String(route?.path ?? "");
export const getAdminDepartmentIdFromRoute = (route) => {
const path = getPath(route);
const fromPath = parsePositiveInteger(path.match(/^\/admin\/(\d+)(?:\/|$)/)?.[1]);
if (fromPath !== null) {
return fromPath;
}
return path.startsWith("/admin") ? parsePositiveInteger(route?.params?.departmentId) : null;
};
export const getLimitedBackofficeDepartmentIdFromRoute = (route) => {
const path = getPath(route);
const fromPath = parsePositiveInteger(path.match(/^\/backoffice\/departments\/(\d+)(?:\/|$)/)?.[1]);
if (fromPath !== null) {
return fromPath;
}
return path.startsWith("/backoffice/departments") ? parsePositiveInteger(route?.params?.departmentId) : null;
};
export const getLimitedBackofficeDepartmentRoute = (departmentId) =>
`/backoffice/departments/${encodeURIComponent(String(departmentId))}/prices`;
export const getAdminDepartmentRoute = (departmentId) => `/admin/${encodeURIComponent(String(departmentId))}`;
@@ -43,23 +43,24 @@ const redirect = async (to: string) => {
}
};
// Permissions and visibility logic
const hasPermission = (permission: string) => {
return SessionUser.hasPermission(permission);
}
const hasDepartmentIdInPath = (path: string) => {
const departmentRegex = /\/admin\/\d+/;
return departmentRegex.test(path);
}
const hasItemPermissions = (item: any) => {
return SessionUser.hasAnyPermission(item?.permissions ?? []);
}
const hasPermissions = computed(() => {
if (!props.item.permissions || props.item.permissions.length === 0) {
return true; // No permissions required, item is visible
}
return props.item.permissions.some(hasPermission);
return hasItemPermissions(props.item);
});
const isVisible = () => {
if (props.item.hidden) {
return false;
}
switch (props.item.type) {
case 'category':
// Check if any child item is visible
@@ -96,10 +97,14 @@ const hasChildren = computed(() => {
});
const isChildVisible = (child: any) => {
if (child.type === 'department') {
return hasPermissions.value && hasDepartmentIdInPath(router.currentRoute.value.path);
if (child.hidden) {
return false;
}
return hasPermissions.value;
if (child.type === 'department') {
return hasItemPermissions(child) && hasDepartmentIdInPath(router.currentRoute.value.path);
}
return hasItemPermissions(child);
}
const hasVisibleChildren = computed(() => {
@@ -189,7 +189,7 @@ const descriptionKeyOverrides: Partial<Record<EntityType, string[]>> = {
xlvask_customers: ['email', 'city', 'address', 'vatnumber'],
motorapi_lookups: ['endpoint', 'license_plate', 'result'],
fxratesapi_conversion_rates: ['base', 'target', 'endpoint', 'result'],
customer_discounts: ['customer_number', 'percentage'],
customer_discounts: ['customer_number', 'fixed_price', 'percentage'],
module_config: ['description', 'value'],
department_goals: ['criteria.type', 'criteria.progress_alert_frequency', 'criteria.progress_alert_destination']
};
@@ -251,6 +251,7 @@ const keyFieldOverrides: Partial<Record<EntityType, FieldSelector[]>> = {
),
customer_discounts: fieldList(
field('Discount', 'discount', 'percentage'),
field('Fixed price', 'fixed_price'),
field('Target', 'product_or_category_id', 'object_id'),
field('Category', 'is_category'),
field('Customer #', 'customer_number'),
+9 -2
View File
@@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { searchAndSelectCustomer } from "@/components/shop/POSDepartmentProcess.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import { economicEanPayloadValue } from "@/services/economicCustomerIdentifiers.js";
import {
applyPosAddCustomerSearchResult,
createPosAddCustomerFields,
@@ -152,14 +153,20 @@ export function usePosAddCustomerForm(options = {}) {
errorMessage.value = null;
try {
await SessionUser.request("/auth/register/cvr", "POST", {
const payload = {
cvr: form.cvr,
companyPhone: Number.parseInt(form.companyPhone, 10),
invoiceEmail: form.invoiceEmail,
contactEmail: form.contactEmail,
contactPhone: Number.parseInt(form.contactPhone, 10),
searchResult: searchResult.value,
});
};
const ean = economicEanPayloadValue(form.ean);
if (ean !== undefined) {
payload.ean = ean;
}
await SessionUser.request("/auth/register/cvr", "POST", payload);
const selectedCustomer = await searchAndSelectCustomer(form.companyPhone);
const createdCustomerNumber = Number.parseInt(form.companyPhone, 10);
@@ -0,0 +1,95 @@
const TANK_CLEANING_TERMS = ["tank cleaning", "tankcleaning", "tankrens"];
const normalizeAttributeName = (attribute) => {
if (typeof attribute === "string") {
return attribute;
}
return String(attribute?.attribute ?? "");
};
export const hasCustomerAttribute = (attributes, attributeName) => {
if (!Array.isArray(attributes)) {
return false;
}
return attributes.some((attribute) => normalizeAttributeName(attribute) === attributeName);
};
const normalizedText = (value) => String(value ?? "").trim().toLowerCase();
const textContainsAny = (value, terms) => {
const text = normalizedText(value);
return terms.some((term) => term !== "" && text.includes(term));
};
export const isTankCleaningCategory = (category) => {
if (Number(category) === 5) {
return true;
}
return textContainsAny(category, ["tank_cleaning", ...TANK_CLEANING_TERMS]);
};
export const isTankCleaningProduct = (product) => {
if (!product) {
return false;
}
if (isTankCleaningCategory(product.category ?? product.product_category)) {
return true;
}
return textContainsAny(
[
product.name,
product.product_name,
product.category_name,
product.categoryName,
].join(" "),
TANK_CLEANING_TERMS
);
};
export const isProductCategoryRestrictedForCustomer = (category, attributes = []) => {
if (category === "addons" && hasCustomerAttribute(attributes, "restrictAdditionalServices")) {
return true;
}
if (category === "spot_free" && hasCustomerAttribute(attributes, "restrictSpotFree")) {
return true;
}
if (category === "interior_cleaning" && hasCustomerAttribute(attributes, "restrictInteriorCleaning")) {
return true;
}
if (isTankCleaningCategory(category) && hasCustomerAttribute(attributes, "restrictTankCleaning")) {
return true;
}
if (hasCustomerAttribute(attributes, "onlyTankCleaning") && !isTankCleaningCategory(category)) {
return true;
}
return false;
};
export const isProductRestrictedForCustomer = (product, attributes = []) => {
if (!product) {
return false;
}
const productName = normalizedText(product.name ?? product.product_name);
if (hasCustomerAttribute(attributes, "restrictSpotFree") && productName.includes("spot free")) {
return true;
}
if (hasCustomerAttribute(attributes, "restrictInteriorCleaning") && productName.includes("indvendig vask")) {
return true;
}
if (isTankCleaningProduct(product) && hasCustomerAttribute(attributes, "restrictTankCleaning")) {
return true;
}
if (hasCustomerAttribute(attributes, "onlyTankCleaning") && !isTankCleaningProduct(product)) {
return true;
}
return false;
};
+103 -7
View File
@@ -1176,6 +1176,9 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1190,6 +1193,15 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -1208,7 +1220,13 @@
"active": "@:{'templates.generated.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'templates.generated.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'templates.generated.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'templates.generated.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'templates.generated.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -1238,7 +1256,8 @@
"save_prices": "@:{'templates.generated.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'templates.generated.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'templates.generated.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'templates.generated.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'templates.generated.compat.limited_backoffice.role_permissions.title'}",
@@ -1498,6 +1517,14 @@
"subtitle_suffix": "@:{'words.generated.salg'} @:{'words.generated.af'} produktet",
"title": "Produktsalg"
},
"product_targets": {
"cancel_aria": "Annuller mål",
"edit_aria": "Rediger målprocent",
"input_label": "Målprocent",
"save_aria": "Gem mål",
"save_error": "Målet kunne ikke gemmes.",
"validation_range": "Målprocenten skal være mellem 0 og 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'words.generated.antal'} @:{'words.generated.fuldførte'}",
@@ -1692,6 +1719,7 @@
"company_phone": "Virksomhedstelefon",
"contact_email": "@.capitalize:{'words.generated.kontakt'}-@:{'words.generated.e'}-@:{'words.generated.mail'}",
"contact_phone": "Kontakttelefon",
"ean": "EAN",
"created_by": "@.capitalize:{'words.generated.oprettet'} @:{'words.generated.af'}",
"customer_conflict": {
"help_text": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.indtastede'} @:{'words.generated.nummerplade'} @:{'words.generated.tilhører'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.anden'} @:{'words.generated.gemt'} @:{'words.generated.kunde'}. @.capitalize:{'words.generated.vælg'} @:{'words.generated.om'} @:{'words.replication.article.host_mention'} @:{'words.generated.valgte'} @:{'words.generated.kunde'} @:{'words.generated.skal'} @:{'words.generated.beholdes'}, @:{'words.generated.eller'} @:{'words.generated.om'} @:{'words.generated.der'} @:{'words.generated.skal'} @:{'words.generated.skiftes'} @:{'words.generated.til'} @:{'words.generated.køretøjets'} @:{'words.generated.kunde'}.",
@@ -3240,6 +3268,7 @@
"benefit_portal": "@:{'words.generated.kundeportal'}",
"benefit_portal_desc": "@:{'words.generated.benyt'} kundeportalen @:{'words.generated.til'} @:{'words.generated.at'} @:{'words.generated.tilføje'} @:{'words.generated.køretøjer'}, @:{'words.generated.booke'} @:{'words.generated.vaske'}, downloade @:{'words.generated.fakturaer'}, @:{'words.generated.vaskecertifikater'} @:{'words.generated.og'} @:{'words.generated.information'} @:{'words.generated.for'} @:{'words.generated.historiske'} @:{'words.generated.vaske'}",
"benefit_wash_desc": "@:{'words.generated.benyt'} @:{'words.generated.vores'} @:{'words.generated.nationale'} @:{'words.generated.netværk'} @:{'words.generated.af'} @:{'words.generated.vaskehaller'} @:{'words.generated.til'} @:{'words.generated.konkurrencedygtige'} @:{'words.generated.priser'}",
"ean_label": "@:{'templates.generated.compat.admin.pos.ean'}",
"email_label": "@:{'words.generated.firma'} @:{'words.generated.email'}",
"intro": "@.capitalize:{'words.generated.som'} @:{'words.generated.kunde'} @:{'words.generated.hos'} @:{'words.generated.truck'} @:{'words.generated.wash'} @:{'words.generated.far'} @:{'words.generated.du'} @:{'words.generated.adgang'} @:{'words.generated.til'}:",
"phone_label": "@:{'words.generated.firma'} @:{'words.generated.telefon'}",
@@ -3485,17 +3514,17 @@
"error_report": {
"button": "Rapporter fejl",
"title": "Rapporter fejl",
"subtitle": "Send det aktuelle skærmbillede og de seneste fejldetaljer til support.",
"subtitle": "Send de seneste fejldetaljer til support. Et skærmbillede vedhæftes, når det er muligt.",
"before_error": "Hvad lavede du, før fejlen opstod?",
"expected": "Hvad forventede du, der ville ske?",
"actual": "Hvad skete der faktisk?",
"before_error_placeholder": "Beskriv handlingen, du var i gang med, for eksempel at åbne ordrer eller vælge en kunde.",
"expected_placeholder": "Beskriv det resultat, du forventede at se.",
"actual_placeholder": "Beskriv, hvad du så i stedet, inklusive eventuel fejltekst.",
"consent": "Jeg accepterer, at det aktuelle app-skærmbillede, de seneste request-fejl, Vue-fejl, browseroplysninger og mine svar indsamles til fejlfinding.",
"consent": "Jeg accepterer, at de seneste request-fejl, Vue-fejl, browseroplysninger, mine svar og et app-skærmbillede, når det er muligt, indsamles til fejlfinding.",
"submit": "Send rapport",
"submitted": "Fejlrapporten er sendt.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Prøv igen.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Rapporten sendes uden skærmbillede.",
"submit_failed": "Fejlrapporten kunne ikke sendes.",
"required": "Alle felter og accept af dataindsamling er påkrævet.",
"page_subtitle": "Rapporterede skærme, request-fejl og Vue-fejl",
@@ -4035,6 +4064,15 @@
"preview_title": "@:{'words.generated.forhandsvis'} @:{'words.generated.manedsopdeling'}",
"success_text": "@:{'words.generated.behandlede'} {processed} @:{'words.generated.fakturasamlinger'}. Opdelte {changed} @:{'words.generated.og'} sprang {skipped} @:{'words.generated.over'}.",
"success_title": "@.capitalize:{'words.generated.manedsopdeling'} @:{'words.generated.fuldført'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Fakturer sammen",
"split_by_month": "Opdel efter måned",
"split_error_title": "Månedsopdeling mislykkedes",
"split_success_text": "Behandlede {processed} fakturasamlinger. Opdelte {changed} og sprang {skipped} over.",
"split_success_title": "Månedsopdeling fuldført",
"text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?",
"title": "Ordrer fra flere måneder"
}
},
"invoicing": {
@@ -5734,6 +5772,31 @@
"transaction_history": "@:{'templates.generated.compat.superuser.nav.orders'}"
},
"other": "@:{'templates.generated.compat.global.other'}",
"overview": {
"active_vehicles": "Aktive køretøjer",
"attributes": "Attributter",
"connected": "Forbundet",
"customer_management": "Kundestyring",
"customer_number_required": "Et kundenummer er påkrævet før kundeindstillinger kan administreres.",
"customer_rules": "Kunderegler",
"effective_access": "Effektiv adgang",
"loaded": "Indlæst",
"load_failed": "Brugerdata kunne ikke indlæses",
"loading_user": "Indlæser bruger",
"management_hub": "Administrationshub",
"missing": "Mangler",
"more_permissions": "+{count} flere tilladelser",
"no_subscription_transactions": "Der er ikke registreret vaskeabonnementstransaktioner for kunden.",
"not_invoiced": "Ikke faktureret",
"open_draft": "Åben kladde",
"open_vehicles": "Åbn køretøjer",
"price_overrides": "Prisoverstyringer",
"pricing": "Priser",
"subscription_invoicing": "Abonnementsfakturering",
"subtitle": "Overblik over kundedata, adgang, priser, køretøjer og abonnementsfakturering.",
"vehicles_load_failed": "Køretøjer kunne ikke indlæses.",
"workspace_shortcuts": "Arbejdsgange"
},
"permissions": "@.capitalize:{'words.generated.tilladelser'}",
"user_data": "@.capitalize:{'words.generated.brugerdata'}",
"user_id": "@.capitalize:{'words.generated.bruger'} @.upper:{'words.generated.id'}",
@@ -5940,6 +6003,20 @@
},
"vehicles": {
"add": "@:{'words.generated.tilføj'} @:{'words.generated.køretøj'}",
"add_modal": {
"customer_label": "Kunde",
"customer_placeholder": "Søg efter kundenavn eller kundenummer",
"error": "Køretøjet kunne ikke tilføjes.",
"no_customer_results": "Ingen kunder fundet",
"reference_placeholder": "Valgfri reference",
"registration_placeholder": "Registreringsnummer",
"selected_customer": "Valgt kunde",
"submit": "Tilføj køretøj",
"title": "Tilføj køretøj",
"type_load_error": "Køretøjstyper kunne ikke indlæses.",
"type_placeholder": "Vælg køretøjstype",
"validation_error": "Vælg kunde, registreringsnummer og køretøjstype."
},
"brand": "@:{'templates.generated.compat.admin.pos.make'}",
"color": "Farve",
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
@@ -6004,6 +6081,9 @@
"title": "@.capitalize:{'words.generated.afdelingspriser'}",
"save": "@:{'words.generated.gem'} @:{'words.generated.priser'}",
"saved": "Priserne @:{'words.generated.er'} @:{'words.generated.gemt'}.",
"autosave_saving": "Gemmer priser...",
"autosave_pending": "Prisændringer afventer.",
"autosave_saved": "Alle prisændringer er gemt.",
"product": "Produkt",
"price": "Afdelingspris",
"price_required": "@.capitalize:{'words.generated.alle'} @:{'words.generated.produkter'} @:{'words.generated.skal'} have @:{'words.replication.host_definite_suffix'} @:{'words.generated.ikke'}-negativ numerisk pris.",
@@ -6018,6 +6098,15 @@
"edit_title": "@:{'words.generated.rediger'} @:{'words.generated.medarbejder'}",
"name": "Navn",
"email": "Email",
"user_id": "Bruger-id",
"phone": "Telefon",
"phone_validation": "Telefonnummeret skal være 4-15 cifre.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.adgangskode'}",
"password_create_help": "@.capitalize:{'words.generated.pakrævet'} @:{'words.generated.for'} nye @:{'words.generated.medarbejdere'}. Brug @:{'words.generated.mindst'} 8 tegn.",
"password_update_help": "Lad feltet være tomt @:{'words.generated.for'} at beholde den nuværende @:{'words.generated.adgangskode'}.",
@@ -6036,7 +6125,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@:{'words.generated.rediger'}",
"deactivate": "Deaktiver"
"deactivate": "Deaktiver",
"login_link": "QR-login",
"login_link_title": "Medarbejderloginlink",
"login_link_qr_alt": "QR-kode-loginlink",
"login_link_url": "Loginlink",
"copy_login_link": "Kopiér link",
"login_link_copied": "Linket er kopieret."
},
"roles": {
"viewer": {
@@ -6066,7 +6161,8 @@
"save_prices": "@.capitalize:{'words.generated.afdelingspriser'} @:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.gemmes'}.",
"load_employees": "@.capitalize:{'words.generated.medarbejdere'} @:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.indlæses'}.",
"save_employee": "@.capitalize:{'words.generated.medarbejderen'} @:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.gemmes'}.",
"deactivate_employee": "@.capitalize:{'words.generated.medarbejderen'} @:{'words.generated.kunne'} @:{'words.generated.ikke'} deaktiveres."
"deactivate_employee": "@.capitalize:{'words.generated.medarbejderen'} @:{'words.generated.kunne'} @:{'words.generated.ikke'} deaktiveres.",
"generate_employee_login_link": "Medarbejderens loginlink kunne ikke oprettes."
},
"role_permissions": {
"title": "Rolletilladelser",
+111 -7
View File
@@ -1287,6 +1287,9 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1301,6 +1304,15 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -1319,7 +1331,13 @@
"active": "@:{'templates.generated.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'templates.generated.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'templates.generated.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'templates.generated.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'templates.generated.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -1349,7 +1367,8 @@
"save_prices": "@:{'templates.generated.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'templates.generated.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'templates.generated.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'templates.generated.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'templates.generated.compat.limited_backoffice.role_permissions.title'}",
@@ -1609,6 +1628,14 @@
"subtitle_suffix": "Produktverk?@:{'words.generated.ufe'}",
"title": "@:{'templates.generated.compat.admin.daily_report.product_sales.subtitle_suffix'}"
},
"product_targets": {
"cancel_aria": "Ziel abbrechen",
"edit_aria": "Zielprozentsatz bearbeiten",
"input_label": "Zielprozentsatz",
"save_aria": "Ziel speichern",
"save_error": "Das Ziel konnte nicht gespeichert werden.",
"validation_range": "Der Zielprozentsatz muss zwischen 0 und 100 liegen."
},
"reports": {
"bookings": {
"completed_count": "@:{'words.generated.abgeschlossene'} @:{'words.generated.anzahl'}",
@@ -1803,6 +1830,7 @@
"company_phone": "Firmentelefon",
"contact_email": "@:{'words.generated.kontakt'}-@.upper:{'words.generated.e'}-@:{'words.generated.mail'}",
"contact_phone": "Kontakttelefonnummer",
"ean": "EAN",
"created_by": "@.capitalize:{'words.generated.erstellt'} @:{'words.generated.von'}",
"customer_conflict": {
"help_text": "@.capitalize:{'words.generated.dieses'} @:{'words.generated.kennzeichen'} @:{'words.generated.gehoert'} @:{'words.generated.zu'} @:{'words.generated.einem'} @:{'words.generated.anderen'} @:{'words.generated.gespeicherten'} @:{'words.generated.kunden'}. @:{'words.generated.waehlen'} @.capitalize:{'words.generated.sie'}, @:{'words.generated.ob'} @:{'words.generated.der'} @:{'words.generated.ausgewaehlte'} @.capitalize:{'words.generated.kunde'} @:{'words.generated.beibehalten'} @:{'words.generated.oder'} @:{'words.generated.zum'} @:{'words.generated.fahrzeugkunden'} @:{'words.generated.gewechselt'} @:{'words.generated.werden'} @:{'words.generated.soll'}.",
@@ -3351,6 +3379,7 @@
"benefit_portal": "@:{'words.generated.kundenportal'}",
"benefit_portal_desc": "@.capitalize:{'words.generated.nutzen'} @.capitalize:{'words.generated.sie'} @:{'words.generated.das'} @:{'words.generated.kundenportal'}, @:{'words.generated.um'} @:{'words.generated.fahrzeuge'} hinzuzuf?@:{'words.generated.gen'}, @.upper:{'words.generated.w'}?@:{'words.generated.schen'} @:{'words.generated.zu'} @:{'words.generated.buchen'}, @:{'words.generated.rechnungen'} @:{'words.generated.und'} @:{'words.generated.waschzertifikate'} herunterzuladen @:{'words.generated.sowie'} @:{'words.generated.informationen'} @:{'words.generated.zu'} @:{'words.generated.fr'}?@:{'words.generated.heren'} @.upper:{'words.generated.w'}?@:{'words.generated.schen'} einzusehen",
"benefit_wash_desc": "@.capitalize:{'words.generated.nutzen'} @.capitalize:{'words.generated.sie'} @:{'words.generated.unser'} @:{'words.generated.nationales'} @:{'words.generated.netz'} @:{'words.generated.von'} @:{'words.generated.waschhallen'} @:{'words.generated.zu'} @:{'words.generated.wettbewerbsf'}?@:{'words.generated.higen'} @:{'words.generated.preisen'}",
"ean_label": "@:{'templates.generated.compat.admin.pos.ean'}",
"email_label": "Firmen-@.upper:{'words.generated.e'}-@:{'words.generated.mail'}",
"intro": "@.capitalize:{'words.generated.als'} @.capitalize:{'words.generated.kunde'} @:{'words.generated.bei'} @:{'words.generated.truck'} @.capitalize:{'words.generated.wash'} @:{'words.generated.erhalten'} @.capitalize:{'words.generated.sie'} @:{'words.generated.zugang'} @:{'words.generated.zu'}:",
"phone_label": "@:{'templates.generated.compat.admin.pos.company_phone'}",
@@ -3566,6 +3595,13 @@
"opening_hours": {
"closed": "@:{'templates.generated.compat.global.closed'}"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiviert",
"custom_pricing_enabled": "Nur eigene Preise",
"custom_pricing_missing_price": "Fehlende Abteilungspreise werden zu 999999.",
"custom_pricing_only": "Keine Fallback-Preise",
"effective_department_price": "Effektiver Abteilungspreis"
},
"search_placeholder": "@.capitalize:{'words.generated.nach'} @:{'words.generated.abteilungsname'} @:{'words.generated.suchen'}",
"select_department": "@:{'templates.generated.compat.nav.select_department'}",
"subtitle": "@:{'words.generated.abteilungen'} @:{'words.generated.verwalten'}",
@@ -3589,17 +3625,17 @@
"error_report": {
"button": "Fehler melden",
"title": "Fehler melden",
"subtitle": "Sende den aktuellen Bildschirm und die letzten Fehlerdetails an den Support.",
"subtitle": "Sende die letzten Fehlerdetails an den Support. Ein Screenshot wird angehängt, wenn möglich.",
"before_error": "Was haben Sie getan, bevor der Fehler auftrat?",
"expected": "Was hatten Sie erwartet?",
"actual": "Was ist tatsächlich passiert?",
"before_error_placeholder": "Beschreiben Sie die Aktion, zum Beispiel Bestellungen öffnen oder einen Kunden auswählen.",
"expected_placeholder": "Beschreiben Sie das erwartete Ergebnis.",
"actual_placeholder": "Beschreiben Sie, was stattdessen zu sehen war, inklusive Fehlermeldung.",
"consent": "Ich akzeptiere, dass der aktuelle App-Bildschirm, letzte Request-Fehler, Vue-Fehler, Browserdetails und meine Antworten zur Fehlersuche erfasst werden.",
"consent": "Ich akzeptiere, dass letzte Request-Fehler, Vue-Fehler, Browserdetails, meine Antworten und ein App-Screenshot, wenn möglich, zur Fehlersuche erfasst werden.",
"submit": "Bericht senden",
"submitted": "Fehlerbericht gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Der Bericht wird ohne Screenshot gesendet.",
"submit_failed": "Der Fehlerbericht konnte nicht gesendet werden.",
"required": "Alle Felder und die Zustimmung zur Datenerfassung sind erforderlich.",
"page_subtitle": "Gemeldete Bildschirme, Request-Fehler und Vue-Fehler",
@@ -4139,6 +4175,15 @@
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Zusammen abrechnen",
"split_by_month": "Nach Monat aufteilen",
"split_error_title": "Monatsaufteilung fehlgeschlagen",
"split_success_text": "{processed} Rechnungssammlungen verarbeitet. {changed} aufgeteilt, {skipped} übersprungen.",
"split_success_title": "Monatsaufteilung abgeschlossen",
"text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?",
"title": "Aufträge aus mehreren Monaten"
}
},
"invoicing": {
@@ -4396,6 +4441,7 @@
"departments": {
"columns": {
"archived": "Archiviert",
"custom_pricing_only": "@:{'templates.generated.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Abmessung",
"economic_department": "@:{'templates.generated.compat.objects.economic_departments.single'}",
"latitude": "@:{'templates.generated.compat.departments.form.latitude'}",
@@ -5837,6 +5883,31 @@
"transaction_history": "@:{'templates.generated.compat.superuser.nav.orders'}"
},
"other": "@.capitalize:{'words.generated.andere'}",
"overview": {
"active_vehicles": "Aktive Fahrzeuge",
"attributes": "Attribute",
"connected": "Verbunden",
"customer_management": "Kundenverwaltung",
"customer_number_required": "Eine Kundennummer ist erforderlich, bevor Kundeneinstellungen verwaltet werden können.",
"customer_rules": "Kundenregeln",
"effective_access": "Effektiver Zugriff",
"loaded": "Geladen",
"load_failed": "Benutzerdaten konnten nicht geladen werden",
"loading_user": "Benutzer wird geladen",
"management_hub": "Verwaltung",
"missing": "Fehlt",
"more_permissions": "+{count} weitere Berechtigungen",
"no_subscription_transactions": "Für diesen Kunden sind keine Waschabonnement-Transaktionen registriert.",
"not_invoiced": "Nicht fakturiert",
"open_draft": "Offener Entwurf",
"open_vehicles": "Fahrzeuge öffnen",
"price_overrides": "Preisüberschreibungen",
"pricing": "Preise",
"subscription_invoicing": "Abonnementabrechnung",
"subtitle": "Überblick über Kundendaten, Zugriff, Preise, Fahrzeuge und Abonnementabrechnung.",
"vehicles_load_failed": "Fahrzeuge konnten nicht geladen werden.",
"workspace_shortcuts": "Arbeitsbereich"
},
"permissions": "Tilladelser",
"user_data": "Brugerdata",
"user_id": "@:{'words.generated.benutzer'}-@.upper:{'words.generated.id'}",
@@ -6043,6 +6114,20 @@
},
"vehicles": {
"add": "@:{'words.generated.fahrzeug'} @:{'words.generated.hinzuf'}?@:{'words.generated.gen'}",
"add_modal": {
"customer_label": "Kunde",
"customer_placeholder": "Nach Kundenname oder Kundennummer suchen",
"error": "Fahrzeug konnte nicht hinzugefuegt werden.",
"no_customer_results": "Keine Kunden gefunden",
"reference_placeholder": "Optionale Referenz",
"registration_placeholder": "Kennzeichen",
"selected_customer": "Ausgewaehlter Kunde",
"submit": "Fahrzeug hinzufuegen",
"title": "Fahrzeug hinzufuegen",
"type_load_error": "Fahrzeugtypen konnten nicht geladen werden.",
"type_placeholder": "Fahrzeugtyp auswaehlen",
"validation_error": "Kunde, Kennzeichen und Fahrzeugtyp auswaehlen."
},
"brand": "@:{'templates.generated.compat.admin.pos.make'}",
"color": "Farbe",
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
@@ -6107,6 +6192,9 @@
"title": "@.capitalize:{'words.generated.abteilungspreise'}",
"save": "@:{'words.generated.preise'} @:{'words.generated.speichern'}",
"saved": "@:{'words.generated.preise'} @:{'words.generated.gespeichert'}.",
"autosave_saving": "Preise werden gespeichert...",
"autosave_pending": "Preisaenderungen ausstehend.",
"autosave_saved": "Alle Preisaenderungen gespeichert.",
"product": "@.capitalize:{'words.generated.produkt'}",
"price": "Abteilungspreis",
"price_required": "Jedes @.capitalize:{'words.generated.produkt'} @:{'words.generated.muss'} einen @:{'words.generated.nicht'}-negativen numerischen Preis @:{'words.generated.haben'}.",
@@ -6121,6 +6209,15 @@
"edit_title": "@:{'words.generated.mitarbeiter'} @:{'words.generated.bearbeiten'}",
"name": "Name",
"email": "E-Mail",
"user_id": "Benutzer-ID",
"phone": "Telefon",
"phone_validation": "Die Telefonnummer muss 4-15 Ziffern enthalten.",
"country_codes": {
"denmark": "Dänemark",
"sweden": "Schweden",
"norway": "Norwegen",
"finland": "Finnland"
},
"password": "@:{'words.generated.passwort'}",
"password_create_help": "@.capitalize:{'words.generated.fur'} neue @:{'words.generated.mitarbeiter'} @:{'words.generated.erforderlich'}. Verwenden @.capitalize:{'words.generated.sie'} @:{'words.generated.mindestens'} 8 Zeichen.",
"password_update_help": "Leer lassen, um das vorhandene @:{'words.generated.passwort'} beizubehalten.",
@@ -6139,7 +6236,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@.capitalize:{'words.generated.bearbeiten'}",
"deactivate": "Deaktivieren"
"deactivate": "Deaktivieren",
"login_link": "QR-Login",
"login_link_title": "Mitarbeiter-Loginlink",
"login_link_qr_alt": "QR-Code-Loginlink",
"login_link_url": "Loginlink",
"copy_login_link": "Link kopieren",
"login_link_copied": "Link kopiert."
},
"roles": {
"viewer": {
@@ -6169,7 +6272,8 @@
"save_prices": "@.capitalize:{'words.generated.abteilungspreise'} @:{'words.generated.konnten'} @:{'words.generated.nicht'} @:{'words.generated.gespeichert'} @:{'words.generated.werden'}.",
"load_employees": "@:{'words.generated.mitarbeiter'} @:{'words.generated.konnten'} @:{'words.generated.nicht'} @:{'words.generated.geladen'} @:{'words.generated.werden'}.",
"save_employee": "@:{'words.generated.mitarbeiter'} @:{'words.generated.konnte'} @:{'words.generated.nicht'} @:{'words.generated.gespeichert'} @:{'words.generated.werden'}.",
"deactivate_employee": "@:{'words.generated.mitarbeiter'} @:{'words.generated.konnte'} @:{'words.generated.nicht'} @:{'words.generated.deaktiviert'} @:{'words.generated.werden'}."
"deactivate_employee": "@:{'words.generated.mitarbeiter'} @:{'words.generated.konnte'} @:{'words.generated.nicht'} @:{'words.generated.deaktiviert'} @:{'words.generated.werden'}.",
"generate_employee_login_link": "Mitarbeiter-Loginlink konnte nicht erstellt werden."
},
"role_permissions": {
"title": "Rollenberechtigungen",
+103 -7
View File
@@ -1008,6 +1008,9 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1022,6 +1025,15 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -1040,7 +1052,13 @@
"active": "@:{'templates.generated.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'templates.generated.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'templates.generated.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'templates.generated.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'templates.generated.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -1070,7 +1088,8 @@
"save_prices": "@:{'templates.generated.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'templates.generated.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'templates.generated.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'templates.generated.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'templates.generated.compat.limited_backoffice.role_permissions.title'}",
@@ -1330,6 +1349,14 @@
"subtitle_suffix": "@:{'words.generated.product'} @:{'words.generated.sales'}",
"title": "@.capitalize:{'words.generated.product'} @.capitalize:{'words.generated.sales'}"
},
"product_targets": {
"cancel_aria": "Cancel target",
"edit_aria": "Edit target percentage",
"input_label": "Target percentage",
"save_aria": "Save target",
"save_error": "The target could not be saved.",
"validation_range": "The target percentage must be between 0 and 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'words.generated.completed'} @:{'words.generated.count'}",
@@ -1524,6 +1551,7 @@
"company_phone": "@.capitalize:{'words.generated.company'} @:{'words.generated.phone'}",
"contact_email": "@.capitalize:{'words.generated.contact'} @:{'words.generated.email'}",
"contact_phone": "@.capitalize:{'words.generated.contact'} @:{'words.generated.phone'}",
"ean": "EAN",
"created_by": "@.capitalize:{'words.generated.created'} @:{'words.generated.by'}",
"customer_conflict": {
"help_text": "@.capitalize:{'words.generated.this'} @:{'words.generated.typed'} @:{'words.generated.plate'} @:{'words.generated.belongs'} @:{'words.generated.to'} @:{'words.generated.another'} @:{'words.generated.saved'} @:{'words.generated.customer'}. @.capitalize:{'words.generated.choose'} @:{'words.generated.whether'} @:{'words.generated.to'} @:{'words.generated.keep'} @:{'words.replication.article.host_mention'} @:{'words.generated.selected'} @:{'words.generated.customer'} @:{'words.generated.or'} @:{'words.generated.switch'} @:{'words.generated.to'} @:{'words.replication.article.host_mention'} @:{'words.generated.vehicle'} @:{'words.generated.customer'}.",
@@ -3072,6 +3100,7 @@
"benefit_portal": "@.capitalize:{'words.generated.customer'} @.capitalize:{'words.generated.portal'}",
"benefit_portal_desc": "@.capitalize:{'words.generated.use'} @:{'words.replication.article.host_mention'} @:{'words.generated.customer'} @:{'words.generated.portal'} @:{'words.generated.to'} @:{'words.generated.add'} @:{'words.generated.vehicles'}, @:{'words.generated.book'} @:{'words.generated.washes'}, @:{'words.generated.download'} @:{'words.generated.invoices'}, @:{'words.generated.wash'} @:{'words.generated.certificates'} @:{'words.generated.and'} @:{'words.generated.information'} @:{'words.generated.for'} @:{'words.generated.historical'} @:{'words.generated.washes'}",
"benefit_wash_desc": "@.capitalize:{'words.generated.use'} @:{'words.generated.our'} @:{'words.generated.national'} @:{'words.generated.network'} @:{'words.generated.of'} @:{'words.generated.wash'} @:{'words.generated.halls'} @:{'words.generated.at'} @:{'words.generated.competitive'} @:{'words.generated.prices'}",
"ean_label": "@:{'templates.generated.compat.admin.pos.ean'}",
"email_label": "@.capitalize:{'words.generated.company'} @:{'words.generated.email'}",
"intro": "@.capitalize:{'words.generated.as'} @:{'words.generated.a'} @:{'words.generated.customer'} @:{'words.generated.at'} @.capitalize:{'words.generated.truck'} @.capitalize:{'words.generated.wash'} @:{'words.generated.you'} @:{'words.generated.get'} @:{'words.generated.access'} @:{'words.generated.to'}:",
"phone_label": "@:{'templates.generated.compat.admin.pos.company_phone'}",
@@ -3317,17 +3346,17 @@
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send the current screen and recent error details to support.",
"subtitle": "Send recent error details to support. A screenshot is attached when available.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"expected_placeholder": "Describe the result you expected to see.",
"actual_placeholder": "Describe what you saw instead, including any error text.",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"consent": "I accept that recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. Please try again.",
"capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
@@ -3867,6 +3896,15 @@
"preview_title": "@.capitalize:{'words.generated.preview'} @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Invoice together",
"split_by_month": "Split by month",
"split_error_title": "Monthly split failed",
"split_success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
"split_success_title": "Monthly split completed",
"text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?",
"title": "Orders from multiple months"
}
},
"invoicing": {
@@ -5566,6 +5604,31 @@
"transaction_history": "@.capitalize:{'words.generated.transaction'} @:{'words.generated.history'}"
},
"other": "@:{'templates.generated.compat.global.other'}",
"overview": {
"active_vehicles": "Active vehicles",
"attributes": "Attributes",
"connected": "Connected",
"customer_management": "Customer management",
"customer_number_required": "A customer number is required before customer settings can be managed.",
"customer_rules": "Customer rules",
"effective_access": "Effective access",
"loaded": "Loaded",
"load_failed": "User data could not be loaded",
"loading_user": "Loading user",
"management_hub": "Management hub",
"missing": "Missing",
"more_permissions": "+{count} more permissions",
"no_subscription_transactions": "No wash subscription transactions are registered for this customer.",
"not_invoiced": "Not invoiced",
"open_draft": "Open draft",
"open_vehicles": "Open vehicles",
"price_overrides": "Price overrides",
"pricing": "Pricing",
"subscription_invoicing": "Subscription invoicing",
"subtitle": "Overview of customer data, access, pricing, vehicles, and subscription invoicing.",
"vehicles_load_failed": "Vehicles could not be loaded.",
"workspace_shortcuts": "Workspace shortcuts"
},
"permissions": "@.capitalize:{'words.generated.permissions'}",
"user_data": "@.capitalize:{'words.generated.user'} @:{'words.generated.data'}",
"user_id": "@.capitalize:{'words.generated.user'} @.upper:{'words.generated.id'}",
@@ -5772,6 +5835,20 @@
},
"vehicles": {
"add": "@.capitalize:{'words.generated.add'} @:{'words.generated.vehicle'}",
"add_modal": {
"customer_label": "Customer",
"customer_placeholder": "Search by customer name or number",
"error": "Unable to add vehicle.",
"no_customer_results": "No customers found",
"reference_placeholder": "Optional reference",
"registration_placeholder": "Registration number",
"selected_customer": "Selected customer",
"submit": "Add vehicle",
"title": "Add vehicle",
"type_load_error": "Unable to load vehicle types.",
"type_placeholder": "Select vehicle type",
"validation_error": "Select a customer, registration number, and vehicle type."
},
"brand": "Brand",
"color": "Color",
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
@@ -5836,6 +5913,9 @@
"title": "@.capitalize:{'words.generated.department'} @:{'words.generated.prices'}",
"save": "@.capitalize:{'words.generated.save'} @:{'words.generated.prices'}",
"saved": "@.capitalize:{'words.generated.prices'} @:{'words.generated.saved'}.",
"autosave_saving": "Saving prices...",
"autosave_pending": "Price changes pending.",
"autosave_saved": "All price changes saved.",
"product": "@.capitalize:{'words.generated.product'}",
"price": "@.capitalize:{'words.generated.department'} @:{'words.generated.price'}",
"price_required": "Every @:{'words.generated.product'} @:{'words.generated.must'} @:{'words.generated.have'} @:{'words.generated.a'} non-negative numeric @:{'words.generated.price'}.",
@@ -5850,6 +5930,15 @@
"edit_title": "@.capitalize:{'words.generated.edit'} @:{'words.generated.employee'}",
"name": "Name",
"email": "Email",
"user_id": "User ID",
"phone": "Phone",
"phone_validation": "Phone number must be 4-15 digits.",
"country_codes": {
"denmark": "Denmark",
"sweden": "Sweden",
"norway": "Norway",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.password'}",
"password_create_help": "@.capitalize:{'words.generated.required'} @:{'words.generated.for'} new @:{'words.generated.employees'}. Use @:{'words.generated.at'} @:{'words.generated.least'} 8 characters.",
"password_update_help": "Leave blank @:{'words.generated.to'} keep @:{'words.replication.article.host_mention'} existing @:{'words.generated.password'}.",
@@ -5868,7 +5957,13 @@
"active": "Active",
"inactive": "@.capitalize:{'words.generated.inactive'}",
"edit": "@.capitalize:{'words.generated.edit'}",
"deactivate": "@.capitalize:{'words.generated.deactivate'}"
"deactivate": "@.capitalize:{'words.generated.deactivate'}",
"login_link": "QR login",
"login_link_title": "Employee login link",
"login_link_qr_alt": "QR code login link",
"login_link_url": "Login link",
"copy_login_link": "Copy link",
"login_link_copied": "Link copied."
},
"roles": {
"viewer": {
@@ -5898,7 +5993,8 @@
"save_prices": "@.capitalize:{'words.generated.could'} @:{'words.generated.not'} @:{'words.generated.save'} @:{'words.generated.department'} @:{'words.generated.prices'}.",
"load_employees": "@.capitalize:{'words.generated.could'} @:{'words.generated.not'} @:{'words.generated.load'} @:{'words.generated.employees'}.",
"save_employee": "@.capitalize:{'words.generated.could'} @:{'words.generated.not'} @:{'words.generated.save'} @:{'words.generated.employee'}.",
"deactivate_employee": "@.capitalize:{'words.generated.could'} @:{'words.generated.not'} @:{'words.generated.deactivate'} @:{'words.generated.employee'}."
"deactivate_employee": "@.capitalize:{'words.generated.could'} @:{'words.generated.not'} @:{'words.generated.deactivate'} @:{'words.generated.employee'}.",
"generate_employee_login_link": "Could not create employee login link."
},
"role_permissions": {
"title": "Role permissions",
+134
View File
@@ -126,6 +126,14 @@
"subtitle_suffix": "@:{'templates.generated.compat.admin.daily_report.product_sales.subtitle_suffix'}",
"title": "@:{'templates.generated.compat.admin.daily_report.product_sales.title'}"
},
"product_targets": {
"cancel_aria": "@:{'templates.generated.compat.admin.daily_report.product_targets.cancel_aria'}",
"edit_aria": "@:{'templates.generated.compat.admin.daily_report.product_targets.edit_aria'}",
"input_label": "@:{'templates.generated.compat.admin.daily_report.product_targets.input_label'}",
"save_aria": "@:{'templates.generated.compat.admin.daily_report.product_targets.save_aria'}",
"save_error": "@:{'templates.generated.compat.admin.daily_report.product_targets.save_error'}",
"validation_range": "@:{'templates.generated.compat.admin.daily_report.product_targets.validation_range'}"
},
"reports": {
"avg_revenue": {
"label": "@:{'templates.generated.compat.department_reports.avg_revenue_per_wash'}",
@@ -385,6 +393,7 @@
"delete": "@:{'templates.generated.compat.global.delete'}",
"details": "@:{'templates.generated.compat.admin.pos.details'}",
"download": "@:{'templates.generated.compat.guest.pwa.title'}",
"ean": "@:{'templates.generated.compat.admin.pos.ean'}",
"drafts_assignment": {
"button": "@:{'templates.generated.compat.admin.pos.drafts_assignment.button'}",
"create_invoice_collection": "@:{'templates.generated.compat.admin.pos.drafts_assignment.create_invoice_collection'}",
@@ -2061,6 +2070,7 @@
"benefit_wash": "@:{'templates.generated.compat.tables.bookings.wash'}",
"benefit_wash_desc": "@:{'templates.generated.compat.tables.bookings.wash_desc'}",
"cvr_label": "@:{'templates.generated.compat.customers.cvr'}",
"ean_label": "@:{'templates.generated.compat.customer_creation.customer.ean_label'}",
"email_label": "@:{'templates.generated.compat.customer_creation.customer.email_label'}",
"intro": "@:{'templates.generated.compat.customer_creation.customer.intro'}",
"phone_label": "@:{'templates.generated.compat.customer_creation.customer.phone_label'}",
@@ -3156,6 +3166,15 @@
"preview_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.preview_title'}",
"success_text": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_text'}",
"success_title": "@:{'templates.generated.compat.invoicing_period.monthly_split.success_title'}"
},
"multi_month_invoice_warning": {
"invoice_together": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.invoice_together'}",
"split_by_month": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_by_month'}",
"split_error_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_error_title'}",
"split_success_text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_text'}",
"split_success_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}",
"text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.text'}",
"title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.title'}"
}
},
"invoicing": {
@@ -4567,6 +4586,82 @@
"subtitle": "@:{'templates.generated.compat.superuser_dashboard.departments.subtitle'}",
"title": "@:common.departments"
},
"department_navigation": {
"overview": "Overview",
"modules": "Modules",
"branding": "Profile & Branding",
"gateways": "@.capitalize:{'words.generated.gateways'}",
"stripe": "@:{'words.generated.stripe'}",
"pricing": "Pricing",
"categories": "Categories"
},
"department_overview": {
"title": "Department overview",
"subtitle": "Operational overview for the selected department",
"loading": "Loading department overview",
"date_from": "From",
"date_to": "To",
"range_label": "{from} to {to}",
"empty_value": "-",
"out_of": "of {total}",
"presets": {
"today": "Today",
"last_seven_days": "Last 7 days"
},
"metrics": {
"bookings": "Bookings",
"complaints": "Complaints",
"night_washes": "Night washes",
"overtime": "Overtime",
"products_sold": "Products sold",
"revenue": "Revenue",
"transactions": "Transactions",
"washes": "Washes",
"water_usage": "Water"
},
"units": {
"hours": "h",
"liters": "L"
},
"products": {
"title": "Product mix",
"subtitle": "Tracked wash products in the selected period",
"empty": "No product activity for the selected period"
},
"profile": {
"title": "Department profile",
"no_description": "No department description",
"department_id": "Department ID",
"economic_department_id": "Economic department",
"branding": "Branding",
"created_at": "@:common.created",
"updated_at": "Updated"
},
"hardware": {
"title": "Hardware readiness",
"subtitle": "Gateway-backed lane and relay state",
"gateways": "Gateways online",
"lanes": "Lanes",
"gates": "Gates",
"relays": "Relays",
"scanners": "Scanners",
"issues": "Issues"
},
"quick_links": {
"title": "Department tools",
"subtitle": "Open the focused setup areas for this department",
"modules": "@:superuser_dashboard.department_navigation.modules",
"branding": "@:superuser_dashboard.department_overview.profile.branding",
"gateways": "@:superuser_dashboard.department_navigation.gateways",
"stripe": "@:superuser_dashboard.department_navigation.stripe",
"pricing": "@:superuser_dashboard.department_navigation.pricing",
"categories": "@:superuser_dashboard.department_navigation.categories"
},
"errors": {
"invalid_department": "A valid department is required",
"load": "Unable to load the department overview"
}
},
"employees": {
"edit_employee_title": "@:employees.edit_employee",
"new_employee_subtitle": "@:{'templates.generated.compat.superuser_dashboard.employees.new_employee_subtitle'}",
@@ -5545,6 +5640,31 @@
"transaction_history": "@:{'templates.generated.compat.user_admin.orders.transaction_history'}"
},
"other": "@:{'templates.generated.compat.user_admin.other'}",
"overview": {
"active_vehicles": "@:{'templates.generated.compat.user_admin.overview.active_vehicles'}",
"attributes": "@:{'templates.generated.compat.user_admin.overview.attributes'}",
"connected": "@:{'templates.generated.compat.user_admin.overview.connected'}",
"customer_management": "@:{'templates.generated.compat.user_admin.overview.customer_management'}",
"customer_number_required": "@:{'templates.generated.compat.user_admin.overview.customer_number_required'}",
"customer_rules": "@:{'templates.generated.compat.user_admin.overview.customer_rules'}",
"effective_access": "@:{'templates.generated.compat.user_admin.overview.effective_access'}",
"loaded": "@:{'templates.generated.compat.user_admin.overview.loaded'}",
"load_failed": "@:{'templates.generated.compat.user_admin.overview.load_failed'}",
"loading_user": "@:{'templates.generated.compat.user_admin.overview.loading_user'}",
"management_hub": "@:{'templates.generated.compat.user_admin.overview.management_hub'}",
"missing": "@:{'templates.generated.compat.user_admin.overview.missing'}",
"more_permissions": "@:{'templates.generated.compat.user_admin.overview.more_permissions'}",
"no_subscription_transactions": "@:{'templates.generated.compat.user_admin.overview.no_subscription_transactions'}",
"not_invoiced": "@:{'templates.generated.compat.user_admin.overview.not_invoiced'}",
"open_draft": "@:{'templates.generated.compat.user_admin.overview.open_draft'}",
"open_vehicles": "@:{'templates.generated.compat.user_admin.overview.open_vehicles'}",
"price_overrides": "@:{'templates.generated.compat.user_admin.overview.price_overrides'}",
"pricing": "@:{'templates.generated.compat.user_admin.overview.pricing'}",
"subscription_invoicing": "@:{'templates.generated.compat.user_admin.overview.subscription_invoicing'}",
"subtitle": "@:{'templates.generated.compat.user_admin.overview.subtitle'}",
"vehicles_load_failed": "@:{'templates.generated.compat.user_admin.overview.vehicles_load_failed'}",
"workspace_shortcuts": "@:{'templates.generated.compat.user_admin.overview.workspace_shortcuts'}"
},
"permissions": "@:{'templates.generated.compat.user_admin.permissions'}",
"subtitle": "@:user_admin.user_data",
"title": "@:{'templates.generated.compat.common.user'}",
@@ -5842,6 +5962,20 @@
"vehicles": {
"actions": "@:common.actions",
"add": "@:{'templates.generated.compat.vehicles.add'}",
"add_modal": {
"customer_label": "@:{'templates.generated.compat.vehicles.add_modal.customer_label'}",
"customer_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.customer_placeholder'}",
"error": "@:{'templates.generated.compat.vehicles.add_modal.error'}",
"no_customer_results": "@:{'templates.generated.compat.vehicles.add_modal.no_customer_results'}",
"reference_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.reference_placeholder'}",
"registration_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.registration_placeholder'}",
"selected_customer": "@:{'templates.generated.compat.vehicles.add_modal.selected_customer'}",
"submit": "@:{'templates.generated.compat.vehicles.add_modal.submit'}",
"title": "@:{'templates.generated.compat.vehicles.add_modal.title'}",
"type_load_error": "@:{'templates.generated.compat.vehicles.add_modal.type_load_error'}",
"type_placeholder": "@:{'templates.generated.compat.vehicles.add_modal.type_placeholder'}",
"validation_error": "@:{'templates.generated.compat.vehicles.add_modal.validation_error'}"
},
"brand": "@:{'templates.generated.compat.vehicles.brand'}",
"color": "@:{'templates.generated.compat.vehicles.color'}",
"created_at": "@:{'templates.generated.compat.global.generated'}",
+111 -7
View File
@@ -1290,6 +1290,9 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1304,6 +1307,15 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -1322,7 +1334,13 @@
"active": "@:{'templates.generated.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'templates.generated.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'templates.generated.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'templates.generated.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'templates.generated.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -1352,7 +1370,8 @@
"save_prices": "@:{'templates.generated.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'templates.generated.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'templates.generated.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'templates.generated.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'templates.generated.compat.limited_backoffice.role_permissions.title'}",
@@ -1612,6 +1631,14 @@
"subtitle_suffix": "@:{'words.generated.produktsalg'}",
"title": "@.capitalize:{'words.generated.produktsalg'}"
},
"product_targets": {
"cancel_aria": "Avbryt mål",
"edit_aria": "Rediger målprosent",
"input_label": "Målprosent",
"save_aria": "Lagre mål",
"save_error": "Målet kunne ikke lagres.",
"validation_range": "Målprosenten må være mellom 0 og 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'words.generated.fullført'} @:{'words.generated.telling'}",
@@ -1806,6 +1833,7 @@
"company_phone": "Firmatelefon",
"contact_email": "@.capitalize:{'words.generated.kontakt'} @:{'words.generated.e'}-@:{'words.generated.post'}",
"contact_phone": "@.capitalize:{'words.generated.kontakt'} @:{'words.generated.telefon'}",
"ean": "EAN",
"created_by": "Laget @:{'words.generated.av'}",
"customer_conflict": {
"help_text": "@.capitalize:{'words.generated.det'} @:{'words.generated.innskrevne'} @:{'words.generated.skiltet'} @:{'words.generated.tilhoerer'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.annen'} @:{'words.generated.lagret'} @:{'words.generated.kunde'}. @.capitalize:{'words.generated.velg'} @:{'words.generated.om'} @:{'words.replication.article.host_mention'} @:{'words.generated.valgte'} @:{'words.generated.kunden'} @:{'words.generated.skal'} @:{'words.generated.beholdes'} @:{'words.generated.eller'} @:{'words.generated.om'} @:{'words.generated.det'} @:{'words.generated.skal'} @:{'words.generated.byttes'} @:{'words.generated.til'} @:{'words.generated.kjoretoykunden'}.",
@@ -3354,6 +3382,7 @@
"benefit_portal": "@:{'words.generated.kundeportal'}",
"benefit_portal_desc": "@.capitalize:{'words.generated.bruk'} kundeportalen @:{'words.generated.til'} @:{'words.generated.a'} @:{'words.generated.legge'} @:{'words.generated.til'} @:{'words.generated.kjøretøy'}, bokvaske, @:{'words.generated.laste'} @:{'words.generated.ned'} @:{'words.generated.fakturaer'}, @:{'words.generated.vaskesertifikater'} @:{'words.generated.og'} @:{'words.generated.informasjon'} @:{'words.generated.for'} @:{'words.generated.historiske'} @:{'words.generated.vask'}.",
"benefit_wash_desc": "@.capitalize:{'words.generated.bruk'} @:{'words.generated.vart'} @:{'words.generated.nasjonale'} @:{'words.generated.nettverk'} @:{'words.generated.av'} @:{'words.generated.vaskehaller'} @:{'words.generated.til'} @:{'words.generated.konkurransedyktige'} @:{'words.generated.priser'}",
"ean_label": "@:{'templates.generated.compat.admin.pos.ean'}",
"email_label": "Firmaets @:{'words.generated.e'}-@:{'words.generated.post'}",
"intro": "@.capitalize:{'words.generated.som'} @:{'words.generated.kunde'} @:{'words.generated.hos'} @:{'words.generated.truck'} @.capitalize:{'words.generated.wash'} @:{'words.generated.far'} @:{'words.generated.du'} @:{'words.generated.tilgang'} @:{'words.generated.til'}:",
"phone_label": "@:{'templates.generated.compat.admin.pos.company_phone'}",
@@ -3569,6 +3598,13 @@
"opening_hours": {
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_enabled": "Kun egendefinerte priser",
"custom_pricing_missing_price": "Manglende avdelingspriser blir 999999.",
"custom_pricing_only": "Ingen fallback-priser",
"effective_department_price": "Effektiv avdelingspris"
},
"search_placeholder": "@.capitalize:{'words.generated.søk'} @:{'words.generated.etter'} @:{'words.generated.avdelingsnavn'}",
"select_department": "@.capitalize:{'words.generated.velg'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.avdeling'}",
"subtitle": "@.capitalize:{'words.generated.administrer'} @:{'words.generated.avdelinger'}",
@@ -3592,17 +3628,17 @@
"error_report": {
"button": "Rapporter feil",
"title": "Rapporter feil",
"subtitle": "Send gjeldende skjermbilde og de siste feildetaljene til support.",
"subtitle": "Send de siste feildetaljene til support. Et skjermbilde legges ved når det er mulig.",
"before_error": "Hva gjorde du før feilen oppstod?",
"expected": "Hva forventet du at skulle skje?",
"actual": "Hva skjedde faktisk?",
"before_error_placeholder": "Beskriv handlingen du utførte, for eksempel å åpne ordre eller velge en kunde.",
"expected_placeholder": "Beskriv resultatet du forventet å se.",
"actual_placeholder": "Beskriv hva du så i stedet, inkludert eventuell feiltekst.",
"consent": "Jeg godtar at gjeldende app-skjermbilde, siste request-feil, Vue-feil, nettleserdetaljer og svarene mine samles inn for feilsøking.",
"consent": "Jeg godtar at siste request-feil, Vue-feil, nettleserdetaljer, svarene mine og et app-skjermbilde når det er mulig, samles inn for feilsøking.",
"submit": "Send rapport",
"submitted": "Feilrapport sendt.",
"capture_failed": "Skjermbildet kunne ikke tas. Prøv igjen.",
"capture_failed": "Skjermbildet kunne ikke tas. Rapporten sendes uten skjermbilde.",
"submit_failed": "Feilrapporten kunne ikke sendes.",
"required": "Alle felter og godkjenning av datainnsamling er påkrevd.",
"page_subtitle": "Rapporterte skjermer, request-feil og Vue-feil",
@@ -4142,6 +4178,15 @@
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Fakturer samlet",
"split_by_month": "Del opp etter måned",
"split_error_title": "Månedsdeling mislyktes",
"split_success_text": "Behandlet {processed} fakturasamlinger. Delte opp {changed}, hoppet over {skipped}.",
"split_success_title": "Månedsdeling fullført",
"text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?",
"title": "Ordrer fra flere måneder"
}
},
"invoicing": {
@@ -4399,6 +4444,7 @@
"departments": {
"columns": {
"archived": "Arkivert",
"custom_pricing_only": "@:{'templates.generated.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Dimensjon",
"economic_department": "@.upper:{'words.generated.e'}-@:{'words.generated.økonomisk'} @:{'words.generated.avdeling'}",
"latitude": "@:{'templates.generated.compat.departments.form.latitude'}",
@@ -5840,6 +5886,31 @@
"transaction_history": "@:{'templates.generated.compat.superuser.nav.orders'}"
},
"other": "@:{'templates.generated.compat.global.other'}",
"overview": {
"active_vehicles": "Aktive kjøretøy",
"attributes": "Attributter",
"connected": "Tilkoblet",
"customer_management": "Kundestyring",
"customer_number_required": "Et kundenummer kreves før kundeinnstillinger kan administreres.",
"customer_rules": "Kunderegler",
"effective_access": "Effektiv tilgang",
"loaded": "Lastet",
"load_failed": "Brukerdata kunne ikke lastes",
"loading_user": "Laster bruker",
"management_hub": "Administrasjon",
"missing": "Mangler",
"more_permissions": "+{count} flere tillatelser",
"no_subscription_transactions": "Ingen vaskeabonnementstransaksjoner er registrert for kunden.",
"not_invoiced": "Ikke fakturert",
"open_draft": "Åpent utkast",
"open_vehicles": "Åpne kjøretøy",
"price_overrides": "Prisoverstyringer",
"pricing": "Priser",
"subscription_invoicing": "Abonnementsfakturering",
"subtitle": "Oversikt over kundedata, tilgang, priser, kjøretøy og abonnementsfakturering.",
"vehicles_load_failed": "Kjøretøy kunne ikke lastes.",
"workspace_shortcuts": "Arbeidsområde"
},
"permissions": "@.capitalize:{'words.generated.tillatelser'}",
"user_data": "@.capitalize:{'words.generated.brukerdata'}",
"user_id": "@.capitalize:{'words.generated.bruker'}-@.upper:{'words.generated.id'}",
@@ -6046,6 +6117,20 @@
},
"vehicles": {
"add": "@:{'words.generated.lagg'} @:{'words.generated.till'} @:{'words.generated.fordon'}",
"add_modal": {
"customer_label": "Kunde",
"customer_placeholder": "Søk etter kundenavn eller kundenummer",
"error": "Kunne ikke legge til kjøretøy.",
"no_customer_results": "Ingen kunder funnet",
"reference_placeholder": "Valgfri referanse",
"registration_placeholder": "Registreringsnummer",
"selected_customer": "Valgt kunde",
"submit": "Legg til kjøretøy",
"title": "Legg til kjøretøy",
"type_load_error": "Kunne ikke laste kjøretøytyper.",
"type_placeholder": "Velg kjøretøytype",
"validation_error": "Velg kunde, registreringsnummer og kjøretøytype."
},
"brand": "Märke",
"color": "Färg",
"delete_vehicle": "@:{'templates.generated.compat.vehicles.delete'}",
@@ -6110,6 +6195,9 @@
"title": "@.capitalize:{'words.generated.avdelingspriser'}",
"save": "@.capitalize:{'words.generated.lagre'} @:{'words.generated.priser'}",
"saved": "Prisene @:{'words.generated.er'} @:{'words.generated.lagret'}.",
"autosave_saving": "Lagrer priser...",
"autosave_pending": "Prisendringer venter.",
"autosave_saved": "Alle prisendringer er lagret.",
"product": "Produkt",
"price": "Avdelingspris",
"price_required": "@.capitalize:{'words.generated.alle'} @:{'words.generated.produkter'} @:{'words.generated.ma'} ha @:{'words.replication.host_definite_suffix'} @:{'words.generated.ikke'}-negativ numerisk pris.",
@@ -6124,6 +6212,15 @@
"edit_title": "@:{'words.generated.rediger'} @:{'words.generated.medarbeider'}",
"name": "Navn",
"email": "E-post",
"user_id": "Bruker-ID",
"phone": "Telefon",
"phone_validation": "Telefonnummeret må være 4-15 sifre.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.passord'}",
"password_create_help": "@.capitalize:{'words.generated.kreves'} @:{'words.generated.for'} nye @:{'words.generated.medarbeidere'}. Bruk @:{'words.generated.minst'} 8 tegn.",
"password_update_help": "La stå tomt @:{'words.generated.for'} å beholde nåværende @:{'words.generated.passord'}.",
@@ -6142,7 +6239,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@:{'words.generated.rediger'}",
"deactivate": "Deaktiver"
"deactivate": "Deaktiver",
"login_link": "QR-innlogging",
"login_link_title": "Innloggingslenke for medarbeider",
"login_link_qr_alt": "QR-kode for innloggingslenke",
"login_link_url": "Innloggingslenke",
"copy_login_link": "Kopier lenke",
"login_link_copied": "Lenken er kopiert."
},
"roles": {
"viewer": {
@@ -6172,7 +6275,8 @@
"save_prices": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.lagre'} @:{'words.generated.avdelingspriser'}.",
"load_employees": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.laste'} @:{'words.generated.medarbeidere'}.",
"save_employee": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} @:{'words.generated.lagre'} @:{'words.generated.medarbeideren'}.",
"deactivate_employee": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} deaktivere @:{'words.generated.medarbeideren'}."
"deactivate_employee": "@.capitalize:{'words.generated.kunne'} @:{'words.generated.ikke'} deaktivere @:{'words.generated.medarbeideren'}.",
"generate_employee_login_link": "Kunne ikke opprette innloggingslenke for medarbeideren."
},
"role_permissions": {
"title": "Rolletillatelser",
+111 -7
View File
@@ -1340,6 +1340,9 @@
"title": "@:{'templates.generated.compat.limited_backoffice.prices.title'}",
"save": "@:{'templates.generated.compat.limited_backoffice.prices.save'}",
"saved": "@:{'templates.generated.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'templates.generated.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'templates.generated.compat.limited_backoffice.prices.product'}",
"price": "@:{'templates.generated.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'templates.generated.compat.limited_backoffice.prices.price_required'}",
@@ -1354,6 +1357,15 @@
"edit_title": "@:{'templates.generated.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'templates.generated.compat.limited_backoffice.employees.name'}",
"email": "@:{'templates.generated.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'templates.generated.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'templates.generated.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'templates.generated.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'templates.generated.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'templates.generated.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'templates.generated.compat.limited_backoffice.employees.password_update_help'}",
@@ -1372,7 +1384,13 @@
"active": "@:{'templates.generated.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'templates.generated.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'templates.generated.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'templates.generated.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'templates.generated.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'templates.generated.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'templates.generated.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -1402,7 +1420,8 @@
"save_prices": "@:{'templates.generated.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'templates.generated.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'templates.generated.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'templates.generated.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'templates.generated.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'templates.generated.compat.limited_backoffice.role_permissions.title'}",
@@ -1662,6 +1681,14 @@
"subtitle_suffix": "@:{'words.generated.produktforsaljning'}",
"title": "@.capitalize:{'words.generated.produktforsaljning'}"
},
"product_targets": {
"cancel_aria": "Avbryt mål",
"edit_aria": "Redigera målprocent",
"input_label": "Målprocent",
"save_aria": "Spara mål",
"save_error": "Målet kunde inte sparas.",
"validation_range": "Målprocenten måste vara mellan 0 och 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'words.generated.antal'} @:{'words.generated.slutforda'}",
@@ -1856,6 +1883,7 @@
"company_phone": "Färetagstelefon",
"contact_email": "@.capitalize:{'words.generated.kontakt'}-@:{'words.generated.e'}-@:{'words.generated.post'}",
"contact_phone": "Kontakttelefon",
"ean": "EAN",
"created_by": "@.capitalize:{'words.generated.skapad'} @:{'words.generated.av'}",
"customer_conflict": {
"help_text": "@.capitalize:{'words.generated.det'} @:{'words.generated.angivna'} @:{'words.generated.registreringsnumret'} @:{'words.generated.tillhor'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.annan'} @:{'words.generated.sparad'} @:{'words.generated.kund'}. @:{'words.generated.valj_2'} @:{'words.generated.om'} @:{'words.replication.article.host_mention'} @:{'words.generated.valda'} @:{'words.generated.kunden'} @:{'words.generated.ska'} @:{'words.generated.behallas'} @:{'words.generated.eller'} @:{'words.generated.om'} @:{'words.generated.bytet'} @:{'words.generated.ska'} @:{'words.generated.goras'} @:{'words.generated.till'} @:{'words.generated.fordonskunden'}.",
@@ -3404,6 +3432,7 @@
"benefit_portal": "@:{'words.generated.kundportal'}",
"benefit_portal_desc": "@.capitalize:{'words.generated.anvand'} kundportalen @:{'words.generated.for'} @:{'words.generated.att'} @:{'words.generated.lagga'} @:{'words.generated.till'} @:{'words.generated.fordon'}, @:{'words.generated.boka'} @:{'words.generated.tvattar'}, @:{'words.generated.ladda'} @:{'words.generated.ner'} @:{'words.generated.fakturor'}, @:{'words.generated.tvattcertifikat'} @:{'words.generated.och'} @:{'words.generated.information'} @:{'words.generated.om'} @:{'words.generated.historiska'} @:{'words.generated.tvattar'}",
"benefit_wash_desc": "@.capitalize:{'words.generated.anvand'} @:{'words.generated.vart'} @:{'words.generated.nationella'} @:{'words.generated.natverk'} @:{'words.generated.av'} @:{'words.generated.tvatthallar'} @:{'words.generated.till'} @:{'words.generated.konkurrenskraftiga'} @:{'words.generated.priser'}",
"ean_label": "@:{'templates.generated.compat.admin.pos.ean'}",
"email_label": "Företagets @:{'words.generated.e'}-@:{'words.generated.post'}",
"intro": "@.capitalize:{'words.generated.som'} @:{'words.generated.kund'} @:{'words.generated.hos'} @:{'words.generated.truck'} @.capitalize:{'words.generated.wash'} @:{'words.generated.far'} @:{'words.generated.du'} @:{'words.generated.tillgang'} @:{'words.generated.till'}:",
"phone_label": "@:{'templates.generated.compat.admin.pos.company_phone'}",
@@ -3619,6 +3648,13 @@
"opening_hours": {
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_enabled": "Endast egna priser",
"custom_pricing_missing_price": "Saknade avdelningspriser blir 999999.",
"custom_pricing_only": "Inga fallback-priser",
"effective_department_price": "Effektivt avdelningspris"
},
"search_placeholder": "@:{'words.generated.sok'} @:{'words.generated.efter'} @:{'words.generated.avdelningsnamn'}",
"select_department": "@:{'words.generated.velg'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.avdeling'}",
"subtitle": "@:{'words.generated.administrer'} @:{'words.generated.avdelinger'}",
@@ -3642,17 +3678,17 @@
"error_report": {
"button": "Rapportera fel",
"title": "Rapportera fel",
"subtitle": "Skicka aktuell skärm och de senaste feldetaljerna till support.",
"subtitle": "Skicka de senaste feldetaljerna till support. En skärmbild bifogas när det är möjligt.",
"before_error": "Vad gjorde du innan felet uppstod?",
"expected": "Vad förväntade du dig skulle hända?",
"actual": "Vad hände faktiskt?",
"before_error_placeholder": "Beskriv åtgärden du gjorde, till exempel att öppna ordrar eller välja en kund.",
"expected_placeholder": "Beskriv resultatet du förväntade dig att se.",
"actual_placeholder": "Beskriv vad du såg i stället, inklusive eventuell feltext.",
"consent": "Jag accepterar att aktuell app-skärm, senaste request-fel, Vue-fel, webbläsardetaljer och mina svar samlas in för felsökning.",
"consent": "Jag accepterar att senaste request-fel, Vue-fel, webbläsardetaljer, mina svar och en app-skärmbild när det är möjligt samlas in för felsökning.",
"submit": "Skicka rapport",
"submitted": "Felrapport skickad.",
"capture_failed": "Skärmbilden kunde inte tas. Försök igen.",
"capture_failed": "Skärmbilden kunde inte tas. Rapporten skickas utan skärmbild.",
"submit_failed": "Felrapporten kunde inte skickas.",
"required": "Alla fält och godkännande av datainsamling krävs.",
"page_subtitle": "Rapporterade skärmar, request-fel och Vue-fel",
@@ -4192,6 +4228,15 @@
"preview_title": "Preview @:{'words.generated.monthly'} @:{'words.generated.split'}",
"success_text": "@:{'words.generated.processed'} {processed} @:{'words.generated.invoice'} @:{'words.generated.collections'}. @.capitalize:{'words.generated.split'} {changed}, @:{'words.generated.skipped'} {skipped}.",
"success_title": "@.capitalize:{'words.generated.monthly'} @:{'words.generated.split'} @:{'words.generated.completed'}"
},
"multi_month_invoice_warning": {
"invoice_together": "Fakturera tillsammans",
"split_by_month": "Dela upp per månad",
"split_error_title": "Månadsuppdelning misslyckades",
"split_success_text": "Bearbetade {processed} fakturasamlingar. Delade upp {changed}, hoppade över {skipped}.",
"split_success_title": "Månadsuppdelning klar",
"text": "Du håller på att fakturera ordrar från flera månader tillsammans ({months}). Ska de delas upp per månad i stället?",
"title": "Ordrar från flera månader"
}
},
"invoicing": {
@@ -4449,6 +4494,7 @@
"departments": {
"columns": {
"archived": "Arkiverad",
"custom_pricing_only": "@:{'templates.generated.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Dimension",
"economic_department": "@:{'templates.generated.compat.objects.economic_departments.single'}",
"latitude": "Latitude",
@@ -5890,6 +5936,31 @@
"transaction_history": "@:{'templates.generated.compat.superuser.nav.orders'}"
},
"other": "Andet",
"overview": {
"active_vehicles": "Aktiva fordon",
"attributes": "Attribut",
"connected": "Ansluten",
"customer_management": "Kundhantering",
"customer_number_required": "Ett kundnummer krävs innan kundinställningar kan hanteras.",
"customer_rules": "Kundregler",
"effective_access": "Effektiv åtkomst",
"loaded": "Inläst",
"load_failed": "Användardata kunde inte läsas in",
"loading_user": "Läser in användare",
"management_hub": "Administration",
"missing": "Saknas",
"more_permissions": "+{count} fler behörigheter",
"no_subscription_transactions": "Inga tvättabonnemangstransaktioner är registrerade för kunden.",
"not_invoiced": "Ej fakturerat",
"open_draft": "Öppet utkast",
"open_vehicles": "Öppna fordon",
"price_overrides": "Prisöverskrivningar",
"pricing": "Priser",
"subscription_invoicing": "Abonnemangsfakturering",
"subtitle": "Översikt över kunddata, åtkomst, priser, fordon och abonnemangsfakturering.",
"vehicles_load_failed": "Fordon kunde inte läsas in.",
"workspace_shortcuts": "Arbetsyta"
},
"permissions": "Tilladelser",
"user_data": "Brugerdata",
"user_id": "Användar-@.upper:{'words.generated.id'}",
@@ -6096,6 +6167,20 @@
},
"vehicles": {
"add": "@:{'words.generated.lagg'} @:{'words.generated.till'} @:{'words.generated.fordon'}",
"add_modal": {
"customer_label": "Kund",
"customer_placeholder": "Sök efter kundnamn eller kundnummer",
"error": "Det gick inte att lägga till fordon.",
"no_customer_results": "Inga kunder hittades",
"reference_placeholder": "Valfri referens",
"registration_placeholder": "Registreringsnummer",
"selected_customer": "Vald kund",
"submit": "Lägg till fordon",
"title": "Lägg till fordon",
"type_load_error": "Det gick inte att läsa in fordonstyper.",
"type_placeholder": "Välj fordonstyp",
"validation_error": "Välj kund, registreringsnummer och fordonstyp."
},
"brand": "Märke",
"color": "Färg",
"delete_vehicle": "@.capitalize:{'words.generated.ta'} @:{'words.generated.bort'} @:{'words.generated.fordon'}",
@@ -6160,6 +6245,9 @@
"title": "@.capitalize:{'words.generated.avdelningspriser'}",
"save": "@.capitalize:{'words.generated.spara'} @:{'words.generated.priser'}",
"saved": "Priserna @:{'words.generated.har'} @:{'words.generated.sparats'}.",
"autosave_saving": "Sparar priser...",
"autosave_pending": "Prisandringar vantar.",
"autosave_saved": "Alla prisandringar har sparats.",
"product": "Produkt",
"price": "Avdelningspris",
"price_required": "@.capitalize:{'words.generated.alla'} @:{'words.generated.produkter'} @:{'words.generated.maste'} ha ett icke-negativt numeriskt pris.",
@@ -6174,6 +6262,15 @@
"edit_title": "@:{'words.generated.redigera'} @:{'words.generated.medarbetare'}",
"name": "Namn",
"email": "E-post",
"user_id": "Användar-ID",
"phone": "Telefon",
"phone_validation": "Telefonnumret måste vara 4-15 siffror.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'words.generated.losenord'}",
"password_create_help": "@.capitalize:{'words.generated.kravs'} @:{'words.generated.for'} nya @:{'words.generated.medarbetare'}. Använd @:{'words.generated.minst'} 8 tecken.",
"password_update_help": "Lämna tomt @:{'words.generated.for'} att behålla nuvarande @:{'words.generated.losenord'}.",
@@ -6192,7 +6289,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@:{'words.generated.redigera'}",
"deactivate": "@.capitalize:{'words.generated.inaktivera'}"
"deactivate": "@.capitalize:{'words.generated.inaktivera'}",
"login_link": "QR-inloggning",
"login_link_title": "Inloggningslänk för medarbetare",
"login_link_qr_alt": "QR-kod för inloggningslänk",
"login_link_url": "Inloggningslänk",
"copy_login_link": "Kopiera länk",
"login_link_copied": "Länken har kopierats."
},
"roles": {
"viewer": {
@@ -6222,7 +6325,8 @@
"save_prices": "@.capitalize:{'words.generated.kunde'} @:{'words.generated.inte'} @:{'words.generated.spara'} @:{'words.generated.avdelningspriser'}.",
"load_employees": "@.capitalize:{'words.generated.kunde'} @:{'words.generated.inte'} @:{'words.generated.ladda'} @:{'words.generated.medarbetare'}.",
"save_employee": "@.capitalize:{'words.generated.kunde'} @:{'words.generated.inte'} @:{'words.generated.spara'} @:{'words.generated.medarbetaren'}.",
"deactivate_employee": "@.capitalize:{'words.generated.kunde'} @:{'words.generated.inte'} @:{'words.generated.inaktivera'} @:{'words.generated.medarbetaren'}."
"deactivate_employee": "@.capitalize:{'words.generated.kunde'} @:{'words.generated.inte'} @:{'words.generated.inaktivera'} @:{'words.generated.medarbetaren'}.",
"generate_employee_login_link": "Kunde inte skapa inloggningslänk för medarbetaren."
},
"role_permissions": {
"title": "Rollbehörigheter",
@@ -27,6 +27,14 @@
"subtitle_suffix": "@:{'terms.glossary.salg'} @:{'terms.glossary.af'} produktet",
"title": "Produktsalg"
},
"product_targets": {
"cancel_aria": "Annuller mål",
"edit_aria": "Rediger målprocent",
"input_label": "Målprocent",
"save_aria": "Gem mål",
"save_error": "Målet kunne ikke gemmes.",
"validation_range": "Målprocenten skal være mellem 0 og 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'terms.glossary.antal'} @:{'terms.glossary.fuldførte'}",
@@ -221,6 +229,7 @@
"company_phone": "Virksomhedstelefon",
"contact_email": "@.capitalize:{'terms.glossary.kontakt'}-@:{'terms.glossary.e'}-@:{'terms.glossary.mail'}",
"contact_phone": "Kontakttelefon",
"ean": "EAN",
"created_by": "@.capitalize:{'terms.glossary.oprettet'} @:{'terms.glossary.af'}",
"customer_conflict": {
"help_text": "@.capitalize:{'terms.replication.article.host_mention'} @:{'terms.glossary.indtastede'} @:{'terms.glossary.nummerplade'} @:{'terms.glossary.tilhører'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.anden'} @:{'terms.glossary.gemt'} @:{'terms.glossary.kunde'}. @.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.om'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valgte'} @:{'terms.glossary.kunde'} @:{'terms.glossary.skal'} @:{'terms.glossary.beholdes'}, @:{'terms.glossary.eller'} @:{'terms.glossary.om'} @:{'terms.glossary.der'} @:{'terms.glossary.skal'} @:{'terms.glossary.skiftes'} @:{'terms.glossary.til'} @:{'terms.glossary.køretøjets'} @:{'terms.glossary.kunde'}.",
@@ -7,6 +7,7 @@
"benefit_portal": "@:{'terms.glossary.kundeportal'}",
"benefit_portal_desc": "@:{'terms.glossary.benyt'} kundeportalen @:{'terms.glossary.til'} @:{'terms.glossary.at'} @:{'terms.glossary.tilføje'} @:{'terms.glossary.køretøjer'}, @:{'terms.glossary.booke'} @:{'terms.glossary.vaske'}, downloade @:{'terms.glossary.fakturaer'}, @:{'terms.glossary.vaskecertifikater'} @:{'terms.glossary.og'} @:{'terms.glossary.information'} @:{'terms.glossary.for'} @:{'terms.glossary.historiske'} @:{'terms.glossary.vaske'}",
"benefit_wash_desc": "@:{'terms.glossary.benyt'} @:{'terms.glossary.vores'} @:{'terms.glossary.nationale'} @:{'terms.glossary.netværk'} @:{'terms.glossary.af'} @:{'terms.glossary.vaskehaller'} @:{'terms.glossary.til'} @:{'terms.glossary.konkurrencedygtige'} @:{'terms.glossary.priser'}",
"ean_label": "@:{'phrases.compat.admin.pos.ean'}",
"email_label": "@:{'terms.glossary.firma'} @:{'terms.glossary.email'}",
"intro": "@.capitalize:{'terms.glossary.som'} @:{'terms.glossary.kunde'} @:{'terms.glossary.hos'} @:{'terms.glossary.truck'} @:{'terms.glossary.wash'} @:{'terms.glossary.far'} @:{'terms.glossary.du'} @:{'terms.glossary.adgang'} @:{'terms.glossary.til'}:",
"phone_label": "@:{'terms.glossary.firma'} @:{'terms.glossary.telefon'}",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Rapporter fejl",
"title": "Rapporter fejl",
"subtitle": "Send det aktuelle skærmbillede og de seneste fejldetaljer til support.",
"subtitle": "Send de seneste fejldetaljer til support. Et skærmbillede vedhæftes, når det er muligt.",
"before_error": "Hvad lavede du, før fejlen opstod?",
"expected": "Hvad forventede du, der ville ske?",
"actual": "Hvad skete der faktisk?",
"before_error_placeholder": "Beskriv handlingen, du var i gang med, for eksempel at åbne ordrer eller vælge en kunde.",
"expected_placeholder": "Beskriv det resultat, du forventede at se.",
"actual_placeholder": "Beskriv, hvad du så i stedet, inklusive eventuel fejltekst.",
"consent": "Jeg accepterer, at det aktuelle app-skærmbillede, de seneste request-fejl, Vue-fejl, browseroplysninger og mine svar indsamles til fejlfinding.",
"consent": "Jeg accepterer, at de seneste request-fejl, Vue-fejl, browseroplysninger, mine svar og et app-skærmbillede, når det er muligt, indsamles til fejlfinding.",
"submit": "Send rapport",
"submitted": "Fejlrapporten er sendt.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Prøv igen.",
"capture_failed": "Skærmbilledet kunne ikke oprettes. Rapporten sendes uden skærmbillede.",
"submit_failed": "Fejlrapporten kunne ikke sendes.",
"required": "Alle felter og accept af dataindsamling er påkrævet.",
"page_subtitle": "Rapporterede skærme, request-fejl og Vue-fejl",
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Fakturer sammen",
"split_by_month": "Opdel efter måned",
"split_error_title": "Månedsopdeling mislykkedes",
"split_success_text": "Behandlede {processed} fakturasamlinger. Opdelte {changed} og sprang {skipped} over.",
"split_success_title": "Månedsopdeling fuldført",
"text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?",
"title": "Ordrer fra flere måneder"
}
}
}
}
@@ -14,6 +14,31 @@
"transaction_history": "@:{'phrases.compat.superuser.nav.orders'}"
},
"other": "@:{'phrases.compat.global.other'}",
"overview": {
"active_vehicles": "Aktive køretøjer",
"attributes": "Attributter",
"connected": "Forbundet",
"customer_management": "Kundestyring",
"customer_number_required": "Et kundenummer er påkrævet før kundeindstillinger kan administreres.",
"customer_rules": "Kunderegler",
"effective_access": "Effektiv adgang",
"loaded": "Indlæst",
"load_failed": "Brugerdata kunne ikke indlæses",
"loading_user": "Indlæser bruger",
"management_hub": "Administrationshub",
"missing": "Mangler",
"more_permissions": "+{count} flere tilladelser",
"no_subscription_transactions": "Der er ikke registreret vaskeabonnementstransaktioner for kunden.",
"not_invoiced": "Ikke faktureret",
"open_draft": "Åben kladde",
"open_vehicles": "Åbn køretøjer",
"price_overrides": "Prisoverstyringer",
"pricing": "Priser",
"subscription_invoicing": "Abonnementsfakturering",
"subtitle": "Overblik over kundedata, adgang, priser, køretøjer og abonnementsfakturering.",
"vehicles_load_failed": "Køretøjer kunne ikke indlæses.",
"workspace_shortcuts": "Arbejdsgange"
},
"permissions": "@.capitalize:{'terms.glossary.tilladelser'}",
"user_data": "@.capitalize:{'terms.glossary.brugerdata'}",
"user_id": "@.capitalize:{'terms.glossary.bruger'} @.upper:{'terms.glossary.id'}",
@@ -2,6 +2,20 @@
"compat": {
"vehicles": {
"add": "@:{'terms.glossary.tilføj'} @:{'terms.glossary.køretøj'}",
"add_modal": {
"customer_label": "Kunde",
"customer_placeholder": "Søg efter kundenavn eller kundenummer",
"error": "Køretøjet kunne ikke tilføjes.",
"no_customer_results": "Ingen kunder fundet",
"reference_placeholder": "Valgfri reference",
"registration_placeholder": "Registreringsnummer",
"selected_customer": "Valgt kunde",
"submit": "Tilføj køretøj",
"title": "Tilføj køretøj",
"type_load_error": "Køretøjstyper kunne ikke indlæses.",
"type_placeholder": "Vælg køretøjstype",
"validation_error": "Vælg kunde, registreringsnummer og køretøjstype."
},
"brand": "@:{'phrases.compat.admin.pos.make'}",
"color": "Farve",
"delete_vehicle": "@:{'phrases.compat.vehicles.delete'}",
+49 -11
View File
@@ -28,13 +28,16 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -46,6 +49,15 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -64,7 +76,13 @@
"active": "@:{'phrases.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'phrases.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'phrases.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'phrases.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'phrases.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'phrases.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'phrases.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'phrases.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'phrases.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -94,7 +112,8 @@
"save_prices": "@:{'phrases.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'phrases.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'phrases.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'phrases.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'phrases.compat.limited_backoffice.role_permissions.title'}",
@@ -265,6 +284,9 @@
"title": "@.capitalize:{'terms.glossary.afdelingspriser'}",
"save": "@:{'terms.glossary.gem'} @:{'terms.glossary.priser'}",
"saved": "Priserne @:{'terms.glossary.er'} @:{'terms.glossary.gemt'}.",
"autosave_saving": "Gemmer priser...",
"autosave_pending": "Prisændringer afventer.",
"autosave_saved": "Alle prisændringer er gemt.",
"product": "Produkt",
"price": "Afdelingspris",
"price_required": "@.capitalize:{'terms.glossary.alle'} @:{'terms.glossary.produkter'} @:{'terms.glossary.skal'} have @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ikke'}-negativ numerisk pris.",
@@ -279,6 +301,15 @@
"edit_title": "@:{'terms.glossary.rediger'} @:{'terms.glossary.medarbejder'}",
"name": "Navn",
"email": "Email",
"user_id": "Bruger-id",
"phone": "Telefon",
"phone_validation": "Telefonnummeret skal være 4-15 cifre.",
"country_codes": {
"denmark": "Danmark",
"sweden": "Sverige",
"norway": "Norge",
"finland": "Finland"
},
"password": "@.capitalize:{'terms.glossary.adgangskode'}",
"password_create_help": "@.capitalize:{'terms.glossary.pakrævet'} @:{'terms.glossary.for'} nye @:{'terms.glossary.medarbejdere'}. Brug @:{'terms.glossary.mindst'} 8 tegn.",
"password_update_help": "Lad feltet være tomt @:{'terms.glossary.for'} at beholde den nuværende @:{'terms.glossary.adgangskode'}.",
@@ -297,7 +328,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@:{'terms.glossary.rediger'}",
"deactivate": "Deaktiver"
"deactivate": "Deaktiver",
"login_link": "QR-login",
"login_link_title": "Medarbejderloginlink",
"login_link_qr_alt": "QR-kode-loginlink",
"login_link_url": "Loginlink",
"copy_login_link": "Kopiér link",
"login_link_copied": "Linket er kopieret."
},
"roles": {
"viewer": {
@@ -327,7 +364,8 @@
"save_prices": "@.capitalize:{'terms.glossary.afdelingspriser'} @:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.gemmes'}.",
"load_employees": "@.capitalize:{'terms.glossary.medarbejdere'} @:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.indlæses'}.",
"save_employee": "@.capitalize:{'terms.glossary.medarbejderen'} @:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} @:{'terms.glossary.gemmes'}.",
"deactivate_employee": "@.capitalize:{'terms.glossary.medarbejderen'} @:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} deaktiveres."
"deactivate_employee": "@.capitalize:{'terms.glossary.medarbejderen'} @:{'terms.glossary.kunne'} @:{'terms.glossary.ikke'} deaktiveres.",
"generate_employee_login_link": "Medarbejderens loginlink kunne ikke oprettes."
},
"role_permissions": {
"title": "Rolletilladelser",
@@ -27,6 +27,14 @@
"subtitle_suffix": "Produktverk?@:{'terms.glossary.ufe'}",
"title": "@:{'phrases.compat.admin.daily_report.product_sales.subtitle_suffix'}"
},
"product_targets": {
"cancel_aria": "Ziel abbrechen",
"edit_aria": "Zielprozentsatz bearbeiten",
"input_label": "Zielprozentsatz",
"save_aria": "Ziel speichern",
"save_error": "Das Ziel konnte nicht gespeichert werden.",
"validation_range": "Der Zielprozentsatz muss zwischen 0 und 100 liegen."
},
"reports": {
"bookings": {
"completed_count": "@:{'terms.glossary.abgeschlossene'} @:{'terms.glossary.anzahl'}",
@@ -221,6 +229,7 @@
"company_phone": "Firmentelefon",
"contact_email": "@:{'terms.glossary.kontakt'}-@.upper:{'terms.glossary.e'}-@:{'terms.glossary.mail'}",
"contact_phone": "Kontakttelefonnummer",
"ean": "EAN",
"created_by": "@.capitalize:{'terms.glossary.erstellt'} @:{'terms.glossary.von'}",
"customer_conflict": {
"help_text": "@.capitalize:{'terms.glossary.dieses'} @:{'terms.glossary.kennzeichen'} @:{'terms.glossary.gehoert'} @:{'terms.glossary.zu'} @:{'terms.glossary.einem'} @:{'terms.glossary.anderen'} @:{'terms.glossary.gespeicherten'} @:{'terms.glossary.kunden'}. @:{'terms.glossary.waehlen'} @.capitalize:{'terms.glossary.sie'}, @:{'terms.glossary.ob'} @:{'terms.glossary.der'} @:{'terms.glossary.ausgewaehlte'} @.capitalize:{'terms.glossary.kunde'} @:{'terms.glossary.beibehalten'} @:{'terms.glossary.oder'} @:{'terms.glossary.zum'} @:{'terms.glossary.fahrzeugkunden'} @:{'terms.glossary.gewechselt'} @:{'terms.glossary.werden'} @:{'terms.glossary.soll'}.",
@@ -7,6 +7,7 @@
"benefit_portal": "@:{'terms.glossary.kundenportal'}",
"benefit_portal_desc": "@.capitalize:{'terms.glossary.nutzen'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.das'} @:{'terms.glossary.kundenportal'}, @:{'terms.glossary.um'} @:{'terms.glossary.fahrzeuge'} hinzuzuf?@:{'terms.glossary.gen'}, @.upper:{'terms.glossary.w'}?@:{'terms.glossary.schen'} @:{'terms.glossary.zu'} @:{'terms.glossary.buchen'}, @:{'terms.glossary.rechnungen'} @:{'terms.glossary.und'} @:{'terms.glossary.waschzertifikate'} herunterzuladen @:{'terms.glossary.sowie'} @:{'terms.glossary.informationen'} @:{'terms.glossary.zu'} @:{'terms.glossary.fr'}?@:{'terms.glossary.heren'} @.upper:{'terms.glossary.w'}?@:{'terms.glossary.schen'} einzusehen",
"benefit_wash_desc": "@.capitalize:{'terms.glossary.nutzen'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.unser'} @:{'terms.glossary.nationales'} @:{'terms.glossary.netz'} @:{'terms.glossary.von'} @:{'terms.glossary.waschhallen'} @:{'terms.glossary.zu'} @:{'terms.glossary.wettbewerbsf'}?@:{'terms.glossary.higen'} @:{'terms.glossary.preisen'}",
"ean_label": "@:{'phrases.compat.admin.pos.ean'}",
"email_label": "Firmen-@.upper:{'terms.glossary.e'}-@:{'terms.glossary.mail'}",
"intro": "@.capitalize:{'terms.glossary.als'} @.capitalize:{'terms.glossary.kunde'} @:{'terms.glossary.bei'} @:{'terms.glossary.truck'} @.capitalize:{'terms.glossary.wash'} @:{'terms.glossary.erhalten'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.zugang'} @:{'terms.glossary.zu'}:",
"phone_label": "@:{'phrases.compat.admin.pos.company_phone'}",
@@ -21,6 +21,13 @@
"opening_hours": {
"closed": "@:{'phrases.compat.global.closed'}"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiviert",
"custom_pricing_enabled": "Nur eigene Preise",
"custom_pricing_missing_price": "Fehlende Abteilungspreise werden zu 999999.",
"custom_pricing_only": "Keine Fallback-Preise",
"effective_department_price": "Effektiver Abteilungspreis"
},
"search_placeholder": "@.capitalize:{'terms.glossary.nach'} @:{'terms.glossary.abteilungsname'} @:{'terms.glossary.suchen'}",
"select_department": "@:{'phrases.compat.nav.select_department'}",
"subtitle": "@:{'terms.glossary.abteilungen'} @:{'terms.glossary.verwalten'}",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Fehler melden",
"title": "Fehler melden",
"subtitle": "Sende den aktuellen Bildschirm und die letzten Fehlerdetails an den Support.",
"subtitle": "Sende die letzten Fehlerdetails an den Support. Ein Screenshot wird angehängt, wenn möglich.",
"before_error": "Was haben Sie getan, bevor der Fehler auftrat?",
"expected": "Was hatten Sie erwartet?",
"actual": "Was ist tatsächlich passiert?",
"before_error_placeholder": "Beschreiben Sie die Aktion, zum Beispiel Bestellungen öffnen oder einen Kunden auswählen.",
"expected_placeholder": "Beschreiben Sie das erwartete Ergebnis.",
"actual_placeholder": "Beschreiben Sie, was stattdessen zu sehen war, inklusive Fehlermeldung.",
"consent": "Ich akzeptiere, dass der aktuelle App-Bildschirm, letzte Request-Fehler, Vue-Fehler, Browserdetails und meine Antworten zur Fehlersuche erfasst werden.",
"consent": "Ich akzeptiere, dass letzte Request-Fehler, Vue-Fehler, Browserdetails, meine Antworten und ein App-Screenshot, wenn möglich, zur Fehlersuche erfasst werden.",
"submit": "Bericht senden",
"submitted": "Fehlerbericht gesendet.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"capture_failed": "Der Screenshot konnte nicht erstellt werden. Der Bericht wird ohne Screenshot gesendet.",
"submit_failed": "Der Fehlerbericht konnte nicht gesendet werden.",
"required": "Alle Felder und die Zustimmung zur Datenerfassung sind erforderlich.",
"page_subtitle": "Gemeldete Bildschirme, Request-Fehler und Vue-Fehler",
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Zusammen abrechnen",
"split_by_month": "Nach Monat aufteilen",
"split_error_title": "Monatsaufteilung fehlgeschlagen",
"split_success_text": "{processed} Rechnungssammlungen verarbeitet. {changed} aufgeteilt, {skipped} übersprungen.",
"split_success_title": "Monatsaufteilung abgeschlossen",
"text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?",
"title": "Aufträge aus mehreren Monaten"
}
}
}
}
@@ -140,6 +140,7 @@
"departments": {
"columns": {
"archived": "Archiviert",
"custom_pricing_only": "@:{'phrases.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Abmessung",
"economic_department": "@:{'phrases.compat.objects.economic_departments.single'}",
"latitude": "@:{'phrases.compat.departments.form.latitude'}",
@@ -14,6 +14,31 @@
"transaction_history": "@:{'phrases.compat.superuser.nav.orders'}"
},
"other": "@.capitalize:{'terms.glossary.andere'}",
"overview": {
"active_vehicles": "Aktive Fahrzeuge",
"attributes": "Attribute",
"connected": "Verbunden",
"customer_management": "Kundenverwaltung",
"customer_number_required": "Eine Kundennummer ist erforderlich, bevor Kundeneinstellungen verwaltet werden können.",
"customer_rules": "Kundenregeln",
"effective_access": "Effektiver Zugriff",
"loaded": "Geladen",
"load_failed": "Benutzerdaten konnten nicht geladen werden",
"loading_user": "Benutzer wird geladen",
"management_hub": "Verwaltung",
"missing": "Fehlt",
"more_permissions": "+{count} weitere Berechtigungen",
"no_subscription_transactions": "Für diesen Kunden sind keine Waschabonnement-Transaktionen registriert.",
"not_invoiced": "Nicht fakturiert",
"open_draft": "Offener Entwurf",
"open_vehicles": "Fahrzeuge öffnen",
"price_overrides": "Preisüberschreibungen",
"pricing": "Preise",
"subscription_invoicing": "Abonnementabrechnung",
"subtitle": "Überblick über Kundendaten, Zugriff, Preise, Fahrzeuge und Abonnementabrechnung.",
"vehicles_load_failed": "Fahrzeuge konnten nicht geladen werden.",
"workspace_shortcuts": "Arbeitsbereich"
},
"permissions": "Tilladelser",
"user_data": "Brugerdata",
"user_id": "@:{'terms.glossary.benutzer'}-@.upper:{'terms.glossary.id'}",
@@ -2,6 +2,20 @@
"compat": {
"vehicles": {
"add": "@:{'terms.glossary.fahrzeug'} @:{'terms.glossary.hinzuf'}?@:{'terms.glossary.gen'}",
"add_modal": {
"customer_label": "Kunde",
"customer_placeholder": "Nach Kundenname oder Kundennummer suchen",
"error": "Fahrzeug konnte nicht hinzugefuegt werden.",
"no_customer_results": "Keine Kunden gefunden",
"reference_placeholder": "Optionale Referenz",
"registration_placeholder": "Kennzeichen",
"selected_customer": "Ausgewaehlter Kunde",
"submit": "Fahrzeug hinzufuegen",
"title": "Fahrzeug hinzufuegen",
"type_load_error": "Fahrzeugtypen konnten nicht geladen werden.",
"type_placeholder": "Fahrzeugtyp auswaehlen",
"validation_error": "Kunde, Kennzeichen und Fahrzeugtyp auswaehlen."
},
"brand": "@:{'phrases.compat.admin.pos.make'}",
"color": "Farbe",
"delete_vehicle": "@:{'phrases.compat.vehicles.delete'}",
+49 -11
View File
@@ -28,13 +28,16 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -46,6 +49,15 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -64,7 +76,13 @@
"active": "@:{'phrases.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'phrases.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'phrases.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'phrases.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'phrases.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'phrases.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'phrases.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'phrases.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'phrases.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -94,7 +112,8 @@
"save_prices": "@:{'phrases.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'phrases.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'phrases.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'phrases.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'phrases.compat.limited_backoffice.role_permissions.title'}",
@@ -265,6 +284,9 @@
"title": "@.capitalize:{'terms.glossary.abteilungspreise'}",
"save": "@:{'terms.glossary.preise'} @:{'terms.glossary.speichern'}",
"saved": "@:{'terms.glossary.preise'} @:{'terms.glossary.gespeichert'}.",
"autosave_saving": "Preise werden gespeichert...",
"autosave_pending": "Preisaenderungen ausstehend.",
"autosave_saved": "Alle Preisaenderungen gespeichert.",
"product": "@.capitalize:{'terms.glossary.produkt'}",
"price": "Abteilungspreis",
"price_required": "Jedes @.capitalize:{'terms.glossary.produkt'} @:{'terms.glossary.muss'} einen @:{'terms.glossary.nicht'}-negativen numerischen Preis @:{'terms.glossary.haben'}.",
@@ -279,6 +301,15 @@
"edit_title": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.bearbeiten'}",
"name": "Name",
"email": "E-Mail",
"user_id": "Benutzer-ID",
"phone": "Telefon",
"phone_validation": "Die Telefonnummer muss 4-15 Ziffern enthalten.",
"country_codes": {
"denmark": "Dänemark",
"sweden": "Schweden",
"norway": "Norwegen",
"finland": "Finnland"
},
"password": "@:{'terms.glossary.passwort'}",
"password_create_help": "@.capitalize:{'terms.glossary.fur'} neue @:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.erforderlich'}. Verwenden @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.mindestens'} 8 Zeichen.",
"password_update_help": "Leer lassen, um das vorhandene @:{'terms.glossary.passwort'} beizubehalten.",
@@ -297,7 +328,13 @@
"active": "Aktiv",
"inactive": "Inaktiv",
"edit": "@.capitalize:{'terms.glossary.bearbeiten'}",
"deactivate": "Deaktivieren"
"deactivate": "Deaktivieren",
"login_link": "QR-Login",
"login_link_title": "Mitarbeiter-Loginlink",
"login_link_qr_alt": "QR-Code-Loginlink",
"login_link_url": "Loginlink",
"copy_login_link": "Link kopieren",
"login_link_copied": "Link kopiert."
},
"roles": {
"viewer": {
@@ -327,7 +364,8 @@
"save_prices": "@.capitalize:{'terms.glossary.abteilungspreise'} @:{'terms.glossary.konnten'} @:{'terms.glossary.nicht'} @:{'terms.glossary.gespeichert'} @:{'terms.glossary.werden'}.",
"load_employees": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.konnten'} @:{'terms.glossary.nicht'} @:{'terms.glossary.geladen'} @:{'terms.glossary.werden'}.",
"save_employee": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.konnte'} @:{'terms.glossary.nicht'} @:{'terms.glossary.gespeichert'} @:{'terms.glossary.werden'}.",
"deactivate_employee": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.konnte'} @:{'terms.glossary.nicht'} @:{'terms.glossary.deaktiviert'} @:{'terms.glossary.werden'}."
"deactivate_employee": "@:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.konnte'} @:{'terms.glossary.nicht'} @:{'terms.glossary.deaktiviert'} @:{'terms.glossary.werden'}.",
"generate_employee_login_link": "Mitarbeiter-Loginlink konnte nicht erstellt werden."
},
"role_permissions": {
"title": "Rollenberechtigungen",
@@ -27,6 +27,14 @@
"subtitle_suffix": "@:{'terms.glossary.product'} @:{'terms.glossary.sales'}",
"title": "@.capitalize:{'terms.glossary.product'} @.capitalize:{'terms.glossary.sales'}"
},
"product_targets": {
"cancel_aria": "Cancel target",
"edit_aria": "Edit target percentage",
"input_label": "Target percentage",
"save_aria": "Save target",
"save_error": "The target could not be saved.",
"validation_range": "The target percentage must be between 0 and 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'terms.glossary.completed'} @:{'terms.glossary.count'}",
@@ -221,6 +229,7 @@
"company_phone": "@.capitalize:{'terms.glossary.company'} @:{'terms.glossary.phone'}",
"contact_email": "@.capitalize:{'terms.glossary.contact'} @:{'terms.glossary.email'}",
"contact_phone": "@.capitalize:{'terms.glossary.contact'} @:{'terms.glossary.phone'}",
"ean": "EAN",
"created_by": "@.capitalize:{'terms.glossary.created'} @:{'terms.glossary.by'}",
"customer_conflict": {
"help_text": "@.capitalize:{'terms.glossary.this'} @:{'terms.glossary.typed'} @:{'terms.glossary.plate'} @:{'terms.glossary.belongs'} @:{'terms.glossary.to'} @:{'terms.glossary.another'} @:{'terms.glossary.saved'} @:{'terms.glossary.customer'}. @.capitalize:{'terms.glossary.choose'} @:{'terms.glossary.whether'} @:{'terms.glossary.to'} @:{'terms.glossary.keep'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.selected'} @:{'terms.glossary.customer'} @:{'terms.glossary.or'} @:{'terms.glossary.switch'} @:{'terms.glossary.to'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.customer'}.",
@@ -7,6 +7,7 @@
"benefit_portal": "@.capitalize:{'terms.glossary.customer'} @.capitalize:{'terms.glossary.portal'}",
"benefit_portal_desc": "@.capitalize:{'terms.glossary.use'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.customer'} @:{'terms.glossary.portal'} @:{'terms.glossary.to'} @:{'terms.glossary.add'} @:{'terms.glossary.vehicles'}, @:{'terms.glossary.book'} @:{'terms.glossary.washes'}, @:{'terms.glossary.download'} @:{'terms.glossary.invoices'}, @:{'terms.glossary.wash'} @:{'terms.glossary.certificates'} @:{'terms.glossary.and'} @:{'terms.glossary.information'} @:{'terms.glossary.for'} @:{'terms.glossary.historical'} @:{'terms.glossary.washes'}",
"benefit_wash_desc": "@.capitalize:{'terms.glossary.use'} @:{'terms.glossary.our'} @:{'terms.glossary.national'} @:{'terms.glossary.network'} @:{'terms.glossary.of'} @:{'terms.glossary.wash'} @:{'terms.glossary.halls'} @:{'terms.glossary.at'} @:{'terms.glossary.competitive'} @:{'terms.glossary.prices'}",
"ean_label": "@:{'phrases.compat.admin.pos.ean'}",
"email_label": "@.capitalize:{'terms.glossary.company'} @:{'terms.glossary.email'}",
"intro": "@.capitalize:{'terms.glossary.as'} @:{'terms.glossary.a'} @:{'terms.glossary.customer'} @:{'terms.glossary.at'} @.capitalize:{'terms.glossary.truck'} @.capitalize:{'terms.glossary.wash'} @:{'terms.glossary.you'} @:{'terms.glossary.get'} @:{'terms.glossary.access'} @:{'terms.glossary.to'}:",
"phone_label": "@:{'phrases.compat.admin.pos.company_phone'}",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Report error",
"title": "Report error",
"subtitle": "Send the current screen and recent error details to support.",
"subtitle": "Send recent error details to support. A screenshot is attached when available.",
"before_error": "What were you doing before the error occurred?",
"expected": "What did you expect would happen?",
"actual": "What actually happened?",
"before_error_placeholder": "Describe the action you were taking, for example opening orders or selecting a customer.",
"expected_placeholder": "Describe the result you expected to see.",
"actual_placeholder": "Describe what you saw instead, including any error text.",
"consent": "I accept that the current app screen, recent request errors, Vue errors, browser details, and my answers are collected for troubleshooting.",
"consent": "I accept that recent request errors, Vue errors, browser details, my answers, and an app screenshot when available are collected for troubleshooting.",
"submit": "Submit report",
"submitted": "Error report submitted.",
"capture_failed": "The screen capture failed. Please try again.",
"capture_failed": "The screen capture failed. The report will be sent without a screenshot.",
"submit_failed": "The error report could not be submitted.",
"required": "All fields and data collection acceptance are required.",
"page_subtitle": "Reported screens, request failures, and Vue errors",
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Invoice together",
"split_by_month": "Split by month",
"split_error_title": "Monthly split failed",
"split_success_text": "Processed {processed} invoice collections. Split {changed}, skipped {skipped}.",
"split_success_title": "Monthly split completed",
"text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?",
"title": "Orders from multiple months"
}
}
}
}
@@ -14,6 +14,31 @@
"transaction_history": "@.capitalize:{'terms.glossary.transaction'} @:{'terms.glossary.history'}"
},
"other": "@:{'phrases.compat.global.other'}",
"overview": {
"active_vehicles": "Active vehicles",
"attributes": "Attributes",
"connected": "Connected",
"customer_management": "Customer management",
"customer_number_required": "A customer number is required before customer settings can be managed.",
"customer_rules": "Customer rules",
"effective_access": "Effective access",
"loaded": "Loaded",
"load_failed": "User data could not be loaded",
"loading_user": "Loading user",
"management_hub": "Management hub",
"missing": "Missing",
"more_permissions": "+{count} more permissions",
"no_subscription_transactions": "No wash subscription transactions are registered for this customer.",
"not_invoiced": "Not invoiced",
"open_draft": "Open draft",
"open_vehicles": "Open vehicles",
"price_overrides": "Price overrides",
"pricing": "Pricing",
"subscription_invoicing": "Subscription invoicing",
"subtitle": "Overview of customer data, access, pricing, vehicles, and subscription invoicing.",
"vehicles_load_failed": "Vehicles could not be loaded.",
"workspace_shortcuts": "Workspace shortcuts"
},
"permissions": "@.capitalize:{'terms.glossary.permissions'}",
"user_data": "@.capitalize:{'terms.glossary.user'} @:{'terms.glossary.data'}",
"user_id": "@.capitalize:{'terms.glossary.user'} @.upper:{'terms.glossary.id'}",
@@ -2,6 +2,20 @@
"compat": {
"vehicles": {
"add": "@.capitalize:{'terms.glossary.add'} @:{'terms.glossary.vehicle'}",
"add_modal": {
"customer_label": "Customer",
"customer_placeholder": "Search by customer name or number",
"error": "Unable to add vehicle.",
"no_customer_results": "No customers found",
"reference_placeholder": "Optional reference",
"registration_placeholder": "Registration number",
"selected_customer": "Selected customer",
"submit": "Add vehicle",
"title": "Add vehicle",
"type_load_error": "Unable to load vehicle types.",
"type_placeholder": "Select vehicle type",
"validation_error": "Select a customer, registration number, and vehicle type."
},
"brand": "Brand",
"color": "Color",
"delete_vehicle": "@:{'phrases.compat.vehicles.delete'}",
+49 -11
View File
@@ -28,13 +28,16 @@
"title": "@:{'phrases.compat.limited_backoffice.forbidden.title'}",
"department": "@:{'phrases.compat.limited_backoffice.forbidden.department'}"
},
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"prices": {
"title": "@:{'phrases.compat.limited_backoffice.prices.title'}",
"save": "@:{'phrases.compat.limited_backoffice.prices.save'}",
"saved": "@:{'phrases.compat.limited_backoffice.prices.saved'}",
"autosave_saving": "@:{'phrases.compat.limited_backoffice.prices.autosave_saving'}",
"autosave_pending": "@:{'phrases.compat.limited_backoffice.prices.autosave_pending'}",
"autosave_saved": "@:{'phrases.compat.limited_backoffice.prices.autosave_saved'}",
"product": "@:{'phrases.compat.limited_backoffice.prices.product'}",
"price": "@:{'phrases.compat.limited_backoffice.prices.price'}",
"price_required": "@:{'phrases.compat.limited_backoffice.prices.price_required'}",
"setup_required_title": "@:{'phrases.compat.limited_backoffice.prices.setup_required_title'}",
"setup_required_message": "@:{'phrases.compat.limited_backoffice.prices.setup_required_message'}",
"no_products": "@:{'phrases.compat.limited_backoffice.prices.no_products'}"
@@ -46,6 +49,15 @@
"edit_title": "@:{'phrases.compat.limited_backoffice.employees.edit_title'}",
"name": "@:{'phrases.compat.limited_backoffice.employees.name'}",
"email": "@:{'phrases.compat.limited_backoffice.employees.email'}",
"user_id": "@:{'phrases.compat.limited_backoffice.employees.user_id'}",
"phone": "@:{'phrases.compat.limited_backoffice.employees.phone'}",
"phone_validation": "@:{'phrases.compat.limited_backoffice.employees.phone_validation'}",
"country_codes": {
"denmark": "@:{'phrases.compat.limited_backoffice.employees.country_codes.denmark'}",
"sweden": "@:{'phrases.compat.limited_backoffice.employees.country_codes.sweden'}",
"norway": "@:{'phrases.compat.limited_backoffice.employees.country_codes.norway'}",
"finland": "@:{'phrases.compat.limited_backoffice.employees.country_codes.finland'}"
},
"password": "@:{'phrases.compat.limited_backoffice.employees.password'}",
"password_create_help": "@:{'phrases.compat.limited_backoffice.employees.password_create_help'}",
"password_update_help": "@:{'phrases.compat.limited_backoffice.employees.password_update_help'}",
@@ -64,7 +76,13 @@
"active": "@:{'phrases.compat.limited_backoffice.employees.active'}",
"inactive": "@:{'phrases.compat.limited_backoffice.employees.inactive'}",
"edit": "@:{'phrases.compat.limited_backoffice.employees.edit'}",
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}"
"deactivate": "@:{'phrases.compat.limited_backoffice.employees.deactivate'}",
"login_link": "@:{'phrases.compat.limited_backoffice.employees.login_link'}",
"login_link_title": "@:{'phrases.compat.limited_backoffice.employees.login_link_title'}",
"login_link_qr_alt": "@:{'phrases.compat.limited_backoffice.employees.login_link_qr_alt'}",
"login_link_url": "@:{'phrases.compat.limited_backoffice.employees.login_link_url'}",
"copy_login_link": "@:{'phrases.compat.limited_backoffice.employees.copy_login_link'}",
"login_link_copied": "@:{'phrases.compat.limited_backoffice.employees.login_link_copied'}"
},
"roles": {
"viewer": {
@@ -94,7 +112,8 @@
"save_prices": "@:{'phrases.compat.limited_backoffice.errors.save_prices'}",
"load_employees": "@:{'phrases.compat.limited_backoffice.errors.load_employees'}",
"save_employee": "@:{'phrases.compat.limited_backoffice.errors.save_employee'}",
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}"
"deactivate_employee": "@:{'phrases.compat.limited_backoffice.errors.deactivate_employee'}",
"generate_employee_login_link": "@:{'phrases.compat.limited_backoffice.errors.generate_employee_login_link'}"
},
"role_permissions": {
"title": "@:{'phrases.compat.limited_backoffice.role_permissions.title'}",
@@ -265,6 +284,9 @@
"title": "@.capitalize:{'terms.glossary.department'} @:{'terms.glossary.prices'}",
"save": "@.capitalize:{'terms.glossary.save'} @:{'terms.glossary.prices'}",
"saved": "@.capitalize:{'terms.glossary.prices'} @:{'terms.glossary.saved'}.",
"autosave_saving": "Saving prices...",
"autosave_pending": "Price changes pending.",
"autosave_saved": "All price changes saved.",
"product": "@.capitalize:{'terms.glossary.product'}",
"price": "@.capitalize:{'terms.glossary.department'} @:{'terms.glossary.price'}",
"price_required": "Every @:{'terms.glossary.product'} @:{'terms.glossary.must'} @:{'terms.glossary.have'} @:{'terms.glossary.a'} non-negative numeric @:{'terms.glossary.price'}.",
@@ -279,6 +301,15 @@
"edit_title": "@.capitalize:{'terms.glossary.edit'} @:{'terms.glossary.employee'}",
"name": "Name",
"email": "Email",
"user_id": "User ID",
"phone": "Phone",
"phone_validation": "Phone number must be 4-15 digits.",
"country_codes": {
"denmark": "Denmark",
"sweden": "Sweden",
"norway": "Norway",
"finland": "Finland"
},
"password": "@.capitalize:{'terms.glossary.password'}",
"password_create_help": "@.capitalize:{'terms.glossary.required'} @:{'terms.glossary.for'} new @:{'terms.glossary.employees'}. Use @:{'terms.glossary.at'} @:{'terms.glossary.least'} 8 characters.",
"password_update_help": "Leave blank @:{'terms.glossary.to'} keep @:{'terms.replication.article.host_mention'} existing @:{'terms.glossary.password'}.",
@@ -297,7 +328,13 @@
"active": "Active",
"inactive": "@.capitalize:{'terms.glossary.inactive'}",
"edit": "@.capitalize:{'terms.glossary.edit'}",
"deactivate": "@.capitalize:{'terms.glossary.deactivate'}"
"deactivate": "@.capitalize:{'terms.glossary.deactivate'}",
"login_link": "QR login",
"login_link_title": "Employee login link",
"login_link_qr_alt": "QR code login link",
"login_link_url": "Login link",
"copy_login_link": "Copy link",
"login_link_copied": "Link copied."
},
"roles": {
"viewer": {
@@ -327,7 +364,8 @@
"save_prices": "@.capitalize:{'terms.glossary.could'} @:{'terms.glossary.not'} @:{'terms.glossary.save'} @:{'terms.glossary.department'} @:{'terms.glossary.prices'}.",
"load_employees": "@.capitalize:{'terms.glossary.could'} @:{'terms.glossary.not'} @:{'terms.glossary.load'} @:{'terms.glossary.employees'}.",
"save_employee": "@.capitalize:{'terms.glossary.could'} @:{'terms.glossary.not'} @:{'terms.glossary.save'} @:{'terms.glossary.employee'}.",
"deactivate_employee": "@.capitalize:{'terms.glossary.could'} @:{'terms.glossary.not'} @:{'terms.glossary.deactivate'} @:{'terms.glossary.employee'}."
"deactivate_employee": "@.capitalize:{'terms.glossary.could'} @:{'terms.glossary.not'} @:{'terms.glossary.deactivate'} @:{'terms.glossary.employee'}.",
"generate_employee_login_link": "Could not create employee login link."
},
"role_permissions": {
"title": "Role permissions",
@@ -34,6 +34,14 @@
"subtitle_suffix": "@:{'phrases.compat.admin.daily_report.product_sales.subtitle_suffix'}",
"title": "@:{'phrases.compat.admin.daily_report.product_sales.title'}"
},
"product_targets": {
"cancel_aria": "@:{'phrases.compat.admin.daily_report.product_targets.cancel_aria'}",
"edit_aria": "@:{'phrases.compat.admin.daily_report.product_targets.edit_aria'}",
"input_label": "@:{'phrases.compat.admin.daily_report.product_targets.input_label'}",
"save_aria": "@:{'phrases.compat.admin.daily_report.product_targets.save_aria'}",
"save_error": "@:{'phrases.compat.admin.daily_report.product_targets.save_error'}",
"validation_range": "@:{'phrases.compat.admin.daily_report.product_targets.validation_range'}"
},
"reports": {
"avg_revenue": {
"label": "@:{'phrases.compat.department_reports.avg_revenue_per_wash'}",
@@ -293,6 +301,7 @@
"delete": "@:{'phrases.compat.global.delete'}",
"details": "@:{'phrases.compat.admin.pos.details'}",
"download": "@:{'phrases.compat.guest.pwa.title'}",
"ean": "@:{'phrases.compat.admin.pos.ean'}",
"drafts_assignment": {
"button": "@:{'phrases.compat.admin.pos.drafts_assignment.button'}",
"create_invoice_collection": "@:{'phrases.compat.admin.pos.drafts_assignment.create_invoice_collection'}",
@@ -8,6 +8,7 @@
"benefit_wash": "@:{'phrases.compat.tables.bookings.wash'}",
"benefit_wash_desc": "@:{'phrases.compat.tables.bookings.wash_desc'}",
"cvr_label": "@:{'phrases.compat.customers.cvr'}",
"ean_label": "@:{'phrases.compat.customer_creation.customer.ean_label'}",
"email_label": "@:{'phrases.compat.customer_creation.customer.email_label'}",
"intro": "@:{'phrases.compat.customer_creation.customer.intro'}",
"phone_label": "@:{'phrases.compat.customer_creation.customer.phone_label'}",
@@ -0,0 +1,13 @@
{
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.invoice_together'}",
"split_by_month": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_by_month'}",
"split_error_title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_error_title'}",
"split_success_text": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_success_text'}",
"split_success_title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}",
"text": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.text'}",
"title": "@:{'phrases.compat.invoicing_period.multi_month_invoice_warning.title'}"
}
}
}
@@ -40,6 +40,82 @@
"subtitle": "@:{'phrases.compat.superuser_dashboard.departments.subtitle'}",
"title": "@:common.departments"
},
"department_navigation": {
"overview": "Overview",
"modules": "Modules",
"branding": "Profile & Branding",
"gateways": "@.capitalize:{'terms.glossary.gateways'}",
"stripe": "@:{'terms.glossary.stripe'}",
"pricing": "Pricing",
"categories": "Categories"
},
"department_overview": {
"title": "Department overview",
"subtitle": "Operational overview for the selected department",
"loading": "Loading department overview",
"date_from": "From",
"date_to": "To",
"range_label": "{from} to {to}",
"empty_value": "-",
"out_of": "of {total}",
"presets": {
"today": "Today",
"last_seven_days": "Last 7 days"
},
"metrics": {
"bookings": "Bookings",
"complaints": "Complaints",
"night_washes": "Night washes",
"overtime": "Overtime",
"products_sold": "Products sold",
"revenue": "Revenue",
"transactions": "Transactions",
"washes": "Washes",
"water_usage": "Water"
},
"units": {
"hours": "h",
"liters": "L"
},
"products": {
"title": "Product mix",
"subtitle": "Tracked wash products in the selected period",
"empty": "No product activity for the selected period"
},
"profile": {
"title": "Department profile",
"no_description": "No department description",
"department_id": "Department ID",
"economic_department_id": "Economic department",
"branding": "Branding",
"created_at": "@:common.created",
"updated_at": "Updated"
},
"hardware": {
"title": "Hardware readiness",
"subtitle": "Gateway-backed lane and relay state",
"gateways": "Gateways online",
"lanes": "Lanes",
"gates": "Gates",
"relays": "Relays",
"scanners": "Scanners",
"issues": "Issues"
},
"quick_links": {
"title": "Department tools",
"subtitle": "Open the focused setup areas for this department",
"modules": "@:superuser_dashboard.department_navigation.modules",
"branding": "@:superuser_dashboard.department_overview.profile.branding",
"gateways": "@:superuser_dashboard.department_navigation.gateways",
"stripe": "@:superuser_dashboard.department_navigation.stripe",
"pricing": "@:superuser_dashboard.department_navigation.pricing",
"categories": "@:superuser_dashboard.department_navigation.categories"
},
"errors": {
"invalid_department": "A valid department is required",
"load": "Unable to load the department overview"
}
},
"employees": {
"edit_employee_title": "@:employees.edit_employee",
"new_employee_subtitle": "@:{'phrases.compat.superuser_dashboard.employees.new_employee_subtitle'}",
@@ -22,6 +22,31 @@
"transaction_history": "@:{'phrases.compat.user_admin.orders.transaction_history'}"
},
"other": "@:{'phrases.compat.user_admin.other'}",
"overview": {
"active_vehicles": "@:{'phrases.compat.user_admin.overview.active_vehicles'}",
"attributes": "@:{'phrases.compat.user_admin.overview.attributes'}",
"connected": "@:{'phrases.compat.user_admin.overview.connected'}",
"customer_management": "@:{'phrases.compat.user_admin.overview.customer_management'}",
"customer_number_required": "@:{'phrases.compat.user_admin.overview.customer_number_required'}",
"customer_rules": "@:{'phrases.compat.user_admin.overview.customer_rules'}",
"effective_access": "@:{'phrases.compat.user_admin.overview.effective_access'}",
"loaded": "@:{'phrases.compat.user_admin.overview.loaded'}",
"load_failed": "@:{'phrases.compat.user_admin.overview.load_failed'}",
"loading_user": "@:{'phrases.compat.user_admin.overview.loading_user'}",
"management_hub": "@:{'phrases.compat.user_admin.overview.management_hub'}",
"missing": "@:{'phrases.compat.user_admin.overview.missing'}",
"more_permissions": "@:{'phrases.compat.user_admin.overview.more_permissions'}",
"no_subscription_transactions": "@:{'phrases.compat.user_admin.overview.no_subscription_transactions'}",
"not_invoiced": "@:{'phrases.compat.user_admin.overview.not_invoiced'}",
"open_draft": "@:{'phrases.compat.user_admin.overview.open_draft'}",
"open_vehicles": "@:{'phrases.compat.user_admin.overview.open_vehicles'}",
"price_overrides": "@:{'phrases.compat.user_admin.overview.price_overrides'}",
"pricing": "@:{'phrases.compat.user_admin.overview.pricing'}",
"subscription_invoicing": "@:{'phrases.compat.user_admin.overview.subscription_invoicing'}",
"subtitle": "@:{'phrases.compat.user_admin.overview.subtitle'}",
"vehicles_load_failed": "@:{'phrases.compat.user_admin.overview.vehicles_load_failed'}",
"workspace_shortcuts": "@:{'phrases.compat.user_admin.overview.workspace_shortcuts'}"
},
"permissions": "@:{'phrases.compat.user_admin.permissions'}",
"subtitle": "@:user_admin.user_data",
"title": "@:{'phrases.compat.common.user'}",
@@ -2,6 +2,20 @@
"vehicles": {
"actions": "@:common.actions",
"add": "@:{'phrases.compat.vehicles.add'}",
"add_modal": {
"customer_label": "@:{'phrases.compat.vehicles.add_modal.customer_label'}",
"customer_placeholder": "@:{'phrases.compat.vehicles.add_modal.customer_placeholder'}",
"error": "@:{'phrases.compat.vehicles.add_modal.error'}",
"no_customer_results": "@:{'phrases.compat.vehicles.add_modal.no_customer_results'}",
"reference_placeholder": "@:{'phrases.compat.vehicles.add_modal.reference_placeholder'}",
"registration_placeholder": "@:{'phrases.compat.vehicles.add_modal.registration_placeholder'}",
"selected_customer": "@:{'phrases.compat.vehicles.add_modal.selected_customer'}",
"submit": "@:{'phrases.compat.vehicles.add_modal.submit'}",
"title": "@:{'phrases.compat.vehicles.add_modal.title'}",
"type_load_error": "@:{'phrases.compat.vehicles.add_modal.type_load_error'}",
"type_placeholder": "@:{'phrases.compat.vehicles.add_modal.type_placeholder'}",
"validation_error": "@:{'phrases.compat.vehicles.add_modal.validation_error'}"
},
"brand": "@:{'phrases.compat.vehicles.brand'}",
"color": "@:{'phrases.compat.vehicles.color'}",
"created_at": "@:{'phrases.compat.global.generated'}",
@@ -27,6 +27,14 @@
"subtitle_suffix": "@:{'terms.glossary.produktsalg'}",
"title": "@.capitalize:{'terms.glossary.produktsalg'}"
},
"product_targets": {
"cancel_aria": "Avbryt mål",
"edit_aria": "Rediger målprosent",
"input_label": "Målprosent",
"save_aria": "Lagre mål",
"save_error": "Målet kunne ikke lagres.",
"validation_range": "Målprosenten må være mellom 0 og 100."
},
"reports": {
"bookings": {
"completed_count": "@.capitalize:{'terms.glossary.fullført'} @:{'terms.glossary.telling'}",
@@ -221,6 +229,7 @@
"company_phone": "Firmatelefon",
"contact_email": "@.capitalize:{'terms.glossary.kontakt'} @:{'terms.glossary.e'}-@:{'terms.glossary.post'}",
"contact_phone": "@.capitalize:{'terms.glossary.kontakt'} @:{'terms.glossary.telefon'}",
"ean": "EAN",
"created_by": "Laget @:{'terms.glossary.av'}",
"customer_conflict": {
"help_text": "@.capitalize:{'terms.glossary.det'} @:{'terms.glossary.innskrevne'} @:{'terms.glossary.skiltet'} @:{'terms.glossary.tilhoerer'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.annen'} @:{'terms.glossary.lagret'} @:{'terms.glossary.kunde'}. @.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.om'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valgte'} @:{'terms.glossary.kunden'} @:{'terms.glossary.skal'} @:{'terms.glossary.beholdes'} @:{'terms.glossary.eller'} @:{'terms.glossary.om'} @:{'terms.glossary.det'} @:{'terms.glossary.skal'} @:{'terms.glossary.byttes'} @:{'terms.glossary.til'} @:{'terms.glossary.kjoretoykunden'}.",
@@ -7,6 +7,7 @@
"benefit_portal": "@:{'terms.glossary.kundeportal'}",
"benefit_portal_desc": "@.capitalize:{'terms.glossary.bruk'} kundeportalen @:{'terms.glossary.til'} @:{'terms.glossary.a'} @:{'terms.glossary.legge'} @:{'terms.glossary.til'} @:{'terms.glossary.kjøretøy'}, bokvaske, @:{'terms.glossary.laste'} @:{'terms.glossary.ned'} @:{'terms.glossary.fakturaer'}, @:{'terms.glossary.vaskesertifikater'} @:{'terms.glossary.og'} @:{'terms.glossary.informasjon'} @:{'terms.glossary.for'} @:{'terms.glossary.historiske'} @:{'terms.glossary.vask'}.",
"benefit_wash_desc": "@.capitalize:{'terms.glossary.bruk'} @:{'terms.glossary.vart'} @:{'terms.glossary.nasjonale'} @:{'terms.glossary.nettverk'} @:{'terms.glossary.av'} @:{'terms.glossary.vaskehaller'} @:{'terms.glossary.til'} @:{'terms.glossary.konkurransedyktige'} @:{'terms.glossary.priser'}",
"ean_label": "@:{'phrases.compat.admin.pos.ean'}",
"email_label": "Firmaets @:{'terms.glossary.e'}-@:{'terms.glossary.post'}",
"intro": "@.capitalize:{'terms.glossary.som'} @:{'terms.glossary.kunde'} @:{'terms.glossary.hos'} @:{'terms.glossary.truck'} @.capitalize:{'terms.glossary.wash'} @:{'terms.glossary.far'} @:{'terms.glossary.du'} @:{'terms.glossary.tilgang'} @:{'terms.glossary.til'}:",
"phone_label": "@:{'phrases.compat.admin.pos.company_phone'}",
@@ -21,6 +21,13 @@
"opening_hours": {
"closed": "Stengt"
},
"pricing": {
"custom_pricing_disabled": "Fallback aktiv",
"custom_pricing_enabled": "Kun egendefinerte priser",
"custom_pricing_missing_price": "Manglende avdelingspriser blir 999999.",
"custom_pricing_only": "Ingen fallback-priser",
"effective_department_price": "Effektiv avdelingspris"
},
"search_placeholder": "@.capitalize:{'terms.glossary.søk'} @:{'terms.glossary.etter'} @:{'terms.glossary.avdelingsnavn'}",
"select_department": "@.capitalize:{'terms.glossary.velg'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.avdeling'}",
"subtitle": "@.capitalize:{'terms.glossary.administrer'} @:{'terms.glossary.avdelinger'}",
@@ -3,17 +3,17 @@
"error_report": {
"button": "Rapporter feil",
"title": "Rapporter feil",
"subtitle": "Send gjeldende skjermbilde og de siste feildetaljene til support.",
"subtitle": "Send de siste feildetaljene til support. Et skjermbilde legges ved når det er mulig.",
"before_error": "Hva gjorde du før feilen oppstod?",
"expected": "Hva forventet du at skulle skje?",
"actual": "Hva skjedde faktisk?",
"before_error_placeholder": "Beskriv handlingen du utførte, for eksempel å åpne ordre eller velge en kunde.",
"expected_placeholder": "Beskriv resultatet du forventet å se.",
"actual_placeholder": "Beskriv hva du så i stedet, inkludert eventuell feiltekst.",
"consent": "Jeg godtar at gjeldende app-skjermbilde, siste request-feil, Vue-feil, nettleserdetaljer og svarene mine samles inn for feilsøking.",
"consent": "Jeg godtar at siste request-feil, Vue-feil, nettleserdetaljer, svarene mine og et app-skjermbilde når det er mulig, samles inn for feilsøking.",
"submit": "Send rapport",
"submitted": "Feilrapport sendt.",
"capture_failed": "Skjermbildet kunne ikke tas. Prøv igjen.",
"capture_failed": "Skjermbildet kunne ikke tas. Rapporten sendes uten skjermbilde.",
"submit_failed": "Feilrapporten kunne ikke sendes.",
"required": "Alle felter og godkjenning av datainnsamling er påkrevd.",
"page_subtitle": "Rapporterte skjermer, request-feil og Vue-feil",
@@ -0,0 +1,15 @@
{
"compat": {
"invoicing_period": {
"multi_month_invoice_warning": {
"invoice_together": "Fakturer samlet",
"split_by_month": "Del opp etter måned",
"split_error_title": "Månedsdeling mislyktes",
"split_success_text": "Behandlet {processed} fakturasamlinger. Delte opp {changed}, hoppet over {skipped}.",
"split_success_title": "Månedsdeling fullført",
"text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?",
"title": "Ordrer fra flere måneder"
}
}
}
}

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