Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard 7180bbd6d4 Add limited backoffice role template actions 2026-07-07 03:23:32 +02:00
Jeppe BandJeppe Bundgaard 96ccc01d08 Add limited backoffice employee migration (#164)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-07 00:14:05 +02:00
Jeppe Bundgaard 2108dd6c94 Send Selvvask import period dates
Include selected invoice-period dates when importing XLVask usage from the Selvvask period view.
2026-07-06 23:49:36 +02:00
Jeppe BandJeppe Bundgaard ca2003f2d6 Allow hosted runners for manual frontend tests (#161)
* Allow hosted runners for manual frontend tests

* Fix hosted full E2E i18n failures

* Stabilize release assignment E2E selection

* Gate full E2E on focused regressions

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 22:38:29 +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 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 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 3c49cec213 Review dynamic i18n view keys 2026-07-06 20:47:51 +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 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 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 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 Bundgaard 1a9e7503ef Stabilize PR Playwright runner load 2026-07-06 15:01:01 +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 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
115 changed files with 6699 additions and 719 deletions
+240 -13
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-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
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: [self-hosted, Linux, X64, pleno, frontend, docker]
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
timeout-minutes: 45
strategy:
fail-fast: false
max-parallel: 4
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
@@ -238,7 +459,6 @@ jobs:
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \
--env MATRIX_SUITE="$MATRIX_SUITE" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
--env DIFF_BASE_REF="$DIFF_BASE_REF" \
@@ -292,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]
@@ -322,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
+20 -4
View File
@@ -29,8 +29,8 @@ export const sourceMappings = [
projects: chromiumProjects,
},
{
name: "backoffice",
patterns: [/^src\/views\/backoffice\//u],
name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\//u, /^src\/services\/limitedBackoffice\.js$/u],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
@@ -43,6 +43,12 @@ 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: [
@@ -90,8 +96,8 @@ export const sourceMappings = [
{
name: "pos",
patterns: [
/\/pos[/-]/iu,
/POS/iu,
/(?:^|[/_.-])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,
@@ -114,12 +120,22 @@ 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],
@@ -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 = "";
}
}
};
@@ -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>
@@ -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">
@@ -112,13 +112,24 @@ if (router.currentRoute.value.params.departmentId) {
}
const isImportLoading = ref(false);
const buildImportUsageParams = () => {
if (!props.inheritPeriodFilters) {
return {};
}
return {
...(props.initialDateFrom ? { dateFrom: props.initialDateFrom } : {}),
...(props.initialDateTo ? { dateTo: props.initialDateTo } : {}),
};
};
const importUsageButton = async () => {
isImportLoading.value = true;
try {
await SessionUser.request(
'/modules/xlvask/tasks/import-usage',
'GET',
{},
buildImportUsageParams(),
function (error) {
// This function is called when the request fails
console.error("Failed to import orders.", error);
@@ -62,7 +62,7 @@ if (props.autoLoad) {
</div>
</div>
</template>
<UsersTable :objects="list" />
<UsersTable :objects="list" @migrated="loadList" />
</TableLabeledPagination>
</template>
@@ -1,21 +1,136 @@
<script setup>
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { useI18n } from 'vue-i18n';
import Swal from "sweetalert2";
const { t } = useI18n();
defineProps(['objects']);
import { ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
const { loadList } = usePaginatedListInstance();
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
const redirect = (path) => {
window.location = path;
}
const limitedBackofficeTemplates = ref([]);
const templatesLoading = ref(false);
const templatesLoadError = ref(false);
const applyingTemplateKey = ref("");
const canApplyLimitedBackofficeTemplates = computed(() => SessionUser.hasPermission("add_role_permission"));
const loadLimitedBackofficeTemplates = async () => {
if (!canApplyLimitedBackofficeTemplates.value || templatesLoading.value) {
return;
}
templatesLoading.value = true;
templatesLoadError.value = false;
try {
const templates = await SessionUser.objects.roles.functions.getLimitedBackofficePermissionTemplates();
limitedBackofficeTemplates.value = Array.isArray(templates) ? templates : [];
} catch (error) {
console.error("Failed to load limited backoffice permission templates", error);
templatesLoadError.value = true;
} finally {
templatesLoading.value = false;
}
};
const templateTranslation = (template, field) => {
const key = `templates.limited_backoffice.roles.${template?.key}.${field}`;
const translated = t(key);
return translated === key ? template?.[field] || template?.key || "" : translated;
};
const templateLabel = (template) => templateTranslation(template, "label");
const rolePermissions = (role) => (Array.isArray(role?.permissions) ? role.permissions : []);
const templatePermissions = (template) => (Array.isArray(template?.permissions) ? template.permissions : []);
const missingTemplatePermissions = (role, template) => {
const currentPermissions = new Set(rolePermissions(role));
return templatePermissions(template).filter((permission) => permission && !currentPermissions.has(permission));
};
const roleTemplateActionKey = (role, template) => `${role?.id}:${template?.key}`;
const isApplyingTemplate = (role, template) => applyingTemplateKey.value === roleTemplateActionKey(role, template);
const isTemplateActionDisabled = (role, template) =>
templatesLoading.value || Boolean(applyingTemplateKey.value) || missingTemplatePermissions(role, template).length === 0;
const applyLimitedBackofficeTemplate = async (role, template) => {
const missingPermissions = missingTemplatePermissions(role, template);
const label = templateLabel(template);
if (missingPermissions.length === 0) {
await Swal.fire({
icon: "success",
title: t("roles.templates.up_to_date_title"),
text: t("roles.templates.up_to_date_text", { role: role.name, template: label }),
});
return;
}
const confirmation = await Swal.fire({
icon: "question",
title: t("roles.templates.confirm_title"),
text: t("roles.templates.confirm_text", {
count: missingPermissions.length,
role: role.name,
template: label,
}),
showCancelButton: true,
confirmButtonText: t("roles.templates.confirm_button"),
cancelButtonText: t("common.cancel"),
});
if (!confirmation.isConfirmed) {
return;
}
applyingTemplateKey.value = roleTemplateActionKey(role, template);
try {
const results = await Promise.allSettled(
missingPermissions.map((permission) =>
SessionUser.objects.roles.functions.permissions.add(role.id, permission)
)
);
const failed = results.filter((result) => result.status === "rejected");
if (failed.length > 0) {
throw failed[0].reason;
}
await Swal.fire({
icon: "success",
title: t("roles.templates.success_title"),
text: t("roles.templates.success_text", {
count: missingPermissions.length,
role: role.name,
template: label,
}),
});
} catch (error) {
console.error("Failed to apply limited backoffice permission template", error);
await Swal.fire({
icon: "error",
title: t("roles.templates.error_title"),
text: t("roles.templates.error_text", { role: role.name, template: label }),
});
} finally {
applyingTemplateKey.value = "";
await loadList();
}
};
onMounted(loadLimitedBackofficeTemplates);
</script>
<template>
@@ -66,6 +181,46 @@ const redirect = (path) => {
icon="fas fa-clone"
:click-action="() => SessionUser.objects.roles.functions.cloneObject(object.id).then(() => loadList())"
></ActionSettingsWheelItem>
<div
v-if="canApplyLimitedBackofficeTemplates"
class="role-limited-backoffice-template-actions"
:data-testid="`role-limited-backoffice-template-actions-${object.id}`"
>
<ActionSettingsWheelItemLabel
:label="$t('roles.templates.section')"
icon="fas fa-id-badge"
></ActionSettingsWheelItemLabel>
<ActionSettingsWheelItem
v-if="templatesLoading"
:label="$t('roles.templates.loading')"
icon="fas fa-spinner"
disabled
></ActionSettingsWheelItem>
<ActionSettingsWheelItem
v-else-if="templatesLoadError"
:label="$t('roles.templates.load_error')"
icon="fas fa-sync-alt"
:click-action="loadLimitedBackofficeTemplates"
></ActionSettingsWheelItem>
<ActionSettingsWheelItem
v-else-if="limitedBackofficeTemplates.length === 0"
:label="$t('roles.templates.unavailable')"
icon="fas fa-ban"
disabled
></ActionSettingsWheelItem>
<template v-else>
<ActionSettingsWheelItem
v-for="template in limitedBackofficeTemplates"
:key="template.key"
:label="$t('roles.templates.apply', { template: templateLabel(template) })"
:icon="isApplyingTemplate(object, template) ? 'fas fa-spinner' : 'fas fa-id-badge'"
:disabled="isTemplateActionDisabled(object, template)"
:test-id="`role-template-${object.id}-${template.key}`"
:click-action="() => applyLimitedBackofficeTemplate(object, template)"
></ActionSettingsWheelItem>
</template>
</div>
</template>
</ActionSettingsWheelButton>
</div>
@@ -85,4 +240,4 @@ const redirect = (path) => {
<style scoped>
</style>
</style>
@@ -2,6 +2,14 @@
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
import {
getLimitedBackofficeDepartments,
getLimitedBackofficeRoles,
limitedBackofficeErrorMessage,
migrateLimitedBackofficeEmployee,
unwrapLimitedBackofficeResponse,
} from "@/services/limitedBackoffice.js";
import Swal from "sweetalert2";
defineProps({
objects: {
@@ -10,11 +18,110 @@ defineProps({
},
});
const emit = defineEmits(["migrated"]);
const editUser = (user) => {
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id, {
limitedBackofficeManaged: Boolean(user.limited_backoffice_managed),
});
};
const escapeHtml = (value) =>
String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const canMigrateToLimitedBackoffice = (user) =>
Number(user?.customer_number) === 0 && !Boolean(user?.limited_backoffice_managed);
const migrateUser = async (user) => {
try {
const [departmentsResponse, rolesResponse] = await Promise.all([
getLimitedBackofficeDepartments(),
getLimitedBackofficeRoles(),
]);
const departments = unwrapLimitedBackofficeResponse(departmentsResponse) || [];
const roles = unwrapLimitedBackofficeResponse(rolesResponse) || [];
if (!Array.isArray(departments) || departments.length === 0 || !Array.isArray(roles) || roles.length === 0) {
throw new Error("Limited backoffice roles or departments are unavailable.");
}
const roleOptions = roles
.filter((role) => role?.key && role?.key !== "superuser")
.map((role) => {
const selected = role.key === "cashier" ? " selected" : "";
return `<option value="${escapeHtml(role.key)}"${selected}>${escapeHtml(role.label || role.key)}</option>`;
})
.join("");
const departmentOptions = departments
.map(
(department) => `
<label class="checkbox is-block has-text-left mb-2">
<input type="checkbox" class="limited-migration-department" value="${escapeHtml(department.id)}" />
${escapeHtml(department.name || `Department ${department.id}`)}
</label>
`
)
.join("");
const result = await Swal.fire({
title: "Migrate employee",
html: `
<div class="field has-text-left">
<label class="label">Employee</label>
<p>${escapeHtml(user.display_name || `#${user.id}`)}</p>
</div>
<div class="field has-text-left">
<label class="label" for="limited-migration-role">Role</label>
<div class="control">
<div class="select is-fullwidth">
<select id="limited-migration-role">${roleOptions}</select>
</div>
</div>
</div>
<div class="field has-text-left">
<label class="label">Departments</label>
<div class="control">${departmentOptions}</div>
</div>
`,
showCancelButton: true,
confirmButtonText: "Migrate",
preConfirm: async () => {
const role = document.getElementById("limited-migration-role")?.value;
const departmentIds = Array.from(document.querySelectorAll(".limited-migration-department:checked")).map(
(checkbox) => Number(checkbox.value)
);
if (!role || departmentIds.length === 0) {
Swal.showValidationMessage("Select a role and at least one department.");
return false;
}
try {
await migrateLimitedBackofficeEmployee(user.id, {
role_key: role,
department_ids: departmentIds,
});
return true;
} catch (error) {
Swal.showValidationMessage(limitedBackofficeErrorMessage(error, "Could not migrate employee."));
return false;
}
},
});
if (result.isConfirmed) {
await Swal.fire("Employee migrated");
emit("migrated");
}
} catch (error) {
await Swal.fire("Error", limitedBackofficeErrorMessage(error, "Could not migrate employee."), "error");
}
};
</script>
<template>
@@ -47,6 +154,13 @@ const editUser = (user) => {
:label="$t('global.edit')"
:test-id="`superuser-user-edit-${user.id}`"
/>
<ActionSettingsWheelItem
v-if="canMigrateToLimitedBackoffice(user)"
:click-action="() => migrateUser(user)"
icon="fas fa-random"
:label="$t('superuser.pages.employees.migrate_to_limited_backoffice')"
:test-id="`superuser-user-migrate-limited-${user.id}`"
/>
</template>
</ActionSettingsWheelButton>
</div>
@@ -7,7 +7,7 @@ import {
productCategoryAllowed,
isProductRestricted,
setProductsCategory,
getUserProductDiscount,
getUserProductPrice,
showFakeCreateOrderItem,
department_id,
user_discounts,
@@ -555,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);
@@ -592,9 +591,7 @@ 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) => {
@@ -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>
@@ -164,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,
@@ -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>
@@ -1,5 +1,4 @@
<script>
import Swal from "sweetalert2";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
@@ -123,6 +122,10 @@ export const Roles = {
id: id
});
},
getLimitedBackofficePermissionTemplates: async () => {
const response = await authenticatedRequest("/roles/limited-backoffice-permission-templates", "GET");
return response?.data?.data ?? response?.data ?? [];
},
permissions: {
add: async (roleId, permissionId) => {
return authenticatedRequest("/roles/permissions", "POST", {
@@ -161,4 +164,4 @@ export const Roles = {
);
}
};
</script>
</script>
+50 -3
View File
@@ -1802,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;
@@ -1906,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),
@@ -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'),
+183 -3
View File
@@ -1270,6 +1270,11 @@
"groups": {
"account": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.account'}",
"orders": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.orders'}",
"products": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.products'}",
"customers": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.customers'}",
"vehicles": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.vehicles'}",
"attachments": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.attachments'}",
"scanner": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.scanner'}",
"bookings": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.bookings'}",
"time_bookings": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.time_bookings'}",
"reports": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.reports'}",
@@ -1321,6 +1326,66 @@
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.charge_orders.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.charge_orders.description'}"
},
"complete_orders": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.complete_orders.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.complete_orders.description'}"
},
"view_product_catalog": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.description'}"
},
"view_product_recommendations": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.description'}"
},
"search_customers": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_customers.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_customers.description'}"
},
"view_customer_details": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.description'}"
},
"view_customer_notes": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.description'}"
},
"add_customer_notes": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.description'}"
},
"view_customer_flags": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.description'}"
},
"search_vehicles": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.description'}"
},
"view_vehicle_matches": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.description'}"
},
"view_vehicle_history": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.description'}"
},
"view_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.description'}"
},
"add_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.description'}"
},
"download_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.description'}"
},
"view_plate_scans": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.description'}"
},
"view_department_bookings": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.description'}"
@@ -1517,6 +1582,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'}",
@@ -4975,6 +5048,22 @@
"title": "Tilladelser for rolle {id}",
"title_with_name": "Tilladelser for {name}"
},
"templates": {
"apply": "Anvend {template}-skabelon",
"confirm_button": "Anvend skabelon",
"confirm_text": "Tilfoej {count} manglende tilladelser fra {template} til {role}?",
"confirm_title": "Anvend tilladelsesskabelon?",
"error_text": "En eller flere tilladelser fra {template} kunne ikke tilfoejes til {role}. Rollelisten blev genindlaest.",
"error_title": "Kunne ikke anvende skabelon",
"load_error": "Kunne ikke indlaese skabeloner. Proev igen",
"loading": "Indlaeser skabeloner...",
"section": "Begraenset backoffice-skabeloner",
"success_text": "Tilfoejede {count} tilladelser fra {template} til {role}.",
"success_title": "Skabelon anvendt",
"unavailable": "Ingen skabeloner er tilgaengelige.",
"up_to_date_text": "{role} har allerede alle tilladelser i {template}.",
"up_to_date_title": "Rollen er opdateret"
},
"subtitle": "@.capitalize:{'words.generated.brugerroller'}"
},
"self_wash": {
@@ -5384,6 +5473,7 @@
},
"employees": {
"create": "@:{'words.generated.opret'} @:{'words.generated.ny'} @:{'words.generated.medarbejder'}",
"migrate_to_limited_backoffice": "Migrer til begrænset backoffice",
"subtitle": "@.capitalize:{'words.generated.her'} @:{'words.generated.kan'} @:{'words.generated.du'} @:{'words.generated.se'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.liste'} @:{'words.generated.over'} @:{'words.generated.alle'} @:{'words.generated.medarbejdere'} @:{'words.generated.i'} @:{'words.generated.systemet'}"
},
"orders": {
@@ -5764,6 +5854,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'}",
@@ -6102,12 +6217,12 @@
},
"roles": {
"viewer": {
"label": "Viser",
"description": "@.capitalize:{'words.generated.kan'} logge ind @:{'words.generated.og'} se data @:{'words.generated.for'} @:{'words.generated.tildelte'} @:{'words.generated.afdelinger'}."
"label": "Deaktiveret",
"description": "Holder medarbejderen registreret uden adgang til ordre-, booking- eller administrationsfunktioner."
},
"cashier": {
"label": "Kassemedarbejder",
"description": "@.capitalize:{'words.generated.kan'} arbejde med @:{'words.generated.ordrer'} @:{'words.generated.og'} ordrelinjer @:{'words.generated.for'} @:{'words.generated.tildelte'} @:{'words.generated.afdelinger'}."
"description": "@.capitalize:{'words.generated.kan'} arbejde med @:{'words.generated.ordrer'}, ordrelinjer @:{'words.generated.og'} @:{'words.generated.bookinger'} @:{'words.generated.for'} @:{'words.generated.tildelte'} @:{'words.generated.afdelinger'}."
},
"booking_coordinator": {
"label": "Bookingkoordinator",
@@ -6142,6 +6257,11 @@
"groups": {
"account": "Konto",
"orders": "Ordrer",
"products": "Produkter",
"customers": "Kunder",
"vehicles": "Køretøjer",
"attachments": "Vedhæftninger",
"scanner": "Scannere",
"bookings": "Bookinger",
"time_bookings": "Tidsbookinger",
"reports": "Rapporter",
@@ -6193,6 +6313,66 @@
"label": "Opkræv ordrer",
"description": "Kan tage betaling eller opkræve en betalingsreservation for en ordre."
},
"complete_orders": {
"label": "Afslut ordrer",
"description": "Kan markere ordrer som afsluttede."
},
"view_product_catalog": {
"label": "Se produktkatalog",
"description": "Kan indlæse POS-kategorier, produkter, priser og tilvalg."
},
"view_product_recommendations": {
"label": "Se produktforslag",
"description": "Kan se produktforslag baseret på ordrer og køretøjsdata."
},
"search_customers": {
"label": "Søg kunder",
"description": "Kan søge og vælge kunder til POS-ordrer."
},
"view_customer_details": {
"label": "Se kundeoplysninger",
"description": "Kan indlæse kundeoplysninger fra et kundenummer."
},
"view_customer_notes": {
"label": "Se kundebemærkninger",
"description": "Kan læse kundebemærkninger i POS."
},
"add_customer_notes": {
"label": "Tilføj kundebemærkninger",
"description": "Kan tilføje kundebemærkninger fra POS."
},
"view_customer_flags": {
"label": "Se kundemarkeringer",
"description": "Kan se kundemarkeringer, der påvirker ordrekrav og produktregler."
},
"search_vehicles": {
"label": "Søg køretøjer",
"description": "Kan søge nummerplader og se køretøjsstatus."
},
"view_vehicle_matches": {
"label": "Se køretøjsmatch",
"description": "Kan se kundematch for køretøjer og forslag til ukendte køretøjer."
},
"view_vehicle_history": {
"label": "Se køretøjshistorik",
"description": "Kan se seneste ordrehistorik for en nummerplade."
},
"view_order_attachments": {
"label": "Se ordrevedhæftninger",
"description": "Kan liste vedhæftninger på ordrer."
},
"add_order_attachments": {
"label": "Tilføj ordrevedhæftninger",
"description": "Kan uploade filer og vedhæfte vaskecertifikater til ordrer."
},
"download_order_attachments": {
"label": "Download ordrevedhæftninger",
"description": "Kan åbne og downloade ordrevedhæftninger."
},
"view_plate_scans": {
"label": "Se nummerpladescanninger",
"description": "Kan se seneste nummerpladescanninger og scannere for tildelte afdelinger."
},
"view_department_bookings": {
"label": "Se afdelingsbookinger",
"description": "Kan se bookinger for tildelte afdelinger."
+205 -3
View File
@@ -1381,6 +1381,11 @@
"groups": {
"account": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.account'}",
"orders": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.orders'}",
"products": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.products'}",
"customers": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.customers'}",
"vehicles": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.vehicles'}",
"attachments": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.attachments'}",
"scanner": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.scanner'}",
"bookings": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.bookings'}",
"time_bookings": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.time_bookings'}",
"reports": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.reports'}",
@@ -1432,6 +1437,66 @@
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.charge_orders.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.charge_orders.description'}"
},
"complete_orders": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.complete_orders.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.complete_orders.description'}"
},
"view_product_catalog": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.description'}"
},
"view_product_recommendations": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.description'}"
},
"search_customers": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_customers.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_customers.description'}"
},
"view_customer_details": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.description'}"
},
"view_customer_notes": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.description'}"
},
"add_customer_notes": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.description'}"
},
"view_customer_flags": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.description'}"
},
"search_vehicles": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.description'}"
},
"view_vehicle_matches": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.description'}"
},
"view_vehicle_history": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.description'}"
},
"view_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.description'}"
},
"add_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.description'}"
},
"download_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.description'}"
},
"view_plate_scans": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.description'}"
},
"view_department_bookings": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.description'}"
@@ -1628,6 +1693,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'}",
@@ -3587,6 +3660,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'}",
@@ -4426,6 +4506,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'}",
@@ -5078,6 +5159,22 @@
"title": "Berechtigungen fuer Rolle {id}",
"title_with_name": "Berechtigungen fuer {name}"
},
"templates": {
"apply": "{template}-Vorlage anwenden",
"confirm_button": "Vorlage anwenden",
"confirm_text": "{count} fehlende Berechtigungen aus {template} zu {role} hinzufuegen?",
"confirm_title": "Berechtigungsvorlage anwenden?",
"error_text": "Eine oder mehrere Berechtigungen aus {template} konnten {role} nicht hinzugefuegt werden. Die Rollenliste wurde neu geladen.",
"error_title": "Vorlage konnte nicht angewendet werden",
"load_error": "Vorlagen konnten nicht geladen werden. Erneut versuchen",
"loading": "Vorlagen werden geladen...",
"section": "Limited-Backoffice-Vorlagen",
"success_text": "{count} Berechtigungen aus {template} zu {role} hinzugefuegt.",
"success_title": "Vorlage angewendet",
"unavailable": "Keine Vorlagen verfuegbar.",
"up_to_date_text": "{role} hat bereits alle Berechtigungen in {template}.",
"up_to_date_title": "Rolle ist aktuell"
},
"subtitle": "Benutzerrollen"
},
"self_wash": {
@@ -5487,6 +5584,7 @@
},
"employees": {
"create": "@.capitalize:{'words.generated.neuen'} @:{'words.generated.mitarbeiter'} @:{'words.generated.erstellen'}",
"migrate_to_limited_backoffice": "Zu eingeschränktem Backoffice migrieren",
"subtitle": "@.capitalize:{'words.generated.hier'} @:{'words.generated.sehen'} @.capitalize:{'words.generated.sie'} @:{'words.generated.eine'} @:{'words.generated.liste'} @:{'words.generated.aller'} @:{'words.generated.mitarbeiter'} @:{'words.generated.im'} @.capitalize:{'words.generated.system'}"
},
"orders": {
@@ -5867,6 +5965,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'}",
@@ -6073,6 +6196,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'}",
@@ -6191,12 +6328,12 @@
},
"roles": {
"viewer": {
"label": "Betrachter",
"description": "@.capitalize:{'words.generated.kann'} sich anmelden @:{'words.generated.und'} Daten zugewiesener @:{'words.generated.abteilungen'} @:{'words.generated.anzeigen'}."
"label": "Deaktiviert",
"description": "Hält den Mitarbeiter registriert, ohne Zugriff auf Auftrags-, Buchungs- oder Verwaltungsfunktionen."
},
"cashier": {
"label": "Kassierer",
"description": "@.capitalize:{'words.generated.kann'} mit Aufträgen @:{'words.generated.und'} Auftragszeilen @:{'words.generated.fur'} @:{'words.generated.zugewiesene'} @:{'words.generated.abteilungen'} arbeiten."
"description": "@.capitalize:{'words.generated.kann'} mit Aufträgen, Auftragszeilen @:{'words.generated.und'} @:{'words.generated.buchungen'} @:{'words.generated.fur'} @:{'words.generated.zugewiesene'} @:{'words.generated.abteilungen'} arbeiten."
},
"booking_coordinator": {
"label": "Buchungskoordinator",
@@ -6231,6 +6368,11 @@
"groups": {
"account": "Konto",
"orders": "Aufträge",
"products": "Produkte",
"customers": "Kunden",
"vehicles": "Fahrzeuge",
"attachments": "Anhänge",
"scanner": "Scanner",
"bookings": "Buchungen",
"time_bookings": "Zeitbuchungen",
"reports": "Berichte",
@@ -6282,6 +6424,66 @@
"label": "Aufträge abrechnen",
"description": "Kann Zahlungen annehmen oder eine Zahlungsreservierung für einen Auftrag belasten."
},
"complete_orders": {
"label": "Aufträge abschließen",
"description": "Kann Aufträge als abgeschlossen markieren."
},
"view_product_catalog": {
"label": "Produktkatalog anzeigen",
"description": "Kann POS-Kategorien, Produkte, Preise und Zusatzoptionen laden."
},
"view_product_recommendations": {
"label": "Produktempfehlungen anzeigen",
"description": "Kann Produktempfehlungen auf Basis von Aufträgen und Fahrzeugdaten sehen."
},
"search_customers": {
"label": "Kunden suchen",
"description": "Kann Kunden für POS-Aufträge suchen und auswählen."
},
"view_customer_details": {
"label": "Kundendetails anzeigen",
"description": "Kann Kundendetails anhand einer Kundennummer laden."
},
"view_customer_notes": {
"label": "Kundennotizen anzeigen",
"description": "Kann Kundennotizen im POS lesen."
},
"add_customer_notes": {
"label": "Kundennotizen hinzufügen",
"description": "Kann Kundennotizen im POS hinzufügen."
},
"view_customer_flags": {
"label": "Kundenmarkierungen anzeigen",
"description": "Kann Kundenmarkierungen sehen, die Auftragsanforderungen und Produktregeln beeinflussen."
},
"search_vehicles": {
"label": "Fahrzeuge suchen",
"description": "Kann Kennzeichen suchen und den Fahrzeugstatus sehen."
},
"view_vehicle_matches": {
"label": "Fahrzeugtreffer anzeigen",
"description": "Kann Kundentreffer für Fahrzeuge und Vorschläge zu unbekannten Fahrzeugen sehen."
},
"view_vehicle_history": {
"label": "Fahrzeughistorie anzeigen",
"description": "Kann die letzte Auftragshistorie für ein Kennzeichen sehen."
},
"view_order_attachments": {
"label": "Auftragsanhänge anzeigen",
"description": "Kann Anhänge auf Aufträgen auflisten."
},
"add_order_attachments": {
"label": "Auftragsanhänge hinzufügen",
"description": "Kann Dateien hochladen und Waschzertifikate an Aufträge anhängen."
},
"download_order_attachments": {
"label": "Auftragsanhänge herunterladen",
"description": "Kann Auftragsanhänge öffnen und herunterladen."
},
"view_plate_scans": {
"label": "Kennzeichenscans anzeigen",
"description": "Kann aktuelle Kennzeichenscans und Scanner für zugewiesene Abteilungen sehen."
},
"view_department_bookings": {
"label": "Abteilungsbuchungen anzeigen",
"description": "Kann Buchungen für zugewiesene Abteilungen sehen."
+183 -3
View File
@@ -1102,6 +1102,11 @@
"groups": {
"account": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.account'}",
"orders": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.orders'}",
"products": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.products'}",
"customers": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.customers'}",
"vehicles": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.vehicles'}",
"attachments": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.attachments'}",
"scanner": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.scanner'}",
"bookings": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.bookings'}",
"time_bookings": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.time_bookings'}",
"reports": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.reports'}",
@@ -1153,6 +1158,66 @@
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.charge_orders.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.charge_orders.description'}"
},
"complete_orders": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.complete_orders.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.complete_orders.description'}"
},
"view_product_catalog": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.description'}"
},
"view_product_recommendations": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.description'}"
},
"search_customers": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_customers.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_customers.description'}"
},
"view_customer_details": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.description'}"
},
"view_customer_notes": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.description'}"
},
"add_customer_notes": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.description'}"
},
"view_customer_flags": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.description'}"
},
"search_vehicles": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.description'}"
},
"view_vehicle_matches": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.description'}"
},
"view_vehicle_history": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.description'}"
},
"view_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.description'}"
},
"add_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.description'}"
},
"download_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.description'}"
},
"view_plate_scans": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.description'}"
},
"view_department_bookings": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.description'}"
@@ -1349,6 +1414,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'}",
@@ -4807,6 +4880,22 @@
"title": "Role {id} permissions",
"title_with_name": "{name} permissions"
},
"templates": {
"apply": "Apply {template} template",
"confirm_button": "Apply template",
"confirm_text": "Add {count} missing permissions from {template} to {role}?",
"confirm_title": "Apply permission template?",
"error_text": "One or more permissions from {template} could not be added to {role}. The role list was reloaded.",
"error_title": "Could not apply template",
"load_error": "Could not load templates. Try again",
"loading": "Loading templates...",
"section": "Limited backoffice templates",
"success_text": "Added {count} permissions from {template} to {role}.",
"success_title": "Template applied",
"unavailable": "No templates are available.",
"up_to_date_text": "{role} already has every permission in {template}.",
"up_to_date_title": "Role is up to date"
},
"subtitle": "@.capitalize:{'words.generated.user'} @:{'words.generated.roles'}"
},
"self_wash": {
@@ -5216,6 +5305,7 @@
},
"employees": {
"create": "@.capitalize:{'words.generated.create'} @:{'words.generated.new'} @:{'words.generated.employee'}",
"migrate_to_limited_backoffice": "Migrate to limited backoffice",
"subtitle": "@.capitalize:{'words.generated.here'} @:{'words.generated.you'} @:{'words.generated.can'} @:{'words.generated.see'} @:{'words.generated.a'} @:{'words.generated.list'} @:{'words.generated.of'} @:{'words.generated.all'} @:{'words.generated.employees'} @:{'words.generated.in'} @:{'words.replication.article.host_mention'} @:{'words.generated.system'}"
},
"orders": {
@@ -5596,6 +5686,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'}",
@@ -5934,12 +6049,12 @@
},
"roles": {
"viewer": {
"label": "Viewer",
"description": "@.capitalize:{'words.generated.can'} sign @:{'words.generated.in'} @:{'words.generated.and'} view @:{'words.generated.assigned'} @:{'words.generated.department'} data."
"label": "Deactivated",
"description": "Keeps the employee registered without order, booking, or management permissions."
},
"cashier": {
"label": "Cashier",
"description": "@.capitalize:{'words.generated.can'} work with @:{'words.generated.orders'} @:{'words.generated.and'} order lines @:{'words.generated.for'} @:{'words.generated.assigned'} @:{'words.generated.departments'}."
"description": "@.capitalize:{'words.generated.can'} work with @:{'words.generated.orders'}, order lines @:{'words.generated.and'} @:{'words.generated.bookings'} @:{'words.generated.for'} @:{'words.generated.assigned'} @:{'words.generated.departments'}."
},
"booking_coordinator": {
"label": "Booking coordinator",
@@ -5974,6 +6089,11 @@
"groups": {
"account": "Account",
"orders": "Orders",
"products": "Products",
"customers": "Customers",
"vehicles": "Vehicles",
"attachments": "Attachments",
"scanner": "Scanners",
"bookings": "Bookings",
"time_bookings": "Time bookings",
"reports": "Reports",
@@ -6025,6 +6145,66 @@
"label": "Charge orders",
"description": "Can take payment or charge a payment intent for an order."
},
"complete_orders": {
"label": "Complete orders",
"description": "Can mark orders as completed."
},
"view_product_catalog": {
"label": "View product catalog",
"description": "Can load POS categories, products, prices, and addons."
},
"view_product_recommendations": {
"label": "View product recommendations",
"description": "Can see product suggestions based on orders and vehicle data."
},
"search_customers": {
"label": "Search customers",
"description": "Can search and select customers for POS orders."
},
"view_customer_details": {
"label": "View customer details",
"description": "Can load customer details from a customer number."
},
"view_customer_notes": {
"label": "View customer notes",
"description": "Can read customer notes in POS."
},
"add_customer_notes": {
"label": "Add customer notes",
"description": "Can add customer notes from POS."
},
"view_customer_flags": {
"label": "View customer flags",
"description": "Can see customer flags that affect order requirements and product rules."
},
"search_vehicles": {
"label": "Search vehicles",
"description": "Can search license plates and see vehicle status."
},
"view_vehicle_matches": {
"label": "View vehicle matches",
"description": "Can see vehicle customer matches and unknown vehicle suggestions."
},
"view_vehicle_history": {
"label": "View vehicle history",
"description": "Can see recent order history for a license plate."
},
"view_order_attachments": {
"label": "View order attachments",
"description": "Can list attachments on orders."
},
"add_order_attachments": {
"label": "Add order attachments",
"description": "Can upload files and attach wash certificates to orders."
},
"download_order_attachments": {
"label": "Download order attachments",
"description": "Can open and download order attachments."
},
"view_plate_scans": {
"label": "View plate scans",
"description": "Can view recent license plate scans and scanners for assigned departments."
},
"view_department_bookings": {
"label": "View department bookings",
"description": "Can see bookings for assigned departments."
+58 -9
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'}",
@@ -4365,6 +4373,22 @@
"title": "@:{'templates.generated.compat.roles.permissions.title'}",
"title_with_name": "@:{'templates.generated.compat.roles.permissions.title_with_name'}"
},
"templates": {
"apply": "@:{'templates.generated.compat.roles.templates.apply'}",
"confirm_button": "@:{'templates.generated.compat.roles.templates.confirm_button'}",
"confirm_text": "@:{'templates.generated.compat.roles.templates.confirm_text'}",
"confirm_title": "@:{'templates.generated.compat.roles.templates.confirm_title'}",
"error_text": "@:{'templates.generated.compat.roles.templates.error_text'}",
"error_title": "@:{'templates.generated.compat.roles.templates.error_title'}",
"load_error": "@:{'templates.generated.compat.roles.templates.load_error'}",
"loading": "@:{'templates.generated.compat.roles.templates.loading'}",
"section": "@:{'templates.generated.compat.roles.templates.section'}",
"success_text": "@:{'templates.generated.compat.roles.templates.success_text'}",
"success_title": "@:{'templates.generated.compat.roles.templates.success_title'}",
"unavailable": "@:{'templates.generated.compat.roles.templates.unavailable'}",
"up_to_date_text": "@:{'templates.generated.compat.roles.templates.up_to_date_text'}",
"up_to_date_title": "@:{'templates.generated.compat.roles.templates.up_to_date_title'}"
},
"subtitle": "@:{'templates.generated.compat.roles.subtitle'}",
"title": "@:{'templates.generated.compat.common.roles'}"
},
@@ -4582,8 +4606,8 @@
"overview": "Overview",
"modules": "Modules",
"branding": "Profile & Branding",
"gateways": "Gateways",
"stripe": "Stripe",
"gateways": "@.capitalize:{'words.generated.gateways'}",
"stripe": "@:{'words.generated.stripe'}",
"pricing": "Pricing",
"categories": "Categories"
},
@@ -4626,7 +4650,7 @@
"department_id": "Department ID",
"economic_department_id": "Economic department",
"branding": "Branding",
"created_at": "Created",
"created_at": "@:common.created",
"updated_at": "Updated"
},
"hardware": {
@@ -4642,12 +4666,12 @@
"quick_links": {
"title": "Department tools",
"subtitle": "Open the focused setup areas for this department",
"modules": "Modules",
"branding": "Branding",
"gateways": "Gateways",
"stripe": "Stripe",
"pricing": "Pricing",
"categories": "Categories"
"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",
@@ -5632,6 +5656,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'}",
+205 -3
View File
@@ -1384,6 +1384,11 @@
"groups": {
"account": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.account'}",
"orders": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.orders'}",
"products": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.products'}",
"customers": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.customers'}",
"vehicles": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.vehicles'}",
"attachments": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.attachments'}",
"scanner": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.scanner'}",
"bookings": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.bookings'}",
"time_bookings": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.time_bookings'}",
"reports": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.reports'}",
@@ -1435,6 +1440,66 @@
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.charge_orders.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.charge_orders.description'}"
},
"complete_orders": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.complete_orders.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.complete_orders.description'}"
},
"view_product_catalog": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.description'}"
},
"view_product_recommendations": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.description'}"
},
"search_customers": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_customers.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_customers.description'}"
},
"view_customer_details": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.description'}"
},
"view_customer_notes": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.description'}"
},
"add_customer_notes": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.description'}"
},
"view_customer_flags": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.description'}"
},
"search_vehicles": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.description'}"
},
"view_vehicle_matches": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.description'}"
},
"view_vehicle_history": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.description'}"
},
"view_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.description'}"
},
"add_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.description'}"
},
"download_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.description'}"
},
"view_plate_scans": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.description'}"
},
"view_department_bookings": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.description'}"
@@ -1631,6 +1696,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'}",
@@ -3590,6 +3663,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'}",
@@ -4429,6 +4509,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'}",
@@ -5081,6 +5162,22 @@
"title": "Tillatelser for rolle {id}",
"title_with_name": "Tillatelser for {name}"
},
"templates": {
"apply": "Bruk {template}-mal",
"confirm_button": "Bruk mal",
"confirm_text": "Legg til {count} manglende tillatelser fra {template} til {role}?",
"confirm_title": "Bruke tillatelsesmal?",
"error_text": "En eller flere tillatelser fra {template} kunne ikke legges til i {role}. Rollelisten ble lastet inn pa nytt.",
"error_title": "Kunne ikke bruke mal",
"load_error": "Kunne ikke laste maler. Prov igjen",
"loading": "Laster maler...",
"section": "Begrenset backoffice-maler",
"success_text": "La til {count} tillatelser fra {template} i {role}.",
"success_title": "Mal brukt",
"unavailable": "Ingen maler er tilgjengelige.",
"up_to_date_text": "{role} har allerede alle tillatelser i {template}.",
"up_to_date_title": "Rollen er oppdatert"
},
"subtitle": "@.capitalize:{'words.generated.user'} roles"
},
"self_wash": {
@@ -5490,6 +5587,7 @@
},
"employees": {
"create": "@:{'words.generated.opprett'} @:{'words.generated.ny'} medarbeider",
"migrate_to_limited_backoffice": "Migrer til begrenset backoffice",
"subtitle": "@.capitalize:{'words.generated.her'} @:{'words.generated.kan'} @:{'words.generated.du'} @:{'words.generated.se'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.liste'} @:{'words.generated.over'} @:{'words.generated.alle'} @:{'words.generated.ansatte'} @:{'words.generated.i'} @:{'words.generated.systemet'}"
},
"orders": {
@@ -5870,6 +5968,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'}",
@@ -6076,6 +6199,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'}",
@@ -6194,12 +6331,12 @@
},
"roles": {
"viewer": {
"label": "Viser",
"description": "@.capitalize:{'words.generated.kan'} logge inn @:{'words.generated.og'} se data @:{'words.generated.for'} @:{'words.generated.tildelte'} @:{'words.generated.avdelinger'}."
"label": "Deaktivert",
"description": "Holder den ansatte registrert uten tilgang til ordre-, booking- eller administrasjonsfunksjoner."
},
"cashier": {
"label": "Kasserer",
"description": "@.capitalize:{'words.generated.kan'} arbeide med @:{'words.generated.ordre'} @:{'words.generated.og'} ordrelinjer @:{'words.generated.for'} @:{'words.generated.tildelte'} @:{'words.generated.avdelinger'}."
"description": "@.capitalize:{'words.generated.kan'} arbeide med @:{'words.generated.ordre'}, ordrelinjer @:{'words.generated.og'} @:{'words.generated.bookinger'} @:{'words.generated.for'} @:{'words.generated.tildelte'} @:{'words.generated.avdelinger'}."
},
"booking_coordinator": {
"label": "Bookingkoordinator",
@@ -6234,6 +6371,11 @@
"groups": {
"account": "Konto",
"orders": "Ordrer",
"products": "Produkter",
"customers": "Kunder",
"vehicles": "Kjøretøy",
"attachments": "Vedlegg",
"scanner": "Skannere",
"bookings": "Bookinger",
"time_bookings": "Tidsbookinger",
"reports": "Rapporter",
@@ -6285,6 +6427,66 @@
"label": "Belast ordrer",
"description": "Kan ta betaling eller belaste en betalingsreservasjon for en ordre."
},
"complete_orders": {
"label": "Fullfør ordrer",
"description": "Kan markere ordrer som fullført."
},
"view_product_catalog": {
"label": "Se produktkatalog",
"description": "Kan laste POS-kategorier, produkter, priser og tillegg."
},
"view_product_recommendations": {
"label": "Se produktforslag",
"description": "Kan se produktforslag basert på ordrer og kjøretøydata."
},
"search_customers": {
"label": "Søk kunder",
"description": "Kan søke og velge kunder for POS-ordrer."
},
"view_customer_details": {
"label": "Se kundeopplysninger",
"description": "Kan laste kundeopplysninger fra et kundenummer."
},
"view_customer_notes": {
"label": "Se kundemerknader",
"description": "Kan lese kundemerknader i POS."
},
"add_customer_notes": {
"label": "Legg til kundemerknader",
"description": "Kan legge til kundemerknader fra POS."
},
"view_customer_flags": {
"label": "Se kundemarkeringer",
"description": "Kan se kundemarkeringer som påvirker ordrekrav og produktregler."
},
"search_vehicles": {
"label": "Søk kjøretøy",
"description": "Kan søke etter registreringsnummer og se kjøretøystatus."
},
"view_vehicle_matches": {
"label": "Se kjøretøytreff",
"description": "Kan se kundetreff for kjøretøy og forslag til ukjente kjøretøy."
},
"view_vehicle_history": {
"label": "Se kjøretøyhistorikk",
"description": "Kan se nylig ordrehistorikk for et registreringsnummer."
},
"view_order_attachments": {
"label": "Se ordrevedlegg",
"description": "Kan liste vedlegg på ordrer."
},
"add_order_attachments": {
"label": "Legg til ordrevedlegg",
"description": "Kan laste opp filer og legge ved vaskesertifikater på ordrer."
},
"download_order_attachments": {
"label": "Last ned ordrevedlegg",
"description": "Kan åpne og laste ned ordrevedlegg."
},
"view_plate_scans": {
"label": "Se nummerskiltskanninger",
"description": "Kan se nylige nummerskiltskanninger og skannere for tildelte avdelinger."
},
"view_department_bookings": {
"label": "Se avdelingsbookinger",
"description": "Kan se bookinger for tildelte avdelinger."
+205 -3
View File
@@ -1434,6 +1434,11 @@
"groups": {
"account": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.account'}",
"orders": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.orders'}",
"products": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.products'}",
"customers": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.customers'}",
"vehicles": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.vehicles'}",
"attachments": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.attachments'}",
"scanner": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.scanner'}",
"bookings": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.bookings'}",
"time_bookings": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.time_bookings'}",
"reports": "@:{'templates.generated.compat.limited_backoffice.role_permissions.groups.reports'}",
@@ -1485,6 +1490,66 @@
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.charge_orders.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.charge_orders.description'}"
},
"complete_orders": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.complete_orders.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.complete_orders.description'}"
},
"view_product_catalog": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.description'}"
},
"view_product_recommendations": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.description'}"
},
"search_customers": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_customers.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_customers.description'}"
},
"view_customer_details": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.description'}"
},
"view_customer_notes": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.description'}"
},
"add_customer_notes": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.description'}"
},
"view_customer_flags": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.description'}"
},
"search_vehicles": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.description'}"
},
"view_vehicle_matches": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.description'}"
},
"view_vehicle_history": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.description'}"
},
"view_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.description'}"
},
"add_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.description'}"
},
"download_order_attachments": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.description'}"
},
"view_plate_scans": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.description'}"
},
"view_department_bookings": {
"label": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.label'}",
"description": "@:{'templates.generated.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.description'}"
@@ -1681,6 +1746,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'}",
@@ -3640,6 +3713,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'}",
@@ -4479,6 +4559,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",
@@ -5131,6 +5212,22 @@
"title": "Behorigheter for roll {id}",
"title_with_name": "Behorigheter for {name}"
},
"templates": {
"apply": "Anvand mallen {template}",
"confirm_button": "Anvand mall",
"confirm_text": "Lagg till {count} saknade behorigheter fran {template} till {role}?",
"confirm_title": "Anvanda behorighetsmall?",
"error_text": "En eller flera behorigheter fran {template} kunde inte laggas till i {role}. Rolllistan laddades om.",
"error_title": "Kunde inte anvanda mall",
"load_error": "Kunde inte ladda mallar. Forsok igen",
"loading": "Laddar mallar...",
"section": "Begransade backoffice-mallar",
"success_text": "Lade till {count} behorigheter fran {template} i {role}.",
"success_title": "Mall anvand",
"unavailable": "Inga mallar ar tillgangliga.",
"up_to_date_text": "{role} har redan alla behorigheter i {template}.",
"up_to_date_title": "Rollen ar uppdaterad"
},
"subtitle": "@.capitalize:{'words.generated.user'} @:{'words.generated.roles'}"
},
"self_wash": {
@@ -5540,6 +5637,7 @@
},
"employees": {
"create": "@.capitalize:{'words.generated.skapa'} @:{'words.generated.ny'} @:{'words.generated.anstalld'}",
"migrate_to_limited_backoffice": "Migrera till begränsad backoffice",
"subtitle": "@.capitalize:{'words.generated.har_2'} @:{'words.generated.kan'} @:{'words.generated.du'} @:{'words.generated.se'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.lista'} @:{'words.generated.over'} @:{'words.generated.alla'} @:{'words.generated.anstallda'} @:{'words.generated.i'} @:{'words.generated.systemet'}"
},
"orders": {
@@ -5920,6 +6018,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'}",
@@ -6126,6 +6249,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'}",
@@ -6244,12 +6381,12 @@
},
"roles": {
"viewer": {
"label": "Visare",
"description": "@.capitalize:{'words.generated.kan'} logga in @:{'words.generated.och'} se data @:{'words.generated.for'} @:{'words.generated.tilldelade'} @:{'words.generated.avdelningar'}."
"label": "Inaktiverad",
"description": "Behåller medarbetaren registrerad utan åtkomst till order-, boknings- eller administrationsfunktioner."
},
"cashier": {
"label": "Kassör",
"description": "@.capitalize:{'words.generated.kan'} arbeta med @:{'words.generated.order'} @:{'words.generated.och'} orderrader @:{'words.generated.for'} @:{'words.generated.tilldelade'} @:{'words.generated.avdelningar'}."
"description": "@.capitalize:{'words.generated.kan'} arbeta med @:{'words.generated.order'}, orderrader @:{'words.generated.och'} @:{'words.generated.bokningar'} @:{'words.generated.for'} @:{'words.generated.tilldelade'} @:{'words.generated.avdelningar'}."
},
"booking_coordinator": {
"label": "Bokningskoordinator",
@@ -6284,6 +6421,11 @@
"groups": {
"account": "Konto",
"orders": "Order",
"products": "Produkter",
"customers": "Kunder",
"vehicles": "Fordon",
"attachments": "Bilagor",
"scanner": "Skannrar",
"bookings": "Bokningar",
"time_bookings": "Tidsbokningar",
"reports": "Rapporter",
@@ -6335,6 +6477,66 @@
"label": "Debitera order",
"description": "Kan ta betalt eller debitera en betalningsreservation för en order."
},
"complete_orders": {
"label": "Slutför ordrar",
"description": "Kan markera ordrar som slutförda."
},
"view_product_catalog": {
"label": "Visa produktkatalog",
"description": "Kan läsa in POS-kategorier, produkter, priser och tillval."
},
"view_product_recommendations": {
"label": "Visa produktförslag",
"description": "Kan se produktförslag baserade på ordrar och fordonsdata."
},
"search_customers": {
"label": "Sök kunder",
"description": "Kan söka och välja kunder för POS-ordrar."
},
"view_customer_details": {
"label": "Visa kunduppgifter",
"description": "Kan läsa in kunduppgifter från ett kundnummer."
},
"view_customer_notes": {
"label": "Visa kundanteckningar",
"description": "Kan läsa kundanteckningar i POS."
},
"add_customer_notes": {
"label": "Lägg till kundanteckningar",
"description": "Kan lägga till kundanteckningar från POS."
},
"view_customer_flags": {
"label": "Visa kundmarkeringar",
"description": "Kan se kundmarkeringar som påverkar orderkrav och produktregler."
},
"search_vehicles": {
"label": "Sök fordon",
"description": "Kan söka registreringsnummer och se fordonsstatus."
},
"view_vehicle_matches": {
"label": "Visa fordonsträffar",
"description": "Kan se kundträffar för fordon och förslag på okända fordon."
},
"view_vehicle_history": {
"label": "Visa fordonshistorik",
"description": "Kan se senaste orderhistorik för ett registreringsnummer."
},
"view_order_attachments": {
"label": "Visa orderbilagor",
"description": "Kan lista bilagor på ordrar."
},
"add_order_attachments": {
"label": "Lägg till orderbilagor",
"description": "Kan ladda upp filer och bifoga tvättcertifikat till ordrar."
},
"download_order_attachments": {
"label": "Ladda ner orderbilagor",
"description": "Kan öppna och ladda ner orderbilagor."
},
"view_plate_scans": {
"label": "Visa nummerskyltsavläsningar",
"description": "Kan se senaste nummerskyltsavläsningar och skannrar för tilldelade avdelningar."
},
"view_department_bookings": {
"label": "Se avdelningsbokningar",
"description": "Kan se bokningar för tilldelade avdelningar."
@@ -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'}",
@@ -91,6 +91,22 @@
"subtitle": "Rolletilladelser",
"title": "Tilladelser for rolle {id}",
"title_with_name": "Tilladelser for {name}"
},
"templates": {
"apply": "Anvend {template}-skabelon",
"confirm_button": "Anvend skabelon",
"confirm_text": "Tilfoej {count} manglende tilladelser fra {template} til {role}?",
"confirm_title": "Anvend tilladelsesskabelon?",
"error_text": "En eller flere tilladelser fra {template} kunne ikke tilfoejes til {role}. Rollelisten blev genindlaest.",
"error_title": "Kunne ikke anvende skabelon",
"load_error": "Kunne ikke indlaese skabeloner. Proev igen",
"loading": "Indlaeser skabeloner...",
"section": "Begraenset backoffice-skabeloner",
"success_text": "Tilfoejede {count} tilladelser fra {template} til {role}.",
"success_title": "Skabelon anvendt",
"unavailable": "Ingen skabeloner er tilgaengelige.",
"up_to_date_text": "{role} har allerede alle tilladelser i {template}.",
"up_to_date_title": "Rollen er opdateret"
}
}
}
@@ -78,6 +78,7 @@
},
"employees": {
"create": "@:{'terms.glossary.opret'} @:{'terms.glossary.ny'} @:{'terms.glossary.medarbejder'}",
"migrate_to_limited_backoffice": "Migrer til begrænset backoffice",
"subtitle": "@.capitalize:{'terms.glossary.her'} @:{'terms.glossary.kan'} @:{'terms.glossary.du'} @:{'terms.glossary.se'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.liste'} @:{'terms.glossary.over'} @:{'terms.glossary.alle'} @:{'terms.glossary.medarbejdere'} @:{'terms.glossary.i'} @:{'terms.glossary.systemet'}"
},
"orders": {
@@ -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'}",
+143 -13
View File
@@ -28,16 +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'}",
"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'}",
"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'}"
@@ -126,6 +126,11 @@
"groups": {
"account": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.account'}",
"orders": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.orders'}",
"products": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.products'}",
"customers": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.customers'}",
"vehicles": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.vehicles'}",
"attachments": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.attachments'}",
"scanner": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.scanner'}",
"bookings": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.bookings'}",
"time_bookings": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.time_bookings'}",
"reports": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.reports'}",
@@ -177,6 +182,66 @@
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.charge_orders.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.charge_orders.description'}"
},
"complete_orders": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.complete_orders.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.complete_orders.description'}"
},
"view_product_catalog": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.description'}"
},
"view_product_recommendations": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.description'}"
},
"search_customers": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_customers.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_customers.description'}"
},
"view_customer_details": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.description'}"
},
"view_customer_notes": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.description'}"
},
"add_customer_notes": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.description'}"
},
"view_customer_flags": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.description'}"
},
"search_vehicles": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.description'}"
},
"view_vehicle_matches": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.description'}"
},
"view_vehicle_history": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.description'}"
},
"view_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.description'}"
},
"add_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.description'}"
},
"download_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.description'}"
},
"view_plate_scans": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.description'}"
},
"view_department_bookings": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.description'}"
@@ -338,12 +403,12 @@
},
"roles": {
"viewer": {
"label": "Viser",
"description": "@.capitalize:{'terms.glossary.kan'} logge ind @:{'terms.glossary.og'} se data @:{'terms.glossary.for'} @:{'terms.glossary.tildelte'} @:{'terms.glossary.afdelinger'}."
"label": "Deaktiveret",
"description": "Holder medarbejderen registreret uden adgang til ordre-, booking- eller administrationsfunktioner."
},
"cashier": {
"label": "Kassemedarbejder",
"description": "@.capitalize:{'terms.glossary.kan'} arbejde med @:{'terms.glossary.ordrer'} @:{'terms.glossary.og'} ordrelinjer @:{'terms.glossary.for'} @:{'terms.glossary.tildelte'} @:{'terms.glossary.afdelinger'}."
"description": "@.capitalize:{'terms.glossary.kan'} arbejde med @:{'terms.glossary.ordrer'}, ordrelinjer @:{'terms.glossary.og'} @:{'terms.glossary.bookinger'} @:{'terms.glossary.for'} @:{'terms.glossary.tildelte'} @:{'terms.glossary.afdelinger'}."
},
"booking_coordinator": {
"label": "Bookingkoordinator",
@@ -378,6 +443,11 @@
"groups": {
"account": "Konto",
"orders": "Ordrer",
"products": "Produkter",
"customers": "Kunder",
"vehicles": "Køretøjer",
"attachments": "Vedhæftninger",
"scanner": "Scannere",
"bookings": "Bookinger",
"time_bookings": "Tidsbookinger",
"reports": "Rapporter",
@@ -429,6 +499,66 @@
"label": "Opkræv ordrer",
"description": "Kan tage betaling eller opkræve en betalingsreservation for en ordre."
},
"complete_orders": {
"label": "Afslut ordrer",
"description": "Kan markere ordrer som afsluttede."
},
"view_product_catalog": {
"label": "Se produktkatalog",
"description": "Kan indlæse POS-kategorier, produkter, priser og tilvalg."
},
"view_product_recommendations": {
"label": "Se produktforslag",
"description": "Kan se produktforslag baseret på ordrer og køretøjsdata."
},
"search_customers": {
"label": "Søg kunder",
"description": "Kan søge og vælge kunder til POS-ordrer."
},
"view_customer_details": {
"label": "Se kundeoplysninger",
"description": "Kan indlæse kundeoplysninger fra et kundenummer."
},
"view_customer_notes": {
"label": "Se kundebemærkninger",
"description": "Kan læse kundebemærkninger i POS."
},
"add_customer_notes": {
"label": "Tilføj kundebemærkninger",
"description": "Kan tilføje kundebemærkninger fra POS."
},
"view_customer_flags": {
"label": "Se kundemarkeringer",
"description": "Kan se kundemarkeringer, der påvirker ordrekrav og produktregler."
},
"search_vehicles": {
"label": "Søg køretøjer",
"description": "Kan søge nummerplader og se køretøjsstatus."
},
"view_vehicle_matches": {
"label": "Se køretøjsmatch",
"description": "Kan se kundematch for køretøjer og forslag til ukendte køretøjer."
},
"view_vehicle_history": {
"label": "Se køretøjshistorik",
"description": "Kan se seneste ordrehistorik for en nummerplade."
},
"view_order_attachments": {
"label": "Se ordrevedhæftninger",
"description": "Kan liste vedhæftninger på ordrer."
},
"add_order_attachments": {
"label": "Tilføj ordrevedhæftninger",
"description": "Kan uploade filer og vedhæfte vaskecertifikater til ordrer."
},
"download_order_attachments": {
"label": "Download ordrevedhæftninger",
"description": "Kan åbne og downloade ordrevedhæftninger."
},
"view_plate_scans": {
"label": "Se nummerpladescanninger",
"description": "Kan se seneste nummerpladescanninger og scannere for tildelte afdelinger."
},
"view_department_bookings": {
"label": "Se afdelingsbookinger",
"description": "Kan se bookinger for tildelte afdelinger."
@@ -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'}",
@@ -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'}",
@@ -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'}",
@@ -91,6 +91,22 @@
"subtitle": "Rollenberechtigungen",
"title": "Berechtigungen fuer Rolle {id}",
"title_with_name": "Berechtigungen fuer {name}"
},
"templates": {
"apply": "{template}-Vorlage anwenden",
"confirm_button": "Vorlage anwenden",
"confirm_text": "{count} fehlende Berechtigungen aus {template} zu {role} hinzufuegen?",
"confirm_title": "Berechtigungsvorlage anwenden?",
"error_text": "Eine oder mehrere Berechtigungen aus {template} konnten {role} nicht hinzugefuegt werden. Die Rollenliste wurde neu geladen.",
"error_title": "Vorlage konnte nicht angewendet werden",
"load_error": "Vorlagen konnten nicht geladen werden. Erneut versuchen",
"loading": "Vorlagen werden geladen...",
"section": "Limited-Backoffice-Vorlagen",
"success_text": "{count} Berechtigungen aus {template} zu {role} hinzugefuegt.",
"success_title": "Vorlage angewendet",
"unavailable": "Keine Vorlagen verfuegbar.",
"up_to_date_text": "{role} hat bereits alle Berechtigungen in {template}.",
"up_to_date_title": "Rolle ist aktuell"
}
}
}
@@ -78,6 +78,7 @@
},
"employees": {
"create": "@.capitalize:{'terms.glossary.neuen'} @:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.erstellen'}",
"migrate_to_limited_backoffice": "Zu eingeschränktem Backoffice migrieren",
"subtitle": "@.capitalize:{'terms.glossary.hier'} @:{'terms.glossary.sehen'} @.capitalize:{'terms.glossary.sie'} @:{'terms.glossary.eine'} @:{'terms.glossary.liste'} @:{'terms.glossary.aller'} @:{'terms.glossary.mitarbeiter'} @:{'terms.glossary.im'} @.capitalize:{'terms.glossary.system'}"
},
"orders": {
@@ -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'}",
+143 -13
View File
@@ -28,16 +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'}",
"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'}",
"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'}"
@@ -126,6 +126,11 @@
"groups": {
"account": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.account'}",
"orders": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.orders'}",
"products": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.products'}",
"customers": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.customers'}",
"vehicles": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.vehicles'}",
"attachments": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.attachments'}",
"scanner": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.scanner'}",
"bookings": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.bookings'}",
"time_bookings": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.time_bookings'}",
"reports": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.reports'}",
@@ -177,6 +182,66 @@
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.charge_orders.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.charge_orders.description'}"
},
"complete_orders": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.complete_orders.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.complete_orders.description'}"
},
"view_product_catalog": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.description'}"
},
"view_product_recommendations": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.description'}"
},
"search_customers": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_customers.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_customers.description'}"
},
"view_customer_details": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.description'}"
},
"view_customer_notes": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.description'}"
},
"add_customer_notes": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.description'}"
},
"view_customer_flags": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.description'}"
},
"search_vehicles": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.description'}"
},
"view_vehicle_matches": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.description'}"
},
"view_vehicle_history": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.description'}"
},
"view_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.description'}"
},
"add_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.description'}"
},
"download_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.description'}"
},
"view_plate_scans": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.description'}"
},
"view_department_bookings": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.description'}"
@@ -338,12 +403,12 @@
},
"roles": {
"viewer": {
"label": "Betrachter",
"description": "@.capitalize:{'terms.glossary.kann'} sich anmelden @:{'terms.glossary.und'} Daten zugewiesener @:{'terms.glossary.abteilungen'} @:{'terms.glossary.anzeigen'}."
"label": "Deaktiviert",
"description": "Hält den Mitarbeiter registriert, ohne Zugriff auf Auftrags-, Buchungs- oder Verwaltungsfunktionen."
},
"cashier": {
"label": "Kassierer",
"description": "@.capitalize:{'terms.glossary.kann'} mit Aufträgen @:{'terms.glossary.und'} Auftragszeilen @:{'terms.glossary.fur'} @:{'terms.glossary.zugewiesene'} @:{'terms.glossary.abteilungen'} arbeiten."
"description": "@.capitalize:{'terms.glossary.kann'} mit Aufträgen, Auftragszeilen @:{'terms.glossary.und'} @:{'terms.glossary.buchungen'} @:{'terms.glossary.fur'} @:{'terms.glossary.zugewiesene'} @:{'terms.glossary.abteilungen'} arbeiten."
},
"booking_coordinator": {
"label": "Buchungskoordinator",
@@ -378,6 +443,11 @@
"groups": {
"account": "Konto",
"orders": "Aufträge",
"products": "Produkte",
"customers": "Kunden",
"vehicles": "Fahrzeuge",
"attachments": "Anhänge",
"scanner": "Scanner",
"bookings": "Buchungen",
"time_bookings": "Zeitbuchungen",
"reports": "Berichte",
@@ -429,6 +499,66 @@
"label": "Aufträge abrechnen",
"description": "Kann Zahlungen annehmen oder eine Zahlungsreservierung für einen Auftrag belasten."
},
"complete_orders": {
"label": "Aufträge abschließen",
"description": "Kann Aufträge als abgeschlossen markieren."
},
"view_product_catalog": {
"label": "Produktkatalog anzeigen",
"description": "Kann POS-Kategorien, Produkte, Preise und Zusatzoptionen laden."
},
"view_product_recommendations": {
"label": "Produktempfehlungen anzeigen",
"description": "Kann Produktempfehlungen auf Basis von Aufträgen und Fahrzeugdaten sehen."
},
"search_customers": {
"label": "Kunden suchen",
"description": "Kann Kunden für POS-Aufträge suchen und auswählen."
},
"view_customer_details": {
"label": "Kundendetails anzeigen",
"description": "Kann Kundendetails anhand einer Kundennummer laden."
},
"view_customer_notes": {
"label": "Kundennotizen anzeigen",
"description": "Kann Kundennotizen im POS lesen."
},
"add_customer_notes": {
"label": "Kundennotizen hinzufügen",
"description": "Kann Kundennotizen im POS hinzufügen."
},
"view_customer_flags": {
"label": "Kundenmarkierungen anzeigen",
"description": "Kann Kundenmarkierungen sehen, die Auftragsanforderungen und Produktregeln beeinflussen."
},
"search_vehicles": {
"label": "Fahrzeuge suchen",
"description": "Kann Kennzeichen suchen und den Fahrzeugstatus sehen."
},
"view_vehicle_matches": {
"label": "Fahrzeugtreffer anzeigen",
"description": "Kann Kundentreffer für Fahrzeuge und Vorschläge zu unbekannten Fahrzeugen sehen."
},
"view_vehicle_history": {
"label": "Fahrzeughistorie anzeigen",
"description": "Kann die letzte Auftragshistorie für ein Kennzeichen sehen."
},
"view_order_attachments": {
"label": "Auftragsanhänge anzeigen",
"description": "Kann Anhänge auf Aufträgen auflisten."
},
"add_order_attachments": {
"label": "Auftragsanhänge hinzufügen",
"description": "Kann Dateien hochladen und Waschzertifikate an Aufträge anhängen."
},
"download_order_attachments": {
"label": "Auftragsanhänge herunterladen",
"description": "Kann Auftragsanhänge öffnen und herunterladen."
},
"view_plate_scans": {
"label": "Kennzeichenscans anzeigen",
"description": "Kann aktuelle Kennzeichenscans und Scanner für zugewiesene Abteilungen sehen."
},
"view_department_bookings": {
"label": "Abteilungsbuchungen anzeigen",
"description": "Kann Buchungen für zugewiesene Abteilungen sehen."
@@ -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'}",
@@ -91,6 +91,22 @@
"subtitle": "Role permissions",
"title": "Role {id} permissions",
"title_with_name": "{name} permissions"
},
"templates": {
"apply": "Apply {template} template",
"confirm_button": "Apply template",
"confirm_text": "Add {count} missing permissions from {template} to {role}?",
"confirm_title": "Apply permission template?",
"error_text": "One or more permissions from {template} could not be added to {role}. The role list was reloaded.",
"error_title": "Could not apply template",
"load_error": "Could not load templates. Try again",
"loading": "Loading templates...",
"section": "Limited backoffice templates",
"success_text": "Added {count} permissions from {template} to {role}.",
"success_title": "Template applied",
"unavailable": "No templates are available.",
"up_to_date_text": "{role} already has every permission in {template}.",
"up_to_date_title": "Role is up to date"
}
}
}
@@ -78,6 +78,7 @@
},
"employees": {
"create": "@.capitalize:{'terms.glossary.create'} @:{'terms.glossary.new'} @:{'terms.glossary.employee'}",
"migrate_to_limited_backoffice": "Migrate to limited backoffice",
"subtitle": "@.capitalize:{'terms.glossary.here'} @:{'terms.glossary.you'} @:{'terms.glossary.can'} @:{'terms.glossary.see'} @:{'terms.glossary.a'} @:{'terms.glossary.list'} @:{'terms.glossary.of'} @:{'terms.glossary.all'} @:{'terms.glossary.employees'} @:{'terms.glossary.in'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.system'}"
},
"orders": {
@@ -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'}",
+143 -13
View File
@@ -28,16 +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'}",
"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'}",
"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'}"
@@ -126,6 +126,11 @@
"groups": {
"account": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.account'}",
"orders": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.orders'}",
"products": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.products'}",
"customers": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.customers'}",
"vehicles": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.vehicles'}",
"attachments": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.attachments'}",
"scanner": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.scanner'}",
"bookings": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.bookings'}",
"time_bookings": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.time_bookings'}",
"reports": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.reports'}",
@@ -177,6 +182,66 @@
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.charge_orders.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.charge_orders.description'}"
},
"complete_orders": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.complete_orders.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.complete_orders.description'}"
},
"view_product_catalog": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.description'}"
},
"view_product_recommendations": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.description'}"
},
"search_customers": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_customers.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_customers.description'}"
},
"view_customer_details": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.description'}"
},
"view_customer_notes": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.description'}"
},
"add_customer_notes": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.description'}"
},
"view_customer_flags": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.description'}"
},
"search_vehicles": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.description'}"
},
"view_vehicle_matches": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.description'}"
},
"view_vehicle_history": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.description'}"
},
"view_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.description'}"
},
"add_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.description'}"
},
"download_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.description'}"
},
"view_plate_scans": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.description'}"
},
"view_department_bookings": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.description'}"
@@ -338,12 +403,12 @@
},
"roles": {
"viewer": {
"label": "Viewer",
"description": "@.capitalize:{'terms.glossary.can'} sign @:{'terms.glossary.in'} @:{'terms.glossary.and'} view @:{'terms.glossary.assigned'} @:{'terms.glossary.department'} data."
"label": "Deactivated",
"description": "Keeps the employee registered without order, booking, or management permissions."
},
"cashier": {
"label": "Cashier",
"description": "@.capitalize:{'terms.glossary.can'} work with @:{'terms.glossary.orders'} @:{'terms.glossary.and'} order lines @:{'terms.glossary.for'} @:{'terms.glossary.assigned'} @:{'terms.glossary.departments'}."
"description": "@.capitalize:{'terms.glossary.can'} work with @:{'terms.glossary.orders'}, order lines @:{'terms.glossary.and'} @:{'terms.glossary.bookings'} @:{'terms.glossary.for'} @:{'terms.glossary.assigned'} @:{'terms.glossary.departments'}."
},
"booking_coordinator": {
"label": "Booking coordinator",
@@ -378,6 +443,11 @@
"groups": {
"account": "Account",
"orders": "Orders",
"products": "Products",
"customers": "Customers",
"vehicles": "Vehicles",
"attachments": "Attachments",
"scanner": "Scanners",
"bookings": "Bookings",
"time_bookings": "Time bookings",
"reports": "Reports",
@@ -429,6 +499,66 @@
"label": "Charge orders",
"description": "Can take payment or charge a payment intent for an order."
},
"complete_orders": {
"label": "Complete orders",
"description": "Can mark orders as completed."
},
"view_product_catalog": {
"label": "View product catalog",
"description": "Can load POS categories, products, prices, and addons."
},
"view_product_recommendations": {
"label": "View product recommendations",
"description": "Can see product suggestions based on orders and vehicle data."
},
"search_customers": {
"label": "Search customers",
"description": "Can search and select customers for POS orders."
},
"view_customer_details": {
"label": "View customer details",
"description": "Can load customer details from a customer number."
},
"view_customer_notes": {
"label": "View customer notes",
"description": "Can read customer notes in POS."
},
"add_customer_notes": {
"label": "Add customer notes",
"description": "Can add customer notes from POS."
},
"view_customer_flags": {
"label": "View customer flags",
"description": "Can see customer flags that affect order requirements and product rules."
},
"search_vehicles": {
"label": "Search vehicles",
"description": "Can search license plates and see vehicle status."
},
"view_vehicle_matches": {
"label": "View vehicle matches",
"description": "Can see vehicle customer matches and unknown vehicle suggestions."
},
"view_vehicle_history": {
"label": "View vehicle history",
"description": "Can see recent order history for a license plate."
},
"view_order_attachments": {
"label": "View order attachments",
"description": "Can list attachments on orders."
},
"add_order_attachments": {
"label": "Add order attachments",
"description": "Can upload files and attach wash certificates to orders."
},
"download_order_attachments": {
"label": "Download order attachments",
"description": "Can open and download order attachments."
},
"view_plate_scans": {
"label": "View plate scans",
"description": "Can view recent license plate scans and scanners for assigned departments."
},
"view_department_bookings": {
"label": "View department bookings",
"description": "Can see bookings for assigned departments."
@@ -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'}",
@@ -91,6 +91,22 @@
"title": "@:{'phrases.compat.roles.permissions.title'}",
"title_with_name": "@:{'phrases.compat.roles.permissions.title_with_name'}"
},
"templates": {
"apply": "@:{'phrases.compat.roles.templates.apply'}",
"confirm_button": "@:{'phrases.compat.roles.templates.confirm_button'}",
"confirm_text": "@:{'phrases.compat.roles.templates.confirm_text'}",
"confirm_title": "@:{'phrases.compat.roles.templates.confirm_title'}",
"error_text": "@:{'phrases.compat.roles.templates.error_text'}",
"error_title": "@:{'phrases.compat.roles.templates.error_title'}",
"load_error": "@:{'phrases.compat.roles.templates.load_error'}",
"loading": "@:{'phrases.compat.roles.templates.loading'}",
"section": "@:{'phrases.compat.roles.templates.section'}",
"success_text": "@:{'phrases.compat.roles.templates.success_text'}",
"success_title": "@:{'phrases.compat.roles.templates.success_title'}",
"unavailable": "@:{'phrases.compat.roles.templates.unavailable'}",
"up_to_date_text": "@:{'phrases.compat.roles.templates.up_to_date_text'}",
"up_to_date_title": "@:{'phrases.compat.roles.templates.up_to_date_title'}"
},
"subtitle": "@:{'phrases.compat.roles.subtitle'}",
"title": "@:{'phrases.compat.common.roles'}"
}
@@ -44,8 +44,8 @@
"overview": "Overview",
"modules": "Modules",
"branding": "Profile & Branding",
"gateways": "Gateways",
"stripe": "Stripe",
"gateways": "@.capitalize:{'terms.glossary.gateways'}",
"stripe": "@:{'terms.glossary.stripe'}",
"pricing": "Pricing",
"categories": "Categories"
},
@@ -88,7 +88,7 @@
"department_id": "Department ID",
"economic_department_id": "Economic department",
"branding": "Branding",
"created_at": "Created",
"created_at": "@:common.created",
"updated_at": "Updated"
},
"hardware": {
@@ -104,12 +104,12 @@
"quick_links": {
"title": "Department tools",
"subtitle": "Open the focused setup areas for this department",
"modules": "Modules",
"branding": "Branding",
"gateways": "Gateways",
"stripe": "Stripe",
"pricing": "Pricing",
"categories": "Categories"
"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",
@@ -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'}",
@@ -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'}",
@@ -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'}",
@@ -140,6 +140,7 @@
"departments": {
"columns": {
"archived": "Arkivert",
"custom_pricing_only": "@:{'phrases.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Dimensjon",
"economic_department": "@.upper:{'terms.glossary.e'}-@:{'terms.glossary.økonomisk'} @:{'terms.glossary.avdeling'}",
"latitude": "@:{'phrases.compat.departments.form.latitude'}",
@@ -91,6 +91,22 @@
"subtitle": "Rolletillatelser",
"title": "Tillatelser for rolle {id}",
"title_with_name": "Tillatelser for {name}"
},
"templates": {
"apply": "Bruk {template}-mal",
"confirm_button": "Bruk mal",
"confirm_text": "Legg til {count} manglende tillatelser fra {template} til {role}?",
"confirm_title": "Bruke tillatelsesmal?",
"error_text": "En eller flere tillatelser fra {template} kunne ikke legges til i {role}. Rollelisten ble lastet inn pa nytt.",
"error_title": "Kunne ikke bruke mal",
"load_error": "Kunne ikke laste maler. Prov igjen",
"loading": "Laster maler...",
"section": "Begrenset backoffice-maler",
"success_text": "La til {count} tillatelser fra {template} i {role}.",
"success_title": "Mal brukt",
"unavailable": "Ingen maler er tilgjengelige.",
"up_to_date_text": "{role} har allerede alle tillatelser i {template}.",
"up_to_date_title": "Rollen er oppdatert"
}
}
}
@@ -78,6 +78,7 @@
},
"employees": {
"create": "@:{'terms.glossary.opprett'} @:{'terms.glossary.ny'} medarbeider",
"migrate_to_limited_backoffice": "Migrer til begrenset backoffice",
"subtitle": "@.capitalize:{'terms.glossary.her'} @:{'terms.glossary.kan'} @:{'terms.glossary.du'} @:{'terms.glossary.se'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.liste'} @:{'terms.glossary.over'} @:{'terms.glossary.alle'} @:{'terms.glossary.ansatte'} @:{'terms.glossary.i'} @:{'terms.glossary.systemet'}"
},
"orders": {
@@ -14,6 +14,31 @@
"transaction_history": "@:{'phrases.compat.superuser.nav.orders'}"
},
"other": "@:{'phrases.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:{'terms.glossary.tillatelser'}",
"user_data": "@.capitalize:{'terms.glossary.brukerdata'}",
"user_id": "@.capitalize:{'terms.glossary.bruker'}-@.upper:{'terms.glossary.id'}",
@@ -2,6 +2,20 @@
"compat": {
"vehicles": {
"add": "@:{'terms.glossary.lagg'} @:{'terms.glossary.till'} @:{'terms.glossary.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": "@:{'phrases.compat.vehicles.delete'}",
+143 -13
View File
@@ -28,16 +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'}",
"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'}",
"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'}"
@@ -126,6 +126,11 @@
"groups": {
"account": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.account'}",
"orders": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.orders'}",
"products": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.products'}",
"customers": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.customers'}",
"vehicles": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.vehicles'}",
"attachments": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.attachments'}",
"scanner": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.scanner'}",
"bookings": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.bookings'}",
"time_bookings": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.time_bookings'}",
"reports": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.reports'}",
@@ -177,6 +182,66 @@
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.charge_orders.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.charge_orders.description'}"
},
"complete_orders": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.complete_orders.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.complete_orders.description'}"
},
"view_product_catalog": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.description'}"
},
"view_product_recommendations": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.description'}"
},
"search_customers": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_customers.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_customers.description'}"
},
"view_customer_details": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.description'}"
},
"view_customer_notes": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.description'}"
},
"add_customer_notes": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.description'}"
},
"view_customer_flags": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.description'}"
},
"search_vehicles": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.description'}"
},
"view_vehicle_matches": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.description'}"
},
"view_vehicle_history": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.description'}"
},
"view_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.description'}"
},
"add_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.description'}"
},
"download_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.description'}"
},
"view_plate_scans": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.description'}"
},
"view_department_bookings": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.description'}"
@@ -338,12 +403,12 @@
},
"roles": {
"viewer": {
"label": "Viser",
"description": "@.capitalize:{'terms.glossary.kan'} logge inn @:{'terms.glossary.og'} se data @:{'terms.glossary.for'} @:{'terms.glossary.tildelte'} @:{'terms.glossary.avdelinger'}."
"label": "Deaktivert",
"description": "Holder den ansatte registrert uten tilgang til ordre-, booking- eller administrasjonsfunksjoner."
},
"cashier": {
"label": "Kasserer",
"description": "@.capitalize:{'terms.glossary.kan'} arbeide med @:{'terms.glossary.ordre'} @:{'terms.glossary.og'} ordrelinjer @:{'terms.glossary.for'} @:{'terms.glossary.tildelte'} @:{'terms.glossary.avdelinger'}."
"description": "@.capitalize:{'terms.glossary.kan'} arbeide med @:{'terms.glossary.ordre'}, ordrelinjer @:{'terms.glossary.og'} @:{'terms.glossary.bookinger'} @:{'terms.glossary.for'} @:{'terms.glossary.tildelte'} @:{'terms.glossary.avdelinger'}."
},
"booking_coordinator": {
"label": "Bookingkoordinator",
@@ -378,6 +443,11 @@
"groups": {
"account": "Konto",
"orders": "Ordrer",
"products": "Produkter",
"customers": "Kunder",
"vehicles": "Kjøretøy",
"attachments": "Vedlegg",
"scanner": "Skannere",
"bookings": "Bookinger",
"time_bookings": "Tidsbookinger",
"reports": "Rapporter",
@@ -429,6 +499,66 @@
"label": "Belast ordrer",
"description": "Kan ta betaling eller belaste en betalingsreservasjon for en ordre."
},
"complete_orders": {
"label": "Fullfør ordrer",
"description": "Kan markere ordrer som fullført."
},
"view_product_catalog": {
"label": "Se produktkatalog",
"description": "Kan laste POS-kategorier, produkter, priser og tillegg."
},
"view_product_recommendations": {
"label": "Se produktforslag",
"description": "Kan se produktforslag basert på ordrer og kjøretøydata."
},
"search_customers": {
"label": "Søk kunder",
"description": "Kan søke og velge kunder for POS-ordrer."
},
"view_customer_details": {
"label": "Se kundeopplysninger",
"description": "Kan laste kundeopplysninger fra et kundenummer."
},
"view_customer_notes": {
"label": "Se kundemerknader",
"description": "Kan lese kundemerknader i POS."
},
"add_customer_notes": {
"label": "Legg til kundemerknader",
"description": "Kan legge til kundemerknader fra POS."
},
"view_customer_flags": {
"label": "Se kundemarkeringer",
"description": "Kan se kundemarkeringer som påvirker ordrekrav og produktregler."
},
"search_vehicles": {
"label": "Søk kjøretøy",
"description": "Kan søke etter registreringsnummer og se kjøretøystatus."
},
"view_vehicle_matches": {
"label": "Se kjøretøytreff",
"description": "Kan se kundetreff for kjøretøy og forslag til ukjente kjøretøy."
},
"view_vehicle_history": {
"label": "Se kjøretøyhistorikk",
"description": "Kan se nylig ordrehistorikk for et registreringsnummer."
},
"view_order_attachments": {
"label": "Se ordrevedlegg",
"description": "Kan liste vedlegg på ordrer."
},
"add_order_attachments": {
"label": "Legg til ordrevedlegg",
"description": "Kan laste opp filer og legge ved vaskesertifikater på ordrer."
},
"download_order_attachments": {
"label": "Last ned ordrevedlegg",
"description": "Kan åpne og laste ned ordrevedlegg."
},
"view_plate_scans": {
"label": "Se nummerskiltskanninger",
"description": "Kan se nylige nummerskiltskanninger og skannere for tildelte avdelinger."
},
"view_department_bookings": {
"label": "Se avdelingsbookinger",
"description": "Kan se bookinger for tildelte avdelinger."
@@ -27,6 +27,14 @@
"subtitle_suffix": "@:{'terms.glossary.produktforsaljning'}",
"title": "@.capitalize:{'terms.glossary.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:{'terms.glossary.antal'} @:{'terms.glossary.slutforda'}",
@@ -21,6 +21,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": "@:{'terms.glossary.sok'} @:{'terms.glossary.efter'} @:{'terms.glossary.avdelningsnamn'}",
"select_department": "@:{'terms.glossary.velg'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.avdeling'}",
"subtitle": "@:{'terms.glossary.administrer'} @:{'terms.glossary.avdelinger'}",
@@ -140,6 +140,7 @@
"departments": {
"columns": {
"archived": "Arkiverad",
"custom_pricing_only": "@:{'phrases.compat.departments.pricing.custom_pricing_only'}",
"dimension": "Dimension",
"economic_department": "@:{'phrases.compat.objects.economic_departments.single'}",
"latitude": "Latitude",
@@ -91,6 +91,22 @@
"subtitle": "Rollbehorigheter",
"title": "Behorigheter for roll {id}",
"title_with_name": "Behorigheter for {name}"
},
"templates": {
"apply": "Anvand mallen {template}",
"confirm_button": "Anvand mall",
"confirm_text": "Lagg till {count} saknade behorigheter fran {template} till {role}?",
"confirm_title": "Anvanda behorighetsmall?",
"error_text": "En eller flera behorigheter fran {template} kunde inte laggas till i {role}. Rolllistan laddades om.",
"error_title": "Kunde inte anvanda mall",
"load_error": "Kunde inte ladda mallar. Forsok igen",
"loading": "Laddar mallar...",
"section": "Begransade backoffice-mallar",
"success_text": "Lade till {count} behorigheter fran {template} i {role}.",
"success_title": "Mall anvand",
"unavailable": "Inga mallar ar tillgangliga.",
"up_to_date_text": "{role} har redan alla behorigheter i {template}.",
"up_to_date_title": "Rollen ar uppdaterad"
}
}
}
@@ -78,6 +78,7 @@
},
"employees": {
"create": "@.capitalize:{'terms.glossary.skapa'} @:{'terms.glossary.ny'} @:{'terms.glossary.anstalld'}",
"migrate_to_limited_backoffice": "Migrera till begränsad backoffice",
"subtitle": "@.capitalize:{'terms.glossary.har_2'} @:{'terms.glossary.kan'} @:{'terms.glossary.du'} @:{'terms.glossary.se'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.lista'} @:{'terms.glossary.over'} @:{'terms.glossary.alla'} @:{'terms.glossary.anstallda'} @:{'terms.glossary.i'} @:{'terms.glossary.systemet'}"
},
"orders": {
@@ -14,6 +14,31 @@
"transaction_history": "@:{'phrases.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:{'terms.glossary.id'}",
@@ -2,6 +2,20 @@
"compat": {
"vehicles": {
"add": "@:{'terms.glossary.lagg'} @:{'terms.glossary.till'} @:{'terms.glossary.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:{'terms.glossary.ta'} @:{'terms.glossary.bort'} @:{'terms.glossary.fordon'}",
+143 -13
View File
@@ -28,16 +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'}",
"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'}",
"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'}"
@@ -126,6 +126,11 @@
"groups": {
"account": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.account'}",
"orders": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.orders'}",
"products": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.products'}",
"customers": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.customers'}",
"vehicles": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.vehicles'}",
"attachments": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.attachments'}",
"scanner": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.scanner'}",
"bookings": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.bookings'}",
"time_bookings": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.time_bookings'}",
"reports": "@:{'phrases.compat.limited_backoffice.role_permissions.groups.reports'}",
@@ -177,6 +182,66 @@
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.charge_orders.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.charge_orders.description'}"
},
"complete_orders": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.complete_orders.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.complete_orders.description'}"
},
"view_product_catalog": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_catalog.description'}"
},
"view_product_recommendations": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_product_recommendations.description'}"
},
"search_customers": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_customers.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_customers.description'}"
},
"view_customer_details": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_details.description'}"
},
"view_customer_notes": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_notes.description'}"
},
"add_customer_notes": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_customer_notes.description'}"
},
"view_customer_flags": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_customer_flags.description'}"
},
"search_vehicles": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.search_vehicles.description'}"
},
"view_vehicle_matches": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.description'}"
},
"view_vehicle_history": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_vehicle_history.description'}"
},
"view_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_order_attachments.description'}"
},
"add_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.add_order_attachments.description'}"
},
"download_order_attachments": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.download_order_attachments.description'}"
},
"view_plate_scans": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_plate_scans.description'}"
},
"view_department_bookings": {
"label": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.label'}",
"description": "@:{'phrases.compat.limited_backoffice.role_permissions.capabilities.view_department_bookings.description'}"
@@ -338,12 +403,12 @@
},
"roles": {
"viewer": {
"label": "Visare",
"description": "@.capitalize:{'terms.glossary.kan'} logga in @:{'terms.glossary.och'} se data @:{'terms.glossary.for'} @:{'terms.glossary.tilldelade'} @:{'terms.glossary.avdelningar'}."
"label": "Inaktiverad",
"description": "Behåller medarbetaren registrerad utan åtkomst till order-, boknings- eller administrationsfunktioner."
},
"cashier": {
"label": "Kassör",
"description": "@.capitalize:{'terms.glossary.kan'} arbeta med @:{'terms.glossary.order'} @:{'terms.glossary.och'} orderrader @:{'terms.glossary.for'} @:{'terms.glossary.tilldelade'} @:{'terms.glossary.avdelningar'}."
"description": "@.capitalize:{'terms.glossary.kan'} arbeta med @:{'terms.glossary.order'}, orderrader @:{'terms.glossary.och'} @:{'terms.glossary.bokningar'} @:{'terms.glossary.for'} @:{'terms.glossary.tilldelade'} @:{'terms.glossary.avdelningar'}."
},
"booking_coordinator": {
"label": "Bokningskoordinator",
@@ -378,6 +443,11 @@
"groups": {
"account": "Konto",
"orders": "Order",
"products": "Produkter",
"customers": "Kunder",
"vehicles": "Fordon",
"attachments": "Bilagor",
"scanner": "Skannrar",
"bookings": "Bokningar",
"time_bookings": "Tidsbokningar",
"reports": "Rapporter",
@@ -429,6 +499,66 @@
"label": "Debitera order",
"description": "Kan ta betalt eller debitera en betalningsreservation för en order."
},
"complete_orders": {
"label": "Slutför ordrar",
"description": "Kan markera ordrar som slutförda."
},
"view_product_catalog": {
"label": "Visa produktkatalog",
"description": "Kan läsa in POS-kategorier, produkter, priser och tillval."
},
"view_product_recommendations": {
"label": "Visa produktförslag",
"description": "Kan se produktförslag baserade på ordrar och fordonsdata."
},
"search_customers": {
"label": "Sök kunder",
"description": "Kan söka och välja kunder för POS-ordrar."
},
"view_customer_details": {
"label": "Visa kunduppgifter",
"description": "Kan läsa in kunduppgifter från ett kundnummer."
},
"view_customer_notes": {
"label": "Visa kundanteckningar",
"description": "Kan läsa kundanteckningar i POS."
},
"add_customer_notes": {
"label": "Lägg till kundanteckningar",
"description": "Kan lägga till kundanteckningar från POS."
},
"view_customer_flags": {
"label": "Visa kundmarkeringar",
"description": "Kan se kundmarkeringar som påverkar orderkrav och produktregler."
},
"search_vehicles": {
"label": "Sök fordon",
"description": "Kan söka registreringsnummer och se fordonsstatus."
},
"view_vehicle_matches": {
"label": "Visa fordonsträffar",
"description": "Kan se kundträffar för fordon och förslag på okända fordon."
},
"view_vehicle_history": {
"label": "Visa fordonshistorik",
"description": "Kan se senaste orderhistorik för ett registreringsnummer."
},
"view_order_attachments": {
"label": "Visa orderbilagor",
"description": "Kan lista bilagor på ordrar."
},
"add_order_attachments": {
"label": "Lägg till orderbilagor",
"description": "Kan ladda upp filer och bifoga tvättcertifikat till ordrar."
},
"download_order_attachments": {
"label": "Ladda ner orderbilagor",
"description": "Kan öppna och ladda ner orderbilagor."
},
"view_plate_scans": {
"label": "Visa nummerskyltsavläsningar",
"description": "Kan se senaste nummerskyltsavläsningar och skannrar för tilldelade avdelningar."
},
"view_department_bookings": {
"label": "Se avdelningsbokningar",
"description": "Kan se bokningar för tilldelade avdelningar."
+6
View File
@@ -442,6 +442,12 @@ export const router = createRouter({
},
{
name: 'limitedBackofficeEmployees',
path: '/backoffice/departments/:departmentId/employees',
component: LimitedBackofficeEmployees,
meta: { middleware: authMiddleware, titleKey: 'templates.limited_backoffice.employees.title' }
},
{
name: 'limitedBackofficeEmployeesLegacy',
path: '/backoffice/employees',
component: LimitedBackofficeEmployees,
meta: { middleware: authMiddleware, titleKey: 'templates.limited_backoffice.employees.title' }
+5 -6
View File
@@ -18,8 +18,10 @@ export const getLimitedBackofficeRoles = () => limitedBackofficeRequest("/roles"
export const getLimitedBackofficeEmployees = ({ includeInactive = false } = {}) =>
limitedBackofficeRequest("/employees", "GET", { include_inactive: includeInactive ? "true" : "false" });
export const createLimitedBackofficeEmployee = (payload) =>
limitedBackofficeRequest("/employees", "POST", payload);
export const createLimitedBackofficeEmployee = (payload) => limitedBackofficeRequest("/employees", "POST", payload);
export const migrateLimitedBackofficeEmployee = (employeeId, payload) =>
limitedBackofficeRequest(`/employees/${encodeURIComponent(String(employeeId))}/migrate`, "POST", payload);
export const updateLimitedBackofficeEmployee = (employeeId, payload) =>
limitedBackofficeRequest(`/employees/${encodeURIComponent(String(employeeId))}`, "PUT", payload);
@@ -33,7 +35,4 @@ export const deactivateLimitedBackofficeEmployee = (employeeId) =>
export const unwrapLimitedBackofficeResponse = (response) => response?.data?.data ?? response?.data ?? null;
export const limitedBackofficeErrorMessage = (error, fallback) =>
error?.response?.data?.data?.message ||
error?.response?.data?.message ||
error?.message ||
fallback;
error?.response?.data?.data?.message || error?.response?.data?.message || error?.message || fallback;
@@ -1,6 +1,7 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import LimitedBackofficeLayout from "@/views/backoffice/components/LimitedBackofficeLayout.vue";
@@ -33,6 +34,14 @@ const loadQRCodeModule = () => {
};
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const countryCodeLabels = {
denmark: () => t("templates.limited_backoffice.employees.country_codes.denmark"),
sweden: () => t("templates.limited_backoffice.employees.country_codes.sweden"),
norway: () => t("templates.limited_backoffice.employees.country_codes.norway"),
finland: () => t("templates.limited_backoffice.employees.country_codes.finland"),
};
const departments = ref([]);
const roles = ref([]);
const employees = ref([]);
@@ -60,6 +69,19 @@ const form = ref({
department_ids: [],
});
const selectedDepartmentId = computed(() => {
const departmentId = Number.parseInt(String(route.params.departmentId ?? ""), 10);
return Number.isInteger(departmentId) && departmentId > 0 ? departmentId : null;
});
const selectedDepartment = computed(
() => departments.value.find((department) => Number(department.id) === selectedDepartmentId.value) || null
);
const hasForbiddenDepartment = computed(
() => selectedDepartmentId.value !== null && departments.value.length > 0 && !selectedDepartment.value
);
const roleMessages = computed(() => ({
viewer: {
label: t("templates.limited_backoffice.roles.viewer.label"),
@@ -90,6 +112,21 @@ const rolePermissionGroupMessages = computed(() => ({
orders: {
label: t("templates.limited_backoffice.role_permissions.groups.orders"),
},
products: {
label: t("templates.limited_backoffice.role_permissions.groups.products"),
},
customers: {
label: t("templates.limited_backoffice.role_permissions.groups.customers"),
},
vehicles: {
label: t("templates.limited_backoffice.role_permissions.groups.vehicles"),
},
attachments: {
label: t("templates.limited_backoffice.role_permissions.groups.attachments"),
},
scanner: {
label: t("templates.limited_backoffice.role_permissions.groups.scanner"),
},
bookings: {
label: t("templates.limited_backoffice.role_permissions.groups.bookings"),
},
@@ -132,6 +169,10 @@ const roleCapabilityMessages = computed(() => ({
label: t("templates.limited_backoffice.role_permissions.capabilities.delete_orders.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.delete_orders.description"),
},
complete_orders: {
label: t("templates.limited_backoffice.role_permissions.capabilities.complete_orders.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.complete_orders.description"),
},
view_order_items: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_order_items.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.view_order_items.description"),
@@ -152,6 +193,64 @@ const roleCapabilityMessages = computed(() => ({
label: t("templates.limited_backoffice.role_permissions.capabilities.charge_orders.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.charge_orders.description"),
},
view_product_catalog: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_product_catalog.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.view_product_catalog.description"),
},
view_product_recommendations: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_product_recommendations.label"),
description: t(
"templates.limited_backoffice.role_permissions.capabilities.view_product_recommendations.description"
),
},
search_customers: {
label: t("templates.limited_backoffice.role_permissions.capabilities.search_customers.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.search_customers.description"),
},
view_customer_details: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_customer_details.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.view_customer_details.description"),
},
view_customer_notes: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_customer_notes.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.view_customer_notes.description"),
},
add_customer_notes: {
label: t("templates.limited_backoffice.role_permissions.capabilities.add_customer_notes.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.add_customer_notes.description"),
},
view_customer_flags: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_customer_flags.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.view_customer_flags.description"),
},
search_vehicles: {
label: t("templates.limited_backoffice.role_permissions.capabilities.search_vehicles.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.search_vehicles.description"),
},
view_vehicle_matches: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.view_vehicle_matches.description"),
},
view_vehicle_history: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_vehicle_history.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.view_vehicle_history.description"),
},
view_order_attachments: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_order_attachments.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.view_order_attachments.description"),
},
add_order_attachments: {
label: t("templates.limited_backoffice.role_permissions.capabilities.add_order_attachments.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.add_order_attachments.description"),
},
download_order_attachments: {
label: t("templates.limited_backoffice.role_permissions.capabilities.download_order_attachments.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.download_order_attachments.description"),
},
view_plate_scans: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_plate_scans.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.view_plate_scans.description"),
},
view_department_bookings: {
label: t("templates.limited_backoffice.role_permissions.capabilities.view_department_bookings.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.view_department_bookings.description"),
@@ -182,7 +281,9 @@ const roleCapabilityMessages = computed(() => ({
},
create_time_booking_entries: {
label: t("templates.limited_backoffice.role_permissions.capabilities.create_time_booking_entries.label"),
description: t("templates.limited_backoffice.role_permissions.capabilities.create_time_booking_entries.description"),
description: t(
"templates.limited_backoffice.role_permissions.capabilities.create_time_booking_entries.description"
),
},
edit_time_booking_entries: {
label: t("templates.limited_backoffice.role_permissions.capabilities.edit_time_booking_entries.label"),
@@ -256,7 +357,7 @@ const countryCodeLabel = (countryCode) => {
if (!option) {
return "";
}
return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;
return `${option.flag} +${option.value} ${countryCodeLabels[option.labelKey]?.() || ""}`;
};
const formatEmployeePhone = (employee) => {
@@ -272,11 +373,23 @@ const sanitizePhone = (event) => {
const absoluteLoginLink = (loginPath) => new URL(String(loginPath || ""), window.location.origin).toString();
const visibleEmployees = computed(() => {
if (!selectedDepartmentId.value) {
return employees.value;
}
return employees.value.filter((employee) =>
(employee.departments || []).some((department) => Number(department.id) === selectedDepartmentId.value)
);
});
const loadEmployees = async () => {
const response = await getLimitedBackofficeEmployees({ includeInactive: includeInactive.value });
employees.value = unwrapLimitedBackofficeResponse(response) || [];
};
const defaultDepartmentIds = () => (selectedDepartment.value?.id ? [Number(selectedDepartment.value.id)] : []);
const loadData = async () => {
loading.value = true;
errorMessage.value = "";
@@ -291,12 +404,21 @@ const loadData = async () => {
if (!ROLE_KEYS.includes(form.value.role_key) || !roles.value.some((role) => role.key === form.value.role_key)) {
form.value.role_key = roles.value[0]?.key || "viewer";
}
const firstDepartment = departments.value[0] || null;
if (!selectedDepartmentId.value && firstDepartment?.id) {
await router.replace(`/backoffice/departments/${firstDepartment.id}/employees`);
}
resetForm();
if (hasForbiddenDepartment.value) {
employees.value = [];
return;
}
await loadEmployees();
} catch (error) {
errorMessage.value = limitedBackofficeErrorMessage(
error,
t("templates.limited_backoffice.errors.load_employees")
);
errorMessage.value = limitedBackofficeErrorMessage(error, t("templates.limited_backoffice.errors.load_employees"));
} finally {
loading.value = false;
}
@@ -312,7 +434,7 @@ const resetForm = () => {
phone: "",
password: "",
role_key: roles.value[0]?.key || "viewer",
department_ids: [],
department_ids: defaultDepartmentIds(),
};
};
@@ -382,10 +504,7 @@ const saveEmployee = async () => {
resetForm();
await loadEmployees();
} catch (error) {
errorMessage.value = limitedBackofficeErrorMessage(
error,
t("templates.limited_backoffice.errors.save_employee")
);
errorMessage.value = limitedBackofficeErrorMessage(error, t("templates.limited_backoffice.errors.save_employee"));
} finally {
saving.value = false;
}
@@ -479,16 +598,22 @@ const closeEmployeeLoginLink = () => {
loginLinkCopyMessage.value = "";
};
const changeDepartment = async (departmentId) => {
await router.push(`/backoffice/departments/${departmentId}/employees`);
};
const reloadEmployees = async () => {
if (hasForbiddenDepartment.value) {
employees.value = [];
return;
}
loading.value = true;
errorMessage.value = "";
try {
await loadEmployees();
} catch (error) {
errorMessage.value = limitedBackofficeErrorMessage(
error,
t("templates.limited_backoffice.errors.load_employees")
);
errorMessage.value = limitedBackofficeErrorMessage(error, t("templates.limited_backoffice.errors.load_employees"));
} finally {
loading.value = false;
}
@@ -497,12 +622,40 @@ const reloadEmployees = async () => {
onMounted(() => {
void loadData();
});
watch(
() => selectedDepartmentId.value,
async (nextDepartmentId, previousDepartmentId) => {
if (nextDepartmentId === previousDepartmentId || loading.value || departments.value.length === 0) {
return;
}
resetForm();
await reloadEmployees();
}
);
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.hasPermission('limited_backoffice_access')">
<LimitedBackofficeLayout active-tab="employees" :departments="departments">
<div class="columns is-variable is-5">
<LimitedBackofficeLayout
active-tab="employees"
:departments="departments"
:selected-department-id="selectedDepartmentId"
:loading-departments="loading"
show-department-switcher
@change-department="changeDepartment"
>
<div
v-if="hasForbiddenDepartment"
class="notification is-danger is-light"
data-testid="limited-employees-forbidden"
>
<strong>{{ t("templates.limited_backoffice.forbidden.title") }}</strong>
<p>{{ t("templates.limited_backoffice.forbidden.department") }}</p>
</div>
<div v-else class="columns is-variable is-5">
<div class="column is-5">
<form class="box limited-employee-form" data-testid="limited-employee-form" @submit.prevent="saveEmployee">
<h2 class="title is-5">
@@ -667,7 +820,11 @@ onMounted(() => {
</p>
</div>
<div v-if="formAttempted && !isFormValid" class="notification is-danger is-light" data-testid="limited-employee-validation">
<div
v-if="formAttempted && !isFormValid"
class="notification is-danger is-light"
data-testid="limited-employee-validation"
>
{{ t("templates.limited_backoffice.employees.validation") }}
</div>
@@ -726,12 +883,19 @@ onMounted(() => {
{{ successMessage }}
</div>
<div v-if="!loading && employees.length === 0" class="notification is-light" data-testid="limited-employees-empty">
<div
v-if="!loading && visibleEmployees.length === 0"
class="notification is-light"
data-testid="limited-employees-empty"
>
{{ t("templates.limited_backoffice.employees.empty") }}
</div>
<div v-if="!loading && employees.length > 0" class="table-container">
<table class="table is-fullwidth is-hoverable limited-employees-table" data-testid="limited-employees-table">
<div v-if="!loading && visibleEmployees.length > 0" class="table-container">
<table
class="table is-fullwidth is-hoverable limited-employees-table"
data-testid="limited-employees-table"
>
<thead>
<tr>
<th>{{ t("templates.limited_backoffice.employees.user_id") }}</th>
@@ -739,11 +903,17 @@ onMounted(() => {
<th>{{ t("templates.limited_backoffice.employees.role") }}</th>
<th>{{ t("templates.limited_backoffice.employees.departments") }}</th>
<th>{{ t("templates.limited_backoffice.employees.status") }}</th>
<th class="limited-employees-table__actions">{{ t("templates.limited_backoffice.employees.actions") }}</th>
<th class="limited-employees-table__actions">
{{ t("templates.limited_backoffice.employees.actions") }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="employee in employees" :key="employee.id" :data-testid="`limited-employee-row-${employee.id}`">
<tr
v-for="employee in visibleEmployees"
:key="employee.id"
:data-testid="`limited-employee-row-${employee.id}`"
>
<td>
<span class="tag is-light" :data-testid="`limited-employee-user-id-${employee.id}`">
{{ employee.user_id || employee.id }}
@@ -765,7 +935,11 @@ onMounted(() => {
<p class="has-text-grey is-size-7">{{ roleDescription(employee.role) }}</p>
</td>
<td>
<span v-for="department in employee.departments" :key="department.id" class="tag is-light mr-1 mb-1">
<span
v-for="department in employee.departments"
:key="department.id"
class="tag is-light mr-1 mb-1"
>
{{ department.name }}
</span>
</td>
@@ -17,6 +17,10 @@ const departments = ref([]);
const loading = ref(false);
const errorMessage = ref("");
const changeDepartment = async (departmentId) => {
await router.push(`/backoffice/departments/${departmentId}/prices`);
};
const loadDepartments = async () => {
loading.value = true;
errorMessage.value = "";
@@ -38,10 +42,6 @@ const loadDepartments = async () => {
}
};
const changeDepartment = async (departmentId) => {
await router.push(`/backoffice/departments/${departmentId}/prices`);
};
onMounted(() => {
void loadDepartments();
});
@@ -35,6 +35,12 @@ const pricesRoute = computed(() =>
: "/backoffice"
);
const employeesRoute = computed(() =>
props.selectedDepartmentId
? `/backoffice/departments/${encodeURIComponent(String(props.selectedDepartmentId))}/employees`
: "/backoffice/employees"
);
const tabs = computed(() => [
{
key: "prices",
@@ -44,7 +50,7 @@ const tabs = computed(() => [
{
key: "employees",
label: t("templates.limited_backoffice.nav.employees"),
to: "/backoffice/employees",
to: employeesRoute.value,
},
]);
@@ -62,8 +68,37 @@ const selectDepartment = (event) => {
<section class="section limited-backoffice" data-testid="limited-backoffice">
<PageTitle
:title="t('templates.limited_backoffice.title')"
:subtitle="t('templates.limited_backoffice.subtitle')"
/>
>
<template #buttons>
<div v-if="props.showDepartmentSwitcher" class="field limited-backoffice__department-field">
<label class="label is-sr-only" for="limited-backoffice-department">
{{ t("templates.limited_backoffice.department") }}
</label>
<div class="control">
<div class="select is-small is-fullwidth">
<select
id="limited-backoffice-department"
:value="props.selectedDepartmentId || ''"
:disabled="props.loadingDepartments || props.departments.length === 0"
data-testid="limited-backoffice-department-select"
@change="selectDepartment"
>
<option value="" disabled>
{{ t("templates.limited_backoffice.select_department") }}
</option>
<option v-for="department in props.departments" :key="department.id" :value="department.id">
{{ department.name }}
</option>
</select>
</div>
</div>
</div>
</template>
<p class="is-size-6 limited-backoffice__subtitle">
{{ t("templates.limited_backoffice.subtitle") }}
</p>
</PageTitle>
<div class="limited-backoffice__toolbar">
<div class="tabs is-toggle is-small limited-backoffice__tabs" data-testid="limited-backoffice-tabs">
@@ -75,30 +110,6 @@ const selectDepartment = (event) => {
</li>
</ul>
</div>
<div v-if="props.showDepartmentSwitcher" class="field limited-backoffice__department-field">
<label class="label" for="limited-backoffice-department">
{{ t("templates.limited_backoffice.department") }}
</label>
<div class="control">
<div class="select is-fullwidth">
<select
id="limited-backoffice-department"
:value="props.selectedDepartmentId || ''"
:disabled="props.loadingDepartments || props.departments.length === 0"
data-testid="limited-backoffice-department-select"
@change="selectDepartment"
>
<option value="" disabled>
{{ t("templates.limited_backoffice.select_department") }}
</option>
<option v-for="department in props.departments" :key="department.id" :value="department.id">
{{ department.name }}
</option>
</select>
</div>
</div>
</div>
</div>
<slot />
@@ -112,10 +123,8 @@ const selectDepartment = (event) => {
.limited-backoffice__toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
margin-bottom: 1.25rem;
}
@@ -123,8 +132,12 @@ const selectDepartment = (event) => {
margin-bottom: 0;
}
.limited-backoffice__subtitle {
margin-top: -0.75rem;
}
.limited-backoffice__department-field {
min-width: min(100%, 260px);
min-width: min(42vw, 280px);
margin-bottom: 0;
}
@@ -138,11 +151,18 @@ const selectDepartment = (event) => {
align-items: stretch;
}
.limited-backoffice__tabs,
.limited-backoffice__department-field {
.limited-backoffice__tabs {
width: 100%;
}
.limited-backoffice__department-field {
min-width: 9rem;
}
.limited-backoffice__subtitle {
margin-top: -0.5rem;
}
.limited-backoffice__tabs :deep(ul) {
width: 100%;
}
+73 -2
View File
@@ -2,9 +2,10 @@
import SessionUser from "@/components/session/token/SessionUser.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import SubuserGrantSelector from "@/components/session/subuser/SubuserGrantSelector.vue";
import {ref, onMounted, nextTick, computed, onUnmounted} from "vue";
import {ref, onMounted, nextTick, computed, onUnmounted, watch} from "vue";
import { IS_DEV } from '@/config.js';
import {BLoading} from "buefy";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
/**
* Subuser grant selection gate
@@ -76,6 +77,59 @@ const canShowVehicleShortcut = computed(() => SessionUser.canAccessCustomerFeatu
const canShowOrderShortcut = computed(() => SessionUser.canAccessCustomerFeature("orders", "list"));
const canShowSelfServeShortcut = computed(() => SessionUser.canAccessCustomerFeature("selfserve", "list"));
const canShowInvoiceShortcut = computed(() => canShowClassicCustomerShortcut());
const hasInvoicesAvailable = ref(false);
const isCheckingInvoiceAvailability = ref(true);
const canDownloadInvoices = computed(() => canShowInvoiceShortcut.value && hasInvoicesAvailable.value && !isCheckingInvoiceAvailability.value);
const responseHasInvoices = (response: any) => {
const total = Number(response?.data?.meta?.pagination?.total);
if (Number.isFinite(total) && total > 0) {
return true;
}
return Array.isArray(response?.data?.data) && response.data.data.length > 0;
};
const resetInvoiceAvailability = () => {
hasInvoicesAvailable.value = false;
isCheckingInvoiceAvailability.value = false;
};
const loadInvoiceAvailability = async () => {
if (!canShowInvoiceShortcut.value) {
resetInvoiceAvailability();
return;
}
isCheckingInvoiceAvailability.value = true;
try {
const response = await authenticatedRequest("/user/invoices", "GET", {
page: 1,
limit: 1,
order: "created_at:desc",
});
hasInvoicesAvailable.value = responseHasInvoices(response);
} catch (_error) {
hasInvoicesAvailable.value = false;
} finally {
isCheckingInvoiceAvailability.value = false;
setEqualHeights();
}
};
watch(
canShowInvoiceShortcut,
(canShow) => {
if (canShow) {
void loadInvoiceAvailability();
return;
}
resetInvoiceAvailability();
},
{ immediate: true }
);
const windowInnerWidth = ref(window.innerWidth);
const updateWindowInnerWidth = () => {
@@ -291,9 +345,26 @@ const isPermissionsLoading = computed(() => {
</template>
<template #footer>
<div class="card-footer-item">
<router-link to="/user/invoices" class="button is-link is-fullwidth" :class="classes.button" id="download-invoices-button">
<router-link
v-if="canDownloadInvoices"
to="/user/invoices"
class="button is-link is-fullwidth"
:class="classes.button"
id="download-invoices-button"
>
{{ $t('user_home.download_invoices') }}
</router-link>
<button
v-else
class="button is-link is-fullwidth"
:class="[classes.button, { 'is-loading': isCheckingInvoiceAvailability }]"
id="download-invoices-button"
type="button"
disabled
aria-disabled="true"
>
{{ $t('user_home.download_invoices') }}
</button>
</div>
</template>
</WhiteBoxCard>
@@ -370,8 +370,12 @@ const isInvoiced = () => {
return isInvoicedWithEconomic() || isInvoicedWithStripe();
};
const isBookedWithEconomic = () => {
return economicModule.value.invoice_id !== null;
const hasEconomicInvoiceDownloadContext = () => {
const module = economicModule.value || {};
return (
Object.prototype.hasOwnProperty.call(module, 'invoice_id') ||
Object.prototype.hasOwnProperty.call(module, 'invoice_draft_id')
);
};
const isCompleted = () => {
@@ -1376,7 +1380,11 @@ const isDisplayingReceipt = () => {
<template #rail-actions>
<ButtonsBox class="pos-actions pos-actions--rail">
<GetOrderInvoicePDFButton :invoice_id="economicModule.invoice_id" class="is-fullwidth" v-if="isBookedWithEconomic()" />
<GetOrderInvoicePDFButton
:invoice_id="economicModule.invoice_id"
class="is-fullwidth"
v-if="hasEconomicInvoiceDownloadContext()"
/>
<div
v-if="isEconomicExportBlocked"
class="message is-warning is-light"
@@ -77,6 +77,15 @@ const normalizeMetricState = (metric, fallbackState = "ready", fallbackMessage =
message: metric?.message || fallbackMessage,
});
const normalizeNullableNumber = (value) => {
if (value === null || value === undefined || value === "") {
return null;
}
const normalized = Number(value);
return Number.isFinite(normalized) ? normalized : null;
};
const resetOverviewState = () => {
count_transactions.value = 0;
count_products_sold.value = 0;
@@ -149,6 +158,8 @@ const applyOverviewResponse = (overview) => {
value: Number(product.value || 0),
out_of: Number(product.out_of || 0),
state: product.state || "ready",
target_percentage: normalizeNullableNumber(product.target_percentage),
target_department_id: normalizeNullableNumber(product.target_department_id),
},
])
);
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue';
import { ref, watch, onMounted, computed, nextTick } from 'vue';
import {BSkeleton} from "buefy";
import { useI18n } from 'vue-i18n';
@@ -76,6 +76,18 @@ const props = defineProps({
type: Function,
default: () => {}
},
canEditTarget: {
type: Boolean,
default: false,
},
targetPercentage: {
type: [Number, String],
default: null,
},
saveTargetFunction: {
type: Function,
default: null,
},
styleContent: {
type: Object,
default: () => {
@@ -84,6 +96,12 @@ const props = defineProps({
}
});
const percentageElement = ref<HTMLElement|null>(null);
const targetInputElement = ref<HTMLInputElement|null>(null);
const isTargetEditorOpen = ref(false);
const targetInputValue = ref("");
const isSavingTarget = ref(false);
const targetError = ref("");
const getPercentage = () => {
if (props.state !== "ready") {
return null;
@@ -93,6 +111,91 @@ const getPercentage = () => {
}
return null;
}
const formatPercentageValue = (value: number) => `${value.toFixed(1)}%`;
const normalizedTargetPercentage = computed(() => {
if (props.targetPercentage === null || props.targetPercentage === undefined || props.targetPercentage === "") {
return null;
}
const numericTarget = Number(props.targetPercentage);
return Number.isFinite(numericTarget) ? numericTarget : null;
});
const hasTargetPercentage = computed(() => normalizedTargetPercentage.value !== null);
const formattedTargetPercentage = computed(() => (
normalizedTargetPercentage.value === null ? "" : formatPercentageValue(normalizedTargetPercentage.value)
));
const canUseTargetEditor = computed(() => Boolean(
props.canEditTarget && props.saveTargetFunction && getPercentage() !== null && !props.isLoading
));
const percentageButtonTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-percentage-button` : null));
const targetButtonTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-target-button` : null));
const targetInputTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-target-input` : null));
const targetSaveTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-target-save` : null));
const targetCancelTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-target-cancel` : null));
const targetErrorTestid = computed(() => (props.dataTestid ? `${props.dataTestid}-target-error` : null));
const openTargetEditor = async () => {
if (!canUseTargetEditor.value) {
return;
}
targetInputValue.value = normalizedTargetPercentage.value === null
? ""
: String(normalizedTargetPercentage.value);
targetError.value = "";
isTargetEditorOpen.value = true;
await nextTick();
targetInputElement.value?.focus();
autoAdjustedFontSizeTitle.value = determineTitleFontSize(props.title);
};
const closeTargetEditor = () => {
targetError.value = "";
isTargetEditorOpen.value = false;
nextTick(() => {
autoAdjustedFontSizeTitle.value = determineTitleFontSize(props.title);
});
};
const parseTargetInputValue = () => {
const rawValue = String(targetInputValue.value || "").trim().replace(",", ".");
if (rawValue === "") {
return { valid: true, value: null };
}
const numericTarget = Number(rawValue);
if (!Number.isFinite(numericTarget) || numericTarget < 0 || numericTarget > 100) {
return { valid: false, value: null };
}
return { valid: true, value: Math.round(numericTarget * 10) / 10 };
};
const saveTarget = async () => {
if (!canUseTargetEditor.value || isSavingTarget.value) {
return;
}
const parsedTarget = parseTargetInputValue();
if (!parsedTarget.valid) {
targetError.value = t("admin.daily_report.product_targets.validation_range");
return;
}
isSavingTarget.value = true;
targetError.value = "";
try {
await props.saveTargetFunction(parsedTarget.value);
closeTargetEditor();
} catch (error) {
console.error("Failed to save daily report product target", error);
targetError.value = t("admin.daily_report.product_targets.save_error");
} finally {
isSavingTarget.value = false;
}
};
const getAveragePercentage = () => {
return null; // TODO: Average percentage
}
@@ -166,6 +269,11 @@ watch(() => props.count, (newVal) => {
autoAdjustedFontSize.value = determineFontSize(newVal);
determineTitleFontSize(props.title);
}, { immediate: true });
watch(() => [props.targetPercentage, isTargetEditorOpen.value], () => {
nextTick(() => {
autoAdjustedFontSizeTitle.value = determineTitleFontSize(props.title);
});
}, { immediate: true });
onMounted(() => {
autoAdjustedFontSizeTitle.value = determineTitleFontSize(props.title);
@@ -180,12 +288,19 @@ const isUnavailable = computed(() => props.state === "unavailable");
<template>
<div class="card" @click="clickFunction" :data-testid="dataTestid || null">
<div class="card-content" :style="styleContent">
<div class="is-pulled-right has-text-right is-flex is-flex-direction-column is-4" style="gap: 4px;" v-if="getPercentage() !== null" ref="percentageElement">
<button class="button is-small is-info" :class="{
<div class="daily-report-count__percentage-stack has-text-right is-flex is-flex-direction-column is-4" v-if="getPercentage() !== null" ref="percentageElement">
<button
type="button"
class="button is-small is-info"
:class="{
'has-background-success has-text-black': getAveragePercentage() !== null && isAverage(),
'has-background-danger has-text-black': getAveragePercentage() !== null && !isAverage(),
'is-outlined': getAveragePercentage() === null
}">
}"
:aria-label="canUseTargetEditor ? t('admin.daily_report.product_targets.edit_aria') : null"
:data-testid="percentageButtonTestid"
@click.stop="openTargetEditor"
>
<!--<span class="icon is-small">
<i class="fas fa-sync-alt"></i>
</span>-->
@@ -197,6 +312,75 @@ const isUnavailable = computed(() => props.state === "unavailable");
</template>
</button>
<form
v-if="isTargetEditorOpen"
class="daily-report-count__target-editor"
@submit.prevent.stop="saveTarget"
@click.stop
>
<div class="field has-addons daily-report-count__target-editor-controls">
<div class="control">
<input
ref="targetInputElement"
v-model="targetInputValue"
class="input is-small daily-report-count__target-input"
type="number"
min="0"
max="100"
step="0.1"
inputmode="decimal"
:aria-label="t('admin.daily_report.product_targets.input_label')"
:data-testid="targetInputTestid"
>
</div>
<div class="control">
<button
class="button is-small is-success"
type="submit"
:class="{ 'is-loading': isSavingTarget }"
:aria-label="t('admin.daily_report.product_targets.save_aria')"
:data-testid="targetSaveTestid"
>
<span class="icon is-small">
<i class="fas fa-check"></i>
</span>
</button>
</div>
<div class="control">
<button
class="button is-small"
type="button"
:aria-label="t('admin.daily_report.product_targets.cancel_aria')"
:data-testid="targetCancelTestid"
@click.stop="closeTargetEditor"
>
<span class="icon is-small">
<i class="fas fa-times"></i>
</span>
</button>
</div>
</div>
<p
v-if="targetError"
class="help is-danger daily-report-count__target-error"
:data-testid="targetErrorTestid"
>
{{ targetError }}
</p>
</form>
<button
v-else-if="hasTargetPercentage"
type="button"
class="button is-small is-info is-outlined daily-report-count__target-button"
:aria-label="canUseTargetEditor ? t('admin.daily_report.product_targets.edit_aria') : null"
:data-testid="targetButtonTestid"
@click.stop="openTargetEditor"
>
<i class="fas fa-bullseye daily-report-count__target-background-icon" aria-hidden="true"></i>
<span class="daily-report-count__target-value">{{ formattedTargetPercentage }}</span>
</button>
<button v-if="getAveragePercentage()" class="button is-small is-info is-outlined">
<!--<span class="icon is-small">
<i class="fas fa-sync-alt"></i>
@@ -282,5 +466,56 @@ const isUnavailable = computed(() => props.state === "unavailable");
</template>
<style scoped>
.card-content {
position: relative;
}
.daily-report-count__percentage-stack {
align-items: flex-end;
gap: 4px;
position: absolute;
right: 1.25rem;
top: 1.25rem;
z-index: 2;
}
.daily-report-count__target-button {
min-width: 64px;
overflow: hidden;
position: relative;
}
.daily-report-count__target-background-icon {
font-size: 1.35rem;
left: 50%;
opacity: 0.16;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
}
.daily-report-count__target-value {
position: relative;
z-index: 1;
}
.daily-report-count__target-editor {
width: 154px;
}
.daily-report-count__target-editor-controls {
justify-content: flex-end;
margin-bottom: 0;
}
.daily-report-count__target-input {
max-width: 68px;
text-align: right;
}
.daily-report-count__target-error {
max-width: 154px;
white-space: normal;
}
</style>
@@ -1,9 +1,16 @@
<script setup>
import { computed } from "vue";
import { Colors } from "@/ThemeConfig.vue";
import { daily_report_products } from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
daily_report_products,
refreshOverview,
selected_department_ids,
} from "@/views/dashboards/departmentDashboard/modules/daily-report/DepartmentDailyReportObject.vue";
import DepartmentDashboardDailyReportCount from "@/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportCount.vue";
const SET_PRODUCT_TARGET_PERMISSION = "set_department_daily_report_product_targets";
const FALLBACK_PRODUCT_TITLES = {
24: "Spot Free (Lastbil)",
25: "Fælg flex pr. enhed",
@@ -51,6 +58,8 @@ const productTile = computed(() => {
out_of: 0,
state: "ready",
message: null,
target_percentage: null,
target_department_id: null,
};
});
@@ -70,6 +79,59 @@ const resolvedTitle = computed(() => {
return FALLBACK_PRODUCT_TITLES[props.product_id] || null;
});
const selectedDepartmentId = computed(() => {
const selectedDepartmentIds = Array.isArray(selected_department_ids.value)
? selected_department_ids.value.map((departmentId) => Number(departmentId)).filter((departmentId) => departmentId > 0)
: [];
return selectedDepartmentIds.length === 1 ? selectedDepartmentIds[0] : null;
});
const targetPercentage = computed(() => {
const target = productTile.value?.target_percentage;
if (target === null || target === undefined || target === "") {
return null;
}
const numericTarget = Number(target);
return Number.isFinite(numericTarget) ? numericTarget : null;
});
const canEditProductTarget = computed(() => Boolean(
selectedDepartmentId.value !== null && SessionUser.hasPermission(SET_PRODUCT_TARGET_PERMISSION)
));
const saveProductTarget = async (nextTargetPercentage) => {
const departmentId = selectedDepartmentId.value;
if (departmentId === null) {
return null;
}
const response = await SessionUser.objects.department_daily_reports.functions.setProductTarget({
department_id: departmentId,
product_id: props.product_id,
target_percentage: nextTargetPercentage,
});
const savedTarget = response?.data?.data?.target_percentage ?? nextTargetPercentage;
const normalizedTarget = savedTarget === null || savedTarget === undefined || savedTarget === ""
? null
: Number(savedTarget);
daily_report_products.value = {
...daily_report_products.value,
[props.product_id]: {
...productTile.value,
product_id: props.product_id,
target_percentage: Number.isFinite(normalizedTarget) ? normalizedTarget : null,
target_department_id: Number.isFinite(normalizedTarget) ? departmentId : null,
},
};
await refreshOverview();
return response;
};
</script>
<template>
@@ -83,6 +145,9 @@ const resolvedTitle = computed(() => {
:isLoading="is_loading"
:state="productTile.state || 'ready'"
:unavailable-message="productTile.message || 'Ikke tilgængelig'"
:can-edit-target="canEditProductTarget"
:target-percentage="targetPercentage"
:save-target-function="saveProductTarget"
:style-content="styleContent"
:data-testid="dataTestid"
/>
@@ -14,6 +14,16 @@ import NotificationsPhonePagination
:title="$t('admin.notifications.title')"
:subtitle="$t('admin.notifications.subtitle')"
>
<header class="mb-5" data-testid="department-notifications-page-header">
<h1 class="title is-3 mb-2" data-testid="department-notifications-title">
{{ $t('admin.notifications.title') }}
</h1>
<p class="subtitle is-6 mb-0" data-testid="department-notifications-subtitle">
<span>{{ $t('admin.notifications.sms_overview') }}</span>
<br>
<span>{{ $t('admin.notifications.sms_activated_description') }}</span>
</p>
</header>
<NotFoundFallBackPageWrapper :exists="SessionUser.functions.getDepartmentIdFromUrl() && SessionUser.canAccessDepartment(SessionUser.functions.getDepartmentIdFromUrl())" :error="$t('admin.errors.select_department')">
<element-tabs-box
defaultActiveTab="sms"
@@ -24,16 +34,6 @@ import NotificationsPhonePagination
]"
>
<template #sms>
<!-- What is this? -->
<div class="message">
<div class="message-header">
<p>{{ $t('admin.notifications.sms_notifications') }}</p>
</div>
<div class="message-body">
<p>{{ $t('admin.notifications.sms_overview') }}</p>
<p>{{ $t('admin.notifications.sms_activated_description') }}</p>
</div>
</div>
<NotificationsPhonePagination />
</template>
</element-tabs-box>
@@ -44,4 +44,4 @@ import NotificationsPhonePagination
<style scoped>
</style>
</style>
@@ -1,45 +1,45 @@
<script setup>
import { useRouter} from "vue-router";
import { ref } from 'vue';
import { showAuthSignOutForm } from '@/components/forms/auth/authSignOutForm.vue';
import UserNavigation from "@/components/global/UserNavigation.vue";
import { computed } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const router = useRouter();
// Get the user from the route
const userId = ref(router.currentRoute.value.params.userId)
const tabs = [
{ name: 'Overview', path: '/superuser/users/' + userId.value },
{ name: 'Orders', path: '/superuser/users/' + userId.value + '/orders' },
{ name: 'Pricing', path: '/superuser/users/' + userId.value + '/pricing' },
{ name: 'Other', path: '/superuser/users/' + userId.value + '/other' },
{ name: 'Vehicles', path: '/superuser/users/' + userId.value + '/vehicles' },
{ name: SessionUser.superUser.modules.xlvask.meta.title, path: '/superuser/users/' + userId.value + '/xlvask' },
];
const route = useRoute();
const { t } = useI18n();
// Get the current path
const currentPath = ref(router.currentRoute.value.path);
const userId = computed(() => route.params.userId);
const basePath = computed(() => `/superuser/users/${userId.value}`);
// Get the index of the active tab
const activeTab = tabs.findIndex(tab => tab.path === currentPath.value);
// Change the tab
const changeTab = (index) => {
router.push(tabs[index].path);
};
const tabs = computed(() => [
{ key: "overview", name: t("nav.overview"), path: basePath.value },
{ key: "orders", name: t("superuser.nav.orders"), path: `${basePath.value}/orders` },
{ key: "pricing", name: t("user_admin.overview.pricing"), path: `${basePath.value}/pricing` },
{ key: "other", name: t("user_admin.other"), path: `${basePath.value}/other` },
{ key: "vehicles", name: t("common.vehicles"), path: `${basePath.value}/vehicles` },
{ key: "xlvask", name: SessionUser.superUser.modules.xlvask.meta.title, path: `${basePath.value}/xlvask` },
]);
</script>
<template>
<div>
<div class="tabs is-right">
<nav class="user-detail-tabs" data-testid="superuser-user-tabs">
<div class="tabs is-right is-boxed">
<ul>
<li v-for="(tab, index) in tabs" :key="index" :class="{'is-active': activeTab === index}" @click="changeTab(index)">
<a>{{ tab.name }}</a>
<li v-for="tab in tabs" :key="tab.path" :class="{ 'is-active': route.path === tab.path }">
<router-link :to="tab.path" :data-testid="`superuser-user-tab-${tab.key}`">
{{ tab.name }}
</router-link>
</li>
</ul>
</div>
</div>
</nav>
</template>
<style scoped>
</style>
.user-detail-tabs {
overflow-x: auto;
}
.tabs ul {
flex-wrap: nowrap;
}
</style>
@@ -1,15 +1,91 @@
<script>
import {ref, watch} from "vue";
import { ref, watch } from "vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
// Get the user from the route
export const userId = ref(0);
export const isUserLoading = ref(false);
export const userLoadError = ref(null);
export const selectedUserLoaded = ref(false);
let activeLoadPromise = null;
let activeLoadUserId = null;
let loadRequestSequence = 0;
const normalizeUserId = (id) => {
const parsed = Number.parseInt(String(id ?? ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
};
const defaultKeys = () => ({
open_invoice_draft: null,
});
const economicFields = {
customerNumber: null,
name: null,
address: null,
zip: null,
city: null,
mobilePhone: null,
email: null,
cvr: null,
currency: null,
country: null,
barred: null,
};
const setEconomicData = (economicCustomer = {}) => {
user.economicData.customerNumber.value = economicCustomer?.customerNumber ?? null;
user.economicData.name.value = economicCustomer?.name ?? null;
user.economicData.address.value = economicCustomer?.address ?? null;
user.economicData.zip.value = economicCustomer?.zip ?? null;
user.economicData.city.value = economicCustomer?.city ?? null;
user.economicData.mobilePhone.value = economicCustomer?.mobilePhone ?? null;
user.economicData.email.value = economicCustomer?.email ?? null;
user.economicData.cvr.value = economicCustomer?.corporateIdentificationNumber ?? economicCustomer?.cvr ?? null;
user.economicData.currency.value = economicCustomer?.currency ?? null;
user.economicData.country.value = economicCustomer?.country ?? null;
user.economicData.barred.value = economicCustomer?.barred ?? false;
};
export const resetUser = ({ preserveUserId = false } = {}) => {
if (!preserveUserId) {
userId.value = 0;
}
user.id.value = "";
user.customer_number.value = "";
user.display_name.value = "";
user.email.value = "";
user.phone.value = null;
user.group_id.value = "";
user.created_at.value = "";
user.updated_at.value = "";
setEconomicData(economicFields);
user.permissions.value = [];
user.attributes.value = [];
user.discounts.value = [];
user.orders_not_invoiced.value = [];
user.keys.value = defaultKeys();
user.wash_subscription_transactions.value = [];
selectedUserLoaded.value = false;
};
export const setUser = (id) => {
userId.value = id;
// Load the user data
getUserData();
}
const normalizedId = normalizeUserId(id);
if (!normalizedId) {
userLoadError.value = new Error("Invalid user id");
resetUser();
return Promise.resolve(null);
}
if (userId.value !== normalizedId) {
resetUser();
userId.value = normalizedId;
}
return getUserData(normalizedId);
};
export const onUserChange = (callback) => {
watch(userId, (newValue) => {
@@ -17,48 +93,89 @@ export const onUserChange = (callback) => {
callback(newValue);
}
});
}
};
export const getUserCustomerNumber = () => {
return user.customer_number.value;
}
};
const applyUserData = (data = {}) => {
user.id.value = data.id ?? "";
user.customer_number.value = data.customer_number ?? "";
user.display_name.value = data.display_name ?? "";
user.email.value = data.email ?? "";
user.phone.value = data.phone ?? null;
user.group_id.value = data.group_id ?? "";
user.created_at.value = data.created_at ?? "";
user.updated_at.value = data.updated_at ?? "";
setEconomicData(data.economic_customer ?? {});
user.permissions.value = Array.isArray(data.permissions) ? data.permissions : [];
user.attributes.value = Array.isArray(data.attributes) ? data.attributes : [];
user.discounts.value = Array.isArray(data.discounts) ? data.discounts : [];
user.orders_not_invoiced.value = Array.isArray(data.orders_not_invoiced) ? data.orders_not_invoiced : [];
user.keys.value = data.keys && typeof data.keys === "object" ? { ...defaultKeys(), ...data.keys } : defaultKeys();
user.wash_subscription_transactions.value = Array.isArray(data.wash_subscription_transactions)
? data.wash_subscription_transactions
: [];
selectedUserLoaded.value = true;
};
// Get the user data
export const getUserData = async () => {
return await authenticatedRequest(`/superuser/user?user_id=${userId.value}`, "GET")
export const getUserData = async (id = userId.value) => {
const normalizedId = normalizeUserId(id);
if (!normalizedId) {
userLoadError.value = new Error("Invalid user id");
resetUser();
return null;
}
if (activeLoadPromise && activeLoadUserId === normalizedId) {
return activeLoadPromise;
}
const requestId = ++loadRequestSequence;
activeLoadUserId = normalizedId;
isUserLoading.value = true;
userLoadError.value = null;
activeLoadPromise = authenticatedRequest(`/superuser/user?user_id=${normalizedId}`, "GET")
.then((response) => {
user.id.value = response.data.data.id;
user.customer_number.value = response.data.data.customer_number;
user.group_id.value = response.data.data.group_id;
user.created_at.value = response.data.data.created_at;
user.updated_at.value = response.data.data.updated_at;
if (response.data.data.economic_customer.name) {
user.economicData.customerNumber.value = response.data.data.economic_customer.customerNumber;
user.economicData.name.value = response.data.data.economic_customer.name;
user.economicData.address.value = response.data.data.economic_customer.address;
user.economicData.zip.value = response.data.data.economic_customer.zip;
user.economicData.city.value = response.data.data.economic_customer.city;
user.economicData.mobilePhone.value = response.data.data.economic_customer.mobilePhone;
user.economicData.email.value = response.data.data.economic_customer.email;
user.economicData.cvr.value = response.data.data.economic_customer.corporateIdentificationNumber;
user.economicData.currency.value = response.data.data.economic_customer.currency;
user.economicData.country.value = response.data.data.economic_customer.country;
user.economicData.barred.value = response.data.data.economic_customer.barred ?? false;
if (requestId !== loadRequestSequence) {
return user;
}
user.permissions.value = response.data.data.permissions;
user.attributes.value = response.data.data.attributes;
user.discounts.value = response.data.data.discounts;
user.orders_not_invoiced.value = response.data.data.orders_not_invoiced || null;
user.keys.value = response.data.data.keys;
user.wash_subscription_transactions.value = response.data.data.wash_subscription_transactions || null;
console.log(user.discounts.value);
applyUserData(response?.data?.data ?? {});
return user;
})
.catch((error) => {
console.error(error);
if (requestId === loadRequestSequence) {
resetUser({ preserveUserId: true });
userLoadError.value = error;
}
return null;
})
.finally(() => {
if (requestId === loadRequestSequence) {
isUserLoading.value = false;
activeLoadPromise = null;
activeLoadUserId = null;
}
});
return activeLoadPromise;
};
const isDirectProductDiscount = (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 */
@@ -133,12 +250,25 @@ export const getUserProductOnlyDiscount = (product) => {
// Find the discount for the product
let discount = null;
try {
discount = user.discounts.value.find(discount => discount.product_or_category_id == productId && !discount.is_category) || null;
discount = user.discounts.value.find(discount => isDirectProductDiscount(discount, productId)) || null;
} catch (e) {}
// If the discount is not found, return 0
return discount ? discount.percentage : 0;
};
export const getUserProductFixedPrice = (product) => {
const productId = product.id;
let fixedPriceDiscount = null;
try {
fixedPriceDiscount = user.discounts.value.find((discount) => (
isDirectProductDiscount(discount, productId) && parseFixedPriceValue(discount.fixed_price) !== null
)) || null;
} catch (e) {}
return fixedPriceDiscount ? parseFixedPriceValue(fixedPriceDiscount.fixed_price) : null;
};
export const isProductAllowedToApplyCategoryDiscounts = (product) => {
// Check if the product is allowed to apply category discounts
return parseInt(product.apply_category_discount) === 1;
@@ -166,9 +296,27 @@ export const getProductBestApplicableDiscount = (product) => {
return Math.max(productDiscount, categoryDiscount, globalDiscount);
}
export const getProductEffectivePrice = (product) => {
const fixedPrice = getUserProductFixedPrice(product);
if (fixedPrice !== null) {
return fixedPrice;
}
const basePrice = Number(product.price ?? 0);
const discount = Number(getProductBestApplicableDiscount(product) || 0);
if (!Number.isFinite(basePrice)) {
return 0;
}
return Number((basePrice - (basePrice * (discount / 100))).toFixed(0));
}
export const user = {
id: ref(''),
customer_number: ref(''),
display_name: ref(''),
email: ref(''),
phone: ref(null),
group_id: ref(''),
created_at: ref(''),
updated_at: ref(''),
@@ -185,23 +333,23 @@ export const user = {
country: ref(null),
barred: ref(null),
},
permissions: ref(null),
attributes: ref(null),
discounts: ref(null),
orders_not_invoiced: ref(null),
keys: ref({
open_invoice_draft: ref(null),
}),
wash_subscription_transactions: ref(null),
permissions: ref([]),
attributes: ref([]),
discounts: ref([]),
orders_not_invoiced: ref([]),
keys: ref(defaultKeys()),
wash_subscription_transactions: ref([]),
functions: {
getUserData: getUserData,
getUserProductDiscount: getUserProductDiscount,
getUserCategoryDiscount: getUserCategoryDiscount,
getUserGlobalDiscount: getUserGlobalDiscount,
getUserProductOnlyDiscount: getUserProductOnlyDiscount,
getUserProductFixedPrice: getUserProductFixedPrice,
isProductAllowedToApplyCategoryDiscounts: isProductAllowedToApplyCategoryDiscounts,
getProductCategory: getProductCategory,
getProductBestApplicableDiscount: getProductBestApplicableDiscount,
getProductEffectivePrice: getProductEffectivePrice,
},
};
</script>
@@ -1,133 +1,506 @@
<script setup>
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRouter } from 'vue-router'
import UserSubPageWrapper from "@/views/dashboards/superUserDashboard/user/UserSubPageWrapper.vue";
// Get the user from the route
const router = useRouter()
import {user, getUserData, userId, setUser} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import UserVehicleSubscriptionsDisplay
from "@/views/dashboards/superUserDashboard/user/displays/vehicles/UserVehicleSubscriptionsDisplay.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import UserFixedPricing from "@/views/dashboards/superUserDashboard/user/displays/UserFixedPricing.vue";
import {
isUserLoading,
selectedUserLoaded,
setUser,
user,
userId,
userLoadError,
} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import UserDefaultDepartment from "@/views/dashboards/superUserDashboard/user/displays/UserDefaultDepartment.vue";
import UserFixedPricing from "@/views/dashboards/superUserDashboard/user/displays/UserFixedPricing.vue";
import UserOtherSpecialArrangement from "@/views/dashboards/superUserDashboard/user/displays/other/UserOtherSpecialArrangement.vue";
import UserOtherVaskeabonnement from "@/views/dashboards/superUserDashboard/user/displays/other/UserOtherVaskeabonnement.vue";
import UserVehicleSubscriptionsDisplay from "@/views/dashboards/superUserDashboard/user/displays/vehicles/UserVehicleSubscriptionsDisplay.vue";
// Set the user
setUser(router.currentRoute.value.params.userId);
const route = useRoute();
const { t } = useI18n();
const vehicles = ref([]);
const vehiclesLoading = ref(false);
const vehiclesError = ref(null);
const customerNumber = computed(() => Number.parseInt(String(user.customer_number.value || 0), 10) || 0);
const basePath = computed(() => `/superuser/users/${userId.value || route.params.userId}`);
const permissions = computed(() => (Array.isArray(user.permissions.value) ? user.permissions.value : []));
const attributes = computed(() => (Array.isArray(user.attributes.value) ? user.attributes.value : []));
const discounts = computed(() => (Array.isArray(user.discounts.value) ? user.discounts.value : []));
const ordersNotInvoiced = computed(() =>
Array.isArray(user.orders_not_invoiced.value) ? user.orders_not_invoiced.value : []
);
const washSubscriptionTransactions = computed(() =>
Array.isArray(user.wash_subscription_transactions.value) ? user.wash_subscription_transactions.value : []
);
const displayName = computed(() => {
return (
user.economicData.name.value ||
user.display_name.value ||
(userId.value ? `${t("user_admin.user_id")} ${userId.value}` : t("user_admin.overview.loading_user"))
);
});
const hasCustomerNumber = computed(() => customerNumber.value > 0);
const hasEconomicData = computed(() => Boolean(user.economicData.name.value || user.economicData.customerNumber.value));
const isEconomicBarred = computed(() => [true, 1, "1", "true"].includes(user.economicData.barred.value));
const activeVehicleSubscriptions = computed(() => vehicles.value.filter((vehicle) => vehicle?.wash_subscription).length);
const selfServiceVehicles = computed(() => vehicles.value.filter((vehicle) => vehicle?.xlvask).length);
const visibleVehicles = computed(() => vehicles.value.slice(0, 8));
const visiblePermissions = computed(() => permissions.value.slice(0, 12));
const hiddenPermissionCount = computed(() => Math.max(permissions.value.length - visiblePermissions.value.length, 0));
const userDetails = computed(() => [
{ label: t("user_admin.user_id"), value: userId.value },
{ label: t("user_admin.customer_number"), value: customerNumber.value || null },
{ label: t("user_admin.group_id"), value: user.group_id.value },
{ label: t("common.email"), value: user.email?.value || user.economicData.email.value },
{ label: t("common.created"), value: user.created_at.value },
{ label: t("user_admin.updated_at"), value: user.updated_at.value },
]);
const economicDetails = computed(() => [
{ label: t("user_admin.customer_number"), value: user.economicData.customerNumber.value },
{ label: t("common.name"), value: user.economicData.name.value },
{ label: t("common.address"), value: user.economicData.address.value },
{ label: t("user_admin.zip"), value: user.economicData.zip.value },
{ label: t("user_admin.city"), value: user.economicData.city.value },
{ label: t("user_admin.mobile_phone"), value: user.economicData.mobilePhone.value },
{ label: t("common.email"), value: user.economicData.email.value },
{ label: t("user_admin.cvr"), value: user.economicData.cvr.value },
{ label: t("user_admin.currency"), value: user.economicData.currency.value },
{ label: t("common.country"), value: user.economicData.country.value },
{ label: t("user_admin.barred"), value: isEconomicBarred.value ? t("common.yes") : t("common.no") },
]);
const overviewMetrics = computed(() => [
{
key: "customer",
icon: "fas fa-id-card",
label: t("user_admin.customer_number"),
value: customerNumber.value || t("global.no_data"),
status: hasCustomerNumber.value ? t("common.active") : t("user_admin.overview.missing"),
tone: hasCustomerNumber.value ? "is-success" : "is-warning",
},
{
key: "economic",
icon: "fas fa-building",
label: t("user_admin.economic_data"),
value: hasEconomicData.value ? t("user_admin.overview.connected") : t("global.no_data"),
status: isEconomicBarred.value ? t("user_admin.barred") : t("common.active"),
tone: isEconomicBarred.value ? "is-danger" : "is-success",
},
{
key: "vehicles",
icon: "fas fa-car",
label: t("common.vehicles"),
value: vehicles.value.length,
status: vehiclesLoading.value ? t("common.loading") : t("user_admin.overview.loaded"),
tone: vehiclesError.value ? "is-warning" : "is-info",
},
{
key: "subscriptions",
icon: "fas fa-car-side",
label: t("user_admin.wash_subscriptions"),
value: activeVehicleSubscriptions.value,
status: t("user_admin.overview.active_vehicles"),
tone: activeVehicleSubscriptions.value > 0 ? "is-success" : "is-light",
},
{
key: "orders",
icon: "fas fa-file-invoice-dollar",
label: t("user_admin.overview.not_invoiced"),
value: ordersNotInvoiced.value.length,
status: user.keys.value?.open_invoice_draft ? t("user_admin.overview.open_draft") : t("global.no_data"),
tone: ordersNotInvoiced.value.length > 0 ? "is-warning" : "is-success",
},
{
key: "discounts",
icon: "fas fa-tags",
label: t("common.discount"),
value: discounts.value.length,
status: t("user_admin.overview.price_overrides"),
tone: discounts.value.length > 0 ? "is-link" : "is-light",
},
{
key: "attributes",
icon: "fas fa-sliders-h",
label: t("user_admin.overview.attributes"),
value: attributes.value.length,
status: t("user_admin.overview.customer_rules"),
tone: attributes.value.length > 0 ? "is-info" : "is-light",
},
{
key: "permissions",
icon: "fas fa-shield-alt",
label: t("user_admin.permissions"),
value: permissions.value.length,
status: t("user_admin.overview.effective_access"),
tone: permissions.value.length > 0 ? "is-info" : "is-warning",
},
]);
const hubLinks = computed(() => [
{ key: "orders", icon: "fas fa-file-alt", label: t("superuser.nav.orders"), to: `${basePath.value}/orders` },
{ key: "pricing", icon: "fas fa-tags", label: t("user_admin.overview.pricing"), to: `${basePath.value}/pricing` },
{ key: "other", icon: "fas fa-sliders-h", label: t("user_admin.other"), to: `${basePath.value}/other` },
{ key: "vehicles", icon: "fas fa-car", label: t("common.vehicles"), to: `${basePath.value}/vehicles` },
{
key: "xlvask",
icon: "fas fa-water",
label: SessionUser.superUser.modules.xlvask.meta.title,
to: `${basePath.value}/xlvask`,
},
]);
const valueOrEmpty = (value) => {
if (value === null || value === undefined || value === "") {
return t("global.no_data");
}
return value;
};
const loadVehicles = async () => {
if (!hasCustomerNumber.value) {
vehicles.value = [];
vehiclesError.value = null;
return;
}
vehiclesLoading.value = true;
vehiclesError.value = null;
try {
const response = await SessionUser.objects.vehicles.get.user(customerNumber.value);
vehicles.value = Array.isArray(response) ? response : [];
} catch (error) {
vehicles.value = [];
vehiclesError.value = error;
} finally {
vehiclesLoading.value = false;
}
};
const reloadOverview = async () => {
await setUser(route.params.userId);
await loadVehicles();
};
watch(
customerNumber,
() => {
loadVehicles();
},
{ immediate: true }
);
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<UserSubPageWrapper>
<template #title>
<PageTitle :title="$t('user_admin.title')" :subtitle="$t('user_admin.subtitle')" />
<PageTitle :title="$t('user_admin.title')" :subtitle="$t('user_admin.overview.subtitle')" />
</template>
<div class="columns is-multiline">
<!-- Customer small -->
<div class="column is-12">
<div class="card">
<div class="card-header">
<!-- User icon -->
<div class="card-header-icon">
<span class="icon">
<i class="fas fa-user"></i>
<section class="user-overview" data-testid="superuser-user-overview-page">
<div v-if="isUserLoading && !selectedUserLoaded" class="box" data-testid="superuser-user-overview-loading">
<p class="has-text-weight-semibold">{{ $t("user_admin.overview.loading_user") }}</p>
<p class="has-text-grey">{{ $t("user_admin.orders.loading_subtitle") }}</p>
</div>
<div v-else-if="userLoadError" class="notification is-danger" data-testid="superuser-user-overview-error">
<p class="has-text-weight-semibold">{{ $t("user_admin.overview.load_failed") }}</p>
<p>{{ userLoadError?.response?.data?.message || userLoadError?.message || $t("common.unknown_error") }}</p>
<button class="button is-light mt-3" type="button" data-testid="superuser-user-overview-retry" @click="reloadOverview">
{{ $t("common.retry") }}
</button>
</div>
<template v-else-if="selectedUserLoaded">
<div class="user-overview__header box" data-testid="superuser-user-overview-header">
<div>
<p class="heading">{{ $t("user_admin.overview.management_hub") }}</p>
<h2 class="title is-3 mb-2">{{ displayName }}</h2>
<p class="subtitle is-6 mb-0">
{{ $t("user_admin.user_id") }} {{ userId }}
<span v-if="hasCustomerNumber">&middot; {{ $t("user_admin.customer_number") }} {{ customerNumber }}</span>
</p>
</div>
<div class="user-overview__header-actions">
<ActionSettingsWheelButton
:user_id="userId"
:customer_number="customerNumber || null"
data-testid="superuser-user-overview-actions"
/>
</div>
</div>
<div class="user-overview__metrics columns is-multiline" data-testid="superuser-user-overview-metrics">
<div v-for="metric in overviewMetrics" :key="metric.key" class="column is-3-desktop is-6-tablet">
<div class="box user-overview__metric" :data-testid="`superuser-user-overview-metric-${metric.key}`">
<div class="user-overview__metric-top">
<span class="icon"><i :class="metric.icon"></i></span>
<span class="tag is-light" :class="metric.tone">{{ metric.status }}</span>
</div>
<p class="heading">{{ metric.label }}</p>
<p class="title is-4">{{ metric.value }}</p>
</div>
</div>
</div>
<div class="box" data-testid="superuser-user-overview-shortcuts">
<div class="level is-mobile user-overview__section-title">
<div class="level-left">
<h3 class="title is-5 mb-0">{{ $t("user_admin.overview.workspace_shortcuts") }}</h3>
</div>
</div>
<div class="buttons">
<router-link
v-for="link in hubLinks"
:key="link.key"
class="button is-light"
:to="link.to"
:data-testid="`superuser-user-overview-link-${link.key}`"
>
<span class="icon"><i :class="link.icon"></i></span>
<span>{{ link.label }}</span>
</router-link>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-account">
<h3 class="title is-5">{{ $t("user_admin.user_data") }}</h3>
<dl class="user-overview__details">
<template v-for="detail in userDetails" :key="detail.label">
<dt>{{ detail.label }}</dt>
<dd>{{ valueOrEmpty(detail.value) }}</dd>
</template>
</dl>
</section>
</div>
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-economic">
<h3 class="title is-5">{{ $t("user_admin.economic_data") }}</h3>
<dl class="user-overview__details">
<template v-for="detail in economicDetails" :key="detail.label">
<dt>{{ detail.label }}</dt>
<dd>{{ valueOrEmpty(detail.value) }}</dd>
</template>
</dl>
</section>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-access">
<h3 class="title is-5">{{ $t("user_admin.permissions") }}</h3>
<div v-if="visiblePermissions.length" class="tags">
<span
v-for="permission in visiblePermissions"
:key="permission"
class="tag is-info is-light"
:data-testid="`superuser-user-overview-permission-${permission}`"
>
{{ permission }}
</span>
<span v-if="hiddenPermissionCount > 0" class="tag">
{{ $t("user_admin.overview.more_permissions", { count: hiddenPermissionCount }) }}
</span>
</div>
<p v-else class="has-text-grey">{{ $t("global.no_data") }}</p>
</section>
</div>
<div class="column is-6-desktop">
<section class="box" data-testid="superuser-user-overview-rules">
<h3 class="title is-5">{{ $t("user_admin.overview.customer_rules") }}</h3>
<ActionSettingsWheelButton
v-if="hasCustomerNumber"
:user_id="userId"
:customer_number="customerNumber"
:display-actions-directly="true"
data-testid="superuser-user-overview-direct-actions"
/>
<p v-else class="has-text-grey">{{ $t("user_admin.overview.customer_number_required") }}</p>
</section>
</div>
</div>
<section v-if="hasCustomerNumber" class="box" data-testid="superuser-user-overview-customer-management">
<h3 class="title is-5">{{ $t("user_admin.overview.customer_management") }}</h3>
<div class="columns is-multiline">
<div class="column is-6-desktop">
<UserFixedPricing :key="`fixed-${customerNumber}`" :customer_number="customerNumber" />
</div>
<div class="column is-6-desktop">
<UserDefaultDepartment :key="`department-${customerNumber}`" :customer_number="customerNumber" />
</div>
<div class="column is-6-desktop">
<UserOtherSpecialArrangement :key="`special-${userId}`" :user_id="userId" />
</div>
<div class="column is-6-desktop">
<UserOtherVaskeabonnement :key="`subscription-note-${userId}`" :user_id="userId" />
</div>
</div>
</section>
<section v-else class="notification is-warning" data-testid="superuser-user-overview-no-customer-number">
{{ $t("user_admin.overview.customer_number_required") }}
</section>
<section v-if="hasCustomerNumber" class="box" data-testid="superuser-user-overview-vehicles">
<div class="level is-mobile user-overview__section-title">
<div class="level-left">
<h3 class="title is-5 mb-0">{{ $t("common.vehicles") }}</h3>
</div>
<div class="level-right">
<button
class="button is-small is-light"
type="button"
:disabled="vehiclesLoading"
data-testid="superuser-user-overview-vehicles-reload"
@click="loadVehicles"
>
{{ $t("global.reload") }}
</button>
</div>
</div>
<div v-if="vehiclesError" class="notification is-warning" data-testid="superuser-user-overview-vehicles-error">
{{ $t("user_admin.overview.vehicles_load_failed") }}
</div>
<div class="columns is-multiline">
<div class="column is-4">
<div class="box user-overview__compact-stat">
<p class="heading">{{ $t("common.vehicles") }}</p>
<p class="title is-4">{{ vehicles.length }}</p>
</div>
</div>
<div class="column is-4">
<div class="box user-overview__compact-stat">
<p class="heading">{{ $t("user_admin.wash_subscriptions") }}</p>
<p class="title is-4">{{ activeVehicleSubscriptions }}</p>
</div>
</div>
<div class="column is-4">
<div class="box user-overview__compact-stat">
<p class="heading">{{ SessionUser.superUser.modules.xlvask.meta.title }}</p>
<p class="title is-4">{{ selfServiceVehicles }}</p>
</div>
</div>
</div>
<div v-if="visibleVehicles.length" class="tags" data-testid="superuser-user-overview-vehicle-preview">
<span
v-for="vehicle in visibleVehicles"
:key="vehicle.id || vehicle.reg"
class="tag is-light"
:data-testid="`superuser-user-overview-vehicle-${vehicle.id || vehicle.reg}`"
>
{{ vehicle.reg || vehicle.reference || `#${vehicle.id}` }}
</span>
</div>
<!-- User name -->
<div class="card-header-title">
{{ user.economicData.name }}
<p v-else-if="!vehiclesLoading" class="has-text-grey">{{ $t("global.no_data") }}</p>
<div class="buttons">
<router-link class="button is-light" :to="`${basePath}/vehicles`" data-testid="superuser-user-overview-open-vehicles">
<span class="icon"><i class="fas fa-list"></i></span>
<span>{{ $t("user_admin.overview.open_vehicles") }}</span>
</router-link>
<button
class="button is-light"
type="button"
data-testid="superuser-user-overview-add-vehicle"
@click="SessionUser.objects.vehicles.functions.showCreateObjectForCustomerForm(customerNumber, loadVehicles)"
>
<span class="icon"><i class="fas fa-plus"></i></span>
<span>{{ $t("user_vehicles.add_vehicle") }}</span>
</button>
</div>
<!-- User settings wheel -->
<div class="card-header-icon">
<ActionSettingsWheelButton
:user_id="userId"
>
<template #actions>
<!-- Since there's no additional actions, we can use the default slot -->
</template>
</ActionSettingsWheelButton>
</div>
</div>
</div>
</div>
<div class="column is-12">
<div class="box">
<!-- User fixed pricing -->
<div class="mb-2">
<UserFixedPricing v-if="user.customer_number.value && user.customer_number.value > 0" v-bind:customer_number="user.customer_number.value" />
</div>
<!-- Default department -->
<div class="mb-2">
<UserDefaultDepartment v-if="user.customer_number.value && user.customer_number.value > 0" v-bind:customer_number="user.customer_number.value" />
</div>
<!-- Vehicle subscriptions -->
<UserVehicleSubscriptionsDisplay
v-if="user.customer_number.value && user.customer_number.value > 0"
:user="user"
/>
</div>
</div>
<div class="column is-6">
<div class="box">
<h3 class="title is-4">{{ $t('user_admin.user_data') }}</h3>
<div class="content">
<p>{{ $t('user_admin.user_id') }}: {{ userId }}</p>
<p>{{ $t('user_admin.customer_number') }}: {{ user.customer_number }}</p>
<p>{{ $t('user_admin.group_id') }}: {{ user.group_id }}</p>
<p>{{ $t('common.created') }}: {{ user.created_at }}</p>
<p>{{ $t('user_admin.updated_at') }}: {{ user.updated_at }}</p>
</div>
</div>
</div>
<!-- Economic data -->
<div class="column is-6">
<div class="box">
<h3 class="title is-4">{{ $t('user_admin.economic_data') }} ({{ user.economicData.name ? user.economicData.name : $t('user_admin.no_data') }})</h3>
<div class="content">
<p>{{ $t('user_admin.customer_number') }}: {{ user.economicData.customerNumber }}</p>
<p>{{ $t('common.name') }}: {{ user.economicData.name }}</p>
<p>{{ $t('common.address') }}: {{ user.economicData.address }}</p>
<p>{{ $t('user_admin.zip') }}: {{ user.economicData.zip }}</p>
<p>{{ $t('user_admin.city') }}: {{ user.economicData.city }}</p>
<p>{{ $t('user_admin.mobile_phone') }}: {{ user.economicData.mobilePhone }}</p>
<p>{{ $t('common.email') }}: {{ user.economicData.email }}</p>
<p>{{ $t('user_admin.cvr') }}: {{ user.economicData.cvr }}</p>
<p>{{ $t('user_admin.currency') }}: {{ user.economicData.currency }}</p>
<p>{{ $t('common.country') }}: {{ user.economicData.country }}</p>
<p>{{ $t('user_admin.barred') }}: {{user.economicData.barred }}</p>
</div>
</div>
</div>
<!-- Permissions -->
<div class="column is-12">
<div class="box">
<h3 class="title is-4">{{ $t('user_admin.permissions') }}</h3>
<div class="content">
<ul>
<li v-for="permission in user.permissions.value" :key="permission.id">{{ permission }}</li>
</ul>
</div>
</div>
</div>
<!-- Variables -->
<div class="column is-12">
<div class="box">
<h3 class="title is-4">{{ $t('user_admin.variables') }}</h3>
<div class="content">
<p>SessionUser: {{ SessionUser.valueOf() }}</p>
<p>userId: {{ userId }}</p>
<p>user: {{ user.valueOf() }}</p>
</div>
</div>
</div>
</div>
</section>
<section v-if="hasCustomerNumber" class="box" data-testid="superuser-user-overview-subscriptions">
<h3 class="title is-5">{{ $t("user_admin.overview.subscription_invoicing") }}</h3>
<UserVehicleSubscriptionsDisplay :key="`subscriptions-${customerNumber}`" :user="user" />
<p v-if="washSubscriptionTransactions.length === 0" class="has-text-grey">
{{ $t("user_admin.overview.no_subscription_transactions") }}
</p>
</section>
</template>
</section>
</UserSubPageWrapper>
</RestrictedPageWrapper>
</template>
<style scoped>
.user-overview {
padding-bottom: 2rem;
}
</style>
.user-overview__header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.user-overview__header-actions {
flex: 0 0 auto;
}
.user-overview__metric,
.user-overview__compact-stat {
height: 100%;
}
.user-overview__metric-top {
align-items: center;
display: flex;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.user-overview__details {
display: grid;
gap: 0.5rem 1rem;
grid-template-columns: minmax(8rem, 38%) 1fr;
}
.user-overview__details dt {
color: #6b7280;
font-weight: 600;
}
.user-overview__details dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
}
.user-overview__section-title {
margin-bottom: 1rem;
}
@media screen and (max-width: 768px) {
.user-overview__header {
display: block;
}
.user-overview__header-actions {
margin-top: 1rem;
}
.user-overview__details {
grid-template-columns: 1fr;
}
}
</style>
@@ -3,7 +3,7 @@
import UserSubPageWrapper from "@/views/dashboards/superUserDashboard/user/UserSubPageWrapper.vue";
import PageTitle from "@/components/global/PageTitle.vue";
import { useRouter } from 'vue-router'
import { user, getUserData, userId, setUser, getUserProductDiscount, getUserGlobalDiscount, getProductBestApplicableDiscount, getUserProductOnlyDiscount, getProductCategory, getUserCategoryDiscount, isProductAllowedToApplyCategoryDiscounts } from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import { user, getUserData, userId, setUser, getUserProductDiscount, getUserGlobalDiscount, getProductBestApplicableDiscount, getUserProductOnlyDiscount, getUserProductFixedPrice, getProductEffectivePrice, getProductCategory, getUserCategoryDiscount, isProductAllowedToApplyCategoryDiscounts } from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import { getProducts } from "@/components/shop/Products.vue";
import { ref } from 'vue';
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
@@ -56,6 +56,72 @@ const editDiscount = (product, isCategory) => {
allowOutsideClick: () => !Swal.isLoading()
});
}
const formatPrice = (price) => `${Number(price || 0).toFixed(0)} Kr.`;
const getProductPriceDisplay = (product) => {
const fixedPrice = getUserProductFixedPrice(product);
if (fixedPrice !== null) {
return `${formatPrice(fixedPrice)} (fast pris)`;
}
const discount = getProductBestApplicableDiscount(product);
if (discount === 0) {
return formatPrice(product.price);
}
return `${formatPrice(getProductEffectivePrice(product))} (${discount}% rabat)`;
};
const editFixedPrice = (product) => {
const fixedPrice = getUserProductFixedPrice(product);
Swal.fire({
title: product.name + ' ( product: ' + product.id + ' )',
input: 'number',
inputValue: fixedPrice === null ? '' : fixedPrice,
inputLabel: 'Fast pris (Kr.)',
inputAttributes: {
autocapitalize: 'off',
min: 0,
step: 1
},
showCancelButton: true,
confirmButtonText: 'Save',
showLoaderOnConfirm: true,
inputValidator: (value) => {
const normalizedValue = String(value ?? '').trim();
if (normalizedValue === '') {
return null;
}
const parsedValue = Number(normalizedValue);
if (!Number.isInteger(parsedValue) || parsedValue < 0) {
return 'Fast pris skal være et heltal eller tom.';
}
return null;
},
preConfirm: (value) => {
const normalizedValue = String(value ?? '').trim();
const fixedPriceValue = normalizedValue === '' ? null : Number.parseInt(normalizedValue, 10);
return authenticatedRequest(`/superuser/user/discounts`, "POST", {
user_id: userId.value,
object_id: product.id,
discount: getUserProductOnlyDiscount(product),
fixed_price: fixedPriceValue,
is_category: false
})
.then((response) => {
console.log(response);
getUserData();
})
.catch((error) => {
console.log(error);
});
},
allowOutsideClick: () => !Swal.isLoading()
});
}
</script>
<template>
@@ -74,6 +140,7 @@ const editDiscount = (product, isCategory) => {
<th>{{ $t('tables.common.category') }}</th>
<th>{{ $t('tables.common.product_name') }}</th>
<th>{{ $t('tables.common.price') }}</th>
<th>Fast pris</th>
<th>{{ $t('tables.common.discount_item') }}</th>
<th>{{ $t('tables.common.discount_category') }}</th>
</tr>
@@ -84,8 +151,16 @@ const editDiscount = (product, isCategory) => {
<td>{{ getProductCategory(product)}}</td>
<td>{{ product.name }}</td>
<!-- Price -->
<td v-if="getProductBestApplicableDiscount(product) === 0">{{ product.price }} Kr.</td>
<td v-else>{{ (product.price - (product.price * (getProductBestApplicableDiscount(product) / 100))).toFixed(0) }} Kr. ({{ getProductBestApplicableDiscount(product) }}% rabat)</td>
<td>{{ getProductPriceDisplay(product) }}</td>
<!-- Fixed price -->
<td
class="is-clickable"
@click="editFixedPrice(product)"
:data-testid="`customer-fixed-price-${product.id}`"
><template v-if="getUserProductFixedPrice(product) !== null">{{ getUserProductFixedPrice(product) }} Kr.</template><template v-else>-</template>
<i class="is-pulled-right fas fa-edit"></i>
</td>
<!-- Discount: Item -->
<td
@@ -122,4 +197,4 @@ const editDiscount = (product, isCategory) => {
<style scoped>
</style>
</style>
@@ -1,12 +1,19 @@
<script setup>
import { watch } from "vue";
import SuperUserDashboardUserNavigation from "@/views/dashboards/superUserDashboard/user/SuperUserDashboardUserNavigation.vue";
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
import {user, getUserData, userId, setUser, getUserCustomerNumber} from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import {useRouter} from "vue-router";
const router = useRouter()
setUser(router.currentRoute.value.params.userId);
import { setUser } from "@/views/dashboards/superUserDashboard/user/SuperUserSelectedUserObject.vue";
import { useRoute } from "vue-router";
const route = useRoute();
watch(
() => route.params.userId,
(nextUserId) => {
setUser(nextUserId);
},
{ immediate: true }
);
</script>
<template>
@@ -21,4 +28,4 @@ setUser(router.currentRoute.value.params.userId);
<style scoped>
</style>
</style>
@@ -2,7 +2,6 @@
import { ref, computed } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import Swal from "sweetalert2";
import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
@@ -26,7 +25,6 @@ const getDefaultDepartment = async () => {
customer_number: props.customer_number,
}).then((response) => {
let responseData = response.data.data;
console.log(response.data.data, 'Default department');
department_value.value = responseData.department;
hasDefaultDepartment.value = true;
}).catch((error) => {
@@ -40,8 +38,7 @@ const onClickSubmit = async () => {
await SessionUser.request('/customer/department/default', 'POST', {
customer_number: props.customer_number,
department: department_value.value,
}).then((response) => {
console.log(response.data.data, 'Created default department');
}).then(() => {
// Get the newly created fixed pricing
getDefaultDepartment();
}).catch((error) => {
@@ -75,8 +72,7 @@ const onDeleteConfirmed = async () => {
// Delete the fixed pricing
await SessionUser.request('/customer/department/default', 'DELETE', {
customer_number: props.customer_number,
}).then((response) => {
console.log(response.data.data, 'Deleted default department');
}).then(() => {
// Get the newly created fixed pricing
getDefaultDepartment();
}).catch((error) => {
@@ -163,4 +159,4 @@ getDepartmentOptions();
<style scoped>
</style>
</style>
@@ -2,7 +2,6 @@
import { ref, computed } from 'vue';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import Swal from "sweetalert2";
@@ -27,8 +26,6 @@ const getFixedPricing = async () => {
await SessionUser.request('/customer/pricing/fixed', 'GET', {
customer_number: props.customer_number,
}).then((response) => {
let responseData = response.data.data;
console.log(response.data.data, 'Fixed pricing');
pricing.value = response.data.data.price;
description.value = response.data.data.description;
hasFixedPricing.value = true;
@@ -44,8 +41,7 @@ const onClickSubmit = async () => {
customer_number: props.customer_number,
price: inputPrice.value,
description: inputDescription.value,
}).then((response) => {
console.log(response.data.data, 'Fixed pricing');
}).then(() => {
// Get the newly created fixed pricing
getFixedPricing();
}).catch((error) => {
@@ -81,8 +77,7 @@ const onDeleteConfirmed = async () => {
// Delete the fixed pricing
await SessionUser.request('/customer/pricing/fixed', 'DELETE', {
customer_number: props.customer_number,
}).then((response) => {
console.log(response.data.data, 'Deleted fixed pricing');
}).then(() => {
// Get the newly created fixed pricing
getFixedPricing();
}).catch((error) => {
@@ -167,4 +162,4 @@ getFixedPricing();
<style scoped>
</style>
</style>
@@ -1,8 +1,8 @@
<script setup>
import { ref } from 'vue';
import { ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
const { t } = useI18n();
@@ -10,79 +10,73 @@ const props = defineProps({
user_id: Number,
readOnly: {
type: Boolean,
default: false
default: false,
},
textOnly: {
type: Boolean,
default: false
}
default: false,
},
});
const OtherSpecialArrangementValue = ref(null);
const userKeys = ref({});
const userKeys = ref([{
key: '',
value: ''
}]);
const showLoadError = () => {
Swal.fire({
title: t("common.error"),
text: t("common.unable_to_load"),
icon: "error",
confirmButtonText: t("common.ok"),
});
};
const showSaveError = () => {
Swal.fire({
title: t("common.error"),
text: t("common.unknown_error"),
icon: "error",
confirmButtonText: t("common.ok"),
});
};
const getKeys = async () => {
// Get the special arrangement
await SessionUser.request('/superuser/user/keys?user_id=' + props.user_id, 'GET').then((response) => {
userKeys.value = response.data.data;
console.log(userKeys.value);
// Check if the key exists
try {
if (userKeys.value.OtherSpecialArrangement) {
OtherSpecialArrangementValue.value = userKeys.value.OtherSpecialArrangement;
}
} catch (e) {
console.log('Key does not exist');
}
}).catch((error) => {
Swal.fire({
title: 'Fejl',
html: '<p>Der skete en fejl ved hentning af dataen. <br>Prøv igen, eller kontakt support.</p>',
icon: 'error',
confirmButtonText: 'OK'
});
});
if (!props.user_id) {
userKeys.value = {};
OtherSpecialArrangementValue.value = null;
return;
}
try {
const response = await SessionUser.request(`/superuser/user/keys?user_id=${props.user_id}`, "GET");
userKeys.value = response?.data?.data || {};
OtherSpecialArrangementValue.value = userKeys.value.OtherSpecialArrangement || null;
} catch (error) {
showLoadError();
}
};
const setKey = async (key, value) => {
// Set the key
await SessionUser.request('/superuser/user/keys', 'POST', {
await SessionUser.request("/superuser/user/keys", "POST", {
user_id: props.user_id,
key: key,
value: value
}).then((response) => {
console.log(response);
getKeys();
value: value,
});
};
getKeys();
const saveForm = async () => {
if (props.readOnly) {
return;
}
// Save the form
console.log('Save the form');
await setKey('OtherSpecialArrangement', OtherSpecialArrangementValue.value ?? '').then((response) => {
console.log(response);
}).catch((error) => {
// Show the error with a sweetalert
Swal.fire({
title: 'Fejl',
html: '<p>Der skete en fejl ved gemningen af dataen. <br>Prøv igen, eller kontakt support.</p>',
icon: 'error',
confirmButtonText: 'OK'
});
});
}
const parseLineBreaks = (text) => {
return text.replace(/\n/g, '<br>');
}
try {
await setKey("OtherSpecialArrangement", OtherSpecialArrangementValue.value ?? "");
await getKeys();
} catch (error) {
showSaveError();
}
};
watch(() => props.user_id, getKeys, { immediate: true });
</script>
<template>
@@ -90,7 +84,12 @@ const parseLineBreaks = (text) => {
<div class="field">
<label class="label is-size-3">{{ t('superuser.user.special_arrangement.label') }}</label>
<div class="control">
<textarea class="textarea" v-model="OtherSpecialArrangementValue" placeholder="Udfyld beskrivelse, hvis der er en intern aftale"></textarea>
<textarea
class="textarea"
v-model="OtherSpecialArrangementValue"
:placeholder="t('superuser.user.special_arrangement.label')"
data-testid="superuser-user-special-arrangement-input"
></textarea>
</div>
</div>
<div class="field" v-if="!props.readOnly">
@@ -103,7 +102,7 @@ const parseLineBreaks = (text) => {
<div class="message is-info" v-if="OtherSpecialArrangementValue">
<div class="message-body">
<span><strong>{{ t('superuser.user.special_arrangement.label') }}</strong><br></span>
<span v-if="OtherSpecialArrangementValue" v-html="parseLineBreaks(OtherSpecialArrangementValue)"></span>
<span v-if="OtherSpecialArrangementValue" class="preserve-lines">{{ OtherSpecialArrangementValue }}</span>
</div>
</div>
</div>
@@ -117,7 +116,7 @@ const parseLineBreaks = (text) => {
<div
class="message-body"
>
<p v-if="OtherSpecialArrangementValue" v-html="parseLineBreaks(OtherSpecialArrangementValue)"></p>
<p v-if="OtherSpecialArrangementValue" class="preserve-lines">{{ OtherSpecialArrangementValue }}</p>
</div>
</div>
</div>
@@ -125,5 +124,7 @@ const parseLineBreaks = (text) => {
</template>
<style scoped>
</style>
.preserve-lines {
white-space: pre-line;
}
</style>
@@ -1,8 +1,8 @@
<script setup>
import { ref, watch } from 'vue';
import { ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
const { t } = useI18n();
@@ -10,91 +10,86 @@ const props = defineProps({
user_id: Number,
readOnly: {
type: Boolean,
default: false
default: false,
},
textOnly: {
type: Boolean,
default: false
}
default: false,
},
});
const OtherVaskeabonnementValue = ref(null);
const userKeys = ref({});
const userKeys = ref([{
key: '',
value: ''
}]);
const showLoadError = () => {
Swal.fire({
title: t("common.error"),
text: t("common.unable_to_load"),
icon: "error",
confirmButtonText: t("common.ok"),
});
};
const showSaveError = () => {
Swal.fire({
title: t("common.error"),
text: t("common.unknown_error"),
icon: "error",
confirmButtonText: t("common.ok"),
});
};
const getKeys = async () => {
// Get the special arrangement
await SessionUser.request('/superuser/user/keys?user_id=' + props.user_id, 'GET').then((response) => {
userKeys.value = response.data.data;
console.log(userKeys.value);
// Check if the key exists
try {
if (userKeys.value.OtherVaskeabonnement) {
OtherVaskeabonnementValue.value = userKeys.value.OtherVaskeabonnement;
}
} catch (e) {
console.log('Key does not exist');
}
}).catch((error) => {
Swal.fire({
title: 'Fejl',
html: '<p>Der skete en fejl ved hentning af dataen. <br>Prøv igen, eller kontakt support.</p>',
icon: 'error',
confirmButtonText: 'OK'
});
});
if (!props.user_id) {
userKeys.value = {};
OtherVaskeabonnementValue.value = null;
return;
}
try {
const response = await SessionUser.request(`/superuser/user/keys?user_id=${props.user_id}`, "GET");
userKeys.value = response?.data?.data || {};
OtherVaskeabonnementValue.value = userKeys.value.OtherVaskeabonnement || null;
} catch (error) {
showLoadError();
}
};
const setKey = async (key, value) => {
// Set the key
await SessionUser.request('/superuser/user/keys', 'POST', {
await SessionUser.request("/superuser/user/keys", "POST", {
user_id: props.user_id,
key: key,
value: value
}).then((response) => {
console.log(response);
getKeys();
value: value,
});
};
getKeys();
const saveForm = async () => {
if (props.readOnly) {
return;
}
// Save the form
console.log('Save the form');
await setKey('OtherVaskeabonnement', OtherVaskeabonnementValue.value ?? '').then((response) => {
console.log(response);
}).catch((error) => {
// Show the error with a sweetalert
Swal.fire({
title: 'Fejl',
html: '<p>Der skete en fejl ved gemningen af dataen. <br>Prøv igen, eller kontakt support.</p>',
icon: 'error',
confirmButtonText: 'OK'
});
});
}
const parseLineBreaks = (text) => {
return text.replace(/\n/g, '<br>');
}
try {
await setKey("OtherVaskeabonnement", OtherVaskeabonnementValue.value ?? "");
await getKeys();
} catch (error) {
showSaveError();
}
};
watch(() => props.user_id, () => {
getKeys();
});
watch(() => props.user_id, getKeys, { immediate: true });
</script>
<template>
<form class="form" @submit.prevent="saveForm" v-if="!readOnly && !textOnly">
<form class="form" @submit.prevent="saveForm" v-if="!props.readOnly && !props.textOnly">
<div class="field">
<label class="label is-size-3">{{ t('superuser.user.wash_subscription.label') }}</label>
<div class="control">
<textarea class="textarea" v-model="OtherVaskeabonnementValue" placeholder="Udfyld beskrivelse, hvis der er en intern aftale"></textarea>
<textarea
class="textarea"
v-model="OtherVaskeabonnementValue"
:placeholder="t('superuser.user.wash_subscription.label')"
data-testid="superuser-user-wash-subscription-note-input"
></textarea>
</div>
</div>
<div class="field">
@@ -107,7 +102,7 @@ watch(() => props.user_id, () => {
<div class="message is-info" v-if="OtherVaskeabonnementValue">
<div class="message-body">
<span><strong>{{ t('superuser.user.wash_subscription.label') }}</strong><br></span>
<span v-if="OtherVaskeabonnementValue" v-html="parseLineBreaks(OtherVaskeabonnementValue)"></span>
<span v-if="OtherVaskeabonnementValue" class="preserve-lines">{{ OtherVaskeabonnementValue }}</span>
</div>
</div>
</div>
@@ -121,7 +116,7 @@ watch(() => props.user_id, () => {
<div
class="message-body"
>
<p v-if="OtherVaskeabonnementValue" v-html="parseLineBreaks(OtherVaskeabonnementValue)"></p>
<p v-if="OtherVaskeabonnementValue" class="preserve-lines">{{ OtherVaskeabonnementValue }}</p>
</div>
</div>
</div>
@@ -129,5 +124,7 @@ watch(() => props.user_id, () => {
</template>
<style scoped>
</style>
.preserve-lines {
white-space: pre-line;
}
</style>
@@ -35,7 +35,6 @@ const washSubscriptionVehicles = ref(null);
const load = () => {
if (!props.user.customer_number.value) {
console.log('No customer number found');
return;
}
// Load the vehicles
@@ -142,13 +141,8 @@ const showAddSubscriptionInvoiceFormOtherMonth = () => {
}).then((result) => {
if (result.isConfirmed) {
const selected = result.value;
console.log(selected);
const monthNumber = selected.substring(5, 7);
const yearNumber = selected.substring(0, 4);
// Redirect to the invoice creation page
console.log(
'Month: ' + monthNumber + ', Year: ' + yearNumber
);
createVehicleSubscriptionInvoice(monthNumber, yearNumber);
}
});
@@ -164,7 +158,6 @@ const createVehicleSubscriptionInvoice = (month, year) => {
year: year,
}
).then((response) => {
console.log(response);
// Reload the data
load();
// Redirect to the invoice creation page
@@ -1369,6 +1369,7 @@ const scheduleRecentCompletedWashRefresh = (attempt = 0) => {
}
recentCompletedRefreshTimeout.value = hostWindow.setTimeout(() => {
recentCompletedRefreshTimeout.value = null;
if (isMyWashStartUnmounted.value) {
return;
}
+133 -4
View File
@@ -4,6 +4,8 @@ import { isCompactProject } from "./support/projects";
const adminPermissions = [
"admin",
"list_department_daily_reports",
"list_bookings",
"create_department_daily_report_complaints",
"department_access_1",
"department_access_2",
@@ -26,8 +28,26 @@ const buildOverviewPayload = ({
complaintsMessage = null,
metrics = {},
products = [
{ product_id: 24, slug: "spot-free-lastbil", title: "Spot Free (Lastbil)", state: "ready", value: 3, out_of: 8 },
{ product_id: 25, slug: "faelg-flex", title: "Fælg flex pr. enhed", state: "ready", value: 2, out_of: 8 },
{
product_id: 24,
slug: "spot-free-lastbil",
title: "Spot Free (Lastbil)",
state: "ready",
value: 3,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
{
product_id: 25,
slug: "faelg-flex",
title: "Fælg flex pr. enhed",
state: "ready",
value: 2,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
{
product_id: 27,
slug: "extraordinary-10-min",
@@ -35,6 +55,8 @@ const buildOverviewPayload = ({
state: "ready",
value: 1,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
{
product_id: 26,
@@ -43,8 +65,19 @@ const buildOverviewPayload = ({
state: "ready",
value: 4,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
{
product_id: 21,
slug: "undervognsskyl",
title: "Undervognsskyl pr. enhed",
state: "ready",
value: 2,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
{ product_id: 21, slug: "undervognsskyl", title: "Undervognsskyl pr. enhed", state: "ready", value: 2, out_of: 8 },
{
product_id: 22,
slug: "double-duty-kemi",
@@ -52,6 +85,8 @@ const buildOverviewPayload = ({
state: "ready",
value: 5,
out_of: 8,
target_percentage: null,
target_department_id: null,
},
],
...overrides
@@ -92,6 +127,9 @@ async function mockDailyReportDependencies(
body: Record<string, unknown>
) => Promise<ReturnType<typeof json>> | ReturnType<typeof json>;
complaintCustomerLookupHandler?: (url: URL) => Promise<ReturnType<typeof json>> | ReturnType<typeof json>;
productTargetHandler?: (
body: Record<string, unknown>
) => Promise<ReturnType<typeof json>> | ReturnType<typeof json>;
permissions?: string[];
}
| ((url: URL) => Promise<ReturnType<typeof json>> | ReturnType<typeof json>)
@@ -114,6 +152,16 @@ async function mockDailyReportDependencies(
},
}));
const complaintCustomerLookupHandler = options.complaintCustomerLookupHandler || (async () => json({ data: [] }));
const productTargetHandler =
options.productTargetHandler ||
(async (body) =>
json({
data: {
department_id: body.department_id,
product_id: body.product_id,
target_percentage: body.target_percentage ?? null,
},
}));
await seedAuthenticatedState(page);
await mockApi(page, {
@@ -167,6 +215,12 @@ async function mockDailyReportDependencies(
await route.fulfill(response);
});
await page.route(/\/departments\/daily-reports\/product-targets$/i, async (route) => {
const body = route.request().postDataJSON() as Record<string, unknown>;
const response = await productTargetHandler(body);
await route.fulfill(response);
});
await page.route(/\/departments\/daily-reports(?:\/overview)?(\?.*)?$/i, async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/departments/daily-reports/overview")) {
@@ -199,7 +253,7 @@ test.describe("Admin daily report", () => {
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-23");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
await expect(page.locator("[data-testid^='daily-report-tile-']")).toHaveCount(17);
await expect(page.locator(".card[data-testid^='daily-report-tile-']")).toHaveCount(17);
await expect(page.getByTestId("daily-report-tile-bookings")).toContainText("4");
await expect(page.getByTestId("daily-report-tile-complaints")).toContainText("2");
await expect(page.getByTestId("daily-report-complaints-add-button")).toBeVisible();
@@ -216,6 +270,81 @@ test.describe("Admin daily report", () => {
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
});
test("lets permitted users set a target from an optional product percentage", async ({ page }) => {
let targetPercentage: number | null = null;
const targetRequests: Array<Record<string, unknown>> = [];
await mockDailyReportDependencies(page, {
permissions: [...adminPermissions, "set_department_daily_report_product_targets"],
overviewHandler: async () =>
json(
buildOverviewPayload({
products: [
{
product_id: 24,
slug: "spot-free-lastbil",
title: "Spot Free (Lastbil)",
state: "ready",
value: 3,
out_of: 8,
target_percentage: targetPercentage,
target_department_id: targetPercentage === null ? null : 1,
},
],
})
),
productTargetHandler: async (body) => {
targetRequests.push(body);
targetPercentage = body.target_percentage === null ? null : Number(body.target_percentage);
return json({
data: {
department_id: body.department_id,
product_id: body.product_id,
target_percentage: targetPercentage,
},
});
},
});
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-23");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
const productTile = page.getByTestId("daily-report-tile-spot-free-lastbil");
const percentageButton = page.getByTestId("daily-report-tile-spot-free-lastbil-percentage-button");
await expect(percentageButton).toBeVisible();
await expect(percentageButton).toHaveClass(/is-outlined/);
await expect(page.locator('[data-testid="daily-report-tile-spot-free-lastbil-target-button"]')).toHaveCount(0);
await percentageButton.click();
await page.getByTestId("daily-report-tile-spot-free-lastbil-target-input").fill("55.5");
await page.getByTestId("daily-report-tile-spot-free-lastbil-target-save").click();
await expect.poll(() => targetRequests.length).toBe(1);
expect(targetRequests[0]).toMatchObject({
department_id: 1,
product_id: 24,
target_percentage: 55.5,
});
await expect(page.getByTestId("daily-report-tile-spot-free-lastbil-target-button")).toContainText("55.5%");
await expect(productTile.locator(".daily-report-count__target-background-icon")).toHaveCount(1);
});
test("does not open the product target editor without permission", async ({ page }) => {
await mockDailyReportDependencies(page, {
overviewHandler: async () => json(buildOverviewPayload()),
permissions: adminPermissions,
});
await page.goto("/admin/1/modules/daily-report?departmentIds=1&dateFrom=2026-03-23&dateTo=2026-03-23");
await expect(page.getByTestId("daily-report-page")).toBeVisible();
await page.getByTestId("daily-report-tile-spot-free-lastbil-percentage-button").click();
await expect(page.locator('[data-testid="daily-report-tile-spot-free-lastbil-target-input"]')).toHaveCount(0);
await expect(page.locator('[data-testid="daily-report-tile-spot-free-lastbil-target-button"]')).toHaveCount(0);
});
test("renders rounded-up weather hours and keeps unknown productivity gray", async ({ page }) => {
await mockDailyReportDependencies(page, {
overviewHandler: async () => json(buildOverviewPayload()),
@@ -18,6 +18,8 @@ const json = (body: unknown, status = 200) => ({
});
const pageReadyTimeout = process.env.CI ? 30_000 : 15_000;
const pageBootstrapTimeoutMs = 45_000;
const setupNotificationSmsApi = async (page: Page) => {
let listRequestCount = 0;
const mutations: Array<{ method: string; body?: Record<string, unknown>; id?: number }> = [];
@@ -118,6 +120,21 @@ test("@smoke department notification SMS active toggle and delete refresh the ta
await page.goto("/admin/1/modules/notifications", { waitUntil: "domcontentloaded" });
await expect.poll(() => notificationSmsApi.listRequestCount, { timeout: pageReadyTimeout }).toBeGreaterThan(0);
const pageTitle = page.getByTestId("department-notifications-title");
await expect(pageTitle).toBeVisible({ timeout: pageBootstrapTimeoutMs });
await expect(pageTitle).toContainText(/notifikationer|notifications/i);
const pageSubtitle = page.getByTestId("department-notifications-subtitle");
await expect(pageSubtitle).toBeVisible();
await expect(pageSubtitle).toContainText(/sms/i);
await expect(page.locator(".message").filter({ hasText: /sms/i })).toHaveCount(0);
const addPhoneButton = page.getByTestId("department-notification-sms-add-button");
await expect(addPhoneButton).toBeVisible();
await expect(addPhoneButton).toHaveClass(/is-primary/);
await expect(addPhoneButton).not.toHaveClass(/is-info/);
await expect(addPhoneButton.locator(".fa-plus")).toBeVisible();
const row = page.locator("tr", { hasText: "Dispatch line" });
await expect(row).toBeVisible({ timeout: pageReadyTimeout });
const enabledToggle = page.getByTestId("department-notification-sms-enabled-toggle-501");
+24 -1
View File
@@ -1844,7 +1844,7 @@ test("shows live Stripe payment status and cancellation for open card payment or
await expect(page.getByTestId("pos-order-stripe-email-cancel")).toBeVisible();
await expect(page.getByTestId("pos-order-print-receipt")).toHaveCount(0);
await expect(page.getByRole("button", { name: /Fuldfør ordre|Complete order/ })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Hent faktura|Get invoice/ })).toHaveCount(0);
await expect(page.locator('[data-action-key="economic-invoice-pdf-download"]')).toBeDisabled();
await page.getByTestId("pos-order-stripe-email-cancel").click();
await deleteStripeInvoiceRequest;
@@ -2950,6 +2950,28 @@ test.describe("Admin POS Orders - desktop locked states", () => {
await expectOrderTotal(page, 1372);
});
test("disables invoice PDF download while the Economic invoice id is missing", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: createPosFixture({
economicModuleOrdersByOrderId: {
54518: {
invoice_id: null,
invoice_draft_id: 88012,
},
},
}),
});
await primeOperatorSession(page, "pos-orders-draft-invoice-token");
await openOrderDetail(page);
const downloadButton = page.locator('[data-action-key="economic-invoice-pdf-download"]');
await expect(downloadButton).toBeVisible();
await expect(downloadButton).toBeDisabled();
});
test("hides item mutation controls when the order is already invoiced", async ({ page }) => {
await mockApi(page, {
authenticated: true,
@@ -2970,6 +2992,7 @@ test.describe("Admin POS Orders - desktop locked states", () => {
await expect(page.getByTestId("pos-order-item-edit-9101")).toHaveCount(0);
await expect(page.getByTestId("pos-order-item-delete-9101")).toHaveCount(0);
await expect(page.getByTestId("pos-order-add-item")).toHaveCount(0);
await expect(page.locator('[data-action-key="economic-invoice-pdf-download"]')).toBeEnabled();
await expectOrderTotal(page, 1372);
});
});
+4 -2
View File
@@ -825,7 +825,7 @@ test.describe("Coolify infrastructure management", () => {
await expect(actions.getByRole("button", { name: "Reconcile Coolify" })).toBeVisible();
await expect(actions.getByRole("button", { name: "Coolify failover" })).toBeVisible();
await actions.getByRole("button", { name: "Reconcile Coolify" }).click();
expect(state.reconciledTargets.length).toBeGreaterThan(0);
await expect.poll(() => state.reconciledTargets.length).toBeGreaterThan(0);
});
test("resumes Coolify-backed provisioning after page reload", async ({ page }) => {
@@ -1044,7 +1044,9 @@ test.describe("Coolify infrastructure management", () => {
await actions.locator(".action-settings-wheel-trigger").click();
await actions.getByRole("button", { name: /Rename|Omdøb/ }).click();
expect(state.renamedHosts).toContainEqual({ kind: "database", id: 2, label: "mariadb-manual-replica" });
await expect
.poll(() => state.renamedHosts)
.toContainEqual({ kind: "database", id: 2, label: "mariadb-manual-replica" });
await expect(page.getByTestId("replication-host-database-2")).toContainText("mariadb-manual-replica");
});
});
+6
View File
@@ -350,6 +350,10 @@ async function readStoredToken(page: Page) {
}
}
async function waitForAppRootMounted(page: Page) {
await expect.poll(async () => page.locator("#app > *").count(), { timeout: AUTH_TIMEOUT }).toBeGreaterThan(0);
}
async function settleAuthenticatedNavigation(page: Page, targetPath: string, targetUrl: RegExp) {
await expect.poll(() => readStoredToken(page), { timeout: AUTH_TIMEOUT }).not.toBeNull();
const navigateToTarget = async () => {
@@ -376,6 +380,8 @@ async function settleAuthenticatedNavigation(page: Page, targetPath: string, tar
await navigateToTarget();
await expect.poll(() => page.url(), { timeout: AUTH_TIMEOUT }).toMatch(targetUrl);
}
await waitForAppRootMounted(page);
}
/**
+18
View File
@@ -6,7 +6,12 @@ import { scanViewTranslationKeys, type ViewTranslationKeyUsage } from "./support
const ACTIVE_LOCALES = ["da", "en", "sv", "de", "no"] as const;
const GENERATED_LOCALES_DIRECTORY = path.join(process.cwd(), "src", "i18n", "generated");
const REVIEWED_NON_LITERAL_CALLS = new Set([
"src/views/backoffice/LimitedBackofficeEmployees.vue|t|return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;",
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue|t|const translated = t(key, params);",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|return t(`replication.status.${status || "unknown"}`);',
"src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|te|if (key && te(key)) {",
"src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|return t(key, params);",
@@ -14,9 +19,18 @@ const REVIEWED_NON_LITERAL_CALLS = new Set([
'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|<li v-for="key in redisComposeInstructionKeys" :key="key">{{ t(key) }}</li>',
'src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|<li v-for="key in minioComposeInstructionKeys" :key="key">{{ t(key) }}</li>',
"src/views/dashboards/superUserDashboard/system/ReplicationManagement.vue|t|<td>{{ t(`replication.roles.${host.role}`) }}</td>",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
"src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue|t|const tr = (key, params = {}) => t(`configuration.release_manager.${key}`, params);",
"src/views/dashboards/superUserDashboard/configuration/ConfigurationReleaseManager.vue|t|const translated = t(fullKey, params);",
"src/views/dashboards/superUserDashboard/ErrorReports.vue|t|const value = t(key);",
"src/views/backoffice/LimitedBackofficeEmployees.vue|t|return `${option.flag} +${option.value} ${t(`templates.limited_backoffice.employees.country_codes.${option.labelKey}`)}`;",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
'src/views/dashboards/userDashboard/wash/components/LaneSelectionSection.vue|$t|:title="option.titleKey ? $t(option.titleKey) : null"',
'src/views/dashboards/userDashboard/wash/components/LaneSelectionSection.vue|$t|:aria-label="option.titleKey ? $t(option.titleKey) : null"',
"src/views/dashboards/userDashboard/wash/components/LaneSelectionSection.vue|$t|{{ $t(option.statusLabelKey) }}",
@@ -24,6 +38,10 @@ const REVIEWED_NON_LITERAL_CALLS = new Set([
"src/views/dashboards/userDashboard/wash/components/WashTypeSelector.vue|$t|{{ $t(option.statusLabelKey) }}",
'src/views/dashboards/userDashboard/wash/MyWashStart.vue|$t|:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"',
"src/views/dashboards/userDashboard/wash/MyWashStart.vue|$t|{{ $t(confirmActionLabelKey) }}",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.page_access.${permission}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.page_access.${permission}.description`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|label: t(`roles.permissions.groups.${group.key}.label`),",
"src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue|t|description: t(`roles.permissions.groups.${group.key}.description`),",
]);
const readGeneratedJson = (fileName: string) => {
+1 -1
View File
@@ -55,7 +55,7 @@ async function suppressVueDevtoolsOverlay(page) {
async function primeSuperuserSession(page) {
const token = "superuser-e2e-token";
await primeMockSession(page, { token });
await primeMockSession(page, { token, bootPath: null });
}
async function prepareInvoiceDistributionPage(page, overrides = {}) {
+16
View File
@@ -1112,6 +1112,7 @@ test.describe("Invoicing period tab", () => {
const usageOrderRequests = [];
const fastLinkRequests = [];
const automationAcceptRequests = [];
const importUsageRequests = [];
let automation = {
id: 7101,
status: "suggested",
@@ -1133,6 +1134,15 @@ test.describe("Invoicing period tab", () => {
await openPeriodView(page);
await page.route("**/modules/xlvask/tasks/import-usage**", async (route) => {
const url = new URL(route.request().url());
importUsageRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
await route.fulfill(json({ data: "Usage logs imported" }));
});
await page.route("**/modules/xlvask/services/usage/orders**", async (route) => {
const path = new URL(route.request().url()).pathname;
if (
@@ -1285,6 +1295,12 @@ test.describe("Invoicing period tab", () => {
request.filters.includes("StartTime-date_to:2026-03-31")
)
).toBe(true);
await selfWashView.getByRole("button", { name: /import/i }).click();
await expect.poll(() => importUsageRequests.length).toBe(1);
expect(importUsageRequests[0]).toEqual({
dateFrom: "2026-03-01",
dateTo: "2026-03-31",
});
});
test("@smoke period view shows flags and saves automatic flag decisions", async ({ page }) => {
+230 -15
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test, type Page } from "@playwright/test";
import { API_HOST, seedAuthenticatedState } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
@@ -14,6 +14,7 @@ const limitedManagerPermissions = [
"limited_backoffice_prices_manage",
"limited_backoffice_employees_manage",
"department_access_1",
"department_access_2",
];
const sessionData = {
@@ -62,7 +63,10 @@ const adminLimitedSessionData = {
permissions: ["user", "admin", "limited_backoffice_access", "department_access_1"],
};
const assignedDepartments = [{ id: 1, name: "Assigned Depot", description: "", visible: true, archived: false }];
const assignedDepartments = [
{ id: 1, name: "Assigned Depot", description: "", visible: true, archived: false },
{ id: 2, name: "Remote Depot", description: "", visible: true, archived: false },
];
const pricePayload = {
department: { id: 1, name: "Assigned Depot", description: "" },
@@ -144,6 +148,7 @@ const rolePermissionGroups = {
"view_orders",
"create_orders",
"edit_orders",
"complete_orders",
"view_order_items",
"create_order_items",
"update_order_lines",
@@ -151,6 +156,43 @@ const rolePermissionGroups = {
"charge_orders",
],
},
{
key: "products",
capabilities: ["view_product_catalog", "view_product_recommendations"],
},
{
key: "customers",
capabilities: [
"search_customers",
"view_customer_details",
"view_customer_notes",
"add_customer_notes",
"view_customer_flags",
],
},
{
key: "vehicles",
capabilities: ["search_vehicles", "view_vehicle_matches", "view_vehicle_history"],
},
{
key: "attachments",
capabilities: ["view_order_attachments", "add_order_attachments", "download_order_attachments"],
},
{
key: "scanner",
capabilities: ["view_plate_scans"],
},
{
key: "bookings",
capabilities: [
"view_department_bookings",
"view_own_bookings",
"update_bookings",
"create_bookings",
"mark_bookings_complete",
"send_booking_confirmations",
],
},
],
booking_coordinator: [
{ key: "account", capabilities: ["sign_in", "view_own_permissions"] },
@@ -180,6 +222,7 @@ const rolePermissionGroups = {
"create_orders",
"edit_orders",
"delete_orders",
"complete_orders",
"view_order_items",
"create_order_items",
"update_order_lines",
@@ -187,6 +230,32 @@ const rolePermissionGroups = {
"charge_orders",
],
},
{
key: "products",
capabilities: ["view_product_catalog", "view_product_recommendations"],
},
{
key: "customers",
capabilities: [
"search_customers",
"view_customer_details",
"view_customer_notes",
"add_customer_notes",
"view_customer_flags",
],
},
{
key: "vehicles",
capabilities: ["search_vehicles", "view_vehicle_matches", "view_vehicle_history"],
},
{
key: "attachments",
capabilities: ["view_order_attachments", "add_order_attachments", "download_order_attachments"],
},
{
key: "scanner",
capabilities: ["view_plate_scans"],
},
{
key: "bookings",
capabilities: [
@@ -209,6 +278,7 @@ const rolePermissionGroups = {
"create_orders",
"edit_orders",
"delete_orders",
"complete_orders",
"view_order_items",
"create_order_items",
"update_order_lines",
@@ -216,6 +286,32 @@ const rolePermissionGroups = {
"charge_orders",
],
},
{
key: "products",
capabilities: ["view_product_catalog", "view_product_recommendations"],
},
{
key: "customers",
capabilities: [
"search_customers",
"view_customer_details",
"view_customer_notes",
"add_customer_notes",
"view_customer_flags",
],
},
{
key: "vehicles",
capabilities: ["search_vehicles", "view_vehicle_matches", "view_vehicle_history"],
},
{
key: "attachments",
capabilities: ["view_order_attachments", "add_order_attachments", "download_order_attachments"],
},
{
key: "scanner",
capabilities: ["view_plate_scans"],
},
{
key: "bookings",
capabilities: [
@@ -235,8 +331,18 @@ const rolePermissionGroups = {
],
};
const remotePricePayload = {
...pricePayload,
department: { id: 2, name: "Remote Depot", description: "" },
};
const rolesPayload = [
{ key: "viewer", label: "Viewer", description: "Can view.", permission_groups: rolePermissionGroups.viewer },
{
key: "viewer",
label: "Deactivated",
description: "No order, booking, or management access.",
permission_groups: rolePermissionGroups.viewer,
},
{ key: "cashier", label: "Cashier", description: "Can sell.", permission_groups: rolePermissionGroups.cashier },
{
key: "booking_coordinator",
@@ -263,7 +369,7 @@ const employeesPayload = [
{
id: 501,
user_id: 501,
customer_number: 900000501,
customer_number: 0,
display_name: "Casey Clerk",
email: "casey@example.com",
phone_country_code: 45,
@@ -274,6 +380,17 @@ const employeesPayload = [
created_at: "2026-01-01 00:00:00",
updated_at: "2026-01-01 00:00:00",
},
{
id: 502,
customer_number: 0,
display_name: "Riley Remote",
email: "riley@example.com",
active: true,
role: { key: "viewer", label: "Deactivated", description: "No order, booking, or management access." },
departments: [{ id: 2, name: "Remote Depot" }],
created_at: "2026-01-01 00:00:00",
updated_at: "2026-01-01 00:00:00",
},
];
async function seedLimitedBackofficeSession(page, token = "limited-backoffice-token") {
@@ -350,6 +467,11 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
return;
}
if (pathname.endsWith("/limited-backoffice/departments/2/prices") && method === "GET") {
await route.fulfill(json({ data: remotePricePayload }));
return;
}
if (pathname.endsWith("/limited-backoffice/departments/1/prices") && method === "PUT") {
const body = request.postDataJSON?.() || null;
priceUpdateCalls.push(body);
@@ -462,6 +584,20 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData, opt
};
}
async function expectDepartmentSelectorInTitleRow(page: Page) {
const headingBox = await page.getByRole("heading", { name: "Backoffice" }).boundingBox();
const selectBox = await page.getByTestId("limited-backoffice-department-select").boundingBox();
expect(headingBox).not.toBeNull();
expect(selectBox).not.toBeNull();
if (!headingBox || !selectBox) {
return;
}
expect(selectBox.x).toBeGreaterThan(headingBox.x);
expect(Math.abs(selectBox.y + selectBox.height / 2 - (headingBox.y + headingBox.height / 2))).toBeLessThan(48);
}
test.describe("Limited backoffice", () => {
test("shows the limited backoffice header shortcut only on department-scoped admin pages", async ({
page,
@@ -539,7 +675,9 @@ test.describe("Limited backoffice", () => {
await page.goto("/backoffice/departments/1/prices");
await expect(page.getByTestId("limited-prices-title")).toBeVisible();
await expectDepartmentSelectorInTitleRow(page);
await expect(page.getByTestId("limited-backoffice-department-select")).toContainText("Assigned Depot");
await expect(page.getByTestId("limited-backoffice-department-select")).toContainText("Remote Depot");
await expect(page.getByTestId("limited-backoffice-department-select")).not.toContainText("Other Depot");
await expect(page.getByTestId("limited-price-row-101")).toContainText("Truck wash");
await expect(page.getByTestId("limited-price-input-101")).toHaveValue("125");
@@ -718,12 +856,16 @@ test.describe("Limited backoffice", () => {
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await page.goto("/backoffice/departments/1/employees");
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
await expectDepartmentSelectorInTitleRow(page);
await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Clerk");
await expect(page.getByTestId("limited-employee-user-id-501")).toHaveText("501");
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+45 12345678");
await expect(page.getByTestId("limited-employee-row-502")).toHaveCount(0);
await expect(page.getByTestId("limited-employee-department-1")).toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).not.toBeChecked();
await expect(page.locator("#limited-employee-role option")).toHaveCount(5);
await expect(page.getByTestId("limited-employee-role")).not.toContainText("Superuser");
await expect(page.locator("body")).not.toContainText("department_access_1");
@@ -797,17 +939,36 @@ test.describe("Limited backoffice", () => {
await expect(modal).toBeVisible();
await expect(modal).toContainText("Role permissions");
await expect(modal).toContainText("Every capability is limited to the departments selected for the employee.");
await expect(page.getByTestId("limited-role-permissions-role-viewer")).toContainText("Deactivated");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Cashier");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Orders");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Products");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Customers");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Vehicles");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Attachments");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Scanners");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Bookings");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("View orders");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Complete orders");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("View product catalog");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Search customers");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Search vehicles");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Add order attachments");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("View plate scans");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Create bookings");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Create order lines");
await expect(page.getByTestId("limited-role-permissions-role-viewer")).toContainText("Selected");
for (const rawPermission of [
"list_orders",
"add_order",
"fetch_order",
"edit_order_items",
"delete_order_items",
"search_customers",
"list_order_attachments",
"list_products",
"add_bookings",
"department_timebookings_entries_get",
"limited_backoffice_employees_manage",
"department_access_1",
@@ -834,15 +995,14 @@ test.describe("Limited backoffice", () => {
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
await expect(page.getByTestId("limited-employee-save")).toBeDisabled();
await expect(page.getByTestId("limited-employee-save")).toHaveClass(/is-fullwidth/);
await expect(page.getByTestId("limited-employee-departments").locator(".switch")).toHaveCount(1);
await expect(page.getByTestId("limited-employee-departments").locator(".switch")).toHaveCount(2);
await expect(page.getByTestId("limited-employee-department-1")).toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).not.toBeChecked();
await expect(page.getByTestId("limited-employee-phone-country-code")).toContainText("+45");
await page.getByTestId("limited-employee-name").fill("No Phone Worker");
await page.getByTestId("limited-employee-email").fill("no-phone@example.com");
await page.getByTestId("limited-employee-password").fill("Secret123!");
await expect(page.getByTestId("limited-employee-save")).toBeDisabled();
await page.getByTestId("limited-employee-department-1").click();
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-save").click();
@@ -856,14 +1016,13 @@ test.describe("Limited backoffice", () => {
role_key: "viewer",
department_ids: [1],
});
await expect(page.getByTestId("limited-employee-row-902")).toContainText("No Phone Worker");
await expect(page.getByTestId("limited-employee-row-903")).toContainText("No Phone Worker");
await page.getByTestId("limited-employee-name").fill("Phone Worker");
await page.getByTestId("limited-employee-email").fill("phone@example.com");
await page.getByTestId("limited-employee-phone-country-code").selectOption("358");
await page.getByTestId("limited-employee-phone").fill("87654321");
await page.getByTestId("limited-employee-password").fill("Secret123!");
await page.getByTestId("limited-employee-department-1").click();
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-save").click();
@@ -877,8 +1036,8 @@ test.describe("Limited backoffice", () => {
role_key: "viewer",
department_ids: [1],
});
await expect(page.getByTestId("limited-employee-row-903")).toContainText("Phone Worker");
await expect(page.getByTestId("limited-employee-phone-903")).toHaveText("+358 87654321");
await expect(page.getByTestId("limited-employee-row-904")).toContainText("Phone Worker");
await expect(page.getByTestId("limited-employee-phone-904")).toHaveText("+358 87654321");
});
test("edits employee contact details without requiring a new password", async ({ page }) => {
@@ -912,19 +1071,75 @@ test.describe("Limited backoffice", () => {
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+358 87654321");
});
test("keeps department context across employee access navigation", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await expect(page).toHaveURL(/\/backoffice\/departments\/1\/employees$/);
await expect(page.getByTestId("limited-backoffice-tab-prices")).toHaveAttribute(
"href",
"/backoffice/departments/1/prices"
);
await expect(page.getByTestId("limited-backoffice-tab-employees")).toHaveAttribute(
"href",
"/backoffice/departments/1/employees"
);
await page.getByTestId("limited-backoffice-department-select").selectOption("2");
await expect(page).toHaveURL(/\/backoffice\/departments\/2\/employees$/);
await expect(page.getByTestId("limited-employee-row-501")).toHaveCount(0);
await expect(page.getByTestId("limited-employee-row-502")).toContainText("Riley Remote");
await expect(page.getByTestId("limited-employee-department-1")).not.toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).toBeChecked();
await expect(page.getByTestId("limited-backoffice-tab-prices")).toHaveAttribute(
"href",
"/backoffice/departments/2/prices"
);
await page.getByTestId("limited-backoffice-tab-prices").click();
await expect(page).toHaveURL(/\/backoffice\/departments\/2\/prices$/);
await expect(page.getByTestId("limited-backoffice-tab-employees")).toHaveAttribute(
"href",
"/backoffice/departments/2/employees"
);
expect(api.forbiddenCalls).toEqual([]);
});
test("does not render data for a department outside the manager scope", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/2/prices");
await page.goto("/backoffice/departments/3/prices");
await expect(page.getByTestId("limited-prices-forbidden")).toBeVisible();
await expect(page.locator("body")).not.toContainText("Secret Depot");
await expect(page.getByTestId("limited-prices-table")).toHaveCount(0);
expect(api.calls.some((call) => call.includes("/limited-backoffice/departments/2/prices"))).toBe(false);
expect(api.calls.some((call) => call.includes("/limited-backoffice/departments/3/prices"))).toBe(false);
expect(api.forbiddenCalls).toEqual([]);
});
test("does not render employee data for a department outside the manager scope", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/3/employees");
await expect(page.getByTestId("limited-employees-forbidden")).toBeVisible();
await expect(page.locator("body")).not.toContainText("Secret Depot");
await expect(page.getByTestId("limited-employees-table")).toHaveCount(0);
expect(api.calls.some((call) => call.includes("/limited-backoffice/employees"))).toBe(false);
expect(api.forbiddenCalls).toEqual([]);
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 107 KiB

+5 -1
View File
@@ -2328,7 +2328,11 @@ test("superusers manage release settings, assignments, integrations, and sync op
await expect(page.getByTestId("release-assignment-subject-manual-user-909")).toContainText("User #909");
await expect(page.getByTestId("release-assignment-subject-manual-subuser-909")).toContainText("Subuser #909");
await expect(page.getByTestId("release-assignment-subject-manual-customer-909")).toContainText("Customer #909");
await page.getByTestId("release-assignment-subject-manual-subuser-909").click({ force: true });
await page
.getByTestId("release-assignment-subject-autocomplete")
.getByRole("button", { name: /Subuser #909/ })
.click();
await expect(assignmentSubjectInput).toHaveValue("Subuser #909 - Use typed value subuser:909");
await page.getByTestId("release-assignment-form").getByRole("button", { name: "Assign" }).click();
expect(state.assignmentPayloads[state.assignmentPayloads.length - 1]).toMatchObject({
subject_type: "subuser",
+23 -2
View File
@@ -27,7 +27,7 @@ async function primeSession(page, { token, permissions, sessionData = {} }) {
await seedAuthenticatedState(page, token);
}
async function mockSelectedSubuserGrant(page, { customerNumber = 12345679, permissions = ["user"] } = {}) {
async function mockSelectedSubuserGrant(page, { customerNumber = 12345679, permissions = ["SELFSERVE_LIST"] } = {}) {
await page.route("**/subusers/me", async (route) => {
await route.fulfill({
status: 200,
@@ -249,6 +249,7 @@ test.describe("Self-serve wash", () => {
},
selfServe: true,
});
api.selfServe.commandResponseDelayMs = [500, 0];
await primeSession(page, {
token: "self-serve-user-token",
permissions: ["user"],
@@ -1045,7 +1046,7 @@ test.describe("Self-serve wash", () => {
customerNumberInput: "777",
});
await mockApi(page, {
const api = await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
@@ -1063,6 +1064,25 @@ test.describe("Self-serve wash", () => {
],
},
});
api.selfServe.previewByKey["7:ZZ00000"] = {
...api.selfServe.previewByKey["7:ZZ00000"],
questions: [],
tasks: [],
};
api.selfServe.summaryBySessionId[601] = {
...api.selfServe.summaryBySessionId[601],
questions: [],
tasks: [],
};
api.selfServe.summaryByKey["7:ZZ00000"] = {
session: api.selfServe.previewByKey["7:ZZ00000"].session,
lane: api.selfServe.previewByKey["7:ZZ00000"].lane,
questions: [],
conditions: [],
rules: [],
tasks: [],
events: [{ id: 4, type: "STARTED", created_at: "2026-01-01T11:01:00.000Z" }],
};
await primeSession(page, {
token: "self-serve-start-retry-token",
permissions: ["user"],
@@ -1163,6 +1183,7 @@ test.describe("Self-serve wash", () => {
authenticated: true,
permissions: ["user"],
selfServe: {
commandResponseDelayMs: [500, 0],
commandResponses: [
{
success: false,
+21 -1
View File
@@ -15,6 +15,7 @@ type ProductRouteOptions = {
categories?: unknown;
productOptions?: unknown;
economicProducts?: unknown;
onProductsRequest?: (url: URL) => void;
};
const collectBrowserErrors = (page: Page) => {
@@ -42,6 +43,7 @@ const stubSuperuserProductRoutes = async (
categories = [],
productOptions = [],
economicProducts = [],
onProductsRequest,
}: ProductRouteOptions
) => {
await seedAuthenticatedState(page, token);
@@ -154,6 +156,7 @@ const stubSuperuserProductRoutes = async (
}
if (pathname.endsWith("/products") && method === "GET") {
onProductsRequest?.(url);
const perPage = Number(url.searchParams.get("limit") || "100");
await route.fulfill(
@@ -273,7 +276,14 @@ test.describe("Superuser products layout", () => {
];
await page.setViewportSize({ width: 1280, height: 720 });
await stubSuperuserProductRoutes(page, { products, categories, productOptions, economicProducts });
const productRequestUrls: string[] = [];
await stubSuperuserProductRoutes(page, {
products,
categories,
productOptions,
economicProducts,
onProductsRequest: (url) => productRequestUrls.push(url.toString()),
});
await page.goto("/superuser/products");
@@ -316,6 +326,16 @@ test.describe("Superuser products layout", () => {
await expect(page.getByTestId("superuser-product-name")).toContainText("Trækker");
await expect(page.getByRole("heading", { name: "Trækker" })).toBeVisible();
const detailRequest = productRequestUrls
.map((url) => new URL(url))
.find((url) => url.searchParams.get("id") === "1");
expect(detailRequest).toBeDefined();
expect(detailRequest?.searchParams.get("department_id")).toBeNull();
expect(detailRequest?.searchParams.get("customer_id")).toBeNull();
expect(detailRequest?.searchParams.get("category_id")).toBeNull();
expect(detailRequest?.searchParams.get("final_price")).toBe("false");
expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
@@ -43,11 +43,46 @@ const departments = [
},
];
const roleTemplatePermissions = [
"user",
"permissions_list_own",
"list_departments",
"list_orders",
"fetch_order",
"statistics_orders_new",
];
const limitedBackofficePermissionTemplates = [
{
key: "viewer",
label: "Deactivated",
description: "Keeps the employee registered without order, booking, or management permissions.",
permissions: ["user", "permissions_list_own"],
},
{
key: "cashier",
label: "Cashier",
description: "Can work with POS orders for assigned departments.",
permissions: roleTemplatePermissions,
},
];
type PermissionCall = {
action: "add" | "remove";
permission: string;
};
const roleListEnvelope = (rows: Array<Record<string, unknown>>) => ({
data: rows,
meta: {
pagination: {
page: 1,
per_page: 100,
total: rows.length,
},
},
});
const openGroupTab = async (page: Page, label: RegExp) => {
await page.locator(".tabs").getByText(label).click();
};
@@ -147,6 +182,107 @@ const bootRolePermissionsPage = async (page: Page, routeOptions = {}) => {
};
test.describe("Superuser role permissions", () => {
test("applies limited backoffice templates from the role action wheel in add-only mode", async ({ page }) => {
const role = {
id: 2,
name: "Operations manager",
description: "Can manage operations.",
created_at: "2026-07-06T08:00:00.000Z",
permissions: ["user", "list_orders"],
};
const calls: PermissionCall[] = [];
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
await seedAuthenticatedState(page, "superuser-roles-template-token");
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user", "list_roles", "add_role_permission"],
sessionData: {
group_id: 1,
},
});
await page.route(apiPathPattern("/roles/limited-backoffice-permission-templates"), async (route: Route) => {
await route.fulfill(json({ data: limitedBackofficePermissionTemplates }));
});
await page.route(apiPathPattern("/roles"), async (route: Route) => {
await route.fulfill(json(roleListEnvelope([role])));
});
await page.route(apiPathPattern("/roles/permissions"), async (route: Route) => {
const body = route.request().postDataJSON();
const permission = String(body.permission_id || "");
calls.push({ action: "add", permission });
if (!role.permissions.includes(permission)) {
role.permissions = [...role.permissions, permission];
}
await route.fulfill(json({ data: role }));
});
await page.goto("/superuser/roles", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("row", { name: /Operations manager/ })).toBeVisible();
const row = page.getByRole("row", { name: /Operations manager/ });
await row.locator(".action-settings-wheel-trigger").click();
await expect(page.getByTestId("role-limited-backoffice-template-actions-2")).toBeVisible();
await page.getByTestId("role-template-2-cashier").click();
await expect(page.getByText("Apply permission template?")).toBeVisible();
await page.getByRole("button", { name: "Apply template" }).click();
await expect
.poll(() => calls.map((call) => call.permission))
.toEqual(["permissions_list_own", "list_departments", "fetch_order", "statistics_orders_new"]);
await expect(page.getByText("Template applied")).toBeVisible();
});
test("disables limited backoffice template actions when a role is already up to date", async ({ page }) => {
const role = {
id: 3,
name: "Cashier role",
description: "Current cashier permissions.",
created_at: "2026-07-06T08:00:00.000Z",
permissions: [...roleTemplatePermissions],
};
const calls: PermissionCall[] = [];
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
await seedAuthenticatedState(page, "superuser-roles-template-up-to-date-token");
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user", "list_roles", "add_role_permission"],
sessionData: {
group_id: 1,
},
});
await page.route(apiPathPattern("/roles/limited-backoffice-permission-templates"), async (route: Route) => {
await route.fulfill(json({ data: limitedBackofficePermissionTemplates }));
});
await page.route(apiPathPattern("/roles"), async (route: Route) => {
await route.fulfill(json(roleListEnvelope([role])));
});
await page.route(apiPathPattern("/roles/permissions"), async (route: Route) => {
calls.push({ action: "add", permission: String(route.request().postDataJSON().permission_id || "") });
await route.fulfill(json({ data: role }));
});
await page.goto("/superuser/roles", { waitUntil: "domcontentloaded" });
const row = page.getByRole("row", { name: /Cashier role/ });
await expect(row).toBeVisible();
await row.locator(".action-settings-wheel-trigger").click();
await expect(page.getByTestId("role-template-3-cashier")).toBeDisabled();
expect(calls).toEqual([]);
});
test("@smoke @pr loads grouped permissions, searches, and toggles without a blank page", async ({ page }) => {
const { calls } = await bootRolePermissionsPage(page);
@@ -25,6 +25,44 @@ async function bootSuperuser(page, { permissions = ["superuser", "user"], edgeGa
await primeSuperuserSession(page);
}
async function waitForLayoutFrame(page) {
await page.evaluate(
() =>
new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
})
);
}
async function getActionMenuVerticalPlacement(trigger, menu) {
const [triggerBox, menuBox] = await Promise.all([trigger.boundingBox(), menu.boundingBox()]);
expect(triggerBox).not.toBeNull();
expect(menuBox).not.toBeNull();
const belowGap = menuBox.y - (triggerBox.y + triggerBox.height);
const aboveGap = triggerBox.y - (menuBox.y + menuBox.height);
const isBelowButton = belowGap >= -2 && belowGap <= 16;
const isAboveButton = aboveGap >= -2 && aboveGap <= 16;
expect(isBelowButton || isAboveButton).toBe(true);
expect(menuBox.y >= triggerBox.y + triggerBox.height - 2 || menuBox.y + menuBox.height <= triggerBox.y + 2).toBe(
true
);
return isBelowButton ? "below" : "above";
}
async function getSettledActionMenuVerticalPlacement(page, trigger, menu) {
await waitForLayoutFrame(page);
const firstPlacement = await getActionMenuVerticalPlacement(trigger, menu);
await waitForLayoutFrame(page);
const secondPlacement = await getActionMenuVerticalPlacement(trigger, menu);
expect(secondPlacement).toBe(firstPlacement);
return secondPlacement;
}
async function installSystemStatusMock(page, snapshot) {
await page.route(/\/superuser\/system\/status(?:\?.*)?$/, async (route) => {
await route.fulfill(
@@ -815,6 +853,38 @@ test.describe("Superuser system status smoke", () => {
await expect(page.getByTestId("database-status-card")).toContainText("8.0.36");
});
test("@smoke @pr superuser replication action menu keeps stable vertical placement", async ({ page }) => {
await bootSuperuser(page);
await installReplicationMock(page);
await page.goto("/superuser/system/replication");
await expect(page.getByTestId("replication-management-page")).toBeVisible({ timeout: PAGE_READY_TIMEOUT });
await expect(page.getByTestId("replication-host-database-2")).toContainText("mysql-replica-1");
const replicaActions = page.getByTestId("replication-actions-database-2");
const replicaActionTrigger = replicaActions.locator(".action-settings-wheel-trigger");
const replicaActionMenu = replicaActions.locator(".dropdown-content");
await expect(replicaActionTrigger).toBeVisible();
await replicaActionTrigger.click();
await expect(replicaActions.locator(".dropdown-item-action:visible")).toHaveCount(4);
const initialPlacement = await getSettledActionMenuVerticalPlacement(page, replicaActionTrigger, replicaActionMenu);
await page.keyboard.press("Escape");
await expect(replicaActionTrigger).not.toHaveClass(/action-settings-wheel-trigger--active/);
await replicaActionTrigger.click();
await expect(replicaActions.locator(".dropdown-item-action:visible")).toHaveCount(4);
const reopenedPlacement = await getSettledActionMenuVerticalPlacement(
page,
replicaActionTrigger,
replicaActionMenu
);
expect(reopenedPlacement).toBe(initialPlacement);
});
test("@smoke superuser replication management page renders topology and percentages", async ({ page }) => {
await bootSuperuser(page);
const replicationRequests = await installReplicationMock(page);
@@ -863,15 +933,11 @@ test.describe("Superuser system status smoke", () => {
await replicaActionTrigger.click();
await expect(replicaActions.locator(".dropdown-item-action:visible")).toHaveCount(4);
await expect(replicaActions.getByRole("button", { name: "Omdøb" })).toBeVisible();
const [triggerBox, menuBox] = await Promise.all([
replicaActionTrigger.boundingBox(),
replicaActions.locator(".dropdown-content").boundingBox(),
]);
expect(triggerBox).not.toBeNull();
expect(menuBox).not.toBeNull();
const isMenuBelowButton = Math.abs(menuBox.y - (triggerBox.y + triggerBox.height)) <= 12;
const isMenuAboveButton = Math.abs(menuBox.y + menuBox.height - triggerBox.y) <= 12;
expect(isMenuBelowButton || isMenuAboveButton).toBe(true);
await getSettledActionMenuVerticalPlacement(
page,
replicaActionTrigger,
replicaActions.locator(".dropdown-content")
);
await replicaActions.getByRole("button", { name: "Test" }).click();
expect(replicationRequests.hostTestKinds).toContain("database");
const addHostTabs = page.getByTestId("replication-add-host-tabs");

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