Compare commits

...
Author SHA1 Message Date
Jeppe B 50794f56a1 Tighten session bootstrap heading assertion 2026-07-09 15:53:54 +02:00
Jeppe B 4e56191b7e Fix cron due i18n link target
Add locale glossary entries for the cron due label and link the source alias through terms.glossary.due so generated runtime links resolve across active locales.
2026-07-09 14:51:17 +02:00
Jeppe B 4404fb49d3 Fix cron i18n duplicate aliases
Link repeated cron labels through existing cron/shared i18n aliases and regenerate global-v2.json so i18n integrity checks pass.
2026-07-09 13:00:25 +02:00
Jeppe B fd4db442e4 Add Playwright component test quarantine flow 2026-07-09 11:29:44 +02:00
Jeppe B 416f392108 Classify superuser cron Playwright spec 2026-07-09 11:26:59 +02:00
Jeppe B dbe9f6b0b2 Redirect QR signups to kundeoprettelse
Redirect legacy QR signup routes for new customers and drivers to the shared /kundeoprettelse customer creation page.

Validated with focused eslint and QR Playwright coverage for mobile and desktop.
2026-07-09 11:19:30 +02:00
Jeppe B 18f06f7a63 Format POS visual e2e spec 2026-07-09 11:19:23 +02:00
Jeppe B fb75ddc3e9 Format superuser cron e2e spec 2026-07-09 11:15:29 +02:00
Jeppe B 1326a40033 Add superuser cron operations UI 2026-07-09 11:12:02 +02:00
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
396 changed files with 31345 additions and 9284 deletions
+243 -12
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,15 +139,195 @@ jobs:
env:
VITEST_BATCH_SIZE: 5
e2e-pr:
if: github.event_name != 'schedule'
e2e-targeted:
if: >
github.event_name == 'workflow_dispatch' &&
(inputs.mode == 'targeted' || inputs.mode == 'targeted-then-full')
needs: build-and-unit
name: E2E-targeted-${{ matrix.project }}
# Use GitHub-hosted runners to avoid self-hosted desktop contention and sleep/power events.
runs-on: ubuntu-24.04
timeout-minutes: 35
strategy:
fail-fast: false
matrix:
project: ${{ fromJSON(inputs.target_projects || '["chromium-desktop"]') }}
env:
MATRIX_PROJECT: ${{ matrix.project }}
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-targeted-${{ matrix.project }}
PLAYWRIGHT_REPORTER_MODE: line-html
PLAYWRIGHT_WORKERS: 1
PLAYWRIGHT_VIDEO_MODE: on-first-retry
TARGET_GREP: ${{ inputs.target_grep }}
TARGET_SPECS: ${{ inputs.target_specs }}
RUN_ID: ${{ github.run_id }}
steps:
- name: Normalize workspace permissions
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
if [[ -n "$foreign_entry" ]]; then
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
rm -rf "$trash" 2>/dev/null || true
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
mkdir -p "$GITHUB_WORKSPACE"
fi
fi
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
- name: Run targeted Playwright specs in container
shell: bash
run: |
set -euo pipefail
case "$MATRIX_PROJECT" in
chromium-mobile) project_offset=1 ;;
chromium-desktop) project_offset=2 ;;
chromium-tablet) project_offset=3 ;;
webkit-mobile) project_offset=31 ;;
webkit-desktop) project_offset=32 ;;
webkit-tablet) project_offset=33 ;;
firefox-mobile) project_offset=61 ;;
firefox-desktop) project_offset=62 ;;
firefox-tablet) project_offset=63 ;;
*) echo "Unsupported Playwright project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + project_offset))
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
mkdir -p "$lock_root"
chmod 1777 "$lock_root" 2>/dev/null || true
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
playwright_port_lock=""
playwright_dev_port=""
for ((candidate = port_seed; candidate < port_seed + 1000; candidate += 1)); do
lock_dir="${lock_root}/${candidate}.lock"
if ! mkdir "$lock_dir" 2>/dev/null; then
continue
fi
if ss -H -ltn "sport = :${candidate}" 2>/dev/null | grep -q .; then
rmdir "$lock_dir" || true
continue
fi
playwright_port_lock="$lock_dir"
playwright_dev_port="$candidate"
break
done
if [[ -z "$playwright_dev_port" ]]; then
echo "Unable to find a free Playwright dev-server port." >&2
exit 1
fi
trap 'if [[ -n "${playwright_port_lock:-}" ]]; then rmdir "$playwright_port_lock" 2>/dev/null || true; fi' EXIT
if docker info >/dev/null 2>&1; then
docker_cmd=(docker)
elif sudo -n docker info >/dev/null 2>&1; then
docker_cmd=(sudo docker)
else
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
exit 1
fi
mkdir -p output/playwright
scripts/ci/runner-diagnostics.sh "before targeted Playwright ${MATRIX_PROJECT}" -- "${docker_cmd[@]}"
SYSTEMD_INHIBIT_REASON="Frontend targeted Playwright ${MATRIX_PROJECT}" \
scripts/ci/with-systemd-inhibit.sh "${docker_cmd[@]}" run --rm --ipc=host --network host \
--volume "$PWD:/source:ro" \
--volume "$PWD/output/playwright:/work/output/playwright" \
--workdir /work \
--env HOME=/tmp \
--env CI="${CI:-}" \
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
--env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \
--env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
--env TARGET_GREP="$TARGET_GREP" \
--env TARGET_SPECS="$TARGET_SPECS" \
mcr.microsoft.com/playwright:v1.58.2-noble \
bash -lc '
set -euo pipefail
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
git config --global --add safe.directory /work
install_dependencies() {
local attempt
for attempt in 1 2 3; do
if npm ci --legacy-peer-deps --fetch-retries=5 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000; then
return 0
fi
if [[ "$attempt" == "3" ]]; then
return 1
fi
echo "npm ci failed on attempt ${attempt}; retrying..." >&2
sleep 20
done
}
install_dependencies
ulimit -n 16384 || true
mapfile -t spec_args < <(printf "%s\n" "$TARGET_SPECS" | tr "," "\n" | sed "s/^[[:space:]]*//;s/[[:space:]]*$//;/^$/d")
if [[ "${#spec_args[@]}" -eq 0 && -z "${TARGET_GREP:-}" ]]; then
echo "Provide at least one spec path or grep pattern." >&2
exit 1
fi
for spec_path in "${spec_args[@]}"; do
if [[ "$spec_path" == /* || "$spec_path" == *".."* || "$spec_path" != tests/e2e/* ]]; then
echo "Targeted spec must stay under tests/e2e: $spec_path" >&2
exit 1
fi
if [[ ! -f "$spec_path" ]]; then
echo "Targeted spec does not exist: $spec_path" >&2
exit 1
fi
done
args=("${spec_args[@]}")
if [[ -n "${TARGET_GREP:-}" ]]; then
args+=(--grep "$TARGET_GREP")
fi
args+=(--project="$MATRIX_PROJECT")
npx playwright test "${args[@]}"
'
- name: Runner diagnostics after Playwright failure
if: failure() || cancelled()
continue-on-error: true
run: scripts/ci/runner-diagnostics.sh "after targeted Playwright ${{ matrix.project }}"
- name: Upload Playwright report
if: failure() || cancelled()
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-targeted-${{ matrix.project }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
if-no-files-found: ignore
retention-days: 1
e2e-pr:
if: >
always() &&
github.event_name != 'schedule' &&
needs.build-and-unit.result == 'success' &&
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
(
github.event_name != 'workflow_dispatch' ||
inputs.mode == 'full' ||
needs.e2e-targeted.result == 'success'
)
needs: [build-and-unit, e2e-targeted]
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: ubuntu-latest
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
timeout-minutes: 45
strategy:
fail-fast: false
max-parallel: 4
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
matrix:
suite: [core, changed]
project: [chromium-desktop, chromium-mobile]
@@ -116,6 +338,7 @@ jobs:
PLAYWRIGHT_VIDEO_MODE: on-first-retry
steps:
- name: Repair self-hosted workspace permissions
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
@@ -263,6 +486,7 @@ jobs:
install_dependencies
ulimit -n 16384 || true
if [[ "$MATRIX_SUITE" == "core" ]]; then
PLAYWRIGHT_ARTIFACT_NAMESPACE="${PLAYWRIGHT_ARTIFACT_NAMESPACE}-ct" npm run test:ct -- --project="$MATRIX_PROJECT"
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
else
@@ -291,15 +515,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]
@@ -321,6 +551,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
+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>
+225
View File
@@ -70,6 +70,7 @@
"@creativebulma/bulma-divider": "^1.1.0",
"@eslint/js": "^10.0.1",
"@event-calendar/core": "^4.1.0",
"@playwright/experimental-ct-vue": "^1.58.2",
"@playwright/test": "^1.58.2",
"@types/event-calendar__core": "^3.7.0",
"@vitejs/plugin-vue": "^6.0.5",
@@ -79,6 +80,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",
@@ -4218,6 +4220,229 @@
"node": ">=14"
}
},
"node_modules/@playwright/experimental-ct-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/experimental-ct-core/-/experimental-ct-core-1.58.2.tgz",
"integrity": "sha512-Imif9ggQp6YIblHAX6MvJuqDFrCGHYspoibxLP3+1soXp+1wBNuuSRajv0VWXzFbh//4l29I+xy2tpTgej0vEA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2",
"playwright-core": "1.58.2",
"vite": "^6.4.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@playwright/experimental-ct-core/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@playwright/experimental-ct-core/node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/@playwright/experimental-ct-vue": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/experimental-ct-vue/-/experimental-ct-vue-1.58.2.tgz",
"integrity": "sha512-joLgyXZOV7odSY3PrEkzNRqz6rdaf7eoyTus4deSAAD6u5qvWYgDZl2mDJYiivcE+jDns9SGiZQlkMmyDcjQpg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@playwright/experimental-ct-core": "1.58.2",
"@vitejs/plugin-vue": "^5.2.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@playwright/experimental-ct-vue/node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
"dev": true,
"engines": {
"node": "^18.0.0 || >=20.0.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@playwright/experimental-ct-vue/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@playwright/experimental-ct-vue/node_modules/vite": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
"picomatch": "^4.0.2",
"postcss": "^8.5.3",
"rollup": "^4.34.9",
"tinyglobby": "^0.2.13"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"jiti": ">=1.21.0",
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"lightningcss": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
+8 -2
View File
@@ -47,15 +47,19 @@
"test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live",
"test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live",
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
"test:ct": "playwright test --config=playwright.ct.config.ts",
"test:ct:pr": "playwright test --config=playwright.ct.config.ts --project=chromium-desktop",
"release:verify-upload": "node scripts/release/verify-upload.mjs",
"release:update-server-version": "node scripts/release/update-server-version.mjs",
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
"test:all": "npm run test:unit && npm run test:e2e:pr",
"test:all": "npm run test:unit && npm run test:ct:pr && npm run test:e2e:pr",
"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",
@@ -126,6 +130,7 @@
"@creativebulma/bulma-divider": "^1.1.0",
"@eslint/js": "^10.0.1",
"@event-calendar/core": "^4.1.0",
"@playwright/experimental-ct-vue": "^1.58.2",
"@playwright/test": "^1.58.2",
"@types/event-calendar__core": "^3.7.0",
"@vitejs/plugin-vue": "^6.0.5",
@@ -135,6 +140,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",
+1 -1
View File
@@ -40,7 +40,7 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/release/**"],
testIgnore: ["**/release/**", "**/quarantine/**"],
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
timeout: 60_000,
fullyParallel: true,
+53
View File
@@ -0,0 +1,53 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, devices } from "@playwright/experimental-ct-vue";
const projectRoot = fileURLToPath(new URL(".", import.meta.url));
const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "ct").trim();
const artifactRoot = path.join("output", "playwright", artifactNamespace);
const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
const reporter =
reporterMode === "line-html"
? [["line"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]]
: [["list"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]];
const configuredWorkers = Number(process.env.PLAYWRIGHT_WORKERS || 2);
const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? configuredWorkers : 2;
export default defineConfig({
testDir: "./tests/ct",
timeout: 45_000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers,
reporter,
outputDir: path.join(artifactRoot, "test-results"),
use: {
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
ctViteConfig: {
resolve: {
alias: {
"@": path.resolve(projectRoot, "src"),
},
preserveSymlinks: true,
dedupe: ["vue", "vue-router", "vue-i18n", "@vueuse/core", "@vueuse/head", "@unhead/vue"],
},
},
},
projects: [
{
name: "chromium-desktop",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "chromium-mobile",
use: {
...devices["Pixel 5"],
},
},
],
});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pleno Component Test</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
import { beforeMount } from "@playwright/experimental-ct-vue/hooks";
import Buefy from "buefy";
import "bulma/css/bulma.min.css";
import "buefy/dist/css/buefy.css";
import i18n from "@/i18n";
type PlenoHooksConfig = {
locale?: string;
};
beforeMount(({ app, hooksConfig }) => {
const config = (hooksConfig || {}) as PlenoHooksConfig;
const locale = config.locale || "en";
const globalLocale = i18n.global.locale as unknown;
if (globalLocale && typeof globalLocale === "object" && "value" in globalLocale) {
(globalLocale as { value: string }).value = locale;
} else {
(i18n.global as unknown as { locale: string }).locale = locale;
}
app.use(i18n);
app.use(Buefy);
});
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;
});
+48 -6
View File
@@ -29,15 +29,30 @@ export const sourceMappings = [
projects: chromiumProjects,
},
{
name: "backoffice",
patterns: [/^src\/views\/backoffice\//u],
name: "limited-backoffice",
patterns: [
/^src\/views\/backoffice\//u,
/^src\/services\/limitedBackoffice\.js$/u,
/^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"],
@@ -65,7 +80,7 @@ export const sourceMappings = [
/^src\/views\/dashboards\/superUserDashboard\/department\/Department\.vue$/u,
/^src\/views\/dashboards\/superUserDashboard\/department\/SuperUserDashboardDepartmentNavigation\.vue$/u,
],
specs: ["tests/e2e/superuser-department-overview.spec.js"],
specs: ["tests/e2e/superuser-department-overview.spec.js", "tests/e2e/superuser-department-employees.spec.ts"],
projects: chromiumProjects,
},
{
@@ -96,8 +111,8 @@ export const sourceMappings = [
{
name: "pos",
patterns: [
/\/pos[/-]/iu,
/POS/iu,
/(?:^|[/_.-])pos(?:[/_.-]|$)/iu,
/(?:^|\/)(?:POS|Pos)[A-Z][^/]*\.(?:vue|js|ts)$/u,
/^src\/assets\/pos\.css$/u,
/^src\/components\/displays\/boxes\/ProductBox\.vue$/u,
/^src\/features\/customer\/customerProductRules\.js$/u,
@@ -120,12 +135,22 @@ export const sourceMappings = [
{
name: "admin-department-notifications",
patterns: [
/^src\/views\/dashboards\/departmentDashboard\/modules\/notifications\/DepartmentNotifications\.vue$/u,
/^src\/components\/displays\/department\/notifications\//u,
/^src\/components\/displays\/pagination\/models\/DepartmentPos\/NotificationsPhonePagination\.vue$/u,
],
specs: ["tests/e2e/admin-department-notifications.spec.ts"],
projects: chromiumProjects,
},
{
name: "admin-daily-report",
patterns: [
/^src\/views\/dashboards\/departmentDashboard\/modules\/daily-report\//u,
/^src\/components\/session\/token\/SessionUser\/Objects\/DepartmentDailyReports\.vue$/u,
],
specs: ["tests/e2e/admin-daily-report.spec.ts"],
projects: chromiumProjects,
},
{
name: "limited-backoffice",
patterns: [/^src\/views\/backoffice\/LimitedBackoffice/u],
@@ -157,12 +182,29 @@ export const sourceMappings = [
{
name: "superuser-department-pricing",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|SuperUserSelectedDepartmentObject)\.vue$/u,
/^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],
+5
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",
@@ -98,15 +99,19 @@ export const ownedFilesByRole = {
"self-serve-studio-flow.spec.js",
"session-bootstrap.spec.ts",
"superuser-bookings.spec.ts",
"superuser-cron.spec.ts",
"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",
+17 -5
View File
@@ -2,7 +2,13 @@ import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { chromiumProjects, fallbackChangePatterns, prGrep, smokeGrep, sourceMappings } from "./playwright-pr-mapping.mjs";
import {
chromiumProjects,
fallbackChangePatterns,
prGrep,
smokeGrep,
sourceMappings,
} from "./playwright-pr-mapping.mjs";
const execFileAsync = promisify(execFile);
const workingDirectory = process.cwd();
@@ -245,7 +251,7 @@ function addSpec(selection, spec, projects) {
}
function isE2eSpec(file) {
return /^tests\/e2e\/.+\.spec\.(?:js|ts)$/u.test(file);
return /^tests\/e2e\/(?!quarantine\/).+\.spec\.(?:js|ts)$/u.test(file);
}
function shouldFallback(file) {
@@ -361,7 +367,9 @@ async function runChangedSelection(selection) {
if (selection.fallback) {
console.log(
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}`
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(
", "
)}`
);
for (const project of projects) {
const code = await runPlaywright({
@@ -386,7 +394,9 @@ async function runChangedSelection(selection) {
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(", ")}`
`[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(
", "
)}`
);
}
@@ -466,7 +476,9 @@ async function main() {
const changed = await getChangedFiles();
if (changed.unavailable) {
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
console.log(
`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`
);
return;
}
+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>
+45 -35
View File
@@ -6,7 +6,7 @@ import Swal from "sweetalert2";
import CustomerProductDiscountDisplay
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
import { useI18n } from 'vue-i18n';
import { isProductRestrictedForCustomer } from "@/features/customer/customerProductRules.js";
import { getCustomerProductRestriction } from "@/features/customer/customerProductRules.js";
const { t } = useI18n();
const expandIcon = ref(null);
@@ -80,10 +80,6 @@ const toggleIsSelected = () => {
}
};
const hasRestrictAdditionalServices = () => {
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
};
/**
* On before add product addon
* This is used to check if adding a product has any requirements.
@@ -93,6 +89,9 @@ const hasRestrictAdditionalServices = () => {
* @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
@@ -123,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
@@ -147,18 +149,35 @@ const doesAddonHaveNoteRequirement = (addon) => {
const isAddonRestricted = (addon) => {
const addonProduct = getAddonProduct(addon);
return isProductRestrictedForCustomer({
return getCustomerProductRestriction({
...addonProduct,
name: getAddonName(addon) || addonProduct.name,
category: addonProduct.category ?? addon?.category,
}, customerAttributesSafe.value);
}, 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 = () => {
return isProductRestrictedForCustomer({
return getCustomerProductRestriction({
...props.product,
name: props.name || props.product?.name,
}, customerAttributesSafe.value);
}, customerAttributesSafe.value).restricted;
};
const orderByOrderPriority = (addons) => {
@@ -227,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"
@@ -287,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>
@@ -349,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
@@ -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
@@ -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>
@@ -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>
@@ -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"
@@ -651,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)"
@@ -669,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,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 -->
@@ -6,23 +6,28 @@ 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: {
@@ -81,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()),
}),
};
@@ -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">
@@ -112,13 +112,24 @@ if (router.currentRoute.value.params.departmentId) {
}
const isImportLoading = ref(false);
const buildImportUsageParams = () => {
if (!props.inheritPeriodFilters) {
return {};
}
return {
...(props.initialDateFrom ? { dateFrom: props.initialDateFrom } : {}),
...(props.initialDateTo ? { dateTo: props.initialDateTo } : {}),
};
};
const importUsageButton = async () => {
isImportLoading.value = true;
try {
await SessionUser.request(
'/modules/xlvask/tasks/import-usage',
'GET',
{},
buildImportUsageParams(),
function (error) {
// This function is called when the request fails
console.error("Failed to import orders.", error);
@@ -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 BuefyDateField from "@/components/forms/BuefyDateField.vue";
import { useI18n } from 'vue-i18n'
import { formatLocalDateOnly, parseLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
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 = formatLocalDateOnly(newSelectionStartDate);
const formattedEndDate = formatLocalDateOnly(newSelectionToDate);
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: parseLocalDateOnly(date_from || todayLocalDateOnly()), endDate: parseLocalDateOnly(date_to || todayLocalDateOnly()) }"/>
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"
@@ -62,7 +62,7 @@ if (props.autoLoad) {
</div>
</div>
</template>
<UsersTable :objects="list" />
<UsersTable :objects="list" @migrated="loadList" />
</TableLabeledPagination>
</template>
@@ -25,6 +25,11 @@ const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/u
const canAddVehicle = computed(() => SessionUser.canAccessCustomerFeature("vehicles", "add"));
const props = defineProps({
endpoint: {
type: String,
required: false,
default: "/vehicles",
},
customer_id: {
type: Number,
required: false,
@@ -35,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>
@@ -75,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,13 +1,16 @@
<script setup lang="ts">
import PaginationDisplayItemColumn from "@/components/displays/pagination/PaginationDisplayItemColumn.vue";
import { computed } from "vue";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
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>({
@@ -19,27 +22,20 @@ defineExpose({
date,
});
const formattedDate = computed(() => {
return formatLocalDateOnly(date.value);
});
// 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 = parseLocalDateOnly(e.target.value);
emit('update:date', newDate);
}"
<BuefyDateField
v-model="date"
value-type="date"
data-testid="pagination-date-picker"
:placeholder="props.placeholder"
@change="updateDate"
/>
</template>
</PaginationDisplayItemColumn>
@@ -9,6 +9,8 @@ import PaginationDisplayTemplateButton
from "@/components/displays/pagination/templates/PaginationDisplayTemplateButton.vue";
import { datePresets , dateFunctions} from '@/components/displays/pagination/PaginationDisplayDates.vue';
const DATE_FILTER_PLACEHOLDER = "--.--.----";
defineProps({
startDate: {
type: Date,
@@ -47,6 +49,23 @@ 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>
<template>
@@ -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>
@@ -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>
@@ -1117,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;
}
@@ -1131,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;
@@ -1140,7 +1142,7 @@ function modulePath(key) {
.module-card__top .status-pill {
justify-self: end;
align-self: start;
margin-left: 0;
}
.gateway-card__title {
@@ -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">
@@ -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,53 +18,165 @@ defineProps({
},
});
const emit = defineEmits(["migrated"]);
const editUser = (user) => {
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id, {
limitedBackofficeManaged: Boolean(user.limited_backoffice_managed),
});
};
const escapeHtml = (value) =>
String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const canMigrateToLimitedBackoffice = (user) =>
Number(user?.customer_number) === 0 && !Boolean(user?.limited_backoffice_managed);
const migrateUser = async (user) => {
try {
const [departmentsResponse, rolesResponse] = await Promise.all([
getLimitedBackofficeDepartments(),
getLimitedBackofficeRoles(),
]);
const departments = unwrapLimitedBackofficeResponse(departmentsResponse) || [];
const roles = unwrapLimitedBackofficeResponse(rolesResponse) || [];
if (!Array.isArray(departments) || departments.length === 0 || !Array.isArray(roles) || roles.length === 0) {
throw new Error("Limited backoffice roles or departments are unavailable.");
}
const roleOptions = roles
.filter((role) => role?.key && role?.key !== "superuser")
.map((role) => {
const selected = role.key === "cashier" ? " selected" : "";
return `<option value="${escapeHtml(role.key)}"${selected}>${escapeHtml(role.label || role.key)}</option>`;
})
.join("");
const departmentOptions = departments
.map(
(department) => `
<label class="checkbox is-block has-text-left mb-2">
<input type="checkbox" class="limited-migration-department" value="${escapeHtml(department.id)}" />
${escapeHtml(department.name || `Department ${department.id}`)}
</label>
`
)
.join("");
const result = await Swal.fire({
title: "Migrate employee",
html: `
<div class="field has-text-left">
<label class="label">Employee</label>
<p>${escapeHtml(user.display_name || `#${user.id}`)}</p>
</div>
<div class="field has-text-left">
<label class="label" for="limited-migration-role">Role</label>
<div class="control">
<div class="select is-fullwidth">
<select id="limited-migration-role">${roleOptions}</select>
</div>
</div>
</div>
<div class="field has-text-left">
<label class="label">Departments</label>
<div class="control">${departmentOptions}</div>
</div>
`,
showCancelButton: true,
confirmButtonText: "Migrate",
preConfirm: async () => {
const role = document.getElementById("limited-migration-role")?.value;
const departmentIds = Array.from(document.querySelectorAll(".limited-migration-department:checked")).map(
(checkbox) => Number(checkbox.value)
);
if (!role || departmentIds.length === 0) {
Swal.showValidationMessage("Select a role and at least one department.");
return false;
}
try {
await migrateLimitedBackofficeEmployee(user.id, {
role_key: role,
department_ids: departmentIds,
});
return true;
} catch (error) {
Swal.showValidationMessage(limitedBackofficeErrorMessage(error, "Could not migrate employee."));
return false;
}
},
});
if (result.isConfirmed) {
await Swal.fire("Employee migrated");
emit("migrated");
}
} catch (error) {
await Swal.fire("Error", limitedBackofficeErrorMessage(error, "Could not migrate employee."), "error");
}
};
</script>
<template>
<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>
@@ -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 = () => {
@@ -49,10 +59,15 @@ const canViewVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "l
const canEditVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "edit");
const canDeleteVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "delete");
const canShowVehicleRowActions = () => canViewVehicle() || canDeleteVehicle();
const isUserScoped = () => Boolean(props.userScopedUserId);
const redirectUserVehiclePage = (vehicleId) => {
// Send the user to the vehicle page
window.location.href = `/user/vehicles/${vehicleId}`;
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)
@@ -60,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) {
@@ -79,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;
};
@@ -122,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>
@@ -150,7 +177,7 @@ const getProductOptionsLabel = (vehicle) => {
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:editFunction="editVehicleField"
:permission-check-function="canEditVehicle"
column="reg"
/>
@@ -158,7 +185,7 @@ const getProductOptionsLabel = (vehicle) => {
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:editFunction="editVehicleField"
:permission-check-function="canEditVehicle"
column="type"
:parse-function="
@@ -171,7 +198,7 @@ const getProductOptionsLabel = (vehicle) => {
<EditableTableColumn
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:editFunction="editVehicleField"
:permission-check-function="canEditVehicle"
column="wash_subscription"
:parse-function="
@@ -225,7 +252,7 @@ const getProductOptionsLabel = (vehicle) => {
v-if="!props.compact"
:object="object"
:loadList="reload"
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
:editFunction="editVehicleField"
:permission-check-function="canEditVehicle"
column="reference"
/>
@@ -244,7 +271,7 @@ const getProductOptionsLabel = (vehicle) => {
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
@@ -252,9 +279,7 @@ const getProductOptionsLabel = (vehicle) => {
: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>
@@ -1,14 +1,17 @@
<script setup>
import { onMounted, ref, watch, nextTick, computed } from 'vue';
import {
clearPendingOrderItems,
getOrderId,
getProductsCategory,
getAddonRestriction,
getProductRestriction,
loadOrderItems,
productCategoryAllowed,
isProductRestricted,
setProductsCategory,
getUserProductPrice,
showFakeCreateOrderItem,
showPendingCreateOrderItem,
department_id,
user_discounts,
getUserGlobalDiscount,
@@ -21,6 +24,7 @@ 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";
@@ -29,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,
@@ -199,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) {
@@ -216,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',
@@ -246,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()
@@ -384,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
);
@@ -406,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));
}
};
@@ -428,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: {
@@ -479,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()
});
@@ -496,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', {
@@ -507,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([]);
@@ -556,11 +703,16 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
}
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 = 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);
}
@@ -604,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();
};
+66 -28
View File
@@ -1,37 +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";
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 lockedRoleFields = options.limitedBackofficeManaged ? 'disabled' : '';
const isRoleLocked = Boolean(options.limitedBackofficeManaged);
const lockedRoleFields = isRoleLocked ? "disabled" : "";
await Swal.fire({
title: 'Edit User',
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}" ${lockedRoleFields} />
<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}" ${lockedRoleFields} />
<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>
@@ -39,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;
}
},
});
}
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;
}
-52
View File
@@ -1,52 +0,0 @@
<script setup>
import logo from "@/assets/branding/truckwash-banner-white-compressed.png";
import { showAuthSignOutForm } from "@/components/forms/auth/authSignOutForm.vue";
import { useRouter } from "vue-router";
import RoundedUserButton from "@/components/displays/userbuttons/RoundedUserButton.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const router = useRouter();
const doesRouteStartWith = (route) => {
return router.currentRoute.value.path.startsWith(route);
}
</script>
<template>
<div class="columns my-1 is-mobile is-multiline">
<div class="column is-half-desktop is-2-touch is-hidden-mobile is-align-content-center">
<!-- Logo -->
<img :src="logo"
class="image"
alt="Company brand banner"
width="112"
height="28">
</div>
<!-- User navigation -->
<div class="column is-half-desktop is-11-mobile is-align-items-center">
<div class="buttons is-float-right">
<RoundedUserButton icon="fa-cogs" @click="router.push('/superuser')" :disabled="doesRouteStartWith('/superuser')" v-if="SessionUser.canAccessSuperUser()">
System
</RoundedUserButton>
<RoundedUserButton icon="fa-user-tie" @click="router.push('/admin')" :disabled="doesRouteStartWith('/admin')" v-if="SessionUser.canAccessAdmin()">
Medarbejder
</RoundedUserButton>
<RoundedUserButton icon="fa-user" @click="router.push('/user')" :disabled="doesRouteStartWith('/user')" v-if="SessionUser.canAccessUser()">
Kunde
</RoundedUserButton>
<RoundedUserButton icon="fa-sign-out-alt" @click="showAuthSignOutForm">
Log ud
</RoundedUserButton>
<!-- If the user has a superuserToken stored, show the go back button -->
<RoundedUserButton icon="fa-arrow-left" @click="SessionUser.superUser.intimidate.goBack()" v-if="SessionUser.superUser.intimidate.isIntimidated()">
Tilbage
</RoundedUserButton>
</div>
</div>
</div>
</template>
<style scoped>
</style>
+9 -2
View File
@@ -3,8 +3,10 @@ import MenuDefault from "@/components/menus/MenuDefault.vue";
import { ref, watch, onMounted } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useRoute } from "vue-router";
import i18n from "@/i18n";
import { isAccessibleVisibleNamedDepartment, sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.js";
import { todayLocalDateOnly } from "@/services/dateOnly.js";
const t = (key) => i18n.global.t(key);
// Get the department ID from the URL
const route = useRoute();
const department_id = route.params.departmentId || SessionUser.functions.getDepartmentIdFromUrl() || null;
@@ -145,6 +147,11 @@ const checkEmphasis = (forcefully = false) => {
}
};
const getBookingsLabel = () =>
t("nav.bookings")
|| SessionUser.objects.order_bookings?.meta?.title
|| "Bookings";
checkEmphasis(); // Initial check for emphasis
// Call the checkEmphasis function on mounted
@@ -228,7 +235,7 @@ const menu_items = ref([
hidden: SessionUser.functions.getDepartmentIdFromUrl() === undefined,
},
{
name: "Bookinger",
name: getBookingsLabel(),
route: "/admin/" + SessionUser.functions.getDepartmentIdFromUrl() + "/modules/bookings",
icon: "fas fa-exchange-alt",
children: [],
@@ -250,7 +257,7 @@ const menu_items = ref([
permissions: ["department_timebookings_create"],
},
{
name: "Bookinger",
name: getBookingsLabel(),
route: "/admin/" + SessionUser.functions.getDepartmentIdFromUrl() + "/modules/time-bookings",
icon: "fas fa-exchange-alt",
children: [],
@@ -463,6 +463,17 @@ const items = computed<NavigationItemProps[]>(() => [
},
],
},
// System
{
label: t("cron.system_nav"),
type: "category",
children: [
{ label: t("system_status.title"), to: "/superuser" },
{ label: t("cron.nav"), to: "/superuser/system/cron" },
{ label: t("system_status.cards.database"), to: "/superuser/system/database" },
{ label: t("cron.replication"), to: "/superuser/system/replication" },
],
},
// Other
{
label: t("superuser.nav.other"),
+1 -1
View File
@@ -279,7 +279,7 @@ export function usePaginatedList() {
filter.value = filter.value ? `${filter.value},${newFilter}:${value}` : `${newFilter}:${value}`;
}
// Remove duplicates
filter.value = [...new Set(filter.value.split(','))].join(',');
filter.value = filter.value ? [...new Set(filter.value.split(','))].join(',') : null;
// If the filter is empty, set it to null
if (filter.value === '') {
filter.value = null;
@@ -1,17 +1,25 @@
<script setup>
const props = defineProps(['invoice_id'])
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { handleEconomicError } from "@/components/request/HandleEconomicError.vue";
import { ref } from "vue";
import { computed } from "vue";
const props = defineProps(["invoice_id"]);
const hasInvoice = computed(() => {
return props.invoice_id !== null && props.invoice_id !== undefined && String(props.invoice_id).trim() !== "";
});
const getOrderPDF = async () => {
await authenticatedRequest('/invoices/pdf?id=' + props.invoice_id,
'GET',{
}) .then((response) => {
if (!hasInvoice.value) {
return;
}
await authenticatedRequest("/invoices/pdf", "GET", {
id: props.invoice_id,
}).then((response) => {
// Open the PDF in a new tab
window.open(response.data.data.url, '_blank');
window.open(response.data.data.url, "_blank");
}).catch((error) => {
handleEconomicError(error);
});
@@ -19,9 +27,17 @@ const getOrderPDF = async () => {
</script>
<template>
<LoadButtonWhileAwait class="is-dark" :loadFunction="getOrderPDF" icon="fas fa-file-pdf"> Hent faktura PDF </LoadButtonWhileAwait>
<LoadButtonWhileAwait
class="is-dark"
:loadFunction="getOrderPDF"
:disabled="!hasInvoice"
actionKey="economic-invoice-pdf-download"
icon="fas fa-file-pdf"
>
Hent faktura PDF
</LoadButtonWhileAwait>
</template>
<style scoped>
</style>
</style>
@@ -218,6 +218,7 @@ const buildRequestQueueOptions = (url, method, options = {}) => {
recordRecentOnSuccess: options?.recordRecentOnSuccess,
trackActiveRequest: options?.trackActiveRequest,
trackProgressCounters: options?.trackProgressCounters,
traceContext: options?.traceContext,
};
if (isSelfServeHardwareMutation(url, method)) {
@@ -1,291 +1,150 @@
<script>
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import i18n from "@/i18n";
const t = (key) => i18n.global.t(key);
/**
* The Branding object
*/
export const Branding = {
meta: {
title: t("objects.branding.title"),
icon: "fas fa-list",
description: t("objects.branding.description"),
endpoint: "/branding",
labels: {
single: t("objects.branding.single"),
multiple: t("objects.branding.multiple"),
},
},
columns: {
id: {
label: t("objects.columns.id"),
type: "number",
sortable: true,
creation: { required: false },
},
name: {
label: t("objects.columns.name"),
type: "string",
sortable: true,
creation: { required: true },
},
description: {
label: t("objects.columns.description"),
type: "string",
sortable: true,
creation: { required: true },
},
cvr: {
label: t("user_admin.cvr"),
type: "number",
sortable: true,
creation: { required: true },
},
address: {
label: t("objects.columns.address"),
type: "string",
sortable: true,
creation: { required: false },
},
phone_country_code: {
label: t("global.phone_country_code"),
type: "number",
sortable: true,
creation: { required: false },
},
phone: {
label: t("objects.columns.phone"),
type: "number",
sortable: true,
creation: { required: false },
},
email: {
label: t("objects.columns.email"),
type: "string",
sortable: true,
creation: { required: false },
},
website: {
label: t("tables.common.website"),
type: "string",
sortable: true,
creation: { required: false },
},
banner: {
label: t("objects.columns.image"),
type: "string",
sortable: false,
creation: { required: false },
},
logo: {
label: t("objects.columns.image"),
type: "string",
sortable: false,
creation: { required: false },
},
favicon: {
label: t("objects.columns.image"),
type: "string",
sortable: false,
creation: { required: false },
},
signature: {
label: t("objects.columns.notes"),
type: "string",
sortable: false,
creation: { required: false },
},
},
add: async (brandingOrName, description = null, cvr = null) => {
const payload =
typeof brandingOrName === "object" && brandingOrName !== null
? brandingOrName
: {
name: brandingOrName,
description,
cvr,
};
/**
* The Branding object
*/
export const Branding = {
meta: {
title: "Branding",
icon: "fas fa-list",
description: "Oversigt over branding",
endpoint: "/branding",
labels: {
single: "branding",
multiple: "brands",
}
},
columns: {
id: {
label: "ID",
type: "number",
sortable: true,
creation: {
required: false
}
},
name: {
label: "Navn",
type: "string",
sortable: true,
creation: {
required: true
},
},
description: {
label: "Beskrivelse",
type: "string",
sortable: true,
creation: {
required: true
},
},
cvr: {
label: "CVR",
type: "number",
sortable: true,
creation: {
required: true
},
},
address: {
label: "Adresse",
type: "string",
sortable: true,
creation: {
required: false
},
},
phone_country_code: {
label: "Landekode",
type: "number",
sortable: true,
creation: {
required: false
},
},
phone: {
label: "Telefonnummer",
type: "number",
sortable: true,
creation: {
required: false
},
},
email: {
label: "Email",
type: "string",
sortable: true,
creation: {
required: false
},
},
website: {
label: "Hjemmeside",
type: "string",
sortable: true,
creation: {
required: false
},
},
banner: {
label: "Banner",
type: "string",
sortable: false,
creation: {
required: false
},
},
logo: {
label: "Logo",
type: "string",
sortable: false,
creation: {
required: false
},
},
favicon: {
label: "Favicon",
type: "string",
sortable: false,
creation: {
required: false
},
},
signature: {
label: "Signatur",
type: "string",
sortable: false,
creation: {
required: false
},
},
},
add: async (brandingOrName, description = null, cvr = null) => {
const payload = typeof brandingOrName === "object" && brandingOrName !== null
? brandingOrName
: {
name: brandingOrName,
description: description,
cvr: cvr
};
return ObjectsGlobal.add.object(
Branding.meta.endpoint,
payload
).then((response) => response.data.data);
},
set: {
all: async (id, branding) => {
ObjectsGlobal.clearCache();
return authenticatedRequest(
Branding.meta.endpoint,
"PUT",
{
id: parseInt(id),
...branding,
}
).then((response) => {
ObjectsGlobal.clearCache();
return response.data.data;
});
},
name: async (id, name) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"name",
name
)
},
description: async (id, description) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"description",
description
)
},
cvr: async (id, cvr) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"cvr",
parseInt(cvr)
)
},
address: async (id, address) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"address",
address
)
},
phone_country_code: async (id, phone_country_code) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"phone_country_code",
parseInt(phone_country_code)
)
},
phone: async (id, phone) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"phone",
parseInt(phone)
)
},
email: async (id, email) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"email",
email
)
},
website: async (id, website) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"website",
website
)
},
banner: async (id, banner) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"banner",
banner
)
},
logo: async (id, logo) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"logo",
logo
)
},
favicon: async (id, favicon) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"favicon",
favicon
)
},
signature: async (id, signature) => {
return ObjectsGlobal.set.column(
Branding.meta.endpoint,
id,
"signature",
signature
)
},
},
get: {
all: async () => {
return ObjectsGlobal.get.objects(Branding.meta.endpoint);
},
single: async (id) => {
return ObjectsGlobal.get.object(Branding.meta.endpoint, parseInt(id));
}
},
delete: async (id) => {
return ObjectsGlobal.delete.object(Branding.meta.endpoint, parseInt(id));
},
functions: {
},
/**
* Show the create object form
* @param onAfterSubmit
* @returns {Promise<SweetAlertResult<Awaited<any>>>}
*/
showCreateObjectForm: (onAfterSubmit = null) => {
return ObjectsGlobal.showCreateObjectForm(Branding, onAfterSubmit);
},
/**
* Show the edit object field form
* @param id
* @param column
* @param value
* @param onAfterSubmit
* @returns {Promise<SweetAlertResult<Awaited<any>>>}
*/
showEditObjectFieldForm: (id, column, value, onAfterSubmit = null) => {
return ObjectsGlobal.showEditObjectFieldForm(
Branding,
id,
column,
value,
onAfterSubmit
);
}
};
return ObjectsGlobal.add.object(Branding.meta.endpoint, payload).then((response) => response.data.data);
},
set: {
all: async (id, branding) => {
ObjectsGlobal.clearCache();
return authenticatedRequest(Branding.meta.endpoint, "PUT", {
id: parseInt(id, 10),
...branding,
}).then((response) => {
ObjectsGlobal.clearCache();
return response.data.data;
});
},
name: async (id, name) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "name", name),
description: async (id, description) =>
ObjectsGlobal.set.column(Branding.meta.endpoint, id, "description", description),
cvr: async (id, cvr) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "cvr", parseInt(cvr, 10)),
address: async (id, address) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "address", address),
phone_country_code: async (id, phone_country_code) =>
ObjectsGlobal.set.column(Branding.meta.endpoint, id, "phone_country_code", parseInt(phone_country_code, 10)),
phone: async (id, phone) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "phone", parseInt(phone, 10)),
email: async (id, email) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "email", email),
website: async (id, website) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "website", website),
banner: async (id, banner) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "banner", banner),
logo: async (id, logo) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "logo", logo),
favicon: async (id, favicon) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "favicon", favicon),
signature: async (id, signature) => ObjectsGlobal.set.column(Branding.meta.endpoint, id, "signature", signature),
},
get: {
all: async () => ObjectsGlobal.get.objects(Branding.meta.endpoint),
single: async (id) => ObjectsGlobal.get.object(Branding.meta.endpoint, parseInt(id, 10)),
},
delete: async (id) => ObjectsGlobal.delete.object(Branding.meta.endpoint, parseInt(id, 10)),
functions: {},
showCreateObjectForm: (onAfterSubmit = null) => ObjectsGlobal.showCreateObjectForm(Branding, onAfterSubmit),
showEditObjectFieldForm: (id, column, value, onAfterSubmit = null) =>
ObjectsGlobal.showEditObjectFieldForm(Branding, id, column, value, onAfterSubmit),
};
</script>
@@ -3,7 +3,7 @@ import Swal from "sweetalert2";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {ref} from "vue";
import {createApp, h, ref, watch} from "vue";
import i18n from '@/i18n';
import {
buildCollectedInvoiceEconomicPayload,
@@ -16,8 +16,8 @@ import {
retryEconomicTransferJob,
} from "@/services/economicTransferQueue.js";
import {createApp} from "vue";
import PickCustomerInvoiceCollectionModal from "@/components/displays/modals/PickCustomerInvoiceCollectionModal.vue";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
const t = (key) => i18n.global.t(key);
@@ -177,16 +177,63 @@ const normalizeClosedAtForApi = (value) => {
return `${parsedDate.getFullYear()}-${padTwoDigits(parsedDate.getMonth() + 1)}-${padTwoDigits(parsedDate.getDate())}`;
};
const mountSweetAlertDateField = ({ mountId, inputId, initialValue, dataTestid }) => {
const mountElement = document.getElementById(mountId);
const inputElement = document.getElementById(inputId);
if (!mountElement || !inputElement) {
return {
cleanup: () => {},
setValue: () => {},
};
}
const value = ref(initialValue || "");
const app = createApp({
setup() {
watch(value, (nextValue) => {
inputElement.value = nextValue || "";
}, { immediate: true });
return () => h(BuefyDateField, {
modelValue: value.value,
valueType: "string",
dataTestid,
"onUpdate:modelValue": (nextValue) => {
value.value = nextValue || "";
},
});
},
});
app.use(i18n);
app.mount(mountElement);
return {
cleanup: () => {
app.unmount();
},
setValue: (nextValue) => {
const normalizedValue = nextValue || "";
value.value = normalizedValue;
inputElement.value = normalizedValue;
},
};
};
const showEditClosedAtObjectFieldForm = async (id, value, onAfterSubmit = null) => {
const inputId = 'collected-order-invoice-closed-at-input';
const pickerId = 'collected-order-invoice-closed-at-picker';
const clearButtonId = 'collected-order-invoice-closed-at-clear-button';
let dateField = null;
return Swal.fire({
title: ObjectsGlobal.language.field(CollectedOrderInvoices, 'closed_at'),
html: `<div class="field">
<label class="label has-text-black">${CollectedOrderInvoices.columns.closed_at.label}</label>
<div class="control mb-3">
<input class="input has-background-light has-text-black" type="date" id="${inputId}" value="${normalizeClosedAtForDateInput(value)}">
<input type="hidden" id="${inputId}" value="${normalizeClosedAtForDateInput(value)}">
<div id="${pickerId}" data-testid="collected-order-invoice-closed-at-picker"></div>
</div>
<div class="control">
<button id="${clearButtonId}" type="button" class="button is-light is-small">${ObjectsGlobal.language.clear}</button>
@@ -196,14 +243,23 @@ const showEditClosedAtObjectFieldForm = async (id, value, onAfterSubmit = null)
confirmButtonText: ObjectsGlobal.language.save,
cancelButtonText: ObjectsGlobal.language.cancel,
didOpen: () => {
dateField = mountSweetAlertDateField({
mountId: pickerId,
inputId,
initialValue: normalizeClosedAtForDateInput(value),
dataTestid: "collected-order-invoice-closed-at-input",
});
const clearButton = document.getElementById(clearButtonId);
const inputElement = document.getElementById(inputId);
if (clearButton && inputElement) {
if (clearButton) {
clearButton.addEventListener('click', () => {
inputElement.value = '';
dateField?.setValue('');
});
}
},
willClose: () => {
dateField?.cleanup();
dateField = null;
},
preConfirm: () => {
const inputElement = document.getElementById(inputId);
if (!inputElement) {
@@ -460,6 +516,43 @@ export const CollectedOrderInvoices = {
throw error;
});
},
bulk_action_preview: async (action, invoiceCollectionIds = [], options = {}, locale = null) => {
return authenticatedRequest('/collected-invoices/bulk-actions/preview', 'POST', {
action,
invoice_collection_ids: invoiceCollectionIds.map((id) => parseInt(id)).filter((id) => Number.isInteger(id) && id > 0),
options,
...(locale ? { locale } : {}),
}).then((response) => {
console.log(response);
return response;
}).catch((error) => {
console.log(error);
throw error;
});
},
bulk_action_apply: async ({
preview_id,
action,
invoice_collection_ids = [],
options = {},
confirmation_text,
locale = null,
}) => {
return authenticatedRequest('/collected-invoices/bulk-actions/apply', 'POST', {
preview_id,
action,
invoice_collection_ids: invoice_collection_ids.map((id) => parseInt(id)).filter((id) => Number.isInteger(id) && id > 0),
options,
confirmation_text,
...(locale ? { locale } : {}),
}).then((response) => {
console.log(response);
return response;
}).catch((error) => {
console.log(error);
throw error;
});
},
move_to_customer: async (id, customerNumber) => {
const invoiceCollectionId = parseInt(id);
const targetCustomerNumber = parseInt(customerNumber);
@@ -1,7 +1,9 @@
<script>
import Swal from "sweetalert2";
import { createApp, h, ref, watch } from "vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
import i18n from "@/i18n";
import {
COMPLAINT_CUSTOMER_SEARCH_DEBOUNCE_MS,
@@ -23,6 +25,8 @@ const EDIT_COMPLAINT_CUSTOMER_RESULTS_WRAPPER_ID = "superuser-complaint-customer
const EDIT_COMPLAINT_CUSTOMER_RESULTS_ID = "superuser-complaint-customer-results";
const EDIT_COMPLAINT_CUSTOMER_HELP_ID = "superuser-complaint-customer-help";
const EDIT_COMPLAINT_CUSTOMER_CLEAR_ID = "superuser-complaint-customer-clear";
const EDIT_COMPLAINT_WASH_DATE_INPUT_ID = "superuser-complaint-wash-date";
const EDIT_COMPLAINT_WASH_DATE_PICKER_ID = "superuser-complaint-wash-date-picker";
const EDIT_COMPLAINT_CUSTOMER_SEARCH_WRAPPER_STYLE = "position: relative;";
const EDIT_COMPLAINT_CUSTOMER_RESULTS_WRAPPER_STYLE = "position: absolute; top: calc(100% + 4px); left: 0; right: 0; width: 100%; z-index: 30;";
const EDIT_COMPLAINT_CUSTOMER_RESULTS_MENU_STYLE = "display: block; position: static; width: 100%; padding-top: 0;";
@@ -67,6 +71,41 @@ const initialComplaintCustomerSelection = (complaint) => normalizeComplaintCusto
const initialComplaintWashDate = (complaint) => String(complaint?.wash_date ?? "").trim();
const initialComplaintCategory = (complaint) => String(complaint?.category ?? "").trim();
const mountSweetAlertDateField = ({ mountId, inputId, initialValue, dataTestid }) => {
const mountElement = document.getElementById(mountId);
const inputElement = document.getElementById(inputId);
if (!mountElement || !inputElement) {
return () => {};
}
const app = createApp({
setup() {
const value = ref(initialValue || "");
watch(value, (nextValue) => {
inputElement.value = nextValue || "";
}, { immediate: true });
return () => h(BuefyDateField, {
modelValue: value.value,
valueType: "string",
dataTestid,
"onUpdate:modelValue": (nextValue) => {
value.value = nextValue || "";
},
});
},
});
app.use(i18n);
app.mount(mountElement);
return () => {
app.unmount();
};
};
const buildEditComplaintForm = (complaint, departments) => {
const selectedCustomer = initialComplaintCustomerSelection(complaint);
const selectedCustomerLabel = formatComplaintCustomerLabel(selectedCustomer);
@@ -136,15 +175,14 @@ const buildEditComplaintForm = (complaint, departments) => {
></p>
</div>
<div class="field">
<label class="label has-text-black" for="superuser-complaint-wash-date">Dato for vask</label>
<label class="label has-text-black" for="${EDIT_COMPLAINT_WASH_DATE_INPUT_ID}">Dato for vask</label>
<div class="control">
<input
id="superuser-complaint-wash-date"
data-testid="superuser-complaint-edit-wash-date"
class="input has-background-light has-text-black"
type="date"
id="${EDIT_COMPLAINT_WASH_DATE_INPUT_ID}"
type="hidden"
value="${escapeHtml(washDate)}"
>
<div id="${EDIT_COMPLAINT_WASH_DATE_PICKER_ID}" data-testid="superuser-complaint-edit-wash-date"></div>
</div>
</div>
<div class="field">
@@ -497,6 +535,7 @@ export const DepartmentDailyReportComplaints = {
);
const customerLookupState = createComplaintCustomerLookupState(complaint);
let cleanupCustomerLookup = null;
let cleanupDateField = null;
return Swal.fire({
title: t("superuser.pages.complaints.edit_title"),
@@ -507,12 +546,22 @@ export const DepartmentDailyReportComplaints = {
focusConfirm: false,
didOpen: () => {
cleanupCustomerLookup = setupComplaintCustomerLookupField(customerLookupState);
cleanupDateField = mountSweetAlertDateField({
mountId: EDIT_COMPLAINT_WASH_DATE_PICKER_ID,
inputId: EDIT_COMPLAINT_WASH_DATE_INPUT_ID,
initialValue: initialComplaintWashDate(complaint),
dataTestid: "superuser-complaint-edit-wash-date-picker",
});
},
willClose: () => {
if (typeof cleanupCustomerLookup === "function") {
cleanupCustomerLookup();
cleanupCustomerLookup = null;
}
if (typeof cleanupDateField === "function") {
cleanupDateField();
cleanupDateField = null;
}
},
preConfirm: async () => {
try {
@@ -525,7 +574,7 @@ export const DepartmentDailyReportComplaints = {
}
const washDate = (
document.getElementById("superuser-complaint-wash-date")?.value || ""
document.getElementById(EDIT_COMPLAINT_WASH_DATE_INPUT_ID)?.value || ""
).trim();
if (washDate === "") {
throw new Error("Dato for vask er påkrævet.");
@@ -164,6 +164,21 @@ const t = (key) => i18n.global.t(key);
}
);
},
setProductTarget: async ({ department_id, product_id, target_percentage }) => {
const normalizedTarget = target_percentage === null || target_percentage === undefined || target_percentage === ""
? null
: Number(target_percentage);
return authenticatedRequest(
"/departments/daily-reports/product-targets",
"PUT",
{
department_id: parseInt(department_id, 10),
product_id: parseInt(product_id, 10),
target_percentage: normalizedTarget,
}
);
},
createComplaint: async ({
department_id,
customer_number = null,

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