Compare commits

..
Author SHA1 Message Date
Jeppe B de780d857c Add POS card payment VAT visual previews 2026-07-27 18:05:53 +02:00
318 changed files with 4004 additions and 20683 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
- name: Qodana
# v2026.1.3
uses: JetBrains/qodana-action@b588768b6e7e6da579e518bc584f79de0d243692
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
with:
use-caches: true
cache-default-branch-only: true
+2 -8
View File
@@ -142,19 +142,13 @@ jobs:
const fs = require("node:fs");
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
const checks = {
schema: proof.schemaVersion === 2,
schema: proof.schemaVersion === 1,
repository: proof.repository === process.env.GITHUB_REPOSITORY,
source: proof.sourceSha === process.env.IOS_SOURCE_SHA,
exactSource: proof.sha === process.env.IOS_SOURCE_SHA,
releaseIdentity: typeof proof.releaseId === "string" && proof.releaseId.length > 0,
archive: /^[a-f0-9]{64}$/.test(proof.archiveSha256 || ""),
activeTarget: typeof proof.activeTarget === "string" && proof.activeTarget.length > 0,
verification: proof.verificationState === "verified",
publicGate: proof.livePublicGate === "passed",
credentialedGate: ["passed", "not-configured"].includes(proof.liveCredentialedGate),
credentialedGate: proof.liveCredentialedGate === "passed",
managerGate: proof.releaseManagerGate === "passed",
serverVersion: proof.serverVersionUpdated === true,
serverVersionReadBack: proof.serverVersionReadBack === "passed",
};
const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label);
if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`);
+1 -1
View File
@@ -118,7 +118,7 @@ jobs:
- name: Setup Java
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: temurin
java-version: 21
-283
View File
@@ -1,283 +0,0 @@
name: Frontend Release Recovery
on:
workflow_dispatch:
inputs:
action:
description: Verify the active release or roll back before verification
required: true
type: choice
options:
- reverify
- rollback
source_sha:
description: Exact 40-character commit SHA expected after recovery
required: true
type: string
rollback_target:
description: Immutable releases/.../dist target; required for rollback
required: false
type: string
permissions:
contents: read
actions: read
concurrency:
group: frontend-production
cancel-in-progress: false
jobs:
recover:
name: Protected production recovery
runs-on: ubuntu-latest
environment: frontend-production
timeout-minutes: 35
env:
PLAYWRIGHT_BASE_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
steps:
- name: Validate exact recovery target
shell: bash
env:
RECOVERY_ACTION: ${{ inputs.action }}
RECOVERY_SHA: ${{ inputs.source_sha }}
RECOVERY_TARGET: ${{ inputs.rollback_target }}
run: |
set -euo pipefail
[[ "$RECOVERY_SHA" =~ ^[a-f0-9]{40}$ ]]
if [[ "$RECOVERY_ACTION" == "rollback" ]]; then
[[ "$RECOVERY_TARGET" =~ ^releases/[A-Za-z0-9._-]+/dist$ ]]
else
[[ -z "$RECOVERY_TARGET" ]]
fi
- name: Checkout exact recovery source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ inputs.source_sha }}
- name: Authorize source from successful release proof
id: authorize
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RECOVERY_ACTION: ${{ inputs.action }}
RECOVERY_SHA: ${{ inputs.source_sha }}
RECOVERY_TARGET: ${{ inputs.rollback_target }}
run: |
set -euo pipefail
runs="$RUNNER_TEMP/recovery-runs.json"
artifacts="$RUNNER_TEMP/recovery-artifacts.json"
curl --fail --silent --show-error \
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/release.yml/runs?head_sha=$RECOVERY_SHA&status=success&per_page=20" \
> "$runs"
release_run_id="$(jq -r '[.workflow_runs[] | select(.event == "workflow_run")] | first | .id // empty' "$runs")"
[[ "$release_run_id" =~ ^[0-9]+$ ]]
artifact_name="frontend-release-proof-$RECOVERY_SHA"
curl --fail --silent --show-error \
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/runs/$release_run_id/artifacts?name=$artifact_name&per_page=20" \
> "$artifacts"
artifact_id="$(jq -r '[.artifacts[] | select(.expired == false)] | first | .id // empty' "$artifacts")"
[[ "$artifact_id" =~ ^[0-9]+$ ]]
mkdir -p "$RUNNER_TEMP/recovery-proof"
curl --fail --silent --show-error --location \
-H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \
"$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/artifacts/$artifact_id/zip" \
-o "$RUNNER_TEMP/recovery-proof.zip"
unzip -q "$RUNNER_TEMP/recovery-proof.zip" -d "$RUNNER_TEMP/recovery-proof"
PROOF_PATH="$RUNNER_TEMP/recovery-proof/frontend-release-proof.json" \
RELEASE_RUN_ID="$release_run_id" node <<'NODE'
const { appendFileSync, readFileSync } = require("node:fs");
const proof = JSON.parse(readFileSync(process.env.PROOF_PATH, "utf8"));
const sha = process.env.RECOVERY_SHA;
const target = process.env.RECOVERY_TARGET;
const expectedPrefix = `releases/${sha}-`;
const valid = proof.schemaVersion === 2
&& proof.repository === process.env.GITHUB_REPOSITORY
&& proof.sha === sha
&& proof.sourceSha === sha
&& proof.frontendReleaseRunId === process.env.RELEASE_RUN_ID
&& proof.verificationState === "verified"
&& proof.livePublicGate === "passed"
&& ["passed", "not-configured"].includes(proof.liveCredentialedGate)
&& proof.releaseManagerGate === "passed"
&& proof.serverVersionUpdated === true
&& proof.serverVersionReadBack === "passed"
&& /^[1-9][0-9]*-[1-9][0-9]*$/.test(String(proof.buildId || ""))
&& typeof proof.activeTarget === "string"
&& proof.activeTarget.startsWith(expectedPrefix)
&& proof.activeTarget.endsWith("/dist");
if (!valid) throw new Error("Recovery source does not have valid exact-release proof.");
if (process.env.RECOVERY_ACTION === "rollback" && target !== proof.activeTarget) {
throw new Error("Rollback target does not match the verified release proof.");
}
appendFileSync(process.env.GITHUB_OUTPUT, `verified_target=${proof.activeTarget}\n`);
appendFileSync(process.env.GITHUB_OUTPUT, `build_id=${proof.buildId}\n`);
NODE
- name: Capture current immutable target
id: current
shell: bash
env:
FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
run: |
node --input-type=module <<'NODE'
import { appendFileSync } from "node:fs";
const response = await fetch(new URL(`release-manifest.json?recovery=${Date.now()}`, process.env.FRONTEND_URL), {
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
});
if (!response.ok) throw new Error(`Active manifest returned HTTP ${response.status}.`);
const manifest = await response.json();
const sha = String(manifest.commit_sha || "").toLowerCase();
const build = String(manifest.build_id || "");
if (!/^[a-f0-9]{40}$/.test(sha) || !/^[A-Za-z0-9._-]{1,180}$/.test(build)) {
throw new Error("Active manifest has invalid release identity.");
}
if (!/^[1-9][0-9]*-[1-9][0-9]*$/.test(build)) {
throw new Error("Active manifest build id is not a release run identity.");
}
appendFileSync(process.env.GITHUB_OUTPUT, `previous_sha=${sha}\nprevious_build_id=${build}\nprevious_target=releases/${sha}-${build}/dist\n`);
NODE
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Install secure FTP client without system changes
run: |
if command -v lftp >/dev/null 2>&1; then
exit 0
fi
package_root="$RUNNER_TEMP/lftp-package"
mkdir -p "$package_root"
(
cd "$package_root"
apt-get download lftp
dpkg-deb --extract ./lftp_*.deb root
)
echo "$package_root/root/usr/bin" >> "$GITHUB_PATH"
- name: Install Playwright Chromium
run: node scripts/install-playwright-browsers.mjs chromium
- name: Roll back atomically
if: inputs.action == 'rollback'
id: rollback
run: node scripts/release/deploy-cpanel.mjs --rollback
env:
NODE_OPTIONS: --use-system-ca
RELEASE_ROLLBACK_TARGET: ${{ inputs.rollback_target }}
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
- name: Verify active manifest matches authorized release
shell: bash
env:
EXPECTED_SHA: ${{ inputs.source_sha }}
EXPECTED_TARGET: ${{ steps.authorize.outputs.verified_target }}
FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
run: |
node --input-type=module <<'NODE'
const deadline = Date.now() + 300_000;
let actual = "";
while (Date.now() < deadline) {
const response = await fetch(new URL(`release-manifest.json?recovery=${Date.now()}`, process.env.FRONTEND_URL), {
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
});
if (response.ok) {
const manifest = await response.json();
const manifestSha = String(manifest.commit_sha || "").toLowerCase();
actual = `releases/${manifestSha}-${String(manifest.build_id || "")}/dist`;
if (manifestSha === process.env.EXPECTED_SHA && actual === process.env.EXPECTED_TARGET) process.exit(0);
}
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
throw new Error(`Active release identity did not converge to the authorized target; observed ${actual || "unavailable"}.`);
NODE
- name: Public live verification
run: npm run test:e2e:live:public
env:
NODE_OPTIONS: --use-system-ca
- name: Credentialed live verification
run: npm run test:e2e:live:roles
env:
NODE_OPTIONS: --use-system-ca
PLAYWRIGHT_REQUIRE_LIVE_CREDENTIALS: "true"
PLAYWRIGHT_USER_CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
PLAYWRIGHT_USER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
PLAYWRIGHT_USER_OTP_SECRET: ${{ secrets.PLAYWRIGHT_USER_OTP_SECRET }}
PLAYWRIGHT_OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Record verified server version
run: npm run release:update-server-version
env:
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_VERSION: ${{ inputs.source_sha }}
RELEASE_BUILD_ID: ${{ steps.authorize.outputs.build_id }}
RELEASE_VERSION_UPDATE_REQUIRED: "true"
- name: Publish recovery audit
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-release-recovery-${{ inputs.source_sha }}-${{ github.run_id }}
path: |
test-results
playwright-report
if-no-files-found: ignore
retention-days: 30
- name: Restore pre-recovery target after downstream failure
if: >-
failure() && inputs.action == 'rollback'
shell: bash
run: |
node scripts/release/deploy-cpanel.mjs --rollback
node --input-type=module <<'NODE'
const deadline = Date.now() + 300_000;
while (Date.now() < deadline) {
const response = await fetch(new URL(`release-manifest.json?restore=${Date.now()}`, process.env.PRODUCTION_FRONTEND_URL), {
headers: { "Cache-Control": "no-cache", Pragma: "no-cache" },
});
if (response.ok) {
const manifest = await response.json();
const sha = String(manifest.commit_sha || "").toLowerCase();
const target = `releases/${sha}-${String(manifest.build_id || "")}/dist`;
if (sha === process.env.RELEASE_VERSION && target === process.env.RELEASE_ROLLBACK_TARGET) process.exit(0);
}
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
throw new Error("Failed to restore and verify the pre-recovery target.");
NODE
npm run release:update-server-version
env:
NODE_OPTIONS: --use-system-ca
RELEASE_ROLLBACK_TARGET: ${{ steps.current.outputs.previous_target }}
RELEASE_VERSION: ${{ steps.current.outputs.previous_sha }}
RELEASE_BUILD_ID: ${{ steps.current.outputs.previous_build_id }}
RELEASE_VERSION_UPDATE_REQUIRED: "true"
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
+23 -83
View File
@@ -37,7 +37,6 @@ jobs:
checksum_name: ${{ steps.package-names.outputs.checksum_name }}
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
release_id: ${{ steps.package.outputs.release_id }}
archive_sha256: ${{ steps.package.outputs.archive_sha256 }}
steps:
- name: Check release commit is current
id: branch-head
@@ -287,28 +286,8 @@ jobs:
env:
NODE_OPTIONS: --use-system-ca
- name: Detect credentialed live gate configuration
if: steps.branch-head.outputs.current == 'true'
id: credentialed_live_config
shell: bash
run: |
set -euo pipefail
if [[ -n "$CUSTOMER_NUMBER" && -n "$CUSTOMER_PASSWORD" &&
-n "$OPERATOR_USER_ID" && -n "$OPERATOR_PASSWORD" ]]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
fi
env:
CUSTOMER_NUMBER: ${{ secrets.PLAYWRIGHT_USER_CUSTOMER_NUMBER }}
CUSTOMER_PASSWORD: ${{ secrets.PLAYWRIGHT_USER_PASSWORD }}
OPERATOR_USER_ID: ${{ secrets.PLAYWRIGHT_OPERATOR_USER_ID }}
OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
- name: Credentialed live Playwright gate (when configured)
if: >-
steps.branch-head.outputs.current == 'true' &&
steps.credentialed_live_config.outputs.configured == 'true'
if: steps.branch-head.outputs.current == 'true'
id: credentialed_live
timeout-minutes: 15
run: npm run test:e2e:live:roles
@@ -321,9 +300,26 @@ jobs:
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
- name: Roll back after live verification failure
if: >-
failure() && steps.branch-head.outputs.current == 'true' &&
steps.deploy.outcome == 'success' &&
(steps.public_live.outcome == 'failure' || steps.credentialed_live.outcome == 'failure')
timeout-minutes: 10
run: node scripts/release/deploy-cpanel.mjs --rollback
env:
NODE_OPTIONS: --use-system-ca
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
- name: Record Release Manager gate
id: release_manager
if: steps.branch-head.outputs.current == 'true'
continue-on-error: true
timeout-minutes: 5
run: |
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
@@ -344,9 +340,7 @@ jobs:
run: npm run release:update-server-version
env:
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_VERSION: ${{ github.event.workflow_run.head_sha }}
RELEASE_VERSION_UPDATE_REQUIRED: "true"
- name: Create verified frontend release proof
if: steps.branch-head.outputs.current == 'true'
@@ -361,52 +355,24 @@ jobs:
if (!process.env[name]) throw new Error(`Missing ${name}`);
return process.env[name];
};
const requireSuccessfulStep = (name) => {
const outcome = required(name);
if (outcome !== "success") throw new Error(`${name} did not succeed: ${outcome}`);
return "passed";
};
const credentialedGate = () => {
const configured = required("LIVE_CREDENTIALED_GATE_CONFIGURED");
if (configured === "false") return "not-configured";
if (configured !== "true") {
throw new Error(`Invalid LIVE_CREDENTIALED_GATE_CONFIGURED: ${configured}`);
}
return requireSuccessfulStep("LIVE_CREDENTIALED_GATE_OUTCOME");
};
const proof = {
schemaVersion: 2,
releaseId: required("RELEASE_ID"),
sha: required("RELEASE_COMMIT_SHA").toLowerCase(),
archiveSha256: required("RELEASE_ARCHIVE_SHA256").toLowerCase(),
activeTarget: required("RELEASE_ACTIVE_TARGET"),
rollbackTarget: process.env.RELEASE_ROLLBACK_TARGET || null,
verificationState: "verified",
observedAt: new Date().toISOString(),
schemaVersion: 1,
repository: required("GITHUB_REPOSITORY"),
sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(),
testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"),
frontendReleaseRunId: required("GITHUB_RUN_ID"),
frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"),
buildId: required("RELEASE_BUILD_ID"),
livePublicGate: requireSuccessfulStep("LIVE_PUBLIC_GATE_OUTCOME"),
liveCredentialedGate: credentialedGate(),
releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME"),
livePublicGate: "passed",
liveCredentialedGate: "passed",
releaseManagerGate: "passed",
serverVersionUpdated: true,
serverVersionReadBack: "passed",
completedAt: new Date().toISOString(),
};
writeFileSync(process.env.PROOF_PATH, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
NODE
env:
TESTED_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
RELEASE_ARCHIVE_SHA256: ${{ needs.build-release.outputs.archive_sha256 }}
RELEASE_ACTIVE_TARGET: ${{ steps.deploy.outputs.active_target }}
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
LIVE_PUBLIC_GATE_OUTCOME: ${{ steps.public_live.outcome }}
LIVE_CREDENTIALED_GATE_CONFIGURED: ${{ steps.credentialed_live_config.outputs.configured }}
LIVE_CREDENTIALED_GATE_OUTCOME: ${{ steps.credentialed_live.outcome }}
RELEASE_MANAGER_GATE_OUTCOME: ${{ steps.release_manager.outcome }}
- name: Publish verified frontend release proof
if: steps.branch-head.outputs.current == 'true'
@@ -417,32 +383,6 @@ jobs:
if-no-files-found: error
retention-days: 30
- name: Roll back after any post-deployment verification failure
if: >-
failure() && steps.branch-head.outputs.current == 'true' &&
steps.deploy.outcome == 'success'
timeout-minutes: 10
shell: bash
run: |
set -euo pipefail
node scripts/release/deploy-cpanel.mjs --rollback
[[ "$RELEASE_ROLLBACK_TARGET" =~ ^releases/([a-f0-9]{40})-([1-9][0-9]*-[1-9][0-9]*)/dist$ ]]
export RELEASE_VERSION="${BASH_REMATCH[1]}"
export RELEASE_BUILD_ID="${BASH_REMATCH[2]}"
npm run release:update-server-version
env:
NODE_OPTIONS: --use-system-ca
RELEASE_ROLLBACK_TARGET: ${{ steps.deploy.outputs.rollback_target }}
PRODUCTION_FTP_HOST: ${{ secrets.PRODUCTION_FTP_HOST }}
PRODUCTION_FTP_USER: ${{ secrets.PRODUCTION_FTP_USER }}
PRODUCTION_FTP_PASSWORD: ${{ secrets.PRODUCTION_FTP_PASSWORD }}
PRODUCTION_FTP_PATH: ${{ secrets.PRODUCTION_FTP_PATH }}
PRODUCTION_ACTIVATION_KEY: ${{ secrets.PRODUCTION_ACTIVATION_KEY }}
PRODUCTION_FRONTEND_URL: ${{ vars.PRODUCTION_FRONTEND_URL || 'https://truckwash.io' }}
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_VERSION_UPDATE_REQUIRED: "true"
- name: Upload Playwright report
if: failure() && steps.branch-head.outputs.current == 'true'
continue-on-error: true
+2 -16
View File
@@ -602,14 +602,7 @@ jobs:
e2e-full:
if: >
always() &&
(
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
(
github.ref_name == github.event.repository.default_branch &&
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
)
) &&
(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' &&
(
@@ -805,14 +798,7 @@ jobs:
full-e2e-summary:
if: >
always() &&
(
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
(
github.ref_name == github.event.repository.default_branch &&
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
)
) &&
(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')
name: Full E2E summary
needs: [e2e-full]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

@@ -1,85 +0,0 @@
{
"schemaVersion": 1,
"kind": "VisualEvidenceManifest",
"taskId": "a0edd464-44c0-4d77-96c1-d3562496a1b7",
"repository": "copenhagentruckwash/pleno-vue",
"baseSha": "eee9ba1c138f0c88c772ea284a5a97a46cb9412c",
"subjectSha": "89dec2c5690f9eaf1e0e34651bb40b7f441b85fb",
"views": [
{
"id": "invoicing-period-review",
"name": "Superuser invoice period review workspace",
"description": "Replaces the long mixed invoice-period page with grouped review navigation, compact totals, explicit review filters, and a responsive master-detail workspace while preserving every invoice category and action.",
"route": "/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all",
"fixture": "Synthetic superuser invoice-period fixture with three fictional customers and no production data",
"comparisons": {
"mobile": {
"width": 390,
"height": 844,
"before": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-mobile.png",
"sha256": "b3c25c072f45e912358cc61d21577bbf61d5216b114a6911f900ca19b90d477e",
"bytes": 28914,
"width": 390,
"height": 844,
"mimeType": "image/png",
"alt": "Invoice period mobile view before the review workspace redesign"
},
"after": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-mobile.png",
"sha256": "257db0e6e295b6b13ba5d0b29509c2a8b7170244ad6539a8e2ff18d2c9c0ffba",
"bytes": 31339,
"width": 390,
"height": 844,
"mimeType": "image/png",
"alt": "Invoice period mobile view after the review workspace redesign"
}
},
"tablet": {
"width": 768,
"height": 1024,
"before": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-tablet.png",
"sha256": "8cd17ea1e384ab6a9711a643d4e50e971cabcf704c3672d8f90cb2f5f2d36ba0",
"bytes": 38508,
"width": 768,
"height": 1024,
"mimeType": "image/png",
"alt": "Invoice period tablet view before the review workspace redesign"
},
"after": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-tablet.png",
"sha256": "1c627c30ade435ee004b8cdcd0223022594a351658639edec1f6e5396efaba18",
"bytes": 46062,
"width": 768,
"height": 1024,
"mimeType": "image/png",
"alt": "Invoice period tablet view after the review workspace redesign"
}
},
"desktop": {
"width": 1440,
"height": 900,
"before": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/before-desktop.png",
"sha256": "849ea0eedabc7f1e52172e26602cddfddbeed32c91d04672dfe8b713343a54fa",
"bytes": 142738,
"width": 1440,
"height": 900,
"mimeType": "image/png",
"alt": "Invoice period desktop view before the review workspace redesign"
},
"after": {
"path": "docs/pr-previews/a0edd464-44c0-4d77-96c1-d3562496a1b7/invoicing-period/after-desktop.png",
"sha256": "d026925bc4f6ced62686ae7f8252594e3a3fe9bd559a7f53f38a1bfcb0b96892",
"bytes": 108740,
"width": 1440,
"height": 900,
"mimeType": "image/png",
"alt": "Invoice period desktop view after the review workspace redesign"
}
}
}
}
]
}
@@ -1,26 +0,0 @@
# Customer and subuser lifecycle visual comparisons
## Forgot-password account selection
The reset page previously accepted only a customer number. It now lets the
visitor choose a customer or chauffeur account. Chauffeur recovery uses the
country code and phone number and sends the one-time reset link by SMS.
- Mobile: [before](before-mobile.png) / [after](after-mobile.png)
- Tablet: [before](before-tablet.png) / [after](after-tablet.png)
- Desktop: [before](before-desktop.png) / [after](after-desktop.png)
## Pre-authorized customer access decision
The SMS link previously had no destination view. It now opens a read-only
request preview, identifies the chauffeur and customer, and requires an
explicit approve or deny action before the one-time token mutates access.
- Mobile: [before](access-before-mobile.png) / [after](access-after-mobile.png)
- Tablet: [before](access-before-tablet.png) / [after](access-after-tablet.png)
- Desktop: [before](access-before-desktop.png) / [after](access-after-desktop.png)
The related signed-in profile and customer grant selector use the same
responsive components. The selector is deduplicated by customer number and
uses colored permission indicators for vehicles, tools, calendar, orders,
and driver access.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

@@ -1,28 +0,0 @@
# Automatic cron execution visual comparison
The Cron workers panel now exposes machine-verifiable scheduler cadence.
The summary reports how many active workers have maintained the required
once-per-minute cadence, and each worker row shows its latest loop gap and
consecutive qualifying loops.
A worker is verified only after two consecutive loops, with no gap above
60 seconds, while the worker is running and its latest observation is no more
than 60 seconds old.
## Mobile
- [Before](before-mobile.png)
- [After](after-mobile.png)
## Tablet
- [Before](before-tablet.png)
- [After](after-tablet.png)
## Desktop
- [Before](before-desktop.png)
- [After](after-desktop.png)
The updated state tags use explicit foreground colors so success and warning
labels remain readable against their backgrounds.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

-48
View File
@@ -1,48 +0,0 @@
# Stripe cleanup visual evidence
Authentic browser captures compare `origin/master` at
`fd31609cb379cda36fb16ef7077fc9db3c91eb2b` with the feature at
`6e795b253062606e6122cc7e630e17651b9b7efd`.
Both revisions were rendered by their own Vite applications and exercised with
the repository's mocked Playwright API support. No production API, Stripe
account, or product-source modification was used to create the evidence.
## Regular POS card-payment view
The baseline identifies the integration as Stripe and offers an email payment.
The feature uses provider-neutral card-terminal wording and removes the hosted
email-payment action while preserving terminal payment.
| Device | Before | After |
| --- | --- | --- |
| Mobile | [Before](before-pos-card-payment-mobile.png) | [After](after-pos-card-payment-mobile.png) |
| Tablet | [Before](before-pos-card-payment-tablet.png) | [After](after-pos-card-payment-tablet.png) |
| Desktop | [Before](before-pos-card-payment-desktop.png) | [After](after-pos-card-payment-desktop.png) |
## Authorized payment capture
Both revisions receive a mocked payment intent in `requires_capture` state. The
baseline exposes a manual capture action. The feature automatically issues the
capture request and shows its in-progress state without a second manual action.
| Device | Before | After |
| --- | --- | --- |
| Mobile | [Before](before-payment-capture-mobile.png) | [After](after-payment-capture-mobile.png) |
| Tablet | [Before](before-payment-capture-tablet.png) | [After](after-payment-capture-tablet.png) |
| Desktop | [Before](before-payment-capture-desktop.png) | [After](after-payment-capture-desktop.png) |
## Order-dashboard action rail
The baseline action rail includes the hosted Stripe invoice/payment-link
action. The feature removes it while preserving receipts, ordinary order
completion, and navigation.
| Device | Before | After |
| --- | --- | --- |
| Mobile | [Before](before-order-dashboard-mobile.png) | [After](after-order-dashboard-mobile.png) |
| Tablet | [Before](before-order-dashboard-tablet.png) | [After](after-order-dashboard-tablet.png) |
| Desktop | [Before](before-order-dashboard-desktop.png) | [After](after-order-dashboard-desktop.png) |
All nine paired states passed their relevant DOM assertions across Chromium
mobile, tablet, and desktop projects.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

@@ -1,85 +0,0 @@
{
"schemaVersion": 1,
"kind": "VisualEvidenceManifest",
"taskId": "xlvask-autopilot-20260803",
"repository": "copenhagentruckwash/pleno-vue",
"baseSha": "f995440098f9e3f3b5ff122a55c8c4e018cc716e",
"subjectSha": "d5c291e6f7f81d6992aa63375d77521c2983f8e5",
"views": [
{
"id": "invoice-period-self-wash",
"name": "Invoice period XL-Vask autopilot",
"description": "Shows the previous self-wash import beside the new state-separated, evidence-led autopilot review workspace.",
"route": "/superuser/invoices?tab=period&periodView=self_wash&startDate=2026-03-01&endDate=2026-03-31",
"fixture": "Playwright mocked March 2026 invoice period with one uncertain match and one failed match",
"comparisons": {
"mobile": {
"width": 1081,
"height": 1999,
"before": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-mobile.png",
"sha256": "8c67915dd48847ef553db32f3fc2481e58af7490dab9a41c49728003b84df5a7",
"bytes": 141379,
"width": 1081,
"height": 1999,
"mimeType": "image/png",
"alt": "Mobile self-wash period import before the autopilot review workspace"
},
"after": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-mobile.png",
"sha256": "02b5d6560566350cbf702ff995602084a70ffdda13290c3754438885c3730a20",
"bytes": 142027,
"width": 1081,
"height": 1999,
"mimeType": "image/png",
"alt": "Mobile XL-Vask autopilot summary, filters, and visible review states"
}
},
"tablet": {
"width": 1536,
"height": 2048,
"before": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-tablet.png",
"sha256": "5a20daa875e211e1bfbfdd4e17282956720954ce14a9e76f1002b1355f795dd4",
"bytes": 179803,
"width": 1536,
"height": 2048,
"mimeType": "image/png",
"alt": "Tablet self-wash period import before the autopilot review workspace"
},
"after": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-tablet.png",
"sha256": "1916b97a6d3f7c0084130b3514fe67b5a8f729e23bca37c1ad5c7ed75f821d44",
"bytes": 198536,
"width": 1536,
"height": 2048,
"mimeType": "image/png",
"alt": "Tablet XL-Vask autopilot summary, filters, rows, and pagination"
}
},
"desktop": {
"width": 1280,
"height": 720,
"before": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-desktop.png",
"sha256": "617095bcb916dd2f25b5cd0e8d77e7b0b05f1b5db662aeb810b52d606bcbe0af",
"bytes": 94026,
"width": 1280,
"height": 720,
"mimeType": "image/png",
"alt": "Desktop self-wash period import before the autopilot review workspace"
},
"after": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-desktop.png",
"sha256": "dd33f7e07ca426ee63c4708d9994e2403375bae9a86c119d0d296f52e5e90a9a",
"bytes": 117715,
"width": 1280,
"height": 720,
"mimeType": "image/png",
"alt": "Desktop XL-Vask autopilot summary and state-separated review rows"
}
}
}
}
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"marketingVersion": "1.0.1",
"marketingVersion": "1.0.0",
"bundleId": "io.truckwash.app",
"minimumIosVersion": "15.0"
}
-28
View File
@@ -3534,19 +3534,6 @@ paths:
properties:
enabled:
type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404':
$ref: '#/components/responses/NotFound'
put:
@@ -3579,21 +3566,6 @@ paths:
properties:
message:
type: string
enabled:
type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404':
$ref: '#/components/responses/NotFound'
+20 -118
View File
@@ -1,5 +1,4 @@
import { execSync } from "node:child_process";
import { fileURLToPath } from "node:url";
function gitCommit() {
try {
@@ -9,140 +8,43 @@ function gitCommit() {
}
}
async function boundedJson(response, limit = 64 * 1024) {
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > limit) throw new Error("Server version response is too large.");
const reader = response.body?.getReader();
const chunks = [];
let bytes = 0;
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > limit) {
await reader.cancel().catch(() => {});
throw new Error("Server version response is too large.");
}
chunks.push(Buffer.from(value));
}
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
export async function updateServerVersion(env = process.env, fetchImpl = globalThis.fetch) {
const serverToken = String(env.SERVER_UPDATE_TOKEN || "").trim();
const releaseGateToken = String(env.RELEASE_MANAGER_GATE_TOKEN || "").trim();
const token = serverToken || releaseGateToken;
const useReleaseGate = !serverToken && Boolean(releaseGateToken);
const required = env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
async function main() {
const token = process.env.SERVER_UPDATE_TOKEN;
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
if (!token) {
if (required) {
throw new Error("SERVER_UPDATE_TOKEN or RELEASE_MANAGER_GATE_TOKEN is required after deploy verification.");
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
}
console.log("No server version credential is set. Skipping server version update.");
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
return;
}
const version = String(
env.RELEASE_VERSION || env.RELEASE_EXPECTED_COMMIT || env.GITHUB_SHA || gitCommit()
).toLowerCase();
if (!/^[a-f0-9]{40}$/.test(version)) {
throw new Error("Server release version must be a full lowercase commit SHA.");
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
if (!version) {
throw new Error("Could not determine release version for server update.");
}
const baseUrl =
env.SERVER_UPDATE_URL ||
(useReleaseGate
? "https://api-v2.truckwash.io/master/api/release/gate/frontend-version"
: "https://api-v2.truckwash.io/master/api/worker/update-version");
const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
const url = new URL(baseUrl);
if (url.protocol !== "https:" || url.username || url.password) {
throw new Error("SERVER_UPDATE_URL must be an HTTPS URL without embedded credentials.");
}
const request = {
method: useReleaseGate ? "POST" : "GET",
redirect: "manual",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${token}`,
"Cache-Control": "no-cache",
},
};
if (useReleaseGate) {
const repository = String(env.GITHUB_REPOSITORY || "copenhagentruckwash/pleno-vue").trim();
const branch = String(env.RELEASE_BRANCH || "master")
.trim()
.toLowerCase();
const buildId = String(env.RELEASE_BUILD_ID || env.RELEASE_EXPECTED_BUILD_ID || "").trim();
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new Error("Frontend release repository is invalid.");
}
if (branch !== "master") {
throw new Error("Frontend release branch must be master.");
}
if (!/^[1-9][0-9]*-[1-9][0-9]*$/.test(buildId)) {
throw new Error("Frontend release build id is invalid.");
}
request.headers["Content-Type"] = "application/json";
request.body = JSON.stringify({ version, repository, branch, build_id: buildId });
} else {
url.searchParams.set("version", version);
}
url.searchParams.set("version", version);
const response = await fetchImpl(url, request);
const body = await boundedJson(response).catch(() => ({}));
if (!response.ok) {
throw new Error(`Server version update failed with HTTP ${response.status}.`);
}
const readUrl = new URL(
env.SERVER_VERSION_READ_URL ||
(useReleaseGate
? "https://api-v2.truckwash.io/master/api/release/gate/frontend-version"
: "https://api-v2.truckwash.io/master/api/worker/version")
);
if (
readUrl.protocol !== "https:" ||
readUrl.origin !== url.origin ||
readUrl.username ||
readUrl.password ||
readUrl.search ||
readUrl.hash
) {
throw new Error("SERVER_VERSION_READ_URL must be an exact HTTPS URL on the update origin.");
}
readUrl.searchParams.set("verify", `${Date.now()}`);
const verification = await fetchImpl(readUrl, {
const response = await fetch(url, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${token}`,
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
if (!verification.ok) {
throw new Error(`Server version read-back failed with HTTP ${verification.status}.`);
}
const verified = await boundedJson(verification);
const observed = String(verified?.data?.version ?? verified?.version ?? "").toLowerCase();
if (observed !== version) {
throw new Error(`Server version read-back did not match ${version}.`);
const body = await response.text();
if (!response.ok) {
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
}
console.log(`Server version updated to ${version}.`);
return { version, update: body, observed };
}
async function main() {
await updateServerVersion();
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
-1
View File
@@ -49,7 +49,6 @@ export const ownedFilesByRole = {
"userVehicles.spec.ts",
],
subuser: [
"driverAuth.spec.ts",
"subuserCompleteRegistration.spec.ts",
"subuserProfileContact.spec.ts",
"subuserProfileGrant.spec.ts",
+1 -1
View File
@@ -98,7 +98,7 @@ const shouldHideGlobalOverlays = computed(
);
const sessionLoaderRoutePattern = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/;
const guestSessionLoaderRoutePattern =
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|subuser-access\/decision$|qr\/new-(?:customer|driver)$)/;
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/;
const currentBrowserPath = () => {
if (typeof window === "undefined") {
return "";
+86 -237
View File
@@ -1,59 +1,44 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, provide, reactive, readonly, ref, watch } from "vue";
import { computed, provide, reactive, watch } from "vue";
import BuefyTreeNode from "./BuefyTreeNode.vue";
type TreeNode = Record<string, any>;
type LoadNodeResult = {
children: TreeNode[];
failed: boolean;
cancelled: boolean;
};
type ExpandAllResult = {
expandedKeys: any[];
failedKeys: any[];
cancelled: boolean;
};
const EXPAND_ALL_CONCURRENCY = 4;
const props = withDefaults(
defineProps<{
data: TreeNode[];
fields?: Partial<{
id: string;
label: string;
children: string;
isLeaf: string;
disabled: string;
}>;
selectionMode?: "none" | "single" | "multiple" | "checkbox";
selected?: any;
expandedKeys?: any[];
checkedKeys?: any[];
defaultExpandAll?: boolean;
expandOnClickNode?: boolean;
lazy?: boolean;
load?: (_node: TreeNode) => Promise<TreeNode[]>;
progressiveBatchSize?: number;
loadMoreLabel?: string;
ariaLabel?: string;
}>(),
{
data: () => [],
fields: () => ({}),
selectionMode: "none",
selected: null,
expandedKeys: () => [],
checkedKeys: () => [],
defaultExpandAll: false,
expandOnClickNode: true,
lazy: false,
load: undefined,
progressiveBatchSize: 0,
loadMoreLabel: "Show {count} more",
ariaLabel: undefined,
}
);
const props = withDefaults(defineProps<{
data: TreeNode[];
fields?: Partial<{
id: string;
label: string;
children: string;
isLeaf: string;
disabled: string;
}>;
selectionMode?: "none" | "single" | "multiple" | "checkbox";
selected?: any;
expandedKeys?: any[];
checkedKeys?: any[];
defaultExpandAll?: boolean;
expandOnClickNode?: boolean;
lazy?: boolean;
load?: (_node: TreeNode) => Promise<TreeNode[]>;
progressiveBatchSize?: number;
loadMoreLabel?: string;
ariaLabel?: string;
}>(), {
data: () => [],
fields: () => ({}),
selectionMode: "none",
selected: null,
expandedKeys: () => [],
checkedKeys: () => [],
defaultExpandAll: false,
expandOnClickNode: true,
lazy: false,
load: undefined,
progressiveBatchSize: 0,
loadMoreLabel: "Show {count} more",
ariaLabel: undefined,
});
const emit = defineEmits<{
"update:selected": [value: any];
@@ -85,11 +70,6 @@ const state = reactive({
loadErrorKeys: [] as any[],
renderedLimits: {} as Record<string, number>,
});
const bulkExpanding = ref(false);
const inFlightLoads = new Map<string, Promise<LoadNodeResult>>();
let activeBulkExpansion: Promise<ExpandAllResult> | null = null;
let bulkOperationVersion = 0;
let dataVersion = 0;
const keyOf = (node: TreeNode) => node?.[resolvedFields.value.id];
const keyString = (key: any) => String(key ?? "");
@@ -98,8 +78,6 @@ const childrenOf = (node: TreeNode) => {
return Array.isArray(children) ? children : [];
};
const cachedChildrenOf = (node: TreeNode) => state.lazyChildrenCache[keyString(keyOf(node))] || [];
const hasCachedChildren = (node: TreeNode) =>
Object.prototype.hasOwnProperty.call(state.lazyChildrenCache, keyString(keyOf(node)));
const effectiveChildrenOf = (node: TreeNode) => {
const children = childrenOf(node);
return children.length > 0 ? children : cachedChildrenOf(node);
@@ -114,8 +92,7 @@ const visibleChildrenOf = (node: TreeNode) => {
const limit = state.renderedLimits[key] || progressiveBatchSize.value;
return children.slice(0, limit);
};
const remainingChildrenCount = (node: TreeNode) =>
Math.max(0, effectiveChildrenOf(node).length - visibleChildrenOf(node).length);
const remainingChildrenCount = (node: TreeNode) => Math.max(0, effectiveChildrenOf(node).length - visibleChildrenOf(node).length);
const hasMoreChildren = (node: TreeNode) => remainingChildrenCount(node) > 0;
const showMoreChildren = (node: TreeNode) => {
if (!hasMoreChildren(node)) {
@@ -125,8 +102,10 @@ const showMoreChildren = (node: TreeNode) => {
const currentLimit = state.renderedLimits[key] || progressiveBatchSize.value;
state.renderedLimits[key] = Math.min(effectiveChildrenOf(node).length, currentLimit + progressiveBatchSize.value);
};
const loadMoreLabelFor = (node: TreeNode) =>
props.loadMoreLabel.replace("{count}", String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node))));
const loadMoreLabelFor = (node: TreeNode) => props.loadMoreLabel.replace(
"{count}",
String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node)))
);
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
@@ -145,6 +124,18 @@ const collectKeys = (node: TreeNode): any[] => {
return keys;
};
const collectAllExpandableKeys = (nodes: TreeNode[]): any[] => {
const keys: any[] = [];
nodes.forEach((node) => {
const key = keyOf(node);
if (key !== undefined && key !== null && !isLeaf(node)) {
keys.push(key);
}
keys.push(...collectAllExpandableKeys(childrenOf(node)));
});
return keys;
};
watch(
() => props.selected,
(value) => {
@@ -168,6 +159,18 @@ watch(
{ deep: true }
);
watch(
() => props.data,
(nodes) => {
state.renderedLimits = {};
if (props.defaultExpandAll) {
state.expandedKeys = collectAllExpandableKeys(nodes);
emit("update:expandedKeys", [...state.expandedKeys]);
}
},
{ immediate: true, deep: true }
);
const setExpandedKeys = (keys: any[]) => {
state.expandedKeys = [...keys];
emit("update:expandedKeys", [...state.expandedKeys]);
@@ -178,146 +181,33 @@ const setCheckedKeys = (keys: any[]) => {
emit("update:checkedKeys", [...state.checkedKeys]);
};
const loadNode = async (node: TreeNode): Promise<LoadNodeResult> => {
const loadNode = async (node: TreeNode) => {
if (!props.lazy || !props.load) {
return { children: effectiveChildrenOf(node), failed: false, cancelled: false };
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return { children: [], failed: false, cancelled: false };
return;
}
const cacheKey = keyString(key);
if (childrenOf(node).length > 0 || hasCachedChildren(node)) {
return { children: effectiveChildrenOf(node), failed: false, cancelled: false };
}
const inFlight = inFlightLoads.get(cacheKey);
if (inFlight) {
return inFlight;
if (state.loadingKeys.includes(key)) {
return;
}
const requestedDataVersion = dataVersion;
let loadPromise: Promise<LoadNodeResult>;
loadPromise = (async () => {
state.loadingKeys = [...new Set([...state.loadingKeys, key])];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
emit("load-start", node, key);
state.loadingKeys = [...state.loadingKeys, key];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
emit("load-start", node, key);
try {
const loadedChildren = await props.load(node);
if (requestedDataVersion !== dataVersion) {
return { children: [], failed: false, cancelled: true };
}
const children = Array.isArray(loadedChildren) ? loadedChildren : [];
state.lazyChildrenCache[cacheKey] = children;
if (progressiveBatchSize.value > 0) {
state.renderedLimits[cacheKey] = progressiveBatchSize.value;
}
return { children, failed: false, cancelled: false };
} catch (error) {
if (requestedDataVersion !== dataVersion) {
return { children: [], failed: false, cancelled: true };
}
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
emit("load-error", error, node, key);
return { children: [], failed: true, cancelled: false };
} finally {
if (requestedDataVersion === dataVersion) {
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
}
if (inFlightLoads.get(cacheKey) === loadPromise) {
inFlightLoads.delete(cacheKey);
}
try {
state.lazyChildrenCache[keyString(key)] = await props.load(node);
if (progressiveBatchSize.value > 0) {
state.renderedLimits[keyString(key)] = progressiveBatchSize.value;
}
})();
inFlightLoads.set(cacheKey, loadPromise);
return loadPromise;
};
const mapWithConcurrency = async <T>(items: T[], concurrency: number, callback: (item: T) => Promise<void>) => {
let nextIndex = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (nextIndex < items.length) {
const item = items[nextIndex];
nextIndex += 1;
await callback(item);
}
});
await Promise.all(workers);
};
const expandAll = (): Promise<ExpandAllResult> => {
if (activeBulkExpansion) {
return activeBulkExpansion;
} catch (error) {
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
emit("load-error", error, node, key);
} finally {
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
}
const operationVersion = ++bulkOperationVersion;
bulkExpanding.value = true;
const expansion = (async (): Promise<ExpandAllResult> => {
const expandedKeys: any[] = [];
const failedKeys: any[] = [];
const visited = new Set<string>();
let currentLevel = [...props.data];
while (currentLevel.length > 0 && operationVersion === bulkOperationVersion) {
const nextLevel: TreeNode[] = [];
await mapWithConcurrency(currentLevel, EXPAND_ALL_CONCURRENCY, async (node) => {
if (operationVersion !== bulkOperationVersion || isDisabled(node) || isLeaf(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
const normalizedKey = keyString(key);
if (visited.has(normalizedKey)) {
return;
}
visited.add(normalizedKey);
expandedKeys.push(key);
const result = await loadNode(node);
if (operationVersion !== bulkOperationVersion || result.cancelled) {
return;
}
if (result.failed) {
failedKeys.push(key);
return;
}
state.renderedLimits[normalizedKey] = result.children.length;
nextLevel.push(...result.children);
});
currentLevel = nextLevel;
}
const cancelled = operationVersion !== bulkOperationVersion;
if (!cancelled) {
setExpandedKeys(expandedKeys);
}
return {
expandedKeys: cancelled ? [...state.expandedKeys] : expandedKeys,
failedKeys,
cancelled,
};
})();
activeBulkExpansion = expansion;
void expansion.finally(() => {
if (activeBulkExpansion === expansion) {
activeBulkExpansion = null;
}
if (operationVersion === bulkOperationVersion) {
bulkExpanding.value = false;
}
});
return expansion;
};
const collapseAll = () => {
bulkOperationVersion += 1;
activeBulkExpansion = null;
bulkExpanding.value = false;
state.renderedLimits = {};
setExpandedKeys([]);
};
const ensureChildrenLoadedForCheck = async (node: TreeNode) => {
@@ -422,7 +312,9 @@ const handleNodeClick = async (node: TreeNode) => {
emit("select", node, key);
} else if (props.selectionMode === "multiple") {
const selected = Array.isArray(state.selected) ? state.selected : [];
state.selected = selected.includes(key) ? selected.filter((current) => current !== key) : [...selected, key];
state.selected = selected.includes(key)
? selected.filter((current) => current !== key)
: [...selected, key];
emit("update:selected", state.selected);
emit("select", node, key);
}
@@ -431,49 +323,6 @@ const handleNodeClick = async (node: TreeNode) => {
}
};
watch(
() => props.data,
() => {
dataVersion += 1;
bulkOperationVersion += 1;
activeBulkExpansion = null;
bulkExpanding.value = false;
inFlightLoads.clear();
state.lazyChildrenCache = {};
state.loadingKeys = [];
state.loadErrorKeys = [];
state.renderedLimits = {};
setExpandedKeys([]);
if (props.defaultExpandAll) {
void nextTick(() => expandAll());
}
},
{ immediate: true }
);
watch(
() => props.defaultExpandAll,
(shouldExpand, wasExpanded) => {
if (shouldExpand && !wasExpanded) {
void expandAll();
}
}
);
defineExpose({
bulkExpanding: readonly(bulkExpanding),
collapseAll,
expandAll,
});
onBeforeUnmount(() => {
dataVersion += 1;
bulkOperationVersion += 1;
activeBulkExpansion = null;
bulkExpanding.value = false;
inFlightLoads.clear();
});
provide("BuefyTreeContext", {
props,
state,
@@ -498,7 +347,7 @@ provide("BuefyTreeContext", {
</script>
<template>
<ul class="b-tree" role="tree" :aria-label="ariaLabel" :aria-busy="bulkExpanding || undefined">
<ul class="b-tree" role="tree" :aria-label="ariaLabel">
<BuefyTreeNode
v-for="(node, index) in data"
:key="keyOf(node) ?? index"
+1 -2
View File
@@ -96,7 +96,6 @@ onBeforeUnmount(disconnectLoadMoreObserver);
>
<div class="b-tree-node-content" @click.stop="tree.handleNodeClick(node)">
<button
v-if="node.hideToggle !== true"
type="button"
class="button is-white is-small b-tree-node-toggle"
:class="{ 'is-invisible': !hasExpandToggle }"
@@ -123,7 +122,7 @@ onBeforeUnmount(disconnectLoadMoreObserver);
/>
</span>
<span v-if="node.hideIcon !== true" class="b-tree-node-icon">
<span class="b-tree-node-icon">
<slot name="icon" :node="node" :expanded="isExpanded" :loading="isLoading" :error="hasLoadError">
<span class="icon is-small">
<i class="fas" :class="hasExpandToggle ? (isExpanded ? 'fa-folder-open' : 'fa-folder') : 'fa-file'"></i>
@@ -62,8 +62,6 @@ const onInput = async (value) => {
await props.changeAction(nextValue);
}
root.value?.dispatchEvent(new CustomEvent("dropdown-action-selected", { bubbles: true }));
} catch {
localValue.value = normalizeSelectValue(props.value);
} finally {
isProcessing.value = false;
}
@@ -6,17 +6,20 @@ import { StripeModule } from "@/components/stripe/StripeModule.vue";
import { Colors } from "@/ThemeConfig.vue";
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
import PrintInvoiceFromOrderItems from "@/components/forms/department/pos/buttons/PrintInvoiceFromOrderItems.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import StripeTerminalReaderSelect from "@/components/displays/department/pos/displays/StripeTerminalReaderSelect.vue";
import { getOrder } from "@/components/shop/Orders.vue";
import {
getStripeTerminalReaderById,
normalizeStripeTerminalReaders,
selectPreferredStripeTerminalReaderId,
STRIPE_TERMINAL_STATUS,
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
const POLLING_INTERVAL_MS = 5000;
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
const CARD_PAYMENT_TAX_PERCENTAGE = 25;
const STRIPE_EMAIL_POLLING_INTERVAL_OVERRIDE_KEY = '__TW_POS_STRIPE_EMAIL_POLLING_INTERVAL_MS__';
const props = defineProps({
departmentId: {
@@ -47,6 +50,16 @@ const props = defineProps({
const { t } = useI18n();
const stripeText = (key, params = {}) => t(`admin.pos.stripe.${key}`, params);
const getStripeEmailPollingIntervalMs = () => {
if (typeof window === 'undefined') {
return POLLING_INTERVAL_MS;
}
const override = Number(window[STRIPE_EMAIL_POLLING_INTERVAL_OVERRIDE_KEY]);
return Number.isFinite(override) && override > 0
? override
: POLLING_INTERVAL_MS;
};
const readers = ref([]);
const readersError = ref(null);
@@ -58,15 +71,229 @@ const selectedReaderId = ref('');
const loadingDeleteButton = ref(false);
const isNotifyingPaymentSuccess = ref(false);
const notifiedPaymentIntentId = ref(null);
const autoCapturePaymentIntentId = ref(null);
const operationState = ref('idle');
const pollingEnabled = ref(true);
const stripeInvoice = ref(null);
const emailAddress = ref('');
const emailPanelState = ref('idle');
const emailPaymentError = ref(null);
const isCompletingPaidEmailOrder = ref(false);
const taxRates = computed(() => ([
{ id: 1, display_name: stripeText('tax_rates.danish_customer'), percentage: 25 },
{ id: 2, display_name: stripeText('tax_rates.eu_customer'), percentage: 0 },
]));
const selectedTaxRate = ref(1);
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
const shouldShowDesktopCompletion = computed(() => isTerminalPaymentCaptured.value);
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
const isStripeEmailInvoiceTerminalState = computed(() => {
const status = String(stripeInvoice.value?.status ?? '');
return ['paid', 'void', 'uncollectible', 'deleted'].includes(status);
});
const isEmailFormVisible = computed(() => {
return ['composing', 'sending'].includes(emailPanelState.value) && !hasStripeEmailInvoice.value;
});
const isSendingEmailPayment = computed(() => emailPanelState.value === 'sending');
const isCancellingEmailPayment = computed(() => emailPanelState.value === 'cancelling');
const shouldShowEmailActionButton = computed(() => emailPanelState.value === 'idle' && !hasStripeEmailInvoice.value);
const canCancelEmailPayment = computed(() => {
if (isSendingEmailPayment.value || isCancellingEmailPayment.value) {
return false;
}
if (hasStripeEmailInvoice.value) {
return !isStripeEmailInvoicePaid.value && !isStripeEmailInvoiceTerminalState.value;
}
return emailPanelState.value === 'composing';
});
const canCompletePaidEmailOrder = computed(() => {
return props.mobileMode && isStripeEmailInvoicePaid.value && typeof props.onPaymentSuccess === 'function';
});
const stripeEmailStatusToneClass = computed(() => {
switch (String(stripeInvoice.value?.status ?? 'unknown')) {
case 'paid':
return 'is-success';
case 'open':
case 'draft':
return 'is-warning';
case 'void':
case 'uncollectible':
case 'deleted':
return 'is-light';
default:
return 'is-light';
}
});
const stripeEmailStatusMessage = computed(() => {
if (!hasStripeEmailInvoice.value) {
return stripeText('email.help');
}
if (isStripeEmailInvoicePaid.value) {
return stripeText('email.messages.paid');
}
if (isStripeEmailInvoiceTerminalState.value) {
return stripeText('email.messages.closed');
}
return stripeText('email.messages.sent');
});
const shouldShowDesktopCompletion = computed(() => {
return isTerminalPaymentCaptured.value || isStripeEmailInvoicePaid.value;
});
const shouldShowDesktopReceipt = computed(() => isTerminalPaymentCaptured.value);
const getStripeEmailStatusLabel = () => {
switch (String(stripeInvoice.value?.status ?? 'unknown')) {
case 'open':
return stripeText('email.states.open');
case 'paid':
return stripeText('email.states.paid');
case 'void':
return stripeText('email.states.void');
case 'uncollectible':
return stripeText('email.states.uncollectible');
case 'deleted':
return stripeText('email.states.deleted');
case 'draft':
return stripeText('email.states.draft');
default:
return stripeText('email.states.unknown');
}
};
const isValidEmailAddress = (value) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value ?? '').trim());
};
const loadStripeInvoiceState = async () => {
if (!props.order_id) {
stripeInvoice.value = null;
return null;
}
try {
const response = await getOrder(props.order_id, true);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id);
stripeInvoice.value = nextInvoice;
if (nextInvoice) {
emailPanelState.value = 'tracking';
} else if (emailPanelState.value === 'tracking') {
emailPanelState.value = 'idle';
}
emailPaymentError.value = null;
return nextInvoice;
} catch (requestError) {
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.load');
return stripeInvoice.value;
}
};
const openEmailPaymentPanel = () => {
emailPaymentError.value = null;
emailPanelState.value = 'composing';
};
const resetEmailPaymentPanel = () => {
stripeInvoice.value = null;
emailAddress.value = '';
emailPaymentError.value = null;
emailPanelState.value = 'idle';
isCompletingPaidEmailOrder.value = false;
};
const cancelEmailPayment = async () => {
if (!hasStripeEmailInvoice.value) {
emailAddress.value = '';
emailPaymentError.value = null;
emailPanelState.value = 'idle';
return;
}
emailPanelState.value = 'cancelling';
emailPaymentError.value = null;
try {
await authenticatedRequest('/modules/stripe/invoice', 'DELETE', {
order_id: props.order_id,
});
resetEmailPaymentPanel();
} catch (requestError) {
const responseData = requestError?.response?.data?.data || {};
stripeInvoice.value = normalizeStripeInvoice(responseData?.stripeModuleOrders) || stripeInvoice.value;
emailPanelState.value = 'tracking';
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.cancel');
}
};
const sendStripeInvoiceByEmail = async () => {
const normalizedEmail = String(emailAddress.value ?? '').trim();
if (!isValidEmailAddress(normalizedEmail)) {
emailPaymentError.value = stripeText('email.errors.invalid_email');
emailPanelState.value = 'composing';
return;
}
emailPanelState.value = 'sending';
emailPaymentError.value = null;
try {
const response = await authenticatedRequest('/modules/stripe/invoice', 'POST', {
email: normalizedEmail,
order_id: props.order_id,
});
emailAddress.value = '';
const refreshedInvoice = await loadStripeInvoiceState();
if (!refreshedInvoice) {
stripeInvoice.value = normalizeStripeInvoice({
id: props.order_id,
invoice_id: response?.data?.data?.id ?? null,
customer_id: response?.data?.data?.customer ?? null,
url: response?.data?.data?.hosted_invoice_url ?? null,
paid: Boolean(response?.data?.data?.paid),
status: response?.data?.data?.status ?? 'open',
amount_due: response?.data?.data?.amount_due ?? 0,
amount_paid: response?.data?.data?.amount_paid ?? 0,
});
emailPanelState.value = stripeInvoice.value ? 'tracking' : 'idle';
}
} catch (requestError) {
const responseData = requestError?.response?.data?.data || {};
if (requestError?.response?.status === 409 && responseData?.code === 'stripe_invoice_exists') {
stripeInvoice.value = normalizeStripeInvoice(responseData?.stripeModuleOrders) || await loadStripeInvoiceState();
emailPanelState.value = stripeInvoice.value ? 'tracking' : 'idle';
emailPaymentError.value = stripeText('email.messages.active');
return;
}
emailPanelState.value = 'composing';
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.send');
}
};
const completePaidEmailOrder = async () => {
if (!canCompletePaidEmailOrder.value) {
return;
}
isCompletingPaidEmailOrder.value = true;
emailPaymentError.value = null;
try {
await Promise.resolve(props.onPaymentSuccess(stripeInvoice.value));
} catch (requestError) {
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.complete');
} finally {
isCompletingPaidEmailOrder.value = false;
}
};
const normalizeErrorState = (errorLike, fallbackMessage, source) => {
const responsePayload = errorLike?.response?.data?.data;
const directPayload = errorLike?.data?.data ?? errorLike?.data;
@@ -88,6 +315,11 @@ const normalizeErrorState = (errorLike, fallbackMessage, source) => {
};
};
const getTaxRatePercentage = (taxRateId) => {
const taxRate = taxRates.value.find((rate) => rate.id === taxRateId);
return taxRate ? taxRate.percentage : 0;
};
const clearErrorState = (...sources) => {
const sourceSet = new Set(sources);
if (sourceSet.has('readers')) {
@@ -125,7 +357,7 @@ const paymentIntentReaderId = computed(() => {
const paymentIntentTaxPercentage = computed(() => {
const value = paymentIntent.value?.metadata?.tax_percentage;
return value === undefined || value === null
? CARD_PAYMENT_TAX_PERCENTAGE
? getTaxRatePercentage(selectedTaxRate.value)
: Number(value);
});
@@ -257,7 +489,18 @@ const selectedReaderLabel = computed(() => {
return stripeText('reader.no_selection');
});
const formatTaxRateLabel = (taxRate) => {
return stripeText('tax_rates.option', {
name: taxRate.display_name,
percentage: taxRate.percentage,
});
};
const paymentTaxSummary = computed(() => {
const matchingRate = taxRates.value.find((rate) => Number(rate.percentage) === Number(paymentIntentTaxPercentage.value));
if (matchingRate) {
return formatTaxRateLabel(matchingRate);
}
return stripeText('tax_rates.fallback', { percentage: paymentIntentTaxPercentage.value });
});
@@ -276,7 +519,9 @@ const primaryActionLabel = computed(() => {
case 'waiting_for_reader':
return stripeText('actions.refresh_payment_status');
case 'ready_to_capture':
return stripeText('states.capturing');
return paymentAmountSummary.value
? stripeText('actions.capture_payment_with_amount', { amount: paymentAmountSummary.value })
: stripeText('actions.capture_payment');
case 'succeeded':
return stripeText('actions.show_payment_details');
case 'failed':
@@ -297,8 +542,9 @@ const primaryActionTestId = computed(() => {
case 'reader_unavailable':
return 'pos-stripe-no-readers';
case 'waiting_for_reader':
case 'ready_to_capture':
return 'pos-stripe-payment-in-progress';
case 'ready_to_capture':
return 'pos-stripe-capture-intent';
case 'succeeded':
return 'pos-stripe-payment-succeeded';
case 'failed':
@@ -324,6 +570,7 @@ const canCancelPaymentIntent = computed(() => {
});
const shouldShowReaderSelection = computed(() => paymentIntent.value === null);
const shouldShowTaxSelection = computed(() => paymentIntent.value === null);
const attemptAutomaticReaderSelection = (readerList) => {
selectedReaderId.value = selectPreferredStripeTerminalReaderId(
@@ -410,7 +657,7 @@ const onClickCreatePaymentIntent = async () => {
const response = await StripeModule.paymentIntents.createPaymentIntent(
selectedReader.value.reader,
props.order_id,
CARD_PAYMENT_TAX_PERCENTAGE,
getTaxRatePercentage(selectedTaxRate.value),
);
if (response?.status !== 200) {
@@ -436,7 +683,6 @@ const onClickCapturePaymentIntent = async () => {
try {
const response = await StripeModule.paymentIntents.capturePaymentIntent(props.order_id);
if (response?.status !== 200) {
autoCapturePaymentIntentId.value = null;
actionError.value = normalizeErrorState(response, stripeText('errors.capture_payment_intent'), 'action');
return;
}
@@ -444,7 +690,6 @@ const onClickCapturePaymentIntent = async () => {
clearErrorState('payment');
await getStripeReaders();
} catch (requestError) {
autoCapturePaymentIntentId.value = null;
actionError.value = normalizeErrorState(requestError, stripeText('errors.capture_payment_intent'), 'action');
console.error('Error capturing payment intent:', requestError);
} finally {
@@ -510,6 +755,7 @@ const onPrimaryAction = async () => {
await retryLoadState();
return;
case 'ready_to_capture':
await onClickCapturePaymentIntent();
return;
case 'succeeded':
showPaymentDetails(paymentIntent.value);
@@ -550,38 +796,26 @@ const maybeNotifyPaymentSuccess = async (stripePaymentIntent) => {
watch(paymentIntent, (nextPaymentIntent) => {
if (!nextPaymentIntent) {
notifiedPaymentIntentId.value = null;
autoCapturePaymentIntentId.value = null;
return;
}
maybeNotifyPaymentSuccess(nextPaymentIntent);
}, { deep: true });
watch([currentIntentState, operationState, currentError], ([nextState]) => {
if (
nextState !== 'ready_to_capture'
|| operationState.value !== 'idle'
|| currentError.value
|| !paymentIntent.value?.id
|| autoCapturePaymentIntentId.value === paymentIntent.value.id
) {
return;
}
autoCapturePaymentIntentId.value = paymentIntent.value.id;
onClickCapturePaymentIntent();
}, { immediate: true });
watch(() => props.order_id, async () => {
clearErrorState('readers', 'payment', 'action');
operationState.value = 'idle';
pollingEnabled.value = true;
notifiedPaymentIntentId.value = null;
autoCapturePaymentIntentId.value = null;
await syncCurrentState();
resetEmailPaymentPanel();
await Promise.all([
syncCurrentState(),
loadStripeInvoiceState(),
]);
});
let pollingIntervalId = null;
let emailPollingIntervalId = null;
const clearPollingInterval = () => {
if (pollingIntervalId !== null) {
@@ -590,6 +824,13 @@ const clearPollingInterval = () => {
}
};
const clearEmailPollingInterval = () => {
if (emailPollingIntervalId !== null) {
window.clearInterval(emailPollingIntervalId);
emailPollingIntervalId = null;
}
};
const shouldPoll = computed(() => {
return pollingEnabled.value
&& paymentFlowState.value !== 'succeeded'
@@ -613,6 +854,25 @@ watch(shouldPoll, (nextShouldPoll) => {
}, POLLING_INTERVAL_MS);
}, { immediate: true });
const shouldPollStripeEmailInvoice = computed(() => {
return hasStripeEmailInvoice.value
&& emailPanelState.value === 'tracking'
&& !isStripeEmailInvoiceTerminalState.value
&& !isCancellingEmailPayment.value;
});
watch(shouldPollStripeEmailInvoice, (nextShouldPoll) => {
clearEmailPollingInterval();
if (!nextShouldPoll) {
return;
}
emailPollingIntervalId = window.setInterval(() => {
loadStripeInvoiceState();
}, getStripeEmailPollingIntervalMs());
}, { immediate: true });
watch(paymentFlowState, (nextState) => {
if (nextState === 'succeeded') {
pollingEnabled.value = false;
@@ -622,11 +882,15 @@ watch(paymentFlowState, (nextState) => {
onMounted(async () => {
pollingEnabled.value = true;
await syncCurrentState();
await Promise.all([
syncCurrentState(),
loadStripeInvoiceState(),
]);
});
onUnmounted(() => {
clearPollingInterval();
clearEmailPollingInterval();
});
</script>
@@ -667,6 +931,19 @@ onUnmounted(() => {
/>
</div>
<div v-if="shouldShowTaxSelection" class="field">
<label class="label is-size-7">{{ stripeText('tax_profile') }}</label>
<div class="select is-fullwidth">
<select v-model="selectedTaxRate" data-testid="pos-stripe-tax-select">
<template v-for="taxRate in taxRates" :key="taxRate.id">
<option :value="taxRate.id">
{{ formatTaxRateLabel(taxRate) }}
</option>
</template>
</select>
</div>
</div>
<div v-if="currentErrorMessage" class="notification is-danger is-light" data-testid="pos-stripe-error-message">
{{ currentErrorMessage }}
</div>
@@ -700,6 +977,123 @@ onUnmounted(() => {
{{ stripeText('actions.cancel_payment') }}
</button>
<div class="mobile-stripe-payment__email">
<div class="pos-stripe-email-panel" data-testid="pos-stripe-email-panel">
<button
v-if="shouldShowEmailActionButton"
class="button is-fullwidth pos-stripe-email-panel__trigger"
type="button"
data-testid="pos-stripe-email-action"
@click="openEmailPaymentPanel"
>
<span class="icon is-small">
<i class="fas fa-envelope"></i>
</span>
<span>{{ stripeText('email.action') }}</span>
</button>
<div v-else class="pos-stripe-email-panel__body">
<div class="pos-stripe-email-panel__header">
<div>
<p class="pos-stripe-email-panel__eyebrow">{{ stripeText('email.eyebrow') }}</p>
<p class="pos-stripe-email-panel__title">{{ stripeText('email.title') }}</p>
</div>
<span
v-if="hasStripeEmailInvoice"
class="tag pos-stripe-email-panel__status-tag"
:class="stripeEmailStatusToneClass"
data-testid="pos-stripe-email-status"
>
{{ getStripeEmailStatusLabel() }}
</span>
</div>
<p class="pos-stripe-email-panel__help">
{{ stripeEmailStatusMessage }}
</p>
<div v-if="isEmailFormVisible" class="pos-stripe-email-panel__form">
<label class="label is-size-7" for="pos-stripe-email-input-mobile">
{{ stripeText('email.address_label') }}
</label>
<div class="field has-addons pos-stripe-email-panel__field">
<div class="control is-expanded">
<input
id="pos-stripe-email-input-mobile"
v-model="emailAddress"
class="input"
type="email"
:placeholder="stripeText('email.address_placeholder')"
autocomplete="email"
data-testid="pos-stripe-email-input"
@keydown.enter.prevent="sendStripeInvoiceByEmail"
>
</div>
<div class="control">
<button
class="button is-link"
type="button"
data-testid="pos-stripe-email-submit"
:class="{ 'is-loading': isSendingEmailPayment }"
:disabled="isSendingEmailPayment"
@click="sendStripeInvoiceByEmail"
>
{{ stripeText('email.submit') }}
</button>
</div>
</div>
</div>
<div v-else-if="hasStripeEmailInvoice" class="pos-stripe-email-panel__meta">
<div class="pos-stripe-email-panel__meta-row">
<span>{{ stripeText('email.status_label') }}</span>
<strong>{{ getStripeEmailStatusLabel() }}</strong>
</div>
<div v-if="stripeInvoice?.url" class="pos-stripe-email-panel__meta-row">
<span>{{ stripeText('email.invoice_link_label') }}</span>
<a :href="stripeInvoice.url" target="_blank" rel="noopener noreferrer">
{{ stripeText('email.invoice_link_action') }}
</a>
</div>
</div>
<p
v-if="emailPaymentError"
class="help is-danger pos-stripe-email-panel__error"
data-testid="pos-stripe-email-error"
>
{{ emailPaymentError }}
</p>
<div
v-if="canCancelEmailPayment || canCompletePaidEmailOrder"
class="pos-stripe-email-panel__actions"
>
<button
v-if="canCancelEmailPayment"
class="button is-light is-fullwidth"
type="button"
data-testid="pos-stripe-email-cancel"
:class="{ 'is-loading': isCancellingEmailPayment }"
@click="cancelEmailPayment"
>
{{ t('common.cancel') }}
</button>
<button
v-if="canCompletePaidEmailOrder"
class="button is-success is-fullwidth"
type="button"
data-testid="pos-stripe-email-complete-order"
:class="{ 'is-loading': isCompletingPaidEmailOrder }"
@click="completePaidEmailOrder"
>
{{ t('admin.pos.complete') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -759,6 +1153,20 @@ onUnmounted(() => {
</div>
<template v-else>
<template v-if="shouldShowTaxSelection">
<div class="pos-stripe-desktop__item" data-testid="pos-stripe-desktop-tax-field">
<div class="select is-fullwidth">
<select v-model="selectedTaxRate" data-testid="pos-stripe-tax-select">
<template v-for="taxRate in taxRates" :key="taxRate.id">
<option :value="taxRate.id">
{{ formatTaxRateLabel(taxRate) }}
</option>
</template>
</select>
</div>
</div>
</template>
<template v-if="shouldShowReaderSelection">
<div class="pos-stripe-desktop__item" data-testid="pos-stripe-desktop-reader-field">
<StripeTerminalReaderSelect
@@ -814,6 +1222,21 @@ onUnmounted(() => {
</button>
</template>
<template v-else-if="paymentFlowState === 'ready_to_capture'">
<button
class="button is-fullwidth"
type="button"
data-testid="pos-stripe-capture-intent"
:style="{ 'background-color': Colors.buttons.success.backgroundColor, 'color': Colors.buttons.success.textColor }"
@click="onClickCapturePaymentIntent"
>
<span class="icon is-small">
<i class="fas fa-credit-card"></i>
</span>
<span>{{ primaryActionLabel }}</span>
</button>
</template>
<template v-else-if="paymentFlowState === 'succeeded'">
<button
class="button is-text is-fullwidth pos-stripe-desktop__text-button"
@@ -866,6 +1289,124 @@ onUnmounted(() => {
</template>
</template>
<div class="pos-stripe-desktop__item">
<div class="pos-stripe-email-panel" data-testid="pos-stripe-email-panel">
<button
v-if="shouldShowEmailActionButton"
class="button is-fullwidth pos-stripe-email-panel__trigger"
type="button"
data-testid="pos-stripe-email-action"
@click="openEmailPaymentPanel"
>
<span class="icon is-small">
<i class="fas fa-envelope"></i>
</span>
<span>{{ stripeText('email.action') }}</span>
</button>
<div v-else class="pos-stripe-email-panel__body">
<div class="pos-stripe-email-panel__header">
<div>
<p class="pos-stripe-email-panel__eyebrow">{{ stripeText('email.eyebrow') }}</p>
<p class="pos-stripe-email-panel__title">{{ stripeText('email.title') }}</p>
</div>
<span
v-if="hasStripeEmailInvoice"
class="tag pos-stripe-email-panel__status-tag"
:class="stripeEmailStatusToneClass"
data-testid="pos-stripe-email-status"
>
{{ getStripeEmailStatusLabel() }}
</span>
</div>
<p class="pos-stripe-email-panel__help">
{{ stripeEmailStatusMessage }}
</p>
<div v-if="isEmailFormVisible" class="pos-stripe-email-panel__form">
<label class="label is-size-7" for="pos-stripe-email-input-desktop">
{{ stripeText('email.address_label') }}
</label>
<div class="field has-addons pos-stripe-email-panel__field">
<div class="control is-expanded">
<input
id="pos-stripe-email-input-desktop"
v-model="emailAddress"
class="input"
type="email"
:placeholder="stripeText('email.address_placeholder')"
autocomplete="email"
data-testid="pos-stripe-email-input"
@keydown.enter.prevent="sendStripeInvoiceByEmail"
>
</div>
<div class="control">
<button
class="button is-link"
type="button"
data-testid="pos-stripe-email-submit"
:class="{ 'is-loading': isSendingEmailPayment }"
:disabled="isSendingEmailPayment"
@click="sendStripeInvoiceByEmail"
>
{{ stripeText('email.submit') }}
</button>
</div>
</div>
</div>
<div v-else-if="hasStripeEmailInvoice" class="pos-stripe-email-panel__meta">
<div class="pos-stripe-email-panel__meta-row">
<span>{{ stripeText('email.status_label') }}</span>
<strong>{{ getStripeEmailStatusLabel() }}</strong>
</div>
<div v-if="stripeInvoice?.url" class="pos-stripe-email-panel__meta-row">
<span>{{ stripeText('email.invoice_link_label') }}</span>
<a :href="stripeInvoice.url" target="_blank" rel="noopener noreferrer">
{{ stripeText('email.invoice_link_action') }}
</a>
</div>
</div>
<p
v-if="emailPaymentError"
class="help is-danger pos-stripe-email-panel__error"
data-testid="pos-stripe-email-error"
>
{{ emailPaymentError }}
</p>
<div
v-if="canCancelEmailPayment || canCompletePaidEmailOrder"
class="pos-stripe-email-panel__actions"
>
<button
v-if="canCancelEmailPayment"
class="button is-light is-fullwidth"
type="button"
data-testid="pos-stripe-email-cancel"
:class="{ 'is-loading': isCancellingEmailPayment }"
@click="cancelEmailPayment"
>
{{ t('common.cancel') }}
</button>
<button
v-if="canCompletePaidEmailOrder"
class="button is-success is-fullwidth"
type="button"
data-testid="pos-stripe-email-complete-order"
:class="{ 'is-loading': isCompletingPaidEmailOrder }"
@click="completePaidEmailOrder"
>
{{ t('admin.pos.complete') }}
</button>
</div>
</div>
</div>
</div>
<template v-if="shouldShowDesktopCompletion">
<div class="pos-stripe-desktop__item">
<NextStep class="is-fullwidth" :label="t('admin.pos.complete')" />
@@ -943,6 +1484,10 @@ onUnmounted(() => {
margin-top: 0.5rem;
}
.mobile-stripe-payment__email {
margin-top: 0.75rem;
}
.select.is-fullwidth,
.select.is-fullwidth select {
width: 100%;
@@ -1006,4 +1551,92 @@ onUnmounted(() => {
justify-content: center;
}
.pos-stripe-email-panel {
width: 100%;
}
.pos-stripe-email-panel__trigger {
font-weight: 700;
}
.pos-stripe-email-panel__body {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 16px;
padding: 0.85rem;
background: rgba(248, 250, 252, 0.9);
}
.pos-stripe-email-panel__header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
}
.pos-stripe-email-panel__eyebrow {
margin: 0;
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6b7280;
}
.pos-stripe-email-panel__title {
margin: 0.15rem 0 0;
font-size: 1rem;
font-weight: 700;
color: #0f172a;
}
.pos-stripe-email-panel__status-tag {
margin-left: auto;
}
.pos-stripe-email-panel__help {
margin: 0.65rem 0 0;
color: #475569;
line-height: 1.45;
}
.pos-stripe-email-panel__form,
.pos-stripe-email-panel__meta,
.pos-stripe-email-panel__actions {
margin-top: 0.75rem;
}
.pos-stripe-email-panel__field {
margin-bottom: 0;
}
.pos-stripe-email-panel__meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.pos-stripe-email-panel__meta-row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
font-size: 0.92rem;
}
.pos-stripe-email-panel__meta-row span {
color: #64748b;
}
.pos-stripe-email-panel__meta-row a {
font-weight: 600;
}
.pos-stripe-email-panel__error {
margin-top: 0.75rem;
}
.pos-stripe-email-panel__actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>
@@ -506,12 +506,4 @@ onBeforeUnmount(() => {
.stripe-terminal-select--mobile .stripe-terminal-select__trigger {
min-height: 3rem;
}
.stripe-terminal-select--mobile {
flex-direction: column;
}
.stripe-terminal-select--mobile .stripe-terminal-select__menu {
position: static;
}
</style>
@@ -2,7 +2,7 @@
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { buildAuditedOrderItemReasonPayload, editOrderItem } from "@/components/shop/OrdersItems.vue";
import { editOrderItem } from "@/components/shop/OrdersItems.vue";
const props = defineProps({
modelValue: {
@@ -85,12 +85,7 @@ const saveChanges = async () => {
Number(form.price),
form.notes,
form.reference,
Number(form.quantity),
buildAuditedOrderItemReasonPayload(props.orderItem.product_id ?? props.orderItem.product?.id, form.notes, {
reason_code: props.orderItem.reason_code,
reason_label_snapshot: props.orderItem.reason_label_snapshot,
reason_comment: form.notes,
})
Number(form.quantity)
);
emits('saved');
} catch (error) {
@@ -12,10 +12,6 @@ import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/Invoi
import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js";
import SuperuserInvoiceRowActions from "@/components/displays/superuser/tables/SuperuserInvoiceRowActions.vue";
import { useLargeTableHeaders } from "@/services/tableHeaderPreferences.js";
import {
normalizeInvoiceCollectionActionPreview,
renderInvoiceCollectionActionPreviewHtml,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoiceCollectionActionPreview.ts";
const props = defineProps({
orders: {
@@ -222,6 +218,14 @@ const normalizeInvoiceCollectionId = (invoiceCollectionId) => {
const getApiPayload = (response) => response?.data?.data ?? response?.data ?? response ?? {};
const escapeHtml = (value) =>
String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const getInvoiceCollectionResponseOrders = (response) => {
if (Array.isArray(response?.orders)) {
return response.orders;
@@ -499,11 +503,84 @@ const selectableInvoiceCollectionCount = computed(() => getUniqueInvoiceCollecti
const getBulkActionLabel = (action) => t(`invoicing_period.invoice_collection_actions.actions.${action}`);
const renderBulkActionPreviewHtml = (preview) => {
return renderInvoiceCollectionActionPreviewHtml({
preview: normalizeInvoiceCollectionActionPreview(preview, getSelectedInvoiceCollectionIds().length),
t,
formatCurrency: (value) => SessionUser.functions.currency.toLocal(value),
});
const summary = preview?.summary || {};
const collections = Number(summary.collections ?? preview?.invoice_collection_ids?.length ?? 0);
const changedCount = Number(summary.changed_count ?? summary.order_items ?? summary.orders_to_move ?? 0);
const skippedCount = Number(summary.skipped_count ?? 0);
const blockers = Array.isArray(preview?.blockers) ? preview.blockers : [];
const orderItems = Array.isArray(preview?.order_items) ? preview.order_items : [];
const orders = Array.isArray(preview?.orders) ? preview.orders : [];
const splitItems = Array.isArray(preview?.items) ? preview.items : [];
const detailItems = [
...orderItems.map((item) =>
t("invoicing_period.invoice_collection_actions.preview.order_item_line", {
collection: item.invoice_collection_id,
order: item.order_id,
product: item.product_name || item.product_id,
})
),
...orders.map((item) =>
t("invoicing_period.invoice_collection_actions.preview.order_move_line", {
order: item.order_id,
source: item.source_invoice_collection_id,
target: item.target_invoice_collection_id,
})
),
...splitItems.map((item) =>
t("invoicing_period.invoice_collection_actions.preview.collection_status_line", {
collection: item.invoice_collection_id,
status: item.status,
})
),
].slice(0, 10);
const summaryLines = [
t("invoicing_period.invoice_collection_actions.preview.collections", { count: collections }),
t("invoicing_period.invoice_collection_actions.preview.changed", { count: changedCount }),
...(skippedCount > 0
? [t("invoicing_period.invoice_collection_actions.preview.skipped", { count: skippedCount })]
: []),
...(preview?.target_invoice_collection_id
? [
t("invoicing_period.invoice_collection_actions.preview.merge_target", {
id: preview.target_invoice_collection_id,
}),
]
: []),
];
const html = [
`<p>${escapeHtml(
t("invoicing_period.invoice_collection_actions.preview.requires_confirmation", {
phrase: preview?.confirmation_phrase || "",
})
)}</p>`,
`<ul class="has-text-left">${summaryLines.map((line) => `<li>${escapeHtml(line)}</li>`).join("")}</ul>`,
];
if (detailItems.length > 0) {
html.push(
`<hr><p class="has-text-left has-text-weight-bold">${escapeHtml(
t("invoicing_period.invoice_collection_actions.preview.affected_examples")
)}</p>`
);
html.push(`<ul class="has-text-left">${detailItems.map((line) => `<li>${escapeHtml(line)}</li>`).join("")}</ul>`);
}
if (blockers.length > 0) {
html.push(
`<hr><p class="has-text-left has-text-weight-bold has-text-danger">${escapeHtml(
t("invoicing_period.invoice_collection_actions.preview.blockers")
)}</p>`
);
html.push(
`<ul class="has-text-left">${blockers
.map((blocker) => `<li>${escapeHtml(blocker.message || blocker.code)}</li>`)
.join("")}</ul>`
);
}
return html.join("");
};
const chooseMergeTargetInvoiceCollection = async (invoiceCollectionIds) => {
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from "vue";
import { computed, ref } from 'vue';
import Swal from "sweetalert2";
import PayWithStripeButton from "@/components/displays/department/pos/displays/PayWithStripeButton.vue";
import {
@@ -22,9 +22,6 @@ import {
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import { finalizeCurrentMobileOrder } from "@/components/displays/department/pos/steps/mobile/objects/mobileOrderCompletion.js";
const SUCCESS_RESET_DELAY_MS = 3000;
const SUCCESS_RESET_DELAY_OVERRIDE_KEY = "__TW_POS_MOBILE_SUCCESS_RESET_DELAY_MS__";
const isCompleting = ref(false);
const completionError = ref(null);
const completedOrderId = ref(null);
@@ -40,12 +37,8 @@ const normalizedDepartmentId = computed(() => {
});
const currentPaymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const paymentIntentState = computed(() =>
StripeModule.paymentIntents.getPaymentIntentState(currentPaymentIntent.value)
);
const hasActivePaymentIntent = computed(() =>
StripeModule.paymentIntents.isPaymentIntentActive(currentPaymentIntent.value)
);
const paymentIntentState = computed(() => StripeModule.paymentIntents.getPaymentIntentState(currentPaymentIntent.value));
const hasActivePaymentIntent = computed(() => StripeModule.paymentIntents.isPaymentIntentActive(currentPaymentIntent.value));
const orderItemsTotal = computed(() => {
return (Array.isArray(order_items.value) ? order_items.value : []).reduce((sum, item) => {
@@ -63,23 +56,18 @@ const mobileOrderTotal = computed(() => {
return orderItemsTotal.value;
});
const orderReference = computed(() => metadata.getReference?.() || "");
const orderNotes = computed(() => metadata.getNotes?.() || "");
const orderRegistration = computed(() => reg_1.value || "Unknown vehicle");
const isBackDisabled = computed(() => isCompleting.value || paymentIntentState.value === "succeeded");
const orderReference = computed(() => metadata.getReference?.() || '');
const orderNotes = computed(() => metadata.getNotes?.() || '');
const orderRegistration = computed(() => reg_1.value || 'Unknown vehicle');
const isBackDisabled = computed(() => isCompleting.value || paymentIntentState.value === 'succeeded');
const formatCurrency = (amount) => {
return new Intl.NumberFormat("da-DK", {
style: "currency",
currency: "DKK",
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
}).format(Number(amount || 0));
};
const getSuccessResetDelayMs = () => {
const override = Number(window[SUCCESS_RESET_DELAY_OVERRIDE_KEY]);
return Number.isFinite(override) && override >= 0 ? override : SUCCESS_RESET_DELAY_MS;
};
const navigateToItems = () => {
setStep(2);
if (normalizedOrderId.value) {
@@ -95,7 +83,7 @@ const clearSuccessfulMobileCardFlow = () => {
order_id.value = 0;
popups.clear();
resetPos();
}, getSuccessResetDelayMs());
}, 3000);
};
const completeMobileCardPayment = async () => {
@@ -112,21 +100,21 @@ const completeMobileCardPayment = async () => {
try {
await finalizeCurrentMobileOrder();
popups.select("completed_transaction", {
popups.select('completed_transaction', {
message: `Order #${normalizedOrderId.value} successfully created.`,
});
clearSuccessfulMobileCardFlow();
} catch (error) {
completedOrderId.value = null;
completionError.value = SessionUser.functions.parseErrorMessage(error) || "Unable to complete card payment.";
console.error("Failed to complete mobile card payment:", error);
completionError.value = SessionUser.functions.parseErrorMessage(error) || 'Unable to complete card payment.';
console.error('Failed to complete mobile card payment:', error);
} finally {
isCompleting.value = false;
}
};
const onBackClick = async () => {
if (paymentIntentState.value === "succeeded") {
if (paymentIntentState.value === 'succeeded') {
return;
}
@@ -136,14 +124,14 @@ const onBackClick = async () => {
}
const result = await Swal.fire({
title: "Payment in progress",
text: "Resume the current payment or cancel it before returning to the item step.",
icon: "warning",
title: 'Payment in progress',
text: 'Resume the current payment or cancel it before returning to the item step.',
icon: 'warning',
showDenyButton: true,
showCancelButton: true,
confirmButtonText: "Resume payment",
denyButtonText: "Cancel payment",
cancelButtonText: "Stay here",
confirmButtonText: 'Resume payment',
denyButtonText: 'Cancel payment',
cancelButtonText: 'Stay here',
reverseButtons: true,
});
@@ -156,7 +144,7 @@ const onBackClick = async () => {
await StripeModule.paymentIntents.deletePaymentIntent(normalizedOrderId.value);
navigateToItems();
} catch (error) {
completionError.value = SessionUser.functions.parseErrorMessage(error) || "Unable to cancel the current payment.";
completionError.value = SessionUser.functions.parseErrorMessage(error) || 'Unable to cancel the current payment.';
}
}
};
@@ -183,7 +171,9 @@ const onBackClick = async () => {
<div class="mobile-card-payment-view__summary box">
<div class="mobile-card-payment-view__summary-row">
<span>Order</span>
<strong data-testid="pos-mobile-step-3-order-id"> #{{ normalizedOrderId || "Pending" }} </strong>
<strong data-testid="pos-mobile-step-3-order-id">
#{{ normalizedOrderId || 'Pending' }}
</strong>
</div>
<div class="mobile-card-payment-view__summary-row">
<span>Total</span>
@@ -226,7 +216,9 @@ const onBackClick = async () => {
</button>
</div>
<p class="mobile-card-payment-view__description">Use the selected card terminal for this order.</p>
<p class="mobile-card-payment-view__description">
Use the selected Stripe Terminal reader or send a Stripe payment link by email for this order.
</p>
<PayWithStripeButton
:department-id="normalizedDepartmentId"
@@ -469,13 +469,6 @@ const onClick = async () => {
}
} catch (error) {
console.warn("Next-step action was interrupted:", error);
// Surface the failure to the operator so silent API rejections don't look
// like the button did nothing. Backend validation errors typically carry a
// human-readable message on error.response.data.data.message.
const parsedError =
SessionUser.functions.parseErrorMessage(error) || error?.message || "Unknown error";
errors.value.push(error);
popups.select("error", { message: parsedError });
} finally {
isProcessingClick.value = false;
}
@@ -1,247 +0,0 @@
export const XLVASK_IMPORT_STATES = ["new", "updated", "unchanged", "invalid"];
export const XLVASK_RESOLUTION_STATES = [
"already_linked",
"auto_linked",
"auto_created",
"needs_review",
"blocked",
"ignored",
"failed",
];
export const XLVASK_CERTAINTY_STATES = ["certain", "uncertain", "none"];
export const XLVASK_PLANNED_ACTIONS = [
"attach_order",
"create_order",
"resolve_mapping",
"recheck",
"ignore",
"none",
];
export const emptyXlvaskAutopilotSummary = () => ({
total: 0,
new: 0,
updated: 0,
unchanged: 0,
invalid: 0,
already_linked: 0,
auto_linked: 0,
auto_created: 0,
needs_review: 0,
blocked: 0,
ignored: 0,
failed: 0,
certain: 0,
uncertain: 0,
none: 0,
});
const nonNegativeInteger = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
};
export const normalizeXlvaskAutopilotSummary = (summary) => {
const normalized = emptyXlvaskAutopilotSummary();
Object.keys(normalized).forEach((key) => {
normalized[key] = nonNegativeInteger(summary?.[key]);
});
return normalized;
};
export const normalizeXlvaskAutopilotRun = (run) => {
if (!run || typeof run !== "object") {
return null;
}
return {
...run,
id: run.id ?? null,
status: String(run.status || "queued"),
phase: String(run.phase || run.status || "queued"),
processed: nonNegativeInteger(run.processed),
total: nonNegativeInteger(run.total),
summary: normalizeXlvaskAutopilotSummary(run.summary),
};
};
const stringList = (value) => Array.isArray(value)
? value.map((entry) => String(entry || "").trim()).filter(Boolean)
: [];
const booleanCapability = (source, key) => source?.[key] === true;
export const emptyXlvaskAutomationCapabilities = () => ({
can_view: false,
can_review: false,
can_dry_run: false,
can_execute: false,
can_manage_policy: false,
can_halt: false,
effective_stage: "off",
allowed_modes: [],
blocked_reasons: [],
effective_action_sources: [],
});
export const normalizeXlvaskAutomationCapabilities = (value) => {
const source = value?.capabilities && typeof value.capabilities === "object"
? value.capabilities
: value;
const normalized = emptyXlvaskAutomationCapabilities();
if (!source || typeof source !== "object") return normalized;
Object.keys(normalized).forEach((key) => {
if (key.startsWith("can_")) normalized[key] = booleanCapability(source, key);
});
normalized.effective_stage = String(source.effective_stage || source.policy_stage || "off").toLowerCase();
normalized.allowed_modes = stringList(source.allowed_modes).map((mode) => mode.toLowerCase());
normalized.blocked_reasons = stringList(source.blocked_reasons);
normalized.effective_action_sources = stringList(source.effective_action_sources);
return normalized;
};
export const emptyXlvaskAutomationReadiness = () => ({
ready: false,
effective_stage: "off",
policy_version: "",
model: "",
worker_healthy: false,
blocked_reasons: [],
budgets: {
attach_order: { remaining_global: 0, remaining_hall: 0 },
create_order: { remaining_global: 0, remaining_hall: 0 },
},
review_progress: {
attach_order: { reviewed: 0, target: 200 },
create_order: { reviewed: 0, target: 50 },
},
eligible_counts: { attach_order: 0, create_order: 0, total: 0 },
calibrations: {},
});
const normalizeProgress = (value, defaultTarget) => ({
reviewed: nonNegativeInteger(value?.reviewed ?? value?.completed),
target: nonNegativeInteger(value?.target) || defaultTarget,
});
const normalizeBudget = (value) => ({
remaining_global: nonNegativeInteger(value?.remaining_global ?? value?.global_remaining),
remaining_hall: nonNegativeInteger(value?.remaining_hall ?? value?.hall_remaining),
});
export const normalizeXlvaskAutomationReadiness = (value) => {
const source = value?.readiness && typeof value.readiness === "object" ? value.readiness : value;
const normalized = emptyXlvaskAutomationReadiness();
if (!source || typeof source !== "object") return normalized;
const policy = source.policy && typeof source.policy === "object" ? source.policy : {};
const worker = source.worker && typeof source.worker === "object"
? source.worker
: source.workers && typeof source.workers === "object"
? source.workers
: {};
normalized.ready = source.ready === true;
normalized.effective_stage = String(
source.effective_stage || policy.effective_stage || policy.stage || "off"
).toLowerCase();
normalized.policy_version = String(source.policy_version || policy.version || "");
normalized.model = String(source.model || source.model_identity || policy.model || "");
normalized.worker_healthy = source.worker_healthy === true || worker.healthy === true;
normalized.blocked_reasons = stringList(source.blocked_reasons);
normalized.budgets.attach_order = normalizeBudget(source.budgets?.attach_order);
normalized.budgets.create_order = normalizeBudget(source.budgets?.create_order);
normalized.review_progress.attach_order = normalizeProgress(source.review_progress?.attach_order, 200);
normalized.review_progress.create_order = normalizeProgress(source.review_progress?.create_order, 50);
normalized.eligible_counts.attach_order = nonNegativeInteger(source.eligible_counts?.attach_order);
normalized.eligible_counts.create_order = nonNegativeInteger(source.eligible_counts?.create_order);
normalized.eligible_counts.total = nonNegativeInteger(source.eligible_counts?.total)
|| normalized.eligible_counts.attach_order + normalized.eligible_counts.create_order;
normalized.calibrations = source.calibrations && typeof source.calibrations === "object"
? source.calibrations
: {};
return normalized;
};
export const isXlvaskReviewEligible = (row) => {
return row?.automation?.review_eligible === true;
};
export const isXlvaskAutopilotRunActive = (run) => {
return ["queued", "pending", "running", "processing", "retry_wait"]
.includes(String(run?.status || "").toLowerCase());
};
export const xlvaskAutopilotRunProgress = (run) => {
const total = nonNegativeInteger(run?.total);
const processed = Math.min(total, nonNegativeInteger(run?.processed));
return total > 0 ? Math.round((processed / total) * 100) : 0;
};
export const getXlvaskAutomation = (row) => row?.automation && typeof row.automation === "object"
? row.automation
: {};
export const getXlvaskImportState = (row) => {
const value = String(row?.import_state || "").toLowerCase();
return XLVASK_IMPORT_STATES.includes(value) ? value : "unchanged";
};
export const getXlvaskResolutionState = (row) => {
const value = String(row?.resolution_state || "").toLowerCase();
if (XLVASK_RESOLUTION_STATES.includes(value)) {
return value;
}
const status = String(getXlvaskAutomation(row).status || "").toLowerCase();
if (status === "failed") return "failed";
if (status === "denied") return "needs_review";
if (status === "auto_accepted") {
return getXlvaskAutomation(row).action === "create_order" ? "auto_created" : "auto_linked";
}
if (status === "accepted" || row?.linked_order_id || row?.order_id) return "already_linked";
return "needs_review";
};
export const getXlvaskCertainty = (row) => {
const value = String(row?.certainty || "").toLowerCase();
return XLVASK_CERTAINTY_STATES.includes(value) ? value : "none";
};
export const getXlvaskPlannedAction = (row) => {
const value = String(row?.planned_action || getXlvaskAutomation(row).action || "none").toLowerCase();
return XLVASK_PLANNED_ACTIONS.includes(value) ? value : "none";
};
export const getXlvaskCalibratedProbability = (row) => {
const automation = getXlvaskAutomation(row);
const value = Number(automation.calibrated_probability ?? 0);
if (!Number.isFinite(value) || value <= 0) return null;
return Math.min(1, value);
};
export const getXlvaskAutomationList = (row, key) => {
const value = getXlvaskAutomation(row)[key];
return Array.isArray(value) ? value.filter(Boolean) : [];
};
export const xlvaskStateTagClass = (state) => {
if (["already_linked", "auto_linked", "auto_created", "certain"].includes(state)) return "is-success";
if (["invalid", "blocked", "failed"].includes(state)) return "is-danger";
if (["new", "updated", "needs_review", "uncertain"].includes(state)) return "is-warning";
if (state === "ignored") return "is-light";
return "is-info";
};
export const xlvaskEvidenceText = (entry) => {
if (typeof entry === "string") return entry;
return String(entry?.label ?? entry?.message ?? entry?.reason ?? entry?.code ?? "");
};
export const xlvaskCandidateOrderId = (candidate) => candidate?.order_id ?? candidate?.id ?? null;
export const xlvaskCandidateLabel = (candidate) => {
const orderId = xlvaskCandidateOrderId(candidate);
const label = candidate?.label || candidate?.reason || candidate?.customer_name || "";
return [orderId ? `#${orderId}` : "", label].filter(Boolean).join(" · ");
};
File diff suppressed because it is too large Load Diff
@@ -52,7 +52,6 @@ const pricingData = ref(null);
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const pricingConflict = ref(false);
const departmentId = computed(() => {
const parsed = Number.parseInt(String(props.departmentId || ""), 10);
@@ -65,8 +64,7 @@ const parsedCustomerNumber = computed(() => {
});
const canLoadPricing = computed(
() =>
props.canRead && props.customPricingEnabled && departmentId.value !== null && parsedCustomerNumber.value !== null
() => props.canRead && props.customPricingEnabled && departmentId.value !== null && parsedCustomerNumber.value !== null
);
const categories = computed(() => (Array.isArray(pricingData.value?.categories) ? pricingData.value.categories : []));
@@ -132,23 +130,12 @@ const requestLoad = () =>
const requestSave = (overrides) =>
props.scope === "limited"
? updateLimitedBackofficeDepartmentCustomerPricing(
departmentId.value,
customerIdentifier.value,
overrides,
pricingData.value?.revision ?? null
)
: updateSuperuserDepartmentCustomerPricing(
departmentId.value,
customerIdentifier.value,
overrides,
pricingData.value?.revision ?? null
);
? updateLimitedBackofficeDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides)
: updateSuperuserDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides);
const resetPricingData = () => {
pricingData.value = null;
errorMessage.value = "";
pricingConflict.value = false;
};
const loadPricing = async () => {
@@ -159,7 +146,6 @@ const loadPricing = async () => {
loading.value = true;
errorMessage.value = "";
pricingConflict.value = false;
try {
const response = await requestLoad();
@@ -207,17 +193,16 @@ const saveOverride = async (nextOverride) => {
saving.value = true;
errorMessage.value = "";
pricingConflict.value = false;
try {
const response = await requestSave(normalizedOverrides(nextOverride));
pricingData.value = unwrapDepartmentCustomerPricingResponse(response);
} catch (error) {
const payload = error?.response?.data?.data || error?.response?.data || {};
pricingConflict.value = Number(error?.response?.status) === 409 && payload?.code === "pricing_revision_conflict";
errorMessage.value = pricingConflict.value
? t("templates.limited_backoffice.errors.pricing_conflict")
: payload?.message || error?.message || t("departments.customer_pricing.errors.save");
errorMessage.value =
error?.response?.data?.data?.message ||
error?.response?.data?.message ||
error?.message ||
t("departments.customer_pricing.errors.save");
} finally {
saving.value = false;
}
@@ -332,11 +317,7 @@ watch(
: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"
>
<div v-if="!props.canRead" class="notification is-danger is-light" data-testid="department-customer-pricing-forbidden">
{{ t("departments.customer_pricing.no_permission") }}
</div>
@@ -356,44 +337,44 @@ watch(
: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 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">
@@ -401,16 +382,7 @@ watch(
</div>
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="department-customer-pricing-error">
<span>{{ errorMessage }}</span>
<button
v-if="pricingConflict"
type="button"
class="button is-small is-danger is-light ml-3"
data-testid="department-customer-pricing-conflict-reload"
@click="loadPricing"
>
{{ t("templates.limited_backoffice.reload_pricing") }}
</button>
{{ errorMessage }}
</div>
<div v-if="pricingData" class="department-customer-pricing__content">
@@ -421,48 +393,39 @@ watch(
: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 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"
>
<div v-if="saving" class="notification is-light py-2" role="status" data-testid="department-customer-pricing-saving">
{{ t("departments.customer_pricing.saving") }}
</div>
@@ -481,11 +444,7 @@ watch(
<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')
"
:label="props.canEdit ? t('departments.customer_pricing.edit_category_discount') : t('departments.customer_pricing.edit_disabled')"
position="is-left"
type="is-dark"
>
@@ -530,11 +489,7 @@ watch(
</td>
<td>
<b-tooltip
:label="
props.canEdit
? t('departments.customer_pricing.edit_fixed_price')
: t('departments.customer_pricing.edit_disabled')
"
:label="props.canEdit ? t('departments.customer_pricing.edit_fixed_price') : t('departments.customer_pricing.edit_disabled')"
position="is-bottom"
type="is-dark"
>
@@ -554,11 +509,7 @@ watch(
</td>
<td>
<b-tooltip
:label="
props.canEdit
? t('departments.customer_pricing.edit_item_discount')
: t('departments.customer_pricing.edit_disabled')
"
:label="props.canEdit ? t('departments.customer_pricing.edit_item_discount') : t('departments.customer_pricing.edit_disabled')"
position="is-bottom"
type="is-dark"
>
@@ -19,11 +19,9 @@ const props = defineProps({
type: Boolean, default: false
}, highlightUsageLogId: {
type: Number, default: 0
}, automationWorkspace: {
type: Boolean, default: false
}
})
import {computed, onMounted, onUnmounted, provide, ref, watch } from "vue";
import {computed, provide, ref, watch } from "vue";
import { useRouter } from "vue-router";
import {
PaginatedListKey,
@@ -39,21 +37,9 @@ import PaginationDisplayTemplateDates
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { useI18n } from 'vue-i18n'
import { BSwitch } from "buefy";
import Swal from "sweetalert2";
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";
import {
emptyXlvaskAutopilotSummary,
emptyXlvaskAutomationCapabilities,
emptyXlvaskAutomationReadiness,
isXlvaskAutopilotRunActive,
normalizeXlvaskAutomationCapabilities,
normalizeXlvaskAutomationReadiness,
normalizeXlvaskAutopilotRun,
normalizeXlvaskAutopilotSummary,
xlvaskAutopilotRunProgress,
} from "@/components/displays/department/pos/sync/xlvaskAutopilotUi.js";
const { t } = useI18n()
const router = useRouter();
@@ -106,11 +92,13 @@ const titleText = computed(() => props.title || SessionUser.objects.orders.meta.
// If the customer filter is set, filter the orders by the customer number
if (props.setCustomerFilter > 0) {
setFilter("customer_number", props.setCustomerFilter);
console.log("Setting customer filter to: " + props.setCustomerFilter);
}
// Hide the search field
if (props.hideSearch) {
setHideSearchField(true);
console.log("Hiding search field");
} else {
setHideSearchField(false);
}
@@ -123,107 +111,10 @@ if (router.currentRoute.value.params.departmentId) {
setOrder("StartTime", "desc");
}
const isImportLoading = ref(false);
const summary = ref(emptyXlvaskAutopilotSummary());
const summaryLoading = ref(false);
const summaryError = ref("");
let summaryRequestSequence = 0;
const autopilotRun = ref(null);
const runError = ref("");
const capabilities = ref(emptyXlvaskAutomationCapabilities());
const readiness = ref(emptyXlvaskAutomationReadiness());
const controlStateLoading = ref(false);
const controlStateError = ref("");
const adminReadinessError = ref("");
const policyActionLoading = ref(false);
const activeRunCheckComplete = ref(false);
let controlStateRequestSequence = 0;
let activeRunRecoverySequence = 0;
let periodScopeSequence = 0;
let currentRunIdempotencyKey = "";
let currentRunFingerprint = "";
let runPollTimer = null;
let runPollSequence = 0;
let runPollingStartedAt = 0;
const runPollingPaused = ref(false);
const RUN_POLL_INTERVAL_MS = 2_000;
const RUN_POLL_MAX_DURATION_MS = 5 * 60 * 1_000;
const extractResponseData = (response) => response?.data?.data ?? response?.data ?? {};
const loadSummary = async () => {
const sequence = ++summaryRequestSequence;
const requestedFrom = props.initialDateFrom || formatLocalDateOnly(dateFrom.value);
const requestedTo = props.initialDateTo || formatLocalDateOnly(dateTo.value);
summaryLoading.value = true;
summaryError.value = "";
try {
const response = await SessionUser.request(
"/modules/xlvask/services/usage/orders/summary",
"GET",
{ dateFrom: requestedFrom, dateTo: requestedTo },
);
if (sequence !== summaryRequestSequence) return;
const data = extractResponseData(response);
summary.value = normalizeXlvaskAutopilotSummary(data.summary);
} catch (error) {
if (sequence !== summaryRequestSequence) return;
summary.value = emptyXlvaskAutopilotSummary();
summaryError.value = t("invoicing_period.xlvask_autopilot.summary_error");
console.error("Failed to load XL-Vask autopilot summary.", error);
} finally {
if (sequence === summaryRequestSequence) summaryLoading.value = false;
}
};
const loadAutomationControlState = async () => {
if (!props.automationWorkspace) return;
const sequence = ++controlStateRequestSequence;
controlStateLoading.value = true;
controlStateError.value = "";
adminReadinessError.value = "";
try {
const capabilityResponse = await SessionUser.request(
"/modules/xlvask/services/usage/automation/capabilities",
"GET",
buildImportUsageParams(),
);
if (sequence !== controlStateRequestSequence) return;
const capabilityData = extractResponseData(capabilityResponse);
capabilities.value = normalizeXlvaskAutomationCapabilities(capabilityData);
readiness.value = normalizeXlvaskAutomationReadiness(capabilityData.readiness || capabilityData);
if (!capabilities.value.can_view) return;
if (capabilities.value.can_manage_policy) {
try {
const readinessResponse = await SessionUser.request(
"/modules/xlvask/services/usage/automation/admin/readiness",
"GET",
buildImportUsageParams(),
);
if (sequence !== controlStateRequestSequence) return;
readiness.value = normalizeXlvaskAutomationReadiness(extractResponseData(readinessResponse));
} catch (error) {
if (sequence !== controlStateRequestSequence) return;
adminReadinessError.value = t("invoicing_period.xlvask_autopilot.controls.readiness_error");
console.error("Failed to load XL-Vask administrator readiness.", error);
}
}
} catch (error) {
if (sequence !== controlStateRequestSequence) return;
capabilities.value = emptyXlvaskAutomationCapabilities();
readiness.value = emptyXlvaskAutomationReadiness();
controlStateError.value = t("invoicing_period.xlvask_autopilot.controls.load_error");
console.error("Failed to load XL-Vask automation control state.", error);
} finally {
if (sequence === controlStateRequestSequence) controlStateLoading.value = false;
}
};
const buildImportUsageParams = () => {
if (!props.inheritPeriodFilters) {
return {
dateFrom: formatLocalDateOnly(dateFrom.value),
dateTo: formatLocalDateOnly(dateTo.value),
};
return {};
}
return {
@@ -232,373 +123,28 @@ const buildImportUsageParams = () => {
};
};
const refreshAutopilotSurface = async () => {
await Promise.all([loadList(), loadSummary()]);
window.dispatchEvent(new CustomEvent("xlvask-usage-order-updated", {
detail: { runId: autopilotRun.value?.id ?? null, source: "autopilot" },
}));
};
const clearRunPollTimer = () => {
if (runPollTimer !== null) {
clearTimeout(runPollTimer);
runPollTimer = null;
}
};
const invalidateAutopilotScope = () => {
periodScopeSequence += 1;
runPollSequence += 1;
activeRunRecoverySequence += 1;
controlStateRequestSequence += 1;
activeRunCheckComplete.value = false;
clearRunPollTimer();
autopilotRun.value = null;
isImportLoading.value = false;
runPollingPaused.value = false;
runPollingStartedAt = 0;
runError.value = "";
currentRunIdempotencyKey = "";
currentRunFingerprint = "";
};
const pollAutopilotRun = async (runId, sequence) => {
if (!runId || sequence !== runPollSequence) return;
if (document.hidden) {
runPollingPaused.value = true;
return;
}
if (runPollingStartedAt > 0 && Date.now() - runPollingStartedAt >= RUN_POLL_MAX_DURATION_MS) {
runPollingPaused.value = true;
return;
}
try {
const response = await SessionUser.request(
`/modules/xlvask/services/usage/autopilot-runs/${runId}`,
"GET",
);
if (sequence !== runPollSequence) return;
const data = extractResponseData(response);
autopilotRun.value = normalizeXlvaskAutopilotRun(data.run);
if (isXlvaskAutopilotRunActive(autopilotRun.value)) {
runPollTimer = setTimeout(() => pollAutopilotRun(runId, sequence), RUN_POLL_INTERVAL_MS);
return;
}
isImportLoading.value = false;
runPollingPaused.value = false;
runPollingStartedAt = 0;
currentRunIdempotencyKey = "";
currentRunFingerprint = "";
await refreshAutopilotSurface();
await loadAutomationControlState();
} catch (error) {
if (sequence !== runPollSequence) return;
runPollingPaused.value = true;
runError.value = t("invoicing_period.xlvask_autopilot.run_status_error");
console.error("Failed to load XL-Vask autopilot run.", error);
}
};
const resumeAutopilotPolling = ({ resetWindow = true } = {}) => {
if (!isXlvaskAutopilotRunActive(autopilotRun.value) || document.hidden) return;
clearRunPollTimer();
if (resetWindow) runPollingStartedAt = Date.now();
runPollingPaused.value = false;
void pollAutopilotRun(autopilotRun.value.id, runPollSequence);
};
const handleVisibilityChange = () => {
if (!document.hidden && runPollingPaused.value && runPollingStartedAt > 0
&& Date.now() - runPollingStartedAt < RUN_POLL_MAX_DURATION_MS) {
resumeAutopilotPolling({ resetWindow: false });
}
};
const handleUsageOrderUpdated = (event) => {
if (event?.detail?.source === "adjudication") {
void Promise.all([loadSummary(), loadAutomationControlState()]);
return;
}
if (event?.detail?.source !== "autopilot") {
void loadSummary();
}
};
const createIdempotencyKey = () => {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
return `xlvask-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const getRunIdempotencyKey = (mode, params) => {
const fingerprint = JSON.stringify({ mode, ...params });
if (fingerprint !== currentRunFingerprint || !currentRunIdempotencyKey) {
currentRunFingerprint = fingerprint;
currentRunIdempotencyKey = createIdempotencyKey();
}
return currentRunIdempotencyKey;
};
const buildExecutionReadinessSnapshot = () => ({
ready: readiness.value.ready,
worker_healthy: readiness.value.worker_healthy,
blocked_reasons: [...readiness.value.blocked_reasons],
can_execute: capabilities.value.can_execute,
allowed_modes: [...capabilities.value.allowed_modes],
policy_version: readiness.value.policy_version,
effective_stage: readiness.value.effective_stage,
eligible: readiness.value.eligible_counts.total,
attach_global: readiness.value.budgets.attach_order.remaining_global,
attach_hall_minimum: readiness.value.budgets.attach_order.remaining_hall,
create_global: readiness.value.budgets.create_order.remaining_global,
create_hall_minimum: readiness.value.budgets.create_order.remaining_hall,
sources: [...capabilities.value.effective_action_sources],
});
const canStartDryRun = computed(() => props.automationWorkspace
&& capabilities.value.can_dry_run
&& capabilities.value.allowed_modes.includes("dry_run")
&& activeRunCheckComplete.value
&& !isXlvaskAutopilotRunActive(autopilotRun.value));
const canStartExecute = computed(() => props.automationWorkspace
&& capabilities.value.can_execute
&& readiness.value.ready
&& capabilities.value.allowed_modes.includes("execute")
&& activeRunCheckComplete.value
&& !isXlvaskAutopilotRunActive(autopilotRun.value));
const startAutopilotRun = async (mode = "dry_run") => {
if (mode === "dry_run" ? !canStartDryRun.value : !canStartExecute.value) return;
const requestScopeSequence = periodScopeSequence;
const runParams = buildImportUsageParams();
const readinessSnapshot = buildExecutionReadinessSnapshot();
const readinessFingerprint = JSON.stringify(readinessSnapshot);
if (mode === "execute") {
const confirmationPhrase = t("invoicing_period.xlvask_autopilot.controls.execute_phrase");
const confirmation = await Swal.fire({
icon: "warning",
title: t("invoicing_period.xlvask_autopilot.controls.execute_title"),
text: t("invoicing_period.xlvask_autopilot.controls.execute_description", {
from: runParams.dateFrom || "—",
to: runParams.dateTo || "—",
eligible: readinessSnapshot.eligible,
attachGlobal: readinessSnapshot.attach_global,
attachHall: readinessSnapshot.attach_hall_minimum,
createGlobal: readinessSnapshot.create_global,
createHall: readinessSnapshot.create_hall_minimum,
sources: readinessSnapshot.sources.join(", ") || "—",
}),
input: "text",
inputLabel: t("invoicing_period.xlvask_autopilot.preview.confirmation_label", { phrase: confirmationPhrase }),
inputPlaceholder: confirmationPhrase,
showCancelButton: true,
confirmButtonText: t("invoicing_period.xlvask_autopilot.controls.execute"),
cancelButtonText: t("common.cancel"),
inputValidator: (value) => String(value ?? "").trim() === confirmationPhrase
? undefined
: t("invoicing_period.xlvask_autopilot.preview.confirmation_mismatch", { phrase: confirmationPhrase }),
});
if (!confirmation.isConfirmed) return;
if (requestScopeSequence !== periodScopeSequence) return;
if (readinessFingerprint !== JSON.stringify(buildExecutionReadinessSnapshot())) return;
if (!canStartExecute.value) return;
}
const importUsageButton = async () => {
isImportLoading.value = true;
runError.value = "";
clearRunPollTimer();
const sequence = ++runPollSequence;
runPollingStartedAt = Date.now();
runPollingPaused.value = false;
try {
const idempotencyKey = getRunIdempotencyKey(mode, runParams);
const response = await SessionUser.request(
"/modules/xlvask/services/usage/autopilot-runs",
"POST",
{
...runParams,
mode,
forceRefetch: true,
idempotency_key: idempotencyKey,
},
await SessionUser.request(
'/modules/xlvask/tasks/import-usage',
'GET',
buildImportUsageParams(),
function (error) {
// This function is called when the request fails
console.error("Failed to import orders.", error);
},
function (response) {
// This function is called when the request succeeds
console.log("Orders imported successfully.", response);
// Reload the list after import
loadList();
}
);
if (requestScopeSequence !== periodScopeSequence) return;
const data = extractResponseData(response);
autopilotRun.value = normalizeXlvaskAutopilotRun(data.run);
if (!autopilotRun.value?.id) {
throw new Error("Autopilot run response did not contain a run id.");
}
if (isXlvaskAutopilotRunActive(autopilotRun.value)) {
await pollAutopilotRun(autopilotRun.value.id, sequence);
} else {
await refreshAutopilotSurface();
isImportLoading.value = false;
currentRunIdempotencyKey = "";
currentRunFingerprint = "";
}
} catch (error) {
if (requestScopeSequence !== periodScopeSequence) return;
runError.value = t("invoicing_period.xlvask_autopilot.run_start_error");
console.error("Failed to start XL-Vask autopilot.", error);
} finally {
isImportLoading.value = false;
}
};
const recoverActiveAutopilotRun = async () => {
if (!props.automationWorkspace || !capabilities.value.can_view) return;
if (!capabilities.value.can_dry_run && !capabilities.value.can_execute && !capabilities.value.can_review) {
activeRunCheckComplete.value = true;
return;
}
const recoverySequence = ++activeRunRecoverySequence;
activeRunCheckComplete.value = false;
try {
const response = await SessionUser.request(
"/modules/xlvask/services/usage/autopilot-runs/active",
"GET",
buildImportUsageParams(),
);
if (recoverySequence !== activeRunRecoverySequence) return;
const data = extractResponseData(response);
const run = normalizeXlvaskAutopilotRun(data.run);
activeRunCheckComplete.value = true;
if (!isXlvaskAutopilotRunActive(run)) return;
autopilotRun.value = run;
isImportLoading.value = true;
runPollSequence += 1;
runPollingStartedAt = Date.now();
resumeAutopilotPolling();
} catch (error) {
if (recoverySequence !== activeRunRecoverySequence) return;
activeRunCheckComplete.value = false;
controlStateError.value = t("invoicing_period.xlvask_autopilot.controls.active_run_error");
console.error("Failed to recover the active XL-Vask automation run.", error);
}
};
const NEXT_POLICY_STAGE = {
off: "advisory",
advisory: "ai_attach_canary",
ai_attach_canary: "ai_attach_verified",
ai_attach_verified: "ai_create_canary",
ai_create_canary: "verified_capped",
halted: "advisory",
};
const nextPolicyStage = computed(() => NEXT_POLICY_STAGE[readiness.value.effective_stage] || "");
const canAdvancePolicy = computed(() => capabilities.value.can_manage_policy
&& !adminReadinessError.value
&& Boolean(nextPolicyStage.value)
&& (readiness.value.ready || readiness.value.effective_stage === "halted"));
const applyPolicyPreview = async () => {
if (!canAdvancePolicy.value || policyActionLoading.value) return;
policyActionLoading.value = true;
try {
const reasonResult = await Swal.fire({
title: t("invoicing_period.xlvask_autopilot.controls.policy_reason_title"),
input: "textarea",
inputLabel: t("invoicing_period.xlvask_autopilot.preview.reason_label"),
showCancelButton: true,
confirmButtonText: t("common.continue"),
cancelButtonText: t("common.cancel"),
inputValidator: (value) => String(value ?? "").trim()
? undefined
: t("invoicing_period.xlvask_autopilot.preview.reason_required"),
});
if (!reasonResult.isConfirmed) return;
const previewResponse = await SessionUser.request(
"/modules/xlvask/services/usage/automation/admin/policy/previews",
"POST",
{
target_stage: nextPolicyStage.value,
expected_policy_version: readiness.value.policy_version,
reason: String(reasonResult.value).trim(),
},
);
const preview = extractResponseData(previewResponse).preview;
if (!preview?.id || !preview?.selection_hash || preview.expected_policy_version === undefined) {
throw new Error("Policy preview is incomplete.");
}
const phrase = String(preview.confirmation_phrase || t("invoicing_period.xlvask_autopilot.controls.policy_phrase")).trim();
const confirmation = await Swal.fire({
icon: "warning",
title: t("invoicing_period.xlvask_autopilot.controls.policy_title"),
text: t("invoicing_period.xlvask_autopilot.controls.policy_description", { stage: nextPolicyStage.value }),
input: preview.requires_confirmation === false ? undefined : "text",
inputLabel: preview.requires_confirmation === false
? undefined
: t("invoicing_period.xlvask_autopilot.preview.confirmation_label", { phrase }),
inputPlaceholder: phrase,
showCancelButton: true,
confirmButtonText: t("invoicing_period.xlvask_autopilot.controls.policy_apply"),
cancelButtonText: t("common.cancel"),
inputValidator: preview.requires_confirmation === false
? undefined
: (value) => String(value ?? "").trim() === phrase
? undefined
: t("invoicing_period.xlvask_autopilot.preview.confirmation_mismatch", { phrase }),
});
if (!confirmation.isConfirmed) return;
const response = await SessionUser.request(
"/modules/xlvask/services/usage/automation/admin/policy/apply",
"POST",
{
preview_id: preview.id,
selection_hash: preview.selection_hash,
...(preview.requires_confirmation === false ? {} : { confirmation_text: confirmation.value }),
expected_policy_version: preview.expected_policy_version,
},
);
const data = extractResponseData(response);
readiness.value = normalizeXlvaskAutomationReadiness(data.readiness || data);
await loadAutomationControlState();
} catch (error) {
await Swal.fire({
icon: "error",
title: t("invoicing_period.xlvask_autopilot.preview.error_title"),
text: SessionUser.functions.parseErrorMessage?.(error) || String(error),
});
} finally {
policyActionLoading.value = false;
}
};
const haltAutomation = async () => {
if (!capabilities.value.can_halt || policyActionLoading.value) return;
const result = await Swal.fire({
icon: "warning",
title: t("invoicing_period.xlvask_autopilot.controls.halt_title"),
input: "textarea",
inputLabel: t("invoicing_period.xlvask_autopilot.preview.reason_label"),
showCancelButton: true,
confirmButtonText: t("invoicing_period.xlvask_autopilot.controls.halt"),
cancelButtonText: t("common.cancel"),
inputValidator: (value) => String(value ?? "").trim()
? undefined
: t("invoicing_period.xlvask_autopilot.preview.reason_required"),
});
if (!result.isConfirmed) return;
policyActionLoading.value = true;
try {
const response = await SessionUser.request(
"/modules/xlvask/services/usage/automation/admin/halt",
"POST",
{ reason: String(result.value).trim() },
);
const data = extractResponseData(response);
readiness.value = normalizeXlvaskAutomationReadiness(data.readiness || data);
await loadAutomationControlState();
} catch (error) {
await Swal.fire({
icon: "error",
title: t("invoicing_period.xlvask_autopilot.preview.error_title"),
text: SessionUser.functions.parseErrorMessage?.(error) || String(error),
});
} finally {
policyActionLoading.value = false;
}
};
const dateFrom = ref(parseInitialDate(props.initialDateFrom));
const dateTo = ref(parseInitialDate(props.initialDateTo));
@@ -640,107 +186,25 @@ applyInitialPeriodFilters();
// Load the list automatically if the autoLoad prop is set
if (props.autoLoad) {
loadList();
loadSummary();
}
watch(
() => [props.initialDateFrom, props.initialDateTo],
async ([nextDateFrom, nextDateTo], [previousDateFrom, previousDateTo]) => {
([nextDateFrom, nextDateTo], [previousDateFrom, previousDateTo]) => {
if (nextDateFrom === previousDateFrom && nextDateTo === previousDateTo) {
return;
}
invalidateAutopilotScope();
const scopeSequence = periodScopeSequence;
dateFrom.value = parseInitialDate(nextDateFrom);
dateTo.value = parseInitialDate(nextDateTo);
currentRunIdempotencyKey = "";
currentRunFingerprint = "";
applyInitialPeriodFilters();
if (props.autoLoad) {
loadList();
loadSummary();
}
if (props.automationWorkspace) {
await loadAutomationControlState();
if (scopeSequence !== periodScopeSequence) return;
await recoverActiveAutopilotRun();
}
}
);
onUnmounted(() => {
runPollSequence += 1;
clearRunPollTimer();
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("xlvask-usage-order-updated", handleUsageOrderUpdated);
});
onMounted(async () => {
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("xlvask-usage-order-updated", handleUsageOrderUpdated);
if (props.automationWorkspace) {
const scopeSequence = periodScopeSequence;
await loadAutomationControlState();
if (scopeSequence !== periodScopeSequence) return;
await recoverActiveAutopilotRun();
}
});
const filterValues = ref({
import_state: "",
resolution_state: "",
certainty: "",
planned_action: "",
});
const applyAutopilotFilter = (key, value) => {
filterValues.value[key] = value;
setFilter(key, value, false);
setPage(1);
loadList();
};
const clearAutopilotFilters = () => {
Object.keys(filterValues.value).forEach((key) => {
filterValues.value[key] = "";
setFilter(key, "", false);
});
showOnlyUnattachedVehicle.value = false;
setPage(1);
loadList();
};
const summaryCards = computed(() => [
{ key: "new", count: summary.value.new, tone: "is-info" },
{ key: "updated", count: summary.value.updated, tone: "is-info" },
{ key: "unchanged", count: summary.value.unchanged, tone: "is-light" },
{ key: "already_linked", count: summary.value.already_linked, tone: "is-success" },
{ key: "auto_linked", count: summary.value.auto_linked, tone: "is-success" },
{ key: "auto_created", count: summary.value.auto_created, tone: "is-success" },
{ key: "certain", count: summary.value.certain, tone: "is-success" },
{ key: "uncertain", count: summary.value.uncertain, tone: "is-warning" },
{ key: "needs_review", count: summary.value.needs_review, tone: "is-warning" },
{ key: "blocked", count: summary.value.blocked, tone: "is-danger" },
{ key: "invalid", count: summary.value.invalid, tone: "is-danger" },
{ key: "ignored", count: summary.value.ignored, tone: "is-light" },
{ key: "failed", count: summary.value.failed, tone: "is-danger" },
]);
const runProgress = computed(() => xlvaskAutopilotRunProgress(autopilotRun.value));
const runStatusLabel = computed(() => {
if (!autopilotRun.value) return "";
const phase = autopilotRun.value.phase || autopilotRun.value.status;
return t(`invoicing_period.xlvask_autopilot.run_phases.${phase}`, phase);
});
const reviewProgressPercent = (action) => {
const progress = readiness.value.review_progress[action];
if (!progress?.target) return 0;
return Math.min(100, Math.round((progress.reviewed / progress.target) * 100));
};
const showOnlyUnattachedVehicle = ref(false);
const visibleObjectsCount = computed(() => {
const currentList = Array.isArray(list.value) ? list.value : [];
@@ -763,204 +227,14 @@ const visibleObjectsCount = computed(() => {
</div>
</div>
<div class="level-right">
<div v-if="props.automationWorkspace && capabilities.can_dry_run" class="level-item">
<LoadButtonWhileAwait
class="is-dark"
:isLoading="isImportLoading"
:disabled="!canStartDryRun"
:loadFunction="() => startAutopilotRun('dry_run')"
icon="fas fa-search"
data-testid="xlvask-autopilot-dry-run"
>{{ t('invoicing_period.xlvask_autopilot.controls.analyze') }}</LoadButtonWhileAwait>
</div>
<div v-if="props.automationWorkspace && capabilities.can_execute" class="level-item">
<LoadButtonWhileAwait
class="is-danger"
:isLoading="isImportLoading"
:disabled="!canStartExecute"
:loadFunction="() => startAutopilotRun('execute')"
icon="fas fa-robot"
data-testid="xlvask-autopilot-execute"
>{{ t('invoicing_period.xlvask_autopilot.controls.execute') }}</LoadButtonWhileAwait>
<div class="level-item">
<LoadButtonWhileAwait class="is-dark" :isLoading="isImportLoading" :loadFunction="importUsageButton" icon="fas fa-file-import">{{SessionUser.objects.global.language.import}}</LoadButtonWhileAwait>
</div>
<div class="level-item">
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{SessionUser.objects.global.language.reload}}</LoadButtonWhileAwait>
</div>
</div>
</div>
<section
v-if="props.automationWorkspace"
class="box xlvask-automation-controls mb-4"
data-testid="xlvask-automation-controls"
aria-labelledby="xlvask-automation-controls-title"
>
<div class="is-flex is-justify-content-space-between is-align-items-flex-start is-flex-wrap-wrap mb-3">
<div>
<h2 id="xlvask-automation-controls-title" class="title is-6 mb-1">
{{ t('invoicing_period.xlvask_autopilot.controls.title') }}
</h2>
<p class="is-size-7 has-text-grey mb-0">
{{ t('invoicing_period.xlvask_autopilot.controls.stage', { stage: readiness.effective_stage }) }}
</p>
</div>
<span v-if="controlStateLoading" class="icon has-text-grey" role="status" :aria-label="t('common.loading')">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
</span>
<div v-else class="buttons mb-0">
<button
v-if="capabilities.can_manage_policy && nextPolicyStage"
type="button"
class="button is-small is-link is-light"
:class="{ 'is-loading': policyActionLoading }"
:disabled="policyActionLoading || !canAdvancePolicy"
data-testid="xlvask-policy-advance"
@click="applyPolicyPreview"
>{{ t('invoicing_period.xlvask_autopilot.controls.policy_advance', { stage: nextPolicyStage }) }}</button>
<button
v-if="capabilities.can_halt"
type="button"
class="button is-small is-danger"
:class="{ 'is-loading': policyActionLoading }"
:disabled="policyActionLoading"
data-testid="xlvask-policy-halt"
@click="haltAutomation"
>{{ t('invoicing_period.xlvask_autopilot.controls.halt') }}</button>
</div>
</div>
<p v-if="controlStateError" class="notification is-danger is-light py-2 px-3" role="alert">{{ controlStateError }}</p>
<template v-else-if="capabilities.can_view">
<p v-if="adminReadinessError" class="notification is-warning is-light py-2 px-3" role="alert">
{{ adminReadinessError }}
</p>
<div class="tags mb-3" data-testid="xlvask-automation-readiness">
<span class="tag" :class="readiness.ready ? 'is-success' : 'is-danger'">
{{ readiness.ready ? t('invoicing_period.xlvask_autopilot.controls.ready') : t('invoicing_period.xlvask_autopilot.controls.not_ready') }}
</span>
<span class="tag" :class="readiness.worker_healthy ? 'is-success is-light' : 'is-warning is-light'">
{{ readiness.worker_healthy ? t('invoicing_period.xlvask_autopilot.controls.worker_healthy') : t('invoicing_period.xlvask_autopilot.controls.worker_unhealthy') }}
</span>
<span v-if="readiness.model" class="tag is-light">{{ t('invoicing_period.xlvask_autopilot.model') }}: {{ readiness.model }}</span>
<span v-if="readiness.policy_version" class="tag is-light">{{ t('invoicing_period.xlvask_autopilot.policy_version') }}: {{ readiness.policy_version }}</span>
</div>
<ul v-if="readiness.blocked_reasons.length || capabilities.blocked_reasons.length" class="notification is-warning is-light py-2 px-4" role="status">
<li v-for="reason in [...readiness.blocked_reasons, ...capabilities.blocked_reasons]" :key="reason">{{ reason }}</li>
</ul>
<div class="columns is-multiline is-variable is-2">
<div v-for="action in ['attach_order', 'create_order']" :key="action" class="column is-12-tablet is-6-desktop">
<div class="xlvask-review-progress">
<div class="is-flex is-justify-content-space-between is-size-7 mb-1">
<strong>{{ t(`invoicing_period.xlvask_autopilot.actions.${action}`) }}</strong>
<span>{{ readiness.review_progress[action].reviewed }}/{{ readiness.review_progress[action].target }}</span>
</div>
<progress class="progress is-small is-info mb-1" :value="reviewProgressPercent(action)" max="100">
{{ reviewProgressPercent(action) }}%
</progress>
<p class="is-size-7 has-text-grey mb-0">
{{ t('invoicing_period.xlvask_autopilot.controls.budget', {
global: readiness.budgets[action].remaining_global,
hall: readiness.budgets[action].remaining_hall,
}) }}
</p>
</div>
</div>
</div>
</template>
<p v-else-if="!controlStateLoading" class="notification is-warning is-light py-2 px-3" role="status">
{{ t('invoicing_period.xlvask_autopilot.controls.no_access') }}
</p>
</section>
<article
v-if="props.automationWorkspace && (autopilotRun || runError)"
class="message xlvask-autopilot-run-status"
:class="{ 'is-info': isXlvaskAutopilotRunActive(autopilotRun), 'is-success': autopilotRun?.status === 'completed', 'is-warning': autopilotRun?.status === 'completed_with_warnings', 'is-danger': runError || autopilotRun?.status === 'failed' }"
data-testid="xlvask-autopilot-run-status"
>
<div class="message-body">
<div class="is-flex is-justify-content-space-between is-flex-wrap-wrap mb-2">
<strong>{{ runError || runStatusLabel }}</strong>
<span v-if="autopilotRun?.id" class="is-size-7 has-text-grey">#{{ autopilotRun.id }}</span>
</div>
<progress
v-if="isXlvaskAutopilotRunActive(autopilotRun)"
class="progress is-info is-small mb-2"
:value="runProgress"
max="100"
>{{ runProgress }}%</progress>
<p v-if="autopilotRun?.total" class="is-size-7 mb-0">
{{ t('invoicing_period.xlvask_autopilot.run_progress', { processed: autopilotRun.processed, total: autopilotRun.total }) }}
</p>
<button
v-if="runPollingPaused && isXlvaskAutopilotRunActive(autopilotRun)"
type="button"
class="button is-small is-info is-light mt-2"
data-testid="xlvask-autopilot-resume"
@click="resumeAutopilotPolling()"
>{{ t('invoicing_period.xlvask_autopilot.resume_status') }}</button>
<p v-if="autopilotRun?.warning" class="has-text-warning-dark mt-2 mb-0">{{ autopilotRun.warning }}</p>
<p v-if="autopilotRun?.error" class="has-text-danger mt-2 mb-0">{{ autopilotRun.error }}</p>
</div>
</article>
<section class="xlvask-autopilot-summary mb-4" aria-live="polite" data-testid="xlvask-autopilot-summary">
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
<h2 class="title is-6 mb-0">{{ t('invoicing_period.xlvask_autopilot.summary_title') }}</h2>
<span v-if="summaryLoading" class="icon has-text-grey"><i class="fas fa-spinner fa-spin"></i></span>
</div>
<p v-if="summaryError" class="help is-danger mb-2">{{ summaryError }}</p>
<div class="tags">
<span
v-for="card in summaryCards"
:key="card.key"
class="tag is-light xlvask-autopilot-summary-chip"
:class="card.tone"
:data-testid="'xlvask-summary-' + card.key"
>
{{ t(`invoicing_period.xlvask_autopilot.states.${card.key}`) }}: {{ card.count }}
</span>
</div>
</section>
<section class="box xlvask-autopilot-filters mb-4" data-testid="xlvask-autopilot-filters">
<div class="columns is-multiline is-variable is-2">
<div class="column is-6-tablet is-3-desktop">
<label class="label is-small" for="xlvask-import-state-filter">{{ t('invoicing_period.xlvask_autopilot.filters.import_state') }}</label>
<div class="select is-small is-fullwidth">
<select id="xlvask-import-state-filter" :value="filterValues.import_state" @change="applyAutopilotFilter('import_state', $event.target.value)">
<option value="">{{ t('invoicing_period.xlvask_autopilot.filters.all') }}</option>
<option v-for="state in ['new', 'updated', 'unchanged', 'invalid']" :key="state" :value="state">{{ t(`invoicing_period.xlvask_autopilot.states.${state}`) }}</option>
</select>
</div>
</div>
<div class="column is-6-tablet is-3-desktop">
<label class="label is-small" for="xlvask-resolution-state-filter">{{ t('invoicing_period.xlvask_autopilot.filters.resolution_state') }}</label>
<div class="select is-small is-fullwidth">
<select id="xlvask-resolution-state-filter" :value="filterValues.resolution_state" @change="applyAutopilotFilter('resolution_state', $event.target.value)">
<option value="">{{ t('invoicing_period.xlvask_autopilot.filters.all') }}</option>
<option v-for="state in ['already_linked', 'auto_linked', 'auto_created', 'needs_review', 'blocked', 'ignored', 'failed']" :key="state" :value="state">{{ t(`invoicing_period.xlvask_autopilot.states.${state}`) }}</option>
</select>
</div>
</div>
<div class="column is-6-tablet is-3-desktop">
<label class="label is-small" for="xlvask-certainty-filter">{{ t('invoicing_period.xlvask_autopilot.filters.certainty') }}</label>
<div class="select is-small is-fullwidth">
<select id="xlvask-certainty-filter" :value="filterValues.certainty" @change="applyAutopilotFilter('certainty', $event.target.value)">
<option value="">{{ t('invoicing_period.xlvask_autopilot.filters.all') }}</option>
<option v-for="state in ['certain', 'uncertain', 'none']" :key="state" :value="state">{{ t(`invoicing_period.xlvask_autopilot.states.${state}`) }}</option>
</select>
</div>
</div>
<div class="column is-6-tablet is-3-desktop">
<label class="label is-small" for="xlvask-action-filter">{{ t('invoicing_period.xlvask_autopilot.filters.planned_action') }}</label>
<div class="select is-small is-fullwidth">
<select id="xlvask-action-filter" :value="filterValues.planned_action" @change="applyAutopilotFilter('planned_action', $event.target.value)">
<option value="">{{ t('invoicing_period.xlvask_autopilot.filters.all') }}</option>
<option v-for="action in ['attach_order', 'create_order', 'resolve_mapping', 'recheck', 'ignore', 'none']" :key="action" :value="action">{{ t(`invoicing_period.xlvask_autopilot.actions.${action}`) }}</option>
</select>
</div>
</div>
</div>
<button class="button is-small is-light" type="button" @click="clearAutopilotFilters">
{{ t('invoicing_period.xlvask_autopilot.filters.clear') }}
</button>
</section>
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_transactions')" v-if="!hideSearchField && shouldShowLocalFilters"/>
<PaginationDisplay v-if="shouldShowLocalFilters" :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<template #paginationColumns>
@@ -983,14 +257,14 @@ const visibleObjectsCount = computed(() => {
@update:endDate="actions.date.to.select"
/>
<div class="column is-narrow my-3">
<label class="label">{{ t('invoicing_period.xlvask_autopilot.filters.title') }}</label>
<label class="label">Filtre</label>
<div class="control">
<b-switch
size="is-small"
type="is-link"
v-model="showOnlyUnattachedVehicle"
>
{{ t('invoicing_period.xlvask_autopilot.filters.unattached_only') }}
Vis kun ikke tilknyttede vaske
</b-switch>
</div>
</div>
@@ -1013,9 +287,6 @@ const visibleObjectsCount = computed(() => {
:objects="list"
:show-only-unattached-vehicle="showOnlyUnattachedVehicle"
:highlight-usage-log-id="props.highlightUsageLogId"
:allow-select-multiple="props.automationWorkspace && capabilities.can_review"
:allow-review-actions="props.automationWorkspace && capabilities.can_review"
:allow-adjudication-actions="props.automationWorkspace && capabilities.can_manage_policy"
/>
<PaginationNavigation
v-if="!props.loadAllAtOnce"
@@ -1028,27 +299,5 @@ const visibleObjectsCount = computed(() => {
</template>
<style scoped>
.xlvask-autopilot-summary .tags {
gap: 0.35rem;
}
.xlvask-autopilot-summary-chip {
height: auto;
min-height: 1.75rem;
white-space: normal;
}
.xlvask-autopilot-filters {
padding: 0.9rem;
}
.xlvask-autopilot-run-status .message-body {
padding: 0.85rem 1rem;
}
.xlvask-review-progress {
border: 1px solid #e7e7e7;
border-radius: 6px;
padding: 0.75rem;
}
</style>
@@ -3,7 +3,6 @@ import { BCheckbox } from "buefy";
import { computed, onMounted, ref, watch } from "vue";
import i18n from "@/i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { permissionCapabilityLabel } from "@/components/session/subuser/subuserPermissionLabels.js";
const props = defineProps({
grant: {
@@ -65,6 +64,27 @@ const LEGACY_PERMISSION_GROUPS: PermissionGroup[] = [
},
];
const LEGACY_PERMISSION_LABEL_KEYS: Record<string, string> = {
VEHICLES_LIST: "view_vehicles",
VEHICLES_EDIT: "edit_vehicles",
VEHICLES_DELETE: "delete_vehicles",
VEHICLES_ADD: "add_vehicles",
SELFSERVE_LIST: "view_selfserve",
SELFSERVE_EDIT: "edit_selfserve",
SELFSERVE_DELETE: "delete_selfserve",
SELFSERVE_ADD: "start_selfserve",
BOOKINGS_LIST: "view_bookings",
BOOKINGS_EDIT: "edit_bookings",
BOOKINGS_DELETE: "delete_bookings",
BOOKINGS_ADD: "add_bookings",
ORDERS_LIST: "view_orders",
ORDERS_EDIT: "edit_orders",
SUBUSERS_LIST: "view_drivers",
SUBUSERS_EDIT: "edit_driver_access",
SUBUSERS_DELETE: "disable_driver_access",
SUBUSERS_ADD: "invite_drivers",
};
const templates = ref<PermissionTemplate[]>([]);
const selectedPermissions = ref<string[]>([]);
const selectedTemplateKey = ref<string>(props.templateKey || props.grant.permission_template_key || "custom");
@@ -141,7 +161,10 @@ const templateLabel = (key: string, _fallback = "") => t(`superuser.driver_acces
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) => permissionCapabilityLabel(permission);
const permissionLabel = (permission: string) => {
const capabilityKey = LEGACY_PERMISSION_LABEL_KEYS[permission] || permission;
return t(`superuser.driver_access.capabilities.${capabilityKey}`);
};
watch(
() => props.permissions,
@@ -1,5 +1,5 @@
<script setup>
import { getCurrentInstance, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import {parseError, getError} from "@/components/request/HandleGlobalError.vue";
const props = defineProps({
@@ -24,7 +24,6 @@ const props = defineProps({
});
const isEditting = ref(false);
const inputId = `configuration-input-${getCurrentInstance()?.uid}`;
const v_model = ref(props.value);
@@ -46,7 +45,7 @@ const parseErrorOnSave = (error) => {
<template>
<div class="field">
<label class="label" :for="inputId">{{ title }}</label>
<label class="label">{{ title }}</label>
<!-- If the value is not set, show a warning message -->
<div v-if="!props.value && showEmptyWarning && !isEditting"
class="message is-warning">
@@ -59,7 +58,6 @@ const parseErrorOnSave = (error) => {
<div class="field has-addons">
<div class="control is-expanded">
<input
:id="inputId"
class="input"
type="text"
v-model="v_model"
@@ -1,9 +1,6 @@
<script setup>
import { ref } from 'vue';
import { parseError, getError } from "@/components/request/HandleGlobalError.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
onSave: Function, // Function to call when the secret key is changed
@@ -14,25 +11,10 @@ const props = defineProps({
icon: String, // The icon of the select
color: String, // The color of the select
isSet: Boolean, // If the secret key is set or not, this is used to show a warning if the secret key is not set.
warningTitle: { // Optional override for the warning title (defaults to i18n key)
type: String,
default: null,
},
warningMessage: { // Optional override for the warning body (defaults to i18n key)
type: String,
default: null,
},
warningKey: { // Override the i18n key used for the default warning body
type: String,
default: 'configuration.secret_key_not_set',
},
});
const emit = defineEmits(['saved']);
const v_model = ref(props.value);
const isEditting = ref(false);
const saving = ref(false);
const errorId = ref(null);
@@ -42,32 +24,6 @@ const parseErrorOnSave = (error) => {
parseError(error, unique_id);
}
const onClickSave = async () => {
if (!props.onSave) {
isEditting.value = false;
return;
}
saving.value = true;
errorId.value = null;
try {
const result = await props.onSave(v_model.value);
// Treat any resolved value (including undefined) as success — the only signal
// we have for failure is the catch path. The previous implementation did the
// same thing but also clobbered `isEditting = false` synchronously, which
// could mask in-flight failures when the parent awaited silently.
emit('saved', { value: v_model.value, result });
isEditting.value = false;
} catch (error) {
parseErrorOnSave(error);
// Stay in edit mode so the user can correct the input.
} finally {
saving.value = false;
}
};
const resolvedWarningTitle = () => props.warningTitle ?? t('common.warning', 'Warning');
const resolvedWarningMessage = () => props.warningMessage ?? t(props.warningKey, 'The secret key is not set, please set it before enabling the integration.');
</script>
@@ -77,8 +33,8 @@ const resolvedWarningMessage = () => props.warningMessage ?? t(props.warningKey,
<!-- If the secret key is not set, show a warning -->
<div v-if="!props.isSet" class="message is-warning">
<div class="message-body">
<strong>{{ resolvedWarningTitle() }}:</strong>
{{ resolvedWarningMessage() }}
<strong>Warning:</strong>
The secret key is not set, please set it before enabling the integration.
</div>
</div>
<div class="control">
@@ -96,16 +52,11 @@ const resolvedWarningMessage = () => props.warningMessage ?? t(props.warningKey,
class="input"
type="text"
v-model="v_model"
:disabled="disabled || !isEditting || saving"
:disabled="disabled || !isEditting"
/>
</div>
<div class="control">
<a
class="button is-info"
@click="onClickSave"
v-if="isEditting"
:class="{ 'is-loading': saving }"
>
<a class="button is-info" @click="onSave(v_model).catch(parseErrorOnSave); isEditting = false" v-if="isEditting">
<span class="icon">
<i class="fas fa-save"></i>
</span>
@@ -1,4 +1,8 @@
<script setup>
import { computed, ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
@@ -36,97 +40,75 @@ const props = defineProps({
type: Array,
default: () => [],
},
});
})
const emit = defineEmits(["flagStatusChanged"]);
const orderItems = ref([]);
// Stable ordering for the order items table: primary items (related_item_id === 0
// / null) first, then addons grouped by their parent, in insertion order. The
// backend now also orders the SELECT, but we sort defensively here so a stale
// cache or older API proxy cannot regress the render order.
const sortOrderItemsForDisplay = (items) => {
if (!Array.isArray(items)) {
return [];
}
return items.slice().sort((left, right) => {
const leftRelatedId = Number(left?.related_item_id ?? 0);
const rightRelatedId = Number(right?.related_item_id ?? 0);
// Primary items (related_item_id 0 / null) come first.
if ((leftRelatedId === 0) !== (rightRelatedId === 0)) {
return leftRelatedId === 0 ? -1 : 1;
}
// Within addons, group by parent.
if (leftRelatedId !== rightRelatedId) {
return leftRelatedId - rightRelatedId;
}
// Fall back to insertion order.
return Number(left?.id ?? 0) - Number(right?.id ?? 0);
});
};
const loadOrderItems = async () => {
if (props.useLocalOrderItems) {
orderItems.value = sortOrderItemsForDisplay(props.localOrderItems);
orderItems.value = props.localOrderItems;
return;
}
await SessionUser.request("/order/items", "GET", {
order_id: props.orderId,
})
.then((response) => {
orderItems.value = sortOrderItemsForDisplay(response.data.data);
})
.catch((error) => {
console.error(error);
});
};
await SessionUser.request(
'/order/items',
'GET',
{
order_id: props.orderId,
}
).then((response) => {
orderItems.value = response.data.data;
}).catch((error) => {
console.error(error);
});
}
const isOrderItemAddon = (item) => {
return item.related_item_id !== 0;
};
}
const getItemAddons = (item) => {
return orderItems.value.filter((order_item) => {
return order_item.related_item_id === item.id;
});
};
}
const _ucFirst = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
}
const sortInvoicePeriodFlags = (flags = []) =>
[...flags].sort((left, right) => {
const sourceOrder = { manual: 0, automatic: 1 };
const leftSource = sourceOrder[left?.source] ?? 99;
const rightSource = sourceOrder[right?.source] ?? 99;
if (leftSource !== rightSource) {
return leftSource - rightSource;
}
const sortInvoicePeriodFlags = (flags = []) => [...flags].sort((left, right) => {
const sourceOrder = { manual: 0, automatic: 1 };
const leftSource = sourceOrder[left?.source] ?? 99;
const rightSource = sourceOrder[right?.source] ?? 99;
if (leftSource !== rightSource) {
return leftSource - rightSource;
}
return String(left?.created_at || left?.fingerprint || "").localeCompare(
String(right?.created_at || right?.fingerprint || "")
);
});
const getOrderItemFlags = (orderItem) =>
sortInvoicePeriodFlags(
(props.invoicePeriodFlags || []).filter((flag) => {
if (String(flag?.status || "active") !== "active") {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order_item", "order_item_field"].includes(targetType)) {
return false;
}
const flagOrderItemId = Number(flag?.order_item_id || flag?.target_id || flag?.context?.order_item_id || 0);
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
})
return String(left?.created_at || left?.fingerprint || "").localeCompare(
String(right?.created_at || right?.fingerprint || "")
);
});
const getOrderItemFlagIconClass = (orderItem) => (getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : "");
const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
if (String(flag?.status || "active") !== "active") {
return false;
}
const targetType = String(flag?.target_type || "");
if (!["order_item", "order_item_field"].includes(targetType)) {
return false;
}
const flagOrderItemId = Number(flag?.order_item_id || flag?.target_id || flag?.context?.order_item_id || 0);
return flagOrderItemId > 0 && flagOrderItemId === Number(orderItem?.id || 0);
}));
const getOrderItemFlagIconClass = (orderItem) => (
getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : ""
);
const getOrderItemFlagColorClass = (orderItem) => {
const flags = getOrderItemFlags(orderItem);
@@ -137,23 +119,19 @@ const getOrderItemFlagColorClass = (orderItem) => {
return flags.some((flag) => flag?.source === "manual") ? "has-text-danger" : "has-text-warning";
};
const displayColumnCount = computed(
() => 2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
);
const displayColumnCount = computed(() => (
2 + (props.displayNotes ? 1 : 0) + (props.displayReference ? 1 : 0) + (props.displayPrice ? 1 : 0)
));
const emitFlagStatusChanged = (flag) => emit("flagStatusChanged", flag);
loadOrderItems();
watch(
() => props.orderId,
(newValue, oldValue) => {
if (newValue !== oldValue) {
loadOrderItems();
}
},
{ immediate: true }
);
watch(() => props.orderId, (newValue, oldValue) => {
if (newValue !== oldValue) {
loadOrderItems();
}
}, { immediate: true });
</script>
<template>
@@ -161,128 +139,125 @@ watch(
<table class="table is-fullwidth" v-if="!props.summaryMode">
<thead>
<tr>
<th>{{ $t("tables.products.name") }}</th>
<th v-if="props.displayNotes">{{ $t("objects.columns.notes") }}</th>
<th v-if="props.displayReference">{{ $t("common.reference") }}</th>
<th>{{ $t("common.quantity") }}</th>
<th v-if="props.displayPrice">{{ $t("tables.products.price") }}</th>
<th>{{ $t('tables.products.name') }}</th>
<th v-if="props.displayNotes">{{ $t('objects.columns.notes') }}</th>
<th v-if="props.displayReference">{{ $t('common.reference') }}</th>
<th>{{ $t('common.quantity') }}</th>
<th v-if="props.displayPrice">{{ $t('tables.products.price') }}</th>
</tr>
</thead>
<tbody>
<template v-for="item in orderItems" :key="item.id">
<!-- If the item is a primary item, show it -->
<template v-if="!isOrderItemAddon(item)">
<!-- If the item is included in the invoice, show it normally -->
<tr v-if="item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
{{ item.product.name }}
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(item.price) }}</td>
</tr>
<!-- If the item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning-light">
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<span style="text-decoration: line-through">{{ item.product.name }}</span> (
{{ SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
<td v-if="props.displayPrice" style="text-decoration: line-through">
{{ SessionUser.functions.currency.toLocal(item.price) }}
</td>
</tr>
<tr v-if="getOrderItemFlags(item).length > 0" :data-testid="`order-content-item-flags-${item.id}`">
<td :colspan="displayColumnCount">
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(item)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
</template>
<!-- Show the add-on items -->
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
<!-- If the add-on item is included in the invoice, show it normally -->
<tr v-if="addon_item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }}
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
</tr>
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ <span style="text-decoration: line-through">{{ addon_item.product.name }}</span> (
{{ SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice" style="text-decoration: line-through">
{{ SessionUser.functions.currency.toLocal(addon_item.price) }}
</td>
</tr>
<tr
v-if="getOrderItemFlags(addon_item).length > 0"
:data-testid="`order-content-item-flags-${addon_item.id}`"
>
<td :colspan="displayColumnCount">
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(addon_item)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
</template>
<template v-for="item in orderItems" :key="item.id">
<!-- If the item is a primary item, show it -->
<template v-if="!isOrderItemAddon(item)">
<!-- If the item is included in the invoice, show it normally -->
<tr v-if="item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
{{ item.product.name }}
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(item.price) }}</td>
</tr>
<!-- If the item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning-light">
<td>
<span
v-if="getOrderItemFlags(item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(item)"
:data-testid="`order-content-item-flag-indicator-${item.id}`"
>
<i :class="getOrderItemFlagIconClass(item)"></i>
</span>
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ item.reference }}</td>
<td v-if="props.displayNotes">{{ item.notes }}</td>
<td>{{ item.quantity }}</td>
<td v-if="props.displayPrice" style="text-decoration: line-through;">{{ SessionUser.functions.currency.toLocal(item.price) }}</td>
</tr>
<tr
v-if="getOrderItemFlags(item).length > 0"
:data-testid="`order-content-item-flags-${item.id}`"
>
<td :colspan="displayColumnCount">
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(item)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
</template>
<!-- Show the add-on items -->
<template v-for="addon_item in getItemAddons(item)" :key="addon_item.id">
<!-- If the add-on item is included in the invoice, show it normally -->
<tr v-if="addon_item.include_in_invoice">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }}
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
</tr>
<!-- If the add-on item is not included in the invoice, show it with a strikethrough -->
<tr v-else class="has-background-warning">
<td>
<span
v-if="getOrderItemFlags(addon_item).length > 0"
class="icon is-small mr-1 invoice-period-item-flag-indicator"
:class="getOrderItemFlagColorClass(addon_item)"
:data-testid="`order-content-item-flag-indicator-${addon_item.id}`"
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ <span style="text-decoration: line-through;">{{ addon_item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
</td>
<td v-if="props.displayReference">{{ addon_item.reference }}</td>
<td v-if="props.displayNotes">{{ addon_item.notes }}</td>
<td>{{ addon_item.quantity }}</td>
<td v-if="props.displayPrice" style="text-decoration: line-through;">{{ SessionUser.functions.currency.toLocal(addon_item.price) }}</td>
</tr>
<tr
v-if="getOrderItemFlags(addon_item).length > 0"
:data-testid="`order-content-item-flags-${addon_item.id}`"
>
<td :colspan="displayColumnCount">
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(addon_item)"
@statusChanged="emitFlagStatusChanged"
/>
</td>
</tr>
</template>
</template>
</tbody>
</table>
<!-- Summary Mode -->
<div v-else>
<!-- Loader -->
<template v-if="orderItems.length === 0">
<span class="is-flex is-justify-content-center is-align-items-center" style="height: 100px">
<span class="is-flex is-justify-content-center is-align-items-center" style="height: 100px;">
<span class="loader is-loading is-align-self-center"></span>
</span>
</template>
@@ -303,7 +278,11 @@ watch(
<strong>{{ item.product.name }}</strong> x {{ item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(item.price) }}</span>
</div>
<InvoicingPeriodFlagList compact :flags="getOrderItemFlags(item)" @statusChanged="emitFlagStatusChanged" />
<InvoicingPeriodFlagList
compact
:flags="getOrderItemFlags(item)"
@statusChanged="emitFlagStatusChanged"
/>
<!-- Show the add-on items -->
<div v-for="addon_item in getItemAddons(item)" :key="addon_item.id" class="ml-4">
<small>
@@ -315,7 +294,7 @@ watch(
>
<i :class="getOrderItemFlagIconClass(addon_item)"></i>
</span>
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
+ {{ addon_item.product.name }} x {{ addon_item.quantity }}
<span v-if="props.displayPrice"> - {{ SessionUser.functions.currency.toLocal(addon_item.price) }}</span>
</small>
<InvoicingPeriodFlagList
@@ -6,7 +6,6 @@ import { usePaginatedListInstance } from "@/components/pagination/paginatedList.
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import { selfServeAccessLabel } from "@/components/session/subuser/subuserPermissionLabels.js";
const SELF_SERVE_PERMISSION_KEYS = ["SELFSERVE_LIST", "SELFSERVE_EDIT", "SELFSERVE_DELETE", "SELFSERVE_ADD"];
const DOGNVASK_PERMISSION_KEYS = ["SELFSERVE_LIST", "SELFSERVE_ADD"];
@@ -458,7 +457,7 @@ const buildDognvaskPermissions = (subuser, enabled) => {
const apiErrorMessage = (error, fallback) =>
error?.response?.data?.data?.message || error?.response?.data?.message || error?.message || fallback;
const updateDognvask = async (subuser, enabled, rejectOnFailure = false) => {
const onToggleDognvask = async (subuser, enabled) => {
if (isDognvaskDisabled(subuser)) {
return;
}
@@ -475,16 +474,9 @@ const updateDognvask = async (subuser, enabled, rejectOnFailure = false) => {
icon: "error",
confirmButtonText: "Luk",
});
if (rejectOnFailure) {
throw error;
}
}
};
const onToggleDognvask = (subuser, enabled) => updateDognvask(subuser, enabled);
const onToggleDognvaskFromSettingsWheel = (subuser, enabled) =>
updateDognvask(subuser, enabled, true);
const onEditPermissions = async (subuser) => {
if (!subuser?.grant_id) {
return;
@@ -1107,15 +1099,10 @@ const menuSection = (key, label, items) => ({
const driverWheelText = (key, values = undefined) =>
t(`templates.generated.compat.superuser.driver_wheel.${key}`, values);
const selfServeActionLabel = computed(() => selfServeAccessLabel());
const verificationStatusOptions = () => [
{ value: true, label: driverContactText("verified") },
{ value: false, label: driverContactText("unverified") },
];
const booleanStatusOptions = () => [
{ value: true, label: t("common.active") },
{ value: false, label: t("common.inactive") },
];
const contactVerificationStatusItem = (subuser, channel, key, icon) => {
const testId = `subuser-action-${channel}-verification-${key}`;
if (!canManageContactVerification(subuser, channel)) {
@@ -1471,26 +1458,6 @@ const accessItemsForGrant = (grant) => {
testId: `subuser-assign-vehicle-${key}`,
})
: menuLabel("subuser-plate", "fas fa-car-side", "Nummerplade", "", formatPlate(grant)),
canEditPermissions() && grant.grant_id
? menuSelect("subuser-self-serve", {
icon: "fas fa-toolbox",
label: selfServeActionLabel.value,
value: dognvaskEnabled(grant),
options: booleanStatusOptions(),
disabled: isDognvaskDisabled(grant),
changeAction: (enabled) =>
onToggleDognvaskFromSettingsWheel(grant, enabled === true || enabled === "true"),
testId: `subuser-self-serve-${key}`,
})
: grant.grant_id
? menuLabel(
"subuser-self-serve",
"fas fa-toolbox",
selfServeActionLabel.value,
"",
dognvaskEnabled(grant) ? t("common.active") : t("common.inactive")
)
: null,
canEditPermissions() && grant.grant_id
? menuAction("subuser-permissions", {
icon: "fas fa-user-shield",
@@ -1645,7 +1612,7 @@ const actionSections = (subuser) => {
<th>Chauffør</th>
<th>Tlf.</th>
<th>Nummerplade</th>
<th>{{ selfServeActionLabel }}</th>
<th>Kundeadgange</th>
<th class="has-text-right action-column">Handlinger</th>
<th class="has-text-right remove-column"></th>
</tr>
@@ -1829,7 +1796,7 @@ const actionSections = (subuser) => {
</div>
<div class="subusers-table__access-control">
<span class="subusers-table__access-control-label">{{ selfServeActionLabel }}</span>
<span class="subusers-table__access-control-label">Døgnvask</span>
<b-tooltip
v-if="isDognvaskDisabled(grant)"
:label="dognvaskDisabledReason(grant)"
@@ -1,35 +1,21 @@
<script setup>
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { onBeforeUnmount, reactive, ref } from "vue";
import axios from "axios";
import { API_URL } from "@/config.js";
import { getError, parseError, removeError } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { economicEanPayloadValue, normalizeEconomicEan } from "@/services/economicCustomerIdentifiers.js";
import {
getPublicRecaptchaConfig,
renderPublicRecaptcha,
resetPublicRecaptcha,
} from "@/services/publicRecaptcha.js";
const customerFormData = reactive({
cvr: null,
contactEmail: null,
contactPhone: null,
ean: null,
ean: null
});
const deliverSuccess = ref(false);
const recaptchaElement = ref(null);
const recaptchaConfig = ref({ enabled: false, site_key: "" });
const recaptchaToken = ref("");
const recaptchaWidgetId = ref(null);
let deliverTimeoutId;
const resetRecaptcha = () => {
recaptchaToken.value = "";
resetPublicRecaptcha(recaptchaWidgetId.value);
};
const submitCustomer = async () => {
removeError("authRegisterCvr");
try {
@@ -38,8 +24,7 @@ const submitCustomer = async () => {
invoiceEmail: customerFormData.contactEmail,
contactEmail: customerFormData.contactEmail,
contactPhone: customerFormData.contactPhone,
companyPhone: customerFormData.contactPhone,
...(recaptchaConfig.value.enabled ? { g_recaptcha_response: recaptchaToken.value } : {}),
companyPhone: customerFormData.contactPhone
};
const ean = economicEanPayloadValue(customerFormData.ean);
if (ean !== undefined) {
@@ -54,39 +39,14 @@ const submitCustomer = async () => {
customerFormData.ean = null;
deliverSuccess.value = true;
resetRecaptcha();
deliverTimeoutId = setTimeout(() => {
deliverSuccess.value = false;
}, 3500);
} catch (error) {
resetRecaptcha();
parseError(error, "authRegisterCvr");
}
};
onMounted(async () => {
try {
recaptchaConfig.value = await getPublicRecaptchaConfig();
if (!recaptchaConfig.value.enabled) {
return;
}
await nextTick();
recaptchaWidgetId.value = await renderPublicRecaptcha(recaptchaElement.value, recaptchaConfig.value.site_key, {
callback: (token) => {
recaptchaToken.value = token;
},
expiredCallback: () => {
recaptchaToken.value = "";
},
errorCallback: () => {
recaptchaToken.value = "";
},
});
} catch (error) {
parseError(error, "authRegisterCvr");
}
});
onBeforeUnmount(() => {
if (deliverTimeoutId) {
clearTimeout(deliverTimeoutId);
@@ -95,7 +55,7 @@ onBeforeUnmount(() => {
</script>
<template>
<form class="truckwash-form" data-testid="customer-registration-form" @submit.prevent="() => {}" method="post">
<form class="truckwash-form" @submit.prevent="() => {}" method="post">
<div class="form-field">
<label for="customer_cvr">{{ $t("customer_creation.customer.cvr_label") }}</label>
<input
@@ -105,7 +65,7 @@ onBeforeUnmount(() => {
inputmode="numeric"
autocomplete="organization"
placeholder="12345678"
/>
>
</div>
<div class="form-field">
@@ -119,12 +79,12 @@ onBeforeUnmount(() => {
autocomplete="off"
placeholder="5790001234567"
@input="customerFormData.ean = normalizeEconomicEan($event.target.value)"
/>
>
</div>
<div class="form-field">
<label for="email">{{ $t("customer_creation.customer.email_label") }}</label>
<input id="email" v-model="customerFormData.contactEmail" type="email" name="email" autocomplete="email" />
<input id="email" v-model="customerFormData.contactEmail" type="email" name="email" autocomplete="email">
</div>
<div class="form-field">
@@ -137,36 +97,21 @@ onBeforeUnmount(() => {
inputmode="tel"
autocomplete="tel"
placeholder="12345678"
/>
>
</div>
<p v-if="getError('authRegisterCvr')" id="customer_creation_alert_error" class="help is-danger">
{{ getError("authRegisterCvr") }}
</p>
<div v-if="recaptchaConfig.enabled" class="form-field" data-testid="customer-registration-recaptcha">
<div ref="recaptchaElement"></div>
</div>
<p
v-if="deliverSuccess"
id="customer_creation_alert_success"
class="help is-success"
data-testid="customer-registration-success"
role="status"
>
<p v-if="deliverSuccess" id="customer_creation_alert_success" class="help is-success">
{{ $t("customer_creation.customer.success_message") }}
</p>
<LoadButtonWhileAwait
id="customer-register-button"
class="truckwash-submit"
:disabled="
!customerFormData.cvr ||
!customerFormData.contactPhone ||
!customerFormData.contactEmail ||
(recaptchaConfig.enabled && !recaptchaToken)
"
:disabled="!customerFormData.cvr || !customerFormData.contactPhone || !customerFormData.contactEmail"
:loadFunction="submitCustomer"
>
{{ $t("customer_creation.customer.submit") }}
@@ -1,79 +1,39 @@
<script setup>
import { nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { onBeforeUnmount, reactive, ref } from "vue";
import axios from "axios";
import { API_URL } from "@/config.js";
import { getError, parseError, removeError } from "@/components/request/HandleGlobalError.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import {
getPublicRecaptchaConfig,
renderPublicRecaptcha,
resetPublicRecaptcha,
} from "@/services/publicRecaptcha.js";
const driverFormData = reactive({
driverCvr: null,
driverPhone: null,
driverPhone: null
});
const deliverSuccess = ref(false);
const recaptchaElement = ref(null);
const recaptchaConfig = ref({ enabled: false, site_key: "" });
const recaptchaToken = ref("");
const recaptchaWidgetId = ref(null);
let deliverTimeoutId;
const resetRecaptcha = () => {
recaptchaToken.value = "";
resetPublicRecaptcha(recaptchaWidgetId.value);
};
const submitDriver = async () => {
removeError("authRegisterDriverCvr");
try {
await axios.post(API_URL + "/subusers/me", {
await axios.post(API_URL + "/subusers", {
cvr: driverFormData.driverCvr,
phone: driverFormData.driverPhone,
phone_country_code: "45",
...(recaptchaConfig.value.enabled ? { g_recaptcha_response: recaptchaToken.value } : {}),
phone_country_code: "45"
});
driverFormData.driverCvr = null;
driverFormData.driverPhone = null;
deliverSuccess.value = true;
resetRecaptcha();
deliverTimeoutId = setTimeout(() => {
deliverSuccess.value = false;
}, 3500);
} catch (error) {
resetRecaptcha();
parseError(error, "authRegisterDriverCvr");
}
};
onMounted(async () => {
try {
recaptchaConfig.value = await getPublicRecaptchaConfig();
if (!recaptchaConfig.value.enabled) {
return;
}
await nextTick();
recaptchaWidgetId.value = await renderPublicRecaptcha(recaptchaElement.value, recaptchaConfig.value.site_key, {
callback: (token) => {
recaptchaToken.value = token;
},
expiredCallback: () => {
recaptchaToken.value = "";
},
errorCallback: () => {
recaptchaToken.value = "";
},
});
} catch (error) {
parseError(error, "authRegisterDriverCvr");
}
});
onBeforeUnmount(() => {
if (deliverTimeoutId) {
clearTimeout(deliverTimeoutId);
@@ -82,7 +42,7 @@ onBeforeUnmount(() => {
</script>
<template>
<form class="truckwash-form" data-testid="driver-registration-form" @submit.prevent="() => {}" method="post">
<form class="truckwash-form" @submit.prevent="() => {}" method="post">
<div class="form-field">
<label for="driver_cvr">{{ $t("customer_creation.driver.cvr_label") }}</label>
<input
@@ -93,7 +53,7 @@ onBeforeUnmount(() => {
inputmode="numeric"
autocomplete="organization"
placeholder="12345678"
/>
>
</div>
<div class="form-field">
@@ -106,31 +66,21 @@ onBeforeUnmount(() => {
inputmode="tel"
autocomplete="tel"
placeholder="12345678"
/>
>
</div>
<p v-if="getError('authRegisterDriverCvr')" id="driver_creation_alert_error" class="help is-danger">
{{ getError("authRegisterDriverCvr") }}
</p>
<div v-if="recaptchaConfig.enabled" class="form-field" data-testid="driver-registration-recaptcha">
<div ref="recaptchaElement"></div>
</div>
<p
v-if="deliverSuccess"
id="driver_creation_alert_success"
class="help is-success"
data-testid="driver-registration-success"
role="status"
>
<p v-if="deliverSuccess" id="driver_creation_alert_success" class="help is-success">
{{ $t("customer_creation.driver.success_message") }}
</p>
<LoadButtonWhileAwait
id="driver-register-button"
class="truckwash-submit"
:disabled="!driverFormData.driverCvr || !driverFormData.driverPhone || (recaptchaConfig.enabled && !recaptchaToken)"
:disabled="!driverFormData.driverCvr || !driverFormData.driverPhone"
:loadFunction="submitDriver"
>
{{ $t("customer_creation.driver.submit") }}
-30
View File
@@ -205,18 +205,6 @@ onMounted(async () => {
<router-link :to="{name: 'password-reset'}" class="" id="forgot-password-button">{{ $t('auth.forgot_password') }}</router-link>
<LoadButtonWhileAwait :loadFunction="login" class="submit" id="login-button" data-testid="login-submit">{{ $t('auth.login') }}</LoadButtonWhileAwait>
<router-link :to="{name: 'loginqr'}" class="submit">{{ $t('auth.login_with_qr_code') }}</router-link>
<!-- Driver login entry point discoverable from any login surface so drivers
don't need the direct /login/driver URL. -->
<router-link
:to="{name: 'subuserlogin'}"
class="submit driver-login-link"
id="driver-login-button"
data-testid="driver-login-link"
>
<span class="icon"><i class="fas fa-truck"></i></span>
<span>{{ $t('auth.driver_login_button') }}</span>
</router-link>
<!-- Passkey login button -->
<button
@@ -293,22 +281,4 @@ form input:not([type="checkbox"]) {
.passkey-btn {
gap: 8px;
}
/* Driver login button — visually distinct from the primary submit so drivers
notice it as a separate path. */
.driver-login-link {
background-color: #1584BC;
color: #fff;
border: 0;
border-radius: 30px;
margin-top: 12px;
min-height: 43px;
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.driver-login-link:hover { background-color: #1a99d6; color: #fff; }
.driver-login-link .icon { display: inline-flex; }
</style>
@@ -79,7 +79,7 @@ const onSelectCardPayment = () => {
return;
}
selectCustomer({name: 'Kortbetaling', customerNumber: 999});
selectCustomer({name: 'Stripe Payment', customerNumber: 999});
};
</script>
@@ -20,7 +20,7 @@ import {
loadCustomerAttributes
} from "@/components/shop/POSDepartmentProcess.vue";
import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem } from "@/components/shop/OrdersItems.vue";
import {createOrderItem} from "@/components/shop/OrdersItems.vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { useRoute } from 'vue-router';
import { useI18n } from "vue-i18n";
@@ -435,18 +435,6 @@ const showAddMultipleProducts = (productId) => {
});
};
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const productRequiresOrderItemNote = (product) => {
if (!product) {
return false;
}
return Boolean(product.requires_note)
|| AUDITED_ORDER_ITEM_PRODUCT_IDS.has(Number(product.id))
|| String(product.name || "").trim() === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME;
};
// Define the categories
const categories_static = ref([
{
@@ -648,7 +636,7 @@ const addProductWithAddonsToOrder = async (product_id) => {
return;
}
// Check if the product requires a note
if (productRequiresOrderItemNote(product)) {
if (product.requires_note) {
// Show the note input
await Swal.fire({
title: 'Tilføj en note',
@@ -215,7 +215,7 @@ const onSelectCardPayment = () => {
showSelector.value = false;
const cardPaymentCustomer = {
name: "Kortbetaling",
name: "Stripe Payment",
customerNumber: 999,
};
@@ -2,7 +2,7 @@
import { useRouter } from "vue-router";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
import { computed, onBeforeUnmount, onMounted, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { pingApiServer } from "@/services/apiHealth.js";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
@@ -14,6 +14,7 @@ const props = defineProps({
});
const router = useRouter();
const showDebugInfo = ref(false);
const isSessionInitiated = computed(() => SessionUser.isInitiated());
let sessionTimeout = null;
@@ -85,17 +86,51 @@ onBeforeUnmount(clearSessionTimeout);
aria-label="Bekræfter bruger"
/>
<slot v-else-if="props.hasPermission" />
<div v-else data-testid="restricted-forbidden">
<div class="message is-danger my-6">
<div v-else>
<div class="my-6">
<h2 class="title is-2">403 Forbidden</h2>
<h4 class="subtitle is-4">You do not have permission to view this page.</h4>
</div>
<div class="message is-danger">
<div class="message-body">
<h2 class="title is-3">403</h2>
<p>{{ $t("errors.forbidden") }}</p>
<h2>403 Forbidden</h2>
<p>You do not have permission to view this page.</p>
</div>
</div>
<!-- Simple checks -->
<p v-if="router.currentRoute.value.path === '/user'">
You are on the /user page. <br />You do {{ SessionUser.hasPermission("user") ? "" : "not" }} have permission (
user ) to view this page.
</p>
<p v-if="router.currentRoute.value.path === '/admin'">
You are on the /admin page. <br />You do {{ SessionUser.hasPermission("admin") ? "" : "not" }} have permission (
admin ) to view this page.
</p>
<p v-if="router.currentRoute.value.path === '/superuser'">
You are on the /superuser <br />page. You do {{ SessionUser.hasPermission("superuser") ? "" : "not" }} have
permission ( superuser ) to view this page.
</p>
<div v-if="showDebugInfo">
<div class="message mb-6">
<div class="message-header">
<p>Debug information</p>
<button class="delete" aria-label="delete" @click="showDebugInfo = false"></button>
</div>
<div class="message-body">
<p class="label">Session user:</p>
<pre>{{ SessionUser }}</pre>
</div>
</div>
</div>
<div class="buttons">
<button class="button is-dark" data-testid="restricted-go-back" @click="router.go(-1)">
{{ $t("common.back") }}
<!-- Button to go back to the previous page -->
<button class="button is-dark" @click="router.go(-1)">Go back</button>
<!-- Button to see the debug information -->
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-if="!showDebugInfo">
Show debug information
</button>
<!-- Button to hide the debug information -->
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-else>Hide debug information</button>
</div>
</div>
</div>
@@ -0,0 +1,55 @@
<script setup>
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import { handleEconomicError } from "@/components/request/HandleEconomicError.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
const props = defineProps(["order_id"]);
const exportOrderToInvoiceStripe = async () => {
// Get the email to send the invoice to
await Swal.fire({
title: "Indtast email til faktura",
input: "email",
inputAttributes: {
autocapitalize: "off",
},
showCancelButton: true,
confirmButtonText: "Send faktura",
showLoaderOnConfirm: true,
preConfirm: (email) => {
return authenticatedRequest("/modules/stripe/invoice", "POST", {
email,
order_id: Number.parseInt(String(props.order_id), 10),
})
.then((response) => {
console.log(response);
Swal.fire({
title: "Faktura sendt",
icon: "success",
});
})
.catch((error) => {
handleEconomicError(error);
});
},
allowOutsideClick: () => !Swal.isLoading(),
});
};
</script>
<template>
<LoadButtonWhileAwait
class="is-success is-inverted"
:loadFunction="exportOrderToInvoiceStripe"
icon="fas fa-file-invoice"
actionKey="economic-invoice-stripe-export-submit"
copyKey="complete"
>
{{ SessionUser.objects.global.language.complete }}
</LoadButtonWhileAwait>
</template>
<style scoped></style>
@@ -1,262 +1,247 @@
<script setup>
import { computed, watch } from "vue";
import i18n from "@/i18n";
import { computed, watch } from 'vue'
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
normalizeSubuserGrantOptions,
permissionGroupsForGrant,
} from "@/components/session/subuser/subuserGrantOptions.js";
import { IS_DEV } from '@/config.js';
defineProps({
showLabel: { type: Boolean, default: true },
compact: { type: Boolean, default: false },
});
const t = (key, values = undefined) => i18n.global.t(key, values);
const grants = computed(() => SessionUser.subuser.grants.value || []);
const selectedCustomerNumber = computed(() => SessionUser.subuser.selectedGrantCustomerNumber.value);
const isSubuser = computed(() => SessionUser.isSubuser.value);
const grants = computed(() => normalizeSubuserGrantOptions(SessionUser.subuser.grants.value));
// Auto-select if subuser has only one grant
watch(
grants,
() => grants.value,
(newGrants) => {
const selectedStillExists = newGrants.some(
(grant) => grant.billing_customer_number === selectedCustomerNumber.value
);
if (!selectedStillExists) {
SessionUser.subuser.selectGrant(newGrants.length === 1 ? newGrants[0].billing_customer_number : null);
if (isSubuser.value && newGrants.length === 1 && !selectedCustomerNumber.value) {
SessionUser.subuser.selectGrant(newGrants[0].billing_customer_number);
}
},
{ immediate: true }
);
const selectGrant = (customerNumber) => SessionUser.subuser.selectGrant(customerNumber);
const selectedGrant = computed(
() => grants.value.find((grant) => grant.billing_customer_number === selectedCustomerNumber.value) || null
);
const customerLabel = (grant) =>
grant?.name || `${t("common.customer")} #${grant?.billing_customer_number || "-"}`;
const selectedGrantName = computed(() =>
selectedGrant.value
? customerLabel(selectedGrant.value)
: t("user_home.select_customer")
);
const selectGrant = (customerNumber) => {
SessionUser.subuser.selectGrant(customerNumber);
};
const visiblePermissionGroups = permissionGroupsForGrant;
const selectedGrant = computed(() => {
if (!selectedCustomerNumber.value) return null;
return grants.value.find(g => g.billing_customer_number === selectedCustomerNumber.value);
});
const selectedGrantName = computed(() => {
if (!selectedGrant.value) return 'Vælg kunde';
return selectedGrant.value.name || 'Kunde #' + selectedGrant.value.billing_customer_number;
});
</script>
<template>
<div
v-if="isSubuser && grants.length === 0"
class="grant-selector no-permissions"
:class="{ 'is-compact': compact }"
data-testid="subuser-grant-empty"
>
<b-message type="is-warning" size="is-small" has-icon>
{{ t("superuser.driver_access.summary.no_permissions") }}
</b-message>
<!-- No permissions message for subusers without grants -->
<div class="grant-selector no-permissions" :class="{ 'is-compact': compact }" v-if="isSubuser && grants.length === 0">
<div v-if="compact" class="no-permissions-compact">
<span class="icon has-text-warning">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span class="has-text-grey">Ingen tilladelser</span>
</div>
<div v-else class="notification is-warning is-light">
<span class="icon">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>Du har ingen tilladelser tildelt. Kontakt venligst din administrator for at adgang.</span>
</div>
</div>
<div
v-else-if="isSubuser && grants.length > 0"
class="grant-selector"
:class="{ 'is-compact': compact }"
data-testid="subuser-grant-selector"
>
<label v-if="showLabel" class="label">
{{ t("user_home.select_customer") }}
</label>
<b-dropdown
position="is-bottom-left"
append-to-body
aria-role="list"
:mobile-modal="false"
class="grant-selector__dropdown"
>
<template #trigger>
<b-button
class="grant-selector__trigger"
:size="compact ? 'is-small' : undefined"
icon-left="building"
icon-right="chevron-down"
expanded
data-testid="subuser-grant-trigger"
>
<span class="grant-selector__trigger-label">{{ selectedGrantName }}</span>
<span v-if="selectedGrant" class="grant-selector__trigger-icons" aria-hidden="true">
<i
v-for="group in visiblePermissionGroups(selectedGrant)"
:key="group.key"
:class="[group.icon, group.color]"
></i>
<div class="grant-selector" :class="{ 'is-compact': compact }" v-else-if="isSubuser && grants.length > 0">
<label v-if="showLabel" class="label">Vælg kunde</label>
<!-- Compact mode: Button-style like user element in header -->
<div v-if="compact" class="dropdown is-hoverable">
<div class="dropdown-trigger">
<button class="button is-white" aria-haspopup="true" aria-controls="grant-dropdown-menu">
<span class="icon">
<i class="fas fa-building" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}"></i>
</span>
</b-button>
</template>
<b-dropdown-item
v-for="grant in grants"
:key="grant.billing_customer_number"
aria-role="listitem"
custom
focusable
:data-testid="`subuser-grant-option-${grant.billing_customer_number}`"
>
<button
type="button"
class="grant-selector__option"
:class="{ 'is-active': grant.billing_customer_number === selectedCustomerNumber }"
@click="selectGrant(grant.billing_customer_number)"
>
<span class="grant-selector__option-check" aria-hidden="true">
<i
:class="
grant.billing_customer_number === selectedCustomerNumber
? 'fas fa-check-circle has-text-success'
: 'far fa-circle has-text-grey-light'
"
></i>
</span>
<span class="grant-selector__option-copy">
<strong>{{ customerLabel(grant) }}</strong>
<small>#{{ grant.billing_customer_number }}</small>
</span>
<span class="grant-selector__permissions">
<b-tooltip
v-for="group in visiblePermissionGroups(grant)"
:key="group.key"
:label="t(`superuser.driver_access.groups.${group.key}`)"
position="is-top"
>
<span class="grant-selector__permission-icon" :class="group.color">
<i :class="group.icon"></i>
</span>
</b-tooltip>
<span
v-if="visiblePermissionGroups(grant).length === 0"
class="grant-selector__permission-icon has-text-grey-light"
:title="t('superuser.driver_access.summary.no_permissions')"
>
<i class="fas fa-lock"></i>
</span>
<span :class="{'has-text-black': !IS_DEV, 'has-text-red': IS_DEV}">{{ selectedGrantName }}</span>
<span class="icon is-small">
<i class="fas fa-angle-down" :class="{'transform-color-black': !IS_DEV, 'transform-color-red': IS_DEV}" aria-hidden="true"></i>
</span>
</button>
</b-dropdown-item>
</b-dropdown>
</div>
<div class="dropdown-menu" id="grant-dropdown-menu" role="menu">
<div class="dropdown-content">
<a
v-for="grant in grants"
:key="grant.billing_customer_number"
class="dropdown-item"
:class="{ 'is-active': grant.billing_customer_number === selectedCustomerNumber }"
@click="selectGrant(grant.billing_customer_number)"
>
<span class="dropdown-item-check">
<i class="fas fa-check" v-if="grant.billing_customer_number === selectedCustomerNumber"></i>
</span>
<span class="dropdown-item-text">{{ grant.name || 'Kunde #' + grant.billing_customer_number }}</span>
</a>
</div>
</div>
</div>
<!-- Full mode: Select with icon prefix -->
<div v-else class="field has-addons is-fullwidth">
<p class="control">
<span class="button is-static">
<span class="icon">
<i class="fas fa-building"></i>
</span>
</span>
</p>
<p class="control is-expanded">
<span class="select is-fullwidth">
<select
:value="selectedCustomerNumber"
@change="selectGrant($event.target.value ? parseInt($event.target.value) : null)"
>
<option :value="null" disabled>-- Vælg kunde --</option>
<option
v-for="grant in grants"
:key="grant.billing_customer_number"
:value="grant.billing_customer_number"
>
{{ grant.name || 'Kunde #' + grant.billing_customer_number }}
</option>
</select>
</span>
</p>
</div>
</div>
</template>
<style scoped>
.grant-selector {
margin-bottom: 1rem;
min-width: min(100%, 19rem);
}
.grant-selector.is-compact {
margin-bottom: 0;
min-width: 0;
}
.grant-selector__dropdown,
.grant-selector__trigger {
width: 100%;
.grant-selector.is-compact .dropdown-trigger .button {
border: none;
box-shadow: none;
}
.grant-selector__trigger {
justify-content: flex-start;
.grant-selector.is-compact .dropdown-menu {
min-width: 250px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.grant-selector__trigger-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.grant-selector.is-compact .dropdown-content {
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.05);
padding: 0.5rem 0;
}
.grant-selector__trigger-icons {
display: inline-flex;
gap: 0.35rem;
margin-left: auto;
}
.grant-selector__option {
align-items: center;
background: transparent;
border: 0;
border-radius: 0.45rem;
color: #25344d;
.grant-selector.is-compact .dropdown-item {
cursor: pointer;
display: grid;
gap: 0.65rem;
grid-template-columns: 1.25rem minmax(8rem, 1fr) auto;
padding: 0.7rem 0.8rem;
text-align: left;
width: min(26rem, calc(100vw - 2rem));
}
.grant-selector__option:hover,
.grant-selector__option:focus-visible {
background: #f2f6fb;
outline: none;
}
.grant-selector__option.is-active {
background: #edf8f1;
}
.grant-selector__option-copy {
padding: 0.625rem 1rem;
font-size: 0.9rem;
transition: background-color 0.15s ease, color 0.15s ease;
border-radius: 0;
display: flex;
flex-direction: column;
min-width: 0;
align-items: center;
gap: 0.5rem;
}
.grant-selector__option-copy strong {
.grant-selector.is-compact .dropdown-item:hover {
background-color: #f5f5f5;
}
.grant-selector.is-compact .dropdown-item.is-active {
background-color: #3273dc;
color: white;
font-weight: 500;
}
.grant-selector.is-compact .dropdown-item.is-active:hover {
background-color: #2366d1;
}
.grant-selector.is-compact .dropdown-item-check {
width: 1.25rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: #3273dc;
}
.grant-selector.is-compact .dropdown-item.is-active .dropdown-item-check {
color: white;
}
.grant-selector.is-compact .dropdown-item-text {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.grant-selector__option-copy small {
color: #6b7280;
.label {
font-weight: 600;
margin-bottom: 0.5rem;
display: block;
}
.grant-selector__permissions {
align-items: center;
display: inline-flex;
gap: 0.3rem;
}
.grant-selector__permission-icon {
align-items: center;
background: currentColor;
border-radius: 999px;
display: inline-flex;
height: 1.65rem;
justify-content: center;
width: 1.65rem;
}
.grant-selector__permission-icon i {
color: white;
font-size: 0.72rem;
}
.no-permissions :deep(.message) {
.field.has-addons {
margin-bottom: 0;
}
@media (max-width: 768px) {
.grant-selector__trigger-icons {
display: none;
}
.button.is-static {
background-color: #f5f5f5;
border-color: #dbdbdb;
}
.grant-selector__option {
grid-template-columns: 1.25rem minmax(6rem, 1fr);
}
.transform-color-black {
filter: invert(100%) grayscale(100%) brightness(0) contrast(100%);
}
.grant-selector__permissions {
grid-column: 2;
}
.transform-color-red {
filter: invert(27%) sepia(96%) saturate(7493%) hue-rotate(357deg) brightness(103%) contrast(101%);
}
.help {
font-size: 0.85rem;
color: #666;
margin-top: 0.5rem;
}
/* No permissions styles */
.no-permissions .notification {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.no-permissions-compact {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
}
.no-permissions-compact .icon {
font-size: 0.9rem;
}
.no-permissions-compact span:not(.icon) {
font-size: 0.9rem;
}
</style>
@@ -1,38 +0,0 @@
export const SUBUSER_PERMISSION_GROUPS = [
{ key: "vehicles", prefix: "VEHICLES_", icon: "fas fa-truck", color: "has-text-info" },
{ key: "selfserve", prefix: "SELFSERVE_", icon: "fas fa-toolbox", color: "has-text-primary" },
{ key: "bookings", prefix: "BOOKINGS_", icon: "fas fa-calendar-check", color: "has-text-success" },
{ key: "orders", prefix: "ORDERS_", icon: "fas fa-receipt", color: "has-text-warning-dark" },
{ key: "driver_management", prefix: "SUBUSERS_", icon: "fas fa-users-cog", color: "has-text-link" },
];
export const normalizeSubuserGrantOptions = (source = []) => {
const byCustomer = new Map();
for (const grant of source || []) {
const customerNumber = Number(grant?.billing_customer_number);
if (!Number.isInteger(customerNumber) || customerNumber <= 0) continue;
const existing = byCustomer.get(customerNumber);
byCustomer.set(customerNumber, {
...(existing || {}),
...grant,
billing_customer_number: customerNumber,
enabled: Boolean(existing?.enabled || grant?.enabled),
name: String(existing?.name || grant?.name || "").trim(),
permissions: [...new Set([...(existing?.permissions || []), ...(grant?.permissions || [])])],
});
}
return [...byCustomer.values()].sort((left, right) => {
const leftName = left.name || String(left.billing_customer_number);
const rightName = right.name || String(right.billing_customer_number);
return leftName.localeCompare(rightName, undefined, { numeric: true, sensitivity: "base" });
});
};
export const permissionGroupsForGrant = (grant) => {
const permissions = new Set((grant?.permissions || []).map((permission) => String(permission).toUpperCase()));
return SUBUSER_PERMISSION_GROUPS.filter((group) =>
[...permissions].some((permission) => permission.startsWith(group.prefix))
);
};
@@ -1,32 +0,0 @@
import i18n from "@/i18n";
export const SELF_SERVE_PERMISSION_LABEL_KEY = "start_selfserve";
export const PERMISSION_CAPABILITY_LABEL_KEYS = {
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: SELF_SERVE_PERMISSION_LABEL_KEY,
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",
};
export const permissionCapabilityLabel = (permissionKey) => {
const normalizedKey = String(permissionKey || "").trim().toUpperCase();
const capabilityKey = PERMISSION_CAPABILITY_LABEL_KEYS[normalizedKey] || normalizedKey;
return i18n.global.t(`superuser.driver_access.capabilities.${capabilityKey}`);
};
export const selfServeAccessLabel = () => permissionCapabilityLabel("SELFSERVE_ADD");
+11 -17
View File
@@ -254,20 +254,6 @@ export const refreshReleaseRuntime = async ({ throwOnError = false } = {}) => {
}
};
const resetEconomicData = () => {
SessionUser.economicData.customerNumber.value = null;
SessionUser.economicData.name.value = null;
SessionUser.economicData.address.value = null;
SessionUser.economicData.zip.value = null;
SessionUser.economicData.city.value = null;
SessionUser.economicData.mobilePhone.value = null;
SessionUser.economicData.email.value = null;
SessionUser.economicData.cvr.value = null;
SessionUser.economicData.currency.value = null;
SessionUser.economicData.country.value = null;
SessionUser.economicData.cached_at.value = null;
};
const resetSessionState = () => {
SessionUser.user.id.value = null;
SessionUser.user.customer_number.value = null;
@@ -316,7 +302,17 @@ const resetSessionState = () => {
}
}
resetEconomicData();
SessionUser.economicData.customerNumber.value = null;
SessionUser.economicData.name.value = null;
SessionUser.economicData.address.value = null;
SessionUser.economicData.zip.value = null;
SessionUser.economicData.city.value = null;
SessionUser.economicData.mobilePhone.value = null;
SessionUser.economicData.email.value = null;
SessionUser.economicData.cvr.value = null;
SessionUser.economicData.currency.value = null;
SessionUser.economicData.country.value = null;
SessionUser.economicData.cached_at.value = null;
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = null;
@@ -702,8 +698,6 @@ export const getSessionData = async () => {
SessionUser.economicData.currency.value = session.economic_customer.currency;
SessionUser.economicData.country.value = session.economic_customer.country;
SessionUser.economicData.cached_at.value = new Date();
} else {
resetEconomicData();
}
SessionUser.initiated.value = true;
})
@@ -516,24 +516,12 @@ export const CollectedOrderInvoices = {
throw error;
});
},
bulk_action_preview: async (
action,
invoiceCollectionIds = [],
options = {},
locale = null,
snapshotBinding = {}
) => {
bulk_action_preview: async (action, invoiceCollectionIds = [], options = {}, locale = null) => {
return authenticatedRequest('/collected-invoices/bulk-actions/preview', 'POST', {
action,
invoice_collection_ids: invoiceCollectionIds.map((id) => parseInt(id)).filter((id) => Number.isInteger(id) && id > 0),
options,
...(locale ? { locale } : {}),
...(Number.isInteger(parseInt(snapshotBinding.customer_number))
? { customer_number: parseInt(snapshotBinding.customer_number) }
: {}),
...(snapshotBinding.snapshot_revision
? { snapshot_revision: String(snapshotBinding.snapshot_revision) }
: {}),
}).then((response) => {
console.log(response);
return response;
@@ -549,8 +537,6 @@ export const CollectedOrderInvoices = {
options = {},
confirmation_text,
locale = null,
customer_number = null,
snapshot_revision = null,
}) => {
return authenticatedRequest('/collected-invoices/bulk-actions/apply', 'POST', {
preview_id,
@@ -559,8 +545,6 @@ export const CollectedOrderInvoices = {
options,
confirmation_text,
...(locale ? { locale } : {}),
...(Number.isInteger(parseInt(customer_number)) ? { customer_number: parseInt(customer_number) } : {}),
...(snapshot_revision ? { snapshot_revision: String(snapshot_revision) } : {}),
}).then((response) => {
console.log(response);
return response;
@@ -569,28 +553,6 @@ export const CollectedOrderInvoices = {
throw error;
});
},
period_tree_action_preview: async ({
action,
invoice_collection_ids = [],
options = {},
customer_number,
snapshot_revision,
locale = null,
}) => authenticatedRequest('/superuser/invoicing/period/tree-actions/preview', 'POST', {
action,
invoice_collection_ids: invoice_collection_ids
.map((id) => parseInt(id))
.filter((id) => Number.isInteger(id) && id > 0),
options,
customer_number: parseInt(customer_number),
snapshot_revision: String(snapshot_revision || ''),
...(locale ? { locale } : {}),
}),
period_tree_action_apply: async ({ preview_id, confirmation_text }) =>
authenticatedRequest('/superuser/invoicing/period/tree-actions/apply', 'POST', {
preview_id: String(preview_id || ''),
confirmation_text: String(confirmation_text || ''),
}),
move_to_customer: async (id, customerNumber) => {
const invoiceCollectionId = parseInt(id);
const targetCustomerNumber = parseInt(customerNumber);
@@ -864,7 +864,6 @@ const assignDraftOrderCustomer = async ({
*/
showChangeCustomerForm: showChangeOrderCustomerForm,
assignDraftCustomer: assignDraftOrderCustomer,
recalculateOrderItemPricesForCustomer,
/**
* Show the change invoice collection form
* @param id (order id)
@@ -35,12 +35,6 @@ export const Bird = {
},
config: Config,
functions: {
getHealth: async () => {
return authenticatedRequest(
"/bird/health",
"GET"
);
},
createVoiceCall: async (workspaceId, channelId, payload) => {
return authenticatedRequest(
"/bird/voice/calls" + withWorkspaceAndChannel(workspaceId, channelId),
@@ -44,15 +44,6 @@ export const Config = {
return Config.set("server_url", value);
},
},
workspaceId: {
get: async () => {
return Config.get("workspaceId");
},
set: async (value) => {
return Config.set("workspaceId", value);
},
},
// Kept for installations that have not migrated the historical typo yet.
workplaceId: {
get: async () => {
return Config.get("workplaceId");
@@ -69,54 +60,6 @@ export const Config = {
return Config.set("channelId", value);
},
},
participantId: {
get: async () => Config.get("participantId"),
set: async (value) => Config.set("participantId", value),
},
allowed_channel_ids_json: {
get: async () => Config.get("allowed_channel_ids_json"),
set: async (value) => Config.set("allowed_channel_ids_json", value),
},
control_plane_token: {
get: async () => Config.get("control_plane_token"),
set: async (value) => Config.set("control_plane_token", value),
},
webhook_signing_key: {
get: async () => Config.get("webhook_signing_key"),
set: async (value) => Config.set("webhook_signing_key", value),
},
webhook_public_url: {
get: async () => Config.get("webhook_public_url"),
set: async (value) => Config.set("webhook_public_url", value),
},
webhook_replay_window_seconds: {
get: async () => Config.get("webhook_replay_window_seconds"),
set: async (value) => Config.set("webhook_replay_window_seconds", value),
},
flow_shared_secret: {
get: async () => Config.get("flow_shared_secret"),
set: async (value) => Config.set("flow_shared_secret", value),
},
flow_policy_json: {
get: async () => Config.get("flow_policy_json"),
set: async (value) => Config.set("flow_policy_json", value),
},
template_policy_json: {
get: async () => Config.get("template_policy_json"),
set: async (value) => Config.set("template_policy_json", value),
},
control_plane_enabled: {
set: async (value) => Config.set("control_plane_enabled", value),
},
flow_enabled: {
set: async (value) => Config.set("flow_enabled", value),
},
operations_actions_enabled: {
set: async (value) => Config.set("operations_actions_enabled", value),
},
outbound_messages_enabled: {
set: async (value) => Config.set("outbound_messages_enabled", value),
},
},
enabled: {
get: async () => {
@@ -1,47 +0,0 @@
<script>
import {authenticatedRequest} from "@/components/session/authenticatedRequest.vue";
/**
* The MiniMax -> Config object
*/
export const Config = {
get: async ( variable ) => {
return authenticatedRequest(
"/minimax/config?variable=" + variable,
"GET")
},
get_all: async () => {
return authenticatedRequest(
"/minimax/config",
"GET")
},
set: async ( variable, value ) => {
return authenticatedRequest(
"/minimax/config",
"POST",
{
variable: variable,
value: value
})
},
keys: {
api_key: {
get: async () => {
return Config.get("api_key");
},
set: async (value) => {
return Config.set("api_key", value);
},
},
},
enabled: {
get: async () => {
return Config.get("enabled");
},
set: async (enabled) => {
return Config.set("enabled", enabled);
},
},
};
</script>
@@ -1,22 +0,0 @@
<script>
import { Config } from "@/components/session/token/superUser/modules/miniMax/Config.vue";
/**
* The MiniMax object
*/
export const MiniMax = {
meta: {
title: "MiniMax M3",
icon: "fas fa-robot",
description: "MiniMax M3 (Anthropic-messages) integration used by XL Vask autopilot and other AI features.",
endpoint: "/modules/MiniMax",
config_endpoint: "/configuration/MiniMax",
labels: {
single: "MiniMax",
multiple: "MiniMax"
}
},
config: Config,
functions: {}
};
</script>

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