Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7058e1a73e | ||
|
|
02e56a3e5f | ||
|
|
97700841ba | ||
|
|
88d5252814 | ||
|
|
7d3ebf8d52 | ||
|
|
0a03cd5f98 | ||
|
|
cc7d5cf4ff | ||
|
|
8d9f1e6fde | ||
|
|
d61d91b6ae | ||
|
|
ba92bc4cb6 | ||
|
|
c353bfac3a | ||
|
|
9024a5a1fa | ||
|
|
35e4bba859 | ||
|
|
50535dbed0 | ||
|
|
f5ccb2a2a9 | ||
|
|
29ef97a86c | ||
|
|
8d646ce770 | ||
|
|
f63e51c96e | ||
|
|
4810e113f3 | ||
|
|
82c95d32c0 | ||
|
|
d4f92cd259 | ||
|
|
6b44835347 | ||
|
|
683196ddf5 | ||
|
|
1548ae8cd5 | ||
|
|
fd8b896c56 | ||
|
|
2e95608b05 | ||
|
|
d393c8c175 | ||
|
|
668e240e12 | ||
|
|
0831d37d3c | ||
|
|
60dff74507 | ||
|
|
f995440098 | ||
|
|
7a5ee1aa5b | ||
|
|
3639527b0e | ||
|
|
f4816124c2 | ||
|
|
664b50d4ef | ||
|
|
1768f5a38e | ||
|
|
f2453ba0a3 | ||
|
|
7b769eeb24 | ||
|
|
391e0c8a6f | ||
|
|
0149e06c42 | ||
|
|
832b362254 | ||
|
|
eee9ba1c13 | ||
|
|
aaecffbdfa | ||
|
|
917c10c1d3 | ||
|
|
14a0d65a01 | ||
|
|
468d436d3e | ||
|
|
c85a82b9ac | ||
|
|
3de5215b5e | ||
|
|
5ffd471a45 | ||
|
|
1da6fbd1c4 | ||
|
|
5204536f92 | ||
|
|
7a84cd9162 | ||
|
|
4f26ddd2cc | ||
|
|
b7859d4ede | ||
|
|
cbdca71e3f |
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
- name: Qodana
|
||||
# v2026.1.3
|
||||
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
|
||||
uses: JetBrains/qodana-action@b588768b6e7e6da579e518bc584f79de0d243692
|
||||
with:
|
||||
use-caches: true
|
||||
cache-default-branch-only: true
|
||||
|
||||
@@ -142,13 +142,19 @@ jobs:
|
||||
const fs = require("node:fs");
|
||||
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
|
||||
const checks = {
|
||||
schema: proof.schemaVersion === 1,
|
||||
schema: proof.schemaVersion === 2,
|
||||
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: proof.liveCredentialedGate === "passed",
|
||||
credentialedGate: ["passed", "not-configured"].includes(proof.liveCredentialedGate),
|
||||
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(", ")}`);
|
||||
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
|
||||
- name: Setup Java
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
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 }}
|
||||
@@ -37,6 +37,7 @@ 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
|
||||
@@ -286,8 +287,28 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: --use-system-ca
|
||||
|
||||
- name: Credentialed live Playwright gate (when configured)
|
||||
- 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'
|
||||
id: credentialed_live
|
||||
timeout-minutes: 15
|
||||
run: npm run test:e2e:live:roles
|
||||
@@ -300,26 +321,9 @@ 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)
|
||||
@@ -340,7 +344,9 @@ 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'
|
||||
@@ -355,24 +361,52 @@ 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: 1,
|
||||
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(),
|
||||
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: "passed",
|
||||
liveCredentialedGate: "passed",
|
||||
releaseManagerGate: "passed",
|
||||
livePublicGate: requireSuccessfulStep("LIVE_PUBLIC_GATE_OUTCOME"),
|
||||
liveCredentialedGate: credentialedGate(),
|
||||
releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME"),
|
||||
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'
|
||||
@@ -383,6 +417,32 @@ 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
|
||||
|
||||
@@ -602,7 +602,14 @@ 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 == '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 == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
||||
needs.build-and-unit.result == 'success' &&
|
||||
(
|
||||
@@ -798,7 +805,14 @@ 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 == '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 == 'workflow_dispatch' && inputs.mode == 'targeted')
|
||||
name: Full E2E summary
|
||||
needs: [e2e-full]
|
||||
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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.
|
||||
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,28 @@
|
||||
# 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.
|
||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 100 KiB |
@@ -0,0 +1,48 @@
|
||||
# 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.
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 160 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 176 KiB |
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"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,5 +1,5 @@
|
||||
{
|
||||
"marketingVersion": "1.0.0",
|
||||
"marketingVersion": "1.0.1",
|
||||
"bundleId": "io.truckwash.app",
|
||||
"minimumIosVersion": "15.0"
|
||||
}
|
||||
|
||||
@@ -3534,6 +3534,19 @@ 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:
|
||||
@@ -3566,6 +3579,21 @@ 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'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
function gitCommit() {
|
||||
try {
|
||||
@@ -8,43 +9,140 @@ function gitCommit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const token = process.env.SERVER_UPDATE_TOKEN;
|
||||
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
|
||||
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";
|
||||
if (!token) {
|
||||
if (required) {
|
||||
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
|
||||
throw new Error("SERVER_UPDATE_TOKEN or RELEASE_MANAGER_GATE_TOKEN is required after deploy verification.");
|
||||
}
|
||||
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
|
||||
console.log("No server version credential is set. Skipping server version update.");
|
||||
return;
|
||||
}
|
||||
|
||||
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 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 baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
|
||||
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 url = new URL(baseUrl);
|
||||
url.searchParams.set("version", version);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
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, {
|
||||
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}.`);
|
||||
}
|
||||
console.log(`Server version updated to ${version}.`);
|
||||
return { version, update: body, observed };
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export const ownedFilesByRole = {
|
||||
"userVehicles.spec.ts",
|
||||
],
|
||||
subuser: [
|
||||
"driverAuth.spec.ts",
|
||||
"subuserCompleteRegistration.spec.ts",
|
||||
"subuserProfileContact.spec.ts",
|
||||
"subuserProfileGrant.spec.ts",
|
||||
|
||||
@@ -98,7 +98,7 @@ const shouldHideGlobalOverlays = computed(
|
||||
);
|
||||
const sessionLoaderRoutePattern = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/;
|
||||
const guestSessionLoaderRoutePattern =
|
||||
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/;
|
||||
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|subuser-access\/decision$|qr\/new-(?:customer|driver)$)/;
|
||||
const currentBrowserPath = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide, reactive, watch } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, provide, reactive, readonly, ref, 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 props = withDefaults(defineProps<{
|
||||
const EXPAND_ALL_CONCURRENCY = 4;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: TreeNode[];
|
||||
fields?: Partial<{
|
||||
id: string;
|
||||
@@ -24,7 +37,8 @@ const props = withDefaults(defineProps<{
|
||||
progressiveBatchSize?: number;
|
||||
loadMoreLabel?: string;
|
||||
ariaLabel?: string;
|
||||
}>(), {
|
||||
}>(),
|
||||
{
|
||||
data: () => [],
|
||||
fields: () => ({}),
|
||||
selectionMode: "none",
|
||||
@@ -38,7 +52,8 @@ const props = withDefaults(defineProps<{
|
||||
progressiveBatchSize: 0,
|
||||
loadMoreLabel: "Show {count} more",
|
||||
ariaLabel: undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:selected": [value: any];
|
||||
@@ -70,6 +85,11 @@ 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 ?? "");
|
||||
@@ -78,6 +98,8 @@ 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);
|
||||
@@ -92,7 +114,8 @@ 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)) {
|
||||
@@ -102,10 +125,8 @@ 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;
|
||||
@@ -124,18 +145,6 @@ 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) => {
|
||||
@@ -159,18 +168,6 @@ 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]);
|
||||
@@ -181,33 +178,146 @@ const setCheckedKeys = (keys: any[]) => {
|
||||
emit("update:checkedKeys", [...state.checkedKeys]);
|
||||
};
|
||||
|
||||
const loadNode = async (node: TreeNode) => {
|
||||
const loadNode = async (node: TreeNode): Promise<LoadNodeResult> => {
|
||||
if (!props.lazy || !props.load) {
|
||||
return { children: effectiveChildrenOf(node), failed: false, cancelled: false };
|
||||
}
|
||||
const key = keyOf(node);
|
||||
if (key === undefined || key === null) {
|
||||
return { children: [], failed: false, cancelled: false };
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
})();
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (state.loadingKeys.includes(key)) {
|
||||
const normalizedKey = keyString(key);
|
||||
if (visited.has(normalizedKey)) {
|
||||
return;
|
||||
}
|
||||
visited.add(normalizedKey);
|
||||
expandedKeys.push(key);
|
||||
|
||||
state.loadingKeys = [...state.loadingKeys, key];
|
||||
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
|
||||
emit("load-start", node, 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;
|
||||
}
|
||||
|
||||
try {
|
||||
state.lazyChildrenCache[keyString(key)] = await props.load(node);
|
||||
if (progressiveBatchSize.value > 0) {
|
||||
state.renderedLimits[keyString(key)] = progressiveBatchSize.value;
|
||||
const cancelled = operationVersion !== bulkOperationVersion;
|
||||
if (!cancelled) {
|
||||
setExpandedKeys(expandedKeys);
|
||||
}
|
||||
} catch (error) {
|
||||
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
|
||||
emit("load-error", error, node, key);
|
||||
} finally {
|
||||
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
|
||||
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) => {
|
||||
@@ -312,9 +422,7 @@ 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);
|
||||
}
|
||||
@@ -323,6 +431,49 @@ 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,
|
||||
@@ -347,7 +498,7 @@ provide("BuefyTreeContext", {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="b-tree" role="tree" :aria-label="ariaLabel">
|
||||
<ul class="b-tree" role="tree" :aria-label="ariaLabel" :aria-busy="bulkExpanding || undefined">
|
||||
<BuefyTreeNode
|
||||
v-for="(node, index) in data"
|
||||
:key="keyOf(node) ?? index"
|
||||
|
||||
@@ -96,6 +96,7 @@ 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 }"
|
||||
@@ -122,7 +123,7 @@ onBeforeUnmount(disconnectLoadMoreObserver);
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span class="b-tree-node-icon">
|
||||
<span v-if="node.hideIcon !== true" 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,6 +62,8 @@ 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,20 +6,17 @@ 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 STRIPE_EMAIL_POLLING_INTERVAL_OVERRIDE_KEY = '__TW_POS_STRIPE_EMAIL_POLLING_INTERVAL_MS__';
|
||||
const CARD_PAYMENT_TAX_PERCENTAGE = 25;
|
||||
|
||||
const props = defineProps({
|
||||
departmentId: {
|
||||
@@ -50,16 +47,6 @@ 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);
|
||||
@@ -71,229 +58,15 @@ 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 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 shouldShowDesktopCompletion = computed(() => isTerminalPaymentCaptured.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;
|
||||
@@ -315,11 +88,6 @@ 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')) {
|
||||
@@ -357,7 +125,7 @@ const paymentIntentReaderId = computed(() => {
|
||||
const paymentIntentTaxPercentage = computed(() => {
|
||||
const value = paymentIntent.value?.metadata?.tax_percentage;
|
||||
return value === undefined || value === null
|
||||
? getTaxRatePercentage(selectedTaxRate.value)
|
||||
? CARD_PAYMENT_TAX_PERCENTAGE
|
||||
: Number(value);
|
||||
});
|
||||
|
||||
@@ -489,18 +257,7 @@ 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 });
|
||||
});
|
||||
|
||||
@@ -519,9 +276,7 @@ const primaryActionLabel = computed(() => {
|
||||
case 'waiting_for_reader':
|
||||
return stripeText('actions.refresh_payment_status');
|
||||
case 'ready_to_capture':
|
||||
return paymentAmountSummary.value
|
||||
? stripeText('actions.capture_payment_with_amount', { amount: paymentAmountSummary.value })
|
||||
: stripeText('actions.capture_payment');
|
||||
return stripeText('states.capturing');
|
||||
case 'succeeded':
|
||||
return stripeText('actions.show_payment_details');
|
||||
case 'failed':
|
||||
@@ -542,9 +297,8 @@ const primaryActionTestId = computed(() => {
|
||||
case 'reader_unavailable':
|
||||
return 'pos-stripe-no-readers';
|
||||
case 'waiting_for_reader':
|
||||
return 'pos-stripe-payment-in-progress';
|
||||
case 'ready_to_capture':
|
||||
return 'pos-stripe-capture-intent';
|
||||
return 'pos-stripe-payment-in-progress';
|
||||
case 'succeeded':
|
||||
return 'pos-stripe-payment-succeeded';
|
||||
case 'failed':
|
||||
@@ -570,7 +324,6 @@ const canCancelPaymentIntent = computed(() => {
|
||||
});
|
||||
|
||||
const shouldShowReaderSelection = computed(() => paymentIntent.value === null);
|
||||
const shouldShowTaxSelection = computed(() => paymentIntent.value === null);
|
||||
|
||||
const attemptAutomaticReaderSelection = (readerList) => {
|
||||
selectedReaderId.value = selectPreferredStripeTerminalReaderId(
|
||||
@@ -657,7 +410,7 @@ const onClickCreatePaymentIntent = async () => {
|
||||
const response = await StripeModule.paymentIntents.createPaymentIntent(
|
||||
selectedReader.value.reader,
|
||||
props.order_id,
|
||||
getTaxRatePercentage(selectedTaxRate.value),
|
||||
CARD_PAYMENT_TAX_PERCENTAGE,
|
||||
);
|
||||
|
||||
if (response?.status !== 200) {
|
||||
@@ -683,6 +436,7 @@ 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;
|
||||
}
|
||||
@@ -690,6 +444,7 @@ 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 {
|
||||
@@ -755,7 +510,6 @@ const onPrimaryAction = async () => {
|
||||
await retryLoadState();
|
||||
return;
|
||||
case 'ready_to_capture':
|
||||
await onClickCapturePaymentIntent();
|
||||
return;
|
||||
case 'succeeded':
|
||||
showPaymentDetails(paymentIntent.value);
|
||||
@@ -796,26 +550,38 @@ 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;
|
||||
resetEmailPaymentPanel();
|
||||
await Promise.all([
|
||||
syncCurrentState(),
|
||||
loadStripeInvoiceState(),
|
||||
]);
|
||||
autoCapturePaymentIntentId.value = null;
|
||||
await syncCurrentState();
|
||||
});
|
||||
|
||||
let pollingIntervalId = null;
|
||||
let emailPollingIntervalId = null;
|
||||
|
||||
const clearPollingInterval = () => {
|
||||
if (pollingIntervalId !== null) {
|
||||
@@ -824,13 +590,6 @@ const clearPollingInterval = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearEmailPollingInterval = () => {
|
||||
if (emailPollingIntervalId !== null) {
|
||||
window.clearInterval(emailPollingIntervalId);
|
||||
emailPollingIntervalId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const shouldPoll = computed(() => {
|
||||
return pollingEnabled.value
|
||||
&& paymentFlowState.value !== 'succeeded'
|
||||
@@ -854,25 +613,6 @@ 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;
|
||||
@@ -882,15 +622,11 @@ watch(paymentFlowState, (nextState) => {
|
||||
|
||||
onMounted(async () => {
|
||||
pollingEnabled.value = true;
|
||||
await Promise.all([
|
||||
syncCurrentState(),
|
||||
loadStripeInvoiceState(),
|
||||
]);
|
||||
await syncCurrentState();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearPollingInterval();
|
||||
clearEmailPollingInterval();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -931,19 +667,6 @@ 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>
|
||||
@@ -977,123 +700,6 @@ 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>
|
||||
|
||||
@@ -1153,20 +759,6 @@ 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
|
||||
@@ -1222,21 +814,6 @@ 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"
|
||||
@@ -1289,124 +866,6 @@ 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')" />
|
||||
@@ -1484,10 +943,6 @@ onUnmounted(() => {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-stripe-payment__email {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.select.is-fullwidth,
|
||||
.select.is-fullwidth select {
|
||||
width: 100%;
|
||||
@@ -1551,92 +1006,4 @@ 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,4 +506,12 @@ 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 { editOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
import { buildAuditedOrderItemReasonPayload, editOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -85,7 +85,12 @@ const saveChanges = async () => {
|
||||
Number(form.price),
|
||||
form.notes,
|
||||
form.reference,
|
||||
Number(form.quantity)
|
||||
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,
|
||||
})
|
||||
);
|
||||
emits('saved');
|
||||
} catch (error) {
|
||||
|
||||
@@ -12,6 +12,10 @@ 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: {
|
||||
@@ -218,14 +222,6 @@ const normalizeInvoiceCollectionId = (invoiceCollectionId) => {
|
||||
|
||||
const getApiPayload = (response) => response?.data?.data ?? response?.data ?? response ?? {};
|
||||
|
||||
const escapeHtml = (value) =>
|
||||
String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const getInvoiceCollectionResponseOrders = (response) => {
|
||||
if (Array.isArray(response?.orders)) {
|
||||
return response.orders;
|
||||
@@ -503,84 +499,11 @@ const selectableInvoiceCollectionCount = computed(() => getUniqueInvoiceCollecti
|
||||
const getBulkActionLabel = (action) => t(`invoicing_period.invoice_collection_actions.actions.${action}`);
|
||||
|
||||
const renderBulkActionPreviewHtml = (preview) => {
|
||||
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("");
|
||||
return renderInvoiceCollectionActionPreviewHtml({
|
||||
preview: normalizeInvoiceCollectionActionPreview(preview, getSelectedInvoiceCollectionIds().length),
|
||||
t,
|
||||
formatCurrency: (value) => SessionUser.functions.currency.toLocal(value),
|
||||
});
|
||||
};
|
||||
|
||||
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,6 +22,9 @@ 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);
|
||||
@@ -37,8 +40,12 @@ 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) => {
|
||||
@@ -56,18 +63,23 @@ 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) {
|
||||
@@ -83,7 +95,7 @@ const clearSuccessfulMobileCardFlow = () => {
|
||||
order_id.value = 0;
|
||||
popups.clear();
|
||||
resetPos();
|
||||
}, 3000);
|
||||
}, getSuccessResetDelayMs());
|
||||
};
|
||||
|
||||
const completeMobileCardPayment = async () => {
|
||||
@@ -100,21 +112,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;
|
||||
}
|
||||
|
||||
@@ -124,14 +136,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,
|
||||
});
|
||||
|
||||
@@ -144,7 +156,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.";
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -171,9 +183,7 @@ 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>
|
||||
@@ -216,9 +226,7 @@ const onBackClick = async () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p class="mobile-card-payment-view__description">Use the selected card terminal for this order.</p>
|
||||
|
||||
<PayWithStripeButton
|
||||
:department-id="normalizedDepartmentId"
|
||||
|
||||
@@ -469,6 +469,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
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(" · ");
|
||||
};
|
||||
@@ -52,6 +52,7 @@ 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);
|
||||
@@ -64,7 +65,8 @@ 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 : []));
|
||||
@@ -130,12 +132,23 @@ const requestLoad = () =>
|
||||
|
||||
const requestSave = (overrides) =>
|
||||
props.scope === "limited"
|
||||
? updateLimitedBackofficeDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides)
|
||||
: updateSuperuserDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides);
|
||||
? updateLimitedBackofficeDepartmentCustomerPricing(
|
||||
departmentId.value,
|
||||
customerIdentifier.value,
|
||||
overrides,
|
||||
pricingData.value?.revision ?? null
|
||||
)
|
||||
: updateSuperuserDepartmentCustomerPricing(
|
||||
departmentId.value,
|
||||
customerIdentifier.value,
|
||||
overrides,
|
||||
pricingData.value?.revision ?? null
|
||||
);
|
||||
|
||||
const resetPricingData = () => {
|
||||
pricingData.value = null;
|
||||
errorMessage.value = "";
|
||||
pricingConflict.value = false;
|
||||
};
|
||||
|
||||
const loadPricing = async () => {
|
||||
@@ -146,6 +159,7 @@ const loadPricing = async () => {
|
||||
|
||||
loading.value = true;
|
||||
errorMessage.value = "";
|
||||
pricingConflict.value = false;
|
||||
|
||||
try {
|
||||
const response = await requestLoad();
|
||||
@@ -193,16 +207,17 @@ 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) {
|
||||
errorMessage.value =
|
||||
error?.response?.data?.data?.message ||
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
t("departments.customer_pricing.errors.save");
|
||||
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");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -317,7 +332,11 @@ 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>
|
||||
|
||||
@@ -382,7 +401,16 @@ watch(
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="department-customer-pricing-error">
|
||||
{{ errorMessage }}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-if="pricingData" class="department-customer-pricing__content">
|
||||
@@ -404,7 +432,11 @@ watch(
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<b-tooltip
|
||||
:label="props.canEdit ? t('departments.customer_pricing.edit_global_discount') : t('departments.customer_pricing.edit_disabled')"
|
||||
:label="
|
||||
props.canEdit
|
||||
? t('departments.customer_pricing.edit_global_discount')
|
||||
: t('departments.customer_pricing.edit_disabled')
|
||||
"
|
||||
position="is-left"
|
||||
type="is-dark"
|
||||
>
|
||||
@@ -425,7 +457,12 @@ watch(
|
||||
</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>
|
||||
|
||||
@@ -444,7 +481,11 @@ 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"
|
||||
>
|
||||
@@ -489,7 +530,11 @@ 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"
|
||||
>
|
||||
@@ -509,7 +554,11 @@ 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,9 +19,11 @@ const props = defineProps({
|
||||
type: Boolean, default: false
|
||||
}, highlightUsageLogId: {
|
||||
type: Number, default: 0
|
||||
}, automationWorkspace: {
|
||||
type: Boolean, default: false
|
||||
}
|
||||
})
|
||||
import {computed, provide, ref, watch } from "vue";
|
||||
import {computed, onMounted, onUnmounted, provide, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
PaginatedListKey,
|
||||
@@ -37,9 +39,21 @@ 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();
|
||||
@@ -92,13 +106,11 @@ 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);
|
||||
}
|
||||
@@ -111,10 +123,107 @@ 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 {};
|
||||
return {
|
||||
dateFrom: formatLocalDateOnly(dateFrom.value),
|
||||
dateTo: formatLocalDateOnly(dateTo.value),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -123,26 +232,371 @@ const buildImportUsageParams = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const importUsageButton = async () => {
|
||||
isImportLoading.value = true;
|
||||
try {
|
||||
await SessionUser.request(
|
||||
'/modules/xlvask/tasks/import-usage',
|
||||
'GET',
|
||||
buildImportUsageParams(),
|
||||
function (error) {
|
||||
// This function is called when the request fails
|
||||
console.error("Failed to import orders.", error);
|
||||
},
|
||||
function (response) {
|
||||
// This function is called when the request succeeds
|
||||
console.log("Orders imported successfully.", response);
|
||||
// Reload the list after import
|
||||
loadList();
|
||||
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;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
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);
|
||||
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));
|
||||
@@ -186,25 +640,107 @@ applyInitialPeriodFilters();
|
||||
// Load the list automatically if the autoLoad prop is set
|
||||
if (props.autoLoad) {
|
||||
loadList();
|
||||
loadSummary();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.initialDateFrom, props.initialDateTo],
|
||||
([nextDateFrom, nextDateTo], [previousDateFrom, previousDateTo]) => {
|
||||
async ([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 : [];
|
||||
@@ -227,14 +763,204 @@ const visibleObjectsCount = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<div class="level-item">
|
||||
<LoadButtonWhileAwait class="is-dark" :isLoading="isImportLoading" :loadFunction="importUsageButton" icon="fas fa-file-import">{{SessionUser.objects.global.language.import}}</LoadButtonWhileAwait>
|
||||
<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>
|
||||
<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>
|
||||
@@ -257,14 +983,14 @@ const visibleObjectsCount = computed(() => {
|
||||
@update:endDate="actions.date.to.select"
|
||||
/>
|
||||
<div class="column is-narrow my-3">
|
||||
<label class="label">Filtre</label>
|
||||
<label class="label">{{ t('invoicing_period.xlvask_autopilot.filters.title') }}</label>
|
||||
<div class="control">
|
||||
<b-switch
|
||||
size="is-small"
|
||||
type="is-link"
|
||||
v-model="showOnlyUnattachedVehicle"
|
||||
>
|
||||
Vis kun ikke tilknyttede vaske
|
||||
{{ t('invoicing_period.xlvask_autopilot.filters.unattached_only') }}
|
||||
</b-switch>
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,6 +1013,9 @@ 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"
|
||||
@@ -299,5 +1028,27 @@ 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,6 +3,7 @@ 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: {
|
||||
@@ -64,27 +65,6 @@ 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");
|
||||
@@ -161,10 +141,7 @@ 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) => {
|
||||
const capabilityKey = LEGACY_PERMISSION_LABEL_KEYS[permission] || permission;
|
||||
return t(`superuser.driver_access.capabilities.${capabilityKey}`);
|
||||
};
|
||||
const permissionLabel = (permission: string) => permissionCapabilityLabel(permission);
|
||||
|
||||
watch(
|
||||
() => props.permissions,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { getCurrentInstance, ref, watch } from 'vue';
|
||||
import {parseError, getError} from "@/components/request/HandleGlobalError.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -24,6 +24,7 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const isEditting = ref(false);
|
||||
const inputId = `configuration-input-${getCurrentInstance()?.uid}`;
|
||||
|
||||
const v_model = ref(props.value);
|
||||
|
||||
@@ -45,7 +46,7 @@ const parseErrorOnSave = (error) => {
|
||||
|
||||
<template>
|
||||
<div class="field">
|
||||
<label class="label">{{ title }}</label>
|
||||
<label class="label" :for="inputId">{{ title }}</label>
|
||||
<!-- If the value is not set, show a warning message -->
|
||||
<div v-if="!props.value && showEmptyWarning && !isEditting"
|
||||
class="message is-warning">
|
||||
@@ -58,6 +59,7 @@ 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,6 +1,9 @@
|
||||
<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
|
||||
@@ -11,10 +14,25 @@ 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);
|
||||
|
||||
@@ -24,6 +42,32 @@ 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>
|
||||
|
||||
@@ -33,8 +77,8 @@ const parseErrorOnSave = (error) => {
|
||||
<!-- If the secret key is not set, show a warning -->
|
||||
<div v-if="!props.isSet" class="message is-warning">
|
||||
<div class="message-body">
|
||||
<strong>Warning:</strong>
|
||||
The secret key is not set, please set it before enabling the integration.
|
||||
<strong>{{ resolvedWarningTitle() }}:</strong>
|
||||
{{ resolvedWarningMessage() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
@@ -52,11 +96,16 @@ const parseErrorOnSave = (error) => {
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="v_model"
|
||||
:disabled="disabled || !isEditting"
|
||||
:disabled="disabled || !isEditting || saving"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a class="button is-info" @click="onSave(v_model).catch(parseErrorOnSave); isEditting = false" v-if="isEditting">
|
||||
<a
|
||||
class="button is-info"
|
||||
@click="onClickSave"
|
||||
v-if="isEditting"
|
||||
:class="{ 'is-loading': saving }"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-save"></i>
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
<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";
|
||||
@@ -40,46 +36,67 @@ 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 = props.localOrderItems;
|
||||
orderItems.value = sortOrderItemsForDisplay(props.localOrderItems);
|
||||
return;
|
||||
}
|
||||
await SessionUser.request(
|
||||
'/order/items',
|
||||
'GET',
|
||||
{
|
||||
await SessionUser.request("/order/items", "GET", {
|
||||
order_id: props.orderId,
|
||||
}
|
||||
).then((response) => {
|
||||
orderItems.value = response.data.data;
|
||||
}).catch((error) => {
|
||||
})
|
||||
.then((response) => {
|
||||
orderItems.value = sortOrderItemsForDisplay(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 sortInvoicePeriodFlags = (flags = []) =>
|
||||
[...flags].sort((left, right) => {
|
||||
const sourceOrder = { manual: 0, automatic: 1 };
|
||||
const leftSource = sourceOrder[left?.source] ?? 99;
|
||||
const rightSource = sourceOrder[right?.source] ?? 99;
|
||||
@@ -90,9 +107,11 @@ const sortInvoicePeriodFlags = (flags = []) => [...flags].sort((left, right) =>
|
||||
return String(left?.created_at || left?.fingerprint || "").localeCompare(
|
||||
String(right?.created_at || right?.fingerprint || "")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePeriodFlags || []).filter((flag) => {
|
||||
const getOrderItemFlags = (orderItem) =>
|
||||
sortInvoicePeriodFlags(
|
||||
(props.invoicePeriodFlags || []).filter((flag) => {
|
||||
if (String(flag?.status || "active") !== "active") {
|
||||
return false;
|
||||
}
|
||||
@@ -104,11 +123,10 @@ const getOrderItemFlags = (orderItem) => sortInvoicePeriodFlags((props.invoicePe
|
||||
|
||||
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 getOrderItemFlagIconClass = (orderItem) => (getOrderItemFlags(orderItem).length > 0 ? "fas fa-flag" : "");
|
||||
|
||||
const getOrderItemFlagColorClass = (orderItem) => {
|
||||
const flags = getOrderItemFlags(orderItem);
|
||||
@@ -119,19 +137,23 @@ 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) => {
|
||||
watch(
|
||||
() => props.orderId,
|
||||
(newValue, oldValue) => {
|
||||
if (newValue !== oldValue) {
|
||||
loadOrderItems();
|
||||
}
|
||||
}, { immediate: true });
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -139,11 +161,11 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
<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>
|
||||
@@ -179,17 +201,17 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
>
|
||||
<i :class="getOrderItemFlagIconClass(item)"></i>
|
||||
</span>
|
||||
<span style="text-decoration: line-through;">{{ item.product.name }}</span> ( {{SessionUser.objects.vehicles.columns.wash_subscription.label }} )
|
||||
<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>
|
||||
<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}`"
|
||||
>
|
||||
<tr v-if="getOrderItemFlags(item).length > 0" :data-testid="`order-content-item-flags-${item.id}`">
|
||||
<td :colspan="displayColumnCount">
|
||||
<InvoicingPeriodFlagList
|
||||
compact
|
||||
@@ -230,12 +252,15 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
>
|
||||
<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 }} )
|
||||
+ <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>
|
||||
<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"
|
||||
@@ -257,7 +282,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
<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>
|
||||
@@ -278,11 +303,7 @@ watch(() => props.orderId, (newValue, oldValue) => {
|
||||
<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>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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"];
|
||||
@@ -457,7 +458,7 @@ const buildDognvaskPermissions = (subuser, enabled) => {
|
||||
const apiErrorMessage = (error, fallback) =>
|
||||
error?.response?.data?.data?.message || error?.response?.data?.message || error?.message || fallback;
|
||||
|
||||
const onToggleDognvask = async (subuser, enabled) => {
|
||||
const updateDognvask = async (subuser, enabled, rejectOnFailure = false) => {
|
||||
if (isDognvaskDisabled(subuser)) {
|
||||
return;
|
||||
}
|
||||
@@ -474,9 +475,16 @@ const onToggleDognvask = async (subuser, enabled) => {
|
||||
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;
|
||||
@@ -1099,10 +1107,15 @@ 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)) {
|
||||
@@ -1458,6 +1471,26 @@ 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",
|
||||
@@ -1612,7 +1645,7 @@ const actionSections = (subuser) => {
|
||||
<th>Chauffør</th>
|
||||
<th>Tlf.</th>
|
||||
<th>Nummerplade</th>
|
||||
<th>Kundeadgange</th>
|
||||
<th>{{ selfServeActionLabel }}</th>
|
||||
<th class="has-text-right action-column">Handlinger</th>
|
||||
<th class="has-text-right remove-column"></th>
|
||||
</tr>
|
||||
@@ -1796,7 +1829,7 @@ const actionSections = (subuser) => {
|
||||
</div>
|
||||
|
||||
<div class="subusers-table__access-control">
|
||||
<span class="subusers-table__access-control-label">Døgnvask</span>
|
||||
<span class="subusers-table__access-control-label">{{ selfServeActionLabel }}</span>
|
||||
<b-tooltip
|
||||
v-if="isDognvaskDisabled(grant)"
|
||||
:label="dognvaskDisabledReason(grant)"
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, reactive, ref } from "vue";
|
||||
import { nextTick, onBeforeUnmount, onMounted, 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 {
|
||||
@@ -24,7 +38,8 @@ const submitCustomer = async () => {
|
||||
invoiceEmail: customerFormData.contactEmail,
|
||||
contactEmail: customerFormData.contactEmail,
|
||||
contactPhone: customerFormData.contactPhone,
|
||||
companyPhone: customerFormData.contactPhone
|
||||
companyPhone: customerFormData.contactPhone,
|
||||
...(recaptchaConfig.value.enabled ? { g_recaptcha_response: recaptchaToken.value } : {}),
|
||||
};
|
||||
const ean = economicEanPayloadValue(customerFormData.ean);
|
||||
if (ean !== undefined) {
|
||||
@@ -39,14 +54,39 @@ 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);
|
||||
@@ -55,7 +95,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="truckwash-form" @submit.prevent="() => {}" method="post">
|
||||
<form class="truckwash-form" data-testid="customer-registration-form" @submit.prevent="() => {}" method="post">
|
||||
<div class="form-field">
|
||||
<label for="customer_cvr">{{ $t("customer_creation.customer.cvr_label") }}</label>
|
||||
<input
|
||||
@@ -65,7 +105,7 @@ onBeforeUnmount(() => {
|
||||
inputmode="numeric"
|
||||
autocomplete="organization"
|
||||
placeholder="12345678"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
@@ -79,12 +119,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">
|
||||
@@ -97,21 +137,36 @@ 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>
|
||||
|
||||
<p v-if="deliverSuccess" id="customer_creation_alert_success" class="help is-success">
|
||||
<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"
|
||||
>
|
||||
{{ $t("customer_creation.customer.success_message") }}
|
||||
</p>
|
||||
|
||||
<LoadButtonWhileAwait
|
||||
id="customer-register-button"
|
||||
class="truckwash-submit"
|
||||
:disabled="!customerFormData.cvr || !customerFormData.contactPhone || !customerFormData.contactEmail"
|
||||
:disabled="
|
||||
!customerFormData.cvr ||
|
||||
!customerFormData.contactPhone ||
|
||||
!customerFormData.contactEmail ||
|
||||
(recaptchaConfig.enabled && !recaptchaToken)
|
||||
"
|
||||
:loadFunction="submitCustomer"
|
||||
>
|
||||
{{ $t("customer_creation.customer.submit") }}
|
||||
|
||||
@@ -1,39 +1,79 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, reactive, ref } from "vue";
|
||||
import { nextTick, onBeforeUnmount, onMounted, 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", {
|
||||
await axios.post(API_URL + "/subusers/me", {
|
||||
cvr: driverFormData.driverCvr,
|
||||
phone: driverFormData.driverPhone,
|
||||
phone_country_code: "45"
|
||||
phone_country_code: "45",
|
||||
...(recaptchaConfig.value.enabled ? { g_recaptcha_response: recaptchaToken.value } : {}),
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -42,7 +82,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="truckwash-form" @submit.prevent="() => {}" method="post">
|
||||
<form class="truckwash-form" data-testid="driver-registration-form" @submit.prevent="() => {}" method="post">
|
||||
<div class="form-field">
|
||||
<label for="driver_cvr">{{ $t("customer_creation.driver.cvr_label") }}</label>
|
||||
<input
|
||||
@@ -53,7 +93,7 @@ onBeforeUnmount(() => {
|
||||
inputmode="numeric"
|
||||
autocomplete="organization"
|
||||
placeholder="12345678"
|
||||
>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
@@ -66,21 +106,31 @@ 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>
|
||||
|
||||
<p v-if="deliverSuccess" id="driver_creation_alert_success" class="help is-success">
|
||||
<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"
|
||||
>
|
||||
{{ $t("customer_creation.driver.success_message") }}
|
||||
</p>
|
||||
|
||||
<LoadButtonWhileAwait
|
||||
id="driver-register-button"
|
||||
class="truckwash-submit"
|
||||
:disabled="!driverFormData.driverCvr || !driverFormData.driverPhone"
|
||||
:disabled="!driverFormData.driverCvr || !driverFormData.driverPhone || (recaptchaConfig.enabled && !recaptchaToken)"
|
||||
:loadFunction="submitDriver"
|
||||
>
|
||||
{{ $t("customer_creation.driver.submit") }}
|
||||
|
||||
@@ -206,6 +206,18 @@ onMounted(async () => {
|
||||
<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
|
||||
v-if="passkeySupported"
|
||||
@@ -281,4 +293,22 @@ 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: 'Stripe Payment', customerNumber: 999});
|
||||
selectCustomer({name: 'Kortbetaling', customerNumber: 999});
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
loadCustomerAttributes
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
|
||||
import {createOrderItem} from "@/components/shop/OrdersItems.vue";
|
||||
import { AUDITED_ORDER_ITEM_PRODUCT_IDS, 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,6 +435,18 @@ 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([
|
||||
{
|
||||
@@ -636,7 +648,7 @@ const addProductWithAddonsToOrder = async (product_id) => {
|
||||
return;
|
||||
}
|
||||
// Check if the product requires a note
|
||||
if (product.requires_note) {
|
||||
if (productRequiresOrderItemNote(product)) {
|
||||
// Show the note input
|
||||
await Swal.fire({
|
||||
title: 'Tilføj en note',
|
||||
|
||||
@@ -215,7 +215,7 @@ const onSelectCardPayment = () => {
|
||||
showSelector.value = false;
|
||||
|
||||
const cardPaymentCustomer = {
|
||||
name: "Stripe Payment",
|
||||
name: "Kortbetaling",
|
||||
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, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from "vue";
|
||||
import { pingApiServer } from "@/services/apiHealth.js";
|
||||
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
|
||||
|
||||
@@ -14,7 +14,6 @@ const props = defineProps({
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const showDebugInfo = ref(false);
|
||||
const isSessionInitiated = computed(() => SessionUser.isInitiated());
|
||||
let sessionTimeout = null;
|
||||
|
||||
@@ -86,51 +85,17 @@ onBeforeUnmount(clearSessionTimeout);
|
||||
aria-label="Bekræfter bruger"
|
||||
/>
|
||||
<slot v-else-if="props.hasPermission" />
|
||||
<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 v-else data-testid="restricted-forbidden">
|
||||
<div class="message is-danger my-6">
|
||||
<div class="message-body">
|
||||
<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>
|
||||
<h2 class="title is-3">403</h2>
|
||||
<p>{{ $t("errors.forbidden") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<!-- 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 class="button is-dark" data-testid="restricted-go-back" @click="router.go(-1)">
|
||||
{{ $t("common.back") }}
|
||||
</button>
|
||||
<!-- Button to hide the debug information -->
|
||||
<button class="button is-dark" @click="showDebugInfo = !showDebugInfo" v-else>Hide debug information</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<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,247 +1,262 @@
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, watch } from "vue";
|
||||
import i18n from "@/i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { IS_DEV } from '@/config.js';
|
||||
import {
|
||||
normalizeSubuserGrantOptions,
|
||||
permissionGroupsForGrant,
|
||||
} from "@/components/session/subuser/subuserGrantOptions.js";
|
||||
|
||||
defineProps({
|
||||
showLabel: { type: Boolean, default: true },
|
||||
compact: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
|
||||
const grants = computed(() => SessionUser.subuser.grants.value || []);
|
||||
const t = (key, values = undefined) => i18n.global.t(key, values);
|
||||
const selectedCustomerNumber = computed(() => SessionUser.subuser.selectedGrantCustomerNumber.value);
|
||||
const isSubuser = computed(() => SessionUser.isSubuser.value);
|
||||
|
||||
// Auto-select if subuser has only one grant
|
||||
const grants = computed(() => normalizeSubuserGrantOptions(SessionUser.subuser.grants.value));
|
||||
|
||||
watch(
|
||||
() => grants.value,
|
||||
grants,
|
||||
(newGrants) => {
|
||||
if (isSubuser.value && newGrants.length === 1 && !selectedCustomerNumber.value) {
|
||||
SessionUser.subuser.selectGrant(newGrants[0].billing_customer_number);
|
||||
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);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const selectGrant = (customerNumber) => {
|
||||
SessionUser.subuser.selectGrant(customerNumber);
|
||||
};
|
||||
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 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;
|
||||
});
|
||||
const visiblePermissionGroups = permissionGroupsForGrant;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 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 få adgang.</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div class="dropdown-menu" id="grant-dropdown-menu" role="menu">
|
||||
<div class="dropdown-content">
|
||||
<a
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
<b-dropdown-item
|
||||
v-for="grant in grants"
|
||||
:key="grant.billing_customer_number"
|
||||
class="dropdown-item"
|
||||
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="dropdown-item-check">
|
||||
<i class="fas fa-check" v-if="grant.billing_customer_number === selectedCustomerNumber"></i>
|
||||
<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="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 class="grant-selector__option-copy">
|
||||
<strong>{{ customerLabel(grant) }}</strong>
|
||||
<small>#{{ grant.billing_customer_number }}</small>
|
||||
</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)"
|
||||
<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"
|
||||
>
|
||||
<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 class="grant-selector__permission-icon" :class="group.color">
|
||||
<i :class="group.icon"></i>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
</button>
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</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.is-compact .dropdown-trigger .button {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
.grant-selector__dropdown,
|
||||
.grant-selector__trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.grant-selector.is-compact .dropdown-menu {
|
||||
min-width: 250px;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
.grant-selector__trigger {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.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.is-compact .dropdown-item {
|
||||
cursor: pointer;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
.grant-selector__trigger-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
.grant-selector__trigger-icons {
|
||||
display: inline-flex;
|
||||
gap: 0.35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.field.has-addons {
|
||||
.grant-selector__option {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0.45rem;
|
||||
color: #25344d;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grant-selector__option-copy strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.grant-selector__option-copy small {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.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) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.button.is-static {
|
||||
background-color: #f5f5f5;
|
||||
border-color: #dbdbdb;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.grant-selector__trigger-icons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.transform-color-black {
|
||||
filter: invert(100%) grayscale(100%) brightness(0) contrast(100%);
|
||||
}
|
||||
.grant-selector__option {
|
||||
grid-template-columns: 1.25rem minmax(6rem, 1fr);
|
||||
}
|
||||
|
||||
.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;
|
||||
.grant-selector__permissions {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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))
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
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");
|
||||
@@ -254,6 +254,20 @@ 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;
|
||||
@@ -302,17 +316,7 @@ const resetSessionState = () => {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
resetEconomicData();
|
||||
|
||||
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = null;
|
||||
SessionUser.runtimeConfig.economic.defaultDistributionDepartmentId.value = null;
|
||||
@@ -698,6 +702,8 @@ 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,12 +516,24 @@ export const CollectedOrderInvoices = {
|
||||
throw error;
|
||||
});
|
||||
},
|
||||
bulk_action_preview: async (action, invoiceCollectionIds = [], options = {}, locale = null) => {
|
||||
bulk_action_preview: async (
|
||||
action,
|
||||
invoiceCollectionIds = [],
|
||||
options = {},
|
||||
locale = null,
|
||||
snapshotBinding = {}
|
||||
) => {
|
||||
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;
|
||||
@@ -537,6 +549,8 @@ export const CollectedOrderInvoices = {
|
||||
options = {},
|
||||
confirmation_text,
|
||||
locale = null,
|
||||
customer_number = null,
|
||||
snapshot_revision = null,
|
||||
}) => {
|
||||
return authenticatedRequest('/collected-invoices/bulk-actions/apply', 'POST', {
|
||||
preview_id,
|
||||
@@ -545,6 +559,8 @@ 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;
|
||||
@@ -553,6 +569,28 @@ 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,6 +864,7 @@ const assignDraftOrderCustomer = async ({
|
||||
*/
|
||||
showChangeCustomerForm: showChangeOrderCustomerForm,
|
||||
assignDraftCustomer: assignDraftOrderCustomer,
|
||||
recalculateOrderItemPricesForCustomer,
|
||||
/**
|
||||
* Show the change invoice collection form
|
||||
* @param id (order id)
|
||||
|
||||
@@ -35,6 +35,12 @@ 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,6 +44,15 @@ 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");
|
||||
@@ -60,6 +69,54 @@ 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 () => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<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>
|
||||
@@ -0,0 +1,22 @@
|
||||
<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>
|
||||