Compare commits

..
Author SHA1 Message Date
Jeppe Bundgaard 85be8d2ce7 Fix Android launcher icon assets
Regenerate Android launcher icons from the blue striped source asset.
Add a repeatable generator and README documentation for the icon workflow.
2026-07-09 10:52:56 +02:00
Jeppe Bundgaard b5fb8c8cac Fix frontend master CI runner failures 2026-07-09 10:50:11 +02:00
Jeppe Bundgaard 71b7ec4987 Fix invoicing period department 75 table overflow 2026-07-08 20:38:39 +02:00
Jeppe Bundgaard ad110d46d9 Fix POS order table primary registration display 2026-07-08 19:23:20 +02:00
Jeppe B dabe0c13ef Format mobile POS addon quantity update 2026-07-08 18:52:23 +02:00
Jeppe B a5dbfb5cba Fix mobile POS additional item product selection 2026-07-08 18:48:44 +02:00
Jeppe B 75c4700ac6 Fix mobile POS addon quantity updates 2026-07-08 18:47:08 +02:00
Jeppe B 19ce60fd0a Fix orders table formatting and unit expectation 2026-07-08 17:02:59 +02:00
Jeppe Bundgaard d26933b722 Enhance orders table with compact text cells and add test for font size consistency 2026-07-08 16:57:13 +02:00
Jeppe B 30dfe2c0c0 Stabilize frontend E2E selectors 2026-07-08 16:53:52 +02:00
Jeppe Bundgaard fa55bcd5f3 Add date cell to orders table and update styling for better visibility 2026-07-08 16:01:27 +02:00
Jeppe B fbc1dc9406 Update view i18n dynamic key allowlist 2026-07-08 15:54:41 +02:00
Jeppe B 4a4b82c8b6 Format invoice bulk actions changes 2026-07-08 15:52:06 +02:00
Jeppe Bundgaard 042e477252 Add invoice collection bulk actions and table header preferences functionality 2026-07-08 15:43:14 +02:00
Jeppe B d1d7b72441 Format duplicate-date E2E assertion 2026-07-08 14:56:29 +02:00
Jeppe B 48d8d7f527 Stabilize mobile superuser E2E assertions 2026-07-08 14:54:17 +02:00
Jeppe B 73a71e5262 Sync i18n v2 generated runtime files 2026-07-08 14:01:10 +02:00
Jeppe B cab779e403 Align Danish vehicle action unit expectation 2026-07-08 13:54:09 +02:00
Jeppe Bundgaard e51e874264 Merge remote-tracking branch 'origin/master' 2026-07-08 13:52:15 +02:00
Jeppe Bundgaard e399c9f974 Add bulk action preview and apply functionality for invoice collections 2026-07-08 13:51:54 +02:00
Jeppe B acd46e5f2a Fix i18n locale integrity coverage 2026-07-08 13:43:37 +02:00
Jeppe B 59af8453f3 Fix i18n word dedupe casing variants 2026-07-08 13:04:41 +02:00
Jeppe Bundgaard d03f4d9aae Add subusers management features and enhance department navigation 2026-07-08 12:27:45 +02:00
Jeppe B 4cc2c3310c Fix frontend test gate expectations 2026-07-07 22:16:25 +02:00
Jeppe Bundgaard 1123b34e10 Enhance UI components and improve date selection functionality 2026-07-07 20:52:14 +02:00
Jeppe Bundgaard 8c12fce187 Add customer pricing labels and update date selection components 2026-07-07 18:16:45 +02:00
Jeppe Bundgaard a82176c855 Merge limited backoffice role template actions 2026-07-07 03:27:39 +02:00
Jeppe Bundgaard 7180bbd6d4 Add limited backoffice role template actions 2026-07-07 03:23:32 +02:00
Copilotandcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> b9b4539764 ci: run automated tests on main and dev pushes (#165)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-07 01:56:19 +02:00
Jeppe BandJeppe Bundgaard 96ccc01d08 Add limited backoffice employee migration (#164)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-07 00:14:05 +02:00
Jeppe Bundgaard 2108dd6c94 Send Selvvask import period dates
Include selected invoice-period dates when importing XLVask usage from the Selvvask period view.
2026-07-06 23:49:36 +02:00
Jeppe BandJeppe Bundgaard ca2003f2d6 Allow hosted runners for manual frontend tests (#161)
* Allow hosted runners for manual frontend tests

* Fix hosted full E2E i18n failures

* Stabilize release assignment E2E selection

* Gate full E2E on focused regressions

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 22:38:29 +02:00
Jeppe BandJeppe Bundgaard acbbac588f [codex] disable invoice downloads until invoices exist (#158)
* disable invoice downloads until invoices exist

* enable github-hosted frontend ci runners

* Update POS invoice download visual expectations

* Fix full E2E i18n and self-serve coverage

* Review dynamic view i18n keys

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 22:35:58 +02:00
Jeppe B f75ff97042 Merge pull request #139 from copenhagentruckwash/codex/backoffice-title-department-selector
[codex] Move backoffice department selector into title
2026-07-06 22:35:13 +02:00
Jeppe Bundgaard 4aa7af9716 Merge remote-tracking branch 'origin/master' into codex/backoffice-title-department-selector
# Conflicts:
#	.github/workflows/tests.yml
2026-07-06 22:34:43 +02:00
Jeppe BandJeppe Bundgaard d2682da3cc [codex] Improve admin notifications page UI (#145)
* Improve admin notifications page UI

* Stabilize notifications page E2E bootstrap wait

* Fix PR Playwright diff head ref

* Guard MyWashStart timers after test teardown

* Stabilize self-serve wash E2E timing

* Refresh edge gateway fixture runtime state

* Run Playwright E2E on GitHub-hosted runners

* Run all frontend CI on GitHub-hosted runners

* Increase hosted full E2E parallelism

* Stabilize full E2E validation

* Preserve superuser gateway navigation label

* Add targeted Playwright dispatch workflow

* Integrate targeted Playwright dispatch into tests workflow

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 22:33:40 +02:00
Jeppe Bundgaard aa1d2ab623 Merge remote-tracking branch 'origin/master' into codex/backoffice-title-department-selector
# Conflicts:
#	.github/workflows/tests.yml
#	src/i18n/generated/de-v2.json
#	src/i18n/generated/global-v2.json
#	src/i18n/generated/no-v2.json
#	src/i18n/generated/sv-v2.json
#	src/i18n/source/de/phrases/compat/departments/index.json
#	src/i18n/source/de/phrases/compat/objects/index.json
#	src/i18n/source/de/phrases/compat/vehicles/index.json
#	src/i18n/source/global/shared/superuser_dashboard/index.json
#	src/i18n/source/no/phrases/compat/departments/index.json
#	src/i18n/source/no/phrases/compat/objects/index.json
#	src/i18n/source/no/phrases/compat/vehicles/index.json
#	src/i18n/source/sv/phrases/compat/objects/index.json
#	src/i18n/source/sv/phrases/compat/vehicles/index.json
#	tests/e2e/self-serve-wash.spec.js
#	tests/unit/playwright-full-workflow.spec.js
#	tests/unit/playwright-pr-mapping.spec.js
2026-07-06 22:33:27 +02:00
Jeppe Bundgaard 929333d264 Add focused E2E dispatch scope 2026-07-06 22:21:30 +02:00
Jeppe B d2cbf823d8 Fix action wheel menu placement stability
Stabilizes the action settings wheel dropdown placement so the menu does not flip above/below after opening, and adds regression coverage plus hosted full-matrix workflow support.
2026-07-06 22:15:46 +02:00
Jeppe Bundgaard ba580e43e6 Align backoffice selector with title 2026-07-06 21:34:44 +02:00
Jeppe Bundgaard 3c49cec213 Review dynamic i18n view keys 2026-07-06 20:47:51 +02:00
Jeppe B c237fce38d Fix null product query parameters
Fixes product detail requests so null optional query params are omitted while final_price=false is preserved.
2026-07-06 20:14:45 +02:00
Jeppe Bundgaard 8acecc61cd Fix hosted full E2E catalog failures 2026-07-06 20:12:12 +02:00
Jeppe B 63149b7597 Merge pull request #162 from copenhagentruckwash/codex/daily-report-product-targets-ui
[codex] Add daily report product target controls
2026-07-06 19:36:03 +02:00
Jeppe Bundgaard a331eeadcb Speed up hosted full E2E dispatch 2026-07-06 19:27:37 +02:00
Jeppe Bundgaard 33e109b131 Fix rebased limited backoffice home handler 2026-07-06 19:27:37 +02:00
Jeppe Bundgaard 68b4328c72 Allow manual test runs on GitHub-hosted runners 2026-07-06 19:27:35 +02:00
Jeppe Bundgaard dd8a0a1952 Move backoffice department selector into title 2026-07-06 19:27:05 +02:00
364 changed files with 29335 additions and 9250 deletions
+242 -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
@@ -291,15 +514,21 @@ jobs:
if: >
always() &&
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
needs.build-and-unit.result == 'success' &&
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr]
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') &&
(
github.event_name != 'workflow_dispatch' ||
inputs.mode == 'full' ||
needs.e2e-targeted.result == 'success'
)
needs: [build-and-unit, e2e-pr, e2e-targeted]
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 2
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '1') }}
matrix:
browser: [chromium, webkit, firefox]
device: [mobile, desktop, tablet]
@@ -321,6 +550,7 @@ jobs:
PLAYWRIGHT_VIDEO_MODE: off
steps:
- name: Repair self-hosted workspace permissions
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
shell: bash
run: |
if [[ -d "$GITHUB_WORKSPACE" ]]; then
+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>
+1
View File
@@ -79,6 +79,7 @@
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jimp": "0.22.12",
"jsdom": "^29.0.0",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
+4 -1
View File
@@ -55,7 +55,9 @@
"twa:build": "bubblewrap build",
"twa:update": "bubblewrap update",
"mobile:sync": "npm run build && npx cap sync",
"mobile:android:sync": "npm run build && npx cap sync android",
"mobile:android:icons": "node scripts/mobile/generate-android-icons.mjs",
"mobile:android:icons:check": "node scripts/mobile/generate-android-icons.mjs --check",
"mobile:android:sync": "npm run mobile:android:icons && npm run build && npx cap sync android",
"mobile:permissions:check": "node scripts/mobile/check-permissions.mjs",
"mobile:android:signing:check": "node scripts/mobile/check-android-signing-env.mjs",
"mobile:android:bundle": "npm run mobile:android:signing:check && npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease",
@@ -135,6 +137,7 @@
"eslint-plugin-vue": "^10.9.2",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jimp": "0.22.12",
"jsdom": "^29.0.0",
"otpauth": "^9.5.0",
"prettier": "2.8.8",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 80 KiB

+18 -1
View File
@@ -275,7 +275,24 @@ const transformLocale = (locale, originalMessages) => {
(entry) => normalizeToken(entry.value) === normalized
);
if (existingWordEntry) {
tokenToWord.set(normalized, { path: `words.${existingWordEntry.key}`, value: existingWordEntry.value });
const word = {
path: `words.${existingWordEntry.key}`,
value: existingWordEntry.value,
variants: new Map(),
};
const casings = [...stat.casings.keys()].sort(compareStringValues);
for (const casing of casings) {
if (modifierForToken(casing, word) !== null) {
continue;
}
const variantKey = slugify(`${normalized}_${casing}`, usedKeys);
generatedWords[variantKey] = casing;
word.variants.set(casing, { path: `words.generated.${variantKey}`, value: casing });
}
tokenToWord.set(normalized, word);
continue;
}
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import Jimp from "jimp";
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const sourcePath = path.join(projectRoot, "public/favicons/web-app-manifest-512x512.png");
const checkOnly = process.argv.includes("--check");
const launcherBackground = "#0787BB";
const densityScale = {
mdpi: 1,
hdpi: 1.5,
xhdpi: 2,
xxhdpi: 3,
xxxhdpi: 4,
};
const targets = [
{ relativePath: "public/icons/icon-512x512.png", size: 512, copySource: true },
{ relativePath: "public/icons/icon-192x192.png", size: 192 },
{ relativePath: "store_icon.png", size: 512, copySource: true },
];
for (const [density, scale] of Object.entries(densityScale)) {
const legacySize = Math.round(48 * scale);
const foregroundSize = Math.round(108 * scale);
targets.push(
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher.png`,
size: legacySize,
},
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_round.png`,
size: legacySize,
},
{
relativePath: `android/app/src/main/res/mipmap-${density}/ic_launcher_foreground.png`,
size: foregroundSize,
},
);
}
const xmlTargets = [
{
relativePath: "android/app/src/main/res/values/ic_launcher_background.xml",
content: `<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="ic_launcher_background">${launcherBackground}</color>\n</resources>\n`,
},
];
function targetPath(relativePath) {
return path.join(projectRoot, relativePath);
}
async function readIfExists(filePath) {
try {
return await fs.readFile(filePath);
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
throw error;
}
}
async function writeIfChanged(relativePath, content) {
const filePath = targetPath(relativePath);
const current = await readIfExists(filePath);
if (current?.equals(content)) {
return false;
}
if (checkOnly) {
return true;
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content);
return true;
}
async function renderPng(sourceImage, size) {
const image = sourceImage.clone().resize(size, size, Jimp.RESIZE_BICUBIC);
return image.getBufferAsync(Jimp.MIME_PNG);
}
async function main() {
const sourceBuffer = await fs.readFile(sourcePath);
const sourceImage = await Jimp.read(sourceBuffer);
if (sourceImage.bitmap.width !== 512 || sourceImage.bitmap.height !== 512) {
throw new Error(`Expected ${path.relative(projectRoot, sourcePath)} to be a 512x512 PNG.`);
}
const changed = [];
for (const target of targets) {
const buffer = target.copySource ? sourceBuffer : await renderPng(sourceImage, target.size);
if (await writeIfChanged(target.relativePath, buffer)) {
changed.push(target.relativePath);
}
}
for (const target of xmlTargets) {
if (await writeIfChanged(target.relativePath, Buffer.from(target.content))) {
changed.push(target.relativePath);
}
}
if (changed.length === 0) {
console.log(`Android icon assets are current (${targets.length + xmlTargets.length} files).`);
return;
}
if (checkOnly) {
console.error("Android icon assets are out of date:");
for (const relativePath of changed) {
console.error(`- ${relativePath}`);
}
console.error("Run `npm run mobile:android:icons`.");
process.exitCode = 1;
return;
}
console.log(`Updated ${changed.length} Android icon asset${changed.length === 1 ? "" : "s"}.`);
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
+39 -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,6 +135,7 @@ 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,
],
@@ -166,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],
+4
View File
@@ -20,6 +20,7 @@ export const ownedFilesByRole = {
"auth.smoke.spec.js",
"booking-selfserve.smoke.spec.js",
"connectivityIssue.spec.ts",
"date-period-selector.smoke.spec.js",
"example.spec.ts",
"guest-book-wash-mobile.spec.ts",
"i18n-v2-integrity.spec.ts",
@@ -101,12 +102,15 @@ export const ownedFilesByRole = {
"superuser-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts",
"superuser-department-branding.spec.js",
"superuser-department-shells.spec.js",
"superuser-department-overview.spec.js",
"superuser-department-employees.spec.ts",
"superuser-department-gates.spec.ts",
"superuser-department-lanes.spec.ts",
"superuser-department-pricing-custom-only.spec.ts",
"superuser-departments-archive.spec.ts",
"superuser-drafts.spec.ts",
"superuser-orders-date-filters.spec.ts",
"superuser-products-layout.spec.ts",
"superuser-roles-permissions.spec.ts",
"superuser-system-status.smoke.spec.js",
+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: [],
+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.");
@@ -3,6 +3,13 @@ import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/Ob
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { formatLocalDateOnly, todayLocalDateOnly } from "@/services/dateOnly.js";
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const normalizeDateFilterValue = (value) => {
const formatted = formatLocalDateOnly(value);
return DATE_ONLY_PATTERN.test(formatted) ? formatted : null;
};
/**
* The DepartmentTimeBookingsEntries object
*/
@@ -209,21 +216,23 @@ export const DepartmentTimeBookingsEntries = {
getPublicEntries: async (departmentId, dateFrom = null, dateTo = null) => {
/**
* Example usage:
* https://api.truckwash.dk:4433/department/timebookings/entries/public?id=1&filters=created_at-date_from:2025-04-01,created_at-date_to:2025-05-30&order=created_at:desc
* https://api.truckwash.dk:4433/department/timebookings/entries/public?id=1&filters=start-date_from:2025-04-01,start-date_to:2025-05-30&order=start:asc
* This will return all public available times for the department with ID 1
* The dateFrom and dateTo parameters are optional and can be used to filter the results by date range.
* If not provided, it will return all available times.
*/
// Parse the dateFrom and dateTo parameters to ensure they are in the correct format (YYYY-MM-DD)
if (!departmentId || isNaN(departmentId)) {
console.warn('Invalid department ID:', departmentId);
return [];
}
if (dateFrom && !/^\d{4}-\d{2}-\d{2}$/.test(dateFrom)) {
dateFrom = normalizeDateFilterValue(dateFrom);
dateTo = normalizeDateFilterValue(dateTo);
if (dateFrom && !DATE_ONLY_PATTERN.test(dateFrom)) {
console.warn('Invalid dateFrom format:', dateFrom, 'Expected format: YYYY-MM-DD');
dateFrom = null;
}
if (dateTo && !/^\d{4}-\d{2}-\d{2}$/.test(dateTo)) {
if (dateTo && !DATE_ONLY_PATTERN.test(dateTo)) {
console.warn('Invalid dateTo format:', dateTo, 'Expected format: YYYY-MM-DD');
dateTo = null;
}
@@ -243,7 +252,8 @@ export const DepartmentTimeBookingsEntries = {
'GET',
{
id: departmentId,
filters: `created_at-date_from:${dateFrom || ''},created_at-date_to:${dateTo || ''}`,
filters: `start-date_from:${dateFrom || ''},start-date_to:${dateTo || ''}`,
order: "start:asc",
},
).then(response => {
return response.data.data;
@@ -4,24 +4,27 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {ref} from "vue";
import i18n from '@/i18n';
const t = (key) => i18n.global.t(key);
/**
* The DepartmentVariables object
*/
export const DepartmentVariables = {
meta: {
title: "Afdelingsvariabler",
title: t("objects.department_variables.title"),
icon: "fas fa-list",
description: "Oversigt over afdelingsvariabler",
description: t("objects.department_variables.description"),
endpoint: "/superuser/department/variables",
labels: {
single: "variabel",
multiple: "variabler"
single: t("objects.department_variables.single"),
multiple: t("objects.department_variables.multiple"),
}
},
columns: {
id: {
label: "ID",
label: t("objects.columns.id"),
type: "number",
sortable: true,
creation: {
@@ -29,7 +32,7 @@ export const DepartmentVariables = {
}
},
department_id: {
label: "Afdeling",
label: t("objects.columns.department_id"),
type: "select",
sortable: true,
creation: {
@@ -40,7 +43,7 @@ export const DepartmentVariables = {
}
},
variable: {
label: "Variabel",
label: t("objects.columns.key"),
type: "string",
sortable: true,
creation: {
@@ -48,7 +51,7 @@ export const DepartmentVariables = {
}
},
value: {
label: "Værdi",
label: t("objects.columns.value"),
type: "string",
sortable: true,
creation: {
@@ -110,4 +113,4 @@ export const DepartmentVariables = {
);
}
};
</script>
</script>
@@ -32,6 +32,23 @@ const normalizeDateTimeLocalValue = (value) => {
return `${datePart}T${timePart.slice(0, 5)}`;
};
const sanitizeQueryParams = (params = {}) => {
if (!params || typeof params !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== null && value !== undefined)
);
};
const buildQueryString = (params = {}) => {
const queryParams = sanitizeQueryParams(params);
const queryString = new URLSearchParams(queryParams).toString();
return queryString ? `?${queryString}` : '';
};
/**
* The Global Objects object, which contains global functions for objects.
*/
@@ -224,6 +241,7 @@ export const ObjectsGlobal = {
return {
get at_hour() { return t('global.time.at_hour'); },
get today() { return t('common.today'); },
get anytime() { return t('global.text.anytime'); },
get yesterday() { return t('global.time.yesterday'); },
get this_month() { return t('global.time.this_month'); },
get last_month() { return t('global.time.last_month'); },
@@ -283,6 +301,7 @@ export const ObjectsGlobal = {
get invoice() { return t('common.invoice'); },
get text() {
return {
get anytime() { return t('global.text.anytime'); },
get today() { return t('common.today'); },
get yesterday() { return t('global.text.yesterday'); },
get this_week() { return t('global.text.this_week'); },
@@ -290,6 +309,7 @@ export const ObjectsGlobal = {
get last_week() { return t('global.text.last_week'); },
get this_month() { return t('global.text.this_month'); },
get last_month() { return t('global.text.last_month'); },
get other_month() { return t('global.text.other_month'); },
get this_year() { return t('global.text.this_year'); },
get same_week_last_year() { return t('global.text.same_week_last_year'); },
get same_month_last_year() { return t('global.text.same_month_last_year'); },
@@ -431,20 +451,15 @@ export const ObjectsGlobal = {
* @returns {Promise} The promise
*/
object: async (endpoint, id, optionsObject = {}) => {
const cacheKey = JSON.stringify({endpoint, id, optionsObject});
const requestParams = typeof id === 'object' && id !== null
? sanitizeQueryParams(id)
: {id};
const cacheKey = JSON.stringify({endpoint, id: requestParams, optionsObject});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
let requestEndpoint = endpoint;
if (typeof id === 'object') {
// If the id is an object, we need to convert it to a query string
const params = new URLSearchParams(id).toString();
requestEndpoint = `${endpoint}?${params}`;
} else {
// If the id is a number, we can just append it to the endpoint
requestEndpoint = `${endpoint}?id=${id}`;
}
const requestEndpoint = `${endpoint}${buildQueryString(requestParams)}`;
let options = ObjectsGlobal.requestOptions.applyOptions(optionsObject);
let promise;
if (options.authenticated) {
@@ -499,23 +514,24 @@ export const ObjectsGlobal = {
* @returns {Promise} The promise
*/
objects: async (endpoint, data = {}) => {
const cacheKey = JSON.stringify({endpoint, data});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
if (data.filters && typeof data.filters === 'object') {
data.filters = Object.entries(data.filters).flatMap(([key, value]) => {
const requestData = sanitizeQueryParams(data);
if (requestData.filters && typeof requestData.filters === 'object') {
requestData.filters = Object.entries(requestData.filters).flatMap(([key, value]) => {
if (Array.isArray(value)) {
return value.map(v => `${key}:${v}`);
}
return `${key}:${value}`;
}).join(',');
}
const cacheKey = JSON.stringify({endpoint, data: requestData});
if (ObjectsGlobal.cache.has(cacheKey)) {
return ObjectsGlobal.cache.get(cacheKey);
}
const promise = authenticatedRequest(
endpoint,
"GET",
data
requestData
).then((response) => {
return response.data.data;
}).catch((error) => {
@@ -2,6 +2,13 @@
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { getDepartmentName } from "@/components/pagination/departmentTabs.vue";
import i18n from "@/i18n";
const t = (key) => i18n.global.t(key);
const tf = (key, fallback) => {
const value = t(key);
return value === key ? fallback : value;
};
const normalizeCompletionSafetySeal = (safetySeal) => {
if (safetySeal === null || safetySeal === undefined) {
@@ -27,13 +34,22 @@ const normalizeCompletionSafetySeal = (safetySeal) => {
*/
export const OrderBookings = {
meta: {
title: "Bookinger",
get title() {
return tf("nav.bookings", "Bookings");
},
icon: "fas fa-list",
description: "Oversigt over order bookinger",
get description() {
const multipleLabel = tf("nav.bookings", "Bookings");
return `Oversigt over ${String(multipleLabel).toLowerCase()}`;
},
endpoint: "/order-bookings",
labels: {
single: "booking",
multiple: "bookinger",
get single() {
return tf("objects.order_bookings.single", "Booking");
},
get multiple() {
return tf("nav.bookings", "Bookings");
},
fields: {
names: {
customer_number: {
@@ -8,6 +8,17 @@ import i18n from '@/i18n';
const t = (key) => i18n.global.t(key);
const normalizeProductQueryOptions = (options = {}) => {
const queryOptions = { ...options };
if (!Object.prototype.hasOwnProperty.call(queryOptions, "final_price")) {
queryOptions.final_price = false;
}
return Object.fromEntries(
Object.entries(queryOptions).filter(([, value]) => value !== null && value !== undefined)
);
};
/**
* Local products cache
* @type {ref<null>}
@@ -269,19 +280,19 @@ export const Products = {
},
},
get: {
all: async (options = {department_id: null, customer_id: null, category_id: null, final_price: false}) => {
return ObjectsGlobal.get.objects(Products.meta.endpoint, {...options});
all: async (options = {}) => {
return ObjectsGlobal.get.objects(Products.meta.endpoint, normalizeProductQueryOptions(options));
},
single: async (id, options = {department_id: null, customer_id: null, category_id: null, final_price: false}) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {id: id, ...options});
single: async (id, options = {}) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {id: id, ...normalizeProductQueryOptions(options)});
},
category: async (category_id, department_id, customer_id = null, final_price = false) => {
return ObjectsGlobal.get.object(Products.meta.endpoint, {
return ObjectsGlobal.get.object(Products.meta.endpoint, normalizeProductQueryOptions({
category: category_id,
department_id: department_id,
...(customer_id !== null ? {customer_id: customer_id} : {}),
...{final_price: final_price}
});
}));
},
},
delete: async (id) => {
@@ -324,4 +335,4 @@ export const Products = {
);
},
};
</script>
</script>
@@ -1,5 +1,4 @@
<script>
import Swal from "sweetalert2";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
@@ -123,6 +122,10 @@ export const Roles = {
id: id
});
},
getLimitedBackofficePermissionTemplates: async () => {
const response = await authenticatedRequest("/roles/limited-backoffice-permission-templates", "GET");
return response?.data?.data ?? response?.data ?? [];
},
permissions: {
add: async (roleId, permissionId) => {
return authenticatedRequest("/roles/permissions", "POST", {
@@ -161,4 +164,4 @@ export const Roles = {
);
}
};
</script>
</script>
@@ -3,6 +3,7 @@ import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/Ob
import Swal from "sweetalert2";
import { createApp, h, ref } from "vue";
import SubuserGrantPermissionNodes from "@/components/displays/selectors/SubuserGrantPermissionNodes.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
/**
* The SubuserGrants object
@@ -122,18 +123,31 @@ export const SubuserGrants = {
getPermissionNodes: async () => {
return ObjectsGlobal.get.objects("/subusers/permission-nodes");
},
showPermissionEditForm: async (grant, refreshCallback) => {
getPermissionTemplates: async () => {
return ObjectsGlobal.get.objects("/subusers/permission-templates");
},
showPermissionEditForm: async (grant, refreshCallback, options = {}) => {
const container = document.createElement("div");
container.id = "permission-edit-container";
const permissionsRef = ref(grant.permissions || []);
const accessRef = ref({
permission_template_key: grant.permission_template_key || "custom",
permissions: grant.permissions || [],
});
const app = createApp({
render() {
return h(SubuserGrantPermissionNodes, {
grant: grant,
permissions: permissionsRef.value,
templateKey: accessRef.value.permission_template_key,
allowAdvanced: Boolean(options.allowAdvanced),
"onUpdate:permissions": (newPermissions) => {
permissionsRef.value = newPermissions;
},
"onUpdate:access": (newAccess) => {
accessRef.value = newAccess;
permissionsRef.value = newAccess.permissions || [];
}
});
}
@@ -150,10 +164,27 @@ export const SubuserGrants = {
cancelButtonText: "Annuller",
preConfirm: async () => {
try {
await SubuserGrants.set.permissions(grant.id, permissionsRef.value);
const accessPayload = accessRef.value?.permission_template_key
&& accessRef.value.permission_template_key !== "custom"
? { permission_template_key: accessRef.value.permission_template_key }
: { permissions: permissionsRef.value };
if (typeof options.saveAccess === "function") {
await options.saveAccess(accessPayload);
} else if (typeof options.savePermissions === "function") {
await options.savePermissions(permissionsRef.value);
} else {
await authenticatedRequest(
SubuserGrants.meta.endpoint,
"PUT",
{ id: grant.id, ...accessPayload },
ObjectsGlobal.errorHandler.handleError
);
}
grant.permissions = [...permissionsRef.value];
return permissionsRef.value;
} catch (error) {
grant.permission_template_key = accessRef.value?.permission_template_key || "custom";
return accessPayload;
} catch (_error) {
Swal.showValidationMessage("Kunne ikke gemme tilladelserne.");
return false;
}

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