Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06e1552a47 |
@@ -4,43 +4,8 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
|
||||||
- master
|
- master
|
||||||
- dev
|
|
||||||
workflow_dispatch:
|
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:
|
schedule:
|
||||||
- cron: "0 2 * * *"
|
- cron: "0 2 * * *"
|
||||||
|
|
||||||
@@ -48,22 +13,16 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.head_ref || github.ref_name }}
|
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
|
||||||
cancel-in-progress: true
|
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:
|
jobs:
|
||||||
format-tests:
|
format-tests:
|
||||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
# CI runs on the repository's self-hosted runner pool.
|
||||||
|
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Repair self-hosted workspace permissions
|
- name: Repair self-hosted workspace permissions
|
||||||
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||||
@@ -96,11 +55,10 @@ jobs:
|
|||||||
|
|
||||||
build-and-unit:
|
build-and-unit:
|
||||||
needs: format-tests
|
needs: format-tests
|
||||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_STANDARD_RUNNER || '["self-hosted","Linux","X64","pleno","frontend"]') }}
|
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Repair self-hosted workspace permissions
|
- name: Repair self-hosted workspace permissions
|
||||||
if: ${{ contains(vars.FRONTEND_CI_STANDARD_RUNNER || 'self-hosted', 'self-hosted') }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||||
@@ -139,206 +97,23 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
VITEST_BATCH_SIZE: 5
|
VITEST_BATCH_SIZE: 5
|
||||||
|
|
||||||
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:
|
e2e-pr:
|
||||||
if: >
|
if: github.event_name != 'schedule'
|
||||||
always() &&
|
needs: build-and-unit
|
||||||
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 }}
|
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
|
||||||
timeout-minutes: 45
|
timeout-minutes: 30
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_PR_E2E_MAX_PARALLEL || '2') }}
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
suite: [core, changed]
|
suite: [core, changed]
|
||||||
project: [chromium-desktop, chromium-mobile]
|
project: [chromium-desktop, chromium-mobile]
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||||
PLAYWRIGHT_WORKERS: 1
|
|
||||||
PLAYWRIGHT_VIDEO_MODE: on-first-retry
|
|
||||||
steps:
|
steps:
|
||||||
- name: Repair self-hosted workspace permissions
|
- name: Repair self-hosted workspace permissions
|
||||||
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||||
@@ -365,29 +140,20 @@ jobs:
|
|||||||
EVENT_NAME: ${{ github.event_name }}
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
HEAD_SHA: ${{ github.sha }}
|
HEAD_SHA: ${{ github.sha }}
|
||||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
zero_sha="0000000000000000000000000000000000000000"
|
zero_sha="0000000000000000000000000000000000000000"
|
||||||
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
|
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_BASE_SHA" ]]; then
|
||||||
base_ref="$PR_BASE_SHA"
|
base_ref="$PR_BASE_SHA"
|
||||||
head_ref="$PR_HEAD_SHA"
|
|
||||||
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
|
elif [[ -z "$PUSH_BEFORE_SHA" || "$PUSH_BEFORE_SHA" == "$zero_sha" ]]; then
|
||||||
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
|
git fetch --no-tags --prune origin "$DEFAULT_BRANCH"
|
||||||
base_ref="origin/$DEFAULT_BRANCH"
|
base_ref="origin/$DEFAULT_BRANCH"
|
||||||
head_ref="$HEAD_SHA"
|
|
||||||
else
|
else
|
||||||
base_ref="$PUSH_BEFORE_SHA"
|
base_ref="$PUSH_BEFORE_SHA"
|
||||||
head_ref="$HEAD_SHA"
|
|
||||||
fi
|
|
||||||
if [[ "$EVENT_NAME" == "pull_request" && -n "$PR_HEAD_SHA" ]]; then
|
|
||||||
head_ref="$PR_HEAD_SHA"
|
|
||||||
else
|
|
||||||
head_ref="$HEAD_SHA"
|
|
||||||
fi
|
fi
|
||||||
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
|
echo "base=$base_ref" >> "$GITHUB_OUTPUT"
|
||||||
echo "head=$head_ref" >> "$GITHUB_OUTPUT"
|
echo "head=$HEAD_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v5
|
||||||
@@ -415,9 +181,8 @@ jobs:
|
|||||||
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
||||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
|
||||||
mkdir -p "$lock_root"
|
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
|
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
|
||||||
playwright_port_lock=""
|
playwright_port_lock=""
|
||||||
playwright_dev_port=""
|
playwright_dev_port=""
|
||||||
@@ -458,8 +223,6 @@ jobs:
|
|||||||
--env CI="${CI:-}" \
|
--env CI="${CI:-}" \
|
||||||
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
|
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
|
||||||
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
|
--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 PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
|
||||||
--env MATRIX_SUITE="$MATRIX_SUITE" \
|
--env MATRIX_SUITE="$MATRIX_SUITE" \
|
||||||
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
|
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
|
||||||
@@ -486,7 +249,6 @@ jobs:
|
|||||||
install_dependencies
|
install_dependencies
|
||||||
ulimit -n 16384 || true
|
ulimit -n 16384 || true
|
||||||
if [[ "$MATRIX_SUITE" == "core" ]]; then
|
if [[ "$MATRIX_SUITE" == "core" ]]; then
|
||||||
PLAYWRIGHT_ARTIFACT_NAMESPACE="${PLAYWRIGHT_ARTIFACT_NAMESPACE}-ct" npm run test:ct -- --project="$MATRIX_PROJECT"
|
|
||||||
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
||||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
||||||
else
|
else
|
||||||
@@ -515,21 +277,15 @@ jobs:
|
|||||||
if: >
|
if: >
|
||||||
always() &&
|
always() &&
|
||||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
(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' &&
|
needs.build-and-unit.result == 'success' &&
|
||||||
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') &&
|
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
|
||||||
(
|
needs: [build-and-unit, e2e-pr]
|
||||||
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 }}
|
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
|
||||||
runs-on: ${{ fromJSON(vars.FRONTEND_CI_E2E_RUNNER || '["self-hosted","Linux","X64","pleno","frontend","docker"]') }}
|
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: ${{ fromJSON(vars.FRONTEND_CI_FULL_E2E_MAX_PARALLEL || '1') }}
|
max-parallel: 2
|
||||||
matrix:
|
matrix:
|
||||||
browser: [chromium, webkit, firefox]
|
browser: [chromium, webkit, firefox]
|
||||||
device: [mobile, desktop, tablet]
|
device: [mobile, desktop, tablet]
|
||||||
@@ -551,7 +307,6 @@ jobs:
|
|||||||
PLAYWRIGHT_VIDEO_MODE: off
|
PLAYWRIGHT_VIDEO_MODE: off
|
||||||
steps:
|
steps:
|
||||||
- name: Repair self-hosted workspace permissions
|
- name: Repair self-hosted workspace permissions
|
||||||
if: ${{ contains(vars.FRONTEND_CI_E2E_RUNNER || 'self-hosted', 'self-hosted') }}
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||||
@@ -602,9 +357,8 @@ jobs:
|
|||||||
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
|
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
|
||||||
lock_root="${PLAYWRIGHT_PORT_LOCK_ROOT:-/tmp/pleno-playwright-port-locks}"
|
lock_root="${RUNNER_TEMP:-/tmp}/pleno-playwright-port-locks"
|
||||||
mkdir -p "$lock_root"
|
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
|
find "$lock_root" -mindepth 1 -maxdepth 1 -type d -mmin +360 -exec rmdir {} \; 2>/dev/null || true
|
||||||
playwright_port_lock=""
|
playwright_port_lock=""
|
||||||
playwright_dev_port=""
|
playwright_dev_port=""
|
||||||
|
|||||||
@@ -154,35 +154,6 @@ Artifacts and summaries:
|
|||||||
- `output/playwright/test-lists/<project>-<role>.txt`
|
- `output/playwright/test-lists/<project>-<role>.txt`
|
||||||
- `output/playwright/test-lists/<role>-<project>.txt` (legacy compatibility copy)
|
- `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
|
## Bubblewrap (TWA) Build and Install
|
||||||
|
|
||||||
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
|
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#0787BB</color>
|
<color name="ic_launcher_background">#FFFFFF</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -2415,12 +2415,6 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
description: Contact person name
|
description: Contact person name
|
||||||
example: "Mikkel"
|
example: "Mikkel"
|
||||||
ean:
|
|
||||||
type: string
|
|
||||||
description: Optional EAN used for e-invoicing in e-conomic
|
|
||||||
maxLength: 13
|
|
||||||
pattern: '^[0-9]{1,13}$'
|
|
||||||
example: "5790001234567"
|
|
||||||
g_recaptcha_response:
|
g_recaptcha_response:
|
||||||
type: string
|
type: string
|
||||||
description: reCAPTCHA verification token
|
description: reCAPTCHA verification token
|
||||||
@@ -8195,12 +8189,6 @@ paths:
|
|||||||
email: {type: string}
|
email: {type: string}
|
||||||
phone: {type: integer}
|
phone: {type: integer}
|
||||||
name: {type: string}
|
name: {type: string}
|
||||||
ean:
|
|
||||||
type: string
|
|
||||||
description: Optional EAN used for e-invoicing in e-conomic
|
|
||||||
maxLength: 13
|
|
||||||
pattern: '^[0-9]{1,13}$'
|
|
||||||
example: "5790001234567"
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
|
|||||||
@@ -70,7 +70,6 @@
|
|||||||
"@creativebulma/bulma-divider": "^1.1.0",
|
"@creativebulma/bulma-divider": "^1.1.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@event-calendar/core": "^4.1.0",
|
"@event-calendar/core": "^4.1.0",
|
||||||
"@playwright/experimental-ct-vue": "^1.58.2",
|
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/event-calendar__core": "^3.7.0",
|
"@types/event-calendar__core": "^3.7.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
@@ -80,7 +79,6 @@
|
|||||||
"eslint-plugin-vue": "^10.9.2",
|
"eslint-plugin-vue": "^10.9.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jimp": "0.22.12",
|
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
@@ -4220,229 +4218,6 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/experimental-ct-core": {
|
|
||||||
"version": "1.58.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/experimental-ct-core/-/experimental-ct-core-1.58.2.tgz",
|
|
||||||
"integrity": "sha512-Imif9ggQp6YIblHAX6MvJuqDFrCGHYspoibxLP3+1soXp+1wBNuuSRajv0VWXzFbh//4l29I+xy2tpTgej0vEA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright": "1.58.2",
|
|
||||||
"playwright-core": "1.58.2",
|
|
||||||
"vite": "^6.4.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@playwright/experimental-ct-core/node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@playwright/experimental-ct-core/node_modules/vite": {
|
|
||||||
"version": "6.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
|
||||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"fdir": "^6.4.4",
|
|
||||||
"picomatch": "^4.0.2",
|
|
||||||
"postcss": "^8.5.3",
|
|
||||||
"rollup": "^4.34.9",
|
|
||||||
"tinyglobby": "^0.2.13"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"vite": "bin/vite.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "~2.3.3"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
|
||||||
"jiti": ">=1.21.0",
|
|
||||||
"less": "*",
|
|
||||||
"lightningcss": "^1.21.0",
|
|
||||||
"sass": "*",
|
|
||||||
"sass-embedded": "*",
|
|
||||||
"stylus": "*",
|
|
||||||
"sugarss": "*",
|
|
||||||
"terser": "^5.16.0",
|
|
||||||
"tsx": "^4.8.1",
|
|
||||||
"yaml": "^2.4.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/node": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"jiti": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"less": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"lightningcss": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"sass": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"sass-embedded": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"stylus": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"sugarss": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"terser": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"tsx": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"yaml": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@playwright/experimental-ct-vue": {
|
|
||||||
"version": "1.58.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/experimental-ct-vue/-/experimental-ct-vue-1.58.2.tgz",
|
|
||||||
"integrity": "sha512-joLgyXZOV7odSY3PrEkzNRqz6rdaf7eoyTus4deSAAD6u5qvWYgDZl2mDJYiivcE+jDns9SGiZQlkMmyDcjQpg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@playwright/experimental-ct-core": "1.58.2",
|
|
||||||
"@vitejs/plugin-vue": "^5.2.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@playwright/experimental-ct-vue/node_modules/@vitejs/plugin-vue": {
|
|
||||||
"version": "5.2.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
|
||||||
"integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
|
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.0.0 || >=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"vite": "^5.0.0 || ^6.0.0",
|
|
||||||
"vue": "^3.2.25"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@playwright/experimental-ct-vue/node_modules/fsevents": {
|
|
||||||
"version": "2.3.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@playwright/experimental-ct-vue/node_modules/vite": {
|
|
||||||
"version": "6.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
|
||||||
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
|
|
||||||
"dev": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"fdir": "^6.4.4",
|
|
||||||
"picomatch": "^4.0.2",
|
|
||||||
"postcss": "^8.5.3",
|
|
||||||
"rollup": "^4.34.9",
|
|
||||||
"tinyglobby": "^0.2.13"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"vite": "bin/vite.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "~2.3.3"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
|
||||||
"jiti": ">=1.21.0",
|
|
||||||
"less": "*",
|
|
||||||
"lightningcss": "^1.21.0",
|
|
||||||
"sass": "*",
|
|
||||||
"sass-embedded": "*",
|
|
||||||
"stylus": "*",
|
|
||||||
"sugarss": "*",
|
|
||||||
"terser": "^5.16.0",
|
|
||||||
"tsx": "^4.8.1",
|
|
||||||
"yaml": "^2.4.2"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/node": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"jiti": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"less": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"lightningcss": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"sass": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"sass-embedded": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"stylus": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"sugarss": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"terser": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"tsx": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"yaml": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
|
|||||||
@@ -47,19 +47,15 @@
|
|||||||
"test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live",
|
"test:e2e:live:public": "playwright test --config=playwright.live.config.ts --grep @public-live",
|
||||||
"test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live",
|
"test:e2e:live:roles": "playwright test --config=playwright.live.config.ts --grep @role-live",
|
||||||
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
|
"test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live",
|
||||||
"test:ct": "playwright test --config=playwright.ct.config.ts",
|
|
||||||
"test:ct:pr": "playwright test --config=playwright.ct.config.ts --project=chromium-desktop",
|
|
||||||
"release:verify-upload": "node scripts/release/verify-upload.mjs",
|
"release:verify-upload": "node scripts/release/verify-upload.mjs",
|
||||||
"release:update-server-version": "node scripts/release/update-server-version.mjs",
|
"release:update-server-version": "node scripts/release/update-server-version.mjs",
|
||||||
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
|
"release:upload:lftp": "bash scripts/release/upload-dist-lftp.sh",
|
||||||
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
|
"test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"",
|
||||||
"test:all": "npm run test:unit && npm run test:ct:pr && npm run test:e2e:pr",
|
"test:all": "npm run test:unit && npm run test:e2e:pr",
|
||||||
"twa:build": "bubblewrap build",
|
"twa:build": "bubblewrap build",
|
||||||
"twa:update": "bubblewrap update",
|
"twa:update": "bubblewrap update",
|
||||||
"mobile:sync": "npm run build && npx cap sync",
|
"mobile:sync": "npm run build && npx cap sync",
|
||||||
"mobile:android:icons": "node scripts/mobile/generate-android-icons.mjs",
|
"mobile:android:sync": "npm run build && npx cap sync android",
|
||||||
"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:permissions:check": "node scripts/mobile/check-permissions.mjs",
|
||||||
"mobile:android:signing:check": "node scripts/mobile/check-android-signing-env.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",
|
"mobile:android:bundle": "npm run mobile:android:signing:check && npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease",
|
||||||
@@ -130,7 +126,6 @@
|
|||||||
"@creativebulma/bulma-divider": "^1.1.0",
|
"@creativebulma/bulma-divider": "^1.1.0",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@event-calendar/core": "^4.1.0",
|
"@event-calendar/core": "^4.1.0",
|
||||||
"@playwright/experimental-ct-vue": "^1.58.2",
|
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@types/event-calendar__core": "^3.7.0",
|
"@types/event-calendar__core": "^3.7.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
@@ -140,7 +135,6 @@
|
|||||||
"eslint-plugin-vue": "^10.9.2",
|
"eslint-plugin-vue": "^10.9.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jimp": "0.22.12",
|
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function buildProject(name: string, browserName: "chromium" | "firefox" | "webki
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./tests/e2e",
|
testDir: "./tests/e2e",
|
||||||
testIgnore: ["**/release/**", "**/quarantine/**"],
|
testIgnore: ["**/release/**"],
|
||||||
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
|
snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}-win32{ext}",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { defineConfig, devices } from "@playwright/experimental-ct-vue";
|
|
||||||
|
|
||||||
const projectRoot = fileURLToPath(new URL(".", import.meta.url));
|
|
||||||
const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "ct").trim();
|
|
||||||
const artifactRoot = path.join("output", "playwright", artifactNamespace);
|
|
||||||
const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim();
|
|
||||||
const reporter =
|
|
||||||
reporterMode === "line-html"
|
|
||||||
? [["line"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]]
|
|
||||||
: [["list"], ["html", { open: "never", outputFolder: path.join(artifactRoot, "report") }]];
|
|
||||||
const configuredWorkers = Number(process.env.PLAYWRIGHT_WORKERS || 2);
|
|
||||||
const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? configuredWorkers : 2;
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: "./tests/ct",
|
|
||||||
timeout: 45_000,
|
|
||||||
fullyParallel: true,
|
|
||||||
forbidOnly: !!process.env.CI,
|
|
||||||
retries: process.env.CI ? 1 : 0,
|
|
||||||
workers,
|
|
||||||
reporter,
|
|
||||||
outputDir: path.join(artifactRoot, "test-results"),
|
|
||||||
use: {
|
|
||||||
trace: "retain-on-failure",
|
|
||||||
screenshot: "only-on-failure",
|
|
||||||
video: "retain-on-failure",
|
|
||||||
ctViteConfig: {
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(projectRoot, "src"),
|
|
||||||
},
|
|
||||||
preserveSymlinks: true,
|
|
||||||
dedupe: ["vue", "vue-router", "vue-i18n", "@vueuse/core", "@vueuse/head", "@unhead/vue"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
name: "chromium-desktop",
|
|
||||||
use: {
|
|
||||||
...devices["Desktop Chrome"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "chromium-mobile",
|
|
||||||
use: {
|
|
||||||
...devices["Pixel 5"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Pleno Component Test</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="./index.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { beforeMount } from "@playwright/experimental-ct-vue/hooks";
|
|
||||||
import Buefy from "buefy";
|
|
||||||
import "bulma/css/bulma.min.css";
|
|
||||||
import "buefy/dist/css/buefy.css";
|
|
||||||
|
|
||||||
import i18n from "@/i18n";
|
|
||||||
|
|
||||||
type PlenoHooksConfig = {
|
|
||||||
locale?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeMount(({ app, hooksConfig }) => {
|
|
||||||
const config = (hooksConfig || {}) as PlenoHooksConfig;
|
|
||||||
const locale = config.locale || "en";
|
|
||||||
const globalLocale = i18n.global.locale as unknown;
|
|
||||||
|
|
||||||
if (globalLocale && typeof globalLocale === "object" && "value" in globalLocale) {
|
|
||||||
(globalLocale as { value: string }).value = locale;
|
|
||||||
} else {
|
|
||||||
(i18n.global as unknown as { locale: string }).locale = locale;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use(i18n);
|
|
||||||
app.use(Buefy);
|
|
||||||
});
|
|
||||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 33 KiB |
@@ -275,24 +275,7 @@ const transformLocale = (locale, originalMessages) => {
|
|||||||
(entry) => normalizeToken(entry.value) === normalized
|
(entry) => normalizeToken(entry.value) === normalized
|
||||||
);
|
);
|
||||||
if (existingWordEntry) {
|
if (existingWordEntry) {
|
||||||
const word = {
|
tokenToWord.set(normalized, { path: `words.${existingWordEntry.key}`, value: existingWordEntry.value });
|
||||||
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,133 +0,0 @@
|
|||||||
#!/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;
|
|
||||||
});
|
|
||||||
@@ -7,18 +7,14 @@ export const fallbackChangePatterns = [
|
|||||||
/^vite\.config\.js$/u,
|
/^vite\.config\.js$/u,
|
||||||
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
|
/^playwright(?:\..+)?\.config\.(?:js|ts)$/u,
|
||||||
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
|
/^playwright\.global-(?:setup|teardown)\.mjs$/u,
|
||||||
/^scripts\/run-playwright-(?:ci-parallel|batched-chromium)\.mjs$/u,
|
/^scripts\/run-playwright-(?:pr|ci-parallel|batched-chromium)\.mjs$/u,
|
||||||
/^tests\/e2e\/(?:support|fixtures)\//u,
|
/^tests\/e2e\/(?:support|fixtures)\//u,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const sourceMappings = [
|
export const sourceMappings = [
|
||||||
{
|
{
|
||||||
name: "auth",
|
name: "auth",
|
||||||
patterns: [
|
patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u],
|
||||||
/^src\/(?:views|components|middleware)\/.*auth/iu,
|
|
||||||
/^src\/views\/auth\//u,
|
|
||||||
/^src\/components\/session\/(?!token\/SessionUser\/Objects\/)/u,
|
|
||||||
],
|
|
||||||
specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"],
|
specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"],
|
||||||
projects: chromiumProjects,
|
projects: chromiumProjects,
|
||||||
},
|
},
|
||||||
@@ -28,70 +24,15 @@ export const sourceMappings = [
|
|||||||
specs: ["tests/e2e/navigation.smoke.spec.js"],
|
specs: ["tests/e2e/navigation.smoke.spec.js"],
|
||||||
projects: chromiumProjects,
|
projects: chromiumProjects,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "limited-backoffice",
|
|
||||||
patterns: [
|
|
||||||
/^src\/views\/backoffice\//u,
|
|
||||||
/^src\/services\/limitedBackoffice\.js$/u,
|
|
||||||
/^src\/services\/departmentCustomerPricing\.js$/u,
|
|
||||||
/^src\/components\/displays\/department\/pricing\/DepartmentCustomerPricingEditor\.vue$/u,
|
|
||||||
],
|
|
||||||
specs: ["tests/e2e/limited-backoffice.spec.ts"],
|
|
||||||
projects: chromiumProjects,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "superuser-department-employees",
|
|
||||||
patterns: [
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentEmployees\.vue$/u,
|
|
||||||
/^src\/views\/backoffice\/components\/LimitedBackofficeEmployeesManager\.vue$/u,
|
|
||||||
],
|
|
||||||
specs: ["tests/e2e/superuser-department-employees.spec.ts"],
|
|
||||||
projects: ["chromium-desktop"],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "superuser-roles-permissions",
|
name: "superuser-roles-permissions",
|
||||||
patterns: [
|
patterns: [
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/roles\/SuperUserRolesPermissions\.vue$/u,
|
/^src\/views\/dashboards\/superUserDashboard\/roles\/SuperUserRolesPermissions\.vue$/u,
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/roles\/RolePermissionManager\.vue$/u,
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/roles\/rolePermissionCatalog\.js$/u,
|
/^src\/views\/dashboards\/superUserDashboard\/roles\/rolePermissionCatalog\.js$/u,
|
||||||
],
|
],
|
||||||
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
|
specs: ["tests/e2e/superuser-roles-permissions.spec.ts"],
|
||||||
projects: chromiumProjects,
|
projects: chromiumProjects,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "superuser-users",
|
|
||||||
patterns: [/^src\/views\/dashboards\/superUserDashboard\/user\//u],
|
|
||||||
specs: ["tests/e2e/superuser-users.spec.ts"],
|
|
||||||
projects: chromiumProjects,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "superuser-dashboard",
|
|
||||||
patterns: [
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/SuperUserDashboard(?:Navigation)?\.vue$/u,
|
|
||||||
/^src\/components\/displays\/superuser\/system\//u,
|
|
||||||
],
|
|
||||||
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
|
|
||||||
projects: chromiumProjects,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "superuser-department-overview",
|
|
||||||
patterns: [
|
|
||||||
/^src\/services\/superuserDepartmentOverview\.js$/u,
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/Department\.vue$/u,
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/SuperUserDashboardDepartmentNavigation\.vue$/u,
|
|
||||||
],
|
|
||||||
specs: ["tests/e2e/superuser-department-overview.spec.js", "tests/e2e/superuser-department-employees.spec.ts"],
|
|
||||||
projects: chromiumProjects,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "header-navigation",
|
|
||||||
patterns: [
|
|
||||||
/^src\/components\/viewport\/page\/headers\//u,
|
|
||||||
/^src\/components\/models\/navigation\/items\/NavigationMenuItemsGlobal\.vue$/u,
|
|
||||||
],
|
|
||||||
specs: ["tests/e2e/navigation.smoke.spec.js", "tests/e2e/limited-backoffice.spec.ts"],
|
|
||||||
projects: chromiumProjects,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "booking",
|
name: "booking",
|
||||||
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
|
patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u],
|
||||||
@@ -110,20 +51,8 @@ export const sourceMappings = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "pos",
|
name: "pos",
|
||||||
patterns: [
|
patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u],
|
||||||
/(?:^|[/_.-])pos(?:[/_.-]|$)/iu,
|
specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"],
|
||||||
/(?:^|\/)(?:POS|Pos)[A-Z][^/]*\.(?:vue|js|ts)$/u,
|
|
||||||
/^src\/assets\/pos\.css$/u,
|
|
||||||
/^src\/components\/displays\/boxes\/ProductBox\.vue$/u,
|
|
||||||
/^src\/features\/customer\/customerProductRules\.js$/u,
|
|
||||||
/^src\/services\/economicCustomerIdentifiers\.js$/u,
|
|
||||||
],
|
|
||||||
specs: [
|
|
||||||
"tests/e2e/pos-flow.spec.js",
|
|
||||||
"tests/e2e/pos-mobile-order-flow.spec.js",
|
|
||||||
"tests/e2e/admin-pos-orders.spec.ts",
|
|
||||||
"tests/e2e/pos-customer-rules.spec.js",
|
|
||||||
],
|
|
||||||
projects: chromiumProjects,
|
projects: chromiumProjects,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -135,28 +64,12 @@ export const sourceMappings = [
|
|||||||
{
|
{
|
||||||
name: "admin-department-notifications",
|
name: "admin-department-notifications",
|
||||||
patterns: [
|
patterns: [
|
||||||
/^src\/views\/dashboards\/departmentDashboard\/modules\/notifications\/DepartmentNotifications\.vue$/u,
|
|
||||||
/^src\/components\/displays\/department\/notifications\//u,
|
/^src\/components\/displays\/department\/notifications\//u,
|
||||||
/^src\/components\/displays\/pagination\/models\/DepartmentPos\/NotificationsPhonePagination\.vue$/u,
|
/^src\/components\/displays\/pagination\/models\/DepartmentPos\/NotificationsPhonePagination\.vue$/u,
|
||||||
],
|
],
|
||||||
specs: ["tests/e2e/admin-department-notifications.spec.ts"],
|
specs: ["tests/e2e/admin-department-notifications.spec.ts"],
|
||||||
projects: chromiumProjects,
|
projects: chromiumProjects,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "admin-daily-report",
|
|
||||||
patterns: [
|
|
||||||
/^src\/views\/dashboards\/departmentDashboard\/modules\/daily-report\//u,
|
|
||||||
/^src\/components\/session\/token\/SessionUser\/Objects\/DepartmentDailyReports\.vue$/u,
|
|
||||||
],
|
|
||||||
specs: ["tests/e2e/admin-daily-report.spec.ts"],
|
|
||||||
projects: chromiumProjects,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "limited-backoffice",
|
|
||||||
patterns: [/^src\/views\/backoffice\/LimitedBackoffice/u],
|
|
||||||
specs: ["tests/e2e/limited-backoffice.spec.ts"],
|
|
||||||
projects: chromiumProjects,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "invoicing",
|
name: "invoicing",
|
||||||
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
|
patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu],
|
||||||
@@ -179,32 +92,6 @@ export const sourceMappings = [
|
|||||||
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
|
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
|
||||||
projects: chromiumProjects,
|
projects: chromiumProjects,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "superuser-department-pricing",
|
|
||||||
patterns: [
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|DepartmentCustomerPricing|SuperUserSelectedDepartmentObject|SuperUserDashboardDepartmentNavigation)\.vue$/u,
|
|
||||||
/^src\/components\/displays\/department\/pricing\/DepartmentCustomerPricingEditor\.vue$/u,
|
|
||||||
/^src\/services\/departmentCustomerPricing\.js$/u,
|
|
||||||
/^src\/components\/session\/token\/SessionUser\/Objects\/Departments\.vue$/u,
|
|
||||||
],
|
|
||||||
specs: ["tests/e2e/superuser-department-pricing-custom-only.spec.ts"],
|
|
||||||
projects: ["chromium-desktop"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "superuser-department-shells",
|
|
||||||
patterns: [
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentCategories\.vue$/u,
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentGatewaysWorkspacePage\.vue$/u,
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/DepartmentProfile\.vue$/u,
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/modules\/DepartmentModulesSetup\.vue$/u,
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/modules\/SuperUserDashboardDepartmentModulesNavigation\.vue$/u,
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/DepartmentStripeSetup\.vue$/u,
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/DepartmentStripeTerminalsReaders\.vue$/u,
|
|
||||||
/^src\/views\/dashboards\/superUserDashboard\/department\/stripe\/SuperUserDashboardDepartmentStripeNavigation\.vue$/u,
|
|
||||||
],
|
|
||||||
specs: ["tests/e2e/superuser-department-shells.spec.js"],
|
|
||||||
projects: chromiumProjects,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "self-serve",
|
name: "self-serve",
|
||||||
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
|
patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu],
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const ownedFilesByRole = {
|
|||||||
"auth.smoke.spec.js",
|
"auth.smoke.spec.js",
|
||||||
"booking-selfserve.smoke.spec.js",
|
"booking-selfserve.smoke.spec.js",
|
||||||
"connectivityIssue.spec.ts",
|
"connectivityIssue.spec.ts",
|
||||||
"date-period-selector.smoke.spec.js",
|
|
||||||
"example.spec.ts",
|
"example.spec.ts",
|
||||||
"guest-book-wash-mobile.spec.ts",
|
"guest-book-wash-mobile.spec.ts",
|
||||||
"i18n-v2-integrity.spec.ts",
|
"i18n-v2-integrity.spec.ts",
|
||||||
@@ -99,19 +98,13 @@ export const ownedFilesByRole = {
|
|||||||
"self-serve-studio-flow.spec.js",
|
"self-serve-studio-flow.spec.js",
|
||||||
"session-bootstrap.spec.ts",
|
"session-bootstrap.spec.ts",
|
||||||
"superuser-bookings.spec.ts",
|
"superuser-bookings.spec.ts",
|
||||||
"superuser-cron.spec.ts",
|
|
||||||
"superuser-customer-complaints.spec.ts",
|
"superuser-customer-complaints.spec.ts",
|
||||||
"superuser-customers-mass-import.spec.ts",
|
"superuser-customers-mass-import.spec.ts",
|
||||||
"superuser-department-branding.spec.js",
|
"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-gates.spec.ts",
|
||||||
"superuser-department-lanes.spec.ts",
|
"superuser-department-lanes.spec.ts",
|
||||||
"superuser-department-pricing-custom-only.spec.ts",
|
|
||||||
"superuser-departments-archive.spec.ts",
|
"superuser-departments-archive.spec.ts",
|
||||||
"superuser-drafts.spec.ts",
|
"superuser-drafts.spec.ts",
|
||||||
"superuser-orders-date-filters.spec.ts",
|
|
||||||
"superuser-products-layout.spec.ts",
|
"superuser-products-layout.spec.ts",
|
||||||
"superuser-roles-permissions.spec.ts",
|
"superuser-roles-permissions.spec.ts",
|
||||||
"superuser-system-status.smoke.spec.js",
|
"superuser-system-status.smoke.spec.js",
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import { execFile, spawn } from "node:child_process";
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import {
|
import { chromiumProjects, fallbackChangePatterns, prGrep, smokeGrep, sourceMappings } from "./playwright-pr-mapping.mjs";
|
||||||
chromiumProjects,
|
|
||||||
fallbackChangePatterns,
|
|
||||||
prGrep,
|
|
||||||
smokeGrep,
|
|
||||||
sourceMappings,
|
|
||||||
} from "./playwright-pr-mapping.mjs";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const workingDirectory = process.cwd();
|
const workingDirectory = process.cwd();
|
||||||
@@ -251,7 +245,7 @@ function addSpec(selection, spec, projects) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isE2eSpec(file) {
|
function isE2eSpec(file) {
|
||||||
return /^tests\/e2e\/(?!quarantine\/).+\.spec\.(?:js|ts)$/u.test(file);
|
return /^tests\/e2e\/.+\.spec\.(?:js|ts)$/u.test(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldFallback(file) {
|
function shouldFallback(file) {
|
||||||
@@ -267,8 +261,6 @@ function selectChangedTests(changedFiles) {
|
|||||||
specProjects: new Map(),
|
specProjects: new Map(),
|
||||||
mappedFiles: [],
|
mappedFiles: [],
|
||||||
unmappedFiles: [],
|
unmappedFiles: [],
|
||||||
directSpecFiles: [],
|
|
||||||
skippedDirectSpecFiles: [],
|
|
||||||
fallback: false,
|
fallback: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -278,7 +270,8 @@ function selectChangedTests(changedFiles) {
|
|||||||
const file = normalizePath(rawFile);
|
const file = normalizePath(rawFile);
|
||||||
|
|
||||||
if (isE2eSpec(file)) {
|
if (isE2eSpec(file)) {
|
||||||
selection.directSpecFiles.push(file);
|
addSpec(selection, file, selectedProjects);
|
||||||
|
selection.mappedFiles.push(file);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,26 +295,13 @@ function selectChangedTests(changedFiles) {
|
|||||||
|
|
||||||
selection.mappedFiles.push(file);
|
selection.mappedFiles.push(file);
|
||||||
for (const mapping of matches) {
|
for (const mapping of matches) {
|
||||||
const mappedProjects = mapping.projects.length > 0 ? mapping.projects : selectedProjects;
|
const projects = mapping.projects.filter((project) => selectedProjects.includes(project));
|
||||||
const projects = mappedProjects.filter((project) => selectedProjects.includes(project));
|
|
||||||
if (projects.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const spec of mapping.specs) {
|
for (const spec of mapping.specs) {
|
||||||
addSpec(selection, spec, projects);
|
addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selection.specProjects.size === 0 && !selection.fallback) {
|
|
||||||
for (const file of selection.directSpecFiles) {
|
|
||||||
addSpec(selection, file, selectedProjects);
|
|
||||||
selection.mappedFiles.push(file);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selection.skippedDirectSpecFiles.push(...selection.directSpecFiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
return selection;
|
return selection;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,9 +347,7 @@ async function runChangedSelection(selection) {
|
|||||||
|
|
||||||
if (selection.fallback) {
|
if (selection.fallback) {
|
||||||
console.log(
|
console.log(
|
||||||
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(
|
`[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}`
|
||||||
", "
|
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
const code = await runPlaywright({
|
const code = await runPlaywright({
|
||||||
@@ -392,14 +370,6 @@ async function runChangedSelection(selection) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selection.skippedDirectSpecFiles.length > 0) {
|
|
||||||
console.log(
|
|
||||||
`[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(
|
|
||||||
", "
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [index, group] of groups.entries()) {
|
for (const [index, group] of groups.entries()) {
|
||||||
for (const project of group.projects) {
|
for (const project of group.projects) {
|
||||||
const code = await runPlaywright({
|
const code = await runPlaywright({
|
||||||
@@ -476,9 +446,7 @@ async function main() {
|
|||||||
|
|
||||||
const changed = await getChangedFiles();
|
const changed = await getChangedFiles();
|
||||||
if (changed.unavailable) {
|
if (changed.unavailable) {
|
||||||
console.log(
|
console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`);
|
||||||
`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,24 +48,7 @@ textarea.has-sharp-edges {
|
|||||||
--bulma-box-shadow: none !important;
|
--bulma-box-shadow: none !important;
|
||||||
--bulma-card-shadow: none !important;
|
--bulma-card-shadow: none !important;
|
||||||
|
|
||||||
--bulma-skeleton-background: hsla(197 100% 35% / 0.7) !important;
|
--bulma-skeleton-background: hsla(197 100% 35% / 0.70) !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 {*/
|
/*:root {*/
|
||||||
@@ -121,16 +104,16 @@ body:not(.pleno-large-table-headers) .table thead th {
|
|||||||
margin-top: 30px !important;
|
margin-top: 30px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell .card-content .field {
|
.cell .card-content .field{
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell .card-content .field .label {
|
.cell .card-content .field .label{
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
.mb-8-mobile {
|
.mb-8-mobile{
|
||||||
margin-bottom: 8px !important;
|
margin-bottom: 8px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,4 +125,4 @@ body:not(.pleno-large-table-headers) .table thead th {
|
|||||||
.mt-30-tablet {
|
.mt-30-tablet {
|
||||||
margin-top: 30px !important;
|
margin-top: 30px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||||
import {clearErrors, hasError, parseError} from "@/components/request/HandleGlobalError.vue";
|
import {clearErrors, hasError, parseError} from "@/components/request/HandleGlobalError.vue";
|
||||||
import ShowErrorField from "@/components/global/ShowErrorField.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 Swal from "sweetalert2";
|
||||||
import {IS_DEV} from "@/config.js";
|
import {IS_DEV} from "@/config.js";
|
||||||
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
form_identifier: {
|
form_identifier: {
|
||||||
@@ -105,6 +106,28 @@ 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 isUserAdmin = ref(false);
|
||||||
const randomElementId = Math.random().toString(36).substring(7)
|
const randomElementId = Math.random().toString(36).substring(7)
|
||||||
const fieldErrors = ref([]);
|
const fieldErrors = ref([]);
|
||||||
@@ -496,6 +519,10 @@ SessionUser.objects.forms.get.single(props.form_identifier).then((response) => {
|
|||||||
}
|
}
|
||||||
fields.value = fields_tmp;
|
fields.value = fields_tmp;
|
||||||
setDefaultValues();
|
setDefaultValues();
|
||||||
|
// Wait one tick before rendering the calendars
|
||||||
|
setTimeout(() => {
|
||||||
|
renderCalendars();
|
||||||
|
}, 0);
|
||||||
|
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -669,16 +696,17 @@ const debugGetForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
<!-- Date -->
|
<!-- Date -->
|
||||||
<div v-else-if="getValidator(field.validation).type === 'date'">
|
<div v-else-if="getValidator(field.validation).type === 'date'">
|
||||||
<BuefyDateField
|
<input
|
||||||
v-model="fieldValues[field.id]"
|
class="input is-link"
|
||||||
value-type="string"
|
:type="getValidator(field.validation).type"
|
||||||
:required="isFieldRequired(field)"
|
:required="isFieldRequired(field)"
|
||||||
:name="field.id"
|
:name="field.id"
|
||||||
:id="field.id"
|
:id="field.id"
|
||||||
|
@change="onFieldChange(field, $event.target.value)"
|
||||||
|
v-model="fieldValues[field.id]"
|
||||||
:disabled="isFieldLocked(field)"
|
:disabled="isFieldLocked(field)"
|
||||||
:placeholder="field.metadata.placeholder"
|
v-bind:placeholder="field.metadata.placeholder"
|
||||||
@change="(value) => onFieldChange(field, value)"
|
>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- If the validator is not defined -->
|
<!-- If the validator is not defined -->
|
||||||
|
|||||||
@@ -19,10 +19,6 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
userScopedUserId: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -46,7 +42,7 @@ const paginationKey = computed(() =>
|
|||||||
const onInviteClick = async () => {
|
const onInviteClick = async () => {
|
||||||
await SessionUser.objects.subusers.functions.showInviteForm(() => {
|
await SessionUser.objects.subusers.functions.showInviteForm(() => {
|
||||||
paginationVersion.value += 1;
|
paginationVersion.value += 1;
|
||||||
}, { superuser: props.superuserPage, userId: props.userScopedUserId });
|
}, { superuser: props.superuserPage });
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -79,7 +75,6 @@ const onInviteClick = async () => {
|
|||||||
:key="paginationKey"
|
:key="paginationKey"
|
||||||
:endpoint="endpoint"
|
:endpoint="endpoint"
|
||||||
:show-customer="showCustomer"
|
:show-customer="showCustomer"
|
||||||
:user-scoped-user-id="userScopedUserId"
|
|
||||||
auto-load="true"
|
auto-load="true"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const { t } = useI18n();
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="superuser-users-index" data-testid="superuser-users-index">
|
<div>
|
||||||
<PageTitle :title="t('superuser.pages.employees.title')" :subtitle="t('superuser.pages.employees.subtitle')">
|
<PageTitle :title="t('superuser.pages.employees.title')" :subtitle="t('superuser.pages.employees.subtitle')">
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<button class="button is-dark" @click="showCreateUserForm">
|
<button class="button is-dark" @click="showCreateUserForm">
|
||||||
@@ -24,7 +24,5 @@ const { t } = useI18n();
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.superuser-users-index {
|
|
||||||
min-width: 0;
|
</style>
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -6,7 +6,6 @@ import Swal from "sweetalert2";
|
|||||||
import CustomerProductDiscountDisplay
|
import CustomerProductDiscountDisplay
|
||||||
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
|
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { getCustomerProductRestriction } from "@/features/customer/customerProductRules.js";
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const expandIcon = ref(null);
|
const expandIcon = ref(null);
|
||||||
@@ -80,6 +79,27 @@ const toggleIsSelected = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasRestrictAdditionalServices = () => {
|
||||||
|
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictAdditionalServices');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRestrictSpotFree = () => {
|
||||||
|
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictSpotFree');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRestrictInteriorCleaning = () => {
|
||||||
|
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictInteriorCleaning');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRestrictTankCleaning = () => {
|
||||||
|
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'restrictTankCleaning');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasOnlyTankCleaning = () => {
|
||||||
|
return customerAttributesSafe.value.some((attribute) => attribute.attribute === 'onlyTankCleaning');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On before add product addon
|
* On before add product addon
|
||||||
* This is used to check if adding a product has any requirements.
|
* This is used to check if adding a product has any requirements.
|
||||||
@@ -89,9 +109,6 @@ const toggleIsSelected = () => {
|
|||||||
* @param onAfterPreCheck
|
* @param onAfterPreCheck
|
||||||
*/
|
*/
|
||||||
const onBeforeAddProductAddon = (addon, onAfterPreCheck) => {
|
const onBeforeAddProductAddon = (addon, onAfterPreCheck) => {
|
||||||
if (isAddonRestricted(addon)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Check if the addon already is selected
|
// Check if the addon already is selected
|
||||||
if (isAddonSelected(props.id, addon.option_id)) { onAfterPreCheck(); return; }
|
if (isAddonSelected(props.id, addon.option_id)) { onAfterPreCheck(); return; }
|
||||||
let note = null
|
let note = null
|
||||||
@@ -122,9 +139,6 @@ const onBeforeAddProductAddon = (addon, onAfterPreCheck) => {
|
|||||||
* @param onAfterPreCheck
|
* @param onAfterPreCheck
|
||||||
*/
|
*/
|
||||||
const onBeforeToggleProductAddon = (addon, isAddonCurrentlySelected, onAfterPreCheck) => {
|
const onBeforeToggleProductAddon = (addon, isAddonCurrentlySelected, onAfterPreCheck) => {
|
||||||
if (!isAddonCurrentlySelected && isAddonRestricted(addon)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Debug:
|
// Debug:
|
||||||
console.log(addon, isAddonCurrentlySelected);
|
console.log(addon, isAddonCurrentlySelected);
|
||||||
let note = null
|
let note = null
|
||||||
@@ -148,36 +162,37 @@ const doesAddonHaveNoteRequirement = (addon) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isAddonRestricted = (addon) => {
|
const isAddonRestricted = (addon) => {
|
||||||
const addonProduct = getAddonProduct(addon);
|
const addonName = getAddonName(addon).toLowerCase();
|
||||||
return getCustomerProductRestriction({
|
// Check if the addon has "Spot Free" in the name
|
||||||
...addonProduct,
|
if (hasRestrictSpotFree() && addonName.includes('spot free')) {
|
||||||
name: getAddonName(addon) || addonProduct.name,
|
return true;
|
||||||
category: addonProduct.category ?? addon?.category,
|
}
|
||||||
}, customerAttributesSafe.value, {
|
// Check if the addon has "Indvendig vask" in the name
|
||||||
includeNumericAddonCategory: true,
|
if (hasRestrictInteriorCleaning() && addonName.includes('indvendig vask')) {
|
||||||
isRelatedAddon: true,
|
return true;
|
||||||
}).restricted;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const getAddonRestrictionMessage = (addon) => {
|
|
||||||
const addonProduct = getAddonProduct(addon);
|
|
||||||
const restriction = getCustomerProductRestriction({
|
|
||||||
...addonProduct,
|
|
||||||
name: getAddonName(addon) || addonProduct.name,
|
|
||||||
category: addonProduct.category ?? addon?.category,
|
|
||||||
}, customerAttributesSafe.value, {
|
|
||||||
includeNumericAddonCategory: true,
|
|
||||||
isRelatedAddon: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return restriction.messageKey ? t(restriction.messageKey) : "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isProductRestricted = () => {
|
const isProductRestricted = () => {
|
||||||
return getCustomerProductRestriction({
|
const productName = String(props.name || '').toLowerCase();
|
||||||
...props.product,
|
// Check if the product has "Spot Free" in the name
|
||||||
name: props.name || props.product?.name,
|
if (hasRestrictSpotFree() && productName.includes('spot free')) {
|
||||||
}, customerAttributesSafe.value).restricted;
|
return true;
|
||||||
|
}
|
||||||
|
// Check if the product has "Indvendig vask" in the name
|
||||||
|
if (hasRestrictInteriorCleaning() && productName.includes('indvendig vask')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check if the product category is 5 (Tank Cleaning)
|
||||||
|
if (props.product?.category === 5) {
|
||||||
|
if (hasRestrictTankCleaning()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!hasOnlyTankCleaning()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderByOrderPriority = (addons) => {
|
const orderByOrderPriority = (addons) => {
|
||||||
@@ -246,7 +261,20 @@ const orderByOrderPriority = (addons) => {
|
|||||||
<div v-if="isSelected" class="column is-12-desktop">
|
<div v-if="isSelected" class="column is-12-desktop">
|
||||||
<!-- Addons -->
|
<!-- Addons -->
|
||||||
<div class="is-centered mt-3 mb-0 pl-6" v-if="addonsSafe.length > 0">
|
<div class="is-centered mt-3 mb-0 pl-6" v-if="addonsSafe.length > 0">
|
||||||
<template v-for="addon in orderByOrderPriority(addonsSafe)" :key="addon.id">
|
<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-if="!isAddonRestricted(addon)">
|
<template v-if="!isAddonRestricted(addon)">
|
||||||
<div
|
<div
|
||||||
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
|
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
|
||||||
@@ -293,21 +321,18 @@ const orderByOrderPriority = (addons) => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- Addon is restricted -->
|
<!-- Addon is restricted -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<div class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap">
|
||||||
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
|
<button
|
||||||
:data-testid="`pos-addon-restriction-${getAddonProduct(addon).id ?? addon.option_id ?? addon.id}`"
|
class="button is-small is-fullwidth is-danger"
|
||||||
>
|
disabled
|
||||||
<button
|
>
|
||||||
class="button is-small is-fullwidth is-danger is-light"
|
<span class="icon is-small">
|
||||||
disabled
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
>
|
</span>
|
||||||
<span class="icon is-small">
|
<span>{{ addon.name }} er ikke tilladt</span>
|
||||||
<i class="fas fa-exclamation-triangle"></i>
|
</button>
|
||||||
</span>
|
</div>
|
||||||
<span>{{ addon.name }} / {{ getAddonRestrictionMessage(addon) }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,5 +383,4 @@ const orderByOrderPriority = (addons) => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -93,10 +93,6 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
directSectionKeys: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
allowBookingCompletion: {
|
allowBookingCompletion: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -130,8 +126,6 @@ const dropdownRoot = ref(null);
|
|||||||
const dropdownContent = ref(null);
|
const dropdownContent = ref(null);
|
||||||
const isDropdownOpen = ref(false);
|
const isDropdownOpen = ref(false);
|
||||||
const dropdownInstanceId = Math.random().toString(36).substring(2, 15);
|
const dropdownInstanceId = Math.random().toString(36).substring(2, 15);
|
||||||
const dropdownPlacement = ref("bottom");
|
|
||||||
const isDropdownPlacementLocked = ref(false);
|
|
||||||
const shouldOpenDropdownUp = ref(false);
|
const shouldOpenDropdownUp = ref(false);
|
||||||
const dropdownMaxHeight = ref(null);
|
const dropdownMaxHeight = ref(null);
|
||||||
const isDesktopFlyoutLayout = ref(false);
|
const isDesktopFlyoutLayout = ref(false);
|
||||||
@@ -146,7 +140,6 @@ const previewRequestsInFlight = new Set();
|
|||||||
const generatedObjectUrls = new Set();
|
const generatedObjectUrls = new Set();
|
||||||
let previewStateGeneration = 0;
|
let previewStateGeneration = 0;
|
||||||
let contentResizeObserver = null;
|
let contentResizeObserver = null;
|
||||||
let dropdownLayoutUpdateId = 0;
|
|
||||||
const desktopFlyoutMinViewportWidth = 1400;
|
const desktopFlyoutMinViewportWidth = 1400;
|
||||||
const desktopFlyoutRootPanelWidthRem = 15;
|
const desktopFlyoutRootPanelWidthRem = 15;
|
||||||
const desktopFlyoutSubmenuWidthRem = 17;
|
const desktopFlyoutSubmenuWidthRem = 17;
|
||||||
@@ -237,8 +230,6 @@ const dropdownContentStyle = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const resetDropdownLayout = () => {
|
const resetDropdownLayout = () => {
|
||||||
dropdownPlacement.value = "bottom";
|
|
||||||
isDropdownPlacementLocked.value = false;
|
|
||||||
shouldOpenDropdownUp.value = false;
|
shouldOpenDropdownUp.value = false;
|
||||||
dropdownMaxHeight.value = null;
|
dropdownMaxHeight.value = null;
|
||||||
isFixedPosition.value = false;
|
isFixedPosition.value = false;
|
||||||
@@ -300,35 +291,7 @@ 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 updateDropdownLayout = async () => {
|
||||||
const updateId = ++dropdownLayoutUpdateId;
|
|
||||||
|
|
||||||
if (!isDropdownOpen.value || !dropdownRoot.value || !dropdownContent.value) {
|
if (!isDropdownOpen.value || !dropdownRoot.value || !dropdownContent.value) {
|
||||||
resetDropdownLayout();
|
resetDropdownLayout();
|
||||||
return;
|
return;
|
||||||
@@ -336,41 +299,35 @@ const updateDropdownLayout = async () => {
|
|||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownRoot.value || !dropdownContent.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const triggerElement = dropdownRoot.value.querySelector(".dropdown-trigger");
|
const triggerElement = dropdownRoot.value.querySelector(".dropdown-trigger");
|
||||||
const triggerRect = (triggerElement ?? dropdownRoot.value).getBoundingClientRect();
|
const triggerRect = (triggerElement ?? dropdownRoot.value).getBoundingClientRect();
|
||||||
syncDesktopFlyoutState(triggerRect);
|
syncDesktopFlyoutState(triggerRect);
|
||||||
|
syncDesktopFlyoutPosition();
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownContent.value) {
|
if (!dropdownContent.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewportInsets = getViewportInsets();
|
const viewportInsets = getViewportInsets();
|
||||||
const viewportTop = viewportInsets.top;
|
const viewportTop = viewportInsets.top;
|
||||||
const viewportBottom = window.innerHeight - viewportInsets.bottom;
|
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 menuHeight = Math.ceil(dropdownContent.value.scrollHeight);
|
||||||
lockDropdownPlacement(triggerRect, menuHeight, viewportInsets);
|
const openUpward = menuHeight > spaceBelow && spaceAbove > spaceBelow;
|
||||||
const availableHeight = getDropdownAvailableHeight(triggerRect, viewportInsets);
|
const availableHeight = openUpward ? spaceAbove : spaceBelow;
|
||||||
const nextMaxHeight = availableHeight > 0 && menuHeight > availableHeight ? Math.floor(availableHeight) : null;
|
const nextMaxHeight = availableHeight > 0 && menuHeight > availableHeight ? Math.floor(availableHeight) : null;
|
||||||
|
|
||||||
|
shouldOpenDropdownUp.value = openUpward;
|
||||||
dropdownMaxHeight.value = nextMaxHeight;
|
dropdownMaxHeight.value = nextMaxHeight;
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownContent.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncDesktopFlyoutPosition();
|
syncDesktopFlyoutPosition();
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value || !dropdownContent.value) {
|
if (!dropdownContent.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,12 +342,6 @@ const updateDropdownLayout = async () => {
|
|||||||
const boundedHeight = Math.floor(renderedMenuRect.height - overflowAbove - overflowBelow);
|
const boundedHeight = Math.floor(renderedMenuRect.height - overflowAbove - overflowBelow);
|
||||||
dropdownMaxHeight.value = boundedHeight > 0 ? boundedHeight : 1;
|
dropdownMaxHeight.value = boundedHeight > 0 ? boundedHeight : 1;
|
||||||
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
if (updateId !== dropdownLayoutUpdateId || !isDropdownOpen.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncDesktopFlyoutPosition();
|
syncDesktopFlyoutPosition();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1514,7 +1465,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
contentResizeObserver = new ResizeObserver(() => {
|
contentResizeObserver = new ResizeObserver(() => {
|
||||||
if (isDropdownOpen.value) {
|
if (isDropdownOpen.value) {
|
||||||
void updateDropdownLayout();
|
syncDesktopFlyoutPosition();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2411,15 +2362,6 @@ const standaloneMenuActions = computed(() => {
|
|||||||
return actions;
|
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 desktopFlyoutMenuSections = computed(() => {
|
||||||
const mergedSections = [];
|
const mergedSections = [];
|
||||||
const mergedSectionsByLabel = new Map();
|
const mergedSectionsByLabel = new Map();
|
||||||
@@ -2457,7 +2399,7 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
if (isDropdownOpen.value) {
|
if (isDropdownOpen.value) {
|
||||||
void nextTick(() => {
|
void nextTick(() => {
|
||||||
void updateDropdownLayout();
|
syncDesktopFlyoutPosition();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2565,6 +2507,7 @@ const syncDesktopFlyoutPosition = () => {
|
|||||||
|
|
||||||
const triggerRect = triggerEl.getBoundingClientRect();
|
const triggerRect = triggerEl.getBoundingClientRect();
|
||||||
const contentHeight = dropdownContentEl.scrollHeight;
|
const contentHeight = dropdownContentEl.scrollHeight;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
const viewportWidth = window.innerWidth;
|
const viewportWidth = window.innerWidth;
|
||||||
const contentRect = dropdownContentEl.getBoundingClientRect();
|
const contentRect = dropdownContentEl.getBoundingClientRect();
|
||||||
|
|
||||||
@@ -2594,13 +2537,12 @@ const syncDesktopFlyoutPosition = () => {
|
|||||||
|
|
||||||
const viewportInsets = getViewportInsets();
|
const viewportInsets = getViewportInsets();
|
||||||
const viewportTop = viewportInsets.top;
|
const viewportTop = viewportInsets.top;
|
||||||
const effectiveContentHeight = dropdownMaxHeight.value
|
const viewportBottom = viewportHeight - viewportInsets.bottom;
|
||||||
? Math.min(contentHeight, dropdownMaxHeight.value)
|
let top = triggerRect.bottom;
|
||||||
: contentHeight;
|
|
||||||
const top =
|
if (top + contentHeight > viewportBottom) {
|
||||||
dropdownPlacement.value === "top"
|
top = Math.max(viewportTop, triggerRect.top - contentHeight);
|
||||||
? Math.max(viewportTop, triggerRect.top - effectiveContentHeight)
|
}
|
||||||
: triggerRect.bottom;
|
|
||||||
|
|
||||||
const nextFixedStyles = {
|
const nextFixedStyles = {
|
||||||
top: `${top}px`,
|
top: `${top}px`,
|
||||||
@@ -2616,8 +2558,18 @@ const syncDesktopFlyoutPosition = () => {
|
|||||||
fixedPositionStyles.value = nextFixedStyles;
|
fixedPositionStyles.value = nextFixedStyles;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (dropdownContentEl.style.top !== "") {
|
const contentRect = dropdownContentEl.getBoundingClientRect();
|
||||||
dropdownContentEl.style.top = "";
|
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 = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2637,7 +2589,7 @@ const syncDesktopFlyoutPosition = () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<slot v-if="hasCustomActionsSlot" name="actions"></slot>
|
<slot v-if="hasCustomActionsSlot" name="actions"></slot>
|
||||||
<template v-for="section in directBuiltInMenuSections" :key="section.key">
|
<template v-for="section in flatBuiltInMenuSections" :key="section.key">
|
||||||
<ActionSettingsWheelItemLabel :label="section.label" />
|
<ActionSettingsWheelItemLabel :label="section.label" />
|
||||||
<template v-for="item in section.items" :key="item.key">
|
<template v-for="item in section.items" :key="item.key">
|
||||||
<ActionSettingsWheelToggleItem
|
<ActionSettingsWheelToggleItem
|
||||||
|
|||||||
@@ -24,24 +24,8 @@ const onCustomerChange = () => {
|
|||||||
isCustomerSelected.value = false;
|
isCustomerSelected.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseFixedPriceValue = (value) => {
|
const parseValue = (value) => {
|
||||||
if (value === null || value === undefined || value === '') {
|
let tmp_value = parseFloat(value).toFixed(2);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = Number.parseInt(String(value), 10);
|
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatNumber = (value) => Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
||||||
|
|
||||||
const parseValue = (discount) => {
|
|
||||||
const fixedPrice = parseFixedPriceValue(discount.fixed_price);
|
|
||||||
if (fixedPrice !== null) {
|
|
||||||
return `${formatNumber(fixedPrice)} Kr.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmp_value = parseFloat(discount.percentage).toFixed(2);
|
|
||||||
// Add the percentage sign
|
// Add the percentage sign
|
||||||
return `${tmp_value}%`;
|
return `${tmp_value}%`;
|
||||||
}
|
}
|
||||||
@@ -94,7 +78,7 @@ watch(customer_id, onCustomerChange, { immediate: true });
|
|||||||
<!--<span class="icon is-small mr-1">
|
<!--<span class="icon is-small mr-1">
|
||||||
<i class="fas fa-percent" aria-hidden="true"></i>
|
<i class="fas fa-percent" aria-hidden="true"></i>
|
||||||
</span> -->
|
</span> -->
|
||||||
<span>{{ parseValue(discount) }}</span>
|
<span>{{ parseValue(discount.percentage) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,4 +86,4 @@ watch(customer_id, onCustomerChange, { immediate: true });
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -29,8 +29,6 @@ const discount_product = ref(null);
|
|||||||
const discount_category = ref(null);
|
const discount_category = ref(null);
|
||||||
// The global discount (if any)
|
// The global discount (if any)
|
||||||
const discount_global = ref(null);
|
const discount_global = ref(null);
|
||||||
// The fixed product price (if any)
|
|
||||||
const fixed_product_price = ref(null);
|
|
||||||
|
|
||||||
// Show the discounts dropdown
|
// Show the discounts dropdown
|
||||||
const showDiscountsDropdown = ref(false);
|
const showDiscountsDropdown = ref(false);
|
||||||
@@ -51,19 +49,6 @@ const hasGlobalDiscount = () => {
|
|||||||
return discount_global.value > 0;
|
return discount_global.value > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFixedProductPrice = () => {
|
|
||||||
return fixed_product_price.value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseFixedPriceValue = (value) => {
|
|
||||||
if (value === null || value === undefined || value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = Number.parseInt(String(value), 10);
|
|
||||||
return Number.isFinite(parsed) ? parsed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the product discount
|
// Set the product discount
|
||||||
const setProductDiscount = (percentage) => {
|
const setProductDiscount = (percentage) => {
|
||||||
// Check if the percentage is a number
|
// Check if the percentage is a number
|
||||||
@@ -98,16 +83,6 @@ const setGlobalDiscount = (percentage) => {
|
|||||||
discount_global.value = percentage;
|
discount_global.value = percentage;
|
||||||
}
|
}
|
||||||
|
|
||||||
const setFixedProductPrice = (fixedPrice) => {
|
|
||||||
const parsedFixedPrice = parseFixedPriceValue(fixedPrice);
|
|
||||||
if (parsedFixedPrice === null) {
|
|
||||||
fixed_product_price.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fixed_product_price.value = parsedFixedPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Debugging function
|
// Debugging function
|
||||||
const getDiscountDebug = () => {
|
const getDiscountDebug = () => {
|
||||||
@@ -126,15 +101,9 @@ const getDiscountDebug = () => {
|
|||||||
|
|
||||||
// Parse the customer discounts
|
// Parse the customer discounts
|
||||||
const parseCustomerDiscounts = () => {
|
const parseCustomerDiscounts = () => {
|
||||||
discount_product.value = null;
|
const tmp_discount_product = props.customer_discounts.find((discount) => discount.product_or_category_id === props.product.id && !discount.is_category)
|
||||||
discount_category.value = null;
|
const tmp_discount_category = props.customer_discounts.find((discount) => discount.product_or_category_id === props.product.category && discount.is_category);
|
||||||
discount_global.value = null;
|
const tmp_discount_global = props.customer_discounts.find((discount) => discount.id === 999999 && discount.is_category);
|
||||||
fixed_product_price.value = null;
|
|
||||||
|
|
||||||
const customerDiscounts = Array.isArray(props.customer_discounts) ? props.customer_discounts : [];
|
|
||||||
const tmp_discount_product = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.id && Number(discount.is_category) === 0)
|
|
||||||
const tmp_discount_category = customerDiscounts.find((discount) => discount.product_or_category_id == props.product.category && Number(discount.is_category) === 1);
|
|
||||||
const tmp_discount_global = customerDiscounts.find((discount) => discount.id === 999999 && Number(discount.is_category) === 1);
|
|
||||||
|
|
||||||
//console.log("Product discount: ", tmp_discount_product);
|
//console.log("Product discount: ", tmp_discount_product);
|
||||||
//console.log("Category discount: ", tmp_discount_category);
|
//console.log("Category discount: ", tmp_discount_category);
|
||||||
@@ -143,7 +112,6 @@ const parseCustomerDiscounts = () => {
|
|||||||
// Set the product discount
|
// Set the product discount
|
||||||
if (tmp_discount_product) {
|
if (tmp_discount_product) {
|
||||||
setProductDiscount(tmp_discount_product.percentage);
|
setProductDiscount(tmp_discount_product.percentage);
|
||||||
setFixedProductPrice(tmp_discount_product.fixed_price);
|
|
||||||
}
|
}
|
||||||
// Set the category discount
|
// Set the category discount
|
||||||
if (tmp_discount_category) {
|
if (tmp_discount_category) {
|
||||||
@@ -157,10 +125,6 @@ const parseCustomerDiscounts = () => {
|
|||||||
|
|
||||||
// Get the best discount for the customer
|
// Get the best discount for the customer
|
||||||
const getBestDiscount = () => {
|
const getBestDiscount = () => {
|
||||||
if (hasFixedProductPrice()) {
|
|
||||||
highestEligibleDiscount.value = 0;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
// Set the best discount to 0
|
// Set the best discount to 0
|
||||||
let tmp_best_discount = 0;
|
let tmp_best_discount = 0;
|
||||||
// Check if the product discount is higher than the current best discount
|
// Check if the product discount is higher than the current best discount
|
||||||
@@ -218,32 +182,13 @@ watch(() => props.customer_discounts, () => {
|
|||||||
<span
|
<span
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-controls="dropdown-menu"
|
aria-controls="dropdown-menu"
|
||||||
v-if="hasFixedProductPrice()"
|
v-if="highestEligibleDiscount > 0"
|
||||||
class="tag is-success is-light is-text text-can-not-select"
|
|
||||||
>{{ fixed_product_price }} Kr.</span>
|
|
||||||
<span
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-controls="dropdown-menu"
|
|
||||||
v-else-if="highestEligibleDiscount > 0"
|
|
||||||
class="tag is-warning is-light is-text text-can-not-select"
|
class="tag is-warning is-light is-text text-can-not-select"
|
||||||
> -{{ highestEligibleDiscount }}%</span>
|
> -{{ highestEligibleDiscount }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
<div class="dropdown-menu" id="dropdown-menu" role="menu">
|
||||||
<div class="dropdown-content py-0">
|
<div class="dropdown-content py-0">
|
||||||
<div class="list has-overflow-ellipsis" style="width: 340px">
|
<div class="list has-overflow-ellipsis" style="width: 340px">
|
||||||
<template v-if="hasFixedProductPrice()">
|
|
||||||
<a class="list-item">
|
|
||||||
<div class="list-item-content">
|
|
||||||
<div class="list-item-title">Fast pris</div>
|
|
||||||
</div>
|
|
||||||
<div class="list-item-controls list-item-controls-force-visible">
|
|
||||||
<div class="tags has-addons">
|
|
||||||
<span class="tag is-success is-light">{{ fixed_product_price }} Kr.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<!-- Best discount -->
|
<!-- Best discount -->
|
||||||
<a class="list-item">
|
<a class="list-item">
|
||||||
<div class="list-item-content">
|
<div class="list-item-content">
|
||||||
@@ -294,7 +239,6 @@ watch(() => props.customer_discounts, () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -310,4 +254,4 @@ watch(() => props.customer_discounts, () => {
|
|||||||
.text-can-not-select {
|
.text-can-not-select {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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",
|
|
||||||
});
|
|
||||||
@@ -30,7 +30,6 @@ import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/
|
|||||||
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
|
import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue";
|
||||||
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
|
import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue";
|
||||||
import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue";
|
import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue";
|
||||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
|
import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js";
|
||||||
import {
|
import {
|
||||||
@@ -378,8 +377,8 @@ const fetchDuplicateOrdersForContext = async (context) => {
|
|||||||
try {
|
try {
|
||||||
const response = await SessionUser.request(SessionUser.objects.orders.meta.endpoint, "GET", {
|
const response = await SessionUser.request(SessionUser.objects.orders.meta.endpoint, "GET", {
|
||||||
filters: `reg_1:${normalizedContext.reg1},department_id:${department_id.value},created_at-date_from:${
|
filters: `reg_1:${normalizedContext.reg1},department_id:${department_id.value},created_at-date_from:${
|
||||||
todayLocalDateOnly()
|
new Date().toISOString().split("T")[0]
|
||||||
},created_at-date_to:${todayLocalDateOnly()}`,
|
},created_at-date_to:${new Date().toISOString().split("T")[0]}`,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ import {
|
|||||||
reg_2,
|
reg_2,
|
||||||
reg_3,
|
reg_3,
|
||||||
department_id,
|
department_id,
|
||||||
customer_id,
|
customer_id,
|
||||||
getCustomerEmail,
|
getCustomerEmail,
|
||||||
customer_name,
|
customer_name,
|
||||||
getAddonRestriction,
|
isAddonRestricted,
|
||||||
getProductRestriction,
|
canBuyAdditionalServices,
|
||||||
registerPosStepSaveBarrier,
|
registerPosStepSaveBarrier,
|
||||||
saveOrderMetadataField,
|
saveOrderMetadataField,
|
||||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||||
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.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";
|
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
||||||
@@ -343,14 +343,13 @@ const applyPendingBookingFromSelection = async () => {
|
|||||||
effectivePrimaryProduct = firstWash;
|
effectivePrimaryProduct = firstWash;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
effectivePrimaryProduct.addons = preparedAddons as any;
|
effectivePrimaryProduct.addons = preparedAddons as any;
|
||||||
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
|
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
|
||||||
if (transactionItems.primaryItem.value) {
|
if (transactionItems.primaryItem.value) {
|
||||||
transactionItems.primaryItem.value.addons = preparedAddons as any;
|
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('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
|
||||||
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
|
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
|
||||||
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
|
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
|
||||||
@@ -482,116 +481,9 @@ const layout = {
|
|||||||
};
|
};
|
||||||
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
|
||||||
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
|
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) => {
|
const onCopyLastOrder = (vehicleIndex: number) => {
|
||||||
lastOrders.select(vehicleIndex);
|
lastOrders.select(vehicleIndex);
|
||||||
sanitizeRestrictedTransactionItems();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCustomerSelection = () => {
|
const openCustomerSelection = () => {
|
||||||
@@ -894,11 +786,10 @@ const buildDesiredOrderItemShapes = () => {
|
|||||||
related_item_id: null,
|
related_item_id: null,
|
||||||
price: Number(transactionItems.primaryItem.value.price ?? 0),
|
price: Number(transactionItems.primaryItem.value.price ?? 0),
|
||||||
notes: String(transactionItems.primaryItem.value?.notes ?? ""),
|
notes: String(transactionItems.primaryItem.value?.notes ?? ""),
|
||||||
skip_price_override: transactionItems.primaryItem.value?.skip_price_override === true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addonShapes = (transactionItems.primaryItem.value.addons || [])
|
const addonShapes = (transactionItems.primaryItem.value.addons || [])
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||||
.map((addon: any) => {
|
.map((addon: any) => {
|
||||||
const addonProduct = addon?.product ?? addon;
|
const addonProduct = addon?.product ?? addon;
|
||||||
return {
|
return {
|
||||||
@@ -909,12 +800,11 @@ const buildDesiredOrderItemShapes = () => {
|
|||||||
related_item_id: "__PRIMARY__",
|
related_item_id: "__PRIMARY__",
|
||||||
price: Number(addonProduct?.price ?? addon?.price ?? 0),
|
price: Number(addonProduct?.price ?? addon?.price ?? 0),
|
||||||
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
|
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
|
||||||
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const additionalShapes = (transactionItems.additionalItems.value || [])
|
const additionalShapes = (transactionItems.additionalItems.value || [])
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||||
.map((item: any) => ({
|
.map((item: any) => ({
|
||||||
kind: "additional",
|
kind: "additional",
|
||||||
relatedKey: null,
|
relatedKey: null,
|
||||||
@@ -923,7 +813,6 @@ const buildDesiredOrderItemShapes = () => {
|
|||||||
related_item_id: null,
|
related_item_id: null,
|
||||||
price: Number(item?.price ?? 0),
|
price: Number(item?.price ?? 0),
|
||||||
notes: String(item?.notes ?? ""),
|
notes: String(item?.notes ?? ""),
|
||||||
skip_price_override: item?.skip_price_override === true,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [primaryShape, ...addonShapes, ...additionalShapes];
|
return [primaryShape, ...addonShapes, ...additionalShapes];
|
||||||
@@ -975,8 +864,8 @@ const buildCurrentSelectionComparableShapes = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addonShapes = sortComparableLastWashShapes(
|
const addonShapes = sortComparableLastWashShapes(
|
||||||
(transactionItems.primaryItem.value.addons || [])
|
(transactionItems.primaryItem.value.addons || [])
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||||
.map((addon: any) => ({
|
.map((addon: any) => ({
|
||||||
kind: "addon",
|
kind: "addon",
|
||||||
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
||||||
@@ -985,8 +874,8 @@ const buildCurrentSelectionComparableShapes = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const additionalShapes = sortComparableLastWashShapes(
|
const additionalShapes = sortComparableLastWashShapes(
|
||||||
(transactionItems.additionalItems.value || [])
|
(transactionItems.additionalItems.value || [])
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||||
.map((item: any) => ({
|
.map((item: any) => ({
|
||||||
kind: "additional",
|
kind: "additional",
|
||||||
product_id: Number(item?.id ?? 0),
|
product_id: Number(item?.id ?? 0),
|
||||||
@@ -1060,10 +949,8 @@ const syncCurrentTransactionToOrder = async () => {
|
|||||||
|
|
||||||
const desiredShapes = buildDesiredOrderItemShapes();
|
const desiredShapes = buildDesiredOrderItemShapes();
|
||||||
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
|
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 (!shouldForceRecreateForRepricing && JSON.stringify(currentShapes) === JSON.stringify(comparableDesiredShapes)) {
|
if (JSON.stringify(currentShapes) === JSON.stringify(desiredShapes.map(({ kind, relatedKey, ...shape }) => shape))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,12 +962,12 @@ const syncCurrentTransactionToOrder = async () => {
|
|||||||
1,
|
1,
|
||||||
null,
|
null,
|
||||||
transactionItems.primaryItem.value?.notes || "",
|
transactionItems.primaryItem.value?.notes || "",
|
||||||
transactionItems.primaryItem.value.skip_price_override === true ? null : transactionItems.primaryItem.value.price
|
transactionItems.primaryItem.value.price
|
||||||
);
|
);
|
||||||
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
|
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
|
||||||
|
|
||||||
const addonPromises = (transactionItems.primaryItem.value.addons || [])
|
const addonPromises = (transactionItems.primaryItem.value.addons || [])
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||||
.map((addon: any) => {
|
.map((addon: any) => {
|
||||||
const addonProduct = addon?.product ?? addon;
|
const addonProduct = addon?.product ?? addon;
|
||||||
return createOrderItem(
|
return createOrderItem(
|
||||||
@@ -1089,23 +976,14 @@ const syncCurrentTransactionToOrder = async () => {
|
|||||||
Number(addon.quantity),
|
Number(addon.quantity),
|
||||||
createdPrimaryItemId,
|
createdPrimaryItemId,
|
||||||
addonProduct?.notes || addon?.notes || "",
|
addonProduct?.notes || addon?.notes || "",
|
||||||
addonProduct?.skip_price_override === true || addon?.skip_price_override === true
|
addonProduct.price ?? addon.price
|
||||||
? null
|
|
||||||
: addonProduct.price ?? addon.price
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const additionalPromises = (transactionItems.additionalItems.value || [])
|
const additionalPromises = (transactionItems.additionalItems.value || [])
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||||
.map((item: any) =>
|
.map((item: any) =>
|
||||||
createOrderItem(
|
createOrderItem(normalizedOrderId, item.id, Number(item.quantity), null, item?.notes || "", item.price)
|
||||||
normalizedOrderId,
|
|
||||||
item.id,
|
|
||||||
Number(item.quantity),
|
|
||||||
null,
|
|
||||||
item?.notes || "",
|
|
||||||
item?.skip_price_override === true ? null : item.price
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all([...addonPromises, ...additionalPromises]);
|
await Promise.all([...addonPromises, ...additionalPromises]);
|
||||||
@@ -1144,8 +1022,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
|
|||||||
missingProducts.push(primaryProduct);
|
missingProducts.push(primaryProduct);
|
||||||
}
|
}
|
||||||
|
|
||||||
(primaryProduct?.addons || [])
|
(primaryProduct?.addons || [])
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0)
|
||||||
.forEach((addon: any) => {
|
.forEach((addon: any) => {
|
||||||
const addonProduct = addon?.product ?? addon;
|
const addonProduct = addon?.product ?? addon;
|
||||||
if (
|
if (
|
||||||
@@ -1157,8 +1035,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
(transactionItems.additionalItems.value || [])
|
(transactionItems.additionalItems.value || [])
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
.filter((item: any) => Number(item?.quantity ?? 0) > 0)
|
||||||
.forEach((item: any) => {
|
.forEach((item: any) => {
|
||||||
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
|
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
|
||||||
missingProducts.push(item);
|
missingProducts.push(item);
|
||||||
@@ -1248,8 +1126,6 @@ const onBeforeComplete = async () => {
|
|||||||
throw new Error("No primary item selected");
|
throw new Error("No primary item selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
sanitizeRestrictedTransactionItems();
|
|
||||||
|
|
||||||
if (!(await ensureRequiredOrderItemNotes())) {
|
if (!(await ensureRequiredOrderItemNotes())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1280,20 +1156,13 @@ const mapAddonsWithQuantity = (sourceAddons = [], previousAddons = []) =>
|
|||||||
const sourceAddonProductId = getAddonProductId(addon);
|
const sourceAddonProductId = getAddonProductId(addon);
|
||||||
const previousAddon = previousAddons.find((a) => getAddonProductId(a) === sourceAddonProductId);
|
const previousAddon = previousAddons.find((a) => getAddonProductId(a) === sourceAddonProductId);
|
||||||
const nextQuantity = previousAddon?.quantity ?? addon?.quantity ?? addon?.product?.quantity ?? 0;
|
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 {
|
return {
|
||||||
...addon,
|
...addon,
|
||||||
quantity: nextQuantity,
|
quantity: nextQuantity,
|
||||||
skip_price_override: skipPriceOverride,
|
|
||||||
product: addon?.product
|
product: addon?.product
|
||||||
? {
|
? {
|
||||||
...addon.product,
|
...addon.product,
|
||||||
quantity: nextQuantity,
|
quantity: nextQuantity,
|
||||||
skip_price_override: skipPriceOverride,
|
|
||||||
}
|
}
|
||||||
: addon?.product,
|
: addon?.product,
|
||||||
};
|
};
|
||||||
@@ -1403,18 +1272,6 @@ 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 for changes in the vehicle 1 reference and update the reference field when it changes
|
||||||
watch(
|
watch(
|
||||||
() => vehicles.vehicle_1.value?.reference,
|
() => vehicles.vehicle_1.value?.reference,
|
||||||
@@ -1425,10 +1282,15 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Computed property to annotate restricted addons based on customer attributes
|
// Computed property to filter out restricted addons based on customer attributes
|
||||||
const filteredAddons = computed(() => {
|
const filteredAddons = computed(() => {
|
||||||
const addons = transactionItems.primaryItem.value?.addons || [];
|
const addons = transactionItems.primaryItem.value?.addons || [];
|
||||||
return addons.map((addon: any) => decorateMobileAddonRestriction(addon));
|
// If additional services are restricted, return empty array
|
||||||
|
if (!canBuyAdditionalServices()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Filter out individually restricted addons
|
||||||
|
return addons.filter((addon: any) => !isAddonRestricted(addon));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1491,17 +1353,10 @@ const filteredAddons = computed(() => {
|
|||||||
<i class="fa fa-search"></i>
|
<i class="fa fa-search"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Registration numbers -->
|
<!-- Registration numbers -->
|
||||||
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
||||||
<p
|
<!-- Product -->
|
||||||
v-if="restrictionWarningMessageKey"
|
<PosDepartmentStepMobile2Product
|
||||||
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:pointerdown="onPrimaryProductPointerDown"
|
||||||
v-on:pointermove="onPrimaryProductPointerMove"
|
v-on:pointermove="onPrimaryProductPointerMove"
|
||||||
v-on:pointerup="onPrimaryProductPointerUp"
|
v-on:pointerup="onPrimaryProductPointerUp"
|
||||||
|
|||||||
@@ -1,58 +1,58 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import { PosOrder } from "../objects/PosOrder.vue";
|
import { PosOrder } from "../objects/PosOrder.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||||
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||||
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.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 PosDepartmentStepMobile2CategoryProduct
|
||||||
import PosDepartmentStepMobile2Addons from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
|
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
|
||||||
import { Addon } from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
|
import PosDepartmentStepMobile2Addons
|
||||||
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
|
||||||
import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
|
||||||
import { transactionItems } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
import PosDepartmentStepMobileFixedBottomControl
|
||||||
import PosDepartmentStepMobile2FloatingCart from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue";
|
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||||
import { customer_id, getProductRestriction, hasAttribute } from "@/components/shop/POSDepartmentProcess.vue";
|
import PosDepartmentStepMobileButtonNextStep
|
||||||
import { useI18n } from "vue-i18n";
|
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
|
||||||
import { orderProducts } from "@/components/shop/Products.vue";
|
import {
|
||||||
|
transactionItems
|
||||||
const { t } = useI18n();
|
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||||
const ADDITIONAL_SERVICES_CATEGORY_ID = 8;
|
import PosDepartmentStep2MobileVehicleSelection
|
||||||
let latestAdditionalSelectionProductsRequestId = 0;
|
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 } from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "Additional items",
|
default: "Additional items",
|
||||||
required: true,
|
required: true
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "Click to modify your additional items",
|
default: "Click to modify your additional items",
|
||||||
required: true,
|
required: true
|
||||||
},
|
},
|
||||||
defaultChecked: {
|
defaultChecked: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false
|
||||||
},
|
},
|
||||||
lastOrder: {
|
lastOrder: {
|
||||||
type: Object as () => PosOrder | null,
|
type: Object as () => PosOrder | null,
|
||||||
default: null,
|
default: null,
|
||||||
required: false,
|
required: false
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const restrictionWarningMessageKey = ref("");
|
|
||||||
const additionalSelectionProducts = ref<PosProduct[]>([]);
|
|
||||||
const additionalSelectionLoading = ref(false);
|
|
||||||
const canSelectAdditionalItems = computed(() => true);
|
|
||||||
const checked = ref(props.defaultChecked);
|
const checked = ref(props.defaultChecked);
|
||||||
// Function to generate a summary from the last order
|
// Function to generate a summary from the last order
|
||||||
function generateSummary(order: PosOrder): string {
|
function generateSummary(order: PosOrder): string {
|
||||||
if (!order || !order.items || order.items.length === 0) {
|
if (!order || !order.items || order.items.length === 0) {
|
||||||
return "Denne ordre har ingen varer.";
|
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));
|
const uniqueItems = Array.from(new Set(itemNames));
|
||||||
return uniqueItems.length > 1
|
return uniqueItems.length > 1
|
||||||
? `${uniqueItems.length} varer: ${uniqueItems.slice(0, 2).join(", ")}${uniqueItems.length > 2 ? " og flere" : ""}`
|
? `${uniqueItems.length} varer: ${uniqueItems.slice(0, 2).join(", ")}${uniqueItems.length > 2 ? " og flere" : ""}`
|
||||||
@@ -66,7 +66,9 @@ const displayLabel = computed(() => {
|
|||||||
: props.label;
|
: props.label;
|
||||||
});
|
});
|
||||||
const displaySubtitle = computed(() => {
|
const displaySubtitle = computed(() => {
|
||||||
return props.lastOrder ? generateSummary(props.lastOrder) : props.subtitle;
|
return props.lastOrder
|
||||||
|
? generateSummary(props.lastOrder)
|
||||||
|
: props.subtitle;
|
||||||
});
|
});
|
||||||
// Emit event on toggle
|
// Emit event on toggle
|
||||||
function onToggle(isOpen: boolean) {
|
function onToggle(isOpen: boolean) {
|
||||||
@@ -82,22 +84,19 @@ const exampleProducts = ref<PosProduct[]>([
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "Extra Towel",
|
name: "Extra Towel",
|
||||||
price: 5.0,
|
price: 5.00,
|
||||||
description: "A soft extra towel",
|
description: "A soft extra towel",
|
||||||
subscription_allowed: false,
|
subscription_allowed: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
name: "Premium Soap",
|
name: "Premium Soap",
|
||||||
price: 3.5,
|
price: 3.50,
|
||||||
description: "A premium quality soap",
|
description: "A premium quality soap",
|
||||||
subscription_allowed: true,
|
subscription_allowed: true,
|
||||||
},
|
}
|
||||||
]);
|
]);
|
||||||
const convertProductToAddon = (
|
const convertProductToAddon = (product: PosProduct, options: {quantity?: number, min?: number, max?: number} = {}): Addon => {
|
||||||
product: PosProduct,
|
|
||||||
options: { quantity?: number; min?: number; max?: number } = {}
|
|
||||||
): Addon => {
|
|
||||||
//console.warn("Converting product to addon:", product, options);
|
//console.warn("Converting product to addon:", product, options);
|
||||||
return {
|
return {
|
||||||
id: product.id,
|
id: product.id,
|
||||||
@@ -107,260 +106,84 @@ const convertProductToAddon = (
|
|||||||
quantity: options.quantity || 0,
|
quantity: options.quantity || 0,
|
||||||
min: options.min || -1,
|
min: options.min || -1,
|
||||||
max: options.max || -1,
|
max: options.max || -1,
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
const getAvailableAdditionalItems = () => {
|
const getAvailableAdditionalItems = () => {
|
||||||
const tmp = transactionItems.additionalItems.value || [];
|
const tmp = transactionItems.additionalItems.value || [];
|
||||||
if (!tmp || tmp.length === 0) return [];
|
if (!tmp || tmp.length === 0) return [];
|
||||||
return <Addon[]>tmp.map((p) =>
|
return <Addon[]>tmp.map(p => convertProductToAddon(p, {
|
||||||
convertProductToAddon(p, {
|
quantity: p?.quantity || 0,
|
||||||
quantity: p?.quantity || 0,
|
min: -1,
|
||||||
min: -1,
|
max: -1,
|
||||||
max: -1,
|
}));
|
||||||
})
|
}
|
||||||
);
|
|
||||||
};
|
|
||||||
const availableAdditionalItems = ref<Addon[]>(getAvailableAdditionalItems());
|
const availableAdditionalItems = ref<Addon[]>(getAvailableAdditionalItems());
|
||||||
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
|
// Computed property to filter out restricted additional items based on customer attributes
|
||||||
const filteredAdditionalItems = computed(() => {
|
const filteredAdditionalItems = computed(() => {
|
||||||
return availableAdditionalItems.value.map((addon: Addon) => decorateAdditionalItemAddon(addon));
|
// If additional services are restricted, return empty array
|
||||||
|
if (!canBuyAdditionalServices()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Filter out individually restricted addons
|
||||||
|
return availableAdditionalItems.value.filter((addon: Addon) => !isAddonRestricted(addon));
|
||||||
});
|
});
|
||||||
const onClickAddOtherProduct = () => {
|
const onClickAddOtherProduct = () => {
|
||||||
pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value;
|
pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value;
|
||||||
if (pos.views.additionalItemSelection.value) {
|
}
|
||||||
scheduleAdditionalSelectionProductsLoad();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadAdditionalSelectionProducts = async () => {
|
|
||||||
if (additionalSelectionLoading.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 for changes in available additional items, to update the pos.transactionItems.additionalItems
|
||||||
watch(
|
watch(availableAdditionalItems, (newVal) => {
|
||||||
availableAdditionalItems,
|
newVal.forEach(addon => {
|
||||||
(newVal) => {
|
if (addon.quantity && addon.quantity > 0 && addon.product) {
|
||||||
let removedRestrictedItem = false;
|
addon.product.quantity = addon.quantity; // Ensure product has correct quantity
|
||||||
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) );
|
||||||
const currentItems = pos.transactionItems.additionalItems.value || [];
|
});
|
||||||
const isSame =
|
}, { deep: true });
|
||||||
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 for changes in pos.transactionItems.additionalItems to remove items with quantity 0
|
||||||
watch(
|
watch(() => pos.transactionItems.additionalItems.value, (newVal) => {
|
||||||
() => pos.transactionItems.additionalItems.value,
|
if (!newVal) return;
|
||||||
(newVal) => {
|
if (newVal.length === 0) {
|
||||||
if (!newVal) return;
|
availableAdditionalItems.value = [];
|
||||||
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();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (pos.views.additionalItemSelection.value) {
|
|
||||||
scheduleAdditionalSelectionProductsLoad();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const onClickAddProduct = async (product: PosProduct) => {
|
|
||||||
if (isAdditionalItemRestricted(product)) {
|
|
||||||
showRestrictedItemsRemovedWarning();
|
|
||||||
return;
|
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 });
|
||||||
|
|
||||||
|
const onClickAddProduct = async (product: PosProduct) => {
|
||||||
|
// If the product requires note, open note input.
|
||||||
pos.transactionItems.addAdditionalItem(product);
|
pos.transactionItems.addAdditionalItem(product);
|
||||||
// If the view is fullscreen, close it after adding
|
// If the view is fullscreen, close it after adding
|
||||||
//if (pos.views.additionalItemSelection.value) {
|
//if (pos.views.additionalItemSelection.value) {
|
||||||
// pos.views.additionalItemSelection.value = false;
|
// pos.views.additionalItemSelection.value = false;
|
||||||
//}
|
//}
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div data-testid="pos-mobile-additional-items">
|
<div data-testid="pos-mobile-additional-items">
|
||||||
<!-- Minimal view, when not set as fullscreen view -->
|
<!-- Minimal view, when not set as fullscreen view -->
|
||||||
<WhiteBoxCard
|
<WhiteBoxCard :toggleable="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length == 0"
|
||||||
:toggleable="
|
:defaultOpen="pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0"
|
||||||
canSelectAdditionalItems &&
|
@toggle="onToggle"
|
||||||
pos.transactionItems.additionalItems.value &&
|
:forceState="(pos.transactionItems.additionalItems.value && pos.transactionItems.additionalItems.value.length > 0) ? true : (pos.views.additionalItemSelection.value)"
|
||||||
pos.transactionItems.additionalItems.value.length == 0
|
v-if="!pos.views.additionalItemSelection.value">
|
||||||
"
|
|
||||||
: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 -->
|
<!-- Header -->
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="pos-mobile-additional-items-header" data-testid="pos-mobile-additional-items-open">
|
<div class="pos-mobile-additional-items-header" data-testid="pos-mobile-additional-items-open">
|
||||||
<div
|
<div class="card-header-title pos-mobile-additional-items-header__title" data-testid="pos-mobile-additional-items-header-title">{{ displayLabel }}</div>
|
||||||
class="card-header-title pos-mobile-additional-items-header__title"
|
<div class="card-header-icon pos-mobile-additional-items-header__icon" data-testid="pos-mobile-additional-items-header-icon">
|
||||||
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 -->
|
<!-- Right arrow, if there's no items, down arrow if there are items -->
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i
|
<i v-if="!pos.transactionItems.additionalItems.value || pos.transactionItems.additionalItems.value.length === 0" class="fas fa-angle-right"></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>
|
<i v-else class="fas fa-angle-down"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -370,13 +193,6 @@ const onClickAddProduct = async (product: PosProduct) => {
|
|||||||
<template #content>
|
<template #content>
|
||||||
<!-- Suggested items -->
|
<!-- Suggested items -->
|
||||||
<div>
|
<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 -->
|
<!-- No items added yet -->
|
||||||
<div v-if="!availableAdditionalItems || availableAdditionalItems.length === 0">
|
<div v-if="!availableAdditionalItems || availableAdditionalItems.length === 0">
|
||||||
<p>{{ SessionUser.objects.global.language.no_additional_items }}</p>
|
<p>{{ SessionUser.objects.global.language.no_additional_items }}</p>
|
||||||
@@ -398,52 +214,22 @@ const onClickAddProduct = async (product: PosProduct) => {
|
|||||||
<!-- Fullscreen view, when selecting other products -->
|
<!-- Fullscreen view, when selecting other products -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div data-testid="pos-mobile-additional-items-selection">
|
<div data-testid="pos-mobile-additional-items-selection">
|
||||||
<div class="pos-mobile-additional-items-categories">
|
<!-- Categories of products -->
|
||||||
<button
|
<PosDepartmentStep2MobileVehicleSelection :onAddProduct="onClickAddProduct" :onSearchClick="() => console.warn('AdditionalItem Search Clicked')"/><!-- :asAddons="true" :addons="availableAdditionalItems" @update:addons="availableAdditionalItems = $event"/>-->
|
||||||
type="button"
|
<!-- Buttons -->
|
||||||
class="button is-rounded is-small is-dark"
|
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
|
||||||
:data-testid="`pos-mobile-category-${ADDITIONAL_SERVICES_CATEGORY_ID}`"
|
<!-- Floating Card -->
|
||||||
@click="scheduleAdditionalSelectionProductsLoad"
|
<PosDepartmentStepMobile2FloatingCart/>
|
||||||
>
|
<!-- Next button -->
|
||||||
Extras
|
<PosDepartmentStepMobileButtonNextStep :isDark="true" :customAction="onClickAddOtherProduct">
|
||||||
</button>
|
<span class="pos-mobile-cta-content">
|
||||||
</div>
|
<span class="pos-mobile-cta-label has-text-white">{{ SessionUser.objects.global.language.next }}</span>
|
||||||
<div class="pos-mobile-additional-items-products">
|
<span class="pos-mobile-cta-value has-text-white">
|
||||||
<div
|
<i class="fas fa-arrow-right"></i>
|
||||||
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>
|
</span>
|
||||||
</PosDepartmentStepMobileFixedBottomControl>
|
</PosDepartmentStepMobileButtonNextStep>
|
||||||
|
</PosDepartmentStepMobileFixedBottomControl>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -468,10 +254,4 @@ const onClickAddProduct = async (product: PosProduct) => {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pos-mobile-additional-items-categories {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem 1rem 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -4,30 +4,27 @@ import Plus from "@/components/viewport/elements/icons/Plus.vue";
|
|||||||
import ControlSelectAmount from "@/components/viewport/elements/controls/ControlSelectAmount.vue";
|
import ControlSelectAmount from "@/components/viewport/elements/controls/ControlSelectAmount.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { Addon } from "../objects/PosAddon.vue";
|
import { Addon } from "../objects/PosAddon.vue";
|
||||||
import PosDepartmentStepMobile2CategoryProduct from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
|
import PosDepartmentStepMobile2CategoryProduct
|
||||||
import { useI18n } from "vue-i18n";
|
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
|
||||||
import { transactionItems } from "../objects/PosDepartmentStepMobileFlow.vue";
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "Add-ons",
|
default: "Add-ons",
|
||||||
required: true,
|
required: true
|
||||||
},
|
},
|
||||||
addons: {
|
addons: {
|
||||||
type: Array as () => Addon[],
|
type: Array as () => Addon[],
|
||||||
default: () => [],
|
default: () => []
|
||||||
},
|
},
|
||||||
compact: {
|
compact: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false
|
||||||
},
|
},
|
||||||
showPrices: {
|
showPrices: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
const emit = defineEmits(["update:addons"]);
|
const emit = defineEmits(["update:addons"]);
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +44,7 @@ const emit = defineEmits(["update:addons"]);
|
|||||||
const getLabel = (addon: Addon) => {
|
const getLabel = (addon: Addon) => {
|
||||||
return addon.name;
|
return addon.name;
|
||||||
//return `${addon.name} / ${SessionUser.functions.currency.toLocal(addon.price)}`;
|
//return `${addon.name} / ${SessionUser.functions.currency.toLocal(addon.price)}`;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When double-clicking on the product, it will add 1 to the quantity
|
* When double-clicking on the product, it will add 1 to the quantity
|
||||||
@@ -55,126 +52,85 @@ const getLabel = (addon: Addon) => {
|
|||||||
const doubleClickTimeout = 500; // milliseconds
|
const doubleClickTimeout = 500; // milliseconds
|
||||||
const doubleClickTimers = ref<{ [key: string]: number | null }>({});
|
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
|
* Function to add a product to the add-ons list
|
||||||
* @param addon
|
* @param addon
|
||||||
*/
|
*/
|
||||||
const onAddProduct = (addon: Addon) => {
|
const onAddProduct = (addon: Addon) => {
|
||||||
if (isAddonRestricted(addon)) {
|
|
||||||
setAddonQuantity(addon, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const addonKey = getAddonKey(addon);
|
|
||||||
// Check if the product was just added
|
// Check if the product was just added
|
||||||
if (doubleClickTimers.value[addonKey]) {
|
if (doubleClickTimers.value[addon.id]) {
|
||||||
// Double-click detected, clear the timeout
|
// Double-click detected, clear the timeout
|
||||||
clearTimeout(doubleClickTimers.value[addonKey]);
|
clearTimeout(doubleClickTimers.value[addon.id]);
|
||||||
doubleClickTimers.value[addonKey] = null;
|
doubleClickTimers.value[addon.id] = null;
|
||||||
// Check if the product can have more quantity
|
// Check if the product can have more quantity
|
||||||
if (addon.quantity >= addon.max && addon.max !== -1) {
|
if (addon.quantity >= addon.max && addon.max !== -1) {
|
||||||
// If the product max is reached, it will not be added.
|
// If the product max is reached, it will not be added.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setAddonQuantity(addon, addon.quantity + 1);
|
addon.quantity = addon.quantity + 1;
|
||||||
|
emit('update:addons', [...props.addons])
|
||||||
// Set the timeout again to allow for the next double-click
|
// Set the timeout again to allow for the next double-click
|
||||||
doubleClickTimers.value[addonKey] = window.setTimeout(() => {
|
doubleClickTimers.value[addon.id] = window.setTimeout(() => {
|
||||||
doubleClickTimers.value[addonKey] = null;
|
doubleClickTimers.value[addon.id] = null;
|
||||||
}, doubleClickTimeout);
|
}, doubleClickTimeout);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Single-click detected, add the product to the list
|
// Single-click detected, add the product to the list
|
||||||
// If the product is already has a quantity, set it to 0
|
// If the product is already has a quantity, set it to 0
|
||||||
if (addon.quantity > 0) {
|
if (addon.quantity > 0) {
|
||||||
setAddonQuantity(addon, 0);
|
addon.quantity = 0;
|
||||||
|
emit('update:addons', [...props.addons])
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextQuantity = addon.min > 0 ? addon.min : 1;
|
addon.quantity = (addon.min > 0 ? addon.min : 1);
|
||||||
// Set a timeout to handle double-clicks.
|
// Set a timeout to handle double-clicks.
|
||||||
// If the product max is reached, it will not be added.
|
// If the product max is reached, it will not be added.
|
||||||
if (nextQuantity >= addon.max && addon.max !== -1) {
|
if (addon.quantity >= addon.max && addon.max !== -1) {
|
||||||
// If the product max is reached, it will not be added.
|
// If the product max is reached, it will not be added.
|
||||||
setAddonQuantity(addon, nextQuantity);
|
emit('update:addons', [...props.addons])
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
doubleClickTimers.value[addonKey] = window.setTimeout(() => {
|
doubleClickTimers.value[addon.id] = window.setTimeout(() => {
|
||||||
doubleClickTimers.value[addonKey] = null;
|
doubleClickTimers.value[addon.id] = null;
|
||||||
}, doubleClickTimeout);
|
}, doubleClickTimeout);
|
||||||
setAddonQuantity(addon, nextQuantity);
|
emit('update:addons', [...props.addons])
|
||||||
};
|
}
|
||||||
|
|
||||||
const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
|
const sortAddonsByProductOrderPriority = (addons: Addon[]) => {
|
||||||
return [...addons].sort((a, b) => {
|
return addons.sort((a, b) => {
|
||||||
const priorityA = a.product.order_priority || 0;
|
const priorityA = a.product.order_priority || 0;
|
||||||
const priorityB = b.product.order_priority || 0;
|
const priorityB = b.product.order_priority || 0;
|
||||||
return priorityA - priorityB;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="addons.length > 0">
|
<template v-if="addons.length > 0"> <!-- If there are add-ons available -->
|
||||||
<!-- If there are add-ons available -->
|
|
||||||
<p class="custom-label">{{ props.label }}</p>
|
<p class="custom-label">{{ props.label }}</p>
|
||||||
<div>
|
<div>
|
||||||
<template v-for="addon in sortAddonsByProductOrderPriority(props.addons)" :key="addon.id">
|
<template v-for="addon in sortAddonsByProductOrderPriority(props.addons)" :key="addon.id">
|
||||||
<PosDepartmentStepMobile2CategoryProduct
|
<PosDepartmentStepMobile2CategoryProduct
|
||||||
:compact="props.compact"
|
:compact="props.compact"
|
||||||
:price="addon.price"
|
:price="addon.price"
|
||||||
:label="getLabel(addon)"
|
:label="getLabel(addon)"
|
||||||
:piktogram="addon.product.piktogram"
|
:piktogram="addon.product.piktogram"
|
||||||
:testId="`pos-mobile-addon-${addon.product.id}`"
|
:testId="`pos-mobile-addon-${addon.product.id}`"
|
||||||
:showPrices="props.showPrices"
|
:showPrices="props.showPrices"
|
||||||
:disabled="isAddonRestricted(addon)"
|
|
||||||
:restrictionMessage="getAddonRestrictionMessage(addon)"
|
|
||||||
@addProduct="onAddProduct(addon)"
|
@addProduct="onAddProduct(addon)"
|
||||||
:customButton="addon.quantity > 0 && !isAddonRestricted(addon)"
|
:customButton="addon.quantity > 0">
|
||||||
>
|
<span class="custom-select-quantity-container">
|
||||||
<span class="custom-select-quantity-container">
|
|
||||||
<ControlSelectAmount
|
<ControlSelectAmount
|
||||||
v-if="!isAddonRestricted(addon)"
|
:testIdPrefix="`pos-mobile-addon-${addon.product.id}`"
|
||||||
:testIdPrefix="`pos-mobile-addon-${addon.product.id}`"
|
@update:quantity="addon.quantity = $event; emit('update:addons', [...props.addons])"
|
||||||
@update:quantity="setAddonQuantity(addon, $event)"
|
v-model:quantity="addon.quantity"
|
||||||
:quantity="addon.quantity"
|
:max="addon.max"
|
||||||
:max="addon.max"
|
:min="addon.min"
|
||||||
:min="addon.min"
|
|
||||||
/>
|
/>
|
||||||
<span v-else class="tag is-danger is-light is-rounded">
|
|
||||||
{{ getAddonRestrictionMessage(addon) }}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</PosDepartmentStepMobile2CategoryProduct>
|
</PosDepartmentStepMobile2CategoryProduct>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -192,11 +148,13 @@ const getAddonRestrictionMessage = (addon: Addon) => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
|
||||||
/* Inside auto layout */
|
/* Inside auto layout */
|
||||||
flex: none;
|
flex: none;
|
||||||
order: 1;
|
order: 1;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
.custom-container-entry-container {
|
.custom-container-entry-container {
|
||||||
/* Frame 47 */
|
/* Frame 47 */
|
||||||
@@ -213,13 +171,14 @@ const getAddonRestrictionMessage = (addon: Addon) => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|
||||||
border-bottom: 1px solid #d9d9d9;
|
border-bottom: 1px solid #D9D9D9;
|
||||||
|
|
||||||
/* Inside auto layout */
|
/* Inside auto layout */
|
||||||
flex: none;
|
flex: none;
|
||||||
order: 0;
|
order: 0;
|
||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
.custom-icon-container {
|
.custom-icon-container {
|
||||||
/* + */
|
/* + */
|
||||||
@@ -251,7 +210,7 @@ const getAddonRestrictionMessage = (addon: Addon) => {
|
|||||||
height: 19px;
|
height: 19px;
|
||||||
|
|
||||||
/* rg-tx */
|
/* rg-tx */
|
||||||
font-family: "Arial";
|
font-family: 'Arial';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
@@ -260,16 +219,18 @@ const getAddonRestrictionMessage = (addon: Addon) => {
|
|||||||
|
|
||||||
color: #000000;
|
color: #000000;
|
||||||
|
|
||||||
|
|
||||||
/* Inside auto layout */
|
/* Inside auto layout */
|
||||||
flex: none;
|
flex: none;
|
||||||
order: 2;
|
order: 2;
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
.custom-icon {
|
.custom-icon {
|
||||||
/* + */
|
/* + */
|
||||||
|
|
||||||
/* Icon color */
|
/* Icon color */
|
||||||
fill: #ffffff;
|
fill: #FFFFFF;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
@@ -279,6 +240,7 @@ const getAddonRestrictionMessage = (addon: Addon) => {
|
|||||||
flex: none;
|
flex: none;
|
||||||
order: 0;
|
order: 0;
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
.custom-label {
|
.custom-label {
|
||||||
/* Add-ons */
|
/* Add-ons */
|
||||||
@@ -286,7 +248,7 @@ const getAddonRestrictionMessage = (addon: Addon) => {
|
|||||||
width: 60px;
|
width: 60px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
|
||||||
font-family: "Arial";
|
font-family: 'Arial';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -295,9 +257,11 @@ const getAddonRestrictionMessage = (addon: Addon) => {
|
|||||||
|
|
||||||
color: #000000;
|
color: #000000;
|
||||||
|
|
||||||
|
|
||||||
/* Inside auto layout */
|
/* Inside auto layout */
|
||||||
flex: none;
|
flex: none;
|
||||||
order: 0;
|
order: 0;
|
||||||
flex-grow: 0;
|
flex-grow: 0;
|
||||||
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,49 +3,6 @@ import GenericTag from "@/components/viewport/page/templates/generic/graphics/Ge
|
|||||||
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||||
import {onBeforeMount } from "vue";
|
import {onBeforeMount } from "vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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
|
// Function to fetch categories based on the department ID
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// Optional list of category IDs to restrict the displayed categories to
|
// Optional list of category IDs to restrict the displayed categories to
|
||||||
@@ -87,12 +44,14 @@ const fetchCategories = async (departmentId: number) => {
|
|||||||
// Select the category 6, if it exists, otherwise select the first category
|
// Select the category 6, if it exists, otherwise select the first category
|
||||||
const category6 = pos.categories.get().find(cat => cat.id === 6);
|
const category6 = pos.categories.get().find(cat => cat.id === 6);
|
||||||
if (category6) {
|
if (category6) {
|
||||||
selectCategory(category6);
|
pos.productList.setLoading(true);
|
||||||
|
pos.categories.select(category6);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pos.categories.get().length > 0) {
|
if (pos.categories.get().length > 0) {
|
||||||
selectCategory(pos.categories.get()[0]);
|
pos.productList.setLoading(true);
|
||||||
|
pos.categories.select(pos.categories.get()[0]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -123,15 +82,10 @@ onBeforeMount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="columns is-mobile is-multiline is-gapless">
|
<div v-else class="columns is-mobile is-multiline is-gapless">
|
||||||
<div
|
<div class="column is-narrow" v-for="category in pos.categories.get()" :key="category.id" :data-testid="`pos-mobile-category-${category.id}`">
|
||||||
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
|
<GenericTag
|
||||||
:active="pos.categories.isCategorySelected(category)"
|
:active="pos.categories.isCategorySelected(category)"
|
||||||
|
@click="pos.categories.select(category)"
|
||||||
class="custom-gap">
|
class="custom-gap">
|
||||||
{{ category.name }}
|
{{ category.name }}
|
||||||
</GenericTag>
|
</GenericTag>
|
||||||
|
|||||||
@@ -37,24 +37,12 @@ const props = defineProps({
|
|||||||
testId: {
|
testId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
restrictionMessage: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["addProduct"]);
|
const emit = defineEmits(["addProduct"]);
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
if (props.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit("addProduct", {
|
emit("addProduct", {
|
||||||
piktogram: props.piktogram,
|
piktogram: props.piktogram,
|
||||||
label: props.label,
|
label: props.label,
|
||||||
@@ -81,23 +69,14 @@ const imageStyleCompact = {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div style="border-bottom: 1px solid #E5E5E5; padding: 10px 0;" class="mb-1">
|
||||||
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="columns is-vcentered is-mobile" :class="props.customButton ? 'has-quantity' : ''">
|
||||||
<div class="column is-narrow" @click="onClick" :style="{ cursor: props.disabled ? 'not-allowed' : 'pointer' }">
|
<div class="column is-narrow" @click="onClick" style="cursor: pointer;">
|
||||||
<img :src="getPicture(props.piktogram)" :alt="props.label" class="product-image"
|
<img :src="getPicture(props.piktogram)" :alt="props.label" class="product-image"
|
||||||
:style="props.compact ? imageStyleCompact : imageSize"
|
:style="props.compact ? imageStyleCompact : imageSize"
|
||||||
style="border-radius: 8px; object-fit: contain;" />
|
style="border-radius: 8px; object-fit: contain;" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="column" @click="onClick" style="cursor: pointer;" :data-testid="props.testId || undefined">
|
||||||
class="column"
|
|
||||||
@click="onClick"
|
|
||||||
:style="{ cursor: props.disabled ? 'not-allowed' : 'pointer' }"
|
|
||||||
:data-testid="props.testId || undefined"
|
|
||||||
>
|
|
||||||
<template v-if="!props.compact">
|
<template v-if="!props.compact">
|
||||||
<h1 class="title is-6">{{ props.label }}</h1>
|
<h1 class="title is-6">{{ props.label }}</h1>
|
||||||
<h2 class="subtitle is-6" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
|
<h2 class="subtitle is-6" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
|
||||||
@@ -108,9 +87,6 @@ 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-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
|
||||||
<h2 class="subtitle is-6" style="font-size: smaller" v-else> </h2>
|
<h2 class="subtitle is-6" style="font-size: smaller" v-else> </h2>
|
||||||
</template>
|
</template>
|
||||||
<p v-if="props.disabled && props.restrictionMessage" class="help is-danger mb-0">
|
|
||||||
{{ props.restrictionMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<slot name="right"/>
|
<slot name="right"/>
|
||||||
@@ -180,13 +156,4 @@ const imageStyleCompact = {
|
|||||||
.has-quantity .column .subtitle {
|
.has-quantity .column .subtitle {
|
||||||
color: rgba(255, 255, 255, 0.6);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const items = computed(() => {
|
|||||||
return [
|
return [
|
||||||
pos.transactionItems.primaryItem.value,
|
pos.transactionItems.primaryItem.value,
|
||||||
...pos.transactionItems.additionalItems.value,
|
...pos.transactionItems.additionalItems.value,
|
||||||
].filter((item) => item && String(item.name ?? "").trim() !== "")
|
]
|
||||||
});
|
});
|
||||||
const isCollapsed = ref(true);
|
const isCollapsed = ref(true);
|
||||||
const isAddonsVisible = ref(true);
|
const isAddonsVisible = ref(true);
|
||||||
@@ -83,7 +83,7 @@ const itemNames = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<!-- Product addons -->
|
<!-- Product addons -->
|
||||||
<template v-for="addon in item.addons" :key="addon.id" v-if="item.addons && item.addons.length > 0 && isAddonsVisible">
|
<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 && addon?.restricted !== true">
|
<div class="columns is-mobile is-vcentered is-gapless m-0" v-if="addon?.quantity > 0">
|
||||||
<div class="column"><!-- Product addons -->
|
<div class="column"><!-- Product addons -->
|
||||||
<p class="has-text-left"><small>{{addon.quantity}}x {{ addon.name }}</small></p>
|
<p class="has-text-left"><small>{{addon.quantity}}x {{ addon.name }}</small></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,4 +101,4 @@ const itemNames = computed(() => {
|
|||||||
max-height: 200px; /* Adjust the max height as needed */
|
max-height: 200px; /* Adjust the max height as needed */
|
||||||
overflow-y: auto; /* Enable vertical scrolling */
|
overflow-y: auto; /* Enable vertical scrolling */
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -3,20 +3,16 @@ import {watch, ref, computed} from "vue";
|
|||||||
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
import { pos } from "../objects/PosDepartmentStepMobileFlow.vue";
|
||||||
import PosDepartmentStepMobile2CategoryProduct
|
import PosDepartmentStepMobile2CategoryProduct
|
||||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2CategoryProduct.vue";
|
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 { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import {PosProduct} from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
import {PosProduct} from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
||||||
import {
|
import { department_id, customer_id, isProductRestricted } from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
getProductRestriction,
|
|
||||||
hasAttribute,
|
|
||||||
isProductRestricted,
|
|
||||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
|
||||||
import PosDepartmentStepMobile2Addons
|
import PosDepartmentStepMobile2Addons
|
||||||
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
|
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
|
||||||
import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
|
import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { orderProducts } from "@/components/shop/Products.vue";
|
||||||
|
|
||||||
const emits = defineEmits(["addProduct"]);
|
const emits = defineEmits(["addProduct"]);
|
||||||
const { t } = useI18n();
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// To be able to count the products, providing the "addonList" object, paired with the "asAddons" boolean
|
// 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
|
// This should be the list of addons, not the complete product list
|
||||||
@@ -30,12 +26,72 @@ const props = defineProps({
|
|||||||
required: false,
|
required: false,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
restrictionContext: {
|
|
||||||
type: String,
|
|
||||||
default: "primary",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const slots = defineSlots();
|
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 **/
|
/** As Addons **/
|
||||||
const mergedAddons = ref<Addon[]>([]);
|
const mergedAddons = ref<Addon[]>([]);
|
||||||
@@ -44,21 +100,18 @@ const mergeCountAddons = (products: PosProduct[], addons: Addon[] | undefined) =
|
|||||||
const tmpAddons = <Addon[]>[]; // Temporary array to hold the merged addons
|
const tmpAddons = <Addon[]>[]; // Temporary array to hold the merged addons
|
||||||
// Convert all products to addons (only non-restricted products)
|
// Convert all products to addons (only non-restricted products)
|
||||||
for (let product of products) {
|
for (let product of products) {
|
||||||
let tmpAddon = pos.transactionItems.convertProductToAddon(product); // Convert product to addon (count = 0)
|
// Skip restricted products
|
||||||
const restriction = getProductRestrictionForRow(product);
|
if (isProductRestricted(product)) {
|
||||||
if (restriction.restricted) {
|
continue;
|
||||||
(tmpAddon as any).restricted = true;
|
|
||||||
(tmpAddon as any).restrictionRule = restriction.rule;
|
|
||||||
(tmpAddon as any).restrictionMessageKey = restriction.messageKey;
|
|
||||||
tmpAddon.quantity = 0;
|
|
||||||
}
|
}
|
||||||
|
let tmpAddon = pos.transactionItems.convertProductToAddon(product); // Convert product to addon (count = 0)
|
||||||
tmpAddons.push(tmpAddon);
|
tmpAddons.push(tmpAddon);
|
||||||
}
|
}
|
||||||
// Loop through the addons and update the count if the addon exists in the tmpAddons array
|
// Loop through the addons and update the count if the addon exists in the tmpAddons array
|
||||||
if (addons) {
|
if (addons) {
|
||||||
for (let addon of addons) {
|
for (let addon of addons) {
|
||||||
let index = tmpAddons.findIndex(a => a.product.id === addon.product.id);
|
let index = tmpAddons.findIndex(a => a.product.id === addon.product.id);
|
||||||
if (index !== -1 && !(tmpAddons[index] as any).restricted) {
|
if (index !== -1) {
|
||||||
tmpAddons[index].quantity = addon.quantity; // Update the count
|
tmpAddons[index].quantity = addon.quantity; // Update the count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,36 +125,11 @@ watch(() => props.addons, (newAddons) => {
|
|||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
|
|
||||||
// Computed property to filter out restricted products
|
// 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(() => {
|
const filteredProducts = computed(() => {
|
||||||
if (props.restrictionContext === "standaloneAdditionalService") {
|
return pos.productList.get().filter(product => !isProductRestricted(product));
|
||||||
return productListItems.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return productListItems.value.filter(product => !isProductRestricted(product));
|
|
||||||
});
|
});
|
||||||
const shouldShowLoadingState = computed(() => {
|
const shouldShowLoadingState = computed(() => {
|
||||||
return pos.categories.loading.value || (pos.productList.loading.value && productListItems.value.length === 0);
|
return pos.categories.loading.value || pos.productList.loading.value;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -120,16 +148,14 @@ const shouldShowLoadingState = computed(() => {
|
|||||||
<template v-else-if="pos.categories.isSelected()">
|
<template v-else-if="pos.categories.isSelected()">
|
||||||
<template v-if="!props.asAddons">
|
<template v-if="!props.asAddons">
|
||||||
<!-- Display of products, without a "basket" (filtered for customer restrictions) -->
|
<!-- Display of products, without a "basket" (filtered for customer restrictions) -->
|
||||||
<template v-for="product in filteredProducts" :key="product.id">
|
<template v-for="product in filteredProducts" :key="product.id">
|
||||||
<PosDepartmentStepMobile2CategoryProduct
|
<PosDepartmentStepMobile2CategoryProduct
|
||||||
@addProduct="emits('addProduct', product)"
|
@addProduct="emits('addProduct', product)"
|
||||||
:price="product.price"
|
:price="product.price"
|
||||||
:label="product.name"
|
:label="product.name"
|
||||||
:piktogram="product.piktogram"
|
:piktogram="product.piktogram"
|
||||||
:testId="`pos-mobile-product-${product.id}`"
|
:testId="`pos-mobile-product-${product.id}`"
|
||||||
:disabled="getProductRestrictionForRow(product).restricted"
|
>
|
||||||
:restrictionMessage="getProductRestrictionMessage(product)"
|
|
||||||
>
|
|
||||||
<template #right>
|
<template #right>
|
||||||
<slot name="right" :product="product"/>
|
<slot name="right" :product="product"/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ watch(
|
|||||||
form.invoiceEmail,
|
form.invoiceEmail,
|
||||||
form.contactEmail,
|
form.contactEmail,
|
||||||
form.contactPhone,
|
form.contactPhone,
|
||||||
form.ean,
|
|
||||||
canCreateCustomer.value,
|
canCreateCustomer.value,
|
||||||
searchResult.value,
|
searchResult.value,
|
||||||
errorMessage.value,
|
errorMessage.value,
|
||||||
@@ -125,24 +124,6 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label class="label"
|
|
||||||
><small>{{ $t("admin.pos.ean") }}</small></label
|
|
||||||
>
|
|
||||||
<div class="control">
|
|
||||||
<input
|
|
||||||
v-model="form.ean"
|
|
||||||
class="input"
|
|
||||||
type="text"
|
|
||||||
inputmode="numeric"
|
|
||||||
maxlength="13"
|
|
||||||
:placeholder="$t('admin.pos.ean')"
|
|
||||||
:disabled="searchResult === null || submitting"
|
|
||||||
data-testid="pos-mobile-add-customer-ean"
|
|
||||||
@input="handleAutofillFieldInput('ean')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label"
|
<label class="label"
|
||||||
><small>{{ $t("admin.pos.invoice_email") }}</small></label
|
><small>{{ $t("admin.pos.invoice_email") }}</small></label
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export const POS_ADD_CUSTOMER_AUTOFILL_FIELDS = [
|
|||||||
"invoiceEmail",
|
"invoiceEmail",
|
||||||
"contactEmail",
|
"contactEmail",
|
||||||
"contactPhone",
|
"contactPhone",
|
||||||
"ean",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function normalizePosAddCustomerValue(value) {
|
export function normalizePosAddCustomerValue(value) {
|
||||||
@@ -21,7 +20,6 @@ export function createPosAddCustomerFields(overrides = {}) {
|
|||||||
invoiceEmail: "",
|
invoiceEmail: "",
|
||||||
contactEmail: "",
|
contactEmail: "",
|
||||||
contactPhone: "",
|
contactPhone: "",
|
||||||
ean: "",
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,7 +30,6 @@ export function createPosAddCustomerManualOverrides(overrides = {}) {
|
|||||||
invoiceEmail: false,
|
invoiceEmail: false,
|
||||||
contactEmail: false,
|
contactEmail: false,
|
||||||
contactPhone: false,
|
contactPhone: false,
|
||||||
ean: false,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -96,7 +93,6 @@ export function buildPosAddCustomerAutofillValues(searchResult) {
|
|||||||
invoiceEmail: normalizePosAddCustomerValue(searchResult?.email),
|
invoiceEmail: normalizePosAddCustomerValue(searchResult?.email),
|
||||||
contactEmail: normalizePosAddCustomerValue(searchResult?.email),
|
contactEmail: normalizePosAddCustomerValue(searchResult?.email),
|
||||||
contactPhone: normalizePosAddCustomerValue(searchResult?.phone),
|
contactPhone: normalizePosAddCustomerValue(searchResult?.phone),
|
||||||
ean: normalizePosAddCustomerValue(searchResult?.ean),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export type Addon = {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
min: number; // Optional minimum quantity (This is the minimum, when the add-on is selected)
|
min: number; // Optional minimum quantity (This is the minimum, when the add-on is selected)
|
||||||
max: number; // Optional maximum quantity
|
max: number; // Optional maximum quantity
|
||||||
skip_price_override?: boolean;
|
|
||||||
/** The product details for the add-on */
|
/** The product details for the add-on */
|
||||||
product?: PosProduct; // The product details for the add-on
|
product?: PosProduct; // The product details for the add-on
|
||||||
};
|
};
|
||||||
@@ -19,4 +18,4 @@ export type PosAddon = Addon;
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: "PosAddon",
|
name: "PosAddon",
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -1458,7 +1458,6 @@ const selectLastVehicleOrder = (vehicleIndex: number) => {
|
|||||||
notes: item?.notes ?? item?.product?.notes ?? "",
|
notes: item?.notes ?? item?.product?.notes ?? "",
|
||||||
addons: Array.isArray(item?.product?.addons) ? item.product.addons : [],
|
addons: Array.isArray(item?.product?.addons) ? item.product.addons : [],
|
||||||
subscription_allowed: Boolean(item?.product?.subscription_allowed ?? false),
|
subscription_allowed: Boolean(item?.product?.subscription_allowed ?? false),
|
||||||
skip_price_override: true,
|
|
||||||
} as PosProduct;
|
} as PosProduct;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,5 @@ export type PosProduct = {
|
|||||||
quantity?: number;
|
quantity?: number;
|
||||||
notes?: string; // Optional notes for the product, used as a part of order item generation
|
notes?: string; // Optional notes for the product, used as a part of order item generation
|
||||||
related_item_id?: number;
|
related_item_id?: number;
|
||||||
skip_price_override?: boolean;
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -12,7 +12,6 @@ import PosDepartmentStepMobileButtonNextStep from "@/components/displays/departm
|
|||||||
import SessionUser from "@/components/session/token/SessionUser.vue";
|
import SessionUser from "@/components/session/token/SessionUser.vue";
|
||||||
import { PosOrder } from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
|
import { PosOrder } from "@/components/displays/department/pos/steps/mobile/objects/PosOrder.vue";
|
||||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
|
||||||
// Define the close event to emit when the component is closed
|
// Define the close event to emit when the component is closed
|
||||||
const emit = defineEmits(["close"]);
|
const emit = defineEmits(["close"]);
|
||||||
/** Display variables */
|
/** Display variables */
|
||||||
@@ -127,7 +126,7 @@ const liveTransactions = ref(null);
|
|||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
|
|
||||||
const syncListTransactionHistory = async () => {
|
const syncListTransactionHistory = async () => {
|
||||||
let dateToday = todayLocalDateOnly(); // Get today's date in YYYY-MM-DD format
|
let dateToday = new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
// Fetch the list of orders created today for the current department
|
// Fetch the list of orders created today for the current department
|
||||||
SessionUser.objects.orders.get
|
SessionUser.objects.orders.get
|
||||||
|
|||||||
@@ -41,11 +41,6 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
required: false,
|
required: false,
|
||||||
default: 750
|
default: 750
|
||||||
},
|
|
||||||
restrictionContext: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: "primary"
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,12 +82,7 @@ const canClose = () => Date.now() >= earliestCloseTime.value;
|
|||||||
<!-- Product Recommendations -->
|
<!-- Product Recommendations -->
|
||||||
<!--<PosDepartmentStepMobile2ProductRecommendations/>-->
|
<!--<PosDepartmentStepMobile2ProductRecommendations/>-->
|
||||||
<!-- Products -->
|
<!-- Products -->
|
||||||
<PosDepartmentStepMobile2Products
|
<PosDepartmentStepMobile2Products @addProduct="onAddProduct" :asAddons="props.asAddons" :addons="props.addons">
|
||||||
@addProduct="onAddProduct"
|
|
||||||
:asAddons="props.asAddons"
|
|
||||||
:addons="props.addons"
|
|
||||||
:restriction-context="props.restrictionContext"
|
|
||||||
>
|
|
||||||
<template v-slot:right>
|
<template v-slot:right>
|
||||||
<!-- Right arrow -->
|
<!-- Right arrow -->
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const onClickAttachToOrder = (object) => {
|
|||||||
}"
|
}"
|
||||||
@click="onTableHeaderClick(tableHeaders.name)"
|
@click="onTableHeaderClick(tableHeaders.name)"
|
||||||
>
|
>
|
||||||
<span class="is-flex-wrap-nowrap pleno-table-header-content">
|
<span class="is-flex-wrap-nowrap">
|
||||||
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="isColumnCurrentlyBeingSortedBy(tableHeaders.name)"
|
v-if="isColumnCurrentlyBeingSortedBy(tableHeaders.name)"
|
||||||
@@ -194,7 +194,7 @@ const onClickAttachToOrder = (object) => {
|
|||||||
'has-text-centered': tableHeaders.name === 'id'
|
'has-text-centered': tableHeaders.name === 'id'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span class="is-flex-wrap-nowrap pleno-table-header-content">
|
<span class="is-flex-wrap-nowrap">
|
||||||
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -453,7 +453,7 @@ const confirmCloseModal = () => {
|
|||||||
}"
|
}"
|
||||||
@click="onTableHeaderClick(tableHeaders.name)"
|
@click="onTableHeaderClick(tableHeaders.name)"
|
||||||
>
|
>
|
||||||
<span class="is-flex-wrap-nowrap pleno-table-header-content">
|
<span class="is-flex-wrap-nowrap">
|
||||||
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="isColumnCurrentlyBeingSortedBy(tableHeaders.name)"
|
v-if="isColumnCurrentlyBeingSortedBy(tableHeaders.name)"
|
||||||
@@ -471,7 +471,7 @@ const confirmCloseModal = () => {
|
|||||||
'has-text-centered': tableHeaders.name === 'id'
|
'has-text-centered': tableHeaders.name === 'id'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span class="is-flex-wrap-nowrap pleno-table-header-content">
|
<span class="is-flex-wrap-nowrap">
|
||||||
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
@@ -512,7 +512,7 @@ const confirmCloseModal = () => {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</template>
|
</template>
|
||||||
<td class="has-text-centered">
|
<td>
|
||||||
<ColorIndicator
|
<ColorIndicator
|
||||||
v-bind:color_class="getOrderInvoiceStatusBarColor(order)"
|
v-bind:color_class="getOrderInvoiceStatusBarColor(order)"
|
||||||
v-bind:is_narrow="true"
|
v-bind:is_narrow="true"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const props = defineProps({
|
|||||||
import {computed, ref, watch} from 'vue';
|
import {computed, ref, watch} from 'vue';
|
||||||
import { departments, getDepartments, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
|
import { departments, getDepartments, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
import {orderBy, orderDirection, setOrder, loadList} from "@/components/pagination/paginatedList.vue";
|
||||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
|
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
|
||||||
@@ -38,8 +38,6 @@ import {
|
|||||||
setCachedXlvaskUsageAmount
|
setCachedXlvaskUsageAmount
|
||||||
} from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
|
} from "@/components/displays/department/pos/sync/xlvaskUsageAmountCache.js";
|
||||||
|
|
||||||
const { orderBy, orderDirection, setOrder, loadList } = usePaginatedListInstance();
|
|
||||||
|
|
||||||
const redirectDepartmentOrderPage = (orderId, departmentId) => {
|
const redirectDepartmentOrderPage = (orderId, departmentId) => {
|
||||||
// Send the user to the order page (In a new tab)
|
// Send the user to the order page (In a new tab)
|
||||||
// `/admin/${departmentId}/modules/pos/orders/${orderId}`
|
// `/admin/${departmentId}/modules/pos/orders/${orderId}`
|
||||||
@@ -651,7 +649,7 @@ const filteredObjects = computed(() => {
|
|||||||
}"
|
}"
|
||||||
@click="onTableHeaderClick(tableHeaders.name)"
|
@click="onTableHeaderClick(tableHeaders.name)"
|
||||||
>
|
>
|
||||||
<span class="is-flex-wrap-nowrap pleno-table-header-content">
|
<span class="is-flex-wrap-nowrap">
|
||||||
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
||||||
<span
|
<span
|
||||||
v-if="isColumnCurrentlyBeingSortedBy(tableHeaders.name)"
|
v-if="isColumnCurrentlyBeingSortedBy(tableHeaders.name)"
|
||||||
@@ -669,7 +667,7 @@ const filteredObjects = computed(() => {
|
|||||||
'has-text-centered': tableHeaders.name === 'id'
|
'has-text-centered': tableHeaders.name === 'id'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span class="is-flex-wrap-nowrap pleno-table-header-content">
|
<span class="is-flex-wrap-nowrap">
|
||||||
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
<span class="has-text-weight-bold">{{ tableHeaders.title }}</span>
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const SELFWASH_PERIOD_ALL_LIMIT = 10000;
|
|
||||||
@@ -1,615 +0,0 @@
|
|||||||
<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,7 +16,6 @@ import InvoicesTable from "@/components/displays/user/invoices/invoicesTable.vue
|
|||||||
import CollectedOrderInvoicesTable from "@/components/displays/superuser/tables/collectedOrderInvoicesTable.vue";
|
import CollectedOrderInvoicesTable from "@/components/displays/superuser/tables/collectedOrderInvoicesTable.vue";
|
||||||
import CollectedOrderInvoicesPagination
|
import CollectedOrderInvoicesPagination
|
||||||
from "@/components/displays/pagination/models/SuperUserDashboard/CollectedOrderInvoicesPagination.vue";
|
from "@/components/displays/pagination/models/SuperUserDashboard/CollectedOrderInvoicesPagination.vue";
|
||||||
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
customer_id: {
|
customer_id: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -173,7 +172,7 @@ const tabs = ref([
|
|||||||
<div class="field">
|
<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>
|
<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">
|
<div class="control">
|
||||||
<BuefyDateField v-model="selectedDate" value-type="string" data-testid="invoice-collection-date" />
|
<input class="input" type="date" v-model="selectedDate" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Create invoice collection button -->
|
<!-- Create invoice collection button -->
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { formatLocalDateOnly } from '@/services/dateOnly.js';
|
|
||||||
type startEndDate = {
|
type startEndDate = {
|
||||||
start: Date; // The start date of the range
|
start: Date; // The start date of the range
|
||||||
end: Date; // The end date of the range
|
end: Date; // The end date of the range
|
||||||
};
|
};
|
||||||
type datePreset = {
|
type datePreset = startEndDate & {
|
||||||
labelKey: string; // The i18n key for the label (E.g., "global.text.today")
|
labelKey: string; // The i18n key for the label (E.g., "global.text.today")
|
||||||
getRange: () => startEndDate;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const datePresetFunctions = {
|
const datePresetFunctions = {
|
||||||
week: {
|
week: {
|
||||||
// Get the first day of the week (Monday) based on the provided date
|
// Get the first day of the week (Monday) based on the provided date
|
||||||
firstDayOfWeek: (date: Date): Date => {
|
firstDayOfWeek: (date: Date): Date => {
|
||||||
const adjustedDate = new Date(date.getTime());
|
const day = date.getDay();
|
||||||
const day = adjustedDate.getDay();
|
|
||||||
const diff = (day === 0 ? -6 : 1) - day; // Adjust for Sunday
|
const diff = (day === 0 ? -6 : 1) - day; // Adjust for Sunday
|
||||||
adjustedDate.setDate(adjustedDate.getDate() + diff);
|
return new Date(date.setDate(date.getDate() + diff));
|
||||||
return adjustedDate;
|
|
||||||
},
|
},
|
||||||
// Get the last day of the week (Sunday) based on the provided date
|
// Get the last day of the week (Sunday) based on the provided date
|
||||||
lastDayOfWeek: (date: Date): Date => {
|
lastDayOfWeek: (date: Date): Date => {
|
||||||
const adjustedDate = new Date(date.getTime());
|
const day = date.getDay();
|
||||||
const day = adjustedDate.getDay();
|
|
||||||
const diff = (day === 0 ? 0 : 7) - day; // Adjust for Sunday
|
const diff = (day === 0 ? 0 : 7) - day; // Adjust for Sunday
|
||||||
adjustedDate.setDate(adjustedDate.getDate() + diff);
|
return new Date(date.setDate(date.getDate() + diff));
|
||||||
return adjustedDate;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
month: {
|
month: {
|
||||||
// Get the first day of the month based on the provided date first day at 00:00:01
|
// Get the first day of the month based on the provided date first day at 00:00:01
|
||||||
firstDayOfMonth: (date: Date): Date => {
|
firstDayOfMonth: (date: Date): Date => {
|
||||||
return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0); // Set to the first day of the month at 00:00:00
|
return new Date(date.getFullYear(), date.getMonth(), 2, 0, 0, 1, 0); // Set to the first day of the month at 00:00:01
|
||||||
},
|
},
|
||||||
// Get the last day of the month based on the provided date last day at 23:59:59
|
// Get the last day of the month based on the provided date last day at 23:59:59
|
||||||
lastDayOfMonth: (date: Date): Date => {
|
lastDayOfMonth: (date: Date): Date => {
|
||||||
@@ -86,34 +80,26 @@ const datePresetFunctions = {
|
|||||||
|
|
||||||
const today: datePreset = {
|
const today: datePreset = {
|
||||||
labelKey: 'global.text.today',
|
labelKey: 'global.text.today',
|
||||||
getRange: () => {
|
start: new Date(),
|
||||||
const today = new Date();
|
end: new Date(),
|
||||||
return { start: today, end: new Date(today.getTime()) };
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const yesterday: datePreset = {
|
const yesterday: datePreset = {
|
||||||
labelKey: 'global.text.yesterday',
|
labelKey: 'global.text.yesterday',
|
||||||
getRange: () => {
|
start: datePresetFunctions.applyStringModifier(new Date(), '-1 day'),
|
||||||
const yesterday = datePresetFunctions.applyStringModifier(new Date(), '-1 day');
|
end: datePresetFunctions.applyStringModifier(new Date(), '-1 day'),
|
||||||
return { start: yesterday, end: new Date(yesterday.getTime()) };
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const thisWeek: datePreset = {
|
const thisWeek: datePreset = {
|
||||||
labelKey: 'global.text.this_week',
|
labelKey: 'global.text.this_week',
|
||||||
getRange: () => ({
|
start: datePresetFunctions.week.firstDayOfWeek(new Date()),
|
||||||
start: datePresetFunctions.week.firstDayOfWeek(new Date()),
|
end: datePresetFunctions.week.lastDayOfWeek(new Date()),
|
||||||
end: datePresetFunctions.week.lastDayOfWeek(new Date()),
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const thisMonth: datePreset = {
|
const thisMonth: datePreset = {
|
||||||
labelKey: 'global.text.this_month',
|
labelKey: 'global.text.this_month',
|
||||||
getRange: () => ({
|
start: datePresetFunctions.month.firstDayOfMonth(new Date()),
|
||||||
start: datePresetFunctions.month.firstDayOfMonth(new Date()),
|
end: datePresetFunctions.month.lastDayOfMonth(new Date()),
|
||||||
end: datePresetFunctions.month.lastDayOfMonth(new Date()),
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -130,7 +116,7 @@ export const datePresets = <datePreset[]>[
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const convertToISO = (date: Date): string => {
|
const convertToISO = (date: Date): string => {
|
||||||
return formatLocalDateOnly(date); // Convert to YYYY-MM-DD format
|
return date.toISOString().split('T')[0]; // Convert to YYYY-MM-DD format
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dateFunctions = {
|
export const dateFunctions = {
|
||||||
@@ -164,4 +150,4 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template></template>
|
<template></template>
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
<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,7 +64,6 @@ const isSmall = ref(window.innerWidth < 1024);
|
|||||||
<slot name="paginationDisplayFiltersElement"></slot>
|
<slot name="paginationDisplayFiltersElement"></slot>
|
||||||
<slot name="leftPaginationColumns"></slot>
|
<slot name="leftPaginationColumns"></slot>
|
||||||
<div class="column is-auto-fill my-3" v-if="!isSmall" />
|
<div class="column is-auto-fill my-3" v-if="!isSmall" />
|
||||||
<slot name="rightFilterActions"></slot>
|
|
||||||
<div class="columns is-multiline">
|
<div class="columns is-multiline">
|
||||||
<div class="column is-12 p-1 m-0"></div>
|
<div class="column is-12 p-1 m-0"></div>
|
||||||
<slot name="rightPaginationColumns"></slot>
|
<slot name="rightPaginationColumns"></slot>
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ if (router.currentRoute.value.params.departmentId) {
|
|||||||
<TableLabeledPagination :label="SessionUser.objects.department_notification_sms.meta.title">
|
<TableLabeledPagination :label="SessionUser.objects.department_notification_sms.meta.title">
|
||||||
<template #buttons="{ loadList }">
|
<template #buttons="{ loadList }">
|
||||||
<button
|
<button
|
||||||
class="button is-primary is-small"
|
class="button is-info is-small"
|
||||||
data-testid="department-notification-sms-add-button"
|
|
||||||
@click="SessionUser.objects.department_notification_sms.showCreateObjectForm(() => loadList(), {department: parseInt(router.currentRoute.value.params.departmentId)})"
|
@click="SessionUser.objects.department_notification_sms.showCreateObjectForm(() => loadList(), {department: parseInt(router.currentRoute.value.params.departmentId)})"
|
||||||
>
|
>
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
|
|||||||
@@ -21,32 +21,9 @@ const props = defineProps({
|
|||||||
type: Number, default: 0
|
type: Number, default: 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
import {computed, provide, ref, watch } from "vue";
|
import {computed, ref, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
PaginatedListKey,
|
|
||||||
usePaginatedList,
|
|
||||||
} from "@/components/pagination/paginatedList.vue";
|
|
||||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
|
||||||
|
|
||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
|
||||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
|
||||||
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
|
|
||||||
import PaginationDisplayTemplateDates
|
|
||||||
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
|
|
||||||
import ShowErrorField from "@/components/global/ShowErrorField.vue";
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { BSwitch } from "buefy";
|
|
||||||
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
|
|
||||||
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
|
|
||||||
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const router = useRouter();
|
|
||||||
const paginatedList = usePaginatedList();
|
|
||||||
provide(PaginatedListKey, paginatedList);
|
|
||||||
|
|
||||||
const {
|
|
||||||
isLoading,
|
isLoading,
|
||||||
list,
|
list,
|
||||||
loadList,
|
loadList,
|
||||||
@@ -61,8 +38,22 @@ const {
|
|||||||
setOrder,
|
setOrder,
|
||||||
hideSearchField,
|
hideSearchField,
|
||||||
setHideSearchField,
|
setHideSearchField,
|
||||||
} = paginatedList;
|
} from "@/components/pagination/paginatedList.vue";
|
||||||
|
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||||
|
|
||||||
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||||
|
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||||
|
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
|
||||||
|
import PaginationDisplayTemplateDates
|
||||||
|
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
|
||||||
|
import ShowErrorField from "@/components/global/ShowErrorField.vue";
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { BSwitch } from "buefy";
|
||||||
|
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter();
|
||||||
|
const SELFWASH_PERIOD_ALL_LIMIT = 10000;
|
||||||
setEndpoint("/modules/xlvask/services/usage/orders", false);
|
setEndpoint("/modules/xlvask/services/usage/orders", false);
|
||||||
if (props.loadAllAtOnce) {
|
if (props.loadAllAtOnce) {
|
||||||
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
|
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
|
||||||
@@ -72,7 +63,7 @@ const parseInitialDate = (value) => {
|
|||||||
return new Date();
|
return new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseLocalDateOnly(value);
|
const parsed = new Date(`${value}T00:00:00`);
|
||||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,24 +103,13 @@ if (router.currentRoute.value.params.departmentId) {
|
|||||||
}
|
}
|
||||||
const isImportLoading = ref(false);
|
const isImportLoading = ref(false);
|
||||||
|
|
||||||
const buildImportUsageParams = () => {
|
|
||||||
if (!props.inheritPeriodFilters) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(props.initialDateFrom ? { dateFrom: props.initialDateFrom } : {}),
|
|
||||||
...(props.initialDateTo ? { dateTo: props.initialDateTo } : {}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const importUsageButton = async () => {
|
const importUsageButton = async () => {
|
||||||
isImportLoading.value = true;
|
isImportLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await SessionUser.request(
|
await SessionUser.request(
|
||||||
'/modules/xlvask/tasks/import-usage',
|
'/modules/xlvask/tasks/import-usage',
|
||||||
'GET',
|
'GET',
|
||||||
buildImportUsageParams(),
|
{},
|
||||||
function (error) {
|
function (error) {
|
||||||
// This function is called when the request fails
|
// This function is called when the request fails
|
||||||
console.error("Failed to import orders.", error);
|
console.error("Failed to import orders.", error);
|
||||||
@@ -149,7 +129,7 @@ const dateFrom = ref(parseInitialDate(props.initialDateFrom));
|
|||||||
const dateTo = ref(parseInitialDate(props.initialDateTo));
|
const dateTo = ref(parseInitialDate(props.initialDateTo));
|
||||||
|
|
||||||
const parsedDate = (date) => {
|
const parsedDate = (date) => {
|
||||||
return parseLocalDateOnly(date);
|
return new Date(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
const reloadScheduled = ref(false);
|
const reloadScheduled = ref(false);
|
||||||
@@ -168,14 +148,14 @@ const actions = {
|
|||||||
from: {
|
from: {
|
||||||
select: (date) => {
|
select: (date) => {
|
||||||
dateFrom.value = parsedDate(date);
|
dateFrom.value = parsedDate(date);
|
||||||
setFilter("StartTime-date_from", formatLocalDateOnly(date), false);
|
setFilter("StartTime-date_from", date.toISOString().split("T")[0], false);
|
||||||
scheduleReload();
|
scheduleReload();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
to: {
|
to: {
|
||||||
select: (date) => {
|
select: (date) => {
|
||||||
dateTo.value = parsedDate(date);
|
dateTo.value = parsedDate(date);
|
||||||
setFilter("StartTime-date_to", formatLocalDateOnly(date), false);
|
setFilter("StartTime-date_to", date.toISOString().split("T")[0], false);
|
||||||
scheduleReload();
|
scheduleReload();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const props = defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const emit = defineEmits(["flagStatusChanged", "flagCreated"]);
|
const emit = defineEmits(["flagStatusChanged", "flagCreated"]);
|
||||||
import { computed, ref, provide, watch } from "vue";
|
import { computed, ref, provide } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
usePaginatedList,
|
usePaginatedList,
|
||||||
@@ -100,13 +100,7 @@ const {
|
|||||||
setPage,
|
setPage,
|
||||||
search,
|
search,
|
||||||
endpoint,
|
endpoint,
|
||||||
filter,
|
|
||||||
metaSearch,
|
|
||||||
additionalQueryParameters,
|
|
||||||
orderBy,
|
|
||||||
orderDirection,
|
|
||||||
setFilter,
|
setFilter,
|
||||||
getFilter,
|
|
||||||
setAdditionalQueryParameters,
|
setAdditionalQueryParameters,
|
||||||
setOrder,
|
setOrder,
|
||||||
setHideSearchField,
|
setHideSearchField,
|
||||||
@@ -114,17 +108,9 @@ const {
|
|||||||
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
|
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
|
||||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||||
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.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 DatePeriodSelector from "@/components/displays/buttons/DatePeriodSelector.vue";
|
||||||
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
|
import {now} from "@vueuse/core";
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
|
||||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
|
||||||
import {
|
|
||||||
buildOrderDateEventRequestParams,
|
|
||||||
buildOrderDateEvents,
|
|
||||||
stripOrderDateFilters,
|
|
||||||
} from "@/services/orderDateEvents.js";
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -187,208 +173,24 @@ if (props.autoLoad) {
|
|||||||
loadList();
|
loadList();
|
||||||
}
|
}
|
||||||
|
|
||||||
const date_from = ref(props.dates?.dateFrom ?? null);
|
const date_from = ref(null);
|
||||||
const date_to = ref(props.dates?.dateTo ?? null);
|
const date_to = ref(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 onDateRangeSelected = (newSelectionStartDate, newSelectionToDate) => {
|
||||||
const formattedStartDate = newSelectionStartDate ? formatLocalDateOnly(newSelectionStartDate) : "";
|
const formattedStartDate = newSelectionStartDate.toISOString().split("T")[0];
|
||||||
const formattedEndDate = newSelectionToDate ? formatLocalDateOnly(newSelectionToDate) : "";
|
const formattedEndDate = newSelectionToDate.toISOString().split("T")[0];
|
||||||
date_from.value = formattedStartDate;
|
date_from.value = formattedStartDate;
|
||||||
date_to.value = formattedEndDate;
|
date_to.value = formattedEndDate;
|
||||||
setDateFilter("created_at-date_from", formattedStartDate, false);
|
setFilter("created_at-date_from", formattedStartDate, true);
|
||||||
setDateFilter("created_at-date_to", formattedEndDate, false);
|
setFilter("created_at-date_to", formattedEndDate, true);
|
||||||
loadList();
|
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) => {
|
const doesEndpointMatch = (matcher) => {
|
||||||
// Check if the endpoint matches the current endpoint
|
// Check if the endpoint matches the current endpoint
|
||||||
return endpoint.value === matcher;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -421,89 +223,145 @@ watch(orderDateEventSignature, () => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #leftPaginationColumns>
|
<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 -->
|
<!-- Date from -->
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<label class="label is-small">{{ t('pagination.from_date') }}</label>
|
<label class="label is-small">{{ t('pagination.from_date') }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<BuefyDateField
|
<input type="date" class="input" @change="setFilter('created_at-date_from', $event.target.value, true)" v-model="date_from" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Date to -->
|
<!-- Date to -->
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<label class="label is-small">{{ t('pagination.to_date') }}</label>
|
<label class="label is-small">{{ t('pagination.to_date') }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<BuefyDateField
|
<input type="date" class="input" @change="setFilter('created_at-date_to', $event.target.value, true)" v-model="date_to" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<template #rightPaginationColumns>
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<!-- Shortcuts for date filters -->
|
<!-- Shortcuts for date filters -->
|
||||||
<DatePeriodSelector :on-selection-change="onDateRangeSelected"
|
<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 }"
|
:visibility="{ showDailySelector: false, showWeeklySelector: false, showMultipleMonthWarning: false, showUpdateButton: false, showMonthSelector: false, showStartDate: false, showEndDate: false, showSelectionValidity: false, showYearSelector: false, showToLabel: false }"
|
||||||
v-bind:allow-empty-selection="true"
|
v-bind:selection="{ startDate: date_from ? new Date(date_from) : new Date(now()), endDate: date_to ? new Date(date_to) : new Date(now()) }"/>
|
||||||
v-bind:events="orderDateEvents"
|
|
||||||
v-bind:selection="{ startDate: date_from ? parseLocalDateOnly(date_from) : null, endDate: date_to ? parseLocalDateOnly(date_to) : null }"/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #default>
|
<template #default>
|
||||||
@@ -529,17 +387,4 @@ watch(orderDateEventSignature, () => {
|
|||||||
margin-top: -0.35rem;
|
margin-top: -0.35rem;
|
||||||
margin-bottom: 0.25rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
userScopedUserId: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -103,11 +99,7 @@ if (props.autoLoad) {
|
|||||||
</template>
|
</template>
|
||||||
</PaginationDisplay>
|
</PaginationDisplay>
|
||||||
|
|
||||||
<SubusersTable
|
<SubusersTable :objects="list" :show-customer="showCustomer" />
|
||||||
:objects="list"
|
|
||||||
:show-customer="showCustomer"
|
|
||||||
:user-scoped-user-id="userScopedUserId"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PaginationNavigation
|
<PaginationNavigation
|
||||||
:currentPage="metaCurrentPage"
|
:currentPage="metaCurrentPage"
|
||||||
|
|||||||
@@ -10,21 +10,11 @@ const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
|
|||||||
|
|
||||||
const paginatedList = usePaginatedList();
|
const paginatedList = usePaginatedList();
|
||||||
provide(PaginatedListKey, paginatedList);
|
provide(PaginatedListKey, paginatedList);
|
||||||
const {
|
const { list, loadList, setEndpoint, setFilter, setOrder, hideSearchField, setHideSearchField } = paginatedList;
|
||||||
list,
|
|
||||||
loadList,
|
|
||||||
setAdditionalQueryParameters,
|
|
||||||
setEndpoint,
|
|
||||||
setFilter,
|
|
||||||
setOrder,
|
|
||||||
hideSearchField,
|
|
||||||
setHideSearchField,
|
|
||||||
} = paginatedList;
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
setEndpoint("/users", false);
|
setEndpoint("/users", false);
|
||||||
setFilter("customer_number", 0, false);
|
setFilter("customer_number", 0, false);
|
||||||
setAdditionalQueryParameters({ include_limited_backoffice_employees: "true" });
|
|
||||||
setOrder("created_at", "desc");
|
setOrder("created_at", "desc");
|
||||||
|
|
||||||
// Hide the search field, if the hideSearch prop is set
|
// Hide the search field, if the hideSearch prop is set
|
||||||
@@ -62,7 +52,7 @@ if (props.autoLoad) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<UsersTable :objects="list" @migrated="loadList" />
|
<UsersTable :objects="list" />
|
||||||
</TableLabeledPagination>
|
</TableLabeledPagination>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -24,16 +24,13 @@ import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue"
|
|||||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||||
import BookingsTable from "@/components/displays/user/bookings/bookingsTable.vue";
|
import BookingsTable from "@/components/displays/user/bookings/bookingsTable.vue";
|
||||||
import {departments} from "@/components/pagination/departmentTabs.vue";
|
import {departments} from "@/components/pagination/departmentTabs.vue";
|
||||||
import {computed, ref, watch} from "vue";
|
import {ref, watch} from "vue";
|
||||||
import { Colors } from "@/ThemeConfig.vue";
|
import { Colors } from "@/ThemeConfig.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { todayLocalDateOnly } from "@/services/dateOnly.js";
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
|
|
||||||
const canCreateBooking = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
|
|
||||||
|
|
||||||
// Set the selected status to all
|
// Set the selected status to all
|
||||||
const selectedStatus = ref("*");
|
const selectedStatus = ref("*");
|
||||||
@@ -55,7 +52,7 @@ watch(() => getFilter("status"), (value) => {
|
|||||||
// If the route starts with /user, set the endpoint to /user/bookings
|
// If the route starts with /user, set the endpoint to /user/bookings
|
||||||
if (router.currentRoute.value.path.startsWith("/user")) {
|
if (router.currentRoute.value.path.startsWith("/user")) {
|
||||||
setEndpoint("/user/bookings", false);
|
setEndpoint("/user/bookings", false);
|
||||||
setFilter("date", todayLocalDateOnly(), false);
|
setFilter("date", new Date().toISOString().split("T")[0], false);
|
||||||
setOrder("created_at", "desc");
|
setOrder("created_at", "desc");
|
||||||
}
|
}
|
||||||
// If the route starts with /superuser, set the endpoint to /bookings
|
// If the route starts with /superuser, set the endpoint to /bookings
|
||||||
@@ -84,7 +81,7 @@ loadList();
|
|||||||
const showingToday = ref(true);
|
const showingToday = ref(true);
|
||||||
|
|
||||||
watch(() => getFilter("date"), (value) => {
|
watch(() => getFilter("date"), (value) => {
|
||||||
showingToday.value = value === todayLocalDateOnly();
|
showingToday.value = value === new Date().toISOString().split("T")[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
// If the screen is mobile, set the is small variable to true
|
// If the screen is mobile, set the is small variable to true
|
||||||
@@ -150,17 +147,17 @@ const showNewOrderBookingsPortal = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="column is-auto-fill my-3" v-if="!isSmall"/>
|
<div class="column is-auto-fill my-3" v-if="!isSmall"/>
|
||||||
<!-- Only show the bookings for today (Switch, if the route is /user) -->
|
<!-- Only show the bookings for today (Switch, if the route is /user) -->
|
||||||
<div class="column is-narrow my-3" v-if="isUserRoute" :class="{ 'has-text-right': !isSmall }">
|
<div class="column is-narrow my-3" v-if="router.currentRoute.value.path.startsWith('/user')" :class="{ 'has-text-right': !isSmall }">
|
||||||
<label class="label">{{ isSmall ? t('common.today') : t('pagination.show_only_today') }}</label>
|
<label class="label">{{ isSmall ? t('common.today') : t('pagination.show_only_today') }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? todayLocalDateOnly() : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
|
<input id="today" type="checkbox" class="switch is-rounded" @change="(event) => { setFilter('date', event.target.checked ? new Date().toISOString().split('T')[0] : '*') }" checked="checked" :class="{ 'is-link': showingToday }" />
|
||||||
<label for="today"></label>
|
<label for="today"></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Create a new booking, if the route is /user -->
|
<!-- Create a new booking, if the route is /user -->
|
||||||
<div class="column is-narrow my-3" v-if="isUserRoute && canCreateBooking" :class="{ 'has-text-right': !isSmall }">
|
<div class="column is-narrow my-3" v-if="router.currentRoute.value.path.startsWith('/user')" :class="{ 'has-text-right': !isSmall }">
|
||||||
<button
|
<button
|
||||||
class="button is-link"
|
class="button is-link"
|
||||||
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
|
@click="SessionUser.functions.redirectTo.external('/user/bookings/new', true)"
|
||||||
@@ -191,4 +188,4 @@ const showNewOrderBookingsPortal = () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -8,7 +8,6 @@ import OrderBookingsTable from "@/views/dashboards/userDashboard/bookings/displa
|
|||||||
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
|
import { departments, getDepartments } from "@/components/pagination/departmentTabs.vue";
|
||||||
import { Colors } from "@/ThemeConfig.vue";
|
import { Colors } from "@/ThemeConfig.vue";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { endOfLocalDate, startOfLocalDate, todayLocalDateOnly } from "@/services/dateOnly.js";
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +16,6 @@ const { t } = useI18n();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
|
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
|
||||||
const isAdminRoute = computed(() => router.currentRoute.value.path.startsWith("/admin"));
|
const isAdminRoute = computed(() => router.currentRoute.value.path.startsWith("/admin"));
|
||||||
const canCreateBooking = computed(() => SessionUser.canAccessCustomerFeature("bookings", "add"));
|
|
||||||
/**
|
/**
|
||||||
* Props
|
* Props
|
||||||
*/
|
*/
|
||||||
@@ -54,9 +52,11 @@ const onOnlyTodayFilterChange = (event, autoLoadList = true) => {
|
|||||||
setFilter("datetime-date_from", null, false);
|
setFilter("datetime-date_from", null, false);
|
||||||
setFilter("datetime-date_to", null, false);
|
setFilter("datetime-date_to", null, false);
|
||||||
} else {
|
} else {
|
||||||
|
const startOfDay = new Date().setHours(0, 0, 0, 0);
|
||||||
|
const endOfDay = new Date().setHours(23, 59, 59, 999);
|
||||||
//setFilter('datetime', null, false);
|
//setFilter('datetime', null, false);
|
||||||
setFilter("datetime-date_from", startOfLocalDate(val).toISOString(), false);
|
setFilter("datetime-date_from", new Date(startOfDay).toISOString(), false);
|
||||||
setFilter("datetime-date_to", endOfLocalDate(val).toISOString(), false);
|
setFilter("datetime-date_to", new Date(endOfDay).toISOString(), false);
|
||||||
}
|
}
|
||||||
if (autoLoadList) {
|
if (autoLoadList) {
|
||||||
loadList();
|
loadList();
|
||||||
@@ -95,7 +95,7 @@ onMounted(() => {
|
|||||||
departmentFilter.value = value;
|
departmentFilter.value = value;
|
||||||
} else if (key === "only_today" && value === true) {
|
} else if (key === "only_today" && value === true) {
|
||||||
setFilterKey = false; // Since the only_today filter is handled separately
|
setFilterKey = false; // Since the only_today filter is handled separately
|
||||||
onOnlyTodayFilterChange({ target: { value: todayLocalDateOnly() } }, false);
|
onOnlyTodayFilterChange({ target: { value: new Date().toISOString().split("T")[0] } }, false);
|
||||||
}
|
}
|
||||||
if (setFilterKey) {
|
if (setFilterKey) {
|
||||||
setFilter(key, value, false);
|
setFilter(key, value, false);
|
||||||
@@ -170,7 +170,7 @@ onMounted(() => {
|
|||||||
<div class="select">
|
<div class="select">
|
||||||
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
|
<select :value="onlyTodayFilter" @change="onOnlyTodayFilterChange">
|
||||||
<option value="*">{{ t("common.all") }}</option>
|
<option value="*">{{ t("common.all") }}</option>
|
||||||
<option :value="todayLocalDateOnly()">{{ t("common.yes") }}</option>
|
<option :value="new Date().toISOString().split('T')[0]">{{ t("common.yes") }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +193,7 @@ onMounted(() => {
|
|||||||
@change="
|
@change="
|
||||||
(event) => {
|
(event) => {
|
||||||
onOnlyTodayFilterChange({
|
onOnlyTodayFilterChange({
|
||||||
target: { value: event.target.checked ? todayLocalDateOnly() : '*' },
|
target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
@@ -203,7 +203,7 @@ onMounted(() => {
|
|||||||
<label for="today"></label>
|
<label for="today"></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="order-bookings-pagination__new-booking-action" v-if="canCreateBooking">
|
<div class="order-bookings-pagination__new-booking-action">
|
||||||
<label class="label is-small order-bookings-pagination__desktop-spacer"> </label>
|
<label class="label is-small order-bookings-pagination__desktop-spacer"> </label>
|
||||||
<button
|
<button
|
||||||
class="button is-link button-same-width"
|
class="button is-link button-same-width"
|
||||||
@@ -226,7 +226,7 @@ onMounted(() => {
|
|||||||
@change="
|
@change="
|
||||||
(event) => {
|
(event) => {
|
||||||
onOnlyTodayFilterChange(
|
onOnlyTodayFilterChange(
|
||||||
{ target: { value: event.target.checked ? todayLocalDateOnly() : '*' } },
|
{ target: { value: event.target.checked ? new Date().toISOString().split('T')[0] : '*' } },
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } });
|
onOrderIdFilterChange({ target: { value: event.target.checked ? 'is null' : '*' } });
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import VehiclesTable from "@/components/displays/user/vehicles/vehiclesTable.vue";
|
import VehiclesTable from "@/components/displays/user/vehicles/vehiclesTable.vue";
|
||||||
import { computed } from "vue";
|
|
||||||
|
|
||||||
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
|
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
|
||||||
import {Colors} from "@/ThemeConfig.vue";
|
import {Colors} from "@/ThemeConfig.vue";
|
||||||
@@ -21,15 +20,8 @@ const { t } = useI18n();
|
|||||||
* Router
|
* Router
|
||||||
*/
|
*/
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isUserRoute = computed(() => router.currentRoute.value.path.startsWith("/user"));
|
|
||||||
const canAddVehicle = computed(() => SessionUser.canAccessCustomerFeature("vehicles", "add"));
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
endpoint: {
|
|
||||||
type: String,
|
|
||||||
required: false,
|
|
||||||
default: "/vehicles",
|
|
||||||
},
|
|
||||||
customer_id: {
|
customer_id: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -40,34 +32,12 @@ const props = defineProps({
|
|||||||
required: false,
|
required: false,
|
||||||
default: null,
|
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(props.endpoint, false);
|
setEndpoint("/vehicles", false);
|
||||||
if (props.customer_id) {
|
if (props.customer_id) {
|
||||||
setFilter("customer_id", props.customer_id, false);
|
setFilter("customer_id", props.customer_id, false);
|
||||||
}
|
}
|
||||||
loadList();
|
loadList();
|
||||||
|
|
||||||
const reloadVehicles = async () => {
|
|
||||||
await loadList();
|
|
||||||
if (typeof props.onAfterMutation === "function") {
|
|
||||||
await props.onAfterMutation();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -88,7 +58,7 @@ const reloadVehicles = async () => {
|
|||||||
</div>
|
</div>
|
||||||
<!-- Create a new vehicle, if the route is /user -->
|
<!-- Create a new vehicle, if the route is /user -->
|
||||||
<div class="vehicles-pagination__add-action"
|
<div class="vehicles-pagination__add-action"
|
||||||
v-if="isUserRoute && canAddVehicle">
|
v-if="router.currentRoute.value.path.startsWith('/user')">
|
||||||
<label class="label is-small"> </label>
|
<label class="label is-small"> </label>
|
||||||
<button
|
<button
|
||||||
class="button is-link button-same-width vehicles-pagination__add-button"
|
class="button is-link button-same-width vehicles-pagination__add-button"
|
||||||
@@ -102,14 +72,7 @@ const reloadVehicles = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #default>
|
<template #default>
|
||||||
<VehiclesTable
|
<VehiclesTable :vehicles="list" :show_add_vehicle="props.customer_id" :add_other_customer_id="props.add_other_customer_id"/>
|
||||||
: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>
|
</template>
|
||||||
</TableLabeledPagination>
|
</TableLabeledPagination>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PaginationDisplayItemColumn from "@/components/displays/pagination/PaginationDisplayItemColumn.vue";
|
import PaginationDisplayItemColumn from "@/components/displays/pagination/PaginationDisplayItemColumn.vue";
|
||||||
import BuefyDateField from "@/components/forms/BuefyDateField.vue";
|
import { ref, computed } from "vue";
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
default: "Date",
|
default: "Date",
|
||||||
},
|
},
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['update:date']);
|
const emit = defineEmits(['update:date']);
|
||||||
const date = defineModel<Date>({
|
const date = defineModel<Date>({
|
||||||
@@ -22,20 +18,28 @@ defineExpose({
|
|||||||
date,
|
date,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateDate = (newDate: Date) => {
|
const formattedDate = computed(() => {
|
||||||
emit('update:date', newDate);
|
return date.value.toISOString().split('T')[0];
|
||||||
};
|
});
|
||||||
|
|
||||||
|
// This component is used to display a date input in a pagination display item column.
|
||||||
|
// It allows the user to select a date, which will be emitted to the parent component.
|
||||||
|
// The date is displayed in a YYYY-MM-DD format, which is compatible with the HTML date
|
||||||
|
// input type.
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PaginationDisplayItemColumn :label="props.label">
|
<PaginationDisplayItemColumn :label="props.label">
|
||||||
<template #control>
|
<template #control>
|
||||||
<BuefyDateField
|
<input
|
||||||
v-model="date"
|
type="date"
|
||||||
value-type="date"
|
class="input"
|
||||||
data-testid="pagination-date-picker"
|
:value="formattedDate"
|
||||||
:placeholder="props.placeholder"
|
@input="(e) => {
|
||||||
@change="updateDate"
|
const newDate = new Date(e.target.value);
|
||||||
|
console.log('Selected date:', newDate);
|
||||||
|
emit('update:date', newDate);
|
||||||
|
}"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</PaginationDisplayItemColumn>
|
</PaginationDisplayItemColumn>
|
||||||
@@ -43,4 +47,4 @@ const updateDate = (newDate: Date) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||||
|
|
||||||
@@ -9,9 +10,7 @@ import PaginationDisplayTemplateButton
|
|||||||
from "@/components/displays/pagination/templates/PaginationDisplayTemplateButton.vue";
|
from "@/components/displays/pagination/templates/PaginationDisplayTemplateButton.vue";
|
||||||
import { datePresets , dateFunctions} from '@/components/displays/pagination/PaginationDisplayDates.vue';
|
import { datePresets , dateFunctions} from '@/components/displays/pagination/PaginationDisplayDates.vue';
|
||||||
|
|
||||||
const DATE_FILTER_PLACEHOLDER = "--.--.----";
|
const props = defineProps({
|
||||||
|
|
||||||
defineProps({
|
|
||||||
startDate: {
|
startDate: {
|
||||||
type: Date,
|
type: Date,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -47,24 +46,6 @@ function updateStartDate(date: Date) {
|
|||||||
// Update the end date model when the date is changed
|
// Update the end date model when the date is changed
|
||||||
function updateEndDate(date: Date) {
|
function updateEndDate(date: Date) {
|
||||||
endDateModel.value = 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>
|
</script>
|
||||||
|
|
||||||
@@ -73,19 +54,23 @@ function applyPreset(preset: { getRange: () => { start: Date; end: Date } }) {
|
|||||||
:label="SessionUser.objects.global.language.date_from"
|
:label="SessionUser.objects.global.language.date_from"
|
||||||
@update:date="updateStartDate"
|
@update:date="updateStartDate"
|
||||||
v-bind:model-value="startDateModel"
|
v-bind:model-value="startDateModel"
|
||||||
:placeholder="DATE_FILTER_PLACEHOLDER"
|
|
||||||
/>
|
/>
|
||||||
<PaginationDisplayTemplateDate
|
<PaginationDisplayTemplateDate
|
||||||
:label="SessionUser.objects.global.language.date_to"
|
:label="SessionUser.objects.global.language.date_to"
|
||||||
@update:date="updateEndDate"
|
@update:date="updateEndDate"
|
||||||
v-bind:model-value="endDateModel"
|
v-bind:model-value="endDateModel"
|
||||||
:placeholder="DATE_FILTER_PLACEHOLDER"
|
|
||||||
/>
|
/>
|
||||||
<template v-for="preset in datePresets" :key="preset.labelKey">
|
<template v-for="preset in datePresets" :key="preset.labelKey">
|
||||||
<PaginationDisplayTemplateButton
|
<PaginationDisplayTemplateButton
|
||||||
:label="t(preset.labelKey)"
|
:label="t(preset.labelKey)"
|
||||||
v-bind:selected="isPresetSelected(preset)"
|
v-bind:selected="dateFunctions.dateComparison.match.dates(
|
||||||
@click="applyPreset(preset)"
|
{ start: startDateModel, end: endDateModel },
|
||||||
|
preset,
|
||||||
|
)"
|
||||||
|
@click="() => {
|
||||||
|
updateStartDate(preset.start);
|
||||||
|
updateEndDate(preset.end);
|
||||||
|
}"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -94,4 +79,4 @@ function applyPreset(preset: { getRange: () => { start: Date; end: Date } }) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { BCheckbox } from "buefy";
|
import { BCheckbox, BField } from "buefy";
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { onMounted, ref, watch } from "vue";
|
||||||
import i18n from "@/i18n";
|
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
grant: {
|
grant: {
|
||||||
type: Object as () => { id: number; permissions: string[]; name: string; permission_template_key?: string },
|
type: Object as () => { id: number; permissions: string[]; name: string },
|
||||||
default: () => ({ id: 0, permissions: [], name: "", permission_template_key: "custom" }),
|
default: () => ({ id: 0, permissions: [], name: "" }),
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
permissions: {
|
permissions: {
|
||||||
@@ -15,182 +14,59 @@ const props = defineProps({
|
|||||||
default: () => [],
|
default: () => [],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
templateKey: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
allowAdvanced: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type PermissionGroup = {
|
type PermissionNode = {
|
||||||
key: string;
|
key: string;
|
||||||
capabilities: string[];
|
name: string;
|
||||||
};
|
|
||||||
|
|
||||||
type PermissionTemplate = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
description: string;
|
||||||
enabled: boolean;
|
type: string;
|
||||||
permissions: string[];
|
default: boolean;
|
||||||
permission_groups: PermissionGroup[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const t = (key: string, values: Record<string, string> | undefined = undefined) => i18n.global.t(key, values);
|
type PermissionNodeGroup = {
|
||||||
|
group: string;
|
||||||
const LEGACY_PERMISSION_GROUPS: PermissionGroup[] = [
|
description: string;
|
||||||
{
|
nodes: PermissionNode[];
|
||||||
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 permissionNodes = ref<PermissionNodeGroup[]>([]);
|
||||||
const selectedPermissions = ref<string[]>([]);
|
const selectedPermissions = ref<string[]>([]);
|
||||||
const selectedTemplateKey = ref<string>(props.templateKey || props.grant.permission_template_key || "custom");
|
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
|
|
||||||
const emit = defineEmits(["update:permissions", "update:access"]);
|
const emit = defineEmits(["update:permissions"]);
|
||||||
|
|
||||||
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 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[]) => {
|
const syncPermissions = (value: string[]) => {
|
||||||
selectedPermissions.value = normalizePermissions(value);
|
selectedPermissions.value = [...new Set((value || []).filter(Boolean))];
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = findTemplate(key);
|
|
||||||
if (!template) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedTemplateKey.value = template.key;
|
|
||||||
selectedPermissions.value = normalizePermissions(template.permissions);
|
|
||||||
emitAccess();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePermission = (permissionKey: string, enabled: boolean) => {
|
const togglePermission = (permissionKey: string, enabled: boolean) => {
|
||||||
selectedTemplateKey.value = "custom";
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
selectedPermissions.value = [...new Set([...selectedPermissions.value, permissionKey])];
|
selectedPermissions.value = [...new Set([...selectedPermissions.value, permissionKey])];
|
||||||
} else {
|
return;
|
||||||
selectedPermissions.value = selectedPermissions.value.filter((value) => value !== permissionKey);
|
|
||||||
}
|
}
|
||||||
emitAccess();
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibleTemplates = computed(() => templates.value.filter((template) => template.key !== "custom"));
|
selectedPermissions.value = selectedPermissions.value.filter((value) => value !== permissionKey);
|
||||||
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(
|
watch(
|
||||||
() => props.permissions,
|
() => props.permissions,
|
||||||
(newPermissions) => {
|
(newPermissions) => {
|
||||||
syncPermissions(newPermissions || []);
|
syncPermissions(newPermissions || []);
|
||||||
emitAccess();
|
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(selectedPermissions, (newPermissions) => {
|
||||||
|
emit("update:permissions", [...newPermissions]);
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const accessModel = await SessionUser.objects.subuser_grants.functions.getPermissionTemplates();
|
permissionNodes.value = await SessionUser.objects.subuser_grants.functions.getPermissionNodes();
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch permission templates:", error);
|
console.error("Failed to fetch permission nodes:", error);
|
||||||
templates.value = [];
|
permissionNodes.value = [];
|
||||||
selectedTemplateKey.value = "custom";
|
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
@@ -199,66 +75,42 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="title is-4">{{ t("superuser.driver_access.editor_title", { name: props.grant.name }) }}</h2>
|
<h2 class="title is-4">Tilladelsesnoder for {{ props.grant.name }}</h2>
|
||||||
<p class="mb-4 has-text-grey">
|
<p class="mb-4 has-text-grey">
|
||||||
{{ t("superuser.driver_access.editor_intro") }}
|
Vælg tilladelserne og tryk derefter på Gem i dialogen for at opdatere chaufføren.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="isLoading" class="notification is-light">
|
<div v-if="isLoading" class="notification is-light">
|
||||||
{{ t("superuser.driver_access.loading") }}
|
Henter tilladelser...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="visibleTemplates.length === 0" class="notification is-warning is-light">
|
<div v-else-if="permissionNodes.length === 0" class="notification is-warning is-light">
|
||||||
{{ t("superuser.driver_access.empty") }}
|
Ingen tilladelsesnoder blev fundet.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="driver-access-editor">
|
<div v-else v-for="group in permissionNodes" :key="group.group" class="box">
|
||||||
<button
|
<div class="divider">
|
||||||
v-for="template in visibleTemplates"
|
<div class="content">
|
||||||
:key="template.key"
|
<h3 class="title is-5">{{ group.group }}</h3>
|
||||||
type="button"
|
<p class="subtitle is-6">{{ group.description }}</p>
|
||||||
class="driver-access-card"
|
</div>
|
||||||
:class="{ 'driver-access-card--selected': selectedTemplateKey === template.key }"
|
</div>
|
||||||
: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>
|
|
||||||
|
|
||||||
<button
|
<div class="columns is-multiline">
|
||||||
v-if="allowAdvanced"
|
<div v-for="node in group.nodes" :key="node.key" class="column is-4">
|
||||||
type="button"
|
<div class="box permission-node">
|
||||||
class="driver-access-card"
|
<h4 class="title is-6">{{ node.name }}</h4>
|
||||||
:class="{ 'driver-access-card--selected': selectedTemplateKey === 'custom' }"
|
<p class="subtitle is-7">{{ node.description }}</p>
|
||||||
data-testid="permission-template-custom"
|
<b-field class="permission-checkbox">
|
||||||
@click="selectTemplate('custom')"
|
<b-checkbox
|
||||||
>
|
:model-value="selectedPermissions.includes(node.key)"
|
||||||
<span class="driver-access-card__title">{{ templateLabel("custom") }}</span>
|
:data-testid="`permission-node-checkbox-${node.key}`"
|
||||||
<span class="driver-access-card__description">{{ t("superuser.driver_access.custom_description") }}</span>
|
@update:model-value="(checked) => togglePermission(node.key, !!checked)"
|
||||||
<span v-if="selectedTemplateKey === 'custom'" class="tag is-warning is-light">{{ t("superuser.driver_access.advanced") }}</span>
|
@input="(checked) => togglePermission(node.key, !!checked)"
|
||||||
</button>
|
>
|
||||||
</div>
|
Aktiver
|
||||||
|
</b-checkbox>
|
||||||
<div v-if="activeTemplate && selectedTemplateKey !== 'custom'" class="notification is-info is-light mt-4">
|
</b-field>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,44 +119,15 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.driver-access-editor {
|
.permission-node {
|
||||||
display: grid;
|
height: 100%;
|
||||||
gap: 0.75rem;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-access-card {
|
.permission-checkbox {
|
||||||
align-items: flex-start;
|
margin-top: 0.75rem;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.driver-access-card:hover,
|
.permission-checkbox :deep(.field) {
|
||||||
.driver-access-card--selected {
|
margin-bottom: 0;
|
||||||
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>
|
</style>
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
activeKey: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
ariaLabel: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
testId: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(["change"]);
|
|
||||||
|
|
||||||
const itemKey = (item) => item.key ?? item.path ?? item.to ?? item.name ?? item.label;
|
|
||||||
const isActive = (item) => String(itemKey(item)) === String(props.activeKey);
|
|
||||||
const itemTestId = (item) => item.testId || undefined;
|
|
||||||
|
|
||||||
const changeItem = (item) => {
|
|
||||||
if (item.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit("change", item);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<nav
|
|
||||||
class="superuser-overview-segmented-tabs"
|
|
||||||
:aria-label="props.ariaLabel || undefined"
|
|
||||||
:data-testid="props.testId || undefined"
|
|
||||||
>
|
|
||||||
<template v-for="item in props.items" :key="itemKey(item)">
|
|
||||||
<router-link
|
|
||||||
v-if="item.to"
|
|
||||||
class="superuser-overview-segmented-tabs__item"
|
|
||||||
:class="{ 'is-active': isActive(item), 'is-disabled': item.disabled }"
|
|
||||||
:to="item.to"
|
|
||||||
:aria-current="isActive(item) ? 'page' : undefined"
|
|
||||||
:data-testid="itemTestId(item)"
|
|
||||||
>
|
|
||||||
<span v-if="item.icon" class="icon is-small"><i :class="item.icon" /></span>
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
</router-link>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="superuser-overview-segmented-tabs__item"
|
|
||||||
:class="{ 'is-active': isActive(item), 'is-disabled': item.disabled }"
|
|
||||||
type="button"
|
|
||||||
:disabled="item.disabled"
|
|
||||||
:aria-current="isActive(item) ? 'page' : undefined"
|
|
||||||
:data-testid="itemTestId(item)"
|
|
||||||
@click="changeItem(item)"
|
|
||||||
>
|
|
||||||
<span v-if="item.icon" class="icon is-small"><i :class="item.icon" /></span>
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.superuser-overview-segmented-tabs {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.superuser-overview-segmented-tabs__item {
|
|
||||||
align-items: center;
|
|
||||||
background: #f8fafc;
|
|
||||||
border: 1px solid #dbe3ec;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #334155;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
font-weight: 800;
|
|
||||||
gap: 0.35rem;
|
|
||||||
min-height: 2.5rem;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.superuser-overview-segmented-tabs__item:hover,
|
|
||||||
.superuser-overview-segmented-tabs__item:focus-visible {
|
|
||||||
border-color: #94a3b8;
|
|
||||||
color: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.superuser-overview-segmented-tabs__item:focus-visible {
|
|
||||||
outline: 2px solid #0f766e;
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.superuser-overview-segmented-tabs__item.is-active {
|
|
||||||
background: #0f172a;
|
|
||||||
border-color: #0f172a;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.superuser-overview-segmented-tabs__item.is-disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { RouterLink } from "vue-router";
|
import { RouterLink } from "vue-router";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
const { t, te, locale } = useI18n();
|
const { t, te, locale } = useI18n();
|
||||||
|
|
||||||
const MAX_GATEWAY_CARDS = 8;
|
const MAX_GATEWAY_CARDS = 8;
|
||||||
const DEFAULT_DASHBOARD_TAB = "infrastructure";
|
|
||||||
const gatewayStatusPriority = Object.freeze({
|
const gatewayStatusPriority = Object.freeze({
|
||||||
OFFLINE: 0,
|
OFFLINE: 0,
|
||||||
DEGRADED: 1,
|
DEGRADED: 1,
|
||||||
@@ -78,14 +77,7 @@ const gatewayFleetUsage = ref(createEmptyGatewayFleetUsage());
|
|||||||
const gatewaySectionSuppressed = ref(false);
|
const gatewaySectionSuppressed = ref(false);
|
||||||
const gatewayDepartments = ref({});
|
const gatewayDepartments = ref({});
|
||||||
const gatewayDepartmentsLoaded = ref(false);
|
const gatewayDepartmentsLoaded = ref(false);
|
||||||
const activeDashboardTab = ref(DEFAULT_DASHBOARD_TAB);
|
|
||||||
const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value);
|
const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value);
|
||||||
const dashboardTabKeys = computed(() => [
|
|
||||||
DEFAULT_DASHBOARD_TAB,
|
|
||||||
...(showGatewaySection.value ? ["gateways"] : []),
|
|
||||||
"modules",
|
|
||||||
"sessions",
|
|
||||||
]);
|
|
||||||
const gatewaySummaryCards = computed(() => [
|
const gatewaySummaryCards = computed(() => [
|
||||||
{
|
{
|
||||||
id: "total",
|
id: "total",
|
||||||
@@ -195,16 +187,6 @@ const isStale = computed(() => {
|
|||||||
return nowTick.value - lastLoadedAt.value.getTime() > (refreshAfterSeconds.value * 2000);
|
return nowTick.value - lastLoadedAt.value.getTime() > (refreshAfterSeconds.value * 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [activeDashboardTab.value, dashboardTabKeys.value.join("|")],
|
|
||||||
() => {
|
|
||||||
if (!dashboardTabKeys.value.includes(activeDashboardTab.value)) {
|
|
||||||
activeDashboardTab.value = DEFAULT_DASHBOARD_TAB;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadStatus = async ({ force = false } = {}) => {
|
const loadStatus = async ({ force = false } = {}) => {
|
||||||
const [snapshotValue] = await Promise.all([
|
const [snapshotValue] = await Promise.all([
|
||||||
getSuperuserSystemStatus({ force }),
|
getSuperuserSystemStatus({ force }),
|
||||||
@@ -725,250 +707,203 @@ function modulePath(key) {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<b-tabs
|
<section class="system-status-section">
|
||||||
v-model="activeDashboardTab"
|
<div class="section-heading">
|
||||||
expanded
|
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
|
||||||
type="is-boxed"
|
<RouterLink class="section-link" to="/superuser/system/replication">
|
||||||
class="system-status-tabs"
|
{{ $t("system_status.actions.open_replication") }}
|
||||||
data-testid="system-status-dashboard-tabs"
|
</RouterLink>
|
||||||
>
|
</div>
|
||||||
<b-tab-item value="infrastructure">
|
<div class="status-card-grid">
|
||||||
<template #header>
|
<article
|
||||||
<b-icon pack="fas" icon="server" />
|
v-for="card in statusCards"
|
||||||
<span data-testid="system-status-tab-infrastructure">
|
:key="card.id"
|
||||||
{{ $t("system_status.sections.infrastructure") }}
|
class="status-card"
|
||||||
</span>
|
:class="statusClass(card.status)"
|
||||||
</template>
|
:data-testid="`status-card-${card.id}`"
|
||||||
|
|
||||||
<section class="system-status-section" data-testid="system-status-panel-infrastructure">
|
|
||||||
<div class="section-heading">
|
|
||||||
<h3>{{ $t("system_status.sections.infrastructure") }}</h3>
|
|
||||||
<RouterLink class="section-link" to="/superuser/system/replication">
|
|
||||||
{{ $t("system_status.actions.open_replication") }}
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
<div class="status-card-grid">
|
|
||||||
<article
|
|
||||||
v-for="card in statusCards"
|
|
||||||
:key="card.id"
|
|
||||||
class="status-card"
|
|
||||||
:class="statusClass(card.status)"
|
|
||||||
:data-testid="`status-card-${card.id}`"
|
|
||||||
>
|
|
||||||
<div class="status-card__top">
|
|
||||||
<p class="status-card__title">{{ card.title }}</p>
|
|
||||||
<span class="status-pill" :class="statusClass(card.status)">{{ statusLabel(card.status) }}</span>
|
|
||||||
</div>
|
|
||||||
<strong class="status-card__primary">{{ card.primary }}</strong>
|
|
||||||
<p class="status-card__secondary">{{ card.secondary }}</p>
|
|
||||||
<small class="status-card__detail">{{ card.detail }}</small>
|
|
||||||
<small
|
|
||||||
v-if="card.replicationLabel"
|
|
||||||
class="status-card__detail status-card__replication"
|
|
||||||
:data-testid="`status-card-${card.id}-replication`"
|
|
||||||
>
|
|
||||||
{{ card.replicationLabel }}
|
|
||||||
</small>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</b-tab-item>
|
|
||||||
|
|
||||||
<b-tab-item v-if="showGatewaySection" value="gateways">
|
|
||||||
<template #header>
|
|
||||||
<b-icon pack="fas" icon="network-wired" />
|
|
||||||
<span data-testid="system-status-tab-gateways">
|
|
||||||
{{ $t("system_status.sections.gateways") }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<section
|
|
||||||
class="system-status-section"
|
|
||||||
data-testid="system-status-gateways"
|
|
||||||
>
|
>
|
||||||
<div class="section-heading">
|
<div class="status-card__top">
|
||||||
|
<p class="status-card__title">{{ card.title }}</p>
|
||||||
|
<span class="status-pill" :class="statusClass(card.status)">{{ statusLabel(card.status) }}</span>
|
||||||
|
</div>
|
||||||
|
<strong class="status-card__primary">{{ card.primary }}</strong>
|
||||||
|
<p class="status-card__secondary">{{ card.secondary }}</p>
|
||||||
|
<small class="status-card__detail">{{ card.detail }}</small>
|
||||||
|
<small
|
||||||
|
v-if="card.replicationLabel"
|
||||||
|
class="status-card__detail status-card__replication"
|
||||||
|
:data-testid="`status-card-${card.id}-replication`"
|
||||||
|
>
|
||||||
|
{{ card.replicationLabel }}
|
||||||
|
</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="showGatewaySection"
|
||||||
|
class="system-status-section"
|
||||||
|
data-testid="system-status-gateways"
|
||||||
|
>
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<h3>{{ $t("system_status.sections.gateways") }}</h3>
|
||||||
|
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
|
||||||
|
</div>
|
||||||
|
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
|
||||||
|
{{ $t("system_status.gateways.actions.open_fleet") }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gateway-summary-grid">
|
||||||
|
<article
|
||||||
|
v-for="card in gatewaySummaryCards"
|
||||||
|
:key="card.id"
|
||||||
|
class="summary-card gateway-summary-card"
|
||||||
|
:class="card.toneClass"
|
||||||
|
:data-testid="`gateway-summary-card-${card.id}`"
|
||||||
|
>
|
||||||
|
<p class="summary-label">{{ card.title }}</p>
|
||||||
|
<strong>{{ card.value }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="gatewayError"
|
||||||
|
class="notification is-warning is-light gateway-notification"
|
||||||
|
data-testid="gateway-section-error"
|
||||||
|
>
|
||||||
|
{{ $t("system_status.gateways.error") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="gatewayLoading && !gatewayCards.length"
|
||||||
|
class="notification is-light gateway-notification"
|
||||||
|
>
|
||||||
|
{{ $t("system_status.gateways.loading") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="gatewayCards.length" class="gateway-grid">
|
||||||
|
<article
|
||||||
|
v-for="gateway in gatewayCards"
|
||||||
|
:key="gateway.id"
|
||||||
|
class="gateway-card"
|
||||||
|
:class="gateway.toneClass"
|
||||||
|
:data-testid="`gateway-card-${gateway.id}`"
|
||||||
|
>
|
||||||
|
<div class="gateway-card__top">
|
||||||
<div>
|
<div>
|
||||||
<h3>{{ $t("system_status.sections.gateways") }}</h3>
|
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
|
||||||
<p class="section-subtitle">{{ $t("system_status.gateways.description") }}</p>
|
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink class="section-link" to="/superuser/selfserve/edge-agents">
|
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
|
||||||
{{ $t("system_status.gateways.actions.open_fleet") }}
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gateway-summary-grid">
|
<div class="gateway-card__meta">
|
||||||
<article
|
<span>
|
||||||
v-for="card in gatewaySummaryCards"
|
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
|
||||||
:key="card.id"
|
</span>
|
||||||
class="summary-card gateway-summary-card"
|
<span>
|
||||||
:class="card.toneClass"
|
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
|
||||||
:data-testid="`gateway-summary-card-${card.id}`"
|
</span>
|
||||||
>
|
<span v-if="gateway.activeOperationLabel">
|
||||||
<p class="summary-label">{{ card.title }}</p>
|
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
|
||||||
<strong>{{ card.value }}</strong>
|
</span>
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<p v-if="gateway.message" class="gateway-card__message">
|
||||||
v-if="gatewayError"
|
{{ gateway.message }}
|
||||||
class="notification is-warning is-light gateway-notification"
|
</p>
|
||||||
data-testid="gateway-section-error"
|
|
||||||
>
|
<RouterLink :to="gateway.link" class="module-card__link">
|
||||||
{{ $t("system_status.gateways.error") }}
|
{{ $t("system_status.gateways.actions.open_gateway") }}
|
||||||
|
</RouterLink>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="!gatewayLoading && !gatewayError"
|
||||||
|
class="gateway-empty"
|
||||||
|
data-testid="gateway-empty-state"
|
||||||
|
>
|
||||||
|
{{ $t("system_status.gateways.empty") }}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="system-status-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h3>{{ $t("system_status.sections.modules") }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="module-grid">
|
||||||
|
<article
|
||||||
|
v-for="module in modules"
|
||||||
|
:key="module.key"
|
||||||
|
class="module-card"
|
||||||
|
:class="statusClass(module.status)"
|
||||||
|
:data-testid="`module-card-${module.key}`"
|
||||||
|
>
|
||||||
|
<div class="module-card__top">
|
||||||
|
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
|
||||||
|
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
|
||||||
<div
|
{{ moduleReasonText(module) }}
|
||||||
v-if="gatewayLoading && !gatewayCards.length"
|
</p>
|
||||||
class="notification is-light gateway-notification"
|
<div class="module-card__meta">
|
||||||
>
|
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
|
||||||
{{ $t("system_status.gateways.loading") }}
|
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
|
||||||
|
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
|
||||||
|
{{ $t("system_status.actions.open_config") }}
|
||||||
|
</RouterLink>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div v-if="gatewayCards.length" class="gateway-grid">
|
<section class="system-status-section">
|
||||||
<article
|
<div class="section-heading">
|
||||||
v-for="gateway in gatewayCards"
|
<h3>{{ $t("system_status.sections.sessions") }}</h3>
|
||||||
:key="gateway.id"
|
<p>{{ $t("system_status.labels.activity_window", { minutes: sessions.active_window_minutes }) }}</p>
|
||||||
class="gateway-card"
|
</div>
|
||||||
:class="gateway.toneClass"
|
|
||||||
:data-testid="`gateway-card-${gateway.id}`"
|
|
||||||
>
|
|
||||||
<div class="gateway-card__top">
|
|
||||||
<div>
|
|
||||||
<strong class="gateway-card__title">{{ gateway.displayLabel }}</strong>
|
|
||||||
<p class="gateway-card__subtitle">{{ gateway.departmentName }}</p>
|
|
||||||
</div>
|
|
||||||
<span class="status-pill" :class="gateway.toneClass">{{ gateway.statusLabel }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="gateway-card__meta">
|
<div class="table-container sessions-table">
|
||||||
<span>
|
<table class="table is-fullwidth is-hoverable">
|
||||||
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t("system_status.table.active") }}</th>
|
||||||
|
<th>{{ $t("system_status.table.name") }}</th>
|
||||||
|
<th>{{ $t("system_status.table.type") }}</th>
|
||||||
|
<th>{{ $t("system_status.table.device") }}</th>
|
||||||
|
<th>{{ $t("system_status.table.route") }}</th>
|
||||||
|
<th>{{ $t("system_status.table.first_seen") }}</th>
|
||||||
|
<th>{{ $t("system_status.table.last_seen") }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
|
||||||
|
<td>
|
||||||
|
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
|
||||||
|
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
</td>
|
||||||
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
|
<td>
|
||||||
</span>
|
<strong>{{ sessionDisplayName(session) }}</strong>
|
||||||
<span v-if="gateway.activeOperationLabel">
|
<div class="session-context">{{ sessionContextLabel(session) }}</div>
|
||||||
{{ $t("system_status.gateways.labels.active_operation") }}: {{ gateway.activeOperationLabel }}
|
</td>
|
||||||
</span>
|
<td>{{ formatSessionKind(session.session_kind) }}</td>
|
||||||
</div>
|
<td>{{ formatDeviceType(session.device_type) }}</td>
|
||||||
|
<td class="session-route">{{ session.last_route || "--" }}</td>
|
||||||
<p v-if="gateway.message" class="gateway-card__message">
|
<td>{{ formatDate(session.first_seen_at) }}</td>
|
||||||
{{ gateway.message }}
|
<td>{{ formatDate(session.last_seen_at) }}</td>
|
||||||
</p>
|
</tr>
|
||||||
|
<tr v-if="!sessions.recent_sessions?.length">
|
||||||
<RouterLink :to="gateway.link" class="module-card__link">
|
<td colspan="7">{{ $t("system_status.states.no_sessions") }}</td>
|
||||||
{{ $t("system_status.gateways.actions.open_gateway") }}
|
</tr>
|
||||||
</RouterLink>
|
</tbody>
|
||||||
</article>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<div
|
|
||||||
v-else-if="!gatewayLoading && !gatewayError"
|
|
||||||
class="gateway-empty"
|
|
||||||
data-testid="gateway-empty-state"
|
|
||||||
>
|
|
||||||
{{ $t("system_status.gateways.empty") }}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</b-tab-item>
|
|
||||||
|
|
||||||
<b-tab-item value="modules">
|
|
||||||
<template #header>
|
|
||||||
<b-icon pack="fas" icon="puzzle-piece" />
|
|
||||||
<span data-testid="system-status-tab-modules">
|
|
||||||
{{ $t("system_status.sections.modules") }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<section class="system-status-section" data-testid="system-status-panel-modules">
|
|
||||||
<div class="section-heading">
|
|
||||||
<h3>{{ $t("system_status.sections.modules") }}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="module-grid">
|
|
||||||
<article
|
|
||||||
v-for="module in modules"
|
|
||||||
:key="module.key"
|
|
||||||
class="module-card"
|
|
||||||
:class="statusClass(module.status)"
|
|
||||||
:data-testid="`module-card-${module.key}`"
|
|
||||||
>
|
|
||||||
<div class="module-card__top">
|
|
||||||
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
|
|
||||||
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
|
|
||||||
{{ moduleReasonText(module) }}
|
|
||||||
</p>
|
|
||||||
<div class="module-card__meta">
|
|
||||||
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
|
|
||||||
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
|
|
||||||
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
|
|
||||||
{{ $t("system_status.actions.open_config") }}
|
|
||||||
</RouterLink>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</b-tab-item>
|
|
||||||
|
|
||||||
<b-tab-item value="sessions">
|
|
||||||
<template #header>
|
|
||||||
<b-icon pack="fas" icon="users" />
|
|
||||||
<span data-testid="system-status-tab-sessions">
|
|
||||||
{{ $t("system_status.sections.sessions") }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<section class="system-status-section" data-testid="system-status-panel-sessions">
|
|
||||||
<div class="section-heading">
|
|
||||||
<h3>{{ $t("system_status.sections.sessions") }}</h3>
|
|
||||||
<p>{{ $t("system_status.labels.activity_window", { minutes: sessions.active_window_minutes }) }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-container sessions-table">
|
|
||||||
<table class="table is-fullwidth is-hoverable">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{ $t("system_status.table.active") }}</th>
|
|
||||||
<th>{{ $t("system_status.table.name") }}</th>
|
|
||||||
<th>{{ $t("system_status.table.type") }}</th>
|
|
||||||
<th>{{ $t("system_status.table.device") }}</th>
|
|
||||||
<th>{{ $t("system_status.table.route") }}</th>
|
|
||||||
<th>{{ $t("system_status.table.first_seen") }}</th>
|
|
||||||
<th>{{ $t("system_status.table.last_seen") }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
|
|
||||||
<td>
|
|
||||||
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
|
|
||||||
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<strong>{{ sessionDisplayName(session) }}</strong>
|
|
||||||
<div class="session-context">{{ sessionContextLabel(session) }}</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ formatSessionKind(session.session_kind) }}</td>
|
|
||||||
<td>{{ formatDeviceType(session.device_type) }}</td>
|
|
||||||
<td class="session-route">{{ session.last_route || "--" }}</td>
|
|
||||||
<td>{{ formatDate(session.first_seen_at) }}</td>
|
|
||||||
<td>{{ formatDate(session.last_seen_at) }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="!sessions.recent_sessions?.length">
|
|
||||||
<td colspan="7">{{ $t("system_status.states.no_sessions") }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</b-tab-item>
|
|
||||||
</b-tabs>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -989,10 +924,10 @@ function modulePath(key) {
|
|||||||
.module-card,
|
.module-card,
|
||||||
.gateway-card {
|
.gateway-card {
|
||||||
border: 1px solid #d7dde7;
|
border: 1px solid #d7dde7;
|
||||||
border-radius: 8px;
|
border-radius: 18px;
|
||||||
padding: 1rem 1.1rem;
|
padding: 1rem 1.1rem;
|
||||||
background: #ffffff;
|
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.05);
|
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-card--overall {
|
.summary-card--overall {
|
||||||
@@ -1056,23 +991,6 @@ function modulePath(key) {
|
|||||||
color: #0f4c81;
|
color: #0f4c81;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-status-tabs {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-status-tabs :deep(.tab-content) {
|
|
||||||
padding: 1rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-status-tabs :deep(.tabs ul) {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-status-tabs :deep(.tabs a) {
|
|
||||||
min-height: 2.75rem;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card__top,
|
.status-card__top,
|
||||||
.module-card__top {
|
.module-card__top {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1117,7 +1035,6 @@ function modulePath(key) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
justify-content: stretch;
|
|
||||||
column-gap: 0.75rem;
|
column-gap: 0.75rem;
|
||||||
row-gap: 0.35rem;
|
row-gap: 0.35rem;
|
||||||
}
|
}
|
||||||
@@ -1132,7 +1049,6 @@ function modulePath(key) {
|
|||||||
|
|
||||||
.module-card__title {
|
.module-card__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 1 1 12rem;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
@@ -1142,7 +1058,7 @@ function modulePath(key) {
|
|||||||
|
|
||||||
.module-card__top .status-pill {
|
.module-card__top .status-pill {
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
margin-left: 0;
|
align-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gateway-card__title {
|
.gateway-card__title {
|
||||||
@@ -1191,7 +1107,7 @@ function modulePath(key) {
|
|||||||
|
|
||||||
.gateway-empty {
|
.gateway-empty {
|
||||||
border: 1px dashed #cbd5e1;
|
border: 1px dashed #cbd5e1;
|
||||||
border-radius: 8px;
|
border-radius: 18px;
|
||||||
padding: 1rem 1.1rem;
|
padding: 1rem 1.1rem;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
background: rgba(248, 250, 252, 0.8);
|
background: rgba(248, 250, 252, 0.8);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const { t } = useI18n();
|
|||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||||
import SuperuserInvoiceRowActions from "@/components/displays/superuser/tables/SuperuserInvoiceRowActions.vue";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
objects: {
|
objects: {
|
||||||
@@ -66,8 +65,8 @@ const parseClosedAt = (value) => {
|
|||||||
<td>{{ parseClosedAt(object.closed_at) }}</td>
|
<td>{{ parseClosedAt(object.closed_at) }}</td>
|
||||||
<td>{{ parseDate(object.created_at) }}</td>
|
<td>{{ parseDate(object.created_at) }}</td>
|
||||||
<td>{{ parseDate(object.updated_at) }}</td>
|
<td>{{ parseDate(object.updated_at) }}</td>
|
||||||
<td class="is-narrow">
|
<td>
|
||||||
<SuperuserInvoiceRowActions :include-buffer="true">
|
<div class="buttons is-float-right">
|
||||||
<ActionSettingsWheelButton>
|
<ActionSettingsWheelButton>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<ActionSettingsWheelItem
|
<ActionSettingsWheelItem
|
||||||
@@ -77,7 +76,7 @@ const parseClosedAt = (value) => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ActionSettingsWheelButton>
|
</ActionSettingsWheelButton>
|
||||||
</SuperuserInvoiceRowActions>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="objects.length === 0">
|
<tr v-if="objects.length === 0">
|
||||||
|
|||||||
@@ -12,11 +12,6 @@ import UserOtherVaskeabonnement
|
|||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
|
import OrderItemsTable from "@/components/displays/department/pos/order/orderItemsTable.vue";
|
||||||
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
|
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
|
||||||
import {
|
|
||||||
buildMultiMonthInvoiceContext,
|
|
||||||
MULTI_MONTH_INVOICE_ACTION,
|
|
||||||
promptMultiMonthInvoiceWarning,
|
|
||||||
} from "@/services/invoiceMonthSplitWarning.js";
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
orders: {
|
orders: {
|
||||||
@@ -131,34 +126,32 @@ const isAnyOrderSelected = () => {
|
|||||||
return selectedInvoiceCollections.value.length > 0;
|
return selectedInvoiceCollections.value.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSelectedOrders = () => {
|
|
||||||
return props.orders.filter((order) => selectedInvoiceCollections.value.includes(order.invoice_collection_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Invoice collections */
|
/** Invoice collections */
|
||||||
const onInvoiceCollections = async () => {
|
const onInvoiceCollections = async () => {
|
||||||
// Check if any orders are selected
|
// Check if any orders are selected
|
||||||
if (selectedInvoiceCollections.value.length === 0) {
|
if (selectedInvoiceCollections.value.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectedOrders = getSelectedOrders();
|
/** Create the invoice */
|
||||||
const invoiceWarningContext = buildMultiMonthInvoiceContext(selectedOrders, {
|
const onCreateInvoiceDraft = async () => {
|
||||||
getDate: (order) => order?.created_at ?? order?.date,
|
// Create the invoice
|
||||||
getInvoiceCollectionId: (order) => order?.invoice_collection_id,
|
console.log('Create invoice');
|
||||||
});
|
await SessionUser.objects.collectedOrderInvoices.functions.economic.invoice(parseInt(props.collectedOrderInvoice.id)).then((response) => {
|
||||||
const invoiceWarningAction = await promptMultiMonthInvoiceWarning({
|
console.log('Invoice created successfully', response);
|
||||||
context: invoiceWarningContext,
|
Swal.fire({
|
||||||
splitByMonth: SessionUser.objects.collectedOrderInvoices.functions.split_by_month,
|
title: 'Fakturaen er oprettet',
|
||||||
parseErrorMessage: SessionUser.functions.parseErrorMessage,
|
text: 'Fakturaen er oprettet i E-conomic',
|
||||||
});
|
icon: 'success',
|
||||||
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.SPLIT) {
|
showConfirmButton: false,
|
||||||
location.reload();
|
timer: 2000
|
||||||
return;
|
}).then(() => {
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
}).catch((error) => {
|
||||||
|
console.log('Error creating invoice', error);
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (invoiceWarningAction === MULTI_MONTH_INVOICE_ACTION.CANCEL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < selectedInvoiceCollections.value.length; i++) {
|
for (let i = 0; i < selectedInvoiceCollections.value.length; i++) {
|
||||||
const selectedInvoiceCollectionId = selectedInvoiceCollections.value[i];
|
const selectedInvoiceCollectionId = selectedInvoiceCollections.value[i];
|
||||||
// Check if the invoice collection is already booked
|
// Check if the invoice collection is already booked
|
||||||
@@ -217,7 +210,6 @@ const isOrderContentVisible = (order) => {
|
|||||||
class="button is-small"
|
class="button is-small"
|
||||||
@click="onInvoiceCollections()"
|
@click="onInvoiceCollections()"
|
||||||
:disabled="!isAnyOrderSelected()"
|
:disabled="!isAnyOrderSelected()"
|
||||||
data-testid="invoice-order-table-invoice-button"
|
|
||||||
>
|
>
|
||||||
{{ $t('global.invoice_now') }}
|
{{ $t('global.invoice_now') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -282,4 +274,4 @@ const isOrderContentVisible = (order) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.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 ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||||
|
|
||||||
@@ -16,14 +14,9 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
userScopedUserId: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { loadList } = usePaginatedListInstance();
|
const { loadList } = usePaginatedListInstance();
|
||||||
const expandedSubusers = ref(new Set());
|
|
||||||
|
|
||||||
const canEditPermissions = () =>
|
const canEditPermissions = () =>
|
||||||
props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canManageSubusers("edit");
|
props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canManageSubusers("edit");
|
||||||
@@ -32,70 +25,8 @@ const canDisableAccess = () =>
|
|||||||
const canResendInvite = (subuser) =>
|
const canResendInvite = (subuser) =>
|
||||||
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canManageSubusers("edit"))
|
(props.showCustomer ? SessionUser.canAccessSuperUser() : SessionUser.canManageSubusers("edit"))
|
||||||
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
|
&& Boolean(subuser?.can_resend_invite ?? subuser?.setup_required);
|
||||||
const hasActionsForContext = (subuser) =>
|
const hasRowActions = (subuser) =>
|
||||||
(canEditPermissions() && subuser?.grant_id) || canResendInvite(subuser) || (canDisableAccess() && subuser?.grant_id);
|
(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) => {
|
const formatDateTime = (dateString) => {
|
||||||
if (!dateString) {
|
if (!dateString) {
|
||||||
@@ -140,11 +71,7 @@ const formatCustomer = (subuser) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const permissionSummary = (subuser) =>
|
const permissionSummary = (subuser) =>
|
||||||
SessionUser.objects.subusers.functions.permissionSummary(
|
SessionUser.objects.subusers.functions.permissionSummary(subuser?.grant_permissions || []);
|
||||||
primaryGrant(subuser)?.grant_permissions || [],
|
|
||||||
primaryGrant(subuser)?.permission_template_key,
|
|
||||||
primaryGrant(subuser)?.permission_groups || []
|
|
||||||
);
|
|
||||||
|
|
||||||
const statusLabel = (subuser) => {
|
const statusLabel = (subuser) => {
|
||||||
switch (subuser?.access_state) {
|
switch (subuser?.access_state) {
|
||||||
@@ -187,20 +114,7 @@ const onEditPermissions = async (subuser) => {
|
|||||||
permissions: subuser.grant_permissions || [],
|
permissions: subuser.grant_permissions || [],
|
||||||
name: subuser.name || formatPhone(subuser) || `Chauffør ${subuser.id}`,
|
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);
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,13 +134,9 @@ const onEditNote = async (subuser) => {
|
|||||||
cancelButtonText: "Annuller",
|
cancelButtonText: "Annuller",
|
||||||
preConfirm: async (value) => {
|
preConfirm: async (value) => {
|
||||||
try {
|
try {
|
||||||
if (isUserScoped()) {
|
await SessionUser.objects.subuser_grants.set.note(subuser.grant_id, value || "");
|
||||||
await patchScopedGrant(subuser, { note: value || "" });
|
|
||||||
} else {
|
|
||||||
await SessionUser.objects.subuser_grants.set.note(subuser.grant_id, value || "");
|
|
||||||
}
|
|
||||||
return value || "";
|
return value || "";
|
||||||
} catch (_error) {
|
} catch (error) {
|
||||||
Swal.showValidationMessage("Kunne ikke gemme noten.");
|
Swal.showValidationMessage("Kunne ikke gemme noten.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -258,11 +168,7 @@ const onToggleEnabled = async (subuser, enabled) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isUserScoped()) {
|
await SessionUser.objects.subuser_grants.set.enabled(subuser.grant_id, enabled);
|
||||||
await patchScopedGrant(subuser, { enabled });
|
|
||||||
} else {
|
|
||||||
await SessionUser.objects.subuser_grants.set.enabled(subuser.grant_id, enabled);
|
|
||||||
}
|
|
||||||
await refreshList();
|
await refreshList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await Swal.fire({
|
await Swal.fire({
|
||||||
@@ -275,10 +181,7 @@ const onToggleEnabled = async (subuser, enabled) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onResendInvite = async (subuser) => {
|
const onResendInvite = async (subuser) => {
|
||||||
await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList, {
|
await SessionUser.objects.subusers.functions.resendInvite(subuser, refreshList, { superuser: props.showCustomer });
|
||||||
superuser: props.showCustomer || isUserScoped(),
|
|
||||||
userId: props.userScopedUserId,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -304,174 +207,90 @@ const onResendInvite = async (subuser) => {
|
|||||||
<td :colspan="showCustomer ? 10 : 9" class="has-text-centered has-text-grey py-6">Ingen chauffører fundet.</td>
|
<td :colspan="showCustomer ? 10 : 9" class="has-text-centered has-text-grey py-6">Ingen chauffører fundet.</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
<template v-for="subuser in props.objects" :key="subuser.id">
|
<tr v-for="subuser in props.objects" :key="`${subuser.id}-${subuser.grant_id || 'none'}`">
|
||||||
<tr>
|
<td>{{ subuser.id }}</td>
|
||||||
<td>{{ subuser.id }}</td>
|
|
||||||
|
|
||||||
<td v-if="showCustomer">
|
<td v-if="showCustomer">
|
||||||
<div v-if="hasMultipleGrants(subuser)">
|
<div class="has-text-weight-semibold">{{ formatCustomer(subuser) }}</div>
|
||||||
<button
|
<div class="is-size-7 has-text-grey">Grant #{{ subuser.grant_id }}</div>
|
||||||
type="button"
|
</td>
|
||||||
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>
|
<td>
|
||||||
<div class="has-text-weight-semibold">{{ subuser.name || "-" }}</div>
|
<div class="has-text-weight-semibold">{{ subuser.name || "-" }}</div>
|
||||||
<div class="is-size-7 has-text-grey" :data-testid="`subuser-username-${subuser.id}`">
|
<div class="is-size-7 has-text-grey" :data-testid="`subuser-username-${subuser.id}`">
|
||||||
{{ formatUsername(subuser) }}
|
{{ formatUsername(subuser) }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div :data-testid="`subuser-email-${subuser.id}`">{{ formatEmail(subuser) }}</div>
|
<div :data-testid="`subuser-email-${subuser.id}`">{{ formatEmail(subuser) }}</div>
|
||||||
<div class="is-size-7 has-text-grey" :data-testid="`subuser-phone-${subuser.id}`">
|
<div class="is-size-7 has-text-grey" :data-testid="`subuser-phone-${subuser.id}`">
|
||||||
{{ formatPhone(subuser) }}
|
{{ formatPhone(subuser) }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<span class="tag" :class="statusClass(subuser)" :data-testid="`subuser-status-${subuser.id}`">
|
<span class="tag" :class="statusClass(subuser)" :data-testid="`subuser-status-${subuser.id}`">
|
||||||
{{ statusLabel(subuser) }}
|
{{ statusLabel(subuser) }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="!hasMultipleGrants(subuser)" class="is-size-7 has-text-grey mt-2">
|
<div class="is-size-7 has-text-grey mt-2">
|
||||||
<span v-if="primaryGrant(subuser).grant_enabled">Grant aktiv</span>
|
<span v-if="subuser.grant_enabled">Grant aktiv</span>
|
||||||
<span v-else>Grant inaktiv</span>
|
<span v-else>Grant inaktiv</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="is-size-7 has-text-grey mt-2">
|
</td>
|
||||||
{{ rowGrants(subuser).filter((grant) => grant.grant_enabled).length }} aktive grants
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div class="permission-summary">{{ permissionSummary(subuser) }}</div>
|
<div class="permission-summary">{{ permissionSummary(subuser) }}</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div>{{ primaryGrant(subuser).grant_note || "-" }}</div>
|
<div>{{ subuser.grant_note || "-" }}</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td>{{ formatDateTime(subuser.created_at) }}</td>
|
<td>{{ formatDateTime(subuser.created_at) }}</td>
|
||||||
<td>{{ formatDateTime(subuser.updated_at) }}</td>
|
<td>{{ formatDateTime(subuser.updated_at) }}</td>
|
||||||
|
|
||||||
<td>
|
<td>
|
||||||
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`">
|
<div class="buttons is-justify-content-flex-end action-buttons" :data-testid="`subuser-actions-${subuser.id}`">
|
||||||
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
|
<ActionSettingsWheelButton v-if="hasRowActions(subuser)">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<ActionSettingsWheelItem
|
<ActionSettingsWheelItem
|
||||||
v-if="canEditPermissions() && subuser.grant_id"
|
v-if="canEditPermissions() && subuser.grant_id"
|
||||||
icon="fas fa-pen"
|
icon="fas fa-pen"
|
||||||
label="Redigér note"
|
label="Redigér note"
|
||||||
:click-action="() => onEditNote(subuser)"
|
:click-action="() => onEditNote(subuser)"
|
||||||
:test-id="`subuser-note-${subuser.id}`"
|
:test-id="`subuser-note-${subuser.id}`"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ActionSettingsWheelItem
|
<ActionSettingsWheelItem
|
||||||
v-if="canEditPermissions() && subuser.grant_id"
|
v-if="canEditPermissions() && subuser.grant_id"
|
||||||
icon="fas fa-user-shield"
|
icon="fas fa-user-shield"
|
||||||
label="Tilladelser"
|
label="Tilladelser"
|
||||||
:click-action="() => onEditPermissions(subuser)"
|
:click-action="() => onEditPermissions(subuser)"
|
||||||
:test-id="`subuser-permissions-${subuser.id}`"
|
:test-id="`subuser-permissions-${subuser.id}`"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ActionSettingsWheelItem
|
<ActionSettingsWheelItem
|
||||||
v-if="canResendInvite(subuser)"
|
v-if="canResendInvite(subuser)"
|
||||||
icon="fas fa-paper-plane"
|
icon="fas fa-paper-plane"
|
||||||
label="Gensend"
|
label="Gensend"
|
||||||
:click-action="() => onResendInvite(subuser)"
|
:click-action="() => onResendInvite(subuser)"
|
||||||
:test-id="`subuser-resend-${subuser.id}`"
|
:test-id="`subuser-resend-${subuser.id}`"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ActionSettingsWheelItem
|
<ActionSettingsWheelItem
|
||||||
v-if="canDisableAccess() && subuser.grant_id"
|
v-if="canDisableAccess() && subuser.grant_id"
|
||||||
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
|
:icon="subuser.grant_enabled ? 'fas fa-ban' : 'fas fa-check'"
|
||||||
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
|
:label="subuser.grant_enabled ? 'Deaktivér' : 'Aktivér'"
|
||||||
:template="subuser.grant_enabled ? 'danger' : 'success'"
|
:template="subuser.grant_enabled ? 'danger' : 'success'"
|
||||||
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
|
:click-action="() => onToggleEnabled(subuser, !subuser.grant_enabled)"
|
||||||
:test-id="`subuser-toggle-${subuser.id}`"
|
:test-id="`subuser-toggle-${subuser.id}`"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ActionSettingsWheelButton>
|
</ActionSettingsWheelButton>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -487,31 +306,4 @@ const onResendInvite = async (subuser) => {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
<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,10 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from 'vue-i18n';
|
||||||
import { BCheckbox } from "buefy";
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
||||||
|
import {getCustomerName} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
|
import { ref } from 'vue';
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
objects: {
|
objects: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -28,141 +29,134 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["update:selected"]);
|
const emit = defineEmits(['update:selected']);
|
||||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||||
const { loadList } = usePaginatedListInstance();
|
const { loadList } = usePaginatedListInstance();
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||||
import SuperuserInvoiceRowActions from "@/components/displays/superuser/tables/SuperuserInvoiceRowActions.vue";
|
|
||||||
|
|
||||||
const redirect = (path) => {
|
const redirect = (path) => {
|
||||||
// Open the url in a new tab
|
// Open the url in a new tab
|
||||||
window.open(path, "_blank");
|
window.open(path, '_blank');
|
||||||
};
|
}
|
||||||
|
|
||||||
const parseTransactionCount = (value) => {
|
const parseTransactionCount = (value) => {
|
||||||
if (typeof value === "number") {
|
if (typeof value === 'number') {
|
||||||
return value === 0 ? t("global.no_data") : value;
|
return value === 0 ? t('global.no_data') : value;
|
||||||
} else if (typeof value === "string") {
|
} else if (typeof value === 'string') {
|
||||||
return parseInt(value);
|
return parseInt(value);
|
||||||
} else if (typeof value === "object") {
|
} else if (typeof value === 'object') {
|
||||||
return value.length;
|
return value.length;
|
||||||
} else if (value === null) {
|
} else if (value === null) {
|
||||||
// Handle other types if necessary
|
// Handle other types if necessary
|
||||||
return t("global.no_data");
|
return t('global.no_data');
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const onSelect = (id) => {
|
const onSelect = (id) => {
|
||||||
emit("update:selected", id);
|
emit('update:selected', id);
|
||||||
props.onSelected(id);
|
props.onSelected(id);
|
||||||
};
|
}
|
||||||
|
|
||||||
const onSelectorChecked = (id, checked) => {
|
|
||||||
if (checked) {
|
|
||||||
onSelect(id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<table class="table is-fullwidth is-hoverable is-striped">
|
<table class="table is-fullwidth is-hoverable is-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ SessionUser.objects.collectedOrderInvoices.columns.id.label }}</th>
|
<th>{{ SessionUser.objects.collectedOrderInvoices.columns.id.label }}</th>
|
||||||
<th>{{ SessionUser.objects.global.language.customer_name }}</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.customer_number.label }}</th>
|
||||||
<th class="is-narrow">{{ SessionUser.objects.collectedOrderInvoices.columns.processor.label }}</th>
|
<th class="is-narrow">{{ SessionUser.objects.collectedOrderInvoices.columns.processor.label }}</th>
|
||||||
<th class="is-narrow">{{ SessionUser.objects.orders.meta.title }}</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>{{ 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>
|
<th><!-- Actions / Selector --></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="object in props.objects" :key="object.id">
|
<template v-for="object in props.objects" :key="object.id">
|
||||||
<tr v-if="object.objects > 0 || props.showEmpty">
|
<tr v-if="object.objects > 0 || props.showEmpty">
|
||||||
<td>{{ object.id }}</td>
|
<td>{{ object.id }}</td>
|
||||||
<!-- Customer name -->
|
<!-- Customer name -->
|
||||||
<EditableTableColumn
|
<EditableTableColumn
|
||||||
:object="object"
|
:object="object"
|
||||||
:loadList="loadList"
|
:loadList="loadList"
|
||||||
column="customer_name"
|
column="customer_name"
|
||||||
:virtual-column="true"
|
:virtual-column="true"
|
||||||
:parse-function="(value) => value.customer_name"
|
:parse-function="(value) => value.customer_name"
|
||||||
/>
|
/>
|
||||||
<!-- Customer number -->
|
<!-- Customer number -->
|
||||||
<EditableTableColumn :object="object" :loadList="loadList" column="customer_number" />
|
<EditableTableColumn
|
||||||
<!-- Processor -->
|
:object="object"
|
||||||
<EditableTableColumn
|
:loadList="loadList"
|
||||||
|
column="customer_number"
|
||||||
|
/>
|
||||||
|
<!-- Processor -->
|
||||||
|
<EditableTableColumn
|
||||||
:object="object"
|
:object="object"
|
||||||
:loadList="loadList"
|
:loadList="loadList"
|
||||||
column="processor"
|
column="processor"
|
||||||
:parse-function="
|
:parse-function="(value) => value === null ? t('global.no_data') : value === 1 ? 'E-conomic' : t('global.other')"
|
||||||
(value) => (value === null ? t('global.no_data') : value === 1 ? 'E-conomic' : t('global.other'))
|
/>
|
||||||
"
|
<!-- Transactions -->
|
||||||
/>
|
<EditableTableColumn
|
||||||
<!-- Transactions -->
|
|
||||||
<EditableTableColumn
|
|
||||||
:object="object"
|
:object="object"
|
||||||
:loadList="loadList"
|
:loadList="loadList"
|
||||||
column="orders"
|
column="orders"
|
||||||
:parse-function="(value) => parseTransactionCount(value)"
|
:parse-function="(value) => parseTransactionCount(value)"
|
||||||
/>
|
/>
|
||||||
<!-- Closed at -->
|
<!-- Closed at -->
|
||||||
<EditableTableColumn
|
<EditableTableColumn
|
||||||
:object="object"
|
:object="object"
|
||||||
:loadList="loadList"
|
:loadList="loadList"
|
||||||
column="closed_at"
|
column="closed_at"
|
||||||
:parse-function="(value) => (value === null ? t('global.open') : t('global.closed') + ' ' + value)"
|
:parse-function="(value) => value === null ? t('global.open') : t('global.closed') + ' ' + value"
|
||||||
/>
|
/>
|
||||||
<!-- Created at -->
|
<!-- Created at -->
|
||||||
<EditableTableColumn :object="object" :loadList="loadList" column="created_at" />
|
<EditableTableColumn
|
||||||
<!-- Total net amount -->
|
:object="object"
|
||||||
<td v-if="props.showTotal">{{ SessionUser.functions.currency.toLocal(object.total_net_amount) }}</td>
|
:loadList="loadList"
|
||||||
<!-- Actions -->
|
column="created_at"
|
||||||
<td v-if="!props.isSelector" class="is-narrow">
|
/>
|
||||||
<SuperuserInvoiceRowActions :include-buffer="true">
|
<!-- Total net amount -->
|
||||||
<ActionSettingsWheelButton>
|
<td v-if="props.showTotal">{{ SessionUser.functions.currency.toLocal(object.total_net_amount) }}</td>
|
||||||
<template #actions>
|
<!-- Actions -->
|
||||||
<ActionSettingsWheelItem
|
<td v-if="!props.isSelector">
|
||||||
icon="fas fa-eye"
|
<ActionSettingsWheelButton>
|
||||||
@click="redirect('/superuser/invoices/' + object.id)"
|
<template #actions>
|
||||||
:label="t('common.show')"
|
<ActionSettingsWheelItem
|
||||||
/>
|
icon="fas fa-eye"
|
||||||
</template>
|
@click="redirect('/superuser/invoices/' + object.id)"
|
||||||
</ActionSettingsWheelButton>
|
:label="t('common.show')"
|
||||||
</SuperuserInvoiceRowActions>
|
/>
|
||||||
</td>
|
</template>
|
||||||
<!-- Selector -->
|
</ActionSettingsWheelButton>
|
||||||
<td v-if="props.isSelector" class="is-narrow">
|
</td>
|
||||||
<BCheckbox
|
<!-- Selector -->
|
||||||
:model-value="false"
|
<td v-if="props.isSelector">
|
||||||
size="is-small"
|
<button class="button is-small is-dark" @click="onSelect(object.id)">
|
||||||
:aria-label="`${$t('common.select')} #${object.id}`"
|
{{ $t('common.select') }}
|
||||||
:data-testid="`collected-invoice-selector-${object.id}`"
|
</button>
|
||||||
@update:model-value="(checked) => onSelectorChecked(object.id, checked)"
|
</td>
|
||||||
/>
|
</tr>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
</template>
|
||||||
<!-- If the list is empty -->
|
<!-- If the list is empty -->
|
||||||
<tr v-if="objects.length === 0">
|
<tr v-if="objects.length === 0">
|
||||||
<td colspan="9">
|
<td colspan="9">
|
||||||
{{ $t("global.no_data") }}
|
{{ $t('global.no_data') }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<!-- Show a count of the total number of objects -->
|
<!-- Show a count of the total number of objects -->
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9">
|
<td colspan="9">{{ $t('tables.showing') }} {{ objects.length - objects.filter(object => object.objects === 0).length }} / {{ objects.length }} {{ SessionUser.objects.collectedOrderInvoices.meta.labels.multiple }}</td>
|
||||||
{{ $t("tables.showing") }} {{ objects.length - objects.filter((object) => object.objects === 0).length }} /
|
|
||||||
{{ objects.length }} {{ SessionUser.objects.collectedOrderInvoices.meta.labels.multiple }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -1,136 +1,21 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import Swal from "sweetalert2";
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
defineProps(['objects']);
|
defineProps(['objects']);
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
|
||||||
const { loadList } = usePaginatedListInstance();
|
const { loadList } = usePaginatedListInstance();
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||||
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
|
|
||||||
|
|
||||||
const redirect = (path) => {
|
const redirect = (path) => {
|
||||||
window.location = 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -181,46 +66,6 @@ onMounted(loadLimitedBackofficeTemplates);
|
|||||||
icon="fas fa-clone"
|
icon="fas fa-clone"
|
||||||
:click-action="() => SessionUser.objects.roles.functions.cloneObject(object.id).then(() => loadList())"
|
:click-action="() => SessionUser.objects.roles.functions.cloneObject(object.id).then(() => loadList())"
|
||||||
></ActionSettingsWheelItem>
|
></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>
|
</template>
|
||||||
</ActionSettingsWheelButton>
|
</ActionSettingsWheelButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,4 +85,4 @@ onMounted(loadLimitedBackofficeTemplates);
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -2,14 +2,6 @@
|
|||||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||||
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
|
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
|
||||||
import {
|
|
||||||
getLimitedBackofficeDepartments,
|
|
||||||
getLimitedBackofficeRoles,
|
|
||||||
limitedBackofficeErrorMessage,
|
|
||||||
migrateLimitedBackofficeEmployee,
|
|
||||||
unwrapLimitedBackofficeResponse,
|
|
||||||
} from "@/services/limitedBackoffice.js";
|
|
||||||
import Swal from "sweetalert2";
|
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
objects: {
|
objects: {
|
||||||
@@ -18,165 +10,51 @@ defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["migrated"]);
|
|
||||||
|
|
||||||
const editUser = (user) => {
|
const editUser = (user) => {
|
||||||
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id, {
|
showEditUserForm(user.id, user.customer_number, user.display_name, user.group_id);
|
||||||
limitedBackofficeManaged: Boolean(user.limited_backoffice_managed),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const escapeHtml = (value) =>
|
|
||||||
String(value ?? "")
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """)
|
|
||||||
.replaceAll("'", "'");
|
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="table-container">
|
<table class="table is-fullwidth" data-testid="superuser-users-table">
|
||||||
<table class="table is-fullwidth is-striped is-hoverable" data-testid="superuser-users-table">
|
<thead>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th>{{ $t("objects.columns.id") }}</th>
|
||||||
<th>{{ $t("objects.columns.id") }}</th>
|
<th>{{ $t("tables.users.name") }}</th>
|
||||||
<th>{{ $t("tables.users.name") }}</th>
|
<th>{{ $t("tables.users.role") }}</th>
|
||||||
<th>{{ $t("tables.users.role") }}</th>
|
<th class="has-text-right">{{ $t("tables.actions") }}</th>
|
||||||
<th class="has-text-right">{{ $t("tables.actions") }}</th>
|
</tr>
|
||||||
</tr>
|
</thead>
|
||||||
</thead>
|
<tbody>
|
||||||
<tbody>
|
<tr v-for="user in objects" :key="user.id" :data-testid="`superuser-users-row-${user.id}`">
|
||||||
<tr v-for="user in objects" :key="user.id" :data-testid="`superuser-users-row-${user.id}`">
|
<td>{{ user.id }}</td>
|
||||||
<td>{{ user.id }}</td>
|
<td>{{ user.display_name }}</td>
|
||||||
<td>{{ user.display_name }}</td>
|
<td>{{ user.group_id }}</td>
|
||||||
<td>{{ user.group_id }}</td>
|
<td>
|
||||||
<td class="has-text-right users-table__actions-cell">
|
<div class="buttons is-float-right">
|
||||||
<div class="buttons is-justify-content-flex-end">
|
<!-- Settings wheel -->
|
||||||
<!-- Settings wheel -->
|
<ActionSettingsWheelButton
|
||||||
<ActionSettingsWheelButton
|
:customer_number="user.customer_number"
|
||||||
:customer_number="user.customer_number"
|
:user_id="user.id"
|
||||||
:user_id="user.id"
|
:data-testid="`superuser-user-actions-${user.id}`"
|
||||||
:data-testid="`superuser-user-actions-${user.id}`"
|
>
|
||||||
>
|
<template #actions>
|
||||||
<template #actions>
|
<ActionSettingsWheelItem
|
||||||
<ActionSettingsWheelItem
|
:click-action="() => editUser(user)"
|
||||||
:click-action="() => editUser(user)"
|
icon="fas fa-user-edit"
|
||||||
icon="fas fa-user-edit"
|
:label="$t('global.edit')"
|
||||||
:label="$t('global.edit')"
|
:test-id="`superuser-user-edit-${user.id}`"
|
||||||
:test-id="`superuser-user-edit-${user.id}`"
|
/>
|
||||||
/>
|
</template>
|
||||||
<ActionSettingsWheelItem
|
</ActionSettingsWheelButton>
|
||||||
v-if="canMigrateToLimitedBackoffice(user)"
|
</div>
|
||||||
:click-action="() => migrateUser(user)"
|
</td>
|
||||||
icon="fas fa-random"
|
</tr>
|
||||||
:label="$t('superuser.pages.employees.migrate_to_limited_backoffice')"
|
<tr v-if="objects.length === 0">
|
||||||
:test-id="`superuser-user-migrate-limited-${user.id}`"
|
<td colspan="4">{{ $t("global.no_data") }}</td>
|
||||||
/>
|
</tr>
|
||||||
</template>
|
</tbody>
|
||||||
</ActionSettingsWheelButton>
|
</table>
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="objects.length === 0">
|
|
||||||
<td colspan="4">{{ $t("global.no_data") }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
.users-table__actions-cell {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { showDownloadWashCertificate } from "@/components/shop/DownloadWashCerti
|
|||||||
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
|
||||||
import Swal from 'sweetalert2';
|
import Swal from 'sweetalert2';
|
||||||
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
|
||||||
import { todayLocalDateOnly, yesterdayLocalDateOnly } from "@/services/dateOnly.js";
|
|
||||||
const redirectBookingObjectPage = (objectId) => {
|
const redirectBookingObjectPage = (objectId) => {
|
||||||
// Send the user to the object page
|
// Send the user to the object page
|
||||||
window.location.href = `/user/bookings/${objectId}`;
|
window.location.href = `/user/bookings/${objectId}`;
|
||||||
@@ -238,8 +237,7 @@ window.addEventListener('resize', () => {
|
|||||||
* To organize the bookings, we want to show the bookings that are created today first, then yesterday, then all other days
|
* To organize the bookings, we want to show the bookings that are created today first, then yesterday, then all other days
|
||||||
* @type {string}
|
* @type {string}
|
||||||
*/
|
*/
|
||||||
const currentDate = todayLocalDateOnly();
|
const currentDate = new Date().toISOString().split("T")[0];
|
||||||
const previousDate = yesterdayLocalDateOnly();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sort the bookings by date
|
* Sort the bookings by date
|
||||||
@@ -364,7 +362,7 @@ const canUserEditObject = (object) => {
|
|||||||
</span>
|
</span>
|
||||||
<span class="has-text-grey">
|
<span class="has-text-grey">
|
||||||
<!-- Human readable date (Today, Yesterday, etc.) -->
|
<!-- Human readable date (Today, Yesterday, etc.) -->
|
||||||
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === previousDate ? $t('tables.bookings.yesterday') : object.date }}
|
{{ object.date === currentDate ? $t('tables.bookings.today') : object.date === new Date(new Date().setDate(new Date().getDate() - 1)).toISOString().split("T")[0] ? $t('tables.bookings.yesterday') : object.date }}
|
||||||
<!-- Number of bookings on the date -->
|
<!-- Number of bookings on the date -->
|
||||||
({{ $t('tables.bookings.bookings_count', { count: objects.filter((booking) => booking.date === object.date).length }) }})
|
({{ $t('tables.bookings.bookings_count', { count: objects.filter((booking) => booking.date === object.date).length }) }})
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,145 +1,42 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { addVehicleUser } from "@/components/session/user/UserVehicleRequest.vue";
|
import { addVehicleUser } from "@/components/session/user/UserVehicleRequest.vue";
|
||||||
import { clearErrors } from "@/components/request/HandleGlobalError.vue";
|
import { clearErrors } from "@/components/request/HandleGlobalError.vue";
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { ref } from "vue";
|
||||||
import ShowErrorField from "@/components/global/ShowErrorField.vue";
|
import ShowErrorField from "@/components/global/ShowErrorField.vue";
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const reg = ref("");
|
const reg = ref("");
|
||||||
const type = ref("");
|
const type = ref("");
|
||||||
const reference = ref("");
|
const notes = 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<form class="user-add-vehicle-form" @submit.prevent="submitVehicle">
|
<form @submit.prevent="clearErrors(); addVehicleUser(reg, type, notes)">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="user-add-vehicle-registration">{{ t('user_vehicles.license_plate_label') }}</label>
|
<label class="label">{{ t('user_vehicles.license_plate_label') }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input
|
<input class="input" type="text" placeholder="Reg" v-model="reg">
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="user-add-vehicle-type">{{ t('user_vehicles.type_label') }}</label>
|
<label class="label">{{ 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">
|
<div class="control">
|
||||||
<input
|
<input class="input" type="text" placeholder="Type" v-model="type">
|
||||||
id="user-add-vehicle-reference"
|
</div>
|
||||||
v-model="reference"
|
</div>
|
||||||
class="input"
|
<div class="field">
|
||||||
type="text"
|
<label class="label">{{ t('user_vehicles.notes_label') }}</label>
|
||||||
autocomplete="off"
|
<div class="control">
|
||||||
:placeholder="t('vehicles.add_modal.reference_placeholder')"
|
<input class="input" type="text" placeholder="Notes" v-model="notes">
|
||||||
:disabled="isSubmitting"
|
|
||||||
data-testid="user-add-vehicle-reference"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShowErrorField error="addVehicleUser" />
|
<ShowErrorField error="addVehicleUser" />
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button
|
<button class="button is-dark">{{ t('user_vehicles.add_vehicle') }}</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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -147,4 +44,4 @@ onMounted(loadVehicleTypes);
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
@@ -17,7 +17,7 @@ const props = defineProps({
|
|||||||
show_add_vehicle: {
|
show_add_vehicle: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: false,
|
required: false,
|
||||||
default: null,
|
default: true,
|
||||||
},
|
},
|
||||||
compact: {
|
compact: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -34,16 +34,6 @@ const props = defineProps({
|
|||||||
required: false,
|
required: false,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
userScopedUserId: {
|
|
||||||
type: [Number, String],
|
|
||||||
required: false,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
superuserPage: {
|
|
||||||
type: Boolean,
|
|
||||||
required: false,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
@@ -55,19 +45,9 @@ const reload = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canViewVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "list");
|
const redirectUserVehiclePage = (vehicleId) => {
|
||||||
const canEditVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "edit");
|
// Send the user to the vehicle page
|
||||||
const canDeleteVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "delete");
|
window.location.href = `/user/vehicles/${vehicleId}`;
|
||||||
const canShowVehicleRowActions = () => canViewVehicle() || canDeleteVehicle();
|
|
||||||
const isUserScoped = () => Boolean(props.userScopedUserId);
|
|
||||||
|
|
||||||
const redirectVehiclePage = (vehicle) => {
|
|
||||||
if (props.superuserPage) {
|
|
||||||
window.location.href = `/superuser/vehicles/${encodeURIComponent(vehicle.reg)}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.href = `/user/vehicles/${encodeURIComponent(vehicle.id)}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get the departments (If the departments are not already loaded)
|
// Get the departments (If the departments are not already loaded)
|
||||||
@@ -75,6 +55,14 @@ if (departments.value.length === 0) {
|
|||||||
getDepartments();
|
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 vehicleAddons = ref(null);
|
||||||
const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
|
const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
|
||||||
if (forceReload === true) {
|
if (forceReload === true) {
|
||||||
@@ -86,9 +74,11 @@ const getVehicleAvailableAddons = (vehicleId, forceReload = false) => {
|
|||||||
SessionUser.request("/vehicles/addons/available", "GET", {
|
SessionUser.request("/vehicles/addons/available", "GET", {
|
||||||
id: vehicleId,
|
id: vehicleId,
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
|
console.log("Vehicle addons: ", response.data.data);
|
||||||
vehicleAddons.value = response.data.data;
|
vehicleAddons.value = response.data.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
console.log("Vehicle addons: " + vehicleAddons.value);
|
||||||
return vehicleAddons.value;
|
return vehicleAddons.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,28 +117,6 @@ const getProductOptionsLabel = (vehicle) => {
|
|||||||
const available = Number(vehicle?.addons?.available ?? 0);
|
const available = Number(vehicle?.addons?.available ?? 0);
|
||||||
return `${SessionUser.objects.product_options.meta.title} ( ${enabled} / ${available} )`;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -177,16 +145,14 @@ const deleteVehicle = (id) => {
|
|||||||
<EditableTableColumn
|
<EditableTableColumn
|
||||||
:object="object"
|
:object="object"
|
||||||
:loadList="reload"
|
:loadList="reload"
|
||||||
:editFunction="editVehicleField"
|
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||||
:permission-check-function="canEditVehicle"
|
|
||||||
column="reg"
|
column="reg"
|
||||||
/>
|
/>
|
||||||
<!-- Type -->
|
<!-- Type -->
|
||||||
<EditableTableColumn
|
<EditableTableColumn
|
||||||
:object="object"
|
:object="object"
|
||||||
:loadList="reload"
|
:loadList="reload"
|
||||||
:editFunction="editVehicleField"
|
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||||
:permission-check-function="canEditVehicle"
|
|
||||||
column="type"
|
column="type"
|
||||||
:parse-function="
|
:parse-function="
|
||||||
(value) => {
|
(value) => {
|
||||||
@@ -198,8 +164,7 @@ const deleteVehicle = (id) => {
|
|||||||
<EditableTableColumn
|
<EditableTableColumn
|
||||||
:object="object"
|
:object="object"
|
||||||
:loadList="reload"
|
:loadList="reload"
|
||||||
:editFunction="editVehicleField"
|
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||||
:permission-check-function="canEditVehicle"
|
|
||||||
column="wash_subscription"
|
column="wash_subscription"
|
||||||
:parse-function="
|
:parse-function="
|
||||||
(value) => {
|
(value) => {
|
||||||
@@ -209,7 +174,7 @@ const deleteVehicle = (id) => {
|
|||||||
/>
|
/>
|
||||||
<!-- Product Options, if the wash subscription is set to true -->
|
<!-- Product Options, if the wash subscription is set to true -->
|
||||||
<td v-if="!props.compact">
|
<td v-if="!props.compact">
|
||||||
<template v-if="object.wash_subscription && canEditVehicle()">
|
<template v-if="object.wash_subscription">
|
||||||
<!-- Enabled subscription -->
|
<!-- Enabled subscription -->
|
||||||
<ActionSettingsWheelButton
|
<ActionSettingsWheelButton
|
||||||
:label="getProductOptionsLabel(object)"
|
:label="getProductOptionsLabel(object)"
|
||||||
@@ -252,15 +217,14 @@ const deleteVehicle = (id) => {
|
|||||||
v-if="!props.compact"
|
v-if="!props.compact"
|
||||||
:object="object"
|
:object="object"
|
||||||
:loadList="reload"
|
:loadList="reload"
|
||||||
:editFunction="editVehicleField"
|
:editFunction="SessionUser.objects.vehicles.showEditObjectFieldForm"
|
||||||
:permission-check-function="canEditVehicle"
|
|
||||||
column="reference"
|
column="reference"
|
||||||
/>
|
/>
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<td v-if="!props.compact">
|
<td>
|
||||||
<!-- Actions stay grouped behind the wheel menu. -->
|
<!-- Actions stay grouped behind the wheel menu. -->
|
||||||
<ActionSettingsWheelButton
|
<ActionSettingsWheelButton
|
||||||
v-if="canShowVehicleRowActions()"
|
v-if="!props.compact"
|
||||||
:user_id="object.user_id"
|
:user_id="object.user_id"
|
||||||
:reg_1="object.reg"
|
:reg_1="object.reg"
|
||||||
:displayActionsDirectly="false"
|
:displayActionsDirectly="false"
|
||||||
@@ -268,18 +232,18 @@ const deleteVehicle = (id) => {
|
|||||||
<template #actions>
|
<template #actions>
|
||||||
<!-- View (Redirect to the vehicle page) -->
|
<!-- View (Redirect to the vehicle page) -->
|
||||||
<ActionSettingsWheelItem
|
<ActionSettingsWheelItem
|
||||||
v-if="canViewVehicle()"
|
|
||||||
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
|
:label="$t('global.manage') + ' ' + $t('objects.vehicles.single')"
|
||||||
icon="fas fa-eye"
|
icon="fas fa-eye"
|
||||||
:click-action="() => redirectVehiclePage(object)"
|
:click-action="() => redirectUserVehiclePage(object.id)"
|
||||||
/>
|
/>
|
||||||
<!-- Delete -->
|
<!-- Delete -->
|
||||||
<ActionSettingsWheelItem
|
<ActionSettingsWheelItem
|
||||||
v-if="canDeleteVehicle()"
|
|
||||||
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
|
:label="$t('global.delete') + ' ' + $t('objects.vehicles.single')"
|
||||||
icon="fas fa-trash"
|
icon="fas fa-trash"
|
||||||
:template="'danger'"
|
:template="'danger'"
|
||||||
:click-action="() => deleteVehicle(object.id)"
|
:click-action="
|
||||||
|
() => SessionUser.objects.vehicles.functions.showDeleteObjectForm(object.id, () => reload())
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ActionSettingsWheelButton>
|
</ActionSettingsWheelButton>
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { computed, useAttrs } from "vue";
|
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
import { BDatepicker } from "buefy";
|
|
||||||
import {
|
|
||||||
formatDatepickerMonthForApi,
|
|
||||||
formatDatepickerMonthForLocale,
|
|
||||||
normalizeDatepickerMonth,
|
|
||||||
parseMonthpickerInput,
|
|
||||||
} from "@/services/buefyDatepicker.js";
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
inheritAttrs: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: [String, Date],
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
valueType: {
|
|
||||||
type: String,
|
|
||||||
default: "string",
|
|
||||||
validator: (value) => ["string", "date", "nullable-string"].includes(value),
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
disabled: Boolean,
|
|
||||||
readonly: Boolean,
|
|
||||||
expanded: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
appendToBody: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
position: {
|
|
||||||
type: String,
|
|
||||||
default: "is-bottom-left",
|
|
||||||
},
|
|
||||||
openOnFocus: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
inline: Boolean,
|
|
||||||
clearable: Boolean,
|
|
||||||
minDate: {
|
|
||||||
type: Date,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
maxDate: {
|
|
||||||
type: Date,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
unselectableDates: {
|
|
||||||
type: [Array, Function],
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
selectableDates: {
|
|
||||||
type: [Array, Function],
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
events: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
indicators: {
|
|
||||||
type: String,
|
|
||||||
default: "dots",
|
|
||||||
},
|
|
||||||
dataTestid: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue", "change"]);
|
|
||||||
const { locale } = useI18n({ useScope: "global" });
|
|
||||||
const attrs = useAttrs();
|
|
||||||
const datepickerTestId = computed(() => props.dataTestid || attrs["data-testid"] || undefined);
|
|
||||||
|
|
||||||
const selectedMonth = computed({
|
|
||||||
get: () => normalizeDatepickerMonth(props.modelValue),
|
|
||||||
set: (value) => {
|
|
||||||
const normalized = normalizeDatepickerMonth(value);
|
|
||||||
let nextValue = "";
|
|
||||||
|
|
||||||
if (props.valueType === "date") {
|
|
||||||
nextValue = normalized;
|
|
||||||
} else if (props.valueType === "nullable-string") {
|
|
||||||
nextValue = normalized ? formatDatepickerMonthForApi(normalized) : null;
|
|
||||||
} else {
|
|
||||||
nextValue = normalized ? formatDatepickerMonthForApi(normalized) : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
emit("update:modelValue", nextValue);
|
|
||||||
emit("change", nextValue);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const showClearIcon = computed(() => (
|
|
||||||
props.clearable
|
|
||||||
&& selectedMonth.value !== null
|
|
||||||
&& !props.disabled
|
|
||||||
&& !props.readonly
|
|
||||||
));
|
|
||||||
|
|
||||||
const clearSelectedMonth = (event) => {
|
|
||||||
event?.preventDefault?.();
|
|
||||||
event?.stopPropagation?.();
|
|
||||||
selectedMonth.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatter = (value) => formatDatepickerMonthForLocale(value, locale.value);
|
|
||||||
const parser = (value) => parseMonthpickerInput(value, locale.value);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div :data-testid="datepickerTestId">
|
|
||||||
<BDatepicker
|
|
||||||
v-bind="attrs"
|
|
||||||
v-model="selectedMonth"
|
|
||||||
type="month"
|
|
||||||
icon-pack="fas"
|
|
||||||
icon="calendar"
|
|
||||||
:locale="locale"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
:position="position"
|
|
||||||
:open-on-focus="openOnFocus"
|
|
||||||
:inline="inline"
|
|
||||||
:disabled="disabled"
|
|
||||||
:readonly="readonly"
|
|
||||||
:expanded="expanded"
|
|
||||||
:append-to-body="appendToBody"
|
|
||||||
:min-date="minDate"
|
|
||||||
:max-date="maxDate"
|
|
||||||
:unselectable-dates="unselectableDates"
|
|
||||||
:selectable-dates="selectableDates"
|
|
||||||
:events="events"
|
|
||||||
:indicators="indicators"
|
|
||||||
:icon-right="showClearIcon ? 'times-circle' : undefined"
|
|
||||||
:icon-right-clickable="showClearIcon"
|
|
||||||
:mobile-native="false"
|
|
||||||
:editable="true"
|
|
||||||
:date-formatter="formatter"
|
|
||||||
:date-parser="parser"
|
|
||||||
@icon-right-click="clearSelectedMonth"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -4,13 +4,11 @@ import axios from "axios";
|
|||||||
import { API_URL } from "@/config.js";
|
import { API_URL } from "@/config.js";
|
||||||
import { getError, parseError, removeError } from "@/components/request/HandleGlobalError.vue";
|
import { getError, parseError, removeError } from "@/components/request/HandleGlobalError.vue";
|
||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||||
import { economicEanPayloadValue, normalizeEconomicEan } from "@/services/economicCustomerIdentifiers.js";
|
|
||||||
|
|
||||||
const customerFormData = reactive({
|
const customerFormData = reactive({
|
||||||
cvr: null,
|
cvr: null,
|
||||||
contactEmail: null,
|
contactEmail: null,
|
||||||
contactPhone: null,
|
contactPhone: null
|
||||||
ean: null
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deliverSuccess = ref(false);
|
const deliverSuccess = ref(false);
|
||||||
@@ -19,24 +17,17 @@ let deliverTimeoutId;
|
|||||||
const submitCustomer = async () => {
|
const submitCustomer = async () => {
|
||||||
removeError("authRegisterCvr");
|
removeError("authRegisterCvr");
|
||||||
try {
|
try {
|
||||||
const payload = {
|
await axios.post(API_URL + "/auth/register/cvr", {
|
||||||
cvr: customerFormData.cvr,
|
cvr: customerFormData.cvr,
|
||||||
invoiceEmail: customerFormData.contactEmail,
|
invoiceEmail: customerFormData.contactEmail,
|
||||||
contactEmail: customerFormData.contactEmail,
|
contactEmail: customerFormData.contactEmail,
|
||||||
contactPhone: customerFormData.contactPhone,
|
contactPhone: customerFormData.contactPhone,
|
||||||
companyPhone: customerFormData.contactPhone
|
companyPhone: customerFormData.contactPhone
|
||||||
};
|
});
|
||||||
const ean = economicEanPayloadValue(customerFormData.ean);
|
|
||||||
if (ean !== undefined) {
|
|
||||||
payload.ean = ean;
|
|
||||||
}
|
|
||||||
|
|
||||||
await axios.post(API_URL + "/auth/register/cvr", payload);
|
|
||||||
|
|
||||||
customerFormData.contactPhone = null;
|
customerFormData.contactPhone = null;
|
||||||
customerFormData.contactEmail = null;
|
customerFormData.contactEmail = null;
|
||||||
customerFormData.cvr = null;
|
customerFormData.cvr = null;
|
||||||
customerFormData.ean = null;
|
|
||||||
|
|
||||||
deliverSuccess.value = true;
|
deliverSuccess.value = true;
|
||||||
deliverTimeoutId = setTimeout(() => {
|
deliverTimeoutId = setTimeout(() => {
|
||||||
@@ -68,20 +59,6 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-field">
|
|
||||||
<label for="customer_ean">{{ $t("customer_creation.customer.ean_label") }}</label>
|
|
||||||
<input
|
|
||||||
id="customer_ean"
|
|
||||||
:value="customerFormData.ean"
|
|
||||||
type="text"
|
|
||||||
inputmode="numeric"
|
|
||||||
maxlength="13"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="5790001234567"
|
|
||||||
@input="customerFormData.ean = normalizeEconomicEan($event.target.value)"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label for="email">{{ $t("customer_creation.customer.email_label") }}</label>
|
<label for="email">{{ $t("customer_creation.customer.email_label") }}</label>
|
||||||
<input id="email" v-model="customerFormData.contactEmail" type="email" name="email" autocomplete="email">
|
<input id="email" v-model="customerFormData.contactEmail" type="email" name="email" autocomplete="email">
|
||||||
|
|||||||