Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 85be8d2ce7 Fix Android launcher icon assets
Regenerate Android launcher icons from the blue striped source asset.
Add a repeatable generator and README documentation for the icon workflow.
2026-07-09 10:52:56 +02:00
Jeppe Bundgaard b5fb8c8cac Fix frontend master CI runner failures 2026-07-09 10:50:11 +02:00
Jeppe Bundgaard 71b7ec4987 Fix invoicing period department 75 table overflow 2026-07-08 20:38:39 +02:00
Jeppe Bundgaard ad110d46d9 Fix POS order table primary registration display 2026-07-08 19:23:20 +02:00
Jeppe B dabe0c13ef Format mobile POS addon quantity update 2026-07-08 18:52:23 +02:00
Jeppe B a5dbfb5cba Fix mobile POS additional item product selection 2026-07-08 18:48:44 +02:00
Jeppe B 75c4700ac6 Fix mobile POS addon quantity updates 2026-07-08 18:47:08 +02:00
Jeppe B 19ce60fd0a Fix orders table formatting and unit expectation 2026-07-08 17:02:59 +02:00
Jeppe Bundgaard d26933b722 Enhance orders table with compact text cells and add test for font size consistency 2026-07-08 16:57:13 +02:00
Jeppe B 30dfe2c0c0 Stabilize frontend E2E selectors 2026-07-08 16:53:52 +02:00
Jeppe Bundgaard fa55bcd5f3 Add date cell to orders table and update styling for better visibility 2026-07-08 16:01:27 +02:00
Jeppe B fbc1dc9406 Update view i18n dynamic key allowlist 2026-07-08 15:54:41 +02:00
Jeppe B 4a4b82c8b6 Format invoice bulk actions changes 2026-07-08 15:52:06 +02:00
Jeppe Bundgaard 042e477252 Add invoice collection bulk actions and table header preferences functionality 2026-07-08 15:43:14 +02:00
Jeppe B d1d7b72441 Format duplicate-date E2E assertion 2026-07-08 14:56:29 +02:00
Jeppe B 48d8d7f527 Stabilize mobile superuser E2E assertions 2026-07-08 14:54:17 +02:00
Jeppe B 73a71e5262 Sync i18n v2 generated runtime files 2026-07-08 14:01:10 +02:00
Jeppe B cab779e403 Align Danish vehicle action unit expectation 2026-07-08 13:54:09 +02:00
Jeppe Bundgaard e51e874264 Merge remote-tracking branch 'origin/master' 2026-07-08 13:52:15 +02:00
Jeppe Bundgaard e399c9f974 Add bulk action preview and apply functionality for invoice collections 2026-07-08 13:51:54 +02:00
Jeppe B acd46e5f2a Fix i18n locale integrity coverage 2026-07-08 13:43:37 +02:00
Jeppe B 59af8453f3 Fix i18n word dedupe casing variants 2026-07-08 13:04:41 +02:00
Jeppe Bundgaard d03f4d9aae Add subusers management features and enhance department navigation 2026-07-08 12:27:45 +02:00
Jeppe B 4cc2c3310c Fix frontend test gate expectations 2026-07-07 22:16:25 +02:00
Jeppe Bundgaard 1123b34e10 Enhance UI components and improve date selection functionality 2026-07-07 20:52:14 +02:00
Jeppe Bundgaard 8c12fce187 Add customer pricing labels and update date selection components 2026-07-07 18:16:45 +02:00
Jeppe Bundgaard a82176c855 Merge limited backoffice role template actions 2026-07-07 03:27:39 +02:00
Jeppe Bundgaard 7180bbd6d4 Add limited backoffice role template actions 2026-07-07 03:23:32 +02:00
Copilotandcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> b9b4539764 ci: run automated tests on main and dev pushes (#165)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-07 01:56:19 +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 6c1c1def78 Add limited backoffice employee QR login links 2026-07-06 17:32:58 +02:00
Jeppe Bundgaard 1a232183a8 Merge master into fixed price override branch 2026-07-06 17:28:58 +02:00
Jeppe Bundgaard cfa7d5be69 Merge remote-tracking branch 'origin/master' into codex/superuser-user-overview-hub
# Conflicts:
#	.github/workflows/tests.yml
#	scripts/playwright-pr-mapping.mjs
#	src/views/dashboards/userDashboard/wash/MyWashStart.vue
#	tests/unit/playwright-pr-mapping.spec.js
2026-07-06 17:12:45 +02:00
Jeppe B bb375eb149 Merge pull request #144 from copenhagentruckwash/codex/fix-invoicing-period-selfwash
Fix invoicing period Selvvask counts
2026-07-06 17:08:06 +02:00
Jeppe B 7e5e6ce680 Enforce only tankcleaning POS product rules (#151) 2026-07-06 17:07:03 +02:00
Jeppe Bundgaard 1032f8b4f1 Show limited backoffice employees in backoffice 2026-07-06 16:59:46 +02:00
Jeppe B 744a56c8b2 Merge PR #157: Cleanup superuser dashboard tabs
Cleanup superuser dashboard tabs
2026-07-06 16:59:27 +02:00
Jeppe Bundgaard e58372a190 Merge remote-tracking branch 'origin/master' into codex/pr157-merge-resolve-692380
# Conflicts:
#	scripts/playwright-pr-mapping.mjs
#	src/i18n/generated/da-v2.json
#	src/i18n/generated/de-v2.json
#	src/i18n/generated/en-v2.json
#	src/i18n/generated/no-v2.json
#	src/i18n/generated/sv-v2.json
#	src/i18n/source/da/phrases/index.json
#	src/i18n/source/de/phrases/index.json
#	src/i18n/source/en/phrases/index.json
#	src/i18n/source/no/phrases/index.json
#	src/i18n/source/sv/phrases/index.json
#	src/views/dashboards/userDashboard/wash/MyWashStart.vue
#	tests/unit/playwright-pr-mapping.spec.js
2026-07-06 16:59:00 +02:00
Jeppe Bundgaard 19d44b8666 Add limited backoffice user ID translations 2026-07-06 16:53:41 +02:00
Jeppe Bundgaard 92a52d6194 Gate subuser customer navigation and actions 2026-07-06 16:48:24 +02:00
Jeppe BandJeppe Bundgaard db1c0e25c0 Move limited backoffice shortcut into header (#140)
* Move limited backoffice shortcut into header

* Use shared Playwright port locks in CI

---------

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

* Map limited backoffice changes to focused E2E

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 15:31:16 +02:00
Jeppe Bundgaard 5a5cc0bed3 Guard MyWashStart timers after teardown 2026-07-06 15:02:57 +02:00
Jeppe Bundgaard 1a9e7503ef Stabilize PR Playwright runner load 2026-07-06 15:01:01 +02:00
Jeppe Bundgaard d84e9e3406 Map superuser dashboard changes to E2E 2026-07-06 14:56:18 +02:00
Jeppe Bundgaard c41954993c Fix PR E2E changed diff head 2026-07-06 14:54:41 +02:00
Jeppe Bundgaard a7c8fc3bff Reduce PR E2E runner pressure 2026-07-06 14:52:30 +02:00
Jeppe Bundgaard 36c1f10de8 Reduce PR E2E runner pressure 2026-07-06 14:47:03 +02:00
Jeppe Bundgaard 6002f97fb6 Finish superuser user overview hub 2026-07-06 14:42:23 +02:00
Jeppe Bundgaard 4378dad2b9 Stabilize PR E2E runner checks 2026-07-06 14:39:06 +02:00
Jeppe Bundgaard b8959cb7bb Cleanup superuser dashboard tabs 2026-07-06 14:07:36 +02:00
Jeppe Bundgaard ee41366c08 Bound changed PR E2E selection 2026-07-06 13:57:06 +02:00
Jeppe Bundgaard 3f42b77d13 Stabilize invoice distribution smoke session setup 2026-07-06 13:48:48 +02:00
Jeppe Bundgaard e22f78a449 Stabilize unit timer cleanup 2026-07-06 13:06:41 +02:00
Jeppe Bundgaard f2b66ff19f Stabilize recent wash refresh timers in tests 2026-07-06 13:05:01 +02:00
Jeppe Bundgaard 2fd0893773 Add customer product fixed price UI 2026-07-06 12:52:31 +02:00
Jeppe Bundgaard 0e9292d6d9 Merge remote-tracking branch 'origin/master' into codex/custom-pricing-only-departments
# Conflicts:
#	scripts/playwright-pr-mapping.mjs
2026-07-06 12:48:54 +02:00
Jeppe Bundgaard 69250ada66 Stabilize self-serve restored lane smoke test 2026-07-06 12:44:26 +02:00
Jeppe Bundgaard ce844137a0 Harden POS order detail E2E wait 2026-07-06 12:18:20 +02:00
Jeppe Bundgaard 7f3a8c07e2 Stabilize edge gateway installer smoke tests 2026-07-06 11:47:58 +02:00
Jeppe Bundgaard bd14d58f08 Stabilize self-serve wash smoke assertion 2026-07-06 11:03:22 +02:00
Jeppe Bundgaard b8494cd1d9 Merge remote-tracking branch 'origin/master' into codex/custom-pricing-only-departments
# Conflicts:
#	tests/unit/playwright-pr-mapping.spec.js
2026-07-06 10:36:57 +02:00
Jeppe Bundgaard 1a1c3db8e5 Fix invoicing period selfwash counts 2026-07-06 10:33:48 +02:00
Jeppe Bundgaard 49772c1334 Trigger frontend PR checks 2026-07-06 10:30:43 +02:00
Jeppe Bundgaard 02ddb99000 Add custom-only department pricing toggle 2026-07-06 10:21:06 +02:00
468 changed files with 36791 additions and 9194 deletions
+262 -17
View File
@@ -4,8 +4,43 @@ on:
pull_request:
push:
branches:
- main
- master
- dev
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 +48,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 +96,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,23 +139,206 @@ jobs:
env:
VITEST_BATCH_SIZE: 5
e2e-pr:
if: github.event_name != 'schedule'
e2e-targeted:
if: >
github.event_name == 'workflow_dispatch' &&
(inputs.mode == 'targeted' || inputs.mode == 'targeted-then-full')
needs: build-and-unit
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
timeout-minutes: 30
name: E2E-targeted-${{ matrix.project }}
# Use GitHub-hosted runners to avoid self-hosted desktop contention and sleep/power events.
runs-on: ubuntu-24.04
timeout-minutes: 35
strategy:
fail-fast: false
max-parallel: 4
matrix:
project: ${{ fromJSON(inputs.target_projects || '["chromium-desktop"]') }}
env:
MATRIX_PROJECT: ${{ matrix.project }}
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-targeted-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
PLAYWRIGHT_WORKERS: 1
PLAYWRIGHT_VIDEO_MODE: on-first-retry
TARGET_GREP: ${{ inputs.target_grep }}
TARGET_SPECS: ${{ inputs.target_specs }}
RUN_ID: ${{ github.run_id }}
steps:
- name: Normalize workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
- name: Run targeted Playwright specs in container
shell: bash
run: |
set -euo pipefail
case "$MATRIX_PROJECT" in
chromium-mobile) project_offset=1 ;;
chromium-desktop) project_offset=2 ;;
chromium-tablet) project_offset=3 ;;
webkit-mobile) project_offset=31 ;;
webkit-desktop) project_offset=32 ;;
webkit-tablet) project_offset=33 ;;
firefox-mobile) project_offset=61 ;;
firefox-desktop) project_offset=62 ;;
firefox-tablet) project_offset=63 ;;
*) echo "Unsupported Playwright project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + project_offset))
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
for ((candidate = port_seed; candidate < port_seed + 1000; candidate += 1)); do
lock_dir="${lock_root}/${candidate}.lock"
if ! mkdir "$lock_dir" 2>/dev/null; then
continue
fi
if ss -H -ltn "sport = :${candidate}" 2>/dev/null | grep -q .; then
rmdir "$lock_dir" || true
continue
fi
playwright_port_lock="$lock_dir"
playwright_dev_port="$candidate"
break
done
if [[ -z "$playwright_dev_port" ]]; then
echo "Unable to find a free Playwright dev-server port." >&2
exit 1
fi
trap 'if [[ -n "${playwright_port_lock:-}" ]]; then rmdir "$playwright_port_lock" 2>/dev/null || true; fi' EXIT
if docker info >/dev/null 2>&1; then
docker_cmd=(docker)
elif sudo -n docker info >/dev/null 2>&1; then
docker_cmd=(sudo docker)
else
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
exit 1
fi
mkdir -p output/playwright
scripts/ci/runner-diagnostics.sh "before targeted Playwright ${MATRIX_PROJECT}" -- "${docker_cmd[@]}"
SYSTEMD_INHIBIT_REASON="Frontend targeted Playwright ${MATRIX_PROJECT}" \
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
--volume "$PWD:/source:ro" \
--volume "$PWD/output/playwright:/work/output/playwright" \
--workdir /work \
--env HOME=/tmp \
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
--env TARGET_GREP="$TARGET_GREP" \
--env TARGET_SPECS="$TARGET_SPECS" \
mcr.microsoft.com/playwright:v1.58.2-noble \
bash -lc '
set -euo pipefail
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
git config --global --add safe.directory /work
install_dependencies() {
local attempt
for attempt in 1 2 3; do
if npm ci --legacy-peer-deps --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000; then
return 0
fi
if [[ "$attempt" == "3" ]]; then
return 1
fi
echo "npm ci failed on attempt ${attempt}; retrying..." >&2
sleep 20
done
}
install_dependencies
ulimit -n 16384 || true
mapfile -t spec_args < <(printf "%s\n" "$TARGET_SPECS" | tr "," "\n" | sed "s/^[[:space:]]*//;s/[[:space:]]*$//;/^$/d")
if [[ "${#spec_args[@]}" -eq 0 && -z "${TARGET_GREP:-}" ]]; then
echo "Provide at least one spec path or grep pattern." >&2
exit 1
fi
for spec_path in "${spec_args[@]}"; do
if [[ "$spec_path" == /* || "$spec_path" == *".."* || "$spec_path" != tests/e2e/* ]]; then
echo "Targeted spec must stay under tests/e2e: $spec_path" >&2
exit 1
fi
if [[ ! -f "$spec_path" ]]; then
echo "Targeted spec does not exist: $spec_path" >&2
exit 1
fi
done
args=("${spec_args[@]}")
if [[ -n "${TARGET_GREP:-}" ]]; then
args+=(--grep "$TARGET_GREP")
fi
args+=(--project="$MATRIX_PROJECT")
npx playwright test "${args[@]}"
'
- name: Runner diagnostics after Playwright failure
if: failure() || cancelled()
continue-on-error: true
run: scripts/ci/runner-diagnostics.sh "after targeted Playwright ${{ matrix.project }}"
- name: Upload Playwright report
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-targeted-${{ matrix.project }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
if-no-files-found: ignore
retention-days: 1
e2e-pr:
if: >
always() &&
github.event_name != 'schedule' &&
needs.build-and-unit.result == 'success' &&
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
(
github.event_name != 'workflow_dispatch' ||
inputs.mode == 'full' ||
needs.e2e-targeted.result == 'success'
)
needs: [build-and-unit, e2e-targeted]
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
timeout-minutes: 45
strategy:
fail-fast: false
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
matrix:
suite: [core, changed]
project: [chromium-desktop, chromium-mobile]
env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
PLAYWRIGHT_WORKERS: 1
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
@@ -140,20 +365,29 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
HEAD_SHA: ${{ github.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
zero_sha="0000000000000000000000000000000000000000"
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
base_ref="$PR_BASE_SHA"
head_ref="$PR_HEAD_SHA"
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
base_ref="origin/$DEFAULT_BRANCH"
head_ref="$HEAD_SHA"
else
base_ref="$PUSH_BEFORE_SHA"
head_ref="$HEAD_SHA"
fi
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_HEAD_SHA" ]]; then
head_ref="$PR_HEAD_SHA"
else
head_ref="$HEAD_SHA"
fi
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v5
@@ -181,8 +415,9 @@ jobs:
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
@@ -223,6 +458,8 @@ jobs:
--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_SUITE="$MATRIX_SUITE" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
@@ -277,15 +514,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]
@@ -307,6 +550,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
@@ -357,8 +601,9 @@ jobs:
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
+29
View File
@@ -154,6 +154,35 @@ Artifacts and summaries:
- `output/playwright/test-lists/<project>-<role>.txt`
- `output/playwright/test-lists/<role>-<project>.txt` (legacy compatibility copy)
## Android App Icon
The Play Store Android package is built from the Capacitor project in `android/`.
The legacy Bubblewrap/TWA project at the repository root is not used by
`npm run mobile:android:bundle`.
The source image for the native launcher icon is:
```text
public/favicons/web-app-manifest-512x512.png
```
Regenerate the checked-in launcher assets after changing that source image:
```sh
npm run mobile:android:icons
```
Check that the generated Android launcher assets are current:
```sh
npm run mobile:android:icons:check
```
`npm run mobile:android:sync` runs the icon generator before building and syncing
the Capacitor Android project. The generator updates `android/app/src/main/res`
launcher assets, `public/icons/icon-192x192.png`, `public/icons/icon-512x512.png`,
and `store_icon.png`.
## Bubblewrap (TWA) Build and Install
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 18 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
<color name="ic_launcher_background">#0787BB</color>
</resources>
+12
View File
@@ -2415,6 +2415,12 @@ paths:
type: string
description: Contact person name
example: "Mikkel"
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
g_recaptcha_response:
type: string
description: reCAPTCHA verification token
@@ -8189,6 +8195,12 @@ paths:
email: {type: string}
phone: {type: integer}
name: {type: string}
ean:
type: string
description: Optional EAN used for e-invoicing in e-conomic
maxLength: 13
pattern: '^[0-9]{1,13}$'
example: "5790001234567"
responses:
'200':
description: Success
+1
View File
@@ -79,6 +79,7 @@
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jimp": "0.22.12",
"jsdom": "^29.0.0",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
+4 -1
View File
@@ -55,7 +55,9 @@
"twa:build": "bubblewrap build",
"twa:update": "bubblewrap update",
"mobile:sync": "npm run build && npx cap sync",
"mobile:android:sync": "npm run build && npx cap sync android",
"mobile:android:icons": "node scripts/mobile/generate-android-icons.mjs",
"mobile:android:icons:check": "node scripts/mobile/generate-android-icons.mjs --check",
"mobile:android:sync": "npm run mobile:android:icons && npm run build && npx cap sync android",
"mobile:permissions:check": "node scripts/mobile/check-permissions.mjs",
"mobile:android:signing:check": "node scripts/mobile/check-android-signing-env.mjs",
"mobile:android:bundle": "npm run mobile:android:signing:check && npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease",
@@ -135,6 +137,7 @@
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jimp": "0.22.12",
"jsdom": "^29.0.0",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 80 KiB

+18 -1
View File
@@ -275,7 +275,24 @@ const transformLocale = (locale, originalMessages) => {
(entry) => normalizeToken(entry.value) === normalized
);
if (existingWordEntry) {
tokenToWord.set(normalized, { path: `words.${existingWordEntry.key}`, value: existingWordEntry.value });
const word = {
path: `words.${existingWordEntry.key}`,
value: existingWordEntry.value,
variants: new Map(),
};
const casings = [...stat.casings.keys()].sort(compareStringValues);
for (const casing of casings) {
if (modifierForToken(casing, word) !== null) {
continue;
}
const variantKey = slugify(`${normalized}_${casing}`, usedKeys);
generatedWords[variantKey] = casing;
word.variants.set(casing, { path: `words.generated.${variantKey}`, value: casing });
}
tokenToWord.set(normalized, word);
continue;
}
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Jimp from "jimp";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourcePath = path.join(projectRoot, "public/favicons/web-app-manifest-512x512.png");
const checkOnly = process.argv.includes("--check");
const launcherBackground = "#0787BB";
const densityScale = {
mdpi: 1,
hdpi: 1.5,
xhdpi: 2,
xxhdpi: 3,
xxxhdpi: 4,
};
const targets = [
{ relativePath: "public/icons/icon-512x512.png", size: 512, copySource: true },
{ relativePath: "public/icons/icon-192x192.png", size: 192 },
{ relativePath: "store_icon.png", size: 512, copySource: true },
];
for (const [density, scale] of Object.entries(densityScale)) {
const legacySize = Math.round(48 * scale);
const foregroundSize = Math.round(108 * scale);
targets.push(
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher.png`,
size: legacySize,
},
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_round.png`,
size: legacySize,
},
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_foreground.png`,
size: foregroundSize,
},
);
}
const xmlTargets = [
{
relativePath: "android/app/src/main/res/values/ic_launcher_background.xml",
content: `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="ic_launcher_background">${launcherBackground}</color>\n</resources>\n`,
},
];
function targetPath(relativePath) {
return path.join(projectRoot, relativePath);
}
async function readIfExists(filePath) {
try {
return await fs.readFile(filePath);
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
throw error;
}
}
async function writeIfChanged(relativePath, content) {
const filePath = targetPath(relativePath);
const current = await readIfExists(filePath);
if (current?.equals(content)) {
return false;
}
if (checkOnly) {
return true;
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content);
return true;
}
async function renderPng(sourceImage, size) {
const image = sourceImage.clone().resize(size, size, Jimp.RESIZE_BICUBIC);
return image.getBufferAsync(Jimp.MIME_PNG);
}
async function main() {
const sourceBuffer = await fs.readFile(sourcePath);
const sourceImage = await Jimp.read(sourceBuffer);
if (sourceImage.bitmap.width !== 512 || sourceImage.bitmap.height !== 512) {
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 512x512 PNG.`);
}
const changed = [];
for (const target of targets) {
const buffer = target.copySource ? sourceBuffer : await renderPng(sourceImage, target.size);
if (await writeIfChanged(target.relativePath, buffer)) {
changed.push(target.relativePath);
}
}
for (const target of xmlTargets) {
if (await writeIfChanged(target.relativePath, Buffer.from(target.content))) {
changed.push(target.relativePath);
}
}
if (changed.length === 0) {
console.log(`Android icon assets are current (${targets.length + xmlTargets.length} files).`);
return;
}
if (checkOnly) {
console.error("Android icon assets are out of date:");
for (const relativePath of changed) {
console.error(`- ${relativePath}`);
}
console.error("Run `npm run mobile:android:icons`.");
process.exitCode = 1;
return;
}
console.log(`Updated ${changed.length} Android icon asset${changed.length === 1 ? "" : "s"}.`);
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
+117 -4
View File
@@ -7,14 +7,18 @@ export const fallbackChangePatterns = [
/^vite\.config\.js$/u,
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
/^scripts\/run-playwright-(?:pr|ci-parallel|batched-chromium)\.mjs$/u,
/^scripts\/run-playwright-(?:ci-parallel|batched-chromium)\.mjs$/u,
/^tests\/e2e\/(?:support|fixtures)\//u,
];
export const sourceMappings = [
{
name: "auth",
patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u],
patterns: [
/^src\/(?:views|components|middleware)\/.*auth/iu,
/^src\/views\/auth\//u,
/^src\/components\/session\/(?!token\/SessionUser\/Objects\/)/u,
],
specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"],
projects: chromiumProjects,
},
@@ -24,15 +28,70 @@ export const sourceMappings = [
specs: ["tests/e2e/navigation.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "limited-backoffice",
patterns: [
/^src\/views\/backoffice\//u,
/^src\/services\/limitedBackoffice\.js$/u,
/^src\/services\/departmentCustomerPricing\.js$/u,
/^src\/components\/displays\/department\/pricing\/DepartmentCustomerPricingEditor\.vue$/u,
],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-department-employees",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentEmployees\.vue$/u,
/^src\/views\/backoffice\/components\/LimitedBackofficeEmployeesManager\.vue$/u,
],
specs: ["tests/e2e/superuser-department-employees.spec.ts"],
projects: ["chromium-desktop"],
},
{
name: "superuser-roles-permissions",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/roles\/SuperUserRolesPermissions\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/roles\/RolePermissionManager\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/roles\/rolePermissionCatalog\.js$/u,
],
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-users",
patterns: [/^src\/views\/dashboards\/superUserDashboard\/user\//u],
specs: ["tests/e2e/superuser-users.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-dashboard",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/SuperUserDashboard(?:Navigation)?\.vue$/u,
/^src\/components\/displays\/superuser\/system\//u,
],
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "superuser-department-overview",
patterns: [
/^src\/services\/superuserDepartmentOverview\.js$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/Department\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/SuperUserDashboardDepartmentNavigation\.vue$/u,
],
specs: ["tests/e2e/superuser-department-overview.spec.js", "tests/e2e/superuser-department-employees.spec.ts"],
projects: chromiumProjects,
},
{
name: "header-navigation",
patterns: [
/^src\/components\/viewport\/page\/headers\//u,
/^src\/components\/models\/navigation\/items\/NavigationMenuItemsGlobal\.vue$/u,
],
specs: ["tests/e2e/navigation.smoke.spec.js", "tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "booking",
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
@@ -51,8 +110,20 @@ export const sourceMappings = [
},
{
name: "pos",
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"],
patterns: [
/(?:^|[/_.-])pos(?:[/_.-]|$)/iu,
/(?:^|\/)(?:POS|Pos)[A-Z][^/]*\.(?:vue|js|ts)$/u,
/^src\/assets\/pos\.css$/u,
/^src\/components\/displays\/boxes\/ProductBox\.vue$/u,
/^src\/features\/customer\/customerProductRules\.js$/u,
/^src\/services\/economicCustomerIdentifiers\.js$/u,
],
specs: [
"tests/e2e/pos-flow.spec.js",
"tests/e2e/pos-mobile-order-flow.spec.js",
"tests/e2e/admin-pos-orders.spec.ts",
"tests/e2e/pos-customer-rules.spec.js",
],
projects: chromiumProjects,
},
{
@@ -64,12 +135,28 @@ export const sourceMappings = [
{
name: "admin-department-notifications",
patterns: [
/^src\/views\/dashboards\/departmentDashboard\/modules\/notifications\/DepartmentNotifications\.vue$/u,
/^src\/components\/displays\/department\/notifications\//u,
/^src\/components\/displays\/pagination\/models\/DepartmentPos\/NotificationsPhonePagination\.vue$/u,
],
specs: ["tests/e2e/admin-department-notifications.spec.ts"],
projects: chromiumProjects,
},
{
name: "admin-daily-report",
patterns: [
/^src\/views\/dashboards\/departmentDashboard\/modules\/daily-report\//u,
/^src\/components\/session\/token\/SessionUser\/Objects\/DepartmentDailyReports\.vue$/u,
],
specs: ["tests/e2e/admin-daily-report.spec.ts"],
projects: chromiumProjects,
},
{
name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\/LimitedBackoffice/u],
specs: ["tests/e2e/limited-backoffice.spec.ts"],
projects: chromiumProjects,
},
{
name: "invoicing",
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
@@ -92,6 +179,32 @@ export const sourceMappings = [
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "superuser-department-pricing",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|DepartmentCustomerPricing|SuperUserSelectedDepartmentObject|SuperUserDashboardDepartmentNavigation)\.vue$/u,
/^src\/components\/displays\/department\/pricing\/DepartmentCustomerPricingEditor\.vue$/u,
/^src\/services\/departmentCustomerPricing\.js$/u,
/^src\/components\/session\/token\/SessionUser\/Objects\/Departments\.vue$/u,
],
specs: ["tests/e2e/superuser-department-pricing-custom-only.spec.ts"],
projects: ["chromium-desktop"],
},
{
name: "superuser-department-shells",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentCategories\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentGatewaysWorkspacePage\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentProfile\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/modules\/DepartmentModulesSetup\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/modules\/SuperUserDashboardDepartmentModulesNavigation\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/DepartmentStripeSetup\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/DepartmentStripeTerminalsReaders\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/SuperUserDashboardDepartmentStripeNavigation\.vue$/u,
],
specs: ["tests/e2e/superuser-department-shells.spec.js"],
projects: chromiumProjects,
},
{
name: "self-serve",
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
+6
View File
@@ -20,6 +20,7 @@ export const ownedFilesByRole = {
"auth.smoke.spec.js",
"booking-selfserve.smoke.spec.js",
"connectivityIssue.spec.ts",
"date-period-selector.smoke.spec.js",
"example.spec.ts",
"guest-book-wash-mobile.spec.ts",
"i18n-v2-integrity.spec.ts",
@@ -101,10 +102,15 @@ export const ownedFilesByRole = {
"superuser-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts",
"superuser-department-branding.spec.js",
"superuser-department-shells.spec.js",
"superuser-department-overview.spec.js",
"superuser-department-employees.spec.ts",
"superuser-department-gates.spec.ts",
"superuser-department-lanes.spec.ts",
"superuser-department-pricing-custom-only.spec.ts",
"superuser-departments-archive.spec.ts",
"superuser-drafts.spec.ts",
"superuser-orders-date-filters.spec.ts",
"superuser-products-layout.spec.ts",
"superuser-roles-permissions.spec.ts",
"superuser-system-status.smoke.spec.js",
+24 -4
View File
@@ -261,6 +261,8 @@ function selectChangedTests(changedFiles) {
specProjects: new Map(),
mappedFiles: [],
unmappedFiles: [],
directSpecFiles: [],
skippedDirectSpecFiles: [],
fallback: false,
};
@@ -270,8 +272,7 @@ function selectChangedTests(changedFiles) {
const file = normalizePath(rawFile);
if (isE2eSpec(file)) {
addSpec(selection, file, selectedProjects);
selection.mappedFiles.push(file);
selection.directSpecFiles.push(file);
continue;
}
@@ -295,13 +296,26 @@ function selectChangedTests(changedFiles) {
selection.mappedFiles.push(file);
for (const mapping of matches) {
const projects = mapping.projects.filter((project) => selectedProjects.includes(project));
const mappedProjects = mapping.projects.length > 0 ? mapping.projects : selectedProjects;
const projects = mappedProjects.filter((project) => selectedProjects.includes(project));
if (projects.length === 0) {
continue;
}
for (const spec of mapping.specs) {
addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects);
addSpec(selection, spec, projects);
}
}
}
if (selection.specProjects.size === 0 && !selection.fallback) {
for (const file of selection.directSpecFiles) {
addSpec(selection, file, selectedProjects);
selection.mappedFiles.push(file);
}
} else {
selection.skippedDirectSpecFiles.push(...selection.directSpecFiles);
}
return selection;
}
@@ -370,6 +384,12 @@ async function runChangedSelection(selection) {
return 0;
}
if (selection.skippedDirectSpecFiles.length > 0) {
console.log(
`[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(", ")}`
);
}
for (const [index, group] of groups.entries()) {
for (const project of group.projects) {
const code = await runPlaywright({
+22 -5
View File
@@ -48,7 +48,24 @@ textarea.has-sharp-edges {
--bulma-box-shadow: none !important;
--bulma-card-shadow: none !important;
--bulma-skeleton-background: hsla(197 100% 35% / 0.70) !important;
--bulma-skeleton-background: hsla(197 100% 35% / 0.7) !important;
--pleno-compact-table-header-font-size: 0.72rem;
--pleno-compact-table-header-line-height: 1;
}
body:not(.pleno-large-table-headers) .table thead th {
font-size: var(--pleno-compact-table-header-font-size);
line-height: var(--pleno-compact-table-header-line-height);
white-space: nowrap;
vertical-align: middle;
}
.pleno-table-header-content {
display: inline-flex;
align-items: center;
gap: 0.15rem;
max-width: 100%;
vertical-align: middle;
}
/*:root {*/
@@ -104,16 +121,16 @@ textarea.has-sharp-edges {
margin-top: 30px !important;
}
.cell .card-content .field{
.cell .card-content .field {
width: 100%;
}
.cell .card-content .field .label{
.cell .card-content .field .label {
font-weight: 800;
}
@media screen and (max-width: 768px) {
.mb-8-mobile{
.mb-8-mobile {
margin-bottom: 8px !important;
}
}
@@ -125,4 +142,4 @@ textarea.has-sharp-edges {
.mt-30-tablet {
margin-top: 30px !important;
}
}
}
+8 -36
View File
@@ -1,12 +1,11 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import {clearErrors, hasError, parseError} from "@/components/request/HandleGlobalError.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import bulmaCalendar from "bulma-calendar";
import 'bulma-calendar/src/scss/index.scss';
import Swal from "sweetalert2";
import {IS_DEV} from "@/config.js";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
const props = defineProps({
form_identifier: {
@@ -106,28 +105,6 @@ SessionUser.auth.reCAPTCHA.preCheck.get().then((response) => {
}
});
// Initialize the calendar
const createCalendar = (element, field) => {
const calendar = bulmaCalendar.attach(element, {
startDate: new Date(),
dateFormat: 'yyyy-MM-dd',
});
}
// Load all date fields
const renderCalendars = () => {
console.log('renderCalendars');
// Get all date fields
const dateFields = document.querySelectorAll('.date-calendar-input');
// Loop through the date fields
dateFields.forEach((field) => {
console.log(field);
// Create the calendar
createCalendar(field);
});
}
const isUserAdmin = ref(false);
const randomElementId = Math.random().toString(36).substring(7)
const fieldErrors = ref([]);
@@ -519,10 +496,6 @@ SessionUser.objects.forms.get.single(props.form_identifier).then((response) => {
}
fields.value = fields_tmp;
setDefaultValues();
// Wait one tick before rendering the calendars
setTimeout(() => {
renderCalendars();
}, 0);
}).catch((error) => {
console.error(error);
@@ -696,17 +669,16 @@ const debugGetForm = () => {
</div>
<!-- Date -->
<div v-else-if="getValidator(field.validation).type === 'date'">
<input
class="input is-link"
:type="getValidator(field.validation).type"
<BuefyDateField
v-model="fieldValues[field.id]"
value-type="string"
:required="isFieldRequired(field)"
:name="field.id"
:id="field.id"
@change="onFieldChange(field, $event.target.value)"
v-model="fieldValues[field.id]"
:disabled="isFieldLocked(field)"
v-bind:placeholder="field.metadata.placeholder"
>
:placeholder="field.metadata.placeholder"
@change="(value) => onFieldChange(field, value)"
/>
</div>
</template>
<!-- If the validator is not defined -->
+6 -1
View File
@@ -19,6 +19,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
userScopedUserId: {
type: [String, Number],
default: null,
},
});
const { t } = useI18n();
@@ -42,7 +46,7 @@ const paginationKey = computed(() =>
const onInviteClick = async () => {
await SessionUser.objects.subusers.functions.showInviteForm(() => {
paginationVersion.value += 1;
}, { superuser: props.superuserPage });
}, { superuser: props.superuserPage, userId: props.userScopedUserId });
};
</script>
@@ -75,6 +79,7 @@ const onInviteClick = async () => {
:key="paginationKey"
:endpoint="endpoint"
:show-customer="showCustomer"
:user-scoped-user-id="userScopedUserId"
auto-load="true"
/>
</div>
+5 -3
View File
@@ -8,7 +8,7 @@ const { t } = useI18n();
</script>
<template>
<div>
<div class="superuser-users-index" data-testid="superuser-users-index">
<PageTitle :title="t('superuser.pages.employees.title')" :subtitle="t('superuser.pages.employees.subtitle')">
<template #buttons>
<button class="button is-dark" @click="showCreateUserForm">
@@ -24,5 +24,7 @@ const { t } = useI18n();
</template>
<style scoped>
</style>
.superuser-users-index {
min-width: 0;
}
</style>
+51 -75
View File
@@ -6,6 +6,7 @@ import Swal from "sweetalert2";
import CustomerProductDiscountDisplay
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
import { useI18n } from 'vue-i18n';
import { getCustomerProductRestriction } from "@/features/customer/customerProductRules.js";
const { t } = useI18n();
const expandIcon = ref(null);
@@ -79,27 +80,6 @@ const toggleIsSelected = () => {
}
};
const hasRestrictAdditionalServices = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
};
const hasRestrictSpotFree = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictSpotFree');
};
const hasRestrictInteriorCleaning = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictInteriorCleaning');
};
const hasRestrictTankCleaning = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictTankCleaning');
};
const hasOnlyTankCleaning = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'onlyTankCleaning');
};
/**
* On before add product addon
* This is used to check if adding a product has any requirements.
@@ -109,6 +89,9 @@ const hasOnlyTankCleaning = () => {
* @param onAfterPreCheck
*/
const onBeforeAddProductAddon = (addon, onAfterPreCheck) => {
if (isAddonRestricted(addon)) {
return;
}
// Check if the addon already is selected
if (isAddonSelected(props.id, addon.option_id)) { onAfterPreCheck(); return; }
let note = null
@@ -139,6 +122,9 @@ const onBeforeAddProductAddon = (addon, onAfterPreCheck) => {
* @param onAfterPreCheck
*/
const onBeforeToggleProductAddon = (addon, isAddonCurrentlySelected, onAfterPreCheck) => {
if (!isAddonCurrentlySelected && isAddonRestricted(addon)) {
return;
}
// Debug:
console.log(addon, isAddonCurrentlySelected);
let note = null
@@ -162,37 +148,36 @@ const doesAddonHaveNoteRequirement = (addon) => {
};
const isAddonRestricted = (addon) => {
const addonName = getAddonName(addon).toLowerCase();
// Check if the addon has "Spot Free" in the name
if (hasRestrictSpotFree() && addonName.includes('spot free')) {
return true;
}
// Check if the addon has "Indvendig vask" in the name
if (hasRestrictInteriorCleaning() && addonName.includes('indvendig vask')) {
return true;
}
const addonProduct = getAddonProduct(addon);
return getCustomerProductRestriction({
...addonProduct,
name: getAddonName(addon) || addonProduct.name,
category: addonProduct.category ?? addon?.category,
}, customerAttributesSafe.value, {
includeNumericAddonCategory: true,
isRelatedAddon: true,
}).restricted;
};
const getAddonRestrictionMessage = (addon) => {
const addonProduct = getAddonProduct(addon);
const restriction = getCustomerProductRestriction({
...addonProduct,
name: getAddonName(addon) || addonProduct.name,
category: addonProduct.category ?? addon?.category,
}, customerAttributesSafe.value, {
includeNumericAddonCategory: true,
isRelatedAddon: true,
});
return restriction.messageKey ? t(restriction.messageKey) : "";
};
const isProductRestricted = () => {
const productName = String(props.name || '').toLowerCase();
// Check if the product has "Spot Free" in the name
if (hasRestrictSpotFree() && productName.includes('spot free')) {
return true;
}
// Check if the product has "Indvendig vask" in the name
if (hasRestrictInteriorCleaning() && productName.includes('indvendig vask')) {
return true;
}
// Check if the product category is 5 (Tank Cleaning)
if (props.product?.category === 5) {
if (hasRestrictTankCleaning()) {
return true;
}
if (!hasOnlyTankCleaning()) {
return true;
}
}
return getCustomerProductRestriction({
...props.product,
name: props.name || props.product?.name,
}, customerAttributesSafe.value).restricted;
};
const orderByOrderPriority = (addons) => {
@@ -261,20 +246,7 @@ const orderByOrderPriority = (addons) => {
<div v-if="isSelected" class="column is-12-desktop">
<!-- Addons -->
<div class="is-centered mt-3 mb-0 pl-6" v-if="addonsSafe.length > 0">
<template v-if="hasRestrictAdditionalServices()">
<div class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap">
<button
class="button is-small is-fullwidth is-danger"
disabled
>
<span class="icon is-small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>Ekstra ydelser er ikke tilladt</span>
</button>
</div>
</template>
<template v-for="addon in orderByOrderPriority(addonsSafe)" :key="addon.id" v-else>
<template v-for="addon in orderByOrderPriority(addonsSafe)" :key="addon.id">
<template v-if="!isAddonRestricted(addon)">
<div
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
@@ -321,18 +293,21 @@ const orderByOrderPriority = (addons) => {
</div>
</template>
<!-- Addon is restricted -->
<template v-else>
<div class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap">
<button
class="button is-small is-fullwidth is-danger"
disabled
>
<span class="icon is-small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>{{ addon.name }} er ikke tilladt</span>
</button>
</div>
<template v-else>
<div
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
:data-testid="`pos-addon-restriction-${getAddonProduct(addon).id ?? addon.option_id ?? addon.id}`"
>
<button
class="button is-small is-fullwidth is-danger is-light"
disabled
>
<span class="icon is-small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>{{ addon.name }} / {{ getAddonRestrictionMessage(addon) }}</span>
</button>
</div>
</template>
</template>
</div>
@@ -383,4 +358,5 @@ const orderByOrderPriority = (addons) => {
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -93,6 +93,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
directSectionKeys: {
type: Array,
default: () => [],
},
allowBookingCompletion: {
type: Boolean,
default: false,
@@ -126,6 +130,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 +146,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 +237,8 @@ const dropdownContentStyle = computed(() => {
});
const resetDropdownLayout = () => {
dropdownPlacement.value = "bottom";
isDropdownPlacementLocked.value = false;
shouldOpenDropdownUp.value = false;
dropdownMaxHeight.value = null;
isFixedPosition.value = false;
@@ -291,7 +300,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 +336,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 +385,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 +1514,7 @@ onMounted(() => {
contentResizeObserver = new ResizeObserver(() => {
if (isDropdownOpen.value) {
syncDesktopFlyoutPosition();
void updateDropdownLayout();
}
});
@@ -2362,6 +2411,15 @@ const standaloneMenuActions = computed(() => {
return actions;
});
const directBuiltInMenuSections = computed(() => {
if (!props.displayActionsDirectly || !Array.isArray(props.directSectionKeys) || props.directSectionKeys.length === 0) {
return flatBuiltInMenuSections.value;
}
const allowedKeys = new Set(props.directSectionKeys.map((key) => String(key)));
return flatBuiltInMenuSections.value.filter((section) => allowedKeys.has(String(section.key)));
});
const desktopFlyoutMenuSections = computed(() => {
const mergedSections = [];
const mergedSectionsByLabel = new Map();
@@ -2399,7 +2457,7 @@ watch(
() => {
if (isDropdownOpen.value) {
void nextTick(() => {
syncDesktopFlyoutPosition();
void updateDropdownLayout();
});
}
}
@@ -2507,7 +2565,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 +2594,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 +2616,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 = "";
}
}
};
@@ -2589,7 +2637,7 @@ const syncDesktopFlyoutPosition = () => {
</div>
</template>
<slot v-if="hasCustomActionsSlot" name="actions"></slot>
<template v-for="section in flatBuiltInMenuSections" :key="section.key">
<template v-for="section in directBuiltInMenuSections" :key="section.key">
<ActionSettingsWheelItemLabel :label="section.label" />
<template v-for="item in section.items" :key="item.key">
<ActionSettingsWheelToggleItem
File diff suppressed because it is too large Load Diff
@@ -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>
@@ -0,0 +1,122 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js";
const props = defineProps({
selectedInvoiceCollectionIds: {
type: Array,
default: () => [],
},
totalInvoiceCollectionCount: {
type: Number,
default: 0,
},
allSelected: {
type: Boolean,
default: false,
},
allExpanded: {
type: Boolean,
default: false,
},
invoiceQueueBusy: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["invoiceSelected", "bulkAction", "toggleSelectAll", "toggleExpandAll"]);
const { t } = useI18n();
const selectedCount = computed(() => props.selectedInvoiceCollectionIds.length);
const hasSelectableCollections = computed(() => props.totalInvoiceCollectionCount > 0);
const hasSelectedCollections = computed(() => selectedCount.value > 0);
const hasMultipleSelectedCollections = computed(() => selectedCount.value > 1);
const triggerLabel = computed(() =>
t("invoicing_period.invoice_collection_actions.menu.label", { count: selectedCount.value })
);
const selectAllLabel = computed(() =>
props.allSelected
? t("invoicing_period.invoice_collection_actions.menu.unselect_all")
: t("invoicing_period.invoice_collection_actions.menu.select_all")
);
const expandAllLabel = computed(() =>
props.allExpanded
? t("invoicing_period.invoice_collection_actions.menu.collapse_all")
: t("invoicing_period.invoice_collection_actions.menu.expand_all")
);
const emitBulkAction = (action) => emit("bulkAction", action);
</script>
<template>
<div
v-if="hasSelectableCollections"
class="invoice-collection-selection-action-wheel"
data-testid="invoice-collection-selection-action-wheel"
>
<ActionSettingsWheelButton icon="fas fa-sliders-h" :label="triggerLabel">
<template #actions>
<ActionSettingsWheelItemLabel
:label="t('invoicing_period.invoice_collection_actions.menu.selection_section')"
data-testid="invoice-collection-selection-action-wheel-selection-section"
/>
<ActionSettingsWheelItem
icon="fas fa-check-double"
:label="selectAllLabel"
:click-action="() => emit('toggleSelectAll')"
test-id="invoice-collection-selection-action-wheel-select-all"
/>
<ActionSettingsWheelItem
:icon="allExpanded ? 'fas fa-compress-alt' : 'fas fa-expand-alt'"
:label="expandAllLabel"
:click-action="() => emit('toggleExpandAll')"
test-id="invoice-collection-selection-action-wheel-expand-all"
/>
<template v-if="hasSelectedCollections">
<ActionSettingsWheelItemLabel
:label="t('invoicing_period.invoice_collection_actions.menu.modification_section')"
data-testid="invoice-collection-selection-action-wheel-modification-section"
/>
<ActionSettingsWheelItem
icon="fas fa-file-invoice-dollar"
:label="t('invoicing_period.invoice_collection_actions.actions.queue_economic')"
:click-action="() => emit('invoiceSelected')"
:disabled="invoiceQueueBusy"
test-id="invoice-collection-selection-action-wheel-invoice-selected"
/>
<ActionSettingsWheelItem
icon="fas fa-broom"
:label="t('invoicing_period.invoice_collection_actions.actions.remove_customer_rule_violations')"
:click-action="() => emitBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.CLEAN_CUSTOMER_RULES)"
test-id="invoice-collection-selection-action-wheel-clean-rules"
/>
<ActionSettingsWheelItem
v-if="hasMultipleSelectedCollections"
icon="fas fa-compress-arrows-alt"
:label="t('invoicing_period.invoice_collection_actions.actions.merge_collections')"
:click-action="() => emitBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.MERGE)"
test-id="invoice-collection-selection-action-wheel-merge"
/>
<ActionSettingsWheelItem
icon="fas fa-calendar-alt"
:label="t('invoicing_period.invoice_collection_actions.actions.split_by_month')"
:click-action="() => emitBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.SPLIT_BY_MONTH)"
test-id="invoice-collection-selection-action-wheel-split-by-month"
/>
<ActionSettingsWheelItem
icon="fas fa-undo"
:label="t('invoicing_period.invoice_collection_actions.actions.reset_hidden_item_prices')"
:click-action="() => emitBulkAction(INVOICE_COLLECTION_BULK_ACTIONS.RESET_HIDDEN_PRICES)"
test-id="invoice-collection-selection-action-wheel-reset-hidden-prices"
/>
</template>
</template>
</ActionSettingsWheelButton>
</div>
</template>
@@ -0,0 +1,7 @@
export const INVOICE_COLLECTION_BULK_ACTIONS = Object.freeze({
CLEAN_CUSTOMER_RULES: "remove_customer_rule_violations",
MERGE: "merge_collections",
SPLIT_BY_MONTH: "split_by_month",
RESET_HIDDEN_PRICES: "reset_hidden_item_prices",
QUEUE_ECONOMIC: "queue_economic",
});
File diff suppressed because it is too large Load Diff
@@ -30,6 +30,7 @@ import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
import {
@@ -377,8 +378,8 @@ const fetchDuplicateOrdersForContext = async (context) => {
try {
const response = await SessionUser.request(SessionUser.objects.orders.meta.endpoint, "GET", {
filters: `reg_1:${normalizedContext.reg1},department_id:${department_id.value},created_at-date_from:${
new Date().toISOString().split("T")[0]
},created_at-date_to:${new Date().toISOString().split("T")[0]}`,
todayLocalDateOnly()
},created_at-date_to:${todayLocalDateOnly()}`,
limit: 5,
});
@@ -38,14 +38,14 @@ import {
reg_2,
reg_3,
department_id,
customer_id,
getCustomerEmail,
customer_name,
isAddonRestricted,
canBuyAdditionalServices,
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
customer_id,
getCustomerEmail,
customer_name,
getAddonRestriction,
getProductRestriction,
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
@@ -343,13 +343,14 @@ const applyPendingBookingFromSelection = async () => {
effectivePrimaryProduct = firstWash;
}
}
effectivePrimaryProduct.addons = preparedAddons as any;
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value.addons = preparedAddons as any;
}
effectivePrimaryProduct.addons = preparedAddons as any;
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value.addons = preparedAddons as any;
}
sanitizeRestrictedTransactionItems();
lastAppliedBookingId.value = booking.id;
lastAppliedBookingId.value = booking.id;
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
@@ -481,9 +482,116 @@ const layout = {
};
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const restrictionWarningMessageKey = ref("");
const showRestrictedItemsRemovedWarning = () => {
restrictionWarningMessageKey.value = "pos.restrictions.restricted_items_removed";
};
const getMobileAddonRestriction = (addon: any) => getAddonRestriction(addon, { isRelatedAddon: true });
const getStandaloneAdditionalItemRestriction = (item: any) =>
getProductRestriction(item, {
includeNumericAddonCategory: true,
isStandaloneAdditionalService: true,
});
const isMobileAddonRestricted = (addon: any) => getMobileAddonRestriction(addon).restricted;
const isStandaloneAdditionalItemRestricted = (item: any) => getStandaloneAdditionalItemRestriction(item).restricted;
const decorateMobileAddonRestriction = (addon: any) => {
const restriction = getMobileAddonRestriction(addon);
const nextQuantity = restriction.restricted ? 0 : Number(addon?.quantity ?? addon?.product?.quantity ?? 0);
const decoratedAddon = {
...addon,
quantity: nextQuantity,
product: addon?.product
? {
...addon.product,
quantity: nextQuantity,
}
: addon?.product,
};
if (restriction.restricted) {
decoratedAddon.restricted = true;
decoratedAddon.restrictionRule = restriction.rule;
decoratedAddon.restrictionMessageKey = restriction.messageKey;
}
return decoratedAddon;
};
const sanitizeRestrictedTransactionItems = () => {
let removedRestrictedItem = false;
const primary = transactionItems.primaryItem.value;
if (primary && Array.isArray(primary.addons)) {
let changedAddons = false;
const sanitizedAddons = primary.addons.map((addon: any) => {
const restriction = getMobileAddonRestriction(addon);
if (!restriction.restricted) {
return addon;
}
if (Number(addon?.quantity ?? addon?.product?.quantity ?? 0) > 0) {
removedRestrictedItem = true;
}
if (
addon?.restricted === true &&
addon?.restrictionRule === restriction.rule &&
addon?.restrictionMessageKey === restriction.messageKey &&
Number(addon?.quantity ?? 0) === 0 &&
Number(addon?.product?.quantity ?? 0) === 0
) {
return addon;
}
changedAddons = true;
return {
...addon,
restricted: true,
restrictionRule: restriction.rule,
restrictionMessageKey: restriction.messageKey,
quantity: 0,
product: addon?.product
? {
...addon.product,
quantity: 0,
}
: addon?.product,
};
});
if (changedAddons) {
primary.addons = sanitizedAddons;
}
}
const allowedAdditionalItems = (transactionItems.additionalItems.value || []).filter((item: any) => {
const restricted = isStandaloneAdditionalItemRestricted(item);
if (restricted && Number(item?.quantity ?? 0) > 0) {
removedRestrictedItem = true;
}
return !restricted;
});
if (allowedAdditionalItems.length !== (transactionItems.additionalItems.value || []).length) {
transactionItems.setAdditionalItems(allowedAdditionalItems);
}
if (removedRestrictedItem) {
showRestrictedItemsRemovedWarning();
}
return !removedRestrictedItem;
};
const onCopyLastOrder = (vehicleIndex: number) => {
lastOrders.select(vehicleIndex);
sanitizeRestrictedTransactionItems();
};
const openCustomerSelection = () => {
@@ -786,10 +894,11 @@ const buildDesiredOrderItemShapes = () => {
related_item_id: null,
price: Number(transactionItems.primaryItem.value.price ?? 0),
notes: String(transactionItems.primaryItem.value?.notes ?? ""),
skip_price_override: transactionItems.primaryItem.value?.skip_price_override === true,
};
const addonShapes = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return {
@@ -800,11 +909,12 @@ const buildDesiredOrderItemShapes = () => {
related_item_id: "__PRIMARY__",
price: Number(addonProduct?.price ?? addon?.price ?? 0),
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
};
});
const additionalShapes = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({
kind: "additional",
relatedKey: null,
@@ -813,6 +923,7 @@ const buildDesiredOrderItemShapes = () => {
related_item_id: null,
price: Number(item?.price ?? 0),
notes: String(item?.notes ?? ""),
skip_price_override: item?.skip_price_override === true,
}));
return [primaryShape, ...addonShapes, ...additionalShapes];
@@ -864,8 +975,8 @@ const buildCurrentSelectionComparableShapes = () => {
};
const addonShapes = sortComparableLastWashShapes(
(transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
(transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => ({
kind: "addon",
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
@@ -874,8 +985,8 @@ const buildCurrentSelectionComparableShapes = () => {
);
const additionalShapes = sortComparableLastWashShapes(
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({
kind: "additional",
product_id: Number(item?.id ?? 0),
@@ -949,8 +1060,10 @@ const syncCurrentTransactionToOrder = async () => {
const desiredShapes = buildDesiredOrderItemShapes();
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
const comparableDesiredShapes = desiredShapes.map(({ kind, relatedKey, skip_price_override, ...shape }) => shape);
if (JSON.stringify(currentShapes) === JSON.stringify(desiredShapes.map(({ kind, relatedKey, ...shape }) => shape))) {
if (!shouldForceRecreateForRepricing && JSON.stringify(currentShapes) === JSON.stringify(comparableDesiredShapes)) {
return true;
}
@@ -962,12 +1075,12 @@ const syncCurrentTransactionToOrder = async () => {
1,
null,
transactionItems.primaryItem.value?.notes || "",
transactionItems.primaryItem.value.price
transactionItems.primaryItem.value.skip_price_override === true ? null : transactionItems.primaryItem.value.price
);
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
const addonPromises = (transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => {
const addonProduct = addon?.product ?? addon;
return createOrderItem(
@@ -976,14 +1089,23 @@ const syncCurrentTransactionToOrder = async () => {
Number(addon.quantity),
createdPrimaryItemId,
addonProduct?.notes || addon?.notes || "",
addonProduct.price ?? addon.price
addonProduct?.skip_price_override === true || addon?.skip_price_override === true
? null
: addonProduct.price ?? addon.price
);
});
const additionalPromises = (transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) =>
createOrderItem(normalizedOrderId, item.id, Number(item.quantity), null, item?.notes || "", item.price)
createOrderItem(
normalizedOrderId,
item.id,
Number(item.quantity),
null,
item?.notes || "",
item?.skip_price_override === true ? null : item.price
)
);
await Promise.all([...addonPromises, ...additionalPromises]);
@@ -1022,8 +1144,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
missingProducts.push(primaryProduct);
}
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.forEach((addon: any) => {
const addonProduct = addon?.product ?? addon;
if (
@@ -1035,8 +1157,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
}
});
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item);
@@ -1126,6 +1248,8 @@ const onBeforeComplete = async () => {
throw new Error("No primary item selected");
}
sanitizeRestrictedTransactionItems();
if (!(await ensureRequiredOrderItemNotes())) {
return false;
}
@@ -1156,13 +1280,20 @@ const mapAddonsWithQuantity = (sourceAddons = [], previousAddons = []) =>
const sourceAddonProductId = getAddonProductId(addon);
const previousAddon = previousAddons.find((a) => getAddonProductId(a) === sourceAddonProductId);
const nextQuantity = previousAddon?.quantity ?? addon?.quantity ?? addon?.product?.quantity ?? 0;
const skipPriceOverride =
previousAddon?.skip_price_override === true ||
previousAddon?.product?.skip_price_override === true ||
addon?.skip_price_override === true ||
addon?.product?.skip_price_override === true;
return {
...addon,
quantity: nextQuantity,
skip_price_override: skipPriceOverride,
product: addon?.product
? {
...addon.product,
quantity: nextQuantity,
skip_price_override: skipPriceOverride,
}
: addon?.product,
};
@@ -1272,6 +1403,18 @@ watch(
}
}
);
watch(
[
() => transactionItems.primaryItem.value?.addons,
() => transactionItems.additionalItems.value,
],
() => {
sanitizeRestrictedTransactionItems();
},
{ deep: true }
);
// Watch for changes in the vehicle 1 reference and update the reference field when it changes
watch(
() => vehicles.vehicle_1.value?.reference,
@@ -1282,15 +1425,10 @@ watch(
}
);
// Computed property to filter out restricted addons based on customer attributes
// Computed property to annotate restricted addons based on customer attributes
const filteredAddons = computed(() => {
const addons = transactionItems.primaryItem.value?.addons || [];
// If additional services are restricted, return empty array
if (!canBuyAdditionalServices()) {
return [];
}
// Filter out individually restricted addons
return addons.filter((addon: any) => !isAddonRestricted(addon));
return addons.map((addon: any) => decorateMobileAddonRestriction(addon));
});
</script>
@@ -1353,10 +1491,17 @@ const filteredAddons = computed(() => {
<i class="fa fa-search"></i>
</span>
</button>
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<!-- Product -->
<PosDepartmentStepMobile2Product
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<p
v-if="restrictionWarningMessageKey"
class="notification is-warning is-light py-2 px-3 mb-0"
data-testid="pos-mobile-restriction-warning"
>
{{ t(restrictionWarningMessageKey) }}
</p>
<!-- Product -->
<PosDepartmentStepMobile2Product
v-on:pointerdown="onPrimaryProductPointerDown"
v-on:pointermove="onPrimaryProductPointerMove"
v-on:pointerup="onPrimaryProductPointerUp"
@@ -1,59 +1,58 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { ref, computed, watch, onMounted } from "vue";
import { PosOrder } from "../objects/PosOrder.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import {PosProduct} from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobile2CategoryProduct
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
import PosDepartmentStepMobile2Addons
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
import PosDepartmentStepMobileFixedBottomControl
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobileButtonNextStep
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import {
transactionItems
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStep2MobileVehicleSelection
from "@/components/displays/department/pos/steps/mobile/views/PosDepartmentStep2MobileVehicleSelection.vue";
import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue";
import PosDepartmentStepMobile2FloatingCart
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue";
import { isAddonRestricted, canBuyAdditionalServices, isProductRestricted } from "@/components/shop/POSDepartmentProcess.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobile2CategoryProduct from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
import PosDepartmentStepMobile2Addons from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
import { Addon } from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { transactionItems } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStepMobile2FloatingCart from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue";
import { customer_id, getProductRestriction, hasAttribute } from "@/components/shop/POSDepartmentProcess.vue";
import { useI18n } from "vue-i18n";
import { orderProducts } from "@/components/shop/Products.vue";
const { t } = useI18n();
const ADDITIONAL_SERVICES_CATEGORY_ID = 8;
let latestAdditionalSelectionProductsRequestId = 0;
const props = defineProps({
label: {
type: String,
default: "Additional items",
required: true
required: true,
},
subtitle: {
type: String,
default: "Click to modify your additional items",
required: true
required: true,
},
defaultChecked: {
type: Boolean,
default: false
default: false,
},
lastOrder: {
type: Object as () => PosOrder | null,
default: null,
required: false
required: false,
},
});
const canSelectAdditionalItems = computed(() => canBuyAdditionalServices());
const restrictionWarningMessageKey = ref("");
const additionalSelectionProducts = ref<PosProduct[]>([]);
const additionalSelectionLoading = ref(false);
const canSelectAdditionalItems = computed(() => true);
const checked = ref(props.defaultChecked);
// Function to generate a summary from the last order
function generateSummary(order: PosOrder): string {
if (!order || !order.items || order.items.length === 0) {
return "Denne ordre har ingen varer.";
}
const itemNames = order.items.map(item => item?.product?.name || "Ukendt vare");
const itemNames = order.items.map((item) => item?.product?.name || "Ukendt vare");
const uniqueItems = Array.from(new Set(itemNames));
return uniqueItems.length > 1
? `${uniqueItems.length} varer: ${uniqueItems.slice(0, 2).join(", ")}${uniqueItems.length > 2 ? " og flere" : ""}`
@@ -67,18 +66,10 @@ const displayLabel = computed(() => {
: props.label;
});
const displaySubtitle = computed(() => {
return props.lastOrder
? generateSummary(props.lastOrder)
: props.subtitle;
return props.lastOrder ? generateSummary(props.lastOrder) : props.subtitle;
});
// Emit event on toggle
function onToggle(isOpen: boolean) {
if (isOpen && !canSelectAdditionalItems.value) {
checked.value = false;
pos.views.additionalItemSelection.value = false;
return;
}
checked.value = isOpen;
// Open the additional item selection view if toggled open
if (isOpen) {
@@ -91,19 +82,22 @@ const exampleProducts = ref<PosProduct[]>([
{
id: 1,
name: "Extra Towel",
price: 5.00,
price: 5.0,
description: "A soft extra towel",
subscription_allowed: false,
},
{
id: 2,
name: "Premium Soap",
price: 3.50,
price: 3.5,
description: "A premium quality soap",
subscription_allowed: true,
}
},
]);
const convertProductToAddon = (product: PosProduct, options: {quantity?: number, min?: number, max?: number} = {}): Addon => {
const convertProductToAddon = (
product: PosProduct,
options: { quantity?: number; min?: number; max?: number } = {}
): Addon => {
//console.warn("Converting product to addon:", product, options);
return {
id: product.id,
@@ -113,88 +107,211 @@ const convertProductToAddon = (product: PosProduct, options: {quantity?: number,
quantity: options.quantity || 0,
min: options.min || -1,
max: options.max || -1,
}
}
};
};
const getAvailableAdditionalItems = () => {
const tmp = transactionItems.additionalItems.value || [];
if (!tmp || tmp.length === 0) return [];
return <Addon[]>tmp.map(p => convertProductToAddon(p, {
quantity: p?.quantity || 0,
min: -1,
max: -1,
}));
}
return <Addon[]>tmp.map((p) =>
convertProductToAddon(p, {
quantity: p?.quantity || 0,
min: -1,
max: -1,
})
);
};
const availableAdditionalItems = ref<Addon[]>(getAvailableAdditionalItems());
const isAdditionalItemRestricted = (product: PosProduct) => {
if (!canSelectAdditionalItems.value) {
return true;
}
if (isProductRestricted(product)) {
return true;
}
return isAddonRestricted(convertProductToAddon(product));
}
watch(canSelectAdditionalItems, (canSelect) => {
if (!canSelect) {
checked.value = false;
pos.views.additionalItemSelection.value = false;
}
const getStandaloneAdditionalServicesRestriction = () => ({
restricted: true,
rule: "restrictAdditionalServices",
messageKey: "pos.restrictions.addons_not_allowed",
});
const isAdditionalItemRestricted = (product: PosProduct) => {
return getAdditionalItemRestriction({ product } as Addon).restricted;
};
const getAdditionalSelectionRestrictionMessage = (product: PosProduct) => {
const restriction = getAdditionalItemRestriction({ product } as Addon);
return restriction.messageKey ? t(restriction.messageKey) : "";
};
const getAdditionalItemRestriction = (addon: Addon) => {
if (hasAttribute("restrictAdditionalServices")) {
return getStandaloneAdditionalServicesRestriction();
}
return getProductRestriction(addon.product || addon, {
includeNumericAddonCategory: true,
isStandaloneAdditionalService: true,
});
};
const decorateAdditionalItemAddon = (addon: Addon): Addon => {
const restriction = getAdditionalItemRestriction(addon);
const nextAddon = {
...addon,
quantity: restriction.restricted ? 0 : addon.quantity,
product: addon.product
? {
...addon.product,
quantity: restriction.restricted ? 0 : addon.product.quantity,
}
: addon.product,
} as Addon;
if (restriction.restricted) {
(nextAddon as any).restricted = true;
(nextAddon as any).restrictionRule = restriction.rule;
(nextAddon as any).restrictionMessageKey = restriction.messageKey;
}
return nextAddon;
};
const showRestrictedItemsRemovedWarning = () => {
restrictionWarningMessageKey.value = "pos.restrictions.restricted_items_removed";
};
// Computed property to filter out restricted additional items based on customer attributes
const filteredAdditionalItems = computed(() => {
// If additional services are restricted, return empty array
if (!canSelectAdditionalItems.value) {
return [];
}
// Filter out individually restricted addons
return availableAdditionalItems.value.filter((addon: Addon) => {
if (isAddonRestricted(addon)) {
return false;
}
if (addon.product && isProductRestricted(addon.product)) {
return false;
}
return true;
});
return availableAdditionalItems.value.map((addon: Addon) => decorateAdditionalItemAddon(addon));
});
const onClickAddOtherProduct = () => {
if (!canSelectAdditionalItems.value) {
pos.views.additionalItemSelection.value = false;
pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value;
if (pos.views.additionalItemSelection.value) {
scheduleAdditionalSelectionProductsLoad();
}
};
const loadAdditionalSelectionProducts = async () => {
if (additionalSelectionLoading.value) {
return;
}
pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value;
}
const requestId = ++latestAdditionalSelectionProductsRequestId;
additionalSelectionLoading.value = true;
try {
const parsedCustomerId = customer_id.value ? parseInt(String(customer_id.value), 10) : null;
const response = await SessionUser.objects.products.get.all({
category: ADDITIONAL_SERVICES_CATEGORY_ID,
department_id: SessionUser.functions.getDepartmentIdFromUrl(),
...(parsedCustomerId !== null ? { customer_id: parsedCustomerId } : {}),
final_price: true,
});
if (requestId !== latestAdditionalSelectionProductsRequestId) {
return;
}
additionalSelectionProducts.value = orderProducts([...(Array.isArray(response) ? response : [])]);
pos.transactionItems.updateTransactionPrices();
} catch (error) {
if (requestId === latestAdditionalSelectionProductsRequestId) {
console.warn("Unable to load additional POS products", error);
}
} finally {
if (requestId === latestAdditionalSelectionProductsRequestId) {
additionalSelectionLoading.value = false;
}
}
};
const scheduleAdditionalSelectionProductsLoad = () => {
window.setTimeout(loadAdditionalSelectionProducts, 0);
};
// Watch for changes in available additional items, to update the pos.transactionItems.additionalItems
watch(availableAdditionalItems, (newVal) => {
newVal.forEach(addon => {
if (addon.quantity && addon.quantity > 0 && addon.product) {
addon.product.quantity = addon.quantity; // Ensure product has correct quantity
watch(
availableAdditionalItems,
(newVal) => {
let removedRestrictedItem = false;
const selectedProducts = newVal
.filter((addon) => {
if (getAdditionalItemRestriction(addon).restricted) {
if (addon.quantity && addon.quantity > 0) {
removedRestrictedItem = true;
}
return false;
}
return Boolean(addon.quantity && addon.quantity > 0 && addon.product);
})
.map((addon) => ({
...addon.product!,
quantity: addon.quantity,
}));
if (removedRestrictedItem) {
showRestrictedItemsRemovedWarning();
}
pos.transactionItems.setAdditionalItems(newVal.filter(a => a.quantity && a.quantity > 0).map(a => a.product!).filter((p): p is PosProduct => !!p) );
});
}, { deep: true });
const currentItems = pos.transactionItems.additionalItems.value || [];
const isSame =
currentItems.length === selectedProducts.length &&
currentItems.every(
(item: PosProduct, index: number) =>
Number(item?.id) === Number(selectedProducts[index]?.id) &&
Number(item?.quantity ?? 0) === Number(selectedProducts[index]?.quantity ?? 0)
);
if (isSame) {
return;
}
pos.transactionItems.setAdditionalItems(
selectedProducts.filter((product): product is PosProduct => Boolean(product))
);
},
{ deep: true }
);
// Watch for changes in pos.transactionItems.additionalItems to remove items with quantity 0
watch(() => pos.transactionItems.additionalItems.value, (newVal) => {
if (!newVal) return;
if (newVal.length === 0) {
availableAdditionalItems.value = [];
//
return;
watch(
() => pos.transactionItems.additionalItems.value,
(newVal) => {
if (!newVal) return;
if (newVal.length === 0) {
if (availableAdditionalItems.value.length === 0) {
return;
}
availableAdditionalItems.value = [];
//
return;
}
// Prevent recursive loop by checking if availableAdditionalItems already matches newVal
const currentProductIds = availableAdditionalItems.value.map((a) => a.id);
const newProductIds = newVal.map((p) => p.id);
const isSame =
currentProductIds.length === newProductIds.length && currentProductIds.every((id) => newProductIds.includes(id));
if (isSame) return;
availableAdditionalItems.value = getAvailableAdditionalItems();
},
{ deep: true }
);
watch(
() => pos.views.additionalItemSelection.value,
(isOpen) => {
if (!isOpen) {
additionalSelectionLoading.value = false;
return;
}
scheduleAdditionalSelectionProductsLoad();
}
// Prevent recursive loop by checking if availableAdditionalItems already matches newVal
const currentProductIds = availableAdditionalItems.value.map(a => a.id);
const newProductIds = newVal.map(p => p.id);
const isSame = currentProductIds.length === newProductIds.length && currentProductIds.every(id => newProductIds.includes(id));
if (isSame) return;
availableAdditionalItems.value = getAvailableAdditionalItems();
}, { deep: true });
);
onMounted(() => {
if (pos.views.additionalItemSelection.value) {
scheduleAdditionalSelectionProductsLoad();
}
});
const onClickAddProduct = async (product: PosProduct) => {
if (isAdditionalItemRestricted(product)) {
showRestrictedItemsRemovedWarning();
return;
}
pos.transactionItems.addAdditionalItem(product);
@@ -202,25 +319,48 @@ const onClickAddProduct = async (product: PosProduct) => {
//if (pos.views.additionalItemSelection.value) {
// pos.views.additionalItemSelection.value = false;
//}
}
};
</script>
<template>
<div data-testid="pos-mobile-additional-items">
<!-- Minimal view, when not set as fullscreen view -->
<WhiteBoxCard :toggleable="canSelectAdditionalItems && pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
@toggle="onToggle"
:forceState="(pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0) ? true : (pos.views.additionalItemSelection.value)"
v-if="!pos.views.additionalItemSelection.value">
<!-- Minimal view, when not set as fullscreen view -->
<WhiteBoxCard
:toggleable="
canSelectAdditionalItems &&
pos.transactionItems.additionalItems.value &&
pos.transactionItems.additionalItems.value.length == 0
"
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
@toggle="onToggle"
:forceState="
pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0
? true
: pos.views.additionalItemSelection.value
"
v-if="!pos.views.additionalItemSelection.value"
>
<!-- Header -->
<template #header>
<div class="pos-mobile-additional-items-header" data-testid="pos-mobile-additional-items-open">
<div class="card-header-title pos-mobile-additional-items-header__title" data-testid="pos-mobile-additional-items-header-title">{{ displayLabel }}</div>
<div class="card-header-icon pos-mobile-additional-items-header__icon" data-testid="pos-mobile-additional-items-header-icon">
<div
class="card-header-title pos-mobile-additional-items-header__title"
data-testid="pos-mobile-additional-items-header-title"
>
{{ displayLabel }}
</div>
<div
class="card-header-icon pos-mobile-additional-items-header__icon"
data-testid="pos-mobile-additional-items-header-icon"
>
<!-- Right arrow, if there's no items, down arrow if there are items -->
<span class="icon">
<i v-if="!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0" class="fas fa-angle-right"></i>
<i
v-if="
!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0
"
class="fas fa-angle-right"
></i>
<i v-else class="fas fa-angle-down"></i>
</span>
</div>
@@ -230,6 +370,13 @@ const onClickAddProduct = async (product: PosProduct) => {
<template #content>
<!-- Suggested items -->
<div>
<p
v-if="restrictionWarningMessageKey"
class="notification is-warning is-light py-2 px-3 mb-3"
data-testid="pos-mobile-restriction-warning"
>
{{ t(restrictionWarningMessageKey) }}
</p>
<!-- No items added yet -->
<div v-if="!availableAdditionalItems || availableAdditionalItems.length === 0">
<p>{{ SessionUser.objects.global.language.no_additional_items }}</p>
@@ -238,7 +385,7 @@ const onClickAddProduct = async (product: PosProduct) => {
</div>
</template>
<!-- Footer -->
<template #footer v-if="canSelectAdditionalItems">
<template #footer>
<!-- Select other product button -->
<a class="card-footer-item" data-testid="pos-mobile-additional-items-open" @click="onClickAddOtherProduct">
<span class="icon">
@@ -249,24 +396,54 @@ const onClickAddProduct = async (product: PosProduct) => {
</template>
</WhiteBoxCard>
<!-- Fullscreen view, when selecting other products -->
<template v-else-if="canSelectAdditionalItems">
<template v-else>
<div data-testid="pos-mobile-additional-items-selection">
<!-- Categories of products -->
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
<!-- Floating Card -->
<PosDepartmentStepMobile2FloatingCart/>
<!-- Next button -->
<PosDepartmentStepMobileButtonNextStep :isDark="true" :customAction="onClickAddOtherProduct">
<span class="pos-mobile-cta-content">
<span class="pos-mobile-cta-label has-text-white">{{ SessionUser.objects.global.language.next }}</span>
<span class="pos-mobile-cta-value has-text-white">
<i class="fas fa-arrow-right"></i>
<div class="pos-mobile-additional-items-categories">
<button
type="button"
class="button is-rounded is-small is-dark"
:data-testid="`pos-mobile-category-${ADDITIONAL_SERVICES_CATEGORY_ID}`"
@click="scheduleAdditionalSelectionProductsLoad"
>
Extras
</button>
</div>
<div class="pos-mobile-additional-items-products">
<div
v-if="additionalSelectionLoading"
class="pos-mobile-products-loading"
data-testid="pos-mobile-products-loading"
>
<span class="pos-mobile-products-loading__label">{{ SessionUser.objects.global.language.loading }}</span>
</div>
<template v-else>
<PosDepartmentStepMobile2CategoryProduct
v-for="product in additionalSelectionProducts"
:key="product.id"
:price="product.price"
:label="product.name"
:piktogram="product.piktogram"
:testId="`pos-mobile-product-${product.id}`"
:disabled="isAdditionalItemRestricted(product)"
:restrictionMessage="getAdditionalSelectionRestrictionMessage(product)"
@addProduct="onClickAddProduct(product)"
/>
</template>
</div>
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
<!-- Floating Card -->
<PosDepartmentStepMobile2FloatingCart />
<!-- Next button -->
<PosDepartmentStepMobileButtonNextStep :isDark="true" :customAction="onClickAddOtherProduct">
<span class="pos-mobile-cta-content">
<span class="pos-mobile-cta-label has-text-white">{{ SessionUser.objects.global.language.next }}</span>
<span class="pos-mobile-cta-value has-text-white">
<i class="fas fa-arrow-right"></i>
</span>
</span>
</span>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</PosDepartmentStepMobileButtonNextStep>
</PosDepartmentStepMobileFixedBottomControl>
</div>
</template>
</div>
@@ -291,4 +468,10 @@ const onClickAddProduct = async (product: PosProduct) => {
flex: 0 0 auto;
margin-left: auto;
}
.pos-mobile-additional-items-categories {
display: flex;
gap: 0.5rem;
padding: 0.75rem 1rem 0;
}
</style>
@@ -4,27 +4,30 @@ import Plus from "@/components/viewport/elements/icons/Plus.vue";
import ControlSelectAmount from "@/components/viewport/elements/controls/ControlSelectAmount.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { Addon } from "../objects/PosAddon.vue";
import PosDepartmentStepMobile2CategoryProduct
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
import PosDepartmentStepMobile2CategoryProduct from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
import { useI18n } from "vue-i18n";
import { transactionItems } from "../objects/PosDepartmentStepMobileFlow.vue";
const { t } = useI18n();
const props = defineProps({
label: {
type: String,
default: "Add-ons",
required: true
required: true,
},
addons: {
type: Array as () => Addon[],
default: () => []
default: () => [],
},
compact: {
type: Boolean,
default: false
default: false,
},
showPrices: {
type: Boolean,
default: true
}
default: true,
},
});
const emit = defineEmits(["update:addons"]);
/**
@@ -44,7 +47,7 @@ const emit = defineEmits(["update:addons"]);
const getLabel = (addon: Addon) => {
return addon.name;
//return `${addon.name} / ${SessionUser.functions.currency.toLocal(addon.price)}`;
}
};
/**
* When double-clicking on the product, it will add 1 to the quantity
@@ -52,85 +55,126 @@ const getLabel = (addon: Addon) => {
const doubleClickTimeout = 500; // milliseconds
const doubleClickTimers = ref<{ [key: string]: number | null }>({});
const getAddonKey = (addon: Addon) => String(addon.id ?? addon.product?.id ?? "");
const setAddonQuantity = (addon: Addon, quantity: number) => {
const nextQuantity = Number(quantity || 0);
const nextAddons = props.addons.map((candidate) =>
getAddonKey(candidate) === getAddonKey(addon) ? { ...candidate, quantity: nextQuantity } : candidate
);
const primaryAddons = transactionItems.primaryItem.value?.addons;
if (
Array.isArray(primaryAddons) &&
primaryAddons.some((candidate) => getAddonKey(candidate) === getAddonKey(addon))
) {
transactionItems.primaryItem.value!.addons = nextAddons;
}
emit("update:addons", nextAddons);
};
/**
* Function to add a product to the add-ons list
* @param addon
*/
const onAddProduct = (addon: Addon) => {
if (isAddonRestricted(addon)) {
setAddonQuantity(addon, 0);
return;
}
const addonKey = getAddonKey(addon);
// Check if the product was just added
if (doubleClickTimers.value[addon.id]) {
if (doubleClickTimers.value[addonKey]) {
// Double-click detected, clear the timeout
clearTimeout(doubleClickTimers.value[addon.id]);
doubleClickTimers.value[addon.id] = null;
clearTimeout(doubleClickTimers.value[addonKey]);
doubleClickTimers.value[addonKey] = null;
// Check if the product can have more quantity
if (addon.quantity >= addon.max && addon.max !== -1) {
// If the product max is reached, it will not be added.
return;
}
addon.quantity = addon.quantity + 1;
emit('update:addons', [...props.addons])
setAddonQuantity(addon, addon.quantity + 1);
// Set the timeout again to allow for the next double-click
doubleClickTimers.value[addon.id] = window.setTimeout(() => {
doubleClickTimers.value[addon.id] = null;
doubleClickTimers.value[addonKey] = window.setTimeout(() => {
doubleClickTimers.value[addonKey] = null;
}, doubleClickTimeout);
return;
}
// Single-click detected, add the product to the list
// If the product is already has a quantity, set it to 0
if (addon.quantity > 0) {
addon.quantity = 0;
emit('update:addons', [...props.addons])
setAddonQuantity(addon, 0);
return;
}
addon.quantity = (addon.min > 0 ? addon.min : 1);
const nextQuantity = addon.min > 0 ? addon.min : 1;
// Set a timeout to handle double-clicks.
// If the product max is reached, it will not be added.
if (addon.quantity >= addon.max && addon.max !== -1) {
if (nextQuantity >= addon.max && addon.max !== -1) {
// If the product max is reached, it will not be added.
emit('update:addons', [...props.addons])
setAddonQuantity(addon, nextQuantity);
return;
}
doubleClickTimers.value[addon.id] = window.setTimeout(() => {
doubleClickTimers.value[addon.id] = null;
doubleClickTimers.value[addonKey] = window.setTimeout(() => {
doubleClickTimers.value[addonKey] = null;
}, doubleClickTimeout);
emit('update:addons', [...props.addons])
}
setAddonQuantity(addon, nextQuantity);
};
const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
return addons.sort((a, b) => {
return [...addons].sort((a, b) => {
const priorityA = a.product.order_priority || 0;
const priorityB = b.product.order_priority || 0;
return priorityA - priorityB;
});
}
};
const isAddonRestricted = (addon: Addon) => (addon as any)?.restricted === true;
const getAddonRestrictionMessage = (addon: Addon) => {
if (!isAddonRestricted(addon)) {
return "";
}
return (addon as any)?.restrictionMessageKey
? t((addon as any).restrictionMessageKey)
: t("pos.restrictions.product_not_allowed");
};
</script>
<template>
<template v-if="addons.length > 0"> <!-- If there are add-ons available -->
<template v-if="addons.length > 0">
<!-- If there are add-ons available -->
<p class="custom-label">{{ props.label }}</p>
<div>
<template v-for="addon in sortAddonsByProductOrderPriority(props.addons)" :key="addon.id">
<PosDepartmentStepMobile2CategoryProduct
<template v-for="addon in sortAddonsByProductOrderPriority(props.addons)" :key="addon.id">
<PosDepartmentStepMobile2CategoryProduct
:compact="props.compact"
:price="addon.price"
:label="getLabel(addon)"
:piktogram="addon.product.piktogram"
:testId="`pos-mobile-addon-${addon.product.id}`"
:showPrices="props.showPrices"
:disabled="isAddonRestricted(addon)"
:restrictionMessage="getAddonRestrictionMessage(addon)"
@addProduct="onAddProduct(addon)"
:customButton="addon.quantity > 0">
<span class="custom-select-quantity-container">
:customButton="addon.quantity > 0 && !isAddonRestricted(addon)"
>
<span class="custom-select-quantity-container">
<ControlSelectAmount
:testIdPrefix="`pos-mobile-addon-${addon.product.id}`"
@update:quantity="addon.quantity = $event; emit('update:addons', [...props.addons])"
v-model:quantity="addon.quantity"
:max="addon.max"
:min="addon.min"
v-if="!isAddonRestricted(addon)"
:testIdPrefix="`pos-mobile-addon-${addon.product.id}`"
@update:quantity="setAddonQuantity(addon, $event)"
:quantity="addon.quantity"
:max="addon.max"
:min="addon.min"
/>
<span v-else class="tag is-danger is-light is-rounded">
{{ getAddonRestrictionMessage(addon) }}
</span>
</span>
</PosDepartmentStepMobile2CategoryProduct>
</template>
</PosDepartmentStepMobile2CategoryProduct>
</template>
</div>
</template>
</template>
@@ -148,13 +192,11 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
width: 100%;
height: 100%;
/* Inside auto layout */
flex: none;
order: 1;
align-self: stretch;
flex-grow: 0;
}
.custom-container-entry-container {
/* Frame 47 */
@@ -171,14 +213,13 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
width: 100%;
height: 32px;
border-bottom: 1px solid #D9D9D9;
border-bottom: 1px solid #d9d9d9;
/* Inside auto layout */
flex: none;
order: 0;
align-self: stretch;
flex-grow: 0;
}
.custom-icon-container {
/* + */
@@ -210,7 +251,7 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
height: 19px;
/* rg-tx */
font-family: 'Arial';
font-family: "Arial";
font-style: normal;
font-weight: 400;
font-size: 19px;
@@ -219,18 +260,16 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
color: #000000;
/* Inside auto layout */
flex: none;
order: 2;
flex-grow: 0;
}
.custom-icon {
/* + */
/* Icon color */
fill: #FFFFFF;
fill: #ffffff;
width: 100%;
height: 100%;
@@ -240,7 +279,6 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
flex: none;
order: 0;
flex-grow: 0;
}
.custom-label {
/* Add-ons */
@@ -248,7 +286,7 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
width: 60px;
height: 16px;
font-family: 'Arial';
font-family: "Arial";
font-style: normal;
font-weight: 400;
font-size: 16px;
@@ -257,11 +295,9 @@ const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
color: #000000;
/* Inside auto layout */
flex: none;
order: 0;
flex-grow: 0;
}
</style>
@@ -3,6 +3,49 @@ import GenericTag from "@/components/viewport/page/templates/generic/graphics/Ge
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import {onBeforeMount } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { customer_id } from "@/components/shop/POSDepartmentProcess.vue";
import { orderProducts } from "@/components/shop/Products.vue";
let latestProductsRequestId = 0;
const fetchCategoryProducts = async (category: {id: number, name: string}, departmentId: number) => {
const requestId = ++latestProductsRequestId;
pos.productList.setLoading(true);
pos.productList.clear();
try {
const response = await SessionUser.objects.products.get.category(
category.id,
departmentId,
customer_id.value ? parseInt(String(customer_id.value)) : null,
true
);
if (requestId !== latestProductsRequestId) {
return;
}
orderProducts(response).forEach((result: any) => {
pos.productList.add(result);
});
pos.transactionItems.updateTransactionPrices();
} catch (error) {
if (requestId === latestProductsRequestId) {
console.warn("Unable to load POS products", error);
}
} finally {
if (requestId === latestProductsRequestId) {
pos.productList.setLoading(false);
}
}
};
const selectCategory = (category: {id: number, name: string}) => {
const nextCategory = { ...category };
pos.categories.select(nextCategory);
fetchCategoryProducts(nextCategory, SessionUser.functions.getDepartmentIdFromUrl());
};
// Function to fetch categories based on the department ID
const props = defineProps({
// Optional list of category IDs to restrict the displayed categories to
@@ -44,14 +87,12 @@ const fetchCategories = async (departmentId: number) => {
// Select the category 6, if it exists, otherwise select the first category
const category6 = pos.categories.get().find(cat => cat.id === 6);
if (category6) {
pos.productList.setLoading(true);
pos.categories.select(category6);
selectCategory(category6);
return;
}
if (pos.categories.get().length > 0) {
pos.productList.setLoading(true);
pos.categories.select(pos.categories.get()[0]);
selectCategory(pos.categories.get()[0]);
return;
}
} catch (error) {
@@ -82,10 +123,15 @@ onBeforeMount(() => {
</div>
</div>
<div v-else class="columns is-mobile is-multiline is-gapless">
<div class="column is-narrow" v-for="category in pos.categories.get()" :key="category.id" :data-testid="`pos-mobile-category-${category.id}`">
<div
class="column is-narrow"
v-for="category in pos.categories.get()"
:key="category.id"
:data-testid="`pos-mobile-category-${category.id}`"
@click="selectCategory(category)"
>
<GenericTag
:active="pos.categories.isCategorySelected(category)"
@click="pos.categories.select(category)"
class="custom-gap">
{{ category.name }}
</GenericTag>
@@ -37,12 +37,24 @@ const props = defineProps({
testId: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
restrictionMessage: {
type: String,
default: "",
}
});
const emit = defineEmits(["addProduct"]);
const onClick = () => {
if (props.disabled) {
return;
}
emit("addProduct", {
piktogram: props.piktogram,
label: props.label,
@@ -69,14 +81,23 @@ const imageStyleCompact = {
</script>
<template>
<div style="border-bottom: 1px solid #E5E5E5; padding: 10px 0;" class="mb-1">
<div
style="border-bottom: 1px solid #E5E5E5; padding: 10px 0;"
class="mb-1"
:class="{ 'pos-mobile-category-product--disabled': props.disabled }"
>
<div class="columns is-vcentered is-mobile" :class="props.customButton ? 'has-quantity' : ''">
<div class="column is-narrow" @click="onClick" style="cursor: pointer;">
<div class="column is-narrow" @click="onClick" :style="{ cursor: props.disabled ? 'not-allowed' : 'pointer' }">
<img :src="getPicture(props.piktogram)" :alt="props.label" class="product-image"
:style="props.compact ? imageStyleCompact : imageSize"
style="border-radius: 8px; object-fit: contain;" />
</div>
<div class="column" @click="onClick" style="cursor: pointer;" :data-testid="props.testId || undefined">
<div
class="column"
@click="onClick"
:style="{ cursor: props.disabled ? 'not-allowed' : 'pointer' }"
:data-testid="props.testId || undefined"
>
<template v-if="!props.compact">
<h1 class="title is-6">{{ props.label }}</h1>
<h2 class="subtitle is-6" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
@@ -87,6 +108,9 @@ const imageStyleCompact = {
<h2 class="subtitle is-6" style="font-size: smaller" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
<h2 class="subtitle is-6" style="font-size: smaller" v-else>&nbsp;</h2>
</template>
<p v-if="props.disabled && props.restrictionMessage" class="help is-danger mb-0">
{{ props.restrictionMessage }}
</p>
</div>
<div class="column is-narrow">
<slot name="right"/>
@@ -156,4 +180,13 @@ const imageStyleCompact = {
.has-quantity .column .subtitle {
color: rgba(255, 255, 255, 0.6);
}
.pos-mobile-category-product--disabled {
opacity: 0.72;
}
.pos-mobile-category-product--disabled.has-quantity,
.pos-mobile-category-product--disabled .has-quantity {
background-color: #f5f5f5;
}
</style>
@@ -7,7 +7,7 @@ const items = computed(() => {
return [
pos.transactionItems.primaryItem.value,
...pos.transactionItems.additionalItems.value,
]
].filter((item) => item && String(item.name ?? "").trim() !== "")
});
const isCollapsed = ref(true);
const isAddonsVisible = ref(true);
@@ -83,7 +83,7 @@ const itemNames = computed(() => {
</div>
<!-- Product addons -->
<template v-for="addon in item.addons" :key="addon.id" v-if="item.addons && item.addons.length > 0 && isAddonsVisible">
<div class="columns is-mobile is-vcentered is-gapless m-0" v-if="addon?.quantity > 0">
<div class="columns is-mobile is-vcentered is-gapless m-0" v-if="addon?.quantity > 0 && addon?.restricted !== true">
<div class="column"><!-- Product addons -->
<p class="has-text-left"><small>{{addon.quantity}}x {{ addon.name }}</small></p>
</div>
@@ -101,4 +101,4 @@ const itemNames = computed(() => {
max-height: 200px; /* Adjust the max height as needed */
overflow-y: auto; /* Enable vertical scrolling */
}
</style>
</style>
@@ -3,16 +3,20 @@ import {watch, ref, computed} from "vue";
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStepMobile2CategoryProduct
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
import {PosCategory} from "@/components/displays/department/pos/steps/mobile/objects/PosCategory.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {PosProduct} from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import { department_id, customer_id, isProductRestricted } from "@/components/shop/POSDepartmentProcess.vue";
import {
getProductRestriction,
hasAttribute,
isProductRestricted,
} from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStepMobile2Addons
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
import { orderProducts } from "@/components/shop/Products.vue";
import { useI18n } from "vue-i18n";
const emits = defineEmits(["addProduct"]);
const { t } = useI18n();
const props = defineProps({
// To be able to count the products, providing the "addonList" object, paired with the "asAddons" boolean
// This should be the list of addons, not the complete product list
@@ -26,72 +30,12 @@ const props = defineProps({
required: false,
default: false,
},
restrictionContext: {
type: String,
default: "primary",
},
});
const slots = defineSlots();
let latestProductsRequestId = 0;
// Function to fetch categories based on the department ID
// This function will be called when the component is mounted
const fetchProducts = async (category: PosCategory, departmentId: number) => {
const requestId = ++latestProductsRequestId;
pos.productList.setLoading(true);
pos.productList.clear();
try {
const response = await SessionUser.objects.products.get.category(
category.id,
departmentId,
(customer_id.value ? parseInt(customer_id.value) : null),
true
);
if (requestId !== latestProductsRequestId) {
return;
}
orderProducts(response).forEach((result: PosProduct) => {
pos.productList.add(result);
});
// TODO: Update prices of products based on customer pricing rules
pos.transactionItems.updateTransactionPrices();
} catch (error) {
if (requestId === latestProductsRequestId) {
console.warn("Unable to load POS products", error);
}
} finally {
if (requestId === latestProductsRequestId) {
pos.productList.setLoading(false);
}
}
};
// Function to fetch suggested products
const fetchSuggestedProducts = async () => {
// TODO: Actually use the vehicle registrations from the pos object
await SessionUser.request(
'/vehicle/product-suggestions',
'GET',
{
department_id: parseInt(department_id.value),
reg_1: pos.vehicles.vehicle_1?.value?.reg || null,
reg_2: pos.vehicles.vehicle_2?.value?.reg || null,
reg_3: pos.vehicles.vehicle_3?.value?.reg || null,
}
).then((response) => {
});
};
// Watch for changes in the selected category
watch(() => pos.categories.selection(), (newCategory) => {
if (newCategory) {
// If the category id is -1, get the suggested products
if (newCategory.id === -1) {
fetchSuggestedProducts();
} else {
fetchProducts(newCategory, parseInt(department_id.value));
}
}
});
/** As Addons **/
const mergedAddons = ref<Addon[]>([]);
@@ -100,18 +44,21 @@ const mergeCountAddons = (products: PosProduct[], addons: Addon[] | undefined) =
const tmpAddons = <Addon[]>[]; // Temporary array to hold the merged addons
// Convert all products to addons (only non-restricted products)
for (let product of products) {
// Skip restricted products
if (isProductRestricted(product)) {
continue;
}
let tmpAddon = pos.transactionItems.convertProductToAddon(product); // Convert product to addon (count = 0)
const restriction = getProductRestrictionForRow(product);
if (restriction.restricted) {
(tmpAddon as any).restricted = true;
(tmpAddon as any).restrictionRule = restriction.rule;
(tmpAddon as any).restrictionMessageKey = restriction.messageKey;
tmpAddon.quantity = 0;
}
tmpAddons.push(tmpAddon);
}
// Loop through the addons and update the count if the addon exists in the tmpAddons array
if (addons) {
for (let addon of addons) {
let index = tmpAddons.findIndex(a => a.product.id === addon.product.id);
if (index !== -1) {
if (index !== -1 && !(tmpAddons[index] as any).restricted) {
tmpAddons[index].quantity = addon.quantity; // Update the count
}
}
@@ -125,11 +72,36 @@ watch(() => props.addons, (newAddons) => {
}, { immediate: true });
// Computed property to filter out restricted products
const getProductRestrictionForRow = (product: PosProduct) => {
if (props.restrictionContext === "standaloneAdditionalService" && hasAttribute("restrictAdditionalServices")) {
return {
restricted: true,
rule: "restrictAdditionalServices",
messageKey: "pos.restrictions.addons_not_allowed",
};
}
const restriction = getProductRestriction(product, props.restrictionContext === "standaloneAdditionalService"
? { isStandaloneAdditionalService: true, includeNumericAddonCategory: true }
: {});
return restriction;
};
const getProductRestrictionMessage = (product: PosProduct) => {
const restriction = getProductRestrictionForRow(product);
return restriction.messageKey ? t(restriction.messageKey) : "";
};
const productListItems = computed(() => pos.productList.list.value || []);
const filteredProducts = computed(() => {
return pos.productList.get().filter(product => !isProductRestricted(product));
if (props.restrictionContext === "standaloneAdditionalService") {
return productListItems.value;
}
return productListItems.value.filter(product => !isProductRestricted(product));
});
const shouldShowLoadingState = computed(() => {
return pos.categories.loading.value || pos.productList.loading.value;
return pos.categories.loading.value || (pos.productList.loading.value && productListItems.value.length === 0);
});
</script>
@@ -148,14 +120,16 @@ const shouldShowLoadingState = computed(() => {
<template v-else-if="pos.categories.isSelected()">
<template v-if="!props.asAddons">
<!-- Display of products, without a "basket" (filtered for customer restrictions) -->
<template v-for="product in filteredProducts" :key="product.id">
<PosDepartmentStepMobile2CategoryProduct
@addProduct="emits('addProduct', product)"
:price="product.price"
:label="product.name"
:piktogram="product.piktogram"
:testId="`pos-mobile-product-${product.id}`"
>
<template v-for="product in filteredProducts" :key="product.id">
<PosDepartmentStepMobile2CategoryProduct
@addProduct="emits('addProduct', product)"
:price="product.price"
:label="product.name"
:piktogram="product.piktogram"
:testId="`pos-mobile-product-${product.id}`"
:disabled="getProductRestrictionForRow(product).restricted"
:restrictionMessage="getProductRestrictionMessage(product)"
>
<template #right>
<slot name="right" :product="product"/>
</template>
@@ -62,6 +62,7 @@ watch(
form.invoiceEmail,
form.contactEmail,
form.contactPhone,
form.ean,
canCreateCustomer.value,
searchResult.value,
errorMessage.value,
@@ -124,6 +125,24 @@ watch(
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.ean") }}</small></label
>
<div class="control">
<input
v-model="form.ean"
class="input"
type="text"
inputmode="numeric"
maxlength="13"
:placeholder="$t('admin.pos.ean')"
:disabled="searchResult === null || submitting"
data-testid="pos-mobile-add-customer-ean"
@input="handleAutofillFieldInput('ean')"
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.invoice_email") }}</small></label
@@ -3,6 +3,7 @@ export const POS_ADD_CUSTOMER_AUTOFILL_FIELDS = [
"invoiceEmail",
"contactEmail",
"contactPhone",
"ean",
];
export function normalizePosAddCustomerValue(value) {
@@ -20,6 +21,7 @@ export function createPosAddCustomerFields(overrides = {}) {
invoiceEmail: "",
contactEmail: "",
contactPhone: "",
ean: "",
...overrides,
};
}
@@ -30,6 +32,7 @@ export function createPosAddCustomerManualOverrides(overrides = {}) {
invoiceEmail: false,
contactEmail: false,
contactPhone: false,
ean: false,
...overrides,
};
}
@@ -93,6 +96,7 @@ export function buildPosAddCustomerAutofillValues(searchResult) {
invoiceEmail: normalizePosAddCustomerValue(searchResult?.email),
contactEmail: normalizePosAddCustomerValue(searchResult?.email),
contactPhone: normalizePosAddCustomerValue(searchResult?.phone),
ean: normalizePosAddCustomerValue(searchResult?.ean),
};
}
@@ -9,6 +9,7 @@ export type Addon = {
quantity: number;
min: number; // Optional minimum quantity (This is the minimum, when the add-on is selected)
max: number; // Optional maximum quantity
skip_price_override?: boolean;
/** The product details for the add-on */
product?: PosProduct; // The product details for the add-on
};
@@ -18,4 +19,4 @@ export type PosAddon = Addon;
export default defineComponent({
name: "PosAddon",
});
</script>
</script>
@@ -1458,6 +1458,7 @@ const selectLastVehicleOrder = (vehicleIndex: number) => {
notes: item?.notes ?? item?.product?.notes ?? "",
addons: Array.isArray(item?.product?.addons) ? item.product.addons : [],
subscription_allowed: Boolean(item?.product?.subscription_allowed ?? false),
skip_price_override: true,
} as PosProduct;
};
@@ -23,5 +23,6 @@ export type PosProduct = {
quantity?: number;
notes?: string; // Optional notes for the product, used as a part of order item generation
related_item_id?: number;
skip_price_override?: boolean;
};
</script>
</script>
@@ -12,6 +12,7 @@ import PosDepartmentStepMobileButtonNextStep from "@/components/displays/departm
import SessionUser from "@/components/session/token/SessionUser.vue";
import { PosOrder } from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
// Define the close event to emit when the component is closed
const emit = defineEmits(["close"]);
/** Display variables */
@@ -126,7 +127,7 @@ const liveTransactions = ref(null);
const isLoading = ref(true);
const syncListTransactionHistory = async () => {
let dateToday = new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format
let dateToday = todayLocalDateOnly(); // Get today's date in YYYY-MM-DD format
isLoading.value = true;
// Fetch the list of orders created today for the current department
SessionUser.objects.orders.get
@@ -41,6 +41,11 @@ const props = defineProps({
type: Number,
required: false,
default: 750
},
restrictionContext: {
type: String,
required: false,
default: "primary"
}
});
@@ -82,7 +87,12 @@ const canClose = () => Date.now() >= earliestCloseTime.value;
<!-- Product Recommendations -->
<!--<PosDepartmentStepMobile2ProductRecommendations/>-->
<!-- Products -->
<PosDepartmentStepMobile2Products @addProduct="onAddProduct" :asAddons="props.asAddons" :addons="props.addons">
<PosDepartmentStepMobile2Products
@addProduct="onAddProduct"
:asAddons="props.asAddons"
:addons="props.addons"
:restriction-context="props.restrictionContext"
>
<template v-slot:right>
<!-- Right arrow -->
<span class="icon is-small">
@@ -176,7 +176,7 @@ const onClickAttachToOrder = (object) => {
}"
@click="onTableHeaderClick(tableHeaders.name)"
>
<span class="is-flex-wrap-nowrap">
<span class="is-flex-wrap-nowrap pleno-table-header-content">
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
<span
v-if="isColumnCurrentlyBeingSortedBy(tableHeaders.name)"
@@ -194,7 +194,7 @@ const onClickAttachToOrder = (object) => {
'has-text-centered': tableHeaders.name === 'id'
}"
>
<span class="is-flex-wrap-nowrap">
<span class="is-flex-wrap-nowrap pleno-table-header-content">
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
</span>
</th>
@@ -453,7 +453,7 @@ const confirmCloseModal = () => {
}"
@click="onTableHeaderClick(tableHeaders.name)"
>
<span class="is-flex-wrap-nowrap">
<span class="is-flex-wrap-nowrap pleno-table-header-content">
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
<span
v-if="isColumnCurrentlyBeingSortedBy(tableHeaders.name)"
@@ -471,7 +471,7 @@ const confirmCloseModal = () => {
'has-text-centered': tableHeaders.name === 'id'
}"
>
<span class="is-flex-wrap-nowrap">
<span class="is-flex-wrap-nowrap pleno-table-header-content">
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
</span>
</th>
@@ -512,7 +512,7 @@ const confirmCloseModal = () => {
</button>
</td>
</template>
<td>
<td class="has-text-centered">
<ColorIndicator
v-bind:color_class="getOrderInvoiceStatusBarColor(order)"
v-bind:is_narrow="true"
@@ -22,7 +22,7 @@ const props = defineProps({
import {computed, ref, watch} from 'vue';
import { departments, getDepartments, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {orderBy, orderDirection, setOrder, loadList} from "@/components/pagination/paginatedList.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import Swal from "sweetalert2";
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
@@ -38,6 +38,8 @@ import {
setCachedXlvaskUsageAmount
} from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();
const redirectDepartmentOrderPage = (orderId, departmentId) => {
// Send the user to the order page (In a new tab)
// `/admin/${departmentId}/modules/pos/orders/${orderId}`
@@ -649,7 +651,7 @@ const filteredObjects = computed(() => {
}"
@click="onTableHeaderClick(tableHeaders.name)"
>
<span class="is-flex-wrap-nowrap">
<span class="is-flex-wrap-nowrap pleno-table-header-content">
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
<span
v-if="isColumnCurrentlyBeingSortedBy(tableHeaders.name)"
@@ -667,7 +669,7 @@ const filteredObjects = computed(() => {
'has-text-centered': tableHeaders.name === 'id'
}"
>
<span class="is-flex-wrap-nowrap">
<span class="is-flex-wrap-nowrap pleno-table-header-content">
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
</span>
</th>
@@ -0,0 +1 @@
export const SELFWASH_PERIOD_ALL_LIMIT = 10000;
@@ -0,0 +1,615 @@
<script setup>
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import Swal from "sweetalert2";
import SuperuserOverviewPanel from "@/components/displays/superuser/overview/SuperuserOverviewPanel.vue";
import {
getLimitedBackofficeDepartmentCustomerPricing,
getSuperuserDepartmentCustomerPricing,
unwrapDepartmentCustomerPricingResponse,
updateLimitedBackofficeDepartmentCustomerPricing,
updateSuperuserDepartmentCustomerPricing,
} from "@/services/departmentCustomerPricing.js";
const props = defineProps({
scope: {
type: String,
required: true,
validator: (value) => ["superuser", "limited"].includes(value),
},
departmentId: {
type: [Number, String],
required: true,
},
customPricingEnabled: {
type: Boolean,
default: false,
},
canRead: {
type: Boolean,
default: false,
},
canEdit: {
type: Boolean,
default: false,
},
initialCustomerNumber: {
type: [Number, String],
default: "",
},
presentation: {
type: String,
default: "plain",
validator: (value) => ["plain", "superuser-tiles"].includes(value),
},
});
const { t, locale } = useI18n({ useScope: "global" });
const customerNumber = ref(String(props.initialCustomerNumber || ""));
const pricingData = ref(null);
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const departmentId = computed(() => {
const parsed = Number.parseInt(String(props.departmentId || ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});
const parsedCustomerNumber = computed(() => {
const parsed = Number.parseInt(String(customerNumber.value || "").trim(), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
});
const canLoadPricing = computed(
() => props.canRead && props.customPricingEnabled && departmentId.value !== null && parsedCustomerNumber.value !== null
);
const categories = computed(() => (Array.isArray(pricingData.value?.categories) ? pricingData.value.categories : []));
const hasProducts = computed(() => categories.value.some((category) => (category.products || []).length > 0));
const customer = computed(() => pricingData.value?.customer || null);
const customerDisplay = computed(() => {
if (!customer.value) {
return "";
}
return [customer.value.display_name, customer.value.customer_number ? `#${customer.value.customer_number}` : null]
.filter(Boolean)
.join(" - ");
});
const formatPrice = (price) => {
if (price === null || price === undefined || price === "") {
return "-";
}
return new Intl.NumberFormat(locale.value || undefined, {
style: "currency",
currency: "DKK",
maximumFractionDigits: 0,
}).format(Number(price || 0));
};
const overrideKey = (isCategory, objectId) => `${isCategory ? 1 : 0}:${String(objectId)}`;
const overridesByKey = computed(() => {
const map = new Map();
(pricingData.value?.overrides || []).forEach((override) => {
map.set(overrideKey(Boolean(override.is_category), override.product_or_category_id), {
is_category: Boolean(override.is_category),
product_or_category_id: override.product_or_category_id,
percentage: Number.parseInt(String(override.percentage ?? 0), 10) || 0,
fixed_price:
override.fixed_price === null || override.fixed_price === undefined
? null
: Number.parseInt(String(override.fixed_price), 10),
});
});
return map;
});
const getOverride = (isCategory, objectId) =>
overridesByKey.value.get(overrideKey(Boolean(isCategory), objectId)) || {
is_category: Boolean(isCategory),
product_or_category_id: objectId,
percentage: 0,
fixed_price: null,
};
const getDiscountDisplay = (override) => (override.percentage > 0 ? `${override.percentage}%` : "-");
const getFixedPriceDisplay = (override) => (override.fixed_price !== null ? formatPrice(override.fixed_price) : "-");
const customerIdentifier = computed(() => ({ customerNumber: parsedCustomerNumber.value }));
const tilePresentation = computed(() => props.presentation === "superuser-tiles");
const requestLoad = () =>
props.scope === "limited"
? getLimitedBackofficeDepartmentCustomerPricing(departmentId.value, customerIdentifier.value)
: getSuperuserDepartmentCustomerPricing(departmentId.value, customerIdentifier.value);
const requestSave = (overrides) =>
props.scope === "limited"
? updateLimitedBackofficeDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides)
: updateSuperuserDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides);
const resetPricingData = () => {
pricingData.value = null;
errorMessage.value = "";
};
const loadPricing = async () => {
if (!canLoadPricing.value) {
resetPricingData();
return;
}
loading.value = true;
errorMessage.value = "";
try {
const response = await requestLoad();
pricingData.value = unwrapDepartmentCustomerPricingResponse(response);
} catch (error) {
pricingData.value = null;
errorMessage.value =
error?.response?.data?.data?.message ||
error?.response?.data?.message ||
error?.message ||
t("departments.customer_pricing.errors.load");
} finally {
loading.value = false;
}
};
const normalizedOverrides = (nextOverride) => {
const map = new Map(overridesByKey.value);
const key = overrideKey(nextOverride.is_category, nextOverride.product_or_category_id);
const percentage = Number.parseInt(String(nextOverride.percentage ?? 0), 10) || 0;
const fixedPrice =
nextOverride.fixed_price === null || nextOverride.fixed_price === undefined || nextOverride.fixed_price === ""
? null
: Number.parseInt(String(nextOverride.fixed_price), 10);
const normalized = {
is_category: Boolean(nextOverride.is_category),
product_or_category_id: nextOverride.product_or_category_id,
percentage: Math.min(100, Math.max(0, percentage)),
fixed_price: nextOverride.is_category ? null : fixedPrice,
};
if (normalized.percentage <= 0 && normalized.fixed_price === null) {
map.delete(key);
} else {
map.set(key, normalized);
}
return [...map.values()];
};
const saveOverride = async (nextOverride) => {
if (!props.canEdit || !canLoadPricing.value) {
return;
}
saving.value = true;
errorMessage.value = "";
try {
const response = await requestSave(normalizedOverrides(nextOverride));
pricingData.value = unwrapDepartmentCustomerPricingResponse(response);
} catch (error) {
errorMessage.value =
error?.response?.data?.data?.message ||
error?.response?.data?.message ||
error?.message ||
t("departments.customer_pricing.errors.save");
} finally {
saving.value = false;
}
};
const promptInteger = async ({ title, inputLabel, inputValue, allowEmpty = false, min = 0, max = null }) => {
const result = await Swal.fire({
title,
input: "number",
inputLabel,
inputValue,
inputAttributes: {
min,
...(max === null ? {} : { max }),
step: 1,
autocapitalize: "off",
},
showCancelButton: true,
confirmButtonText: t("common.save"),
showLoaderOnConfirm: true,
inputValidator: (value) => {
const normalized = String(value ?? "").trim();
if (allowEmpty && normalized === "") {
return null;
}
const parsed = Number.parseInt(normalized, 10);
if (!Number.isInteger(parsed) || parsed < min || (max !== null && parsed > max)) {
return max === null
? t("departments.customer_pricing.fixed_price_validation")
: t("departments.customer_pricing.discount_validation");
}
return null;
},
});
if (!result.isConfirmed) {
return undefined;
}
const normalized = String(result.value ?? "").trim();
return allowEmpty && normalized === "" ? null : Number.parseInt(normalized, 10);
};
const editDiscount = async (isCategory, objectId, label) => {
const override = getOverride(isCategory, objectId);
const value = await promptInteger({
title: label,
inputLabel: t("departments.customer_pricing.discount"),
inputValue: override.percentage > 0 ? override.percentage : "",
allowEmpty: true,
max: 100,
});
if (value === undefined) {
return;
}
await saveOverride({
...override,
percentage: value ?? 0,
});
};
const editFixedPrice = async (product) => {
const override = getOverride(false, product.id);
const value = await promptInteger({
title: product.name,
inputLabel: t("departments.customer_pricing.fixed_price"),
inputValue: override.fixed_price === null ? "" : override.fixed_price,
allowEmpty: true,
max: null,
});
if (value === undefined) {
return;
}
await saveOverride({
...override,
fixed_price: value,
});
};
watch(
() => props.initialCustomerNumber,
(value) => {
customerNumber.value = String(value || "");
if (canLoadPricing.value) {
void loadPricing();
}
},
{ immediate: true }
);
watch(
() => [props.departmentId, props.customPricingEnabled, props.canRead],
() => {
if (canLoadPricing.value) {
void loadPricing();
} else if (!props.customPricingEnabled || !props.canRead) {
resetPricingData();
}
}
);
</script>
<template>
<section
class="department-customer-pricing"
:class="{ 'department-customer-pricing--tiles': tilePresentation }"
data-testid="department-customer-pricing-editor"
>
<div v-if="!props.canRead" class="notification is-danger is-light" data-testid="department-customer-pricing-forbidden">
{{ t("departments.customer_pricing.no_permission") }}
</div>
<div
v-else-if="!props.customPricingEnabled"
class="notification is-warning is-light"
data-testid="department-customer-pricing-disabled"
>
{{ t("departments.customer_pricing.not_enabled") }}
</div>
<template v-else>
<component
:is="tilePresentation ? SuperuserOverviewPanel : 'div'"
class="department-customer-pricing__toolbar-panel"
:title="tilePresentation ? t('departments.customer_pricing.title') : undefined"
:subtitle="tilePresentation ? t('departments.customer_pricing.open') : undefined"
data-testid="department-customer-pricing-lookup"
>
<div class="department-customer-pricing__toolbar">
<div class="field department-customer-pricing__customer-field">
<label class="label" for="department-customer-pricing-customer-number">
{{ t("departments.customer_pricing.customer_number") }}
</label>
<div class="field has-addons">
<div class="control is-expanded">
<input
id="department-customer-pricing-customer-number"
v-model="customerNumber"
class="input"
type="number"
min="1"
step="1"
inputmode="numeric"
:placeholder="t('departments.customer_pricing.customer_number')"
data-testid="department-customer-pricing-customer-number"
@keydown.enter.prevent="loadPricing"
/>
</div>
<div class="control">
<b-tooltip :label="t('departments.customer_pricing.load_customer')" position="is-bottom" type="is-dark">
<button
class="button is-info"
type="button"
:disabled="!canLoadPricing || loading"
data-testid="department-customer-pricing-load"
@click="loadPricing"
>
<span class="icon is-small">
<i class="fas" :class="loading ? 'fa-spinner fa-spin' : 'fa-search'" aria-hidden="true"></i>
</span>
</button>
</b-tooltip>
</div>
</div>
</div>
</div>
</component>
<div v-if="loading" class="notification is-light" data-testid="department-customer-pricing-loading">
{{ t("common.loading") }}
</div>
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="department-customer-pricing-error">
{{ errorMessage }}
</div>
<div v-if="pricingData" class="department-customer-pricing__content">
<component
:is="tilePresentation ? SuperuserOverviewPanel : 'div'"
class="department-customer-pricing__summary-panel"
:title="tilePresentation ? customerDisplay : undefined"
:subtitle="tilePresentation ? pricingData.department?.name : undefined"
data-testid="department-customer-pricing-customer-panel"
>
<div class="level department-customer-pricing__summary">
<div class="level-left">
<div>
<h2 class="title is-5" data-testid="department-customer-pricing-customer">
{{ customerDisplay }}
</h2>
<p class="subtitle is-6">{{ pricingData.department?.name }}</p>
</div>
</div>
<div class="level-right">
<b-tooltip
:label="props.canEdit ? t('departments.customer_pricing.edit_global_discount') : t('departments.customer_pricing.edit_disabled')"
position="is-left"
type="is-dark"
>
<span>
<button
class="button is-small"
type="button"
:disabled="!props.canEdit || saving"
data-testid="department-customer-pricing-global-discount"
@click="editDiscount(true, 'global', t('departments.customer_pricing.global_discount'))"
>
<span class="icon is-small"><i class="fas fa-percent" aria-hidden="true"></i></span>
<span>{{ getDiscountDisplay(getOverride(true, "global")) }}</span>
</button>
</span>
</b-tooltip>
</div>
</div>
</component>
<div v-if="saving" class="notification is-light py-2" role="status" data-testid="department-customer-pricing-saving">
{{ t("departments.customer_pricing.saving") }}
</div>
<div v-if="!hasProducts" class="notification is-light">
{{ t("departments.customer_pricing.no_products") }}
</div>
<component
:is="tilePresentation ? SuperuserOverviewPanel : 'div'"
v-for="category in categories"
:key="category.id"
class="department-customer-pricing__category"
:title="tilePresentation ? category.name : undefined"
:subtitle="tilePresentation ? t('departments.customer_pricing.item_discount') : undefined"
>
<div class="department-customer-pricing__category-header">
<h3 class="title is-6">{{ category.name }}</h3>
<b-tooltip
:label="props.canEdit ? t('departments.customer_pricing.edit_category_discount') : t('departments.customer_pricing.edit_disabled')"
position="is-left"
type="is-dark"
>
<span>
<button
class="button is-small"
type="button"
:disabled="!props.canEdit || saving"
:data-testid="`department-customer-pricing-category-discount-${category.id}`"
@click="editDiscount(true, category.id, category.name)"
>
<span class="icon is-small"><i class="fas fa-tags" aria-hidden="true"></i></span>
<span>{{ getDiscountDisplay(getOverride(true, category.id)) }}</span>
</button>
</span>
</b-tooltip>
</div>
<div class="table-container">
<table class="table is-fullwidth is-hoverable is-striped department-customer-pricing__table">
<thead>
<tr>
<th>{{ t("common.product") }}</th>
<th>{{ t("departments.customer_pricing.department_price") }}</th>
<th>{{ t("departments.customer_pricing.fixed_price") }}</th>
<th>{{ t("departments.customer_pricing.item_discount") }}</th>
<th>{{ t("departments.customer_pricing.effective_price") }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="product in category.products"
:key="product.id"
:data-testid="`department-customer-pricing-product-${product.id}`"
>
<td>
<strong>{{ product.name }}</strong>
<p v-if="product.description" class="is-size-7 has-text-grey">{{ product.description }}</p>
</td>
<td :class="{ 'has-text-danger has-text-weight-semibold': product.missing_department_price }">
{{ formatPrice(product.department_price) }}
</td>
<td>
<b-tooltip
:label="props.canEdit ? t('departments.customer_pricing.edit_fixed_price') : t('departments.customer_pricing.edit_disabled')"
position="is-bottom"
type="is-dark"
>
<span>
<button
class="button is-small is-white department-customer-pricing__cell-button"
type="button"
:disabled="!props.canEdit || saving"
:data-testid="`department-customer-pricing-fixed-price-${product.id}`"
@click="editFixedPrice(product)"
>
<span>{{ getFixedPriceDisplay(getOverride(false, product.id)) }}</span>
<span class="icon is-small"><i class="fas fa-edit" aria-hidden="true"></i></span>
</button>
</span>
</b-tooltip>
</td>
<td>
<b-tooltip
:label="props.canEdit ? t('departments.customer_pricing.edit_item_discount') : t('departments.customer_pricing.edit_disabled')"
position="is-bottom"
type="is-dark"
>
<span>
<button
class="button is-small is-white department-customer-pricing__cell-button"
type="button"
:disabled="!props.canEdit || saving"
:data-testid="`department-customer-pricing-item-discount-${product.id}`"
@click="editDiscount(false, product.id, product.name)"
>
<span>{{ getDiscountDisplay(getOverride(false, product.id)) }}</span>
<span class="icon is-small"><i class="fas fa-edit" aria-hidden="true"></i></span>
</button>
</span>
</b-tooltip>
</td>
<td>{{ formatPrice(product.effective_price) }}</td>
</tr>
</tbody>
</table>
</div>
</component>
</div>
</template>
</section>
</template>
<style scoped>
.department-customer-pricing {
display: flex;
flex-direction: column;
gap: 1rem;
}
.department-customer-pricing__toolbar,
.department-customer-pricing__summary,
.department-customer-pricing__category-header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.department-customer-pricing__customer-field {
max-width: 420px;
width: 100%;
}
.department-customer-pricing__content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.department-customer-pricing__category {
margin-top: 0.5rem;
}
.department-customer-pricing--tiles .department-customer-pricing__category {
margin-top: 0;
}
.department-customer-pricing--tiles .department-customer-pricing__summary-panel .department-customer-pricing__summary,
.department-customer-pricing--tiles .department-customer-pricing__toolbar-panel .department-customer-pricing__toolbar {
margin-bottom: 0;
}
.department-customer-pricing--tiles .department-customer-pricing__category-header .title,
.department-customer-pricing--tiles .department-customer-pricing__summary .title,
.department-customer-pricing--tiles .department-customer-pricing__summary .subtitle {
display: none;
}
.department-customer-pricing__category-header {
align-items: center;
margin-bottom: 0.5rem;
}
.department-customer-pricing__category-header .title {
margin-bottom: 0;
}
.department-customer-pricing__table th,
.department-customer-pricing__table td {
vertical-align: middle;
}
.department-customer-pricing__cell-button {
justify-content: space-between;
min-width: 7rem;
width: 100%;
}
@media screen and (max-width: 768px) {
.department-customer-pricing__toolbar,
.department-customer-pricing__summary,
.department-customer-pricing__category-header {
align-items: stretch;
flex-direction: column;
}
}
</style>
@@ -16,6 +16,7 @@ import InvoicesTable from "@/components/displays/user/invoices/invoicesTable.vue
import CollectedOrderInvoicesTable from "@/components/displays/superuser/tables/collectedOrderInvoicesTable.vue";
import CollectedOrderInvoicesPagination
from "@/components/displays/pagination/models/SuperUserDashboard/CollectedOrderInvoicesPagination.vue";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
const props = defineProps({
customer_id: {
type: Number,
@@ -172,7 +173,7 @@ const tabs = ref([
<div class="field">
<label class="label">{{SessionUser.objects.global.language.select}} {{SessionUser.objects.global.language.invoice.toLowerCase()}} {{SessionUser.objects.global.language.time.date.toLowerCase()}}</label>
<div class="control">
<input class="input" type="date" v-model="selectedDate" />
<BuefyDateField v-model="selectedDate" value-type="string" data-testid="invoice-collection-date" />
</div>
</div>
<!-- Create invoice collection button -->
@@ -1,33 +1,39 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatLocalDateOnly } from '@/services/dateOnly.js';
type startEndDate = {
start: Date; // The start date of the range
end: Date; // The end date of the range
};
type datePreset = startEndDate & {
type datePreset = {
labelKey: string; // The i18n key for the label (E.g., "global.text.today")
getRange: () => startEndDate;
};
const datePresetFunctions = {
week: {
// Get the first day of the week (Monday) based on the provided date
firstDayOfWeek: (date: Date): Date => {
const day = date.getDay();
const adjustedDate = new Date(date.getTime());
const day = adjustedDate.getDay();
const diff = (day === 0 ? -6 : 1) - day; // Adjust for Sunday
return new Date(date.setDate(date.getDate() + diff));
adjustedDate.setDate(adjustedDate.getDate() + diff);
return adjustedDate;
},
// Get the last day of the week (Sunday) based on the provided date
lastDayOfWeek: (date: Date): Date => {
const day = date.getDay();
const adjustedDate = new Date(date.getTime());
const day = adjustedDate.getDay();
const diff = (day === 0 ? 0 : 7) - day; // Adjust for Sunday
return new Date(date.setDate(date.getDate() + diff));
adjustedDate.setDate(adjustedDate.getDate() + diff);
return adjustedDate;
},
},
month: {
// Get the first day of the month based on the provided date first day at 00:00:01
firstDayOfMonth: (date: Date): Date => {
return new Date(date.getFullYear(), date.getMonth(), 2, 0, 0, 1, 0); // Set to the first day of the month at 00:00:01
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0); // Set to the first day of the month at 00:00:00
},
// Get the last day of the month based on the provided date last day at 23:59:59
lastDayOfMonth: (date: Date): Date => {
@@ -80,26 +86,34 @@ const datePresetFunctions = {
const today: datePreset = {
labelKey: 'global.text.today',
start: new Date(),
end: new Date(),
getRange: () => {
const today = new Date();
return { start: today, end: new Date(today.getTime()) };
},
};
const yesterday: datePreset = {
labelKey: 'global.text.yesterday',
start: datePresetFunctions.applyStringModifier(new Date(), '-1 day'),
end: datePresetFunctions.applyStringModifier(new Date(), '-1 day'),
getRange: () => {
const yesterday = datePresetFunctions.applyStringModifier(new Date(), '-1 day');
return { start: yesterday, end: new Date(yesterday.getTime()) };
},
};
const thisWeek: datePreset = {
labelKey: 'global.text.this_week',
start: datePresetFunctions.week.firstDayOfWeek(new Date()),
end: datePresetFunctions.week.lastDayOfWeek(new Date()),
getRange: () => ({
start: datePresetFunctions.week.firstDayOfWeek(new Date()),
end: datePresetFunctions.week.lastDayOfWeek(new Date()),
}),
};
const thisMonth: datePreset = {
labelKey: 'global.text.this_month',
start: datePresetFunctions.month.firstDayOfMonth(new Date()),
end: datePresetFunctions.month.lastDayOfMonth(new Date()),
getRange: () => ({
start: datePresetFunctions.month.firstDayOfMonth(new Date()),
end: datePresetFunctions.month.lastDayOfMonth(new Date()),
}),
};
@@ -116,7 +130,7 @@ export const datePresets = <datePreset[]>[
*/
const convertToISO = (date: Date): string => {
return date.toISOString().split('T')[0]; // Convert to YYYY-MM-DD format
return formatLocalDateOnly(date); // Convert to YYYY-MM-DD format
};
export const dateFunctions = {
@@ -150,4 +164,4 @@ export default defineComponent({
},
});
</script>
<template></template>
<template></template>
@@ -0,0 +1,134 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps({
label: {
type: String,
required: true,
},
activeCount: {
type: Number,
default: 0,
},
testId: {
type: String,
default: "pagination-other-filters",
},
});
const isOpen = ref(false);
const root = ref(null);
const normalizedActiveCount = computed(() => {
const parsedCount = Number.parseInt(props.activeCount, 10);
return Number.isFinite(parsedCount) && parsedCount > 0 ? parsedCount : 0;
});
const close = () => {
isOpen.value = false;
};
const toggle = () => {
isOpen.value = !isOpen.value;
};
const handleDocumentClick = (event) => {
if (!root.value || root.value.contains(event.target)) {
return;
}
close();
};
const handleDocumentKeydown = (event) => {
if (event.key === "Escape") {
close();
}
};
onMounted(() => {
document.addEventListener("click", handleDocumentClick);
document.addEventListener("keydown", handleDocumentKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener("click", handleDocumentClick);
document.removeEventListener("keydown", handleDocumentKeydown);
});
</script>
<template>
<div
ref="root"
class="dropdown is-right pagination-other-filters"
:class="{ 'is-active': isOpen }"
:data-testid="testId"
>
<div class="dropdown-trigger">
<button
type="button"
class="button is-light is-small pagination-other-filters__button"
:aria-expanded="isOpen ? 'true' : 'false'"
aria-haspopup="true"
:aria-label="label"
:data-testid="`${testId}-button`"
@click.stop="toggle"
>
<span class="icon is-small">
<i class="fas fa-filter" aria-hidden="true"></i>
</span>
<span>{{ label }}</span>
<span
v-if="normalizedActiveCount > 0"
class="tag is-info is-rounded is-small pagination-other-filters__count"
:data-testid="`${testId}-count`"
>
{{ normalizedActiveCount }}
</span>
</button>
</div>
<div
class="dropdown-menu pagination-other-filters__menu"
role="menu"
:data-testid="`${testId}-menu`"
>
<div class="dropdown-content pagination-other-filters__content">
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.pagination-other-filters__button {
overflow: visible;
padding-right: 1.15rem;
position: relative;
white-space: nowrap;
}
.pagination-other-filters__count {
align-items: center;
border: 2px solid #fff;
display: inline-flex;
height: 1.15rem;
justify-content: center;
min-width: 1.15rem;
padding: 0 0.3rem;
position: absolute;
right: -0.35rem;
top: -0.45rem;
}
.pagination-other-filters__menu {
min-width: min(22rem, calc(100vw - 2rem));
}
.pagination-other-filters__content {
border: 1px solid #dbdbdb;
border-radius: 6px;
max-height: min(70vh, 36rem);
overflow-y: auto;
padding: 0.75rem;
}
</style>
@@ -64,6 +64,7 @@ const isSmall = ref(window.innerWidth < 1024);
<slot name="paginationDisplayFiltersElement"></slot>
<slot name="leftPaginationColumns"></slot>
<div class="column is-auto-fill my-3" v-if="!isSmall" />
<slot name="rightFilterActions"></slot>
<div class="columns is-multiline">
<div class="column is-12 p-1 m-0"></div>
<slot name="rightPaginationColumns"></slot>
@@ -38,7 +38,8 @@ if (router.currentRoute.value.params.departmentId) {
<TableLabeledPagination :label="SessionUser.objects.department_notification_sms.meta.title">
<template #buttons="{ loadList }">
<button
class="button is-info is-small"
class="button is-primary is-small"
data-testid="department-notification-sms-add-button"
@click="SessionUser.objects.department_notification_sms.showCreateObjectForm(() => loadList(), {department: parseInt(router.currentRoute.value.params.departmentId)})"
>
<span class="icon is-small">
@@ -21,9 +21,32 @@ const props = defineProps({
type: Number, default: 0
}
})
import {computed, ref, watch } from "vue";
import {computed, provide, ref, watch } from "vue";
import { useRouter } from "vue-router";
import {
PaginatedListKey,
usePaginatedList,
} from "@/components/pagination/paginatedList.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
import PaginationDisplayTemplateDates
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { useI18n } from 'vue-i18n'
import { BSwitch } from "buefy";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoading,
list,
loadList,
@@ -38,22 +61,8 @@ import {
setOrder,
hideSearchField,
setHideSearchField,
} from "@/components/pagination/paginatedList.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
} = paginatedList;
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
import PaginationDisplayTemplateDates
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { useI18n } from 'vue-i18n'
import { BSwitch } from "buefy";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
const { t } = useI18n()
const router = useRouter();
const SELFWASH_PERIOD_ALL_LIMIT = 10000;
setEndpoint("/modules/xlvask/services/usage/orders", false);
if (props.loadAllAtOnce) {
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
@@ -63,7 +72,7 @@ const parseInitialDate = (value) => {
return new Date();
}
const parsed = new Date(`${value}T00:00:00`);
const parsed = parseLocalDateOnly(value);
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
};
@@ -103,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);
@@ -129,7 +149,7 @@ const dateFrom = ref(parseInitialDate(props.initialDateFrom));
const dateTo = ref(parseInitialDate(props.initialDateTo));
const parsedDate = (date) => {
return new Date(date);
return parseLocalDateOnly(date);
};
const reloadScheduled = ref(false);
@@ -148,14 +168,14 @@ const actions = {
from: {
select: (date) => {
dateFrom.value = parsedDate(date);
setFilter("StartTime-date_from", date.toISOString().split("T")[0], false);
setFilter("StartTime-date_from", formatLocalDateOnly(date), false);
scheduleReload();
}
},
to: {
select: (date) => {
dateTo.value = parsedDate(date);
setFilter("StartTime-date_to", date.toISOString().split("T")[0], false);
setFilter("StartTime-date_to", formatLocalDateOnly(date), false);
scheduleReload();
}
},
@@ -81,7 +81,7 @@ const props = defineProps({
}
})
const emit = defineEmits(["flagStatusChanged", "flagCreated"]);
import { computed, ref, provide } from "vue";
import { computed, ref, provide, watch } from "vue";
import { useRouter } from "vue-router";
import {
usePaginatedList,
@@ -100,7 +100,13 @@ const {
setPage,
search,
endpoint,
filter,
metaSearch,
additionalQueryParameters,
orderBy,
orderDirection,
setFilter,
getFilter,
setAdditionalQueryParameters,
setOrder,
setHideSearchField,
@@ -108,9 +114,17 @@ const {
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import PaginationOtherFiltersDropdown from "@/components/displays/pagination/PaginationOtherFiltersDropdown.vue";
import DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
import {now} from "@vueuse/core";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
import { useI18n } from 'vue-i18n'
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import {
buildOrderDateEventRequestParams,
buildOrderDateEvents,
stripOrderDateFilters,
} from "@/services/orderDateEvents.js";
const { t } = useI18n()
const router = useRouter();
@@ -173,24 +187,208 @@ if (props.autoLoad) {
loadList();
}
const date_from = ref(null);
const date_to = ref(null);
const date_from = ref(props.dates?.dateFrom ?? null);
const date_to = ref(props.dates?.dateTo ?? null);
const orderDateEvents = ref([]);
const orderDateEventRequestId = ref(0);
const orderDateEventSignature = computed(() => JSON.stringify({
endpoint: endpoint.value,
filters: stripOrderDateFilters(filter.value),
search: metaSearch.value,
additionalQueryParameters: additionalQueryParameters.value,
}));
const setDateFilter = (filterKey, value, autoLoad = true) => {
setFilter(filterKey, value || "*", autoLoad);
};
const onDateFilterChange = (filterKey, value) => {
setDateFilter(filterKey, value, true);
};
const onDateRangeSelected = (newSelectionStartDate, newSelectionToDate) => {
const formattedStartDate = newSelectionStartDate.toISOString().split("T")[0];
const formattedEndDate = newSelectionToDate.toISOString().split("T")[0];
const formattedStartDate = newSelectionStartDate ? formatLocalDateOnly(newSelectionStartDate) : "";
const formattedEndDate = newSelectionToDate ? formatLocalDateOnly(newSelectionToDate) : "";
date_from.value = formattedStartDate;
date_to.value = formattedEndDate;
setFilter("created_at-date_from", formattedStartDate, true);
setFilter("created_at-date_to", formattedEndDate, true);
setDateFilter("created_at-date_from", formattedStartDate, false);
setDateFilter("created_at-date_to", formattedEndDate, false);
loadList();
};
const hiddenOrderFilterDefinitions = computed(() => [
{
key: "order-direction",
type: "order",
label: t("pagination.order_direction"),
defaultValue: "desc",
options: [
{ value: "asc", label: t("pagination.ascending") },
{ value: "desc", label: t("pagination.descending") },
],
},
{
key: "booked-invoice-id",
filterKey: "booked_invoice_id",
label: t("pagination.booking_status"),
options: [
{ value: "*", label: t("common.all") },
{ value: "not null", label: t("pagination.booked") },
{ value: "is_null", label: t("pagination.not_booked") },
],
},
{
key: "processor",
filterKey: "processor",
label: t("pagination.payment_processor"),
options: [
{ value: "*", label: t("common.all") },
{ value: "1", label: "E-conomic" },
{ value: "2", label: "Stripe" },
{ value: "3", label: t("pagination.other_no_tracking") },
{ value: "is_null", label: t("pagination.not_specified") },
],
},
{
key: "completed-at",
filterKey: "completed_at",
label: t("pagination.completed"),
options: [
{ value: "*", label: t("common.all") },
{ value: "not null", label: t("pagination.completed") },
{ value: "is_null", label: t("pagination.not_completed") },
],
},
{
key: "invoice-selection",
filterKey: "customer_id-has_attribute",
label: t("pagination.invoice_selection"),
options: [
{ value: "*", label: t("common.all") },
{ value: "invoiceAllOrdersIndividually", label: t("pagination.invoice_per_order") },
{ value: "!invoiceAllOrdersIndividually", label: t("pagination.invoice_per_month") },
],
},
{
key: "special-agreement",
filterKey: "customer_id-has_key-OtherSpecialArrangement",
label: t("pagination.special_agreement"),
options: [
{ value: "*", label: t("common.all") },
{ value: "OtherSpecialArrangement", label: t("pagination.has_special_agreement") },
{ value: "!OtherSpecialArrangement", label: t("pagination.no_special_agreement") },
],
},
{
key: "wash-subscription",
filterKey: "customer_id-has_key-OtherVaskeabonnement",
label: t("pagination.wash_subscription"),
options: [
{ value: "*", label: t("common.all") },
{ value: "OtherVaskeabonnement", label: t("pagination.has_wash_subscription") },
{ value: "!OtherVaskeabonnement", label: t("pagination.no_wash_subscription") },
],
},
]);
const isActiveHiddenOrderFilterValue = (value) => (
value !== undefined && value !== null && value !== "" && value !== "*"
);
const getHiddenOrderFilterValue = (filterDefinition) => {
if (filterDefinition.type === "order") {
return orderDirection.value || filterDefinition.defaultValue;
}
return getFilter(filterDefinition.filterKey) || "*";
};
const applyHiddenOrderFilter = (filterDefinition, value) => {
if (filterDefinition.type === "order") {
setOrder("created_at", value);
loadList();
return;
}
setFilter(filterDefinition.filterKey, value, true);
};
const activeHiddenOrderFiltersCount = computed(() => hiddenOrderFilterDefinitions.value.filter((filterDefinition) => {
const value = getHiddenOrderFilterValue(filterDefinition);
if (filterDefinition.type === "order") {
return value !== filterDefinition.defaultValue;
}
return isActiveHiddenOrderFilterValue(value);
}).length);
const doesEndpointMatch = (matcher) => {
// Check if the endpoint matches the current endpoint
return endpoint.value === matcher;
}
const loadOrderDateEvents = async () => {
if (!doesEndpointMatch("/orders")) {
orderDateEvents.value = [];
return;
}
const requestId = orderDateEventRequestId.value + 1;
orderDateEventRequestId.value = requestId;
try {
const rows = [];
let page = 1;
let totalPages = 1;
const limit = 1000;
while (page <= totalPages) {
const response = await authenticatedRequest(
"/orders",
"GET",
buildOrderDateEventRequestParams({
filters: filter.value,
search: metaSearch.value,
additionalQueryParameters: additionalQueryParameters.value,
orderBy: orderBy.value,
orderDirection: orderDirection.value,
page,
limit,
})
);
if (requestId !== orderDateEventRequestId.value) {
return;
}
const pageRows = Array.isArray(response?.data?.data) ? response.data.data : [];
rows.push(...pageRows);
const pagination = response?.data?.meta?.pagination || {};
const perPage = Number.parseInt(pagination.per_page, 10) || limit;
const total = Number.parseInt(pagination.total, 10);
totalPages = Number.isFinite(total) && total > 0
? Math.max(1, Math.ceil(total / Math.max(1, perPage)))
: 1;
page += 1;
}
if (requestId === orderDateEventRequestId.value) {
orderDateEvents.value = buildOrderDateEvents(rows);
}
} catch (error) {
if (requestId === orderDateEventRequestId.value) {
console.warn("Unable to load order date events", error);
orderDateEvents.value = [];
}
}
};
watch(orderDateEventSignature, () => {
loadOrderDateEvents();
}, { immediate: true });
</script>
<template>
@@ -223,145 +421,89 @@ const doesEndpointMatch = (matcher) => {
/>
</template>
<template #leftPaginationColumns>
<!-- Sort by created_at -->
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.order_direction') }}</label>
<div class="control">
<div class="select">
<select @change="setOrder('created_at', $event.target.value); loadList();">
<option value="asc">{{ t('pagination.ascending') }}</option>
<option value="desc" selected>{{ t('pagination.descending') }}</option>
</select>
</div>
</div>
</div>
<!-- Invoice status -->
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.booking_status') }}</label>
<div class="control">
<div class="select">
<select @change="setFilter('booked_invoice_id', $event.target.value, true);">
<option value="*" :selected="!props.applyDefaultFilters">{{ t('common.all') }}</option>
<option value="not null">{{ t('pagination.booked') }}</option>
<option value="is_null" :selected="props.applyDefaultFilters">{{ t('pagination.not_booked') }}</option>
</select>
</div>
</div>
</div>
<!-- Error status -->
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.error_status') }}</label>
<div class="control">
<div class="select">
<select @change="setFilter('error_message', $event.target.value, true);">
<option value="*" selected>{{ t('common.all') }}</option>
<option value="not null">{{ t('common.yes') }}</option>
<option value="is_null">{{ t('common.no') }}</option>
</select>
</div>
</div>
</div>
<!-- Payment processor -->
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.payment_processor') }}</label>
<div class="control">
<div class="select">
<select @change="setFilter('processor', $event.target.value, true);">
<option value="*" selected>{{ t('common.all') }}</option>
<option value="1">E-conomic</option>
<option value="2">Stripe</option>
<option value="3">{{ t('pagination.other_no_tracking') }}</option>
<option :value="'is_null'">{{ t('pagination.not_specified') }}</option>
</select>
</div>
</div>
</div>
<!-- Marked as complete ("Gennemført") -->
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.completed') }}</label>
<div class="control">
<div class="select">
<select @change="setFilter('completed_at', $event.target.value, true);">
<option value="*" selected>{{ t('common.all') }}</option>
<option value="not null">{{ t('pagination.completed') }}</option>
<option :value="'is_null'">{{ t('pagination.not_completed') }}</option>
</select>
</div>
</div>
</div>
<!-- Multiple selection, customer has attribute -->
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.invoice_selection') }}</label>
<div class="control">
<div class="select">
<select @change="setFilter('customer_id-has_attribute', $event.target.value, true);">
<option value="*" selected>{{ t('common.all') }}</option>
<option value="invoiceAllOrdersIndividually">{{ t('pagination.invoice_per_order') }}</option>
<option value="!invoiceAllOrdersIndividually">{{ t('pagination.invoice_per_month') }}</option>
</select>
</div>
</div>
</div>
<!-- Multiple selection, customer has special arrangement -->
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.special_agreement') }}</label>
<div class="control">
<div class="select">
<select @change="setFilter('customer_id-has_key-OtherSpecialArrangement', $event.target.value, true);">
<option value="*" :selected="!props.applyDefaultFilters">{{ t('common.all') }}</option>
<option value="OtherSpecialArrangement">{{ t('pagination.has_special_agreement') }}</option>
<option value="!OtherSpecialArrangement" :selected="props.applyDefaultFilters">{{ t('pagination.no_special_agreement') }}</option>
</select>
</div>
</div>
</div>
<!-- Multiple selection, customer has washing subscription -->
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.wash_subscription') }}</label>
<div class="control">
<div class="select">
<select @change="setFilter('customer_id-has_key-OtherVaskeabonnement', $event.target.value, true);">
<option value="*" :selected="!props.applyDefaultFilters">{{ t('common.all') }}</option>
<option value="OtherVaskeabonnement">{{ t('pagination.has_wash_subscription') }}</option>
<option value="!OtherVaskeabonnement" :selected="props.applyDefaultFilters">{{ t('pagination.no_wash_subscription') }}</option>
</select>
</div>
</div>
</div>
<!-- Multiple selection, customer has washing subscription -FIX
<div class="column is-narrow">
<label class="label">Only tank cleaning</label>s
<div class="control">
<div class="select">
<select @change="setFilter('customer_id-has_attribute', $event.target.value, true);">
<option value="*" :selected="!props.applyDefaultFilters">All</option>
<option value="onlyTankCleaning">Only tank cleaning</option>
<option value="!onlyTankCleaning" :selected="props.applyDefaultFilters">Ikke tankcleaning</option>
</select>
</div>
</div>
</div> -->
<!-- Date from -->
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.from_date') }}</label>
<div class="control">
<input type="date" class="input" @change="setFilter('created_at-date_from', $event.target.value, true)" v-model="date_from" />
<BuefyDateField
v-model="date_from"
value-type="string"
data-testid="invoice-orders-date-from"
placeholder="--.--.----"
clearable
:events="orderDateEvents"
indicators="dots"
@change="(value) => onDateFilterChange('created_at-date_from', value)"
/>
</div>
</div>
<!-- Date to -->
<div class="column is-narrow">
<label class="label is-small">{{ t('pagination.to_date') }}</label>
<div class="control">
<input type="date" class="input" @change="setFilter('created_at-date_to', $event.target.value, true)" v-model="date_to" />
<BuefyDateField
v-model="date_to"
value-type="string"
data-testid="invoice-orders-date-to"
placeholder="--.--.----"
clearable
:events="orderDateEvents"
indicators="dots"
@change="(value) => onDateFilterChange('created_at-date_to', value)"
/>
</div>
</div>
</template>
<template #rightFilterActions>
<div class="column is-narrow invoice-orders-other-filters-column">
<PaginationOtherFiltersDropdown
:label="t('pagination.other_filters')"
:active-count="activeHiddenOrderFiltersCount"
test-id="invoice-orders-other-filters"
>
<div class="invoice-orders-other-filters__fields">
<div
v-for="filterDefinition in hiddenOrderFilterDefinitions"
:key="filterDefinition.key"
class="field invoice-orders-other-filters__field"
>
<label
class="label is-small"
:for="`invoice-orders-other-filter-${filterDefinition.key}`"
>
{{ filterDefinition.label }}
</label>
<div class="control">
<div class="select is-fullwidth">
<select
:id="`invoice-orders-other-filter-${filterDefinition.key}`"
:value="getHiddenOrderFilterValue(filterDefinition)"
:data-testid="`invoice-orders-other-filter-${filterDefinition.key}`"
@change="applyHiddenOrderFilter(filterDefinition, $event.target.value)"
>
<option
v-for="option in filterDefinition.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
</div>
</div>
</PaginationOtherFiltersDropdown>
</div>
</template>
<template #rightPaginationColumns>
<div class="column is-12">
<!-- Shortcuts for date filters -->
<DatePeriodSelector :on-selection-change="onDateRangeSelected"
:visibility="{ showDailySelector: false, showWeeklySelector: false, showMultipleMonthWarning: false, showUpdateButton: false, showMonthSelector: false, showStartDate: false, showEndDate: false, showSelectionValidity: false, showYearSelector: false, showToLabel: false }"
v-bind:selection="{ startDate: date_from ? new Date(date_from) : new Date(now()), endDate: date_to ? new Date(date_to) : new Date(now()) }"/>
v-bind:allow-empty-selection="true"
v-bind:events="orderDateEvents"
v-bind:selection="{ startDate: date_from ? parseLocalDateOnly(date_from) : null, endDate: date_to ? parseLocalDateOnly(date_to) : null }"/>
</div>
</template>
<template #default>
@@ -387,4 +529,17 @@ const doesEndpointMatch = (matcher) => {
margin-top: -0.35rem;
margin-bottom: 0.25rem;
}
.invoice-orders-other-filters-column {
align-self: flex-end;
margin-left: auto;
}
.invoice-orders-other-filters__field {
margin-bottom: 0.75rem;
}
.invoice-orders-other-filters__field:last-child {
margin-bottom: 0;
}
</style>
@@ -24,6 +24,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
userScopedUserId: {
type: [String, Number],
default: null,
},
});
const { t } = useI18n();
@@ -99,7 +103,11 @@ if (props.autoLoad) {
</template>
</PaginationDisplay>
<SubusersTable :objects="list" :show-customer="showCustomer" />
<SubusersTable
:objects="list"
:show-customer="showCustomer"
:user-scoped-user-id="userScopedUserId"
/>
<PaginationNavigation
:currentPage="metaCurrentPage"
@@ -10,11 +10,21 @@ const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const { list, loadList, setEndpoint, setFilter, setOrder, hideSearchField, setHideSearchField } = paginatedList;
const {
list,
loadList,
setAdditionalQueryParameters,
setEndpoint,
setFilter,
setOrder,
hideSearchField,
setHideSearchField,
} = paginatedList;
const { t } = useI18n();
setEndpoint("/users", false);
setFilter("customer_number", 0, false);
setAdditionalQueryParameters({ include_limited_backoffice_employees: "true" });
setOrder("created_at", "desc");
// Hide the search field, if the hideSearch prop is set
@@ -52,7 +62,7 @@ if (props.autoLoad) {
</div>
</div>
</template>
<UsersTable :objects="list" />
<UsersTable :objects="list" @migrated="loadList" />
</TableLabeledPagination>
</template>
@@ -24,13 +24,16 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import BookingsTable from "@/components/displays/user/bookings/bookingsTable.vue";
import {departments} from "@/components/pagination/departmentTabs.vue";
import {ref, watch} from "vue";
import {computed, ref, watch} from "vue";
import { Colors } from "@/ThemeConfig.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from 'vue-i18n'
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const canCreateBooking = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
// Set the selected status to all
const selectedStatus = ref("*");
@@ -52,7 +55,7 @@ watch(() => getFilter("status"), (value) => {
// If the route starts with /user, set the endpoint to /user/bookings
if (router.currentRoute.value.path.startsWith("/user")) {
setEndpoint("/user/bookings", false);
setFilter("date", new Date().toISOString().split("T")[0], false);
setFilter("date", todayLocalDateOnly(), false);
setOrder("created_at", "desc");
}
// If the route starts with /superuser, set the endpoint to /bookings
@@ -81,7 +84,7 @@ loadList();
const showingToday = ref(true);
watch(() => getFilter("date"), (value) => {
showingToday.value = value === new Date().toISOString().split("T")[0];
showingToday.value = value === todayLocalDateOnly();
});
// If the screen is mobile, set the is small variable to true
@@ -147,17 +150,17 @@ const showNewOrderBookingsPortal = () => {
</div>
<div class="column is-auto-fill my-3" v-if="!isSmall"/>
<!-- Only show the bookings for today (Switch, if the route is /user) -->
<div class="column is-narrow my-3" v-if="router.currentRoute.value.path.startsWith('/user')" :class="{ 'has-text-right': !isSmall }">
<div class="column is-narrow my-3" v-if="isUserRoute" :class="{ 'has-text-right': !isSmall }">
<label class="label">{{ isSmall ? t('common.today') : t('pagination.show_only_today') }}</label>
<div class="control">
<div class="field">
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? new Date().toISOString().split('T')[0] : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? todayLocalDateOnly() : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
<label for="today"></label>
</div>
</div>
</div>
<!-- Create a new booking, if the route is /user -->
<div class="column is-narrow my-3" v-if="router.currentRoute.value.path.startsWith('/user')" :class="{ 'has-text-right': !isSmall }">
<div class="column is-narrow my-3" v-if="isUserRoute && canCreateBooking" :class="{ 'has-text-right': !isSmall }">
<button
class="button is-link"
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
@@ -188,4 +191,4 @@ const showNewOrderBookingsPortal = () => {
<style scoped>
</style>
</style>
@@ -8,6 +8,7 @@ import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displa
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
import { Colors } from "@/ThemeConfig.vue";
import { useI18n } from "vue-i18n";
import { endOfLocalDate, startOfLocalDate, todayLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n();
/**
@@ -16,6 +17,7 @@ const { t } = useI18n();
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const isAdminRoute = computed(() => router.currentRoute.value.path.startsWith("/admin"));
const canCreateBooking = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
/**
* Props
*/
@@ -52,11 +54,9 @@ const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
setFilter("datetime-date_from", null, false);
setFilter("datetime-date_to", null, false);
} else {
const startOfDay = new Date().setHours(0, 0, 0, 0);
const endOfDay = new Date().setHours(23, 59, 59, 999);
//setFilter('datetime', null, false);
setFilter("datetime-date_from", new Date(startOfDay).toISOString(), false);
setFilter("datetime-date_to", new Date(endOfDay).toISOString(), false);
setFilter("datetime-date_from", startOfLocalDate(val).toISOString(), false);
setFilter("datetime-date_to", endOfLocalDate(val).toISOString(), false);
}
if (autoLoadList) {
loadList();
@@ -95,7 +95,7 @@ onMounted(() => {
departmentFilter.value = value;
} else if (key === "only_today" && value === true) {
setFilterKey = false; // Since the only_today filter is handled separately
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split("T")[0] } }, false);
onOnlyTodayFilterChange({ target: { value: todayLocalDateOnly() } }, false);
}
if (setFilterKey) {
setFilter(key, value, false);
@@ -170,7 +170,7 @@ onMounted(() => {
<div class="select">
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
<option value="*">{{ t("common.all") }}</option>
<option :value="new Date().toISOString().split('T')[0]">{{ t("common.yes") }}</option>
<option :value="todayLocalDateOnly()">{{ t("common.yes") }}</option>
</select>
</div>
</div>
@@ -193,7 +193,7 @@ onMounted(() => {
@change="
(event) => {
onOnlyTodayFilterChange({
target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' },
target: { value: event.target.checked ? todayLocalDateOnly() : '*' },
});
}
"
@@ -203,7 +203,7 @@ onMounted(() => {
<label for="today"></label>
</div>
</div>
<div class="order-bookings-pagination__new-booking-action">
<div class="order-bookings-pagination__new-booking-action" v-if="canCreateBooking">
<label class="label is-small order-bookings-pagination__desktop-spacer">&nbsp;</label>
<button
class="button is-link button-same-width"
@@ -226,7 +226,7 @@ onMounted(() => {
@change="
(event) => {
onOnlyTodayFilterChange(
{ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } },
{ target: { value: event.target.checked ? todayLocalDateOnly() : '*' } },
false
);
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } });
@@ -8,6 +8,7 @@ import {
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useRouter } from "vue-router";
import VehiclesTable from "@/components/displays/user/vehicles/vehiclesTable.vue";
import { computed } from "vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import {Colors} from "@/ThemeConfig.vue";
@@ -20,8 +21,15 @@ const { t } = useI18n();
* Router
*/
const router = useRouter();
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
const canAddVehicle = computed(() => SessionUser.canAccessCustomerFeature("vehicles", "add"));
const props = defineProps({
endpoint: {
type: String,
required: false,
default: "/vehicles",
},
customer_id: {
type: Number,
required: false,
@@ -32,12 +40,34 @@ const props = defineProps({
required: false,
default: null,
},
userScopedUserId: {
type: [Number, String],
required: false,
default: null,
},
superuserPage: {
type: Boolean,
required: false,
default: false,
},
onAfterMutation: {
type: Function,
required: false,
default: null,
},
})
setEndpoint("/vehicles", false);
setEndpoint(props.endpoint, false);
if (props.customer_id) {
setFilter("customer_id", props.customer_id, false);
}
loadList();
const reloadVehicles = async () => {
await loadList();
if (typeof props.onAfterMutation === "function") {
await props.onAfterMutation();
}
};
</script>
<template>
@@ -58,7 +88,7 @@ loadList();
</div>
<!-- Create a new vehicle, if the route is /user -->
<div class="vehicles-pagination__add-action"
v-if="router.currentRoute.value.path.startsWith('/user')">
v-if="isUserRoute && canAddVehicle">
<label class="label is-small">&nbsp;</label>
<button
class="button is-link button-same-width vehicles-pagination__add-button"
@@ -72,7 +102,14 @@ loadList();
</div>
</template>
<template #default>
<VehiclesTable :vehicles="list" :show_add_vehicle="props.customer_id" :add_other_customer_id="props.add_other_customer_id"/>
<VehiclesTable
:vehicles="list"
:show_add_vehicle="props.customer_id"
:add_other_customer_id="props.add_other_customer_id"
:reload-list="reloadVehicles"
:user-scoped-user-id="props.userScopedUserId"
:superuser-page="props.superuserPage"
/>
</template>
</TableLabeledPagination>
</div>
@@ -1,12 +1,16 @@
<script setup lang="ts">
import PaginationDisplayItemColumn from "@/components/displays/pagination/PaginationDisplayItemColumn.vue";
import { ref, computed } from "vue";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
const props = defineProps({
label: {
type: String,
required: true,
default: "Date",
},
placeholder: {
type: String,
default: "",
},
});
const emit = defineEmits(['update:date']);
const date = defineModel<Date>({
@@ -18,28 +22,20 @@ defineExpose({
date,
});
const formattedDate = computed(() => {
return date.value.toISOString().split('T')[0];
});
// This component is used to display a date input in a pagination display item column.
// It allows the user to select a date, which will be emitted to the parent component.
// The date is displayed in a YYYY-MM-DD format, which is compatible with the HTML date
// input type.
const updateDate = (newDate: Date) => {
emit('update:date', newDate);
};
</script>
<template>
<PaginationDisplayItemColumn :label="props.label">
<template #control>
<input
type="date"
class="input"
:value="formattedDate"
@input="(e) => {
const newDate = new Date(e.target.value);
console.log('Selected date:', newDate);
emit('update:date', newDate);
}"
<BuefyDateField
v-model="date"
value-type="date"
data-testid="pagination-date-picker"
:placeholder="props.placeholder"
@change="updateDate"
/>
</template>
</PaginationDisplayItemColumn>
@@ -47,4 +43,4 @@ const formattedDate = computed(() => {
<style scoped>
</style>
</style>
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from '@/components/session/token/SessionUser.vue';
@@ -10,7 +9,9 @@ import PaginationDisplayTemplateButton
from "@/components/displays/pagination/templates/PaginationDisplayTemplateButton.vue";
import { datePresets , dateFunctions} from '@/components/displays/pagination/PaginationDisplayDates.vue';
const props = defineProps({
const DATE_FILTER_PLACEHOLDER = "--.--.----";
defineProps({
startDate: {
type: Date,
required: true,
@@ -46,6 +47,24 @@ function updateStartDate(date: Date) {
// Update the end date model when the date is changed
function updateEndDate(date: Date) {
endDateModel.value = date;
emits('update:endDate', date);
}
function getPresetRange(preset: { getRange: () => { start: Date; end: Date } }) {
return preset.getRange();
}
function isPresetSelected(preset: { getRange: () => { start: Date; end: Date } }) {
return dateFunctions.dateComparison.match.dates(
{ start: startDateModel.value, end: endDateModel.value },
getPresetRange(preset),
);
}
function applyPreset(preset: { getRange: () => { start: Date; end: Date } }) {
const range = getPresetRange(preset);
updateStartDate(new Date(range.start.getTime()));
updateEndDate(new Date(range.end.getTime()));
}
</script>
@@ -54,23 +73,19 @@ function updateEndDate(date: Date) {
:label="SessionUser.objects.global.language.date_from"
@update:date="updateStartDate"
v-bind:model-value="startDateModel"
:placeholder="DATE_FILTER_PLACEHOLDER"
/>
<PaginationDisplayTemplateDate
:label="SessionUser.objects.global.language.date_to"
@update:date="updateEndDate"
v-bind:model-value="endDateModel"
:placeholder="DATE_FILTER_PLACEHOLDER"
/>
<template v-for="preset in datePresets" :key="preset.labelKey">
<PaginationDisplayTemplateButton
:label="t(preset.labelKey)"
v-bind:selected="dateFunctions.dateComparison.match.dates(
{ start: startDateModel, end: endDateModel },
preset,
)"
@click="() => {
updateStartDate(preset.start);
updateEndDate(preset.end);
}"
v-bind:selected="isPresetSelected(preset)"
@click="applyPreset(preset)"
/>
</template>
@@ -79,4 +94,4 @@ function updateEndDate(date: Date) {
<style scoped>
</style>
</style>
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { BCheckbox, BField } from "buefy";
import { onMounted, ref, watch } from "vue";
import { BCheckbox } from "buefy";
import { computed, onMounted, ref, watch } from "vue";
import i18n from "@/i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps({
grant: {
type: Object as () => { id: number; permissions: string[]; name: string },
default: () => ({ id: 0, permissions: [], name: "" }),
type: Object as () => { id: number; permissions: string[]; name: string; permission_template_key?: string },
default: () => ({ id: 0, permissions: [], name: "", permission_template_key: "custom" }),
required: true,
},
permissions: {
@@ -14,59 +15,182 @@ const props = defineProps({
default: () => [],
required: true,
},
templateKey: {
type: String,
default: null,
},
allowAdvanced: {
type: Boolean,
default: false,
},
});
type PermissionNode = {
type PermissionGroup = {
key: string;
name: string;
description: string;
type: string;
default: boolean;
capabilities: string[];
};
type PermissionNodeGroup = {
group: string;
type PermissionTemplate = {
key: string;
label: string;
description: string;
nodes: PermissionNode[];
enabled: boolean;
permissions: string[];
permission_groups: PermissionGroup[];
};
const permissionNodes = ref<PermissionNodeGroup[]>([]);
const t = (key: string, values: Record<string, string> | undefined = undefined) => i18n.global.t(key, values);
const LEGACY_PERMISSION_GROUPS: PermissionGroup[] = [
{
key: "vehicles",
capabilities: ["VEHICLES_LIST", "VEHICLES_EDIT", "VEHICLES_DELETE", "VEHICLES_ADD"],
},
{
key: "selfserve",
capabilities: ["SELFSERVE_LIST", "SELFSERVE_EDIT", "SELFSERVE_DELETE", "SELFSERVE_ADD"],
},
{
key: "bookings",
capabilities: ["BOOKINGS_LIST", "BOOKINGS_EDIT", "BOOKINGS_DELETE", "BOOKINGS_ADD"],
},
{
key: "orders",
capabilities: ["ORDERS_LIST", "ORDERS_EDIT"],
},
{
key: "driver_management",
capabilities: ["SUBUSERS_LIST", "SUBUSERS_EDIT", "SUBUSERS_DELETE", "SUBUSERS_ADD"],
},
];
const LEGACY_PERMISSION_LABEL_KEYS: Record<string, string> = {
VEHICLES_LIST: "view_vehicles",
VEHICLES_EDIT: "edit_vehicles",
VEHICLES_DELETE: "delete_vehicles",
VEHICLES_ADD: "add_vehicles",
SELFSERVE_LIST: "view_selfserve",
SELFSERVE_EDIT: "edit_selfserve",
SELFSERVE_DELETE: "delete_selfserve",
SELFSERVE_ADD: "start_selfserve",
BOOKINGS_LIST: "view_bookings",
BOOKINGS_EDIT: "edit_bookings",
BOOKINGS_DELETE: "delete_bookings",
BOOKINGS_ADD: "add_bookings",
ORDERS_LIST: "view_orders",
ORDERS_EDIT: "edit_orders",
SUBUSERS_LIST: "view_drivers",
SUBUSERS_EDIT: "edit_driver_access",
SUBUSERS_DELETE: "disable_driver_access",
SUBUSERS_ADD: "invite_drivers",
};
const templates = ref<PermissionTemplate[]>([]);
const selectedPermissions = ref<string[]>([]);
const selectedTemplateKey = ref<string>(props.templateKey || props.grant.permission_template_key || "custom");
const isLoading = ref(true);
const emit = defineEmits(["update:permissions"]);
const emit = defineEmits(["update:permissions", "update:access"]);
const syncPermissions = (value: string[]) => {
selectedPermissions.value = [...new Set((value || []).filter(Boolean))];
const normalizePermissions = (value: string[]) => [...new Set((value || []).filter(Boolean).map((item) => String(item).toUpperCase()))];
const samePermissions = (left: string[], right: string[]) => {
const a = normalizePermissions(left).sort();
const b = normalizePermissions(right).sort();
return a.length === b.length && a.every((value, index) => value === b[index]);
};
const togglePermission = (permissionKey: string, enabled: boolean) => {
if (enabled) {
selectedPermissions.value = [...new Set([...selectedPermissions.value, permissionKey])];
const findTemplate = (key: string | null | undefined) => templates.value.find((template) => template.key === key) || null;
const classifyTemplate = (permissions: string[]) => {
if (permissions.length === 0) {
return "deactivated";
}
const match = templates.value.find((template) => template.key !== "deactivated" && samePermissions(template.permissions, permissions));
return match?.key || "custom";
};
const emitAccess = () => {
emit("update:permissions", [...selectedPermissions.value]);
emit("update:access", {
permission_template_key: selectedTemplateKey.value,
permissions: [...selectedPermissions.value],
});
};
const syncPermissions = (value: string[]) => {
selectedPermissions.value = normalizePermissions(value);
if (templates.value.length > 0 && (!props.templateKey || props.templateKey === "custom")) {
selectedTemplateKey.value = classifyTemplate(selectedPermissions.value);
}
};
const selectTemplate = (key: string) => {
if (key === "custom") {
selectedTemplateKey.value = "custom";
emitAccess();
return;
}
selectedPermissions.value = selectedPermissions.value.filter((value) => value !== permissionKey);
const template = findTemplate(key);
if (!template) {
return;
}
selectedTemplateKey.value = template.key;
selectedPermissions.value = normalizePermissions(template.permissions);
emitAccess();
};
const togglePermission = (permissionKey: string, enabled: boolean) => {
selectedTemplateKey.value = "custom";
if (enabled) {
selectedPermissions.value = [...new Set([...selectedPermissions.value, permissionKey])];
} else {
selectedPermissions.value = selectedPermissions.value.filter((value) => value !== permissionKey);
}
emitAccess();
};
const visibleTemplates = computed(() => templates.value.filter((template) => template.key !== "custom"));
const activeTemplate = computed(() => findTemplate(selectedTemplateKey.value));
const showAdvanced = computed(() => props.allowAdvanced && selectedTemplateKey.value === "custom");
const templateLabel = (key: string, _fallback = "") => t(`superuser.driver_access.templates.${key}.label`);
const templateDescription = (template: PermissionTemplate) =>
t(`superuser.driver_access.templates.${template.key}.description`);
const groupLabel = (key: string) => t(`superuser.driver_access.groups.${key}`);
const permissionLabel = (permission: string) => {
const capabilityKey = LEGACY_PERMISSION_LABEL_KEYS[permission] || permission;
return t(`superuser.driver_access.capabilities.${capabilityKey}`);
};
watch(
() => props.permissions,
(newPermissions) => {
syncPermissions(newPermissions || []);
emitAccess();
},
{ immediate: true }
);
watch(selectedPermissions, (newPermissions) => {
emit("update:permissions", [...newPermissions]);
});
onMounted(async () => {
try {
permissionNodes.value = await SessionUser.objects.subuser_grants.functions.getPermissionNodes();
const accessModel = await SessionUser.objects.subuser_grants.functions.getPermissionTemplates();
templates.value = Array.isArray(accessModel?.templates) ? accessModel.templates : [];
selectedTemplateKey.value = props.templateKey || props.grant.permission_template_key || classifyTemplate(selectedPermissions.value);
if (selectedTemplateKey.value !== "custom") {
const template = findTemplate(selectedTemplateKey.value);
if (template) {
selectedPermissions.value = normalizePermissions(template.permissions);
}
}
emitAccess();
} catch (error) {
console.error("Failed to fetch permission nodes:", error);
permissionNodes.value = [];
console.error("Failed to fetch permission templates:", error);
templates.value = [];
selectedTemplateKey.value = "custom";
} finally {
isLoading.value = false;
}
@@ -75,42 +199,66 @@ onMounted(async () => {
<template>
<div>
<h2 class="title is-4">Tilladelsesnoder for {{ props.grant.name }}</h2>
<h2 class="title is-4">{{ t("superuser.driver_access.editor_title", { name: props.grant.name }) }}</h2>
<p class="mb-4 has-text-grey">
Vælg tilladelserne og tryk derefter Gem i dialogen for at opdatere chaufføren.
{{ t("superuser.driver_access.editor_intro") }}
</p>
<div v-if="isLoading" class="notification is-light">
Henter tilladelser...
{{ t("superuser.driver_access.loading") }}
</div>
<div v-else-if="permissionNodes.length === 0" class="notification is-warning is-light">
Ingen tilladelsesnoder blev fundet.
<div v-else-if="visibleTemplates.length === 0" class="notification is-warning is-light">
{{ t("superuser.driver_access.empty") }}
</div>
<div v-else v-for="group in permissionNodes" :key="group.group" class="box">
<div class="divider">
<div class="content">
<h3 class="title is-5">{{ group.group }}</h3>
<p class="subtitle is-6">{{ group.description }}</p>
</div>
</div>
<div v-else class="driver-access-editor">
<button
v-for="template in visibleTemplates"
:key="template.key"
type="button"
class="driver-access-card"
:class="{ 'driver-access-card--selected': selectedTemplateKey === template.key }"
:data-testid="`permission-template-${template.key}`"
@click="selectTemplate(template.key)"
>
<span class="driver-access-card__title">{{ templateLabel(template.key, template.label) }}</span>
<span class="driver-access-card__description">{{ templateDescription(template) }}</span>
<span v-if="selectedTemplateKey === template.key" class="tag is-success is-light">{{ t("superuser.driver_access.selected") }}</span>
</button>
<div class="columns is-multiline">
<div v-for="node in group.nodes" :key="node.key" class="column is-4">
<div class="box permission-node">
<h4 class="title is-6">{{ node.name }}</h4>
<p class="subtitle is-7">{{ node.description }}</p>
<b-field class="permission-checkbox">
<b-checkbox
:model-value="selectedPermissions.includes(node.key)"
:data-testid="`permission-node-checkbox-${node.key}`"
@update:model-value="(checked) => togglePermission(node.key, !!checked)"
@input="(checked) => togglePermission(node.key, !!checked)"
>
Aktiver
</b-checkbox>
</b-field>
<button
v-if="allowAdvanced"
type="button"
class="driver-access-card"
:class="{ 'driver-access-card--selected': selectedTemplateKey === 'custom' }"
data-testid="permission-template-custom"
@click="selectTemplate('custom')"
>
<span class="driver-access-card__title">{{ templateLabel("custom") }}</span>
<span class="driver-access-card__description">{{ t("superuser.driver_access.custom_description") }}</span>
<span v-if="selectedTemplateKey === 'custom'" class="tag is-warning is-light">{{ t("superuser.driver_access.advanced") }}</span>
</button>
</div>
<div v-if="activeTemplate && selectedTemplateKey !== 'custom'" class="notification is-info is-light mt-4">
<strong>{{ templateLabel(activeTemplate.key, activeTemplate.label) }}</strong>
<p>{{ templateDescription(activeTemplate) }}</p>
</div>
<div v-if="showAdvanced" class="advanced-access mt-5" data-testid="permission-template-custom-editor">
<div v-for="group in LEGACY_PERMISSION_GROUPS" :key="group.key" class="box">
<h3 class="title is-6">{{ groupLabel(group.key) }}</h3>
<div class="columns is-multiline">
<div v-for="capability in group.capabilities" :key="capability" class="column is-6">
<b-checkbox
:model-value="selectedPermissions.includes(capability)"
:data-testid="`permission-node-checkbox-${capability}`"
@update:model-value="(checked) => togglePermission(capability, !!checked)"
@input="(checked) => togglePermission(capability, !!checked)"
>
{{ permissionLabel(capability) }}
</b-checkbox>
</div>
</div>
</div>
@@ -119,15 +267,44 @@ onMounted(async () => {
</template>
<style scoped>
.permission-node {
height: 100%;
.driver-access-editor {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.permission-checkbox {
margin-top: 0.75rem;
.driver-access-card {
align-items: flex-start;
background: #fff;
border: 1px solid #dbdbdb;
border-radius: 6px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.4rem;
min-height: 150px;
padding: 1rem;
text-align: left;
}
.permission-checkbox :deep(.field) {
margin-bottom: 0;
.driver-access-card:hover,
.driver-access-card--selected {
border-color: #3273dc;
box-shadow: 0 0 0 1px #3273dc;
}
.driver-access-card__title {
color: #1f2933;
font-weight: 700;
}
.driver-access-card__description {
color: #4a4a4a;
font-size: 0.9rem;
line-height: 1.35;
}
.advanced-access :deep(.checkbox) {
line-height: 1.35;
}
</style>
@@ -0,0 +1,72 @@
<script setup>
const props = defineProps({
items: {
type: Array,
required: true,
},
});
</script>
<template>
<div class="superuser-overview-action-grid">
<slot name="before"></slot>
<template v-for="item in props.items" :key="item.key">
<router-link
v-if="item.to"
class="superuser-overview-action-grid__item"
:to="item.to"
:data-testid="item.testId"
>
<span class="icon" v-if="item.icon"><i :class="item.icon" /></span>
<span>{{ item.label }}</span>
</router-link>
<button
v-else
type="button"
class="superuser-overview-action-grid__item"
:data-testid="item.testId"
@click="item.onClick?.()"
>
<span class="icon" v-if="item.icon"><i :class="item.icon" /></span>
<span>{{ item.label }}</span>
</button>
</template>
<slot name="after"></slot>
</div>
</template>
<style scoped>
.superuser-overview-action-grid {
display: grid;
gap: 0.6rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.superuser-overview-action-grid__item {
align-items: center;
background: #ffffff;
border: 1px solid #dbe3ec;
border-radius: 8px;
color: #1e293b;
cursor: pointer;
display: inline-flex;
font-weight: 800;
gap: 0.5rem;
justify-content: flex-start;
min-height: 3rem;
padding: 0 0.85rem;
text-align: left;
}
.superuser-overview-action-grid__item:hover,
.superuser-overview-action-grid__item:focus {
border-color: #0f766e;
color: #0f766e;
}
@media (max-width: 640px) {
.superuser-overview-action-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,75 @@
<script setup>
const props = defineProps({
rows: {
type: Array,
required: true,
},
});
</script>
<template>
<dl class="superuser-overview-definition-list">
<div v-for="row in props.rows" :key="row.key || row.label" class="superuser-overview-definition-list__row">
<dt>{{ row.label }}</dt>
<dd>
<b-tooltip
v-if="row.tooltip"
:label="row.tooltip"
multilined
position="is-bottom"
type="is-dark"
>
<span>{{ row.value }}</span>
</b-tooltip>
<span v-else>{{ row.value }}</span>
</dd>
</div>
</dl>
</template>
<style scoped>
.superuser-overview-definition-list {
display: flex;
flex-direction: column;
gap: 0.55rem;
margin: 0;
}
.superuser-overview-definition-list__row {
align-items: center;
border-top: 1px solid #edf2f7;
display: flex;
gap: 1rem;
justify-content: space-between;
min-height: 3.25rem;
padding-top: 0.55rem;
}
.superuser-overview-definition-list__row dt {
color: #64748b;
font-size: 0.82rem;
margin: 0;
}
.superuser-overview-definition-list__row dd {
color: #0f172a;
font-weight: 800;
margin: 0;
max-width: 58%;
overflow-wrap: anywhere;
text-align: right;
}
@media (max-width: 640px) {
.superuser-overview-definition-list__row {
align-items: flex-start;
flex-direction: column;
gap: 0.35rem;
}
.superuser-overview-definition-list__row dd {
max-width: 100%;
text-align: left;
}
}
</style>
@@ -0,0 +1,202 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
actionIcon: {
type: String,
default: "fas fa-exchange-alt",
},
actionLabel: {
type: String,
default: "",
},
actionTestId: {
type: String,
default: "superuser-overview-metric-card-action",
},
actionable: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: "",
},
label: {
type: String,
required: true,
},
value: {
type: [String, Number],
required: true,
},
secondary: {
type: String,
default: "",
},
status: {
type: String,
default: "",
},
tone: {
type: String,
default: "",
},
valueTestId: {
type: String,
default: "",
},
});
const emit = defineEmits(["click"]);
const rootTag = computed(() => (props.actionable ? "button" : "article"));
const onClick = (event) => {
if (!props.actionable || props.disabled) {
return;
}
emit("click", event);
};
</script>
<template>
<component
:is="rootTag"
class="superuser-overview-metric-card"
:class="{ 'superuser-overview-metric-card--actionable': props.actionable }"
:type="props.actionable ? 'button' : undefined"
:disabled="props.actionable ? props.disabled : undefined"
@click="onClick"
>
<div class="superuser-overview-metric-card__icon" v-if="props.icon">
<i :class="props.icon" />
</div>
<div class="superuser-overview-metric-card__content">
<div class="superuser-overview-metric-card__top">
<span class="superuser-overview-metric-card__label">{{ props.label }}</span>
<span v-if="props.status" class="tag is-light" :class="props.tone">{{ props.status }}</span>
</div>
<strong class="superuser-overview-metric-card__value" :data-testid="props.valueTestId || undefined">
{{ props.value }}
</strong>
<span v-if="props.secondary" class="superuser-overview-metric-card__secondary">{{ props.secondary }}</span>
<span
v-if="props.actionLabel"
class="superuser-overview-metric-card__action"
:data-testid="props.actionTestId"
>
<span class="icon is-small">
<i :class="props.actionIcon" aria-hidden="true"></i>
</span>
<span>{{ props.actionLabel }}</span>
</span>
</div>
</component>
</template>
<style scoped>
.superuser-overview-metric-card {
align-items: center;
appearance: none;
background: #ffffff;
border: 1px solid #dbe3ec;
border-radius: 8px;
display: flex;
font: inherit;
gap: 0.75rem;
min-height: 6rem;
padding: 1rem;
text-align: left;
transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
width: 100%;
}
.superuser-overview-metric-card--actionable {
cursor: pointer;
}
.superuser-overview-metric-card--actionable:hover,
.superuser-overview-metric-card--actionable:focus-visible {
background: #f8fafc;
border-color: #94a3b8;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
.superuser-overview-metric-card--actionable:focus-visible {
outline: 2px solid #0f766e;
outline-offset: 2px;
}
.superuser-overview-metric-card--actionable:disabled {
cursor: default;
opacity: 0.7;
}
.superuser-overview-metric-card__icon {
align-items: center;
background: #ecfeff;
border-radius: 8px;
color: #0f766e;
display: inline-flex;
height: 2.5rem;
justify-content: center;
width: 2.5rem;
}
.superuser-overview-metric-card__content {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.superuser-overview-metric-card__top {
align-items: center;
display: flex;
gap: 0.5rem;
justify-content: space-between;
}
.superuser-overview-metric-card__label,
.superuser-overview-metric-card__secondary {
color: #64748b;
font-size: 0.82rem;
}
.superuser-overview-metric-card__value {
color: #0f172a;
font-size: 1.35rem;
line-height: 1.25;
overflow-wrap: anywhere;
}
.superuser-overview-metric-card__action {
align-items: center;
color: #0f766e;
display: inline-flex;
font-size: 0.82rem;
font-weight: 800;
gap: 0.25rem;
margin-top: 0.4rem;
opacity: 0;
transition: opacity 140ms ease;
}
.superuser-overview-metric-card--actionable:hover .superuser-overview-metric-card__action,
.superuser-overview-metric-card--actionable:focus-visible .superuser-overview-metric-card__action {
opacity: 1;
}
@media (hover: none) {
.superuser-overview-metric-card--actionable .superuser-overview-metric-card__action {
opacity: 1;
}
}
</style>
@@ -0,0 +1,79 @@
<script setup>
import { computed } from "vue";
import SuperuserOverviewMetricCard from "@/components/displays/superuser/overview/SuperuserOverviewMetricCard.vue";
const props = defineProps({
columns: {
type: Number,
default: 4,
},
metrics: {
type: Array,
required: true,
},
testId: {
type: String,
default: "",
},
});
const emit = defineEmits(["metric-click"]);
const gridStyle = computed(() => ({
"--superuser-overview-metric-grid-columns": Math.max(1, props.columns),
}));
const handleMetricClick = (metric, event) => {
emit("metric-click", metric, event);
};
</script>
<template>
<div
class="superuser-overview-metric-grid"
:data-testid="props.testId || undefined"
:style="gridStyle"
>
<SuperuserOverviewMetricCard
v-for="metric in props.metrics"
:key="metric.key"
:action-icon="metric.actionIcon"
:action-label="metric.actionLabel"
:action-test-id="metric.actionTestId"
:actionable="Boolean(metric.actionable)"
:disabled="Boolean(metric.disabled)"
:icon="metric.icon"
:label="metric.label"
:secondary="metric.secondary"
:status="metric.status"
:tone="metric.tone"
:value="metric.value"
:value-test-id="metric.valueTestId"
:data-testid="metric.testId"
@click="handleMetricClick(metric, $event)"
/>
</div>
</template>
<style scoped>
.superuser-overview-metric-grid {
align-items: stretch;
display: grid;
gap: 1rem;
grid-template-columns: repeat(var(--superuser-overview-metric-grid-columns), minmax(0, 1fr));
min-width: 0;
}
@media (max-width: 1024px) {
.superuser-overview-metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.superuser-overview-metric-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,87 @@
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
subtitle: {
type: String,
default: "",
},
count: {
type: [String, Number],
default: null,
},
});
</script>
<template>
<section class="superuser-overview-panel">
<header class="superuser-overview-panel__header">
<div class="superuser-overview-panel__copy">
<h2>{{ props.title }}</h2>
<p v-if="props.subtitle">{{ props.subtitle }}</p>
</div>
<div class="superuser-overview-panel__actions">
<span v-if="props.count !== null && props.count !== undefined" class="superuser-overview-panel__count">
{{ props.count }}
</span>
<slot name="actions"></slot>
</div>
</header>
<div class="superuser-overview-panel__content">
<slot />
</div>
</section>
</template>
<style scoped>
.superuser-overview-panel {
background: #ffffff;
border: 1px solid #dbe3ec;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.superuser-overview-panel__header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.superuser-overview-panel__copy h2 {
color: #0f172a;
font-size: 1.05rem;
font-weight: 800;
margin: 0;
}
.superuser-overview-panel__copy p {
color: #64748b;
font-size: 0.82rem;
margin: 0.2rem 0 0;
}
.superuser-overview-panel__actions {
align-items: center;
display: flex;
gap: 0.5rem;
}
.superuser-overview-panel__count {
align-items: center;
background: #f1f5f9;
border: 1px solid #dbe3ec;
border-radius: 999px;
color: #334155;
display: inline-flex;
font-weight: 800;
justify-content: center;
min-width: 2rem;
padding: 0.2rem 0.55rem;
}
</style>
@@ -0,0 +1,44 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
columns: {
type: Number,
default: 2,
},
testId: {
type: String,
default: "",
},
});
const gridStyle = computed(() => ({
"--superuser-overview-panel-grid-columns": Math.max(1, props.columns),
}));
</script>
<template>
<div
class="superuser-overview-panel-grid"
:data-testid="props.testId || undefined"
:style="gridStyle"
>
<slot />
</div>
</template>
<style scoped>
.superuser-overview-panel-grid {
align-items: start;
display: grid;
gap: 1rem;
grid-template-columns: repeat(var(--superuser-overview-panel-grid-columns), minmax(0, 1fr));
min-width: 0;
}
@media (max-width: 768px) {
.superuser-overview-panel-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,117 @@
<script setup>
const props = defineProps({
activeKey: {
type: [String, Number],
default: "",
},
ariaLabel: {
type: String,
default: "",
},
items: {
type: Array,
required: true,
},
testId: {
type: String,
default: "",
},
});
const emit = defineEmits(["change"]);
const itemKey = (item) => item.key ?? item.path ?? item.to ?? item.name ?? item.label;
const isActive = (item) => String(itemKey(item)) === String(props.activeKey);
const itemTestId = (item) => item.testId || undefined;
const changeItem = (item) => {
if (item.disabled) {
return;
}
emit("change", item);
};
</script>
<template>
<nav
class="superuser-overview-segmented-tabs"
:aria-label="props.ariaLabel || undefined"
:data-testid="props.testId || undefined"
>
<template v-for="item in props.items" :key="itemKey(item)">
<router-link
v-if="item.to"
class="superuser-overview-segmented-tabs__item"
:class="{ 'is-active': isActive(item), 'is-disabled': item.disabled }"
:to="item.to"
:aria-current="isActive(item) ? 'page' : undefined"
:data-testid="itemTestId(item)"
>
<span v-if="item.icon" class="icon is-small"><i :class="item.icon" /></span>
<span>{{ item.label }}</span>
</router-link>
<button
v-else
class="superuser-overview-segmented-tabs__item"
:class="{ 'is-active': isActive(item), 'is-disabled': item.disabled }"
type="button"
:disabled="item.disabled"
:aria-current="isActive(item) ? 'page' : undefined"
:data-testid="itemTestId(item)"
@click="changeItem(item)"
>
<span v-if="item.icon" class="icon is-small"><i :class="item.icon" /></span>
<span>{{ item.label }}</span>
</button>
</template>
</nav>
</template>
<style scoped>
.superuser-overview-segmented-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-width: 0;
}
.superuser-overview-segmented-tabs__item {
align-items: center;
background: #f8fafc;
border: 1px solid #dbe3ec;
border-radius: 8px;
color: #334155;
cursor: pointer;
display: inline-flex;
font: inherit;
font-size: 0.88rem;
font-weight: 800;
gap: 0.35rem;
min-height: 2.5rem;
padding: 0 0.75rem;
text-decoration: none;
}
.superuser-overview-segmented-tabs__item:hover,
.superuser-overview-segmented-tabs__item:focus-visible {
border-color: #94a3b8;
color: #0f172a;
}
.superuser-overview-segmented-tabs__item:focus-visible {
outline: 2px solid #0f766e;
outline-offset: 2px;
}
.superuser-overview-segmented-tabs__item.is-active {
background: #0f172a;
border-color: #0f172a;
color: #ffffff;
}
.superuser-overview-segmented-tabs__item.is-disabled {
cursor: default;
opacity: 0.6;
}
</style>
@@ -1,5 +1,5 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterLink } from "vue-router";
import { useI18n } from "vue-i18n";
import {
@@ -16,6 +16,7 @@ import {
const { t, te, locale } = useI18n();
const MAX_GATEWAY_CARDS = 8;
const DEFAULT_DASHBOARD_TAB = "infrastructure";
const gatewayStatusPriority = Object.freeze({
OFFLINE: 0,
DEGRADED: 1,
@@ -77,7 +78,14 @@ const gatewayFleetUsage = ref(createEmptyGatewayFleetUsage());
const gatewaySectionSuppressed = ref(false);
const gatewayDepartments = ref({});
const gatewayDepartmentsLoaded = ref(false);
const activeDashboardTab = ref(DEFAULT_DASHBOARD_TAB);
const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value);
const dashboardTabKeys = computed(() => [
DEFAULT_DASHBOARD_TAB,
...(showGatewaySection.value ? ["gateways"] : []),
"modules",
"sessions",
]);
const gatewaySummaryCards = computed(() => [
{
id: "total",
@@ -187,6 +195,16 @@ const isStale = computed(() => {
return nowTick.value - lastLoadedAt.value.getTime() > (refreshAfterSeconds.value * 2000);
});
watch(
() => [activeDashboardTab.value, dashboardTabKeys.value.join("|")],
() => {
if (!dashboardTabKeys.value.includes(activeDashboardTab.value)) {
activeDashboardTab.value = DEFAULT_DASHBOARD_TAB;
}
},
{ immediate: true }
);
const loadStatus = async ({ force = false } = {}) => {
const [snapshotValue] = await Promise.all([
getSuperuserSystemStatus({ force }),
@@ -707,203 +725,250 @@ function modulePath(key) {
</ul>
</div>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
<RouterLink class="section-link" to="/superuser/system/replication">
{{ $t("system_status.actions.open_replication") }}
</RouterLink>
</div>
<div class="status-card-grid">
<article
v-for="card in statusCards"
:key="card.id"
class="status-card"
:class="statusClass(card.status)"
:data-testid="`status-card-${card.id}`"
>
<div class="status-card__top">
<p class="status-card__title">{{ card.title }}</p>
<span class="status-pill" :class="statusClass(card.status)">{{ statusLabel(card.status) }}</span>
</div>
<strong class="status-card__primary">{{ card.primary }}</strong>
<p class="status-card__secondary">{{ card.secondary }}</p>
<small class="status-card__detail">{{ card.detail }}</small>
<small
v-if="card.replicationLabel"
class="status-card__detail status-card__replication"
:data-testid="`status-card-${card.id}-replication`"
>
{{ card.replicationLabel }}
</small>
</article>
</div>
</section>
<section
v-if="showGatewaySection"
class="system-status-section"
data-testid="system-status-gateways"
<b-tabs
v-model="activeDashboardTab"
expanded
type="is-boxed"
class="system-status-tabs"
data-testid="system-status-dashboard-tabs"
>
<div class="section-heading">
<div>
<h3>{{ $t("system_status.sections.gateways") }}</h3>
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
</div>
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
{{ $t("system_status.gateways.actions.open_fleet") }}
</RouterLink>
</div>
<b-tab-item value="infrastructure">
<template #header>
<b-icon pack="fas" icon="server" />
<span data-testid="system-status-tab-infrastructure">
{{ $t("system_status.sections.infrastructure") }}
</span>
</template>
<div class="gateway-summary-grid">
<article
v-for="card in gatewaySummaryCards"
:key="card.id"
class="summary-card gateway-summary-card"
:class="card.toneClass"
:data-testid="`gateway-summary-card-${card.id}`"
<section class="system-status-section" data-testid="system-status-panel-infrastructure">
<div class="section-heading">
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
<RouterLink class="section-link" to="/superuser/system/replication">
{{ $t("system_status.actions.open_replication") }}
</RouterLink>
</div>
<div class="status-card-grid">
<article
v-for="card in statusCards"
:key="card.id"
class="status-card"
:class="statusClass(card.status)"
:data-testid="`status-card-${card.id}`"
>
<div class="status-card__top">
<p class="status-card__title">{{ card.title }}</p>
<span class="status-pill" :class="statusClass(card.status)">{{ statusLabel(card.status) }}</span>
</div>
<strong class="status-card__primary">{{ card.primary }}</strong>
<p class="status-card__secondary">{{ card.secondary }}</p>
<small class="status-card__detail">{{ card.detail }}</small>
<small
v-if="card.replicationLabel"
class="status-card__detail status-card__replication"
:data-testid="`status-card-${card.id}-replication`"
>
{{ card.replicationLabel }}
</small>
</article>
</div>
</section>
</b-tab-item>
<b-tab-item v-if="showGatewaySection" value="gateways">
<template #header>
<b-icon pack="fas" icon="network-wired" />
<span data-testid="system-status-tab-gateways">
{{ $t("system_status.sections.gateways") }}
</span>
</template>
<section
class="system-status-section"
data-testid="system-status-gateways"
>
<p class="summary-label">{{ card.title }}</p>
<strong>{{ card.value }}</strong>
</article>
</div>
<div
v-if="gatewayError"
class="notification is-warning is-light gateway-notification"
data-testid="gateway-section-error"
>
{{ $t("system_status.gateways.error") }}
</div>
<div
v-if="gatewayLoading && !gatewayCards.length"
class="notification is-light gateway-notification"
>
{{ $t("system_status.gateways.loading") }}
</div>
<div v-if="gatewayCards.length" class="gateway-grid">
<article
v-for="gateway in gatewayCards"
:key="gateway.id"
class="gateway-card"
:class="gateway.toneClass"
:data-testid="`gateway-card-${gateway.id}`"
>
<div class="gateway-card__top">
<div class="section-heading">
<div>
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
<h3>{{ $t("system_status.sections.gateways") }}</h3>
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
</div>
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
{{ $t("system_status.gateways.actions.open_fleet") }}
</RouterLink>
</div>
<div class="gateway-card__meta">
<span>
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
</span>
<span>
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
</span>
<span v-if="gateway.activeOperationLabel">
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
</span>
<div class="gateway-summary-grid">
<article
v-for="card in gatewaySummaryCards"
:key="card.id"
class="summary-card gateway-summary-card"
:class="card.toneClass"
:data-testid="`gateway-summary-card-${card.id}`"
>
<p class="summary-label">{{ card.title }}</p>
<strong>{{ card.value }}</strong>
</article>
</div>
<p v-if="gateway.message" class="gateway-card__message">
{{ gateway.message }}
</p>
<RouterLink :to="gateway.link" class="module-card__link">
{{ $t("system_status.gateways.actions.open_gateway") }}
</RouterLink>
</article>
</div>
<div
v-else-if="!gatewayLoading && !gatewayError"
class="gateway-empty"
data-testid="gateway-empty-state"
>
{{ $t("system_status.gateways.empty") }}
</div>
</section>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.modules") }}</h3>
</div>
<div class="module-grid">
<article
v-for="module in modules"
:key="module.key"
class="module-card"
:class="statusClass(module.status)"
:data-testid="`module-card-${module.key}`"
>
<div class="module-card__top">
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
<div
v-if="gatewayError"
class="notification is-warning is-light gateway-notification"
data-testid="gateway-section-error"
>
{{ $t("system_status.gateways.error") }}
</div>
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
{{ moduleReasonText(module) }}
</p>
<div class="module-card__meta">
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
<div
v-if="gatewayLoading && !gatewayCards.length"
class="notification is-light gateway-notification"
>
{{ $t("system_status.gateways.loading") }}
</div>
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
{{ $t("system_status.actions.open_config") }}
</RouterLink>
</article>
</div>
</section>
<section class="system-status-section">
<div class="section-heading">
<h3>{{ $t("system_status.sections.sessions") }}</h3>
<p>{{ $t("system_status.labels.activity_window", { minutes: sessions.active_window_minutes }) }}</p>
</div>
<div v-if="gatewayCards.length" class="gateway-grid">
<article
v-for="gateway in gatewayCards"
:key="gateway.id"
class="gateway-card"
:class="gateway.toneClass"
:data-testid="`gateway-card-${gateway.id}`"
>
<div class="gateway-card__top">
<div>
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
</div>
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
</div>
<div class="table-container sessions-table">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>{{ $t("system_status.table.active") }}</th>
<th>{{ $t("system_status.table.name") }}</th>
<th>{{ $t("system_status.table.type") }}</th>
<th>{{ $t("system_status.table.device") }}</th>
<th>{{ $t("system_status.table.route") }}</th>
<th>{{ $t("system_status.table.first_seen") }}</th>
<th>{{ $t("system_status.table.last_seen") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
<td>
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
<div class="gateway-card__meta">
<span>
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
</span>
</td>
<td>
<strong>{{ sessionDisplayName(session) }}</strong>
<div class="session-context">{{ sessionContextLabel(session) }}</div>
</td>
<td>{{ formatSessionKind(session.session_kind) }}</td>
<td>{{ formatDeviceType(session.device_type) }}</td>
<td class="session-route">{{ session.last_route || "--" }}</td>
<td>{{ formatDate(session.first_seen_at) }}</td>
<td>{{ formatDate(session.last_seen_at) }}</td>
</tr>
<tr v-if="!sessions.recent_sessions?.length">
<td colspan="7">{{ $t("system_status.states.no_sessions") }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<span>
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
</span>
<span v-if="gateway.activeOperationLabel">
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
</span>
</div>
<p v-if="gateway.message" class="gateway-card__message">
{{ gateway.message }}
</p>
<RouterLink :to="gateway.link" class="module-card__link">
{{ $t("system_status.gateways.actions.open_gateway") }}
</RouterLink>
</article>
</div>
<div
v-else-if="!gatewayLoading && !gatewayError"
class="gateway-empty"
data-testid="gateway-empty-state"
>
{{ $t("system_status.gateways.empty") }}
</div>
</section>
</b-tab-item>
<b-tab-item value="modules">
<template #header>
<b-icon pack="fas" icon="puzzle-piece" />
<span data-testid="system-status-tab-modules">
{{ $t("system_status.sections.modules") }}
</span>
</template>
<section class="system-status-section" data-testid="system-status-panel-modules">
<div class="section-heading">
<h3>{{ $t("system_status.sections.modules") }}</h3>
</div>
<div class="module-grid">
<article
v-for="module in modules"
:key="module.key"
class="module-card"
:class="statusClass(module.status)"
:data-testid="`module-card-${module.key}`"
>
<div class="module-card__top">
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
</div>
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
{{ moduleReasonText(module) }}
</p>
<div class="module-card__meta">
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
</div>
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
{{ $t("system_status.actions.open_config") }}
</RouterLink>
</article>
</div>
</section>
</b-tab-item>
<b-tab-item value="sessions">
<template #header>
<b-icon pack="fas" icon="users" />
<span data-testid="system-status-tab-sessions">
{{ $t("system_status.sections.sessions") }}
</span>
</template>
<section class="system-status-section" data-testid="system-status-panel-sessions">
<div class="section-heading">
<h3>{{ $t("system_status.sections.sessions") }}</h3>
<p>{{ $t("system_status.labels.activity_window", { minutes: sessions.active_window_minutes }) }}</p>
</div>
<div class="table-container sessions-table">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>{{ $t("system_status.table.active") }}</th>
<th>{{ $t("system_status.table.name") }}</th>
<th>{{ $t("system_status.table.type") }}</th>
<th>{{ $t("system_status.table.device") }}</th>
<th>{{ $t("system_status.table.route") }}</th>
<th>{{ $t("system_status.table.first_seen") }}</th>
<th>{{ $t("system_status.table.last_seen") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
<td>
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
</span>
</td>
<td>
<strong>{{ sessionDisplayName(session) }}</strong>
<div class="session-context">{{ sessionContextLabel(session) }}</div>
</td>
<td>{{ formatSessionKind(session.session_kind) }}</td>
<td>{{ formatDeviceType(session.device_type) }}</td>
<td class="session-route">{{ session.last_route || "--" }}</td>
<td>{{ formatDate(session.first_seen_at) }}</td>
<td>{{ formatDate(session.last_seen_at) }}</td>
</tr>
<tr v-if="!sessions.recent_sessions?.length">
<td colspan="7">{{ $t("system_status.states.no_sessions") }}</td>
</tr>
</tbody>
</table>
</div>
</section>
</b-tab-item>
</b-tabs>
</div>
</template>
@@ -924,10 +989,10 @@ function modulePath(key) {
.module-card,
.gateway-card {
border: 1px solid #d7dde7;
border-radius: 18px;
border-radius: 8px;
padding: 1rem 1.1rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.06);
background: #ffffff;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
}
.summary-card--overall {
@@ -991,6 +1056,23 @@ function modulePath(key) {
color: #0f4c81;
}
.system-status-tabs {
min-width: 0;
}
.system-status-tabs :deep(.tab-content) {
padding: 1rem 0 0;
}
.system-status-tabs :deep(.tabs ul) {
align-items: stretch;
}
.system-status-tabs :deep(.tabs a) {
min-height: 2.75rem;
gap: 0.35rem;
}
.status-card__top,
.module-card__top {
display: flex;
@@ -1035,6 +1117,7 @@ function modulePath(key) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
justify-content: stretch;
column-gap: 0.75rem;
row-gap: 0.35rem;
}
@@ -1049,6 +1132,7 @@ function modulePath(key) {
.module-card__title {
margin: 0;
flex: 1 1 12rem;
min-width: 0;
text-transform: none;
letter-spacing: 0.04em;
@@ -1058,7 +1142,7 @@ function modulePath(key) {
.module-card__top .status-pill {
justify-self: end;
align-self: start;
margin-left: 0;
}
.gateway-card__title {
@@ -1107,7 +1191,7 @@ function modulePath(key) {
.gateway-empty {
border: 1px dashed #cbd5e1;
border-radius: 18px;
border-radius: 8px;
padding: 1rem 1.1rem;
color: #475569;
background: rgba(248, 250, 252, 0.8);
@@ -7,6 +7,7 @@ const { t } = useI18n();
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 SuperuserInvoiceRowActions from "@/components/displays/superuser/tables/SuperuserInvoiceRowActions.vue";
const props = defineProps({
objects: {
@@ -65,8 +66,8 @@ const parseClosedAt = (value) => {
<td>{{ parseClosedAt(object.closed_at) }}</td>
<td>{{ parseDate(object.created_at) }}</td>
<td>{{ parseDate(object.updated_at) }}</td>
<td>
<div class="buttons is-float-right">
<td class="is-narrow">
<SuperuserInvoiceRowActions :include-buffer="true">
<ActionSettingsWheelButton>
<template #actions>
<ActionSettingsWheelItem
@@ -76,7 +77,7 @@ const parseClosedAt = (value) => {
/>
</template>
</ActionSettingsWheelButton>
</div>
</SuperuserInvoiceRowActions>
</td>
</tr>
<tr v-if="objects.length === 0">
@@ -12,6 +12,11 @@ import UserOtherVaskeabonnement
import Swal from "sweetalert2";
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
import {
buildMultiMonthInvoiceContext,
MULTI_MONTH_INVOICE_ACTION,
promptMultiMonthInvoiceWarning,
} from "@/services/invoiceMonthSplitWarning.js";
const props = defineProps({
orders: {
@@ -126,32 +131,34 @@ const isAnyOrderSelected = () => {
return selectedInvoiceCollections.value.length > 0;
}
const getSelectedOrders = () => {
return props.orders.filter((order) => selectedInvoiceCollections.value.includes(order.invoice_collection_id));
}
/** Invoice collections */
const onInvoiceCollections = async () => {
// Check if any orders are selected
if (selectedInvoiceCollections.value.length === 0) {
return;
}
/** Create the invoice */
const onCreateInvoiceDraft = async () => {
// Create the invoice
console.log('Create invoice');
await SessionUser.objects.collectedOrderInvoices.functions.economic.invoice(parseInt(props.collectedOrderInvoice.id)).then((response) => {
console.log('Invoice created successfully', response);
Swal.fire({
title: 'Fakturaen er oprettet',
text: 'Fakturaen er oprettet i E-conomic',
icon: 'success',
showConfirmButton: false,
timer: 2000
}).then(() => {
location.reload();
});
}).catch((error) => {
console.log('Error creating invoice', error);
}
)
const selectedOrders = getSelectedOrders();
const invoiceWarningContext = buildMultiMonthInvoiceContext(selectedOrders, {
getDate: (order) => order?.created_at ?? order?.date,
getInvoiceCollectionId: (order) => order?.invoice_collection_id,
});
const invoiceWarningAction = await promptMultiMonthInvoiceWarning({
context: invoiceWarningContext,
splitByMonth: SessionUser.objects.collectedOrderInvoices.functions.split_by_month,
parseErrorMessage: SessionUser.functions.parseErrorMessage,
});
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.SPLIT) {
location.reload();
return;
}
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.CANCEL) {
return;
}
for (let i = 0; i < selectedInvoiceCollections.value.length; i++) {
const selectedInvoiceCollectionId = selectedInvoiceCollections.value[i];
// Check if the invoice collection is already booked
@@ -210,6 +217,7 @@ const isOrderContentVisible = (order) => {
class="button is-small"
@click="onInvoiceCollections()"
:disabled="!isAnyOrderSelected()"
data-testid="invoice-order-table-invoice-button"
>
{{ $t('global.invoice_now') }}
</button>
@@ -274,4 +282,4 @@ const isOrderContentVisible = (order) => {
<style scoped>
</style>
</style>
@@ -1,7 +1,9 @@
<script setup>
import { ref } from "vue";
import Swal from "sweetalert2";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
@@ -14,9 +16,14 @@ const props = defineProps({
type: Boolean,
default: false,
},
userScopedUserId: {
type: [String, Number],
default: null,
},
});
const { loadList } = usePaginatedListInstance();
const expandedSubusers = ref(new Set());
const canEditPermissions = () =>
props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canManageSubusers("edit");
@@ -25,8 +32,70 @@ const canDisableAccess = () =>
const canResendInvite = (subuser) =>
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canManageSubusers("edit"))
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
const hasRowActions = (subuser) =>
const hasActionsForContext = (subuser) =>
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
const hasRowActions = (subuser) => rowGrants(subuser).length <= 1 && hasActionsForContext(subuser);
const hasGrantActions = (subuser, grant) => hasActionsForContext(grantContext(subuser, grant));
const isUserScoped = () => Boolean(props.userScopedUserId);
const rowGrants = (subuser) => {
if (Array.isArray(subuser?.grants) && subuser.grants.length > 0) {
return subuser.grants;
}
return subuser?.grant_id ? [subuser] : [];
};
const primaryGrant = (subuser) => rowGrants(subuser)[0] || subuser;
const grantContext = (subuser, grant) => ({
...subuser,
...grant,
grants: subuser?.grants || [],
grant_count: subuser?.grant_count || rowGrants(subuser).length,
customer_numbers: subuser?.customer_numbers || [],
});
const isExpanded = (subuser) => expandedSubusers.value.has(subuser?.id);
const toggleExpanded = (subuser) => {
const next = new Set(expandedSubusers.value);
if (next.has(subuser.id)) {
next.delete(subuser.id);
} else {
next.add(subuser.id);
}
expandedSubusers.value = next;
};
const hasMultipleGrants = (subuser) => rowGrants(subuser).length > 1;
const scopedGrantEndpoint = (grantId) =>
`/superuser/users/${encodeURIComponent(props.userScopedUserId)}/subusers/grants/${encodeURIComponent(grantId)}`;
const patchScopedGrant = async (subuser, payload) => {
if (!isUserScoped() || !subuser?.grant_id) {
return null;
}
return authenticatedRequest(scopedGrantEndpoint(subuser.grant_id), "PATCH", payload);
};
const patchGrant = async (subuser, payload) => {
if (!subuser?.grant_id) {
return null;
}
if (isUserScoped()) {
return patchScopedGrant(subuser, payload);
}
return authenticatedRequest("/subusers/grants", "PUT", {
id: subuser.grant_id,
...payload,
});
};
const formatDateTime = (dateString) => {
if (!dateString) {
@@ -71,7 +140,11 @@ const formatCustomer = (subuser) => {
};
const permissionSummary = (subuser) =>
SessionUser.objects.subusers.functions.permissionSummary(subuser?.grant_permissions || []);
SessionUser.objects.subusers.functions.permissionSummary(
primaryGrant(subuser)?.grant_permissions || [],
primaryGrant(subuser)?.permission_template_key,
primaryGrant(subuser)?.permission_groups || []
);
const statusLabel = (subuser) => {
switch (subuser?.access_state) {
@@ -114,7 +187,20 @@ const onEditPermissions = async (subuser) => {
permissions: subuser.grant_permissions || [],
name: subuser.name || formatPhone(subuser) || `Chauffør ${subuser.id}`,
},
refreshList
refreshList,
isUserScoped()
? {
allowAdvanced: true,
saveAccess: async (payload) => {
await patchGrant(subuser, payload);
},
}
: {
allowAdvanced: props.showCustomer,
saveAccess: async (payload) => {
await patchGrant(subuser, payload);
},
}
);
};
@@ -134,9 +220,13 @@ const onEditNote = async (subuser) => {
cancelButtonText: "Annuller",
preConfirm: async (value) => {
try {
await SessionUser.objects.subuser_grants.set.note(subuser.grant_id, value || "");
if (isUserScoped()) {
await patchScopedGrant(subuser, { note: value || "" });
} else {
await SessionUser.objects.subuser_grants.set.note(subuser.grant_id, value || "");
}
return value || "";
} catch (error) {
} catch (_error) {
Swal.showValidationMessage("Kunne ikke gemme noten.");
return false;
}
@@ -168,7 +258,11 @@ const onToggleEnabled = async (subuser, enabled) => {
}
try {
await SessionUser.objects.subuser_grants.set.enabled(subuser.grant_id, enabled);
if (isUserScoped()) {
await patchScopedGrant(subuser, { enabled });
} else {
await SessionUser.objects.subuser_grants.set.enabled(subuser.grant_id, enabled);
}
await refreshList();
} catch (error) {
await Swal.fire({
@@ -181,7 +275,10 @@ const onToggleEnabled = async (subuser, enabled) => {
};
const onResendInvite = async (subuser) => {
await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList, { superuser: props.showCustomer });
await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList, {
superuser: props.showCustomer || isUserScoped(),
userId: props.userScopedUserId,
});
};
</script>
@@ -207,90 +304,174 @@ const onResendInvite = async (subuser) => {
<td :colspan="showCustomer ? 10 : 9" class="has-text-centered has-text-grey py-6">Ingen chauffører fundet.</td>
</tr>
<tr v-for="subuser in props.objects" :key="`${subuser.id}-${subuser.grant_id || 'none'}`">
<td>{{ subuser.id }}</td>
<template v-for="subuser in props.objects" :key="subuser.id">
<tr>
<td>{{ subuser.id }}</td>
<td v-if="showCustomer">
<div class="has-text-weight-semibold">{{ formatCustomer(subuser) }}</div>
<div class="is-size-7 has-text-grey">Grant #{{ subuser.grant_id }}</div>
</td>
<td v-if="showCustomer">
<div v-if="hasMultipleGrants(subuser)">
<button
type="button"
class="button is-small is-light"
:data-testid="`subuser-expand-${subuser.id}`"
@click="toggleExpanded(subuser)"
>
<span class="icon is-small">
<i :class="isExpanded(subuser) ? 'fas fa-chevron-down' : 'fas fa-chevron-right'"></i>
</span>
<span>{{ rowGrants(subuser).length }} kunder</span>
</button>
<div class="is-size-7 has-text-grey mt-1">
Primær: {{ formatCustomer(primaryGrant(subuser)) }}
</div>
</div>
<div v-else>
<div class="has-text-weight-semibold">{{ formatCustomer(primaryGrant(subuser)) }}</div>
<div class="is-size-7 has-text-grey">Grant #{{ primaryGrant(subuser).grant_id }}</div>
</div>
</td>
<td>
<div class="has-text-weight-semibold">{{ subuser.name || "-" }}</div>
<div class="is-size-7 has-text-grey" :data-testid="`subuser-username-${subuser.id}`">
{{ formatUsername(subuser) }}
</div>
</td>
<td>
<div class="has-text-weight-semibold">{{ subuser.name || "-" }}</div>
<div class="is-size-7 has-text-grey" :data-testid="`subuser-username-${subuser.id}`">
{{ formatUsername(subuser) }}
</div>
</td>
<td>
<div :data-testid="`subuser-email-${subuser.id}`">{{ formatEmail(subuser) }}</div>
<div class="is-size-7 has-text-grey" :data-testid="`subuser-phone-${subuser.id}`">
{{ formatPhone(subuser) }}
</div>
</td>
<td>
<div :data-testid="`subuser-email-${subuser.id}`">{{ formatEmail(subuser) }}</div>
<div class="is-size-7 has-text-grey" :data-testid="`subuser-phone-${subuser.id}`">
{{ formatPhone(subuser) }}
</div>
</td>
<td>
<span class="tag" :class="statusClass(subuser)" :data-testid="`subuser-status-${subuser.id}`">
{{ statusLabel(subuser) }}
</span>
<div class="is-size-7 has-text-grey mt-2">
<span v-if="subuser.grant_enabled">Grant aktiv</span>
<span v-else>Grant inaktiv</span>
</div>
</td>
<td>
<span class="tag" :class="statusClass(subuser)" :data-testid="`subuser-status-${subuser.id}`">
{{ statusLabel(subuser) }}
</span>
<div v-if="!hasMultipleGrants(subuser)" class="is-size-7 has-text-grey mt-2">
<span v-if="primaryGrant(subuser).grant_enabled">Grant aktiv</span>
<span v-else>Grant inaktiv</span>
</div>
<div v-else class="is-size-7 has-text-grey mt-2">
{{ rowGrants(subuser).filter((grant) => grant.grant_enabled).length }} aktive grants
</div>
</td>
<td>
<div class="permission-summary">{{ permissionSummary(subuser) }}</div>
</td>
<td>
<div class="permission-summary">{{ permissionSummary(subuser) }}</div>
</td>
<td>
<div>{{ subuser.grant_note || "-" }}</div>
</td>
<td>
<div>{{ primaryGrant(subuser).grant_note || "-" }}</div>
</td>
<td>{{ formatDateTime(subuser.created_at) }}</td>
<td>{{ formatDateTime(subuser.updated_at) }}</td>
<td>{{ formatDateTime(subuser.created_at) }}</td>
<td>{{ formatDateTime(subuser.updated_at) }}</td>
<td>
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
<template #actions>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-pen"
label="Redigér note"
:click-action="() => onEditNote(subuser)"
:test-id="`subuser-note-${subuser.id}`"
/>
<td>
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`">
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
<template #actions>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-pen"
label="Redigér note"
:click-action="() => onEditNote(subuser)"
:test-id="`subuser-note-${subuser.id}`"
/>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-user-shield"
label="Tilladelser"
:click-action="() => onEditPermissions(subuser)"
:test-id="`subuser-permissions-${subuser.id}`"
/>
<ActionSettingsWheelItem
v-if="canEditPermissions() && subuser.grant_id"
icon="fas fa-user-shield"
label="Tilladelser"
:click-action="() => onEditPermissions(subuser)"
:test-id="`subuser-permissions-${subuser.id}`"
/>
<ActionSettingsWheelItem
v-if="canResendInvite(subuser)"
icon="fas fa-paper-plane"
label="Gensend"
:click-action="() => onResendInvite(subuser)"
:test-id="`subuser-resend-${subuser.id}`"
/>
<ActionSettingsWheelItem
v-if="canResendInvite(subuser)"
icon="fas fa-paper-plane"
label="Gensend"
:click-action="() => onResendInvite(subuser)"
:test-id="`subuser-resend-${subuser.id}`"
/>
<ActionSettingsWheelItem
v-if="canDisableAccess() && subuser.grant_id"
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
:template="subuser.grant_enabled ? 'danger' : 'success'"
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
:test-id="`subuser-toggle-${subuser.id}`"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
<ActionSettingsWheelItem
v-if="canDisableAccess() && subuser.grant_id"
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
:template="subuser.grant_enabled ? 'danger' : 'success'"
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
:test-id="`subuser-toggle-${subuser.id}`"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
<tr v-if="showCustomer && hasMultipleGrants(subuser) && isExpanded(subuser)" class="subuser-grants-row">
<td :colspan="showCustomer ? 10 : 9">
<div class="subuser-grants" :data-testid="`subuser-grants-${subuser.id}`">
<div
v-for="grant in rowGrants(subuser)"
:key="grant.grant_id"
class="subuser-grant"
:data-testid="`subuser-grant-${subuser.id}-${grant.grant_id}`"
>
<div>
<div class="has-text-weight-semibold">{{ formatCustomer(grant) }}</div>
<div class="is-size-7 has-text-grey">Grant #{{ grant.grant_id }}</div>
</div>
<div>
<span class="tag" :class="statusClass(grant)">{{ statusLabel(grant) }}</span>
</div>
<div class="permission-summary">{{ permissionSummary(grant) }}</div>
<div>{{ grant.grant_note || "-" }}</div>
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-grant-actions-${subuser.id}-${grant.grant_id}`">
<ActionSettingsWheelButton v-if="hasGrantActions(subuser, grant)">
<template #actions>
<ActionSettingsWheelItem
v-if="canEditPermissions() && grant.grant_id"
icon="fas fa-pen"
label="Redigér note"
:click-action="() => onEditNote(grantContext(subuser, grant))"
:test-id="`subuser-note-${subuser.id}-${grant.grant_id}`"
/>
<ActionSettingsWheelItem
v-if="canEditPermissions() && grant.grant_id"
icon="fas fa-user-shield"
label="Tilladelser"
:click-action="() => onEditPermissions(grantContext(subuser, grant))"
:test-id="`subuser-permissions-${subuser.id}-${grant.grant_id}`"
/>
<ActionSettingsWheelItem
v-if="canResendInvite(grantContext(subuser, grant))"
icon="fas fa-paper-plane"
label="Gensend"
:click-action="() => onResendInvite(grantContext(subuser, grant))"
:test-id="`subuser-resend-${subuser.id}-${grant.grant_id}`"
/>
<ActionSettingsWheelItem
v-if="canDisableAccess() && grant.grant_id"
:icon="grant.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
:label="grant.grant_enabled ? 'Deaktivér' : 'Aktivér'"
:template="grant.grant_enabled ? 'danger' : 'success'"
:click-action="() => onToggleEnabled(grantContext(subuser, grant), !grant.grant_enabled)"
:test-id="`subuser-toggle-${subuser.id}-${grant.grant_id}`"
/>
</template>
</ActionSettingsWheelButton>
</div>
</div>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
@@ -306,4 +487,31 @@ const onResendInvite = async (subuser) => {
gap: 0.5rem;
flex-wrap: wrap;
}
.subuser-grants-row > td {
background: #f8fafc;
}
.subuser-grants {
display: grid;
gap: 0.75rem;
}
.subuser-grant {
align-items: center;
background: #fff;
border: 1px solid #dbdbdb;
border-radius: 6px;
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(180px, 1.4fr) minmax(120px, 0.7fr) minmax(180px, 1fr) minmax(140px, 1fr) auto;
padding: 0.75rem;
}
@media screen and (max-width: 1023px) {
.subuser-grant {
align-items: flex-start;
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,49 @@
<script setup>
defineProps({
includeBuffer: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div class="buttons superuser-invoice-row-actions">
<button
v-if="includeBuffer"
type="button"
class="button is-small superuser-invoice-row-actions__buffer"
aria-hidden="true"
tabindex="-1"
disabled
data-testid="superuser-invoice-action-buffer"
>
<span class="icon">
<i class="fas fa-paperclip"></i>
</span>
</button>
<slot />
</div>
</template>
<style scoped>
.superuser-invoice-row-actions {
flex-wrap: nowrap;
justify-content: flex-end;
gap: 0.5rem;
margin-bottom: 0;
}
.superuser-invoice-row-actions > :deep(*) {
flex-shrink: 0;
}
.superuser-invoice-row-actions__buffer {
width: 2rem;
min-width: 2rem;
height: 2rem;
padding: 0;
visibility: hidden;
pointer-events: none;
}
</style>
@@ -1,11 +1,10 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useI18n } from "vue-i18n";
import { BCheckbox } from "buefy";
const { t } = useI18n();
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import {getCustomerName} from "@/components/shop/POSDepartmentProcess.vue";
import { ref } from 'vue';
const props = defineProps({
objects: {
type: Array,
@@ -29,134 +28,141 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:selected']);
const emit = defineEmits(["update:selected"]);
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 SuperuserInvoiceRowActions from "@/components/displays/superuser/tables/SuperuserInvoiceRowActions.vue";
const redirect = (path) => {
// Open the url in a new tab
window.open(path, '_blank');
}
window.open(path, "_blank");
};
const parseTransactionCount = (value) => {
if (typeof value === 'number') {
return value === 0 ? t('global.no_data') : value;
} else if (typeof value === 'string') {
if (typeof value === "number") {
return value === 0 ? t("global.no_data") : value;
} else if (typeof value === "string") {
return parseInt(value);
} else if (typeof value === 'object') {
} else if (typeof value === "object") {
return value.length;
} else if (value === null) {
// Handle other types if necessary
return t('global.no_data');
return t("global.no_data");
}
}
};
const onSelect = (id) => {
emit('update:selected', id);
emit("update:selected", id);
props.onSelected(id);
}
};
const onSelectorChecked = (id, checked) => {
if (checked) {
onSelect(id);
}
};
</script>
<template>
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th>{{ SessionUser.objects.collectedOrderInvoices.columns.id.label }}</th>
<th>{{ SessionUser.objects.global.language.customer_name }}</th>
<th class="is-narrow">{{ SessionUser.objects.collectedOrderInvoices.columns.customer_number.label }}</th>
<th class="is-narrow">{{ SessionUser.objects.collectedOrderInvoices.columns.processor.label }}</th>
<th class="is-narrow">{{ SessionUser.objects.orders.meta.title }}</th>
<th>{{ $t('common.status') }}</th>
<th>{{ $t("common.status") }}</th>
<th>{{ SessionUser.objects.collectedOrderInvoices.columns.created_at.label }}</th>
<th v-if="props.showTotal">{{ $t('tables.orders.total') }}</th>
<th v-if="props.showTotal">{{ $t("tables.orders.total") }}</th>
<th><!-- Actions / Selector --></th>
</tr>
</thead>
<tbody>
</thead>
<tbody>
<template v-for="object in props.objects" :key="object.id">
<tr v-if="object.objects > 0 || props.showEmpty">
<td>{{ object.id }}</td>
<!-- Customer name -->
<EditableTableColumn
<td>{{ object.id }}</td>
<!-- Customer name -->
<EditableTableColumn
:object="object"
:loadList="loadList"
column="customer_name"
:virtual-column="true"
:parse-function="(value) => value.customer_name"
/>
<!-- Customer number -->
<EditableTableColumn
:object="object"
:loadList="loadList"
column="customer_number"
/>
<!-- Processor -->
<EditableTableColumn
/>
<!-- Customer number -->
<EditableTableColumn :object="object" :loadList="loadList" column="customer_number" />
<!-- Processor -->
<EditableTableColumn
:object="object"
:loadList="loadList"
column="processor"
:parse-function="(value) => value === null ? t('global.no_data') : value === 1 ? 'E-conomic' : t('global.other')"
/>
<!-- Transactions -->
<EditableTableColumn
:parse-function="
(value) => (value === null ? t('global.no_data') : value === 1 ? 'E-conomic' : t('global.other'))
"
/>
<!-- Transactions -->
<EditableTableColumn
:object="object"
:loadList="loadList"
column="orders"
:parse-function="(value) => parseTransactionCount(value)"
/>
<!-- Closed at -->
<EditableTableColumn
/>
<!-- Closed at -->
<EditableTableColumn
:object="object"
:loadList="loadList"
column="closed_at"
:parse-function="(value) => value === null ? t('global.open') : t('global.closed') + ' ' + value"
/>
<!-- Created at -->
<EditableTableColumn
:object="object"
:loadList="loadList"
column="created_at"
/>
<!-- Total net amount -->
<td v-if="props.showTotal">{{ SessionUser.functions.currency.toLocal(object.total_net_amount) }}</td>
<!-- Actions -->
<td v-if="!props.isSelector">
<ActionSettingsWheelButton>
<template #actions>
<ActionSettingsWheelItem
icon="fas fa-eye"
@click="redirect('/superuser/invoices/' + object.id)"
:label="t('common.show')"
/>
</template>
</ActionSettingsWheelButton>
</td>
<!-- Selector -->
<td v-if="props.isSelector">
<button class="button is-small is-dark" @click="onSelect(object.id)">
{{ $t('common.select') }}
</button>
</td>
</tr>
:parse-function="(value) => (value === null ? t('global.open') : t('global.closed') + ' ' + value)"
/>
<!-- Created at -->
<EditableTableColumn :object="object" :loadList="loadList" column="created_at" />
<!-- Total net amount -->
<td v-if="props.showTotal">{{ SessionUser.functions.currency.toLocal(object.total_net_amount) }}</td>
<!-- Actions -->
<td v-if="!props.isSelector" class="is-narrow">
<SuperuserInvoiceRowActions :include-buffer="true">
<ActionSettingsWheelButton>
<template #actions>
<ActionSettingsWheelItem
icon="fas fa-eye"
@click="redirect('/superuser/invoices/' + object.id)"
:label="t('common.show')"
/>
</template>
</ActionSettingsWheelButton>
</SuperuserInvoiceRowActions>
</td>
<!-- Selector -->
<td v-if="props.isSelector" class="is-narrow">
<BCheckbox
:model-value="false"
size="is-small"
:aria-label="`${$t('common.select')} #${object.id}`"
:data-testid="`collected-invoice-selector-${object.id}`"
@update:model-value="(checked) => onSelectorChecked(object.id, checked)"
/>
</td>
</tr>
</template>
<!-- If the list is empty -->
<tr v-if="objects.length === 0">
<td colspan="9">
{{ $t('global.no_data') }}
{{ $t("global.no_data") }}
</td>
</tr>
<!-- Show a count of the total number of objects -->
<tr>
<td colspan="9">{{ $t('tables.showing') }} {{ objects.length - objects.filter(object => object.objects === 0).length }} / {{ objects.length }} {{ SessionUser.objects.collectedOrderInvoices.meta.labels.multiple }}</td>
<td colspan="9">
{{ $t("tables.showing") }} {{ objects.length - objects.filter((object) => object.objects === 0).length }} /
{{ objects.length }} {{ SessionUser.objects.collectedOrderInvoices.meta.labels.multiple }}
</td>
</tr>
</tbody>
</table>
</tbody>
</table>
</template>
<style scoped>
</style>
<style scoped></style>
@@ -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,51 +18,165 @@ defineProps({
},
});
const emit = defineEmits(["migrated"]);
const editUser = (user) => {
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id);
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id, {
limitedBackofficeManaged: Boolean(user.limited_backoffice_managed),
});
};
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>
<table class="table is-fullwidth" data-testid="superuser-users-table">
<thead>
<tr>
<th>{{ $t("objects.columns.id") }}</th>
<th>{{ $t("tables.users.name") }}</th>
<th>{{ $t("tables.users.role") }}</th>
<th class="has-text-right">{{ $t("tables.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in objects" :key="user.id" :data-testid="`superuser-users-row-${user.id}`">
<td>{{ user.id }}</td>
<td>{{ user.display_name }}</td>
<td>{{ user.group_id }}</td>
<td>
<div class="buttons is-float-right">
<!-- Settings wheel -->
<ActionSettingsWheelButton
:customer_number="user.customer_number"
:user_id="user.id"
:data-testid="`superuser-user-actions-${user.id}`"
>
<template #actions>
<ActionSettingsWheelItem
:click-action="() => editUser(user)"
icon="fas fa-user-edit"
:label="$t('global.edit')"
:test-id="`superuser-user-edit-${user.id}`"
/>
</template>
</ActionSettingsWheelButton>
</div>
</td>
</tr>
<tr v-if="objects.length === 0">
<td colspan="4">{{ $t("global.no_data") }}</td>
</tr>
</tbody>
</table>
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable" data-testid="superuser-users-table">
<thead>
<tr>
<th>{{ $t("objects.columns.id") }}</th>
<th>{{ $t("tables.users.name") }}</th>
<th>{{ $t("tables.users.role") }}</th>
<th class="has-text-right">{{ $t("tables.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="user in objects" :key="user.id" :data-testid="`superuser-users-row-${user.id}`">
<td>{{ user.id }}</td>
<td>{{ user.display_name }}</td>
<td>{{ user.group_id }}</td>
<td class="has-text-right users-table__actions-cell">
<div class="buttons is-justify-content-flex-end">
<!-- Settings wheel -->
<ActionSettingsWheelButton
:customer_number="user.customer_number"
:user_id="user.id"
:data-testid="`superuser-user-actions-${user.id}`"
>
<template #actions>
<ActionSettingsWheelItem
:click-action="() => editUser(user)"
icon="fas fa-user-edit"
: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>
</td>
</tr>
<tr v-if="objects.length === 0">
<td colspan="4">{{ $t("global.no_data") }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped></style>
<style scoped>
.users-table__actions-cell {
white-space: nowrap;
}
</style>
@@ -13,6 +13,7 @@ import { showDownloadWashCertificate } from "@/components/shop/DownloadWashCerti
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import Swal from 'sweetalert2';
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import { todayLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
const redirectBookingObjectPage = (objectId) => {
// Send the user to the object page
window.location.href = `/user/bookings/${objectId}`;
@@ -237,7 +238,8 @@ window.addEventListener('resize', () => {
* To organize the bookings, we want to show the bookings that are created today first, then yesterday, then all other days
* @type {string}
*/
const currentDate = new Date().toISOString().split("T")[0];
const currentDate = todayLocalDateOnly();
const previousDate = yesterdayLocalDateOnly();
/**
* Sort the bookings by date
@@ -362,7 +364,7 @@ const canUserEditObject = (object) => {
</span>
<span class="has-text-grey">
<!-- Human readable date (Today, Yesterday, etc.) -->
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split("T")[0] ? $t('tables.bookings.yesterday') : object.date }}
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === previousDate ? $t('tables.bookings.yesterday') : object.date }}
<!-- Number of bookings on the date -->
({{ $t('tables.bookings.bookings_count', { count: objects.filter((booking) => booking.date === object.date).length }) }})
</span>
@@ -1,42 +1,145 @@
<script setup>
import { addVehicleUser } from "@/components/session/user/UserVehicleRequest.vue";
import { clearErrors } from "@/components/request/HandleGlobalError.vue";
import { ref } from "vue";
import { computed, onMounted, ref } from "vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { useI18n } from 'vue-i18n';
import { useRouter } from "vue-router";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const { t } = useI18n();
const router = useRouter();
const reg = ref("");
const type = ref("");
const notes = ref("");
const reference = ref("");
const vehicleTypeOptions = ref([]);
const isLoadingVehicleTypes = ref(false);
const isSubmitting = ref(false);
const vehicleTypeError = ref("");
const normalizedReg = computed(() => reg.value.trim().toUpperCase());
const normalizedType = computed(() => {
const parsedValue = Number.parseInt(String(type.value), 10);
return Number.isInteger(parsedValue) && parsedValue >= 0 ? parsedValue : null;
});
const canSubmit = computed(() => {
return (
!isSubmitting.value &&
!isLoadingVehicleTypes.value &&
normalizedReg.value.length > 0 &&
normalizedType.value !== null
);
});
const parseErrorMessage = (error, fallback) => {
return SessionUser.functions.parseErrorMessage(error) || fallback;
};
const loadVehicleTypes = async () => {
isLoadingVehicleTypes.value = true;
vehicleTypeError.value = "";
try {
vehicleTypeOptions.value = await SessionUser.objects.vehicles.columns.type.options();
} catch (error) {
vehicleTypeError.value = parseErrorMessage(error, t("vehicles.add_modal.type_load_error"));
} finally {
isLoadingVehicleTypes.value = false;
}
};
const submitVehicle = async () => {
if (!canSubmit.value) {
return;
}
clearErrors();
isSubmitting.value = true;
try {
await addVehicleUser(normalizedReg.value, normalizedType.value, reference.value.trim() || null);
await router.push("/user/vehicles");
} catch {
// addVehicleUser records the displayable error in the shared error store.
} finally {
isSubmitting.value = false;
}
};
onMounted(loadVehicleTypes);
</script>
<template>
<form @submit.prevent="clearErrors(); addVehicleUser(reg, type, notes)">
<form class="user-add-vehicle-form" @submit.prevent="submitVehicle">
<div class="field">
<label class="label">{{ t('user_vehicles.license_plate_label') }}</label>
<label class="label" for="user-add-vehicle-registration">{{ t('user_vehicles.license_plate_label') }}</label>
<div class="control">
<input class="input" type="text" placeholder="Reg" v-model="reg">
<input
id="user-add-vehicle-registration"
v-model="reg"
class="input"
type="text"
autocomplete="off"
:placeholder="t('vehicles.add_modal.registration_placeholder')"
:disabled="isSubmitting"
data-testid="user-add-vehicle-registration"
>
</div>
</div>
<div class="field">
<label class="label">{{ t('user_vehicles.type_label') }}</label>
<label class="label" for="user-add-vehicle-type">{{ t('user_vehicles.type_label') }}</label>
<div class="control" :class="{ 'is-loading': isLoadingVehicleTypes }">
<div class="select is-fullwidth">
<select
id="user-add-vehicle-type"
v-model="type"
:disabled="isSubmitting || isLoadingVehicleTypes || vehicleTypeOptions.length === 0"
data-testid="user-add-vehicle-type"
>
<option disabled value="">{{ t('vehicles.add_modal.type_placeholder') }}</option>
<option v-for="option in vehicleTypeOptions" :key="option.id" :value="option.id">
{{ option.name }}
</option>
</select>
</div>
</div>
<p v-if="vehicleTypeError" class="help is-danger" data-testid="user-add-vehicle-type-error">
{{ vehicleTypeError }}
</p>
</div>
<div class="field">
<label class="label" for="user-add-vehicle-reference">{{ t('common.reference') }}</label>
<div class="control">
<input class="input" type="text" placeholder="Type" v-model="type">
</div>
</div>
<div class="field">
<label class="label">{{ t('user_vehicles.notes_label') }}</label>
<div class="control">
<input class="input" type="text" placeholder="Notes" v-model="notes">
<input
id="user-add-vehicle-reference"
v-model="reference"
class="input"
type="text"
autocomplete="off"
:placeholder="t('vehicles.add_modal.reference_placeholder')"
:disabled="isSubmitting"
data-testid="user-add-vehicle-reference"
>
</div>
</div>
<ShowErrorField error="addVehicleUser" />
<div class="field">
<div class="control">
<button class="button is-dark">{{ t('user_vehicles.add_vehicle') }}</button>
<button
class="button is-dark"
type="submit"
:class="{ 'is-loading': isSubmitting }"
:disabled="!canSubmit"
data-testid="user-add-vehicle-submit"
>
{{ t('user_vehicles.add_vehicle') }}
</button>
</div>
</div>
</form>
@@ -44,4 +147,4 @@ const notes = ref("");
<style scoped>
</style>
</style>
@@ -17,7 +17,7 @@ const props = defineProps({
show_add_vehicle: {
type: Number,
required: false,
default: true,
default: null,
},
compact: {
type: Boolean,
@@ -34,6 +34,16 @@ const props = defineProps({
required: false,
default: null,
},
userScopedUserId: {
type: [Number, String],
required: false,
default: null,
},
superuserPage: {
type: Boolean,
required: false,
default: false,
},
});
const reload = () => {
@@ -45,9 +55,19 @@ const reload = () => {
}
};
const redirectUserVehiclePage = (vehicleId) => {
// Send the user to the vehicle page
window.location.href = `/user/vehicles/${vehicleId}`;
const canViewVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "list");
const canEditVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "edit");
const canDeleteVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "delete");
const canShowVehicleRowActions = () => canViewVehicle() || canDeleteVehicle();
const isUserScoped = () => Boolean(props.userScopedUserId);
const redirectVehiclePage = (vehicle) => {
if (props.superuserPage) {
window.location.href = `/superuser/vehicles/${encodeURIComponent(vehicle.reg)}`;
return;
}
window.location.href = `/user/vehicles/${encodeURIComponent(vehicle.id)}`;
};
// Get the departments (If the departments are not already loaded)
@@ -55,14 +75,6 @@ if (departments.value.length === 0) {
getDepartments();
}
const onClickListAddons = (vehicleId) => {
// Redirect to the product addons page
console.log("Fetching product addons for vehicle: " + vehicleId);
SessionUser.request("/vehicles/addons/available", "GET", {
id: vehicleId,
});
};
const vehicleAddons = ref(null);
const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
if (forceReload === true) {
@@ -74,11 +86,9 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
SessionUser.request("/vehicles/addons/available", "GET", {
id: vehicleId,
}).then((response) => {
console.log("Vehicle addons: ", response.data.data);
vehicleAddons.value = response.data.data;
});
}
console.log("Vehicle addons: " + vehicleAddons.value);
return vehicleAddons.value;
};
@@ -117,6 +127,28 @@ const getProductOptionsLabel = (vehicle) => {
const available = Number(vehicle?.addons?.available ?? 0);
return `${SessionUser.objects.product_options.meta.title} ( ${enabled} / ${available} )`;
};
const editVehicleField = (id, column, value, onAfterSubmit = null) => {
if (isUserScoped()) {
return SessionUser.objects.vehicles.functions.showEditObjectFieldForUserForm(
props.userScopedUserId,
id,
column,
value,
onAfterSubmit
);
}
return SessionUser.objects.vehicles.showEditObjectFieldForm(id, column, value, onAfterSubmit);
};
const deleteVehicle = (id) => {
if (isUserScoped()) {
return SessionUser.objects.vehicles.functions.showDeleteObjectForUserForm(props.userScopedUserId, id, () => reload());
}
return SessionUser.objects.vehicles.functions.showDeleteObjectForm(id, () => reload());
};
</script>
<template>
@@ -145,14 +177,16 @@ const getProductOptionsLabel = (vehicle) => {
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:editFunction="editVehicleField"
:permission-check-function="canEditVehicle"
column="reg"
/>
<!-- Type -->
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:editFunction="editVehicleField"
:permission-check-function="canEditVehicle"
column="type"
:parse-function="
(value) => {
@@ -164,7 +198,8 @@ const getProductOptionsLabel = (vehicle) => {
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:editFunction="editVehicleField"
:permission-check-function="canEditVehicle"
column="wash_subscription"
:parse-function="
(value) => {
@@ -174,7 +209,7 @@ const getProductOptionsLabel = (vehicle) => {
/>
<!-- Product Options, if the wash subscription is set to true -->
<td v-if="!props.compact">
<template v-if="object.wash_subscription">
<template v-if="object.wash_subscription && canEditVehicle()">
<!-- Enabled subscription -->
<ActionSettingsWheelButton
:label="getProductOptionsLabel(object)"
@@ -217,14 +252,15 @@ const getProductOptionsLabel = (vehicle) => {
v-if="!props.compact"
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:editFunction="editVehicleField"
:permission-check-function="canEditVehicle"
column="reference"
/>
<!-- Actions -->
<td>
<td v-if="!props.compact">
<!-- Actions stay grouped behind the wheel menu. -->
<ActionSettingsWheelButton
v-if="!props.compact"
v-if="canShowVehicleRowActions()"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
@@ -232,18 +268,18 @@ const getProductOptionsLabel = (vehicle) => {
<template #actions>
<!-- View (Redirect to the vehicle page) -->
<ActionSettingsWheelItem
v-if="canViewVehicle()"
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-eye"
:click-action="() => redirectUserVehiclePage(object.id)"
:click-action="() => redirectVehiclePage(object)"
/>
<!-- Delete -->
<ActionSettingsWheelItem
v-if="canDeleteVehicle()"
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
icon="fas fa-trash"
:template="'danger'"
:click-action="
() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload())
"
:click-action="() => deleteVehicle(object.id)"
/>
</template>
</ActionSettingsWheelButton>
+156
View File
@@ -0,0 +1,156 @@
<script setup>
import { computed, useAttrs } from "vue";
import { useI18n } from "vue-i18n";
import { BDatepicker } from "buefy";
import {
formatDatepickerDateForApi,
formatDatepickerDateForLocale,
normalizeDatepickerDate,
parseDatepickerInput,
} from "@/services/buefyDatepicker.js";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
modelValue: {
type: [String, Date],
default: "",
},
valueType: {
type: String,
default: "string",
validator: (value) => ["string", "date", "nullable-string"].includes(value),
},
placeholder: {
type: String,
default: "",
},
icon: {
type: String,
default: "calendar",
},
disabled: Boolean,
readonly: Boolean,
required: Boolean,
expanded: {
type: Boolean,
default: true,
},
appendToBody: {
type: Boolean,
default: true,
},
position: {
type: String,
default: "is-bottom-left",
},
openOnFocus: {
type: Boolean,
default: true,
},
clearable: Boolean,
minDate: {
type: Date,
default: null,
},
maxDate: {
type: Date,
default: null,
},
unselectableDates: {
type: [Array, Function],
default: null,
},
selectableDates: {
type: [Array, Function],
default: null,
},
events: {
type: Array,
default: () => [],
},
indicators: {
type: String,
default: "dots",
},
dataTestid: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue", "change"]);
const { locale } = useI18n({ useScope: "global" });
const attrs = useAttrs();
const datepickerTestId = computed(() => props.dataTestid || attrs["data-testid"] || undefined);
const selectedDate = computed({
get: () => normalizeDatepickerDate(props.modelValue),
set: (value) => {
const normalized = normalizeDatepickerDate(value);
let nextValue = "";
if (props.valueType === "date") {
nextValue = normalized;
} else if (props.valueType === "nullable-string") {
nextValue = normalized ? formatDatepickerDateForApi(normalized) : null;
} else {
nextValue = normalized ? formatDatepickerDateForApi(normalized) : "";
}
emit("update:modelValue", nextValue);
emit("change", nextValue);
},
});
const showClearIcon = computed(() => (
props.clearable
&& selectedDate.value !== null
&& !props.disabled
&& !props.readonly
));
const clearSelectedDate = (event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
selectedDate.value = null;
};
const formatter = (value) => formatDatepickerDateForLocale(value, locale.value);
const parser = (value) => parseDatepickerInput(value, locale.value);
</script>
<template>
<div :data-testid="datepickerTestId">
<BDatepicker
v-bind="attrs"
v-model="selectedDate"
icon-pack="fas"
:icon="icon"
:locale="locale"
:placeholder="placeholder"
:position="position"
:open-on-focus="openOnFocus"
:disabled="disabled"
:readonly="readonly"
:required="required"
:expanded="expanded"
:append-to-body="appendToBody"
:min-date="minDate"
:max-date="maxDate"
:unselectable-dates="unselectableDates"
:selectable-dates="selectableDates"
:events="events"
:indicators="indicators"
:icon-right="showClearIcon ? 'times-circle' : undefined"
:icon-right-clickable="showClearIcon"
:mobile-native="false"
:editable="true"
:date-formatter="formatter"
:date-parser="parser"
@icon-right-click="clearSelectedDate"
/>
</div>
</template>
+153
View File
@@ -0,0 +1,153 @@
<script setup>
import { computed, useAttrs } from "vue";
import { useI18n } from "vue-i18n";
import { BDatepicker } from "buefy";
import {
formatDatepickerMonthForApi,
formatDatepickerMonthForLocale,
normalizeDatepickerMonth,
parseMonthpickerInput,
} from "@/services/buefyDatepicker.js";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
modelValue: {
type: [String, Date],
default: "",
},
valueType: {
type: String,
default: "string",
validator: (value) => ["string", "date", "nullable-string"].includes(value),
},
placeholder: {
type: String,
default: "",
},
disabled: Boolean,
readonly: Boolean,
expanded: {
type: Boolean,
default: true,
},
appendToBody: {
type: Boolean,
default: true,
},
position: {
type: String,
default: "is-bottom-left",
},
openOnFocus: {
type: Boolean,
default: true,
},
inline: Boolean,
clearable: Boolean,
minDate: {
type: Date,
default: null,
},
maxDate: {
type: Date,
default: null,
},
unselectableDates: {
type: [Array, Function],
default: null,
},
selectableDates: {
type: [Array, Function],
default: null,
},
events: {
type: Array,
default: () => [],
},
indicators: {
type: String,
default: "dots",
},
dataTestid: {
type: String,
default: "",
},
});
const emit = defineEmits(["update:modelValue", "change"]);
const { locale } = useI18n({ useScope: "global" });
const attrs = useAttrs();
const datepickerTestId = computed(() => props.dataTestid || attrs["data-testid"] || undefined);
const selectedMonth = computed({
get: () => normalizeDatepickerMonth(props.modelValue),
set: (value) => {
const normalized = normalizeDatepickerMonth(value);
let nextValue = "";
if (props.valueType === "date") {
nextValue = normalized;
} else if (props.valueType === "nullable-string") {
nextValue = normalized ? formatDatepickerMonthForApi(normalized) : null;
} else {
nextValue = normalized ? formatDatepickerMonthForApi(normalized) : "";
}
emit("update:modelValue", nextValue);
emit("change", nextValue);
},
});
const showClearIcon = computed(() => (
props.clearable
&& selectedMonth.value !== null
&& !props.disabled
&& !props.readonly
));
const clearSelectedMonth = (event) => {
event?.preventDefault?.();
event?.stopPropagation?.();
selectedMonth.value = null;
};
const formatter = (value) => formatDatepickerMonthForLocale(value, locale.value);
const parser = (value) => parseMonthpickerInput(value, locale.value);
</script>
<template>
<div :data-testid="datepickerTestId">
<BDatepicker
v-bind="attrs"
v-model="selectedMonth"
type="month"
icon-pack="fas"
icon="calendar"
:locale="locale"
:placeholder="placeholder"
:position="position"
:open-on-focus="openOnFocus"
:inline="inline"
:disabled="disabled"
:readonly="readonly"
:expanded="expanded"
:append-to-body="appendToBody"
:min-date="minDate"
:max-date="maxDate"
:unselectable-dates="unselectableDates"
:selectable-dates="selectableDates"
:events="events"
:indicators="indicators"
:icon-right="showClearIcon ? 'times-circle' : undefined"
:icon-right-clickable="showClearIcon"
:mobile-native="false"
:editable="true"
:date-formatter="formatter"
:date-parser="parser"
@icon-right-click="clearSelectedMonth"
/>
</div>
</template>
@@ -4,11 +4,13 @@ import axios from "axios";
import { API_URL } from "@/config.js";
import { getError, parseError, removeError } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { economicEanPayloadValue, normalizeEconomicEan } from "@/services/economicCustomerIdentifiers.js";
const customerFormData = reactive({
cvr: null,
contactEmail: null,
contactPhone: null
contactPhone: null,
ean: null
});
const deliverSuccess = ref(false);
@@ -17,17 +19,24 @@ let deliverTimeoutId;
const submitCustomer = async () => {
removeError("authRegisterCvr");
try {
await axios.post(API_URL + "/auth/register/cvr", {
const payload = {
cvr: customerFormData.cvr,
invoiceEmail: customerFormData.contactEmail,
contactEmail: customerFormData.contactEmail,
contactPhone: customerFormData.contactPhone,
companyPhone: customerFormData.contactPhone
});
};
const ean = economicEanPayloadValue(customerFormData.ean);
if (ean !== undefined) {
payload.ean = ean;
}
await axios.post(API_URL + "/auth/register/cvr", payload);
customerFormData.contactPhone = null;
customerFormData.contactEmail = null;
customerFormData.cvr = null;
customerFormData.ean = null;
deliverSuccess.value = true;
deliverTimeoutId = setTimeout(() => {
@@ -59,6 +68,20 @@ onBeforeUnmount(() => {
>
</div>
<div class="form-field">
<label for="customer_ean">{{ $t("customer_creation.customer.ean_label") }}</label>
<input
id="customer_ean"
:value="customerFormData.ean"
type="text"
inputmode="numeric"
maxlength="13"
autocomplete="off"
placeholder="5790001234567"
@input="customerFormData.ean = normalizeEconomicEan($event.target.value)"
>
</div>
<div class="form-field">
<label for="email">{{ $t("customer_creation.customer.email_label") }}</label>
<input id="email" v-model="customerFormData.contactEmail" type="email" name="email" autocomplete="email">
@@ -1,24 +1,30 @@
<script setup>
import { onMounted, ref, watch, nextTick, computed } from 'vue';
import {
clearPendingOrderItems,
getOrderId,
getProductsCategory,
getAddonRestriction,
getProductRestriction,
loadOrderItems,
productCategoryAllowed,
isProductRestricted,
setProductsCategory,
getUserProductDiscount,
showFakeCreateOrderItem,
getUserProductPrice,
showPendingCreateOrderItem,
department_id,
user_discounts,
getUserGlobalDiscount,
getUserDiscounts,
customer_id,
customer_attributes
customer_attributes,
loadCustomerAttributes
} from "@/components/shop/POSDepartmentProcess.vue";
import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
import {createOrderItem} from "@/components/shop/OrdersItems.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useRoute } from 'vue-router';
import { useI18n } from "vue-i18n";
import Swal from "sweetalert2";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
@@ -27,6 +33,7 @@ import { getPicture } from "@/components/displays/department/pos/displays/Piktog
import { products_category } from "@/components/shop/POSDepartmentProcess.vue";
const emits = defineEmits(['onAddToCartProduct', 'onAddProduct', 'onSelectProduct']);
const route = useRoute();
const { t } = useI18n();
const props = defineProps({
isCustomerBooking: {
type: Boolean,
@@ -151,6 +158,10 @@ const fetchProductsForCategory = async ({
products.value = [];
try {
if (customerNumber) {
await loadCustomerAttributes(customerNumber);
}
const response = normalizedCategory === null
? await getProducts(departmentId, props.finalPrice, customerNumber)
: await getProductCategory(normalizedCategory, departmentId, props.finalPrice, customerNumber);
@@ -193,6 +204,46 @@ const getValidOrderId = () => {
return Number.isInteger(orderId) && orderId > 0 ? orderId : null;
};
const CUSTOMER_PRODUCT_RULE_BLOCK_MESSAGE = "This product is not allowed for the selected customer";
const getCreateOrderItemErrorMessage = (error) => {
return String(
error?.response?.data?.data?.message ??
error?.response?.data?.message ??
error?.message ??
""
);
};
const isCustomerProductRuleBlockError = (error) => {
return getCreateOrderItemErrorMessage(error).includes(CUSTOMER_PRODUCT_RULE_BLOCK_MESSAGE);
};
const showRestrictionWarning = async (messageKey = "pos.restrictions.product_not_allowed") => {
await Swal.fire({
icon: "warning",
title: t("pos.restrictions.title"),
text: t(messageKey),
});
};
const handleCreateOrderItemError = async (error, { validationMessage = false } = {}) => {
clearPendingOrderItems();
await loadOrderItems().catch(() => {});
if (isCustomerProductRuleBlockError(error)) {
const message = t("pos.restrictions.backend_rejected");
if (validationMessage) {
Swal.showValidationMessage(message);
} else {
await showRestrictionWarning("pos.restrictions.backend_rejected");
}
return true;
}
throw error;
};
const ensureOrderId = (showWarning = true) => {
const orderId = getValidOrderId();
if (orderId !== null) {
@@ -210,10 +261,90 @@ const ensureOrderId = (showWarning = true) => {
return null;
};
const findProductById = (productId) => {
return products.value.find((product) => Number(product.id) === Number(productId)) || null;
};
const getProductAddonDefinition = (productId, addonId) => {
const product = findProductById(productId);
if (!product || !Array.isArray(product.addons)) {
return null;
}
return product.addons.find((addon) => Number(addon.option_id ?? addon.id) === Number(addonId)) || null;
};
const getPendingProductFromAddon = (productId, addonId) => {
const addon = getProductAddonDefinition(productId, addonId);
const addonProduct = addon?.product || {};
if (!addon) {
return null;
}
return {
...addonProduct,
id: addon.option_id ?? addonProduct.id ?? addon.id,
name: addon.name || addonProduct.name,
price: getAddonPrice(productId, addonId),
category: addonProduct.category ?? addon.category,
};
};
const getSelectedProductAddons = (productId) => {
return productAddons.value.filter(
(productAddon) => productAddon.product_id === productId && productAddon.status === true
);
};
const getAllowedSelectedProductAddons = (productId, options = {}) => {
const restrictedSelections = [];
const allowedSelections = [];
getSelectedProductAddons(productId).forEach((productAddon) => {
const addon = getProductAddonDefinition(productId, productAddon.addon_id);
const restriction = getAddonRestriction(addon || productAddon, {
isRelatedAddon: true,
...options,
});
if (restriction.restricted) {
restrictedSelections.push({ productAddon, restriction });
return;
}
allowedSelections.push(productAddon);
});
return { allowedSelections, restrictedSelections };
};
const clearRestrictedProductAddonSelections = (restrictedSelections = []) => {
restrictedSelections.forEach(({ productAddon }) => {
productAddon.status = false;
productAddon.quantity = 0;
});
};
const warnIfRestrictedAddonsWereSkipped = async (restrictedSelections = []) => {
if (restrictedSelections.length === 0) {
return;
}
clearRestrictedProductAddonSelections(restrictedSelections);
await showRestrictionWarning("pos.restrictions.restricted_items_removed");
};
const showAddMultipleProducts = (productId) => {
const product = findProductById(productId);
const restriction = getProductRestriction(product);
if (restriction.restricted) {
void showRestrictionWarning(restriction.messageKey);
return;
}
Swal.fire({
title: 'Tilføj flere produkter',
input: 'number',
@@ -240,7 +371,9 @@ const showAddMultipleProducts = (productId) => {
Swal.showValidationMessage('Order ID is required');
return false;
}
return createOrderItem(orderId, productId, inputValue).then(async () => await addAddonsToOrderMiddleware(productId, inputValue, null, orderId).then(() => loadOrderItems()));
return createOrderItem(orderId, productId, inputValue)
.then(async () => await addAddonsToOrderMiddleware(productId, inputValue, null, orderId).then(() => loadOrderItems()))
.catch((error) => handleCreateOrderItemError(error, { validationMessage: true }).then(() => false));
}
},
allowOutsideClick: () => !Swal.isLoading()
@@ -378,15 +511,19 @@ const addAddonsToOrderMiddleware = async (product_id, quantity = 1, related_item
}
// If the product addons are set, add them to the order (If the product is added)
var product_addons = productAddons.value.filter((productAddon) => productAddon.product_id === product_id && productAddon.status === true);
const { allowedSelections: product_addons, restrictedSelections } = getAllowedSelectedProductAddons(product_id, {
relatedItemId: related_item_id,
});
await warnIfRestrictedAddonsWereSkipped(restrictedSelections);
for (let i = 0; i < product_addons.length; i++) {
// Get the addons quantity (If the addon quantity is 0, use the product quantity, otherwise multiply the addon quantity with the product quantity)
let addon_quantity = product_addons[i].quantity === 0 ? quantity : product_addons[i].quantity * quantity;
// Get the products addon, and show the fake create order item
showFakeCreateOrderItem(
product_addons[i].addon_id,
showPendingCreateOrderItem(
getPendingProductFromAddon(product_id, product_addons[i].addon_id),
addon_quantity,
0,
getAddonPrice(product_id, product_addons[i].addon_id),
related_item_id,
product_addons[i].notes === undefined ? null : product_addons[i].notes
);
@@ -400,7 +537,7 @@ const addAddonsToOrderMiddleware = async (product_id, quantity = 1, related_item
addon_quantity,
related_item_id,
product_addons[i].notes === undefined ? null : product_addons[i].notes
);
).catch((error) => handleCreateOrderItemError(error));
}
};
@@ -422,11 +559,23 @@ const setProductAddonQuantity = (productId, addonId, quantity) => {
const addProductWithAddonsToOrder = async (product_id) => {
// Check if the emit only is set
const product = products.value.find((product) => product.id === product_id);
const product = findProductById(product_id);
if (!product) {
return;
}
const productRestriction = getProductRestriction(product);
if (productRestriction.restricted) {
await showRestrictionWarning(productRestriction.messageKey);
return;
}
const { restrictedSelections } = getAllowedSelectedProductAddons(product_id);
await warnIfRestrictedAddonsWereSkipped(restrictedSelections);
// Add quantities to the product addons
const productWithAddons = {
...product,
addons: product.addons.map((addon) => {
addons: (Array.isArray(product.addons) ? product.addons : [])
.filter((addon) => !getAddonRestriction(addon, { isRelatedAddon: true }).restricted)
.map((addon) => {
return {
...addon,
product: {
@@ -473,15 +622,17 @@ const addProductWithAddonsToOrder = async (product_id) => {
return false;
}
// Show the fake create order item
showFakeCreateOrderItem(product_id, 1, 0, 0, normalizedNote);
showPendingCreateOrderItem(product, 1, getUserProductPrice(product), 0, normalizedNote);
// Create the order item
return createOrderItem(confirmedOrderId, product_id, 1, 0, normalizedNote).then(async (result) => {
let order_item_id = result.data.data.id;
// Add the addons to the order
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => {
loadOrderItems();
});
});
return createOrderItem(confirmedOrderId, product_id, 1, 0, normalizedNote)
.then(async (result) => {
let order_item_id = result.data.data.id;
// Add the addons to the order
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => {
loadOrderItems();
});
})
.catch((error) => handleCreateOrderItemError(error, { validationMessage: true }).then(() => false));
},
allowOutsideClick: () => !Swal.isLoading()
});
@@ -490,7 +641,7 @@ const addProductWithAddonsToOrder = async (product_id) => {
// If the product requires a note, show the note input
let quantity = 1;
// Show the fake create order item
showFakeCreateOrderItem(product_id, quantity, 0);
showPendingCreateOrderItem(product, quantity, getUserProductPrice(product));
// IF only emitting the product, then we don't need to add the addons to the order
if (props.emitActionsOnly) {
emits('onAddProduct', {
@@ -501,13 +652,15 @@ const addProductWithAddonsToOrder = async (product_id) => {
return;
}
// Create the order item
await createOrderItem(orderId, product_id, quantity).then(async (result) => {
let order_item_id = result.data.data.id;
// Add the addons to the order
await addAddonsToOrderMiddleware(product_id, quantity, order_item_id, orderId).then(() => {
loadOrderItems();
});
});
await createOrderItem(orderId, product_id, quantity)
.then(async (result) => {
let order_item_id = result.data.data.id;
// Add the addons to the order
await addAddonsToOrderMiddleware(product_id, quantity, order_item_id, orderId).then(() => {
loadOrderItems();
});
})
.catch((error) => handleCreateOrderItemError(error));
};
const recommended_product_ids = ref([]);
@@ -549,13 +702,17 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
continue;
}
const previousOrderProduct = getPreviousOrderProduct(orderItem);
const discount = getUserProductDiscount(previousOrderProduct || {});
const previousOrderProduct = getPreviousOrderProduct(orderItem) || { id: productId, price: getPreviousOrderProductPrice(orderItem) };
const restriction = getProductRestriction(previousOrderProduct);
if (restriction.restricted) {
await showRestrictionWarning(restriction.messageKey);
continue;
}
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);
showPendingCreateOrderItem({ ...previousOrderProduct, price: basePrice }, quantity, discountedPrice);
await createOrderItem(orderId, productId, quantity).catch((error) => handleCreateOrderItemError(error));
await addAddonsToOrderMiddleware(productId, quantity, null, orderId);
}
@@ -586,14 +743,12 @@ const getRecommendedProductPrice = (productId) => {
return '0';
}
const discount = getUserProductDiscount(product);
const price = Number(product.price ?? 0);
return (price - (price * (discount / 100))).toFixed(0);
return String(getUserProductPrice(product));
};
const isRecommendedProductCategoryAllowed = (productId) => {
const product = getProductById(productId);
return !!product && productCategoryAllowed(product.category);
return !!product && !isProductRestricted(product);
};
const addRecommendedProductToOrder = async (productId) => {
@@ -601,13 +756,18 @@ const addRecommendedProductToOrder = async (productId) => {
if (!product) {
return;
}
const restriction = getProductRestriction(product);
if (restriction.restricted) {
await showRestrictionWarning(restriction.messageKey);
return;
}
const orderId = ensureOrderId();
if (!orderId) {
return;
}
showFakeCreateOrderItem(product, 1, getRecommendedProductPrice(productId));
await createOrderItem(orderId, productId, 1);
showPendingCreateOrderItem(product, 1, getRecommendedProductPrice(productId));
await createOrderItem(orderId, productId, 1).catch((error) => handleCreateOrderItemError(error));
await addAddonsToOrderMiddleware(productId, 1, null, orderId);
await loadOrderItems();
};
@@ -405,6 +405,24 @@ watch(activeQuickAction, (nextMode, previousMode) => {
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.ean") }}</small></label
>
<div class="control">
<input
v-model="addCustomerForm.ean"
class="input"
type="text"
inputmode="numeric"
maxlength="13"
:placeholder="$t('admin.pos.ean')"
:disabled="addCustomerSearchResult === null || isAddCustomerSubmitting"
data-testid="pos-desktop-add-customer-ean"
@input="handleAddCustomerFieldInput('ean')"
/>
</div>
</div>
<div class="field">
<label class="label"
><small>{{ $t("admin.pos.invoice_email") }}</small></label
+69 -29
View File
@@ -1,35 +1,64 @@
<script>
import { ref } from 'vue'
import { editUser } from '@/components/session/Session.vue'
import Swal from "sweetalert2";
//
import { editUser } from "@/components/session/Session.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import i18n from "@/i18n";
export const showEditUserForm = async (id, customer_number, display_name, role) => {
await Swal.fire({
title: 'Edit User',
const t = (key, values = {}) => i18n.global.t(key, values);
const escapeHtml = (value) =>
String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const buildRoleOptions = (roles, selectedRoleId) =>
roles
.map((item) => {
const roleId = String(item?.id ?? "");
const roleName = item?.name || `#${roleId}`;
const selected = roleId === String(selectedRoleId ?? "") ? " selected" : "";
return `<option value="${escapeHtml(roleId)}"${selected}>${escapeHtml(roleName)}</option>`;
})
.join("");
export const showEditUserForm = async (id, customer_number, display_name, role, options = {}) => {
const isRoleLocked = Boolean(options.limitedBackofficeManaged);
const lockedRoleFields = isRoleLocked ? "disabled" : "";
const roles = await SessionUser.objects.roles.get.all().catch(() => []);
const result = await Swal.fire({
title: t("user_admin.edit_user"),
html: `
<form id="editNumberPlateScannerForm">
<div class="field">
<label class="label">Customer Number</label>
<div class="field has-text-left">
<label class="label" for="customer_number">${escapeHtml(t("user_admin.customer_number"))}</label>
<div class="control">
<input class="input" type="text" id="customer_number" value="${customer_number}" />
<input class="input" type="text" id="customer_number" value="${escapeHtml(customer_number)}" ${lockedRoleFields} />
</div>
</div>
<div class="field">
<label class="label">Display Name</label>
<div class="field has-text-left">
<label class="label" for="display_name">${escapeHtml(t("common.name"))}</label>
<div class="control">
<input class="input" type="text" id="display_name" value="${display_name}" />
<input class="input" type="text" id="display_name" value="${escapeHtml(display_name)}" />
</div>
</div>
<div class="field">
<label class="label">Role</label>
<div class="field has-text-left">
<label class="label" for="role">${escapeHtml(t("user_admin.group_id"))}</label>
<div class="control">
<input class="input" type="text" id="role" value="${role}" />
<div class="select is-fullwidth ${roles.length === 0 ? "is-loading" : ""}">
<select id="role" ${lockedRoleFields}>
${buildRoleOptions(Array.isArray(roles) ? roles : [], role)}
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="field has-text-left">
<label class="label" for="password">${escapeHtml(t("global.password"))}</label>
<div class="control">
<input class="input" type="password" id="password" />
</div>
@@ -37,17 +66,28 @@ export const showEditUserForm = async (id, customer_number, display_name, role)
</form>
`,
showCancelButton: true,
confirmButtonText: 'Edit User',
preConfirm: () => {
editUser(id, document.getElementById('customer_number').value, document.getElementById('display_name').value, document.getElementById('role').value, document.getElementById('password').value)
.then(() => {
Swal.fire('User edited successfully');
})
.catch((e) => {
Swal.fire('Error', e.response.data.error ?? e.response, 'error');
});
}
confirmButtonText: t("user_admin.edit_user"),
preConfirm: async () => {
try {
await editUser(
id,
document.getElementById("customer_number").value,
document.getElementById("display_name").value,
document.getElementById("role").value,
document.getElementById("password").value
);
return true;
} catch (error) {
Swal.showValidationMessage(
error?.response?.data?.error ?? error?.response?.data?.message ?? error?.response ?? t("common.unknown_error")
);
return false;
}
},
});
}
</script>
if (result.isConfirmed) {
await Swal.fire(t("user_admin.user_edited_successfully"));
}
};
</script>
+133 -1
View File
@@ -2,11 +2,14 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import axios from "axios";
import { REQUEST_QUEUE_CONFIG } from "@/config.js";
import i18n from "@/i18n";
import router from "@/router";
import {
buildReleaseSessionSummary,
getReleaseRuntimeApiBaseUrl,
resolveReleaseApiUrl,
} from "@/services/releaseTimeline.js";
import { buildRequestErrorTraceText } from "@/services/requestErrorTrace.js";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
clearErrorRequests,
@@ -54,10 +57,22 @@ const pingLatencyMs = ref(null);
const pingIsUnavailable = ref(false);
const lastShiftKeyPressedAtMs = ref(0);
const shiftKeyPressCount = ref(0);
const copyTraceStatusByRequestId = ref({});
let tickerTimer = null;
let pingTimer = null;
const LABEL_FALLBACKS = Object.freeze({
"maintenance_menu.copy_trace": "Copy trace",
"maintenance_menu.copy_trace_copied": "Copied",
"maintenance_menu.copy_trace_failed": "Copy failed",
});
const tr = (key) => {
const value = i18n.global.t(key);
return value === key ? LABEL_FALLBACKS[key] || key : value;
};
const hasOutstandingRequests = computed(() => requestQueueState.pending + requestQueueState.active > 0);
const processedRequests = computed(() => requestQueueState.batchCompleted + requestQueueState.batchFailed);
const missingPermissions = computed(() => requestQueueState.missingPermissions || []);
@@ -547,12 +562,87 @@ const handleWindowKeydown = (event) => {
const handleClearErrors = () => {
clearErrorRequests();
copyTraceStatusByRequestId.value = {};
};
const handleClearMissingPermissions = () => {
clearMissingPermissions();
};
const getCurrentRouteContext = () => {
const route = router?.currentRoute?.value || null;
return {
name: route?.name ? String(route.name) : "",
fullPath: route?.fullPath || (typeof window !== "undefined" ? window.location.pathname : ""),
path: route?.path || (typeof window !== "undefined" ? window.location.pathname : ""),
};
};
const writeClipboardText = async (text) => {
if (typeof navigator !== "undefined" && typeof navigator.clipboard?.writeText === "function") {
await navigator.clipboard.writeText(text);
return;
}
if (typeof document === "undefined" || typeof document.execCommand !== "function") {
throw new Error("Clipboard API is unavailable.");
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "readonly");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
const copied = document.execCommand("copy");
if (!copied) {
throw new Error("Clipboard copy failed.");
}
} finally {
textarea.remove();
}
};
const setCopyTraceStatus = (requestId, status) => {
copyTraceStatusByRequestId.value = {
...copyTraceStatusByRequestId.value,
[String(requestId)]: status,
};
};
const getCopyTraceStatus = (request) => copyTraceStatusByRequestId.value[String(request?.id)] || "idle";
const getCopyTraceLabel = (request) => {
const status = getCopyTraceStatus(request);
if (status === "copied") {
return tr("maintenance_menu.copy_trace_copied");
}
if (status === "failed") {
return tr("maintenance_menu.copy_trace_failed");
}
return tr("maintenance_menu.copy_trace");
};
const handleCopyTrace = async (request) => {
if (!request?.id) {
return;
}
try {
const traceText = buildRequestErrorTraceText(request, {
routeContext: getCurrentRouteContext(),
});
await writeClipboardText(traceText);
setCopyTraceStatus(request.id, "copied");
} catch (error) {
console.warn("Failed to copy request error trace", error);
setCopyTraceStatus(request.id, "failed");
}
};
const getPermissionGrantStatus = (permission) =>
permissionGrantStatusByKey.value[String(permission || "")] || "idle";
@@ -682,6 +772,18 @@ onBeforeUnmount(() => {
<span class="request-queue-progress__error-meta">
{{ request.statusCode || "n/a" }} - {{ formatDuration(request.requestDurationMs) }}
</span>
<button
class="request-queue-progress__copy-trace-button"
:class="{
'request-queue-progress__copy-trace-button--copied': getCopyTraceStatus(request) === 'copied',
'request-queue-progress__copy-trace-button--failed': getCopyTraceStatus(request) === 'failed',
}"
:data-testid="`request-queue-copy-trace-${request.id}`"
type="button"
@click="handleCopyTrace(request)"
>
{{ getCopyTraceLabel(request) }}
</button>
</div>
<details class="request-queue-progress__error-details">
@@ -1281,7 +1383,7 @@ onBeforeUnmount(() => {
.request-queue-progress__error-summary {
display: grid;
grid-template-columns: 110px 1fr auto;
grid-template-columns: 110px minmax(0, 1fr) auto auto;
gap: 8px;
align-items: center;
font-size: 12px;
@@ -1292,6 +1394,36 @@ onBeforeUnmount(() => {
color: rgba(255, 255, 255, 0.75);
}
.request-queue-progress__copy-trace-button {
border: 1px solid rgba(125, 211, 252, 0.55);
background: rgba(14, 165, 233, 0.16);
color: #e0f2fe;
border-radius: 999px;
font-size: 11px;
line-height: 1;
padding: 4px 8px;
cursor: pointer;
white-space: nowrap;
}
.request-queue-progress__copy-trace-button:hover,
.request-queue-progress__copy-trace-button:focus-visible {
background: rgba(14, 165, 233, 0.28);
border-color: rgba(125, 211, 252, 0.85);
}
.request-queue-progress__copy-trace-button--copied {
border-color: rgba(74, 222, 128, 0.65);
background: rgba(22, 163, 74, 0.2);
color: #dcfce7;
}
.request-queue-progress__copy-trace-button--failed {
border-color: rgba(248, 113, 113, 0.7);
background: rgba(220, 38, 38, 0.2);
color: #fee2e2;
}
.request-queue-progress__error-details {
margin-top: 4px;
}

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