Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a37e925dc6 | ||
|
|
2e1d7f3e8f | ||
|
|
7c9eb8b644 | ||
|
|
e2cc76091f | ||
|
|
4db3be34f8 | ||
|
|
b1e0c61df0 | ||
|
|
eb8482585b | ||
|
|
c207fea61e | ||
|
|
01c5864382 | ||
|
|
c01596aeb5 | ||
|
|
5d4de1d932 | ||
|
|
768b6dcdab | ||
|
|
253d72f7fb | ||
|
|
a1fa132c99 | ||
|
|
113f49f018 | ||
|
|
666d467b46 | ||
|
|
9c74c4d477 | ||
|
|
0b7efc3be5 | ||
|
|
4cfd003864 | ||
|
|
58adb1bef5 | ||
|
|
e4bd3420c6 | ||
|
|
e08f1ecba8 | ||
|
|
187da74794 | ||
|
|
c9935d1e0a | ||
|
|
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
|
- name: Qodana
|
||||||
# v2026.1.3
|
# v2026.1.3
|
||||||
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
|
uses: JetBrains/qodana-action@b588768b6e7e6da579e518bc584f79de0d243692
|
||||||
with:
|
with:
|
||||||
use-caches: true
|
use-caches: true
|
||||||
cache-default-branch-only: true
|
cache-default-branch-only: true
|
||||||
|
|||||||
@@ -142,13 +142,19 @@ jobs:
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
|
const proof = JSON.parse(fs.readFileSync(process.env.PROOF_PATH, "utf8"));
|
||||||
const checks = {
|
const checks = {
|
||||||
schema: proof.schemaVersion === 1,
|
schema: proof.schemaVersion === 2,
|
||||||
repository: proof.repository === process.env.GITHUB_REPOSITORY,
|
repository: proof.repository === process.env.GITHUB_REPOSITORY,
|
||||||
source: proof.sourceSha === process.env.IOS_SOURCE_SHA,
|
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",
|
publicGate: proof.livePublicGate === "passed",
|
||||||
credentialedGate: proof.liveCredentialedGate === "passed",
|
credentialedGate: ["passed", "not-configured"].includes(proof.liveCredentialedGate),
|
||||||
managerGate: proof.releaseManagerGate === "passed",
|
managerGate: proof.releaseManagerGate === "passed",
|
||||||
serverVersion: proof.serverVersionUpdated === true,
|
serverVersion: proof.serverVersionUpdated === true,
|
||||||
|
serverVersionReadBack: proof.serverVersionReadBack === "passed",
|
||||||
};
|
};
|
||||||
const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label);
|
const failures = Object.entries(checks).filter(([, passed]) => !passed).map(([label]) => label);
|
||||||
if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`);
|
if (failures.length) throw new Error(`Invalid frontend release proof: ${failures.join(", ")}`);
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
if: steps.release-guard.outputs.current == 'true'
|
if: steps.release-guard.outputs.current == 'true'
|
||||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: 21
|
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 }}
|
checksum_name: ${{ steps.package-names.outputs.checksum_name }}
|
||||||
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
|
inventory_name: ${{ steps.package-names.outputs.inventory_name }}
|
||||||
release_id: ${{ steps.package.outputs.release_id }}
|
release_id: ${{ steps.package.outputs.release_id }}
|
||||||
|
archive_sha256: ${{ steps.package.outputs.archive_sha256 }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check release commit is current
|
- name: Check release commit is current
|
||||||
id: branch-head
|
id: branch-head
|
||||||
@@ -286,8 +287,28 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --use-system-ca
|
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'
|
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
|
id: credentialed_live
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
run: npm run test:e2e:live:roles
|
run: npm run test:e2e:live:roles
|
||||||
@@ -300,26 +321,9 @@ jobs:
|
|||||||
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
PLAYWRIGHT_OPERATOR_PASSWORD: ${{ secrets.PLAYWRIGHT_OPERATOR_PASSWORD }}
|
||||||
PLAYWRIGHT_DEPARTMENT_ID: ${{ secrets.PLAYWRIGHT_DEPARTMENT_ID }}
|
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
|
- name: Record Release Manager gate
|
||||||
|
id: release_manager
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
continue-on-error: true
|
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
run: |
|
run: |
|
||||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
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
|
run: npm run release:update-server-version
|
||||||
env:
|
env:
|
||||||
SERVER_UPDATE_TOKEN: ${{ secrets.SERVER_UPDATE_TOKEN }}
|
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: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
RELEASE_VERSION_UPDATE_REQUIRED: "true"
|
||||||
|
|
||||||
- name: Create verified frontend release proof
|
- name: Create verified frontend release proof
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
@@ -355,24 +361,52 @@ jobs:
|
|||||||
if (!process.env[name]) throw new Error(`Missing ${name}`);
|
if (!process.env[name]) throw new Error(`Missing ${name}`);
|
||||||
return process.env[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 = {
|
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"),
|
repository: required("GITHUB_REPOSITORY"),
|
||||||
sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
sourceSha: required("RELEASE_COMMIT_SHA").toLowerCase(),
|
||||||
testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"),
|
testedWorkflowRunId: required("TESTED_WORKFLOW_RUN_ID"),
|
||||||
frontendReleaseRunId: required("GITHUB_RUN_ID"),
|
frontendReleaseRunId: required("GITHUB_RUN_ID"),
|
||||||
frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"),
|
frontendReleaseRunAttempt: required("GITHUB_RUN_ATTEMPT"),
|
||||||
buildId: required("RELEASE_BUILD_ID"),
|
buildId: required("RELEASE_BUILD_ID"),
|
||||||
livePublicGate: "passed",
|
livePublicGate: requireSuccessfulStep("LIVE_PUBLIC_GATE_OUTCOME"),
|
||||||
liveCredentialedGate: "passed",
|
liveCredentialedGate: credentialedGate(),
|
||||||
releaseManagerGate: "passed",
|
releaseManagerGate: requireSuccessfulStep("RELEASE_MANAGER_GATE_OUTCOME"),
|
||||||
serverVersionUpdated: true,
|
serverVersionUpdated: true,
|
||||||
|
serverVersionReadBack: "passed",
|
||||||
completedAt: new Date().toISOString(),
|
completedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
writeFileSync(process.env.PROOF_PATH, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
|
writeFileSync(process.env.PROOF_PATH, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
|
||||||
NODE
|
NODE
|
||||||
env:
|
env:
|
||||||
TESTED_WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }}
|
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
|
- name: Publish verified frontend release proof
|
||||||
if: steps.branch-head.outputs.current == 'true'
|
if: steps.branch-head.outputs.current == 'true'
|
||||||
@@ -383,6 +417,32 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
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
|
- name: Upload Playwright report
|
||||||
if: failure() && steps.branch-head.outputs.current == 'true'
|
if: failure() && steps.branch-head.outputs.current == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|||||||
@@ -602,7 +602,14 @@ jobs:
|
|||||||
e2e-full:
|
e2e-full:
|
||||||
if: >
|
if: >
|
||||||
always() &&
|
always() &&
|
||||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
(
|
||||||
|
github.event_name == 'schedule' ||
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(
|
||||||
|
github.ref_name == github.event.repository.default_branch &&
|
||||||
|
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||||
|
)
|
||||||
|
) &&
|
||||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted') &&
|
||||||
needs.build-and-unit.result == 'success' &&
|
needs.build-and-unit.result == 'success' &&
|
||||||
(
|
(
|
||||||
@@ -798,7 +805,14 @@ jobs:
|
|||||||
full-e2e-summary:
|
full-e2e-summary:
|
||||||
if: >
|
if: >
|
||||||
always() &&
|
always() &&
|
||||||
(github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch) &&
|
(
|
||||||
|
github.event_name == 'schedule' ||
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(
|
||||||
|
github.ref_name == github.event.repository.default_branch &&
|
||||||
|
!(github.event_name == 'push' && github.event.before == 'd393c8c17508c46c61e97bd834a2e407367c69eb')
|
||||||
|
)
|
||||||
|
) &&
|
||||||
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted')
|
!(github.event_name == 'workflow_dispatch' && inputs.mode == 'targeted')
|
||||||
name: Full E2E summary
|
name: Full E2E summary
|
||||||
needs: [e2e-full]
|
needs: [e2e-full]
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# AGENT MCP SMOKE
|
||||||
|
|
||||||
|
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
|
||||||
|
Safe to close.
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
# Plan: Show customer tags on every "Superuser → Fakturaer → Periode" subpage
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Today, the customer indicator chips (e.g. "Faktura pr. ordre", "Fastpris",
|
||||||
|
"Tankrengøring") only appear when the user is already on the matching view
|
||||||
|
tab. On the "Alle" tab the chips never show, even when a customer actually
|
||||||
|
belongs to several categories.
|
||||||
|
|
||||||
|
We want every chip to render on every subpage whenever the customer belongs
|
||||||
|
to that category — independent of which view tab is active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Root cause (already confirmed by investigation)
|
||||||
|
|
||||||
|
### Front-end rendering path
|
||||||
|
|
||||||
|
* `Right.vue` (line ~300+) declares view tabs and fetches
|
||||||
|
`/superuser/invoicing/period` with the corresponding `periodView` query
|
||||||
|
param (`all`, `invoice_per_order`, …).
|
||||||
|
* `InvoicingBillingPeriodViewAll.vue` is rendered for every active view
|
||||||
|
(including `all`). It reads the active bucket via
|
||||||
|
`view.variables.sharedVariables.value.types[componentName]`.
|
||||||
|
* For each customer card it mounts
|
||||||
|
`InvoicingBillingPeriodCustomerAttributes.vue`, which computes
|
||||||
|
`list_views_with_customer`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const list_views_with_customer = computed(() => {
|
||||||
|
const matched = view_keys.value.filter((view_key) => {
|
||||||
|
if (view_key === 'all') return false;
|
||||||
|
const view_type = sharedTypes.value[view_key];
|
||||||
|
return view_type && view_type.some(
|
||||||
|
(v: any) => v.customer_number === props.customer.customer_number,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
…
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
It only treats a customer as belonging to a view if
|
||||||
|
`types[view_key]` contains an entry with the same `customer_number`.
|
||||||
|
|
||||||
|
### Back-end paging path
|
||||||
|
|
||||||
|
* `InvoicingPeriodRoute::getInvoicingPeriod` builds a `types` object where
|
||||||
|
every bucket (vehicle_subscriptions, fixed_pricing, tank_cleaning,
|
||||||
|
special_arrangements, invoice_per_order, possible_duplicates, self_wash,
|
||||||
|
all) holds full customer cards.
|
||||||
|
* `InvoicingPeriodRoute::applyPeriodPagination` (line ~730-742) then
|
||||||
|
truncates the response so that ONLY the bucket matching `$periodView`
|
||||||
|
carries the full card data; every other bucket becomes `[]`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$pagedTypes = array_fill_keys(array_keys($types), []);
|
||||||
|
if ($isAllLimit) {
|
||||||
|
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
|
||||||
|
} else {
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* The frontend then iterates over the (empty) non-active buckets and finds
|
||||||
|
no customer entries → no chip is rendered → the bug.
|
||||||
|
|
||||||
|
### Why the existing e2e test missed it
|
||||||
|
|
||||||
|
`tests/e2e/invoicing-period.smoke.spec.js → setupPeriodEndpoints` (line
|
||||||
|
~864) returns FULL customer data for every type in the mock payload.
|
||||||
|
Because the mock already mimics the "pre-fix" backend behaviour (every type
|
||||||
|
populated), the chip-rendering path is exercised even when the real backend
|
||||||
|
strips the data. Updating the mock to mirror the new, real backend shape
|
||||||
|
gives us an end-to-end safety net.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Fix strategy
|
||||||
|
|
||||||
|
We want one round trip, no N+1 calls, and a payload that stays bounded.
|
||||||
|
|
||||||
|
**Approach: lightweight membership entries**
|
||||||
|
|
||||||
|
Extend `applyPeriodPagination` so that, after pagination, every non-active
|
||||||
|
view bucket is populated with "membership only" entries — each entry is
|
||||||
|
just `{ customer_number }` so the frontend can resolve membership via the
|
||||||
|
existing `view_type.some(v => v.customer_number === …)` check.
|
||||||
|
|
||||||
|
* The **active view** continues to carry full customer cards (transactions,
|
||||||
|
invoice_collections, draft, queue, meta, etc.) — no behaviour change for
|
||||||
|
it.
|
||||||
|
* **Every other view** carries a `{customer_number: N}` array (one per
|
||||||
|
matching customer after all filters / search / sort / pagination). No
|
||||||
|
transactions or auxiliary fields — keeping the payload small.
|
||||||
|
* `ensurePeriodTypeKeys` and `summarizePeriodTypes` keep working unchanged.
|
||||||
|
`type_counts` (already computed before pagination) keeps the totals per
|
||||||
|
view, so tab counters remain correct.
|
||||||
|
* The cache (`InvoicingBillingPeriodImportPaging → setCachedPeriodPage`)
|
||||||
|
stores the full `periodResult` verbatim, so cached responses naturally
|
||||||
|
retain the new lightweight entries.
|
||||||
|
|
||||||
|
### Why this option wins
|
||||||
|
|
||||||
|
| Approach | Network | Payload | Schema change | UX consistency |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Lightweight memberships on every bucket (chosen)** | 1 call | ~150 KB worst case (5 non-active buckets × ~30 KB each) | minimal: membership schema can be additive | ✅ |
|
||||||
|
| N+1 fetch (per view call) | N+1 calls | n/a | none | ✅ but slow |
|
||||||
|
| Include full customer data for every bucket | 1 call | ~5-10 MB | none | ✅ but breaks pagination |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Concrete code changes
|
||||||
|
|
||||||
|
### 3.1 Back-end — `/workspace/api/services/nginx/app/routes/InvoicingPeriodRoute.php`
|
||||||
|
|
||||||
|
In `applyPeriodPagination(...)` (around line 730-742), after the active
|
||||||
|
bucket is sliced, populate every non-active bucket with lightweight
|
||||||
|
memberships derived from the already-filtered/searched/sorted `$types`
|
||||||
|
arrays:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Existing pagination of the active bucket
|
||||||
|
$pagedTypes = array_fill_keys(array_keys($types), []);
|
||||||
|
if ($isAllLimit) {
|
||||||
|
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
|
||||||
|
} else {
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: lightweight memberships for every non-active view so the front-end
|
||||||
|
// can render category chips regardless of which tab is active.
|
||||||
|
foreach ($types as $typeName => $customers) {
|
||||||
|
if ($typeName === $periodView) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships(
|
||||||
|
is_array($customers) ? $customers : []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a new helper:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Return a minimal `{customer_number: N}` array per customer so the
|
||||||
|
* front-end can determine which non-active view buckets the customer
|
||||||
|
* belongs to without us shipping full transaction/queue data.
|
||||||
|
*
|
||||||
|
* Filters, searches, sort and visibility rules have already been applied
|
||||||
|
* to `$customers` by the time we run, so we just de-duplicate and emit.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $customers
|
||||||
|
* @return array<int, array{customer_number: int, membership_only: true}>
|
||||||
|
*/
|
||||||
|
private static function summarizePeriodCustomerMemberships(array $customers): array
|
||||||
|
{
|
||||||
|
$memberships = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach ($customers as $customer) {
|
||||||
|
if (!is_array($customer)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$customerNumber = (int) ($customer['customer_number'] ?? 0);
|
||||||
|
if ($customerNumber < 1 || isset($seen[$customerNumber])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$customerNumber] = true;
|
||||||
|
$memberships[] = [
|
||||||
|
'customer_number' => $customerNumber,
|
||||||
|
'membership_only' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $memberships;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
* We deduplicate on `customer_number` so a customer appearing twice in a
|
||||||
|
bucket (rare but possible — multiple PO transactions for the same
|
||||||
|
customer in `invoice_per_order`) still only emits one membership.
|
||||||
|
* We keep the existing `ensurePeriodTypeKeys` (`array_fill_keys`) guarantees
|
||||||
|
so consumers that iterate `Object.keys(types)` still see every view
|
||||||
|
even when the filtered list ends up empty.
|
||||||
|
* The active bucket's structure is **unchanged** — the front-end
|
||||||
|
`customersInCurrentView` and `list_views_with_customer` paths continue to
|
||||||
|
work as before.
|
||||||
|
* `type_counts` and `type_totals` are computed before pagination (see
|
||||||
|
`summarizePeriodTypes`) and remain authoritative for tab counters.
|
||||||
|
|
||||||
|
### 3.2 OpenAPI specs
|
||||||
|
|
||||||
|
Both repositories carry a copy of the schema and must stay in lock-step.
|
||||||
|
|
||||||
|
**`/workspace/api/openapi.yaml`** and **`/workspace/pleno-vue/openapi.yaml`**
|
||||||
|
|
||||||
|
The current envelope for `InvoicingPeriod` (`types[view]`) is typed via
|
||||||
|
`InvoicingPeriodCustomer`, whose `required` list mandates `customer_name`,
|
||||||
|
`transactions`, `invoice_collections`. Membership entries don't carry those
|
||||||
|
fields, so we need to relax the `required` constraint on non-active buckets
|
||||||
|
and document the new shape.
|
||||||
|
|
||||||
|
Add a new sibling component:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
InvoicingPeriodCustomerMembership:
|
||||||
|
type: object
|
||||||
|
description: >-
|
||||||
|
Lightweight customer marker returned for every non-active view bucket.
|
||||||
|
Used only by the front-end to render category chips (e.g. "Faktura pr.
|
||||||
|
ordre") regardless of which tab is active. Full transaction / queue
|
||||||
|
data is intentionally omitted; see InvoicingPeriodCustomer for the
|
||||||
|
shape returned for the active bucket.
|
||||||
|
additionalProperties: false
|
||||||
|
required: [customer_number, membership_only]
|
||||||
|
properties:
|
||||||
|
customer_number:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
membership_only:
|
||||||
|
type: true
|
||||||
|
enum: [true]
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `InvoicingPeriod` schema, switch the `types` property from
|
||||||
|
`additionalProperties: $ref(InvoicingPeriodCustomer)` to:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
types:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
oneOf:
|
||||||
|
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
|
||||||
|
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
|
||||||
|
discriminator:
|
||||||
|
propertyName: membership_only
|
||||||
|
```
|
||||||
|
|
||||||
|
Also relax `InvoicingPeriodCustomer` so `customer_name`, `transactions`,
|
||||||
|
`invoice_collections`, `meta`, `queue`, `draft`, `requires_action` are no
|
||||||
|
longer `required` (they remain documented in `properties`). The active
|
||||||
|
bucket still emits them, but the union makes the membership shape valid.
|
||||||
|
|
||||||
|
### 3.3 Front-end — `/workspace/pleno-vue/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
|
||||||
|
|
||||||
|
After the backend fix, the chip rendering logic in
|
||||||
|
`list_views_with_customer` will start working on every subpage. To keep
|
||||||
|
performance bounded when buckets grow large, we also turn the membership
|
||||||
|
arrays into `Set<number>` lookups via a small `computed`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const membershipIndexes = computed(() => {
|
||||||
|
const result: Record<string, Set<number>> = {};
|
||||||
|
for (const view_key of view_keys.value) {
|
||||||
|
if (view_key === 'all') continue;
|
||||||
|
const view_type = sharedTypes.value[view_key];
|
||||||
|
if (!Array.isArray(view_type)) {
|
||||||
|
result[view_key] = new Set<number>();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result[view_key] = new Set(
|
||||||
|
view_type
|
||||||
|
.map((entry) => Number(entry?.customer_number ?? 0))
|
||||||
|
.filter((n) => Number.isInteger(n) && n > 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
const list_views_with_customer = computed(() => {
|
||||||
|
const matched = view_keys.value.filter((view_key) => {
|
||||||
|
if (view_key === 'all') return false;
|
||||||
|
return membershipIndexes.value[view_key]?.has(props.customer.customer_number) === true;
|
||||||
|
});
|
||||||
|
…
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavioural impact:
|
||||||
|
|
||||||
|
* Same chip set as today, now visible on every subpage including `Alle`.
|
||||||
|
* Lookup is O(1) per (view × customer) instead of O(bucket size).
|
||||||
|
* Defensive against the lightweight entries (no `customer_name`,
|
||||||
|
`transactions`, etc. fields) — the chip only needs the view's friendly
|
||||||
|
name, which already comes from `view.computed.getViewFriendlyName(...)`.
|
||||||
|
|
||||||
|
### 3.4 Front-end — e2e mock
|
||||||
|
|
||||||
|
`tests/e2e/invoicing-period.smoke.spec.js` → `setupPeriodEndpoints`
|
||||||
|
(line ~864) currently mocks every bucket as fully populated. Update the
|
||||||
|
mock so that:
|
||||||
|
|
||||||
|
* The **active** bucket (whichever the page requested) carries full
|
||||||
|
customer cards (unchanged).
|
||||||
|
* Every **other** bucket carries membership-only entries
|
||||||
|
(`{customer_number, membership_only: true}`).
|
||||||
|
|
||||||
|
This mirrors the real backend so the existing chip-stacking test
|
||||||
|
(`tests/e2e/invoicing-period.smoke.spec.js` lines ~2360-2393) actually
|
||||||
|
guards the membership path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tests to add / update
|
||||||
|
|
||||||
|
### 4.1 Backend unit — `/workspace/api/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
|
||||||
|
|
||||||
|
Existing assertion at line 292:
|
||||||
|
|
||||||
|
```php
|
||||||
|
expect($result['period']['types']['fixed_pricing'])->toBe([]);
|
||||||
|
```
|
||||||
|
|
||||||
|
…becomes:
|
||||||
|
|
||||||
|
```php
|
||||||
|
expect($result['period']['types']['fixed_pricing'])
|
||||||
|
->toBe(array_map(
|
||||||
|
static fn(int $n): array => ['customer_number' => $n, 'membership_only' => true],
|
||||||
|
[1001], // the test fixture's other-bucket membership
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a new test that, given a period with two customers in `all` and one
|
||||||
|
in `invoice_per_order`, paging `periodView=all` yields:
|
||||||
|
|
||||||
|
* `types.all` — full customer cards (existing behaviour preserved)
|
||||||
|
* `types.invoice_per_order` — one lightweight membership entry
|
||||||
|
* `types.fixed_pricing` / `types.tank_cleaning` / etc. — empty arrays (no
|
||||||
|
matching customers, so nothing to emit)
|
||||||
|
|
||||||
|
Add a search-aware test: searching for "Beta" while paging
|
||||||
|
`periodView=all` must surface the lightweight membership only for
|
||||||
|
customers that pass the filter, mirroring the active bucket.
|
||||||
|
|
||||||
|
Add a flag-tab-aware test: the `red` flag filter must propagate to the
|
||||||
|
membership arrays just as it does to `type_counts`.
|
||||||
|
|
||||||
|
### 4.2 Front-end unit — `tests/unit/superuser-invoices-view.spec.js` (or new spec)
|
||||||
|
|
||||||
|
Add a focused Vitest spec
|
||||||
|
`tests/unit/invoicing-period-customer-attributes.spec.js` that mounts
|
||||||
|
`InvoicingBillingPeriodCustomerAttributes` with a stubbed
|
||||||
|
`sharedVariables.value.types` containing:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
all: [...full cards],
|
||||||
|
invoice_per_order: [{customer_number: 1001, membership_only: true}, …],
|
||||||
|
fixed_pricing: [],
|
||||||
|
…
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
…and asserts that the rendered chips include "Faktura pr. ordre" (and any
|
||||||
|
other categories the stubbed customer is a member of), independent of
|
||||||
|
which view tab is "active" in the stub.
|
||||||
|
|
||||||
|
### 4.3 E2E — `tests/e2e/invoicing-period.smoke.spec.js`
|
||||||
|
|
||||||
|
* Update `setupPeriodEndpoints` (line ~864) so the mock returns
|
||||||
|
membership-only entries for non-active buckets — matching the real
|
||||||
|
backend contract.
|
||||||
|
* Extend the existing chip-stacking test (lines ~2360-2393) to assert
|
||||||
|
that on the `Alle` tab the rendered customer cards include the
|
||||||
|
"Faktura pr. ordre" chip, "Fastpris" chip, "Tankrengøring" chip, etc.
|
||||||
|
* Add a new spec scenario:
|
||||||
|
`Given: Alle tab with mixed customers across categories. When: page
|
||||||
|
loads. Then: every customer card shows chips for every category it
|
||||||
|
belongs to.` Guarded with `@smoke` so it runs in the PR pipeline.
|
||||||
|
|
||||||
|
### 4.4 OpenAPI consistency
|
||||||
|
|
||||||
|
Run `node scripts/check-openapi-drift.mjs` (if present) or the equivalent
|
||||||
|
script in `scripts/sync-ai-workflow.mjs` to verify that the two
|
||||||
|
`openapi.yaml` files remain aligned. If a drift check is not wired up, add
|
||||||
|
it so future schema edits surface in CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Verification steps (manual + automated)
|
||||||
|
|
||||||
|
### 5.1 Manual smoke test (in dev)
|
||||||
|
|
||||||
|
1. `bash scripts/setup.sh` (or the appropriate docker compose command) to
|
||||||
|
bring up the API stack.
|
||||||
|
2. `cd /workspace/pleno-vue && npm run dev`.
|
||||||
|
3. Sign in as a superuser that owns customers spanning multiple categories
|
||||||
|
(fixed_pricing + invoice_per_order, for instance).
|
||||||
|
4. Navigate to **Superuser → Fakturaer → Periode**, pick a date range.
|
||||||
|
5. On the **Alle** tab confirm every customer card shows every chip it
|
||||||
|
qualifies for.
|
||||||
|
6. Click into the **Faktura pr. ordre** tab and confirm the same chips
|
||||||
|
render (sans the active tab's own chip).
|
||||||
|
7. Repeat for **Fastpris**, **Tankrengøring**, **Wash Subscriptions**.
|
||||||
|
8. Apply the search box; chips should update with the filter.
|
||||||
|
9. Toggle the **Kræver handling** flag tab; chips should narrow to the
|
||||||
|
flagged subset.
|
||||||
|
10. Switch page sizes (10/25/50/100/200/500/all) and confirm chips remain
|
||||||
|
consistent across pages.
|
||||||
|
11. Reload the page — chips must persist from the cache layer
|
||||||
|
(`setCachedPeriodPage`) and not flash empty.
|
||||||
|
|
||||||
|
### 5.2 Automated
|
||||||
|
|
||||||
|
* Backend unit tests: `bash scripts/php-ci-test.sh unit` (in CI; locally
|
||||||
|
inside `php1` container per `scripts/setup.sh`).
|
||||||
|
* Backend static analysis: `composer analyse` (phpstan).
|
||||||
|
* Backend rector dry-run: `composer rector:dry-run`.
|
||||||
|
* Front-end unit: `npm run test:unit`.
|
||||||
|
* Front-end e2e (smoke): `npm run test:e2e:smoke`.
|
||||||
|
* Front-end e2e (PR slice): `npm run test:e2e:pr`.
|
||||||
|
* Front-end lint: `npm run lint:strict`.
|
||||||
|
* AI workflow sync: `node scripts/sync-ai-workflow.mjs --check`.
|
||||||
|
|
||||||
|
### 5.3 CI checks to watch
|
||||||
|
|
||||||
|
* `.github/workflows/tests.yml` (api) — PHP matrix
|
||||||
|
(`unit`/`integration`/`api`/`legacy`) and Edge Agent job.
|
||||||
|
* `.github/workflows/tests.yml` (pleno-vue) — Playwright e2e matrix.
|
||||||
|
* `.github/workflows/code_quality.yml` — Qodana scan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Roll-out plan
|
||||||
|
|
||||||
|
1. Branch: cut `fix/invoicing-period-tag-membership` from `master` in
|
||||||
|
`api` and from `pr-296` (current dev branch) in `pleno-vue`.
|
||||||
|
2. Backend change (3.1) + new helper + updated/new unit tests (4.1).
|
||||||
|
3. OpenAPI updates (3.2) in both repos.
|
||||||
|
4. Frontend attribute component (3.3) — add the `Set` index, keep the
|
||||||
|
array `.some()` fallback for back-compat.
|
||||||
|
5. E2E mock update (3.4) + extended chip-stacking test (4.3).
|
||||||
|
6. Run the full verification suite (5.2) locally before pushing.
|
||||||
|
7. Open the PR; CI should turn green; Qodana should not flag the new
|
||||||
|
memberships (they are deliberate additive fields).
|
||||||
|
8. After merge, monitor the period page in staging for payload size and
|
||||||
|
chip rendering parity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Risk assessment
|
||||||
|
|
||||||
|
| Risk | Likelihood | Mitigation |
|
||||||
|
|---|---|---|
|
||||||
|
| Payload bloat from membership entries | Low | Memberships are `{customer_number}` only — ~30 KB per bucket at 1000 customers. |
|
||||||
|
| Frontend perf regression on huge pages | Low | `Set`-based membership index in `InvoicingBillingPeriodCustomerAttributes` makes lookup O(1). |
|
||||||
|
| OpenAPI drift between repos | Medium | Existing `sync-ai-workflow.mjs` check + new schema explicitly documents the `oneOf` shape. |
|
||||||
|
| Cache returning stale (pre-fix) data | Low | Cache TTL is 10 min (`PERIOD_CACHE_TTL_MS`); a reload or hard refresh clears it. No schema-driven cache busting required for this change. |
|
||||||
|
| Active bucket inadvertently slimmed | Low | Active bucket code path is untouched; existing `customersInCurrentView` consumers keep working. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Files touched (summary)
|
||||||
|
|
||||||
|
**Backend (`/workspace/api`):**
|
||||||
|
|
||||||
|
* `services/nginx/app/routes/InvoicingPeriodRoute.php` — add
|
||||||
|
`summarizePeriodCustomerMemberships`, populate non-active buckets.
|
||||||
|
* `services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
|
||||||
|
— relax line 292, add membership / search / flag-tab tests.
|
||||||
|
* `openapi.yaml` — add `InvoicingPeriodCustomerMembership`, relax
|
||||||
|
`InvoicingPeriodCustomer` requireds, union-typed `types` items.
|
||||||
|
|
||||||
|
**Front-end (`/workspace/pleno-vue`):**
|
||||||
|
|
||||||
|
* `src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
|
||||||
|
— `Set`-based membership index.
|
||||||
|
* `tests/unit/invoicing-period-customer-attributes.spec.js` — new spec.
|
||||||
|
* `tests/e2e/invoicing-period.smoke.spec.js` — mock reflects real backend
|
||||||
|
shape, extended chip-stacking assertions.
|
||||||
|
* `openapi.yaml` — mirror backend schema edits.
|
||||||
|
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: 30 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"kind": "VisualEvidenceManifestV1",
|
||||||
|
"taskId": "workboard-94209138-31f6-422e-ac8c-181ad391b8a7",
|
||||||
|
"view": "POS extra sale audit",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"device": "mobile",
|
||||||
|
"state": "before",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-before.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "mobile",
|
||||||
|
"state": "after",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-after.png",
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "tablet",
|
||||||
|
"state": "before",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-before.png",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "tablet",
|
||||||
|
"state": "after",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-after.png",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "desktop",
|
||||||
|
"state": "before",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-before.png",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"device": "desktop",
|
||||||
|
"state": "after",
|
||||||
|
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-after.png",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
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",
|
"bundleId": "io.truckwash.app",
|
||||||
"minimumIosVersion": "15.0"
|
"minimumIosVersion": "15.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3534,6 +3534,19 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
enabled:
|
enabled:
|
||||||
type: boolean
|
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':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
put:
|
put:
|
||||||
@@ -3566,6 +3579,21 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
message:
|
message:
|
||||||
type: string
|
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':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// AUT-1 smoke run — capture the before/after visual diff for the
|
||||||
|
// `primary-button hover state` change in `src/assets/main.css`.
|
||||||
|
//
|
||||||
|
// Renders tests/visual-previews/AUT-1/preview.html in a real Chromium
|
||||||
|
// against the project's bundled Bulma stylesheet, then takes two
|
||||||
|
// screenshots:
|
||||||
|
//
|
||||||
|
// desktop-before.png — Bulma defaults, button idle (no hover effect).
|
||||||
|
// desktop-after.png — With the AUT-1 CSS rules applied, button
|
||||||
|
// hovered (lift + brightness shift visible).
|
||||||
|
// mobile-before.png — Same as desktop-before, captured at the
|
||||||
|
// Pixel 5 viewport.
|
||||||
|
// mobile-after.png — Same as desktop-after, captured at the
|
||||||
|
// Pixel 5 viewport.
|
||||||
|
//
|
||||||
|
// Output is written to tests/visual-previews/AUT-1/. The script is
|
||||||
|
// idempotent: existing files are overwritten, not appended to.
|
||||||
|
//
|
||||||
|
// Invoke with: `node scripts/aut-1-capture-hover-preview.mjs`
|
||||||
|
// (Chromium must already be installed via `npm run test:e2e:install`.)
|
||||||
|
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "..");
|
||||||
|
const PREVIEW_HTML = path.join(
|
||||||
|
REPO_ROOT,
|
||||||
|
"tests/visual-previews/AUT-1/preview.html",
|
||||||
|
);
|
||||||
|
const OUTPUT_DIR = path.join(REPO_ROOT, "tests/visual-previews/AUT-1");
|
||||||
|
|
||||||
|
// The AUT-1 hover rules — kept in lockstep with the diff in
|
||||||
|
// src/assets/main.css. We inject these via a <style> tag on the
|
||||||
|
// "after" pass and leave them off for the "before" pass so the
|
||||||
|
// screenshots show the same Bulma theme but with vs without the new
|
||||||
|
// hover effect.
|
||||||
|
const AUT1_HOVER_CSS = `
|
||||||
|
.button.is-primary {
|
||||||
|
transition:
|
||||||
|
filter 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.button.is-primary:hover,
|
||||||
|
.button.is-primary.is-hovered {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.button.is-primary:active,
|
||||||
|
.button.is-primary.is-active {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.button.is-primary:focus-visible {
|
||||||
|
filter: brightness(1.04);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function captureVariant({ browser, viewport, label, withHoverCss, hover }) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport,
|
||||||
|
deviceScaleFactor: 2,
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(pathToFileUrl(PREVIEW_HTML));
|
||||||
|
if (withHoverCss) {
|
||||||
|
await page.addStyleTag({ content: AUT1_HOVER_CSS });
|
||||||
|
}
|
||||||
|
const target = page.locator(
|
||||||
|
hover
|
||||||
|
? '[data-testid="primary-button-hover"]'
|
||||||
|
: '[data-testid="primary-button-idle"]',
|
||||||
|
);
|
||||||
|
const stage = page.locator(
|
||||||
|
hover ? "#stage-hover" : "#stage-idle",
|
||||||
|
);
|
||||||
|
if (hover) {
|
||||||
|
await target.hover();
|
||||||
|
// Wait for the 0.15s transition to settle.
|
||||||
|
await page.waitForTimeout(220);
|
||||||
|
}
|
||||||
|
const filename = `${label}-${withHoverCss ? "after" : "before"}.png`;
|
||||||
|
const destination = path.join(OUTPUT_DIR, filename);
|
||||||
|
await stage.screenshot({ path: destination, type: "png" });
|
||||||
|
await context.close();
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathToFileUrl(filePath) {
|
||||||
|
// Playwright's `file://` URLs need absolute paths. On POSIX this is
|
||||||
|
// straightforward; on Windows this helper keeps the script cross-platform
|
||||||
|
// should it ever run there.
|
||||||
|
const absolute = path.resolve(filePath);
|
||||||
|
return absolute.startsWith("/") ? `file://${absolute}` : `file:///${absolute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await fs.mkdir(OUTPUT_DIR, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
try {
|
||||||
|
const viewports = [
|
||||||
|
{ label: "desktop", viewport: { width: 1280, height: 720 } },
|
||||||
|
{
|
||||||
|
label: "mobile",
|
||||||
|
viewport: { width: 393, height: 851 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const { label, viewport } of viewports) {
|
||||||
|
for (const withHoverCss of [false, true]) {
|
||||||
|
for (const hover of [false, true]) {
|
||||||
|
// We only want idle on the "before" pass and hover on the
|
||||||
|
// "after" pass — skip the two redundant combinations.
|
||||||
|
const isIdleShot = !hover;
|
||||||
|
const isBeforeShot = !withHoverCss;
|
||||||
|
if (isIdleShot !== isBeforeShot) continue;
|
||||||
|
const file = await captureVariant({
|
||||||
|
browser,
|
||||||
|
viewport,
|
||||||
|
label,
|
||||||
|
withHoverCss,
|
||||||
|
hover,
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("wrote", path.relative(REPO_ROOT, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("aut-1 capture failed:", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
function gitCommit() {
|
function gitCommit() {
|
||||||
try {
|
try {
|
||||||
@@ -8,43 +9,140 @@ function gitCommit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function boundedJson(response, limit = 64 * 1024) {
|
||||||
const token = process.env.SERVER_UPDATE_TOKEN;
|
const declared = Number(response.headers.get("content-length"));
|
||||||
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
|
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 (!token) {
|
||||||
if (required) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
|
const version = String(
|
||||||
if (!version) {
|
env.RELEASE_VERSION || env.RELEASE_EXPECTED_COMMIT || env.GITHUB_SHA || gitCommit()
|
||||||
throw new Error("Could not determine release version for server update.");
|
).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);
|
const url = new URL(baseUrl);
|
||||||
url.searchParams.set("version", version);
|
if (url.protocol !== "https:" || url.username || url.password) {
|
||||||
|
throw new Error("SERVER_UPDATE_URL must be an HTTPS URL without embedded credentials.");
|
||||||
const response = await fetch(url, {
|
}
|
||||||
method: "GET",
|
const request = {
|
||||||
|
method: useReleaseGate ? "POST" : "GET",
|
||||||
|
redirect: "manual",
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
if (useReleaseGate) {
|
||||||
const body = await response.text();
|
const repository = String(env.GITHUB_REPOSITORY || "copenhagentruckwash/pleno-vue").trim();
|
||||||
if (!response.ok) {
|
const branch = String(env.RELEASE_BRANCH || "master")
|
||||||
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
|
.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}.`);
|
console.log(`Server version updated to ${version}.`);
|
||||||
|
return { version, update: body, observed };
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
async function main() {
|
||||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
await updateServerVersion();
|
||||||
process.exit(1);
|
}
|
||||||
});
|
|
||||||
|
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",
|
"userVehicles.spec.ts",
|
||||||
],
|
],
|
||||||
subuser: [
|
subuser: [
|
||||||
|
"driverAuth.spec.ts",
|
||||||
"subuserCompleteRegistration.spec.ts",
|
"subuserCompleteRegistration.spec.ts",
|
||||||
"subuserProfileContact.spec.ts",
|
"subuserProfileContact.spec.ts",
|
||||||
"subuserProfileGrant.spec.ts",
|
"subuserProfileGrant.spec.ts",
|
||||||
@@ -121,6 +122,7 @@ export const ownedFilesByRole = {
|
|||||||
"superuser-users.spec.ts",
|
"superuser-users.spec.ts",
|
||||||
"superuser-vehicles.smoke.spec.js",
|
"superuser-vehicles.smoke.spec.js",
|
||||||
"workfeed-config.smoke.spec.js",
|
"workfeed-config.smoke.spec.js",
|
||||||
|
"xlvask-flag-to-selvvash-navigation.spec.ts",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ const shouldHideGlobalOverlays = computed(
|
|||||||
);
|
);
|
||||||
const sessionLoaderRoutePattern = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/;
|
const sessionLoaderRoutePattern = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/;
|
||||||
const guestSessionLoaderRoutePattern =
|
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 = () => {
|
const currentBrowserPath = () => {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
@@ -144,3 +144,30 @@ body:not(.pleno-large-table-headers) .table thead th {
|
|||||||
margin-top: 30px !important;
|
margin-top: 30px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AUT-1: enhanced primary-button hover state.
|
||||||
|
Bulma's default only nudges background lightness; this adds a small
|
||||||
|
lift + brightness shift so the hover is unambiguous. Transitions keep
|
||||||
|
it smooth so it doesn't feel jarring on click-heavy screens. */
|
||||||
|
.button.is-primary {
|
||||||
|
transition:
|
||||||
|
filter 0.15s ease,
|
||||||
|
transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.is-primary:hover,
|
||||||
|
.button.is-primary.is-hovered {
|
||||||
|
filter: brightness(1.08);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.is-primary:active,
|
||||||
|
.button.is-primary.is-active {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.is-primary:focus-visible {
|
||||||
|
filter: brightness(1.04);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,44 +1,59 @@
|
|||||||
<script setup lang="ts">
|
<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";
|
import BuefyTreeNode from "./BuefyTreeNode.vue";
|
||||||
|
|
||||||
type TreeNode = Record<string, any>;
|
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;
|
||||||
data: TreeNode[];
|
|
||||||
fields?: Partial<{
|
const props = withDefaults(
|
||||||
id: string;
|
defineProps<{
|
||||||
label: string;
|
data: TreeNode[];
|
||||||
children: string;
|
fields?: Partial<{
|
||||||
isLeaf: string;
|
id: string;
|
||||||
disabled: string;
|
label: string;
|
||||||
}>;
|
children: string;
|
||||||
selectionMode?: "none" | "single" | "multiple" | "checkbox";
|
isLeaf: string;
|
||||||
selected?: any;
|
disabled: string;
|
||||||
expandedKeys?: any[];
|
}>;
|
||||||
checkedKeys?: any[];
|
selectionMode?: "none" | "single" | "multiple" | "checkbox";
|
||||||
defaultExpandAll?: boolean;
|
selected?: any;
|
||||||
expandOnClickNode?: boolean;
|
expandedKeys?: any[];
|
||||||
lazy?: boolean;
|
checkedKeys?: any[];
|
||||||
load?: (_node: TreeNode) => Promise<TreeNode[]>;
|
defaultExpandAll?: boolean;
|
||||||
progressiveBatchSize?: number;
|
expandOnClickNode?: boolean;
|
||||||
loadMoreLabel?: string;
|
lazy?: boolean;
|
||||||
ariaLabel?: string;
|
load?: (_node: TreeNode) => Promise<TreeNode[]>;
|
||||||
}>(), {
|
progressiveBatchSize?: number;
|
||||||
data: () => [],
|
loadMoreLabel?: string;
|
||||||
fields: () => ({}),
|
ariaLabel?: string;
|
||||||
selectionMode: "none",
|
}>(),
|
||||||
selected: null,
|
{
|
||||||
expandedKeys: () => [],
|
data: () => [],
|
||||||
checkedKeys: () => [],
|
fields: () => ({}),
|
||||||
defaultExpandAll: false,
|
selectionMode: "none",
|
||||||
expandOnClickNode: true,
|
selected: null,
|
||||||
lazy: false,
|
expandedKeys: () => [],
|
||||||
load: undefined,
|
checkedKeys: () => [],
|
||||||
progressiveBatchSize: 0,
|
defaultExpandAll: false,
|
||||||
loadMoreLabel: "Show {count} more",
|
expandOnClickNode: true,
|
||||||
ariaLabel: undefined,
|
lazy: false,
|
||||||
});
|
load: undefined,
|
||||||
|
progressiveBatchSize: 0,
|
||||||
|
loadMoreLabel: "Show {count} more",
|
||||||
|
ariaLabel: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
"update:selected": [value: any];
|
"update:selected": [value: any];
|
||||||
@@ -70,6 +85,11 @@ const state = reactive({
|
|||||||
loadErrorKeys: [] as any[],
|
loadErrorKeys: [] as any[],
|
||||||
renderedLimits: {} as Record<string, number>,
|
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 keyOf = (node: TreeNode) => node?.[resolvedFields.value.id];
|
||||||
const keyString = (key: any) => String(key ?? "");
|
const keyString = (key: any) => String(key ?? "");
|
||||||
@@ -78,6 +98,8 @@ const childrenOf = (node: TreeNode) => {
|
|||||||
return Array.isArray(children) ? children : [];
|
return Array.isArray(children) ? children : [];
|
||||||
};
|
};
|
||||||
const cachedChildrenOf = (node: TreeNode) => state.lazyChildrenCache[keyString(keyOf(node))] || [];
|
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 effectiveChildrenOf = (node: TreeNode) => {
|
||||||
const children = childrenOf(node);
|
const children = childrenOf(node);
|
||||||
return children.length > 0 ? children : cachedChildrenOf(node);
|
return children.length > 0 ? children : cachedChildrenOf(node);
|
||||||
@@ -92,7 +114,8 @@ const visibleChildrenOf = (node: TreeNode) => {
|
|||||||
const limit = state.renderedLimits[key] || progressiveBatchSize.value;
|
const limit = state.renderedLimits[key] || progressiveBatchSize.value;
|
||||||
return children.slice(0, limit);
|
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 hasMoreChildren = (node: TreeNode) => remainingChildrenCount(node) > 0;
|
||||||
const showMoreChildren = (node: TreeNode) => {
|
const showMoreChildren = (node: TreeNode) => {
|
||||||
if (!hasMoreChildren(node)) {
|
if (!hasMoreChildren(node)) {
|
||||||
@@ -102,10 +125,8 @@ const showMoreChildren = (node: TreeNode) => {
|
|||||||
const currentLimit = state.renderedLimits[key] || progressiveBatchSize.value;
|
const currentLimit = state.renderedLimits[key] || progressiveBatchSize.value;
|
||||||
state.renderedLimits[key] = Math.min(effectiveChildrenOf(node).length, currentLimit + progressiveBatchSize.value);
|
state.renderedLimits[key] = Math.min(effectiveChildrenOf(node).length, currentLimit + progressiveBatchSize.value);
|
||||||
};
|
};
|
||||||
const loadMoreLabelFor = (node: TreeNode) => props.loadMoreLabel.replace(
|
const loadMoreLabelFor = (node: TreeNode) =>
|
||||||
"{count}",
|
props.loadMoreLabel.replace("{count}", String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node))));
|
||||||
String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node)))
|
|
||||||
);
|
|
||||||
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
|
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
|
||||||
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
|
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
|
||||||
const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
|
const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
|
||||||
@@ -124,18 +145,6 @@ const collectKeys = (node: TreeNode): any[] => {
|
|||||||
return keys;
|
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(
|
watch(
|
||||||
() => props.selected,
|
() => props.selected,
|
||||||
(value) => {
|
(value) => {
|
||||||
@@ -159,18 +168,6 @@ watch(
|
|||||||
{ deep: true }
|
{ 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[]) => {
|
const setExpandedKeys = (keys: any[]) => {
|
||||||
state.expandedKeys = [...keys];
|
state.expandedKeys = [...keys];
|
||||||
emit("update:expandedKeys", [...state.expandedKeys]);
|
emit("update:expandedKeys", [...state.expandedKeys]);
|
||||||
@@ -181,33 +178,146 @@ const setCheckedKeys = (keys: any[]) => {
|
|||||||
emit("update:checkedKeys", [...state.checkedKeys]);
|
emit("update:checkedKeys", [...state.checkedKeys]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadNode = async (node: TreeNode) => {
|
const loadNode = async (node: TreeNode): Promise<LoadNodeResult> => {
|
||||||
if (!props.lazy || !props.load) {
|
if (!props.lazy || !props.load) {
|
||||||
return;
|
return { children: effectiveChildrenOf(node), failed: false, cancelled: false };
|
||||||
}
|
}
|
||||||
const key = keyOf(node);
|
const key = keyOf(node);
|
||||||
if (key === undefined || key === null) {
|
if (key === undefined || key === null) {
|
||||||
return;
|
return { children: [], failed: false, cancelled: false };
|
||||||
}
|
}
|
||||||
if (state.loadingKeys.includes(key)) {
|
const cacheKey = keyString(key);
|
||||||
return;
|
if (childrenOf(node).length > 0 || hasCachedChildren(node)) {
|
||||||
|
return { children: effectiveChildrenOf(node), failed: false, cancelled: false };
|
||||||
|
}
|
||||||
|
const inFlight = inFlightLoads.get(cacheKey);
|
||||||
|
if (inFlight) {
|
||||||
|
return inFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.loadingKeys = [...state.loadingKeys, key];
|
const requestedDataVersion = dataVersion;
|
||||||
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
|
let loadPromise: Promise<LoadNodeResult>;
|
||||||
emit("load-start", node, key);
|
loadPromise = (async () => {
|
||||||
|
state.loadingKeys = [...new Set([...state.loadingKeys, key])];
|
||||||
|
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
|
||||||
|
emit("load-start", node, key);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.lazyChildrenCache[keyString(key)] = await props.load(node);
|
const loadedChildren = await props.load(node);
|
||||||
if (progressiveBatchSize.value > 0) {
|
if (requestedDataVersion !== dataVersion) {
|
||||||
state.renderedLimits[keyString(key)] = progressiveBatchSize.value;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
})();
|
||||||
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
|
inFlightLoads.set(cacheKey, loadPromise);
|
||||||
emit("load-error", error, node, key);
|
return loadPromise;
|
||||||
} finally {
|
};
|
||||||
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
const normalizedKey = keyString(key);
|
||||||
|
if (visited.has(normalizedKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visited.add(normalizedKey);
|
||||||
|
expandedKeys.push(key);
|
||||||
|
|
||||||
|
const result = await loadNode(node);
|
||||||
|
if (operationVersion !== bulkOperationVersion || result.cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.failed) {
|
||||||
|
failedKeys.push(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.renderedLimits[normalizedKey] = result.children.length;
|
||||||
|
nextLevel.push(...result.children);
|
||||||
|
});
|
||||||
|
currentLevel = nextLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelled = operationVersion !== bulkOperationVersion;
|
||||||
|
if (!cancelled) {
|
||||||
|
setExpandedKeys(expandedKeys);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
expandedKeys: cancelled ? [...state.expandedKeys] : expandedKeys,
|
||||||
|
failedKeys,
|
||||||
|
cancelled,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
activeBulkExpansion = expansion;
|
||||||
|
void expansion.finally(() => {
|
||||||
|
if (activeBulkExpansion === expansion) {
|
||||||
|
activeBulkExpansion = null;
|
||||||
|
}
|
||||||
|
if (operationVersion === bulkOperationVersion) {
|
||||||
|
bulkExpanding.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return expansion;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collapseAll = () => {
|
||||||
|
bulkOperationVersion += 1;
|
||||||
|
activeBulkExpansion = null;
|
||||||
|
bulkExpanding.value = false;
|
||||||
|
state.renderedLimits = {};
|
||||||
|
setExpandedKeys([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureChildrenLoadedForCheck = async (node: TreeNode) => {
|
const ensureChildrenLoadedForCheck = async (node: TreeNode) => {
|
||||||
@@ -312,9 +422,7 @@ const handleNodeClick = async (node: TreeNode) => {
|
|||||||
emit("select", node, key);
|
emit("select", node, key);
|
||||||
} else if (props.selectionMode === "multiple") {
|
} else if (props.selectionMode === "multiple") {
|
||||||
const selected = Array.isArray(state.selected) ? state.selected : [];
|
const selected = Array.isArray(state.selected) ? state.selected : [];
|
||||||
state.selected = selected.includes(key)
|
state.selected = selected.includes(key) ? selected.filter((current) => current !== key) : [...selected, key];
|
||||||
? selected.filter((current) => current !== key)
|
|
||||||
: [...selected, key];
|
|
||||||
emit("update:selected", state.selected);
|
emit("update:selected", state.selected);
|
||||||
emit("select", node, key);
|
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", {
|
provide("BuefyTreeContext", {
|
||||||
props,
|
props,
|
||||||
state,
|
state,
|
||||||
@@ -347,7 +498,7 @@ provide("BuefyTreeContext", {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ul class="b-tree" role="tree" :aria-label="ariaLabel">
|
<ul class="b-tree" role="tree" :aria-label="ariaLabel" :aria-busy="bulkExpanding || undefined">
|
||||||
<BuefyTreeNode
|
<BuefyTreeNode
|
||||||
v-for="(node, index) in data"
|
v-for="(node, index) in data"
|
||||||
:key="keyOf(node) ?? index"
|
:key="keyOf(node) ?? index"
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ onBeforeUnmount(disconnectLoadMoreObserver);
|
|||||||
>
|
>
|
||||||
<div class="b-tree-node-content" @click.stop="tree.handleNodeClick(node)">
|
<div class="b-tree-node-content" @click.stop="tree.handleNodeClick(node)">
|
||||||
<button
|
<button
|
||||||
|
v-if="node.hideToggle !== true"
|
||||||
type="button"
|
type="button"
|
||||||
class="button is-white is-small b-tree-node-toggle"
|
class="button is-white is-small b-tree-node-toggle"
|
||||||
:class="{ 'is-invisible': !hasExpandToggle }"
|
:class="{ 'is-invisible': !hasExpandToggle }"
|
||||||
@@ -122,7 +123,7 @@ onBeforeUnmount(disconnectLoadMoreObserver);
|
|||||||
/>
|
/>
|
||||||
</span>
|
</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">
|
<slot name="icon" :node="node" :expanded="isExpanded" :loading="isLoading" :error="hasLoadError">
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
<i class="fas" :class="hasExpandToggle ? (isExpanded ? 'fa-folder-open' : 'fa-folder') : 'fa-file'"></i>
|
<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);
|
await props.changeAction(nextValue);
|
||||||
}
|
}
|
||||||
root.value?.dispatchEvent(new CustomEvent("dropdown-action-selected", { bubbles: true }));
|
root.value?.dispatchEvent(new CustomEvent("dropdown-action-selected", { bubbles: true }));
|
||||||
|
} catch {
|
||||||
|
localValue.value = normalizeSelectValue(props.value);
|
||||||
} finally {
|
} finally {
|
||||||
isProcessing.value = false;
|
isProcessing.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,20 +6,17 @@ import { StripeModule } from "@/components/stripe/StripeModule.vue";
|
|||||||
import { Colors } from "@/ThemeConfig.vue";
|
import { Colors } from "@/ThemeConfig.vue";
|
||||||
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
|
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
|
||||||
import PrintInvoiceFromOrderItems from "@/components/forms/department/pos/buttons/PrintInvoiceFromOrderItems.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 StripeTerminalReaderSelect from "@/components/displays/department/pos/displays/StripeTerminalReaderSelect.vue";
|
||||||
import { getOrder } from "@/components/shop/Orders.vue";
|
|
||||||
import {
|
import {
|
||||||
getStripeTerminalReaderById,
|
getStripeTerminalReaderById,
|
||||||
normalizeStripeTerminalReaders,
|
normalizeStripeTerminalReaders,
|
||||||
selectPreferredStripeTerminalReaderId,
|
selectPreferredStripeTerminalReaderId,
|
||||||
STRIPE_TERMINAL_STATUS,
|
STRIPE_TERMINAL_STATUS,
|
||||||
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
|
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
|
||||||
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
|
|
||||||
|
|
||||||
const POLLING_INTERVAL_MS = 5000;
|
const POLLING_INTERVAL_MS = 5000;
|
||||||
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
|
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({
|
const props = defineProps({
|
||||||
departmentId: {
|
departmentId: {
|
||||||
@@ -50,16 +47,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const stripeText = (key, params = {}) => t(`admin.pos.stripe.${key}`, params);
|
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 readers = ref([]);
|
||||||
const readersError = ref(null);
|
const readersError = ref(null);
|
||||||
@@ -71,229 +58,15 @@ const selectedReaderId = ref('');
|
|||||||
const loadingDeleteButton = ref(false);
|
const loadingDeleteButton = ref(false);
|
||||||
const isNotifyingPaymentSuccess = ref(false);
|
const isNotifyingPaymentSuccess = ref(false);
|
||||||
const notifiedPaymentIntentId = ref(null);
|
const notifiedPaymentIntentId = ref(null);
|
||||||
|
const autoCapturePaymentIntentId = ref(null);
|
||||||
const operationState = ref('idle');
|
const operationState = ref('idle');
|
||||||
const pollingEnabled = ref(true);
|
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 paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
|
||||||
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
|
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
|
||||||
|
const shouldShowDesktopCompletion = computed(() => isTerminalPaymentCaptured.value);
|
||||||
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
|
|
||||||
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
|
|
||||||
const isStripeEmailInvoiceTerminalState = computed(() => {
|
|
||||||
const status = String(stripeInvoice.value?.status ?? '');
|
|
||||||
return ['paid', 'void', 'uncollectible', 'deleted'].includes(status);
|
|
||||||
});
|
|
||||||
const isEmailFormVisible = computed(() => {
|
|
||||||
return ['composing', 'sending'].includes(emailPanelState.value) && !hasStripeEmailInvoice.value;
|
|
||||||
});
|
|
||||||
const isSendingEmailPayment = computed(() => emailPanelState.value === 'sending');
|
|
||||||
const isCancellingEmailPayment = computed(() => emailPanelState.value === 'cancelling');
|
|
||||||
const shouldShowEmailActionButton = computed(() => emailPanelState.value === 'idle' && !hasStripeEmailInvoice.value);
|
|
||||||
const canCancelEmailPayment = computed(() => {
|
|
||||||
if (isSendingEmailPayment.value || isCancellingEmailPayment.value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasStripeEmailInvoice.value) {
|
|
||||||
return !isStripeEmailInvoicePaid.value && !isStripeEmailInvoiceTerminalState.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return emailPanelState.value === 'composing';
|
|
||||||
});
|
|
||||||
const canCompletePaidEmailOrder = computed(() => {
|
|
||||||
return props.mobileMode && isStripeEmailInvoicePaid.value && typeof props.onPaymentSuccess === 'function';
|
|
||||||
});
|
|
||||||
const stripeEmailStatusToneClass = computed(() => {
|
|
||||||
switch (String(stripeInvoice.value?.status ?? 'unknown')) {
|
|
||||||
case 'paid':
|
|
||||||
return 'is-success';
|
|
||||||
case 'open':
|
|
||||||
case 'draft':
|
|
||||||
return 'is-warning';
|
|
||||||
case 'void':
|
|
||||||
case 'uncollectible':
|
|
||||||
case 'deleted':
|
|
||||||
return 'is-light';
|
|
||||||
default:
|
|
||||||
return 'is-light';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const stripeEmailStatusMessage = computed(() => {
|
|
||||||
if (!hasStripeEmailInvoice.value) {
|
|
||||||
return stripeText('email.help');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStripeEmailInvoicePaid.value) {
|
|
||||||
return stripeText('email.messages.paid');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isStripeEmailInvoiceTerminalState.value) {
|
|
||||||
return stripeText('email.messages.closed');
|
|
||||||
}
|
|
||||||
|
|
||||||
return stripeText('email.messages.sent');
|
|
||||||
});
|
|
||||||
const shouldShowDesktopCompletion = computed(() => {
|
|
||||||
return isTerminalPaymentCaptured.value || isStripeEmailInvoicePaid.value;
|
|
||||||
});
|
|
||||||
const shouldShowDesktopReceipt = computed(() => isTerminalPaymentCaptured.value);
|
const 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 normalizeErrorState = (errorLike, fallbackMessage, source) => {
|
||||||
const responsePayload = errorLike?.response?.data?.data;
|
const responsePayload = errorLike?.response?.data?.data;
|
||||||
const directPayload = errorLike?.data?.data ?? errorLike?.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 clearErrorState = (...sources) => {
|
||||||
const sourceSet = new Set(sources);
|
const sourceSet = new Set(sources);
|
||||||
if (sourceSet.has('readers')) {
|
if (sourceSet.has('readers')) {
|
||||||
@@ -357,7 +125,7 @@ const paymentIntentReaderId = computed(() => {
|
|||||||
const paymentIntentTaxPercentage = computed(() => {
|
const paymentIntentTaxPercentage = computed(() => {
|
||||||
const value = paymentIntent.value?.metadata?.tax_percentage;
|
const value = paymentIntent.value?.metadata?.tax_percentage;
|
||||||
return value === undefined || value === null
|
return value === undefined || value === null
|
||||||
? getTaxRatePercentage(selectedTaxRate.value)
|
? CARD_PAYMENT_TAX_PERCENTAGE
|
||||||
: Number(value);
|
: Number(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -489,18 +257,7 @@ const selectedReaderLabel = computed(() => {
|
|||||||
return stripeText('reader.no_selection');
|
return stripeText('reader.no_selection');
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatTaxRateLabel = (taxRate) => {
|
|
||||||
return stripeText('tax_rates.option', {
|
|
||||||
name: taxRate.display_name,
|
|
||||||
percentage: taxRate.percentage,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const paymentTaxSummary = computed(() => {
|
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 });
|
return stripeText('tax_rates.fallback', { percentage: paymentIntentTaxPercentage.value });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -519,9 +276,7 @@ const primaryActionLabel = computed(() => {
|
|||||||
case 'waiting_for_reader':
|
case 'waiting_for_reader':
|
||||||
return stripeText('actions.refresh_payment_status');
|
return stripeText('actions.refresh_payment_status');
|
||||||
case 'ready_to_capture':
|
case 'ready_to_capture':
|
||||||
return paymentAmountSummary.value
|
return stripeText('states.capturing');
|
||||||
? stripeText('actions.capture_payment_with_amount', { amount: paymentAmountSummary.value })
|
|
||||||
: stripeText('actions.capture_payment');
|
|
||||||
case 'succeeded':
|
case 'succeeded':
|
||||||
return stripeText('actions.show_payment_details');
|
return stripeText('actions.show_payment_details');
|
||||||
case 'failed':
|
case 'failed':
|
||||||
@@ -542,9 +297,8 @@ const primaryActionTestId = computed(() => {
|
|||||||
case 'reader_unavailable':
|
case 'reader_unavailable':
|
||||||
return 'pos-stripe-no-readers';
|
return 'pos-stripe-no-readers';
|
||||||
case 'waiting_for_reader':
|
case 'waiting_for_reader':
|
||||||
return 'pos-stripe-payment-in-progress';
|
|
||||||
case 'ready_to_capture':
|
case 'ready_to_capture':
|
||||||
return 'pos-stripe-capture-intent';
|
return 'pos-stripe-payment-in-progress';
|
||||||
case 'succeeded':
|
case 'succeeded':
|
||||||
return 'pos-stripe-payment-succeeded';
|
return 'pos-stripe-payment-succeeded';
|
||||||
case 'failed':
|
case 'failed':
|
||||||
@@ -570,7 +324,6 @@ const canCancelPaymentIntent = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const shouldShowReaderSelection = computed(() => paymentIntent.value === null);
|
const shouldShowReaderSelection = computed(() => paymentIntent.value === null);
|
||||||
const shouldShowTaxSelection = computed(() => paymentIntent.value === null);
|
|
||||||
|
|
||||||
const attemptAutomaticReaderSelection = (readerList) => {
|
const attemptAutomaticReaderSelection = (readerList) => {
|
||||||
selectedReaderId.value = selectPreferredStripeTerminalReaderId(
|
selectedReaderId.value = selectPreferredStripeTerminalReaderId(
|
||||||
@@ -657,7 +410,7 @@ const onClickCreatePaymentIntent = async () => {
|
|||||||
const response = await StripeModule.paymentIntents.createPaymentIntent(
|
const response = await StripeModule.paymentIntents.createPaymentIntent(
|
||||||
selectedReader.value.reader,
|
selectedReader.value.reader,
|
||||||
props.order_id,
|
props.order_id,
|
||||||
getTaxRatePercentage(selectedTaxRate.value),
|
CARD_PAYMENT_TAX_PERCENTAGE,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response?.status !== 200) {
|
if (response?.status !== 200) {
|
||||||
@@ -683,6 +436,7 @@ const onClickCapturePaymentIntent = async () => {
|
|||||||
try {
|
try {
|
||||||
const response = await StripeModule.paymentIntents.capturePaymentIntent(props.order_id);
|
const response = await StripeModule.paymentIntents.capturePaymentIntent(props.order_id);
|
||||||
if (response?.status !== 200) {
|
if (response?.status !== 200) {
|
||||||
|
autoCapturePaymentIntentId.value = null;
|
||||||
actionError.value = normalizeErrorState(response, stripeText('errors.capture_payment_intent'), 'action');
|
actionError.value = normalizeErrorState(response, stripeText('errors.capture_payment_intent'), 'action');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -690,6 +444,7 @@ const onClickCapturePaymentIntent = async () => {
|
|||||||
clearErrorState('payment');
|
clearErrorState('payment');
|
||||||
await getStripeReaders();
|
await getStripeReaders();
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
|
autoCapturePaymentIntentId.value = null;
|
||||||
actionError.value = normalizeErrorState(requestError, stripeText('errors.capture_payment_intent'), 'action');
|
actionError.value = normalizeErrorState(requestError, stripeText('errors.capture_payment_intent'), 'action');
|
||||||
console.error('Error capturing payment intent:', requestError);
|
console.error('Error capturing payment intent:', requestError);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -755,7 +510,6 @@ const onPrimaryAction = async () => {
|
|||||||
await retryLoadState();
|
await retryLoadState();
|
||||||
return;
|
return;
|
||||||
case 'ready_to_capture':
|
case 'ready_to_capture':
|
||||||
await onClickCapturePaymentIntent();
|
|
||||||
return;
|
return;
|
||||||
case 'succeeded':
|
case 'succeeded':
|
||||||
showPaymentDetails(paymentIntent.value);
|
showPaymentDetails(paymentIntent.value);
|
||||||
@@ -796,26 +550,38 @@ const maybeNotifyPaymentSuccess = async (stripePaymentIntent) => {
|
|||||||
watch(paymentIntent, (nextPaymentIntent) => {
|
watch(paymentIntent, (nextPaymentIntent) => {
|
||||||
if (!nextPaymentIntent) {
|
if (!nextPaymentIntent) {
|
||||||
notifiedPaymentIntentId.value = null;
|
notifiedPaymentIntentId.value = null;
|
||||||
|
autoCapturePaymentIntentId.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
maybeNotifyPaymentSuccess(nextPaymentIntent);
|
maybeNotifyPaymentSuccess(nextPaymentIntent);
|
||||||
}, { deep: true });
|
}, { 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 () => {
|
watch(() => props.order_id, async () => {
|
||||||
clearErrorState('readers', 'payment', 'action');
|
clearErrorState('readers', 'payment', 'action');
|
||||||
operationState.value = 'idle';
|
operationState.value = 'idle';
|
||||||
pollingEnabled.value = true;
|
pollingEnabled.value = true;
|
||||||
notifiedPaymentIntentId.value = null;
|
notifiedPaymentIntentId.value = null;
|
||||||
resetEmailPaymentPanel();
|
autoCapturePaymentIntentId.value = null;
|
||||||
await Promise.all([
|
await syncCurrentState();
|
||||||
syncCurrentState(),
|
|
||||||
loadStripeInvoiceState(),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let pollingIntervalId = null;
|
let pollingIntervalId = null;
|
||||||
let emailPollingIntervalId = null;
|
|
||||||
|
|
||||||
const clearPollingInterval = () => {
|
const clearPollingInterval = () => {
|
||||||
if (pollingIntervalId !== null) {
|
if (pollingIntervalId !== null) {
|
||||||
@@ -824,13 +590,6 @@ const clearPollingInterval = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearEmailPollingInterval = () => {
|
|
||||||
if (emailPollingIntervalId !== null) {
|
|
||||||
window.clearInterval(emailPollingIntervalId);
|
|
||||||
emailPollingIntervalId = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldPoll = computed(() => {
|
const shouldPoll = computed(() => {
|
||||||
return pollingEnabled.value
|
return pollingEnabled.value
|
||||||
&& paymentFlowState.value !== 'succeeded'
|
&& paymentFlowState.value !== 'succeeded'
|
||||||
@@ -854,25 +613,6 @@ watch(shouldPoll, (nextShouldPoll) => {
|
|||||||
}, POLLING_INTERVAL_MS);
|
}, POLLING_INTERVAL_MS);
|
||||||
}, { immediate: true });
|
}, { 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) => {
|
watch(paymentFlowState, (nextState) => {
|
||||||
if (nextState === 'succeeded') {
|
if (nextState === 'succeeded') {
|
||||||
pollingEnabled.value = false;
|
pollingEnabled.value = false;
|
||||||
@@ -882,15 +622,11 @@ watch(paymentFlowState, (nextState) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
pollingEnabled.value = true;
|
pollingEnabled.value = true;
|
||||||
await Promise.all([
|
await syncCurrentState();
|
||||||
syncCurrentState(),
|
|
||||||
loadStripeInvoiceState(),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
clearPollingInterval();
|
clearPollingInterval();
|
||||||
clearEmailPollingInterval();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -931,19 +667,6 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div v-if="currentErrorMessage" class="notification is-danger is-light" data-testid="pos-stripe-error-message">
|
||||||
{{ currentErrorMessage }}
|
{{ currentErrorMessage }}
|
||||||
</div>
|
</div>
|
||||||
@@ -977,123 +700,6 @@ onUnmounted(() => {
|
|||||||
{{ stripeText('actions.cancel_payment') }}
|
{{ stripeText('actions.cancel_payment') }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1153,20 +759,6 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<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">
|
<template v-if="shouldShowReaderSelection">
|
||||||
<div class="pos-stripe-desktop__item" data-testid="pos-stripe-desktop-reader-field">
|
<div class="pos-stripe-desktop__item" data-testid="pos-stripe-desktop-reader-field">
|
||||||
<StripeTerminalReaderSelect
|
<StripeTerminalReaderSelect
|
||||||
@@ -1222,21 +814,6 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</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'">
|
<template v-else-if="paymentFlowState === 'succeeded'">
|
||||||
<button
|
<button
|
||||||
class="button is-text is-fullwidth pos-stripe-desktop__text-button"
|
class="button is-text is-fullwidth pos-stripe-desktop__text-button"
|
||||||
@@ -1289,124 +866,6 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</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">
|
<template v-if="shouldShowDesktopCompletion">
|
||||||
<div class="pos-stripe-desktop__item">
|
<div class="pos-stripe-desktop__item">
|
||||||
<NextStep class="is-fullwidth" :label="t('admin.pos.complete')" />
|
<NextStep class="is-fullwidth" :label="t('admin.pos.complete')" />
|
||||||
@@ -1484,10 +943,6 @@ onUnmounted(() => {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-stripe-payment__email {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select.is-fullwidth,
|
.select.is-fullwidth,
|
||||||
.select.is-fullwidth select {
|
.select.is-fullwidth select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1551,92 +1006,4 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -506,4 +506,12 @@ onBeforeUnmount(() => {
|
|||||||
.stripe-terminal-select--mobile .stripe-terminal-select__trigger {
|
.stripe-terminal-select--mobile .stripe-terminal-select__trigger {
|
||||||
min-height: 3rem;
|
min-height: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stripe-terminal-select--mobile {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripe-terminal-select--mobile .stripe-terminal-select__menu {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
import { computed, reactive, ref, watch } from 'vue';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
import { editOrderItem } from "@/components/shop/OrdersItems.vue";
|
import { buildAuditedOrderItemReasonPayload, editOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||||
|
import {
|
||||||
|
EXTRA_SALE_REASONS,
|
||||||
|
isExtraSaleAuditProduct,
|
||||||
|
isExtraSaleCommentRequired,
|
||||||
|
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -29,6 +34,8 @@ const form = reactive({
|
|||||||
notes: '',
|
notes: '',
|
||||||
reference: '',
|
reference: '',
|
||||||
quantity: '1',
|
quantity: '1',
|
||||||
|
extraSaleReasonCode: '',
|
||||||
|
extraSaleComment: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
@@ -45,6 +52,8 @@ const syncForm = () => {
|
|||||||
form.notes = String(props.orderItem?.notes ?? '');
|
form.notes = String(props.orderItem?.notes ?? '');
|
||||||
form.reference = String(props.orderItem?.reference ?? '');
|
form.reference = String(props.orderItem?.reference ?? '');
|
||||||
form.quantity = String(props.orderItem?.quantity ?? 1);
|
form.quantity = String(props.orderItem?.quantity ?? 1);
|
||||||
|
form.extraSaleReasonCode = String(props.orderItem?.extra_sale_reason_code ?? '');
|
||||||
|
form.extraSaleComment = String(props.orderItem?.extra_sale_comment ?? '');
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -60,8 +69,27 @@ const isQuantityValid = computed(() => {
|
|||||||
return Number.isInteger(value) && value > 0;
|
return Number.isInteger(value) && value > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const requiresExtraSaleAudit = computed(() => isExtraSaleAuditProduct(props.orderItem?.product || props.orderItem));
|
||||||
|
|
||||||
|
const isExtraSaleAuditValid = computed(() => {
|
||||||
|
if (!requiresExtraSaleAudit.value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.extraSaleReasonCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isExtraSaleCommentRequired(form.extraSaleReasonCode) || form.extraSaleComment.trim().length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
return Boolean(props.orderItem) && props.canEdit && !isSubmitting.value && isPriceValid.value && isQuantityValid.value;
|
return Boolean(props.orderItem)
|
||||||
|
&& props.canEdit
|
||||||
|
&& !isSubmitting.value
|
||||||
|
&& isPriceValid.value
|
||||||
|
&& isQuantityValid.value
|
||||||
|
&& isExtraSaleAuditValid.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
@@ -85,7 +113,16 @@ const saveChanges = async () => {
|
|||||||
Number(form.price),
|
Number(form.price),
|
||||||
form.notes,
|
form.notes,
|
||||||
form.reference,
|
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,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
extra_sale_reason_code: form.extraSaleReasonCode || null,
|
||||||
|
extra_sale_comment: form.extraSaleComment.trim() || null,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
emits('saved');
|
emits('saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -172,6 +209,41 @@ const saveChanges = async () => {
|
|||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="requiresExtraSaleAudit">
|
||||||
|
<div class="column is-12-mobile is-6-tablet">
|
||||||
|
<label class="label" for="pos-order-item-extra-sale-reason">Årsag</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select
|
||||||
|
id="pos-order-item-extra-sale-reason"
|
||||||
|
v-model="form.extraSaleReasonCode"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
data-testid="pos-order-item-extra-sale-reason"
|
||||||
|
>
|
||||||
|
<option value="">Vælg godkendt årsag</option>
|
||||||
|
<option
|
||||||
|
v-for="reason in EXTRA_SALE_REASONS"
|
||||||
|
:key="reason.code"
|
||||||
|
:value="reason.code"
|
||||||
|
>
|
||||||
|
{{ reason.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-12-mobile is-6-tablet">
|
||||||
|
<label class="label" for="pos-order-item-extra-sale-comment">Kommentar</label>
|
||||||
|
<input
|
||||||
|
id="pos-order-item-extra-sale-comment"
|
||||||
|
v-model="form.extraSaleComment"
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
data-testid="pos-order-item-extra-sale-comment"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
|
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -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 { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js";
|
||||||
import SuperuserInvoiceRowActions from "@/components/displays/superuser/tables/SuperuserInvoiceRowActions.vue";
|
import SuperuserInvoiceRowActions from "@/components/displays/superuser/tables/SuperuserInvoiceRowActions.vue";
|
||||||
import { useLargeTableHeaders } from "@/services/tableHeaderPreferences.js";
|
import { useLargeTableHeaders } from "@/services/tableHeaderPreferences.js";
|
||||||
|
import {
|
||||||
|
normalizeInvoiceCollectionActionPreview,
|
||||||
|
renderInvoiceCollectionActionPreviewHtml,
|
||||||
|
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoiceCollectionActionPreview.ts";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
orders: {
|
orders: {
|
||||||
@@ -218,14 +222,6 @@ const normalizeInvoiceCollectionId = (invoiceCollectionId) => {
|
|||||||
|
|
||||||
const getApiPayload = (response) => response?.data?.data ?? response?.data ?? response ?? {};
|
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) => {
|
const getInvoiceCollectionResponseOrders = (response) => {
|
||||||
if (Array.isArray(response?.orders)) {
|
if (Array.isArray(response?.orders)) {
|
||||||
return 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 getBulkActionLabel = (action) => t(`invoicing_period.invoice_collection_actions.actions.${action}`);
|
||||||
|
|
||||||
const renderBulkActionPreviewHtml = (preview) => {
|
const renderBulkActionPreviewHtml = (preview) => {
|
||||||
const summary = preview?.summary || {};
|
return renderInvoiceCollectionActionPreviewHtml({
|
||||||
const collections = Number(summary.collections ?? preview?.invoice_collection_ids?.length ?? 0);
|
preview: normalizeInvoiceCollectionActionPreview(preview, getSelectedInvoiceCollectionIds().length),
|
||||||
const changedCount = Number(summary.changed_count ?? summary.order_items ?? summary.orders_to_move ?? 0);
|
t,
|
||||||
const skippedCount = Number(summary.skipped_count ?? 0);
|
formatCurrency: (value) => SessionUser.functions.currency.toLocal(value),
|
||||||
const blockers = Array.isArray(preview?.blockers) ? preview.blockers : [];
|
});
|
||||||
const orderItems = Array.isArray(preview?.order_items) ? preview.order_items : [];
|
|
||||||
const orders = Array.isArray(preview?.orders) ? preview.orders : [];
|
|
||||||
const splitItems = Array.isArray(preview?.items) ? preview.items : [];
|
|
||||||
const detailItems = [
|
|
||||||
...orderItems.map((item) =>
|
|
||||||
t("invoicing_period.invoice_collection_actions.preview.order_item_line", {
|
|
||||||
collection: item.invoice_collection_id,
|
|
||||||
order: item.order_id,
|
|
||||||
product: item.product_name || item.product_id,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
...orders.map((item) =>
|
|
||||||
t("invoicing_period.invoice_collection_actions.preview.order_move_line", {
|
|
||||||
order: item.order_id,
|
|
||||||
source: item.source_invoice_collection_id,
|
|
||||||
target: item.target_invoice_collection_id,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
...splitItems.map((item) =>
|
|
||||||
t("invoicing_period.invoice_collection_actions.preview.collection_status_line", {
|
|
||||||
collection: item.invoice_collection_id,
|
|
||||||
status: item.status,
|
|
||||||
})
|
|
||||||
),
|
|
||||||
].slice(0, 10);
|
|
||||||
|
|
||||||
const summaryLines = [
|
|
||||||
t("invoicing_period.invoice_collection_actions.preview.collections", { count: collections }),
|
|
||||||
t("invoicing_period.invoice_collection_actions.preview.changed", { count: changedCount }),
|
|
||||||
...(skippedCount > 0
|
|
||||||
? [t("invoicing_period.invoice_collection_actions.preview.skipped", { count: skippedCount })]
|
|
||||||
: []),
|
|
||||||
...(preview?.target_invoice_collection_id
|
|
||||||
? [
|
|
||||||
t("invoicing_period.invoice_collection_actions.preview.merge_target", {
|
|
||||||
id: preview.target_invoice_collection_id,
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
|
|
||||||
const html = [
|
|
||||||
`<p>${escapeHtml(
|
|
||||||
t("invoicing_period.invoice_collection_actions.preview.requires_confirmation", {
|
|
||||||
phrase: preview?.confirmation_phrase || "",
|
|
||||||
})
|
|
||||||
)}</p>`,
|
|
||||||
`<ul class="has-text-left">${summaryLines.map((line) => `<li>${escapeHtml(line)}</li>`).join("")}</ul>`,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (detailItems.length > 0) {
|
|
||||||
html.push(
|
|
||||||
`<hr><p class="has-text-left has-text-weight-bold">${escapeHtml(
|
|
||||||
t("invoicing_period.invoice_collection_actions.preview.affected_examples")
|
|
||||||
)}</p>`
|
|
||||||
);
|
|
||||||
html.push(`<ul class="has-text-left">${detailItems.map((line) => `<li>${escapeHtml(line)}</li>`).join("")}</ul>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blockers.length > 0) {
|
|
||||||
html.push(
|
|
||||||
`<hr><p class="has-text-left has-text-weight-bold has-text-danger">${escapeHtml(
|
|
||||||
t("invoicing_period.invoice_collection_actions.preview.blockers")
|
|
||||||
)}</p>`
|
|
||||||
);
|
|
||||||
html.push(
|
|
||||||
`<ul class="has-text-left">${blockers
|
|
||||||
.map((blocker) => `<li>${escapeHtml(blocker.message || blocker.code)}</li>`)
|
|
||||||
.join("")}</ul>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return html.join("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const chooseMergeTargetInvoiceCollection = async (invoiceCollectionIds) => {
|
const chooseMergeTargetInvoiceCollection = async (invoiceCollectionIds) => {
|
||||||
|
|||||||
@@ -38,17 +38,23 @@ import {
|
|||||||
reg_2,
|
reg_2,
|
||||||
reg_3,
|
reg_3,
|
||||||
department_id,
|
department_id,
|
||||||
customer_id,
|
customer_id,
|
||||||
customer_attributes,
|
customer_attributes,
|
||||||
customer_attributes_status,
|
customer_attributes_status,
|
||||||
customer_name,
|
customer_name,
|
||||||
getAddonRestriction,
|
getAddonRestriction,
|
||||||
getProductRestriction,
|
getProductRestriction,
|
||||||
retryCustomerAttributes,
|
retryCustomerAttributes,
|
||||||
registerPosStepSaveBarrier,
|
registerPosStepSaveBarrier,
|
||||||
saveOrderMetadataField,
|
saveOrderMetadataField,
|
||||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||||
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
import {
|
||||||
|
AUDITED_ORDER_ITEM_PRODUCT_IDS,
|
||||||
|
createOrderItem,
|
||||||
|
getOrderItems,
|
||||||
|
removeOrderItem,
|
||||||
|
} from "@/components/shop/OrdersItems.vue";
|
||||||
|
import { syncMobileOrderItems } from "@/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js";
|
||||||
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
|
||||||
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
|
||||||
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||||
@@ -58,6 +64,7 @@ import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
|
|||||||
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
|
||||||
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
|
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { promptExtraSaleAuditIfRequired } from "@/components/displays/department/pos/utils/extraSaleAudit.js";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -345,14 +352,14 @@ const applyPendingBookingFromSelection = async () => {
|
|||||||
effectivePrimaryProduct = firstWash;
|
effectivePrimaryProduct = firstWash;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
effectivePrimaryProduct.addons = preparedAddons as any;
|
effectivePrimaryProduct.addons = preparedAddons as any;
|
||||||
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
|
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
|
||||||
if (transactionItems.primaryItem.value) {
|
if (transactionItems.primaryItem.value) {
|
||||||
transactionItems.primaryItem.value.addons = preparedAddons as any;
|
transactionItems.primaryItem.value.addons = preparedAddons as any;
|
||||||
}
|
}
|
||||||
sanitizeRestrictedTransactionItems();
|
sanitizeRestrictedTransactionItems();
|
||||||
|
|
||||||
lastAppliedBookingId.value = booking.id;
|
lastAppliedBookingId.value = booking.id;
|
||||||
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
|
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
|
||||||
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
|
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
|
||||||
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
|
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
|
||||||
@@ -885,83 +892,12 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const normalizeOrderItemShape = (item: any) => ({
|
// The pre-existing helpers buildDesiredOrderItemShapes and
|
||||||
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
|
// normalizeExistingOrderItemShapes (and their shared normalizeOrderItemShape
|
||||||
quantity: Number(item?.quantity ?? 0),
|
// normalizer) used to live here. They have been extracted to
|
||||||
related_item_id:
|
// src/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js
|
||||||
item?.related_item_id === null || item?.related_item_id === undefined ? null : Number(item.related_item_id),
|
// so they can be unit-tested in isolation and so the same comparison logic
|
||||||
price: Number(item?.price ?? 0),
|
// is used by syncCurrentTransactionToOrder and any future caller.
|
||||||
notes: String(item?.notes ?? ""),
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildDesiredOrderItemShapes = () => {
|
|
||||||
if (!transactionItems.primaryItem.value) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryShape = {
|
|
||||||
kind: "primary",
|
|
||||||
relatedKey: "primary",
|
|
||||||
product_id: Number(transactionItems.primaryItem.value.id),
|
|
||||||
quantity: 1,
|
|
||||||
related_item_id: null,
|
|
||||||
price: Number(transactionItems.primaryItem.value.price ?? 0),
|
|
||||||
notes: String(transactionItems.primaryItem.value?.notes ?? ""),
|
|
||||||
skip_price_override: transactionItems.primaryItem.value?.skip_price_override === true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const addonShapes = (transactionItems.primaryItem.value.addons || [])
|
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
|
||||||
.map((addon: any) => {
|
|
||||||
const addonProduct = addon?.product ?? addon;
|
|
||||||
return {
|
|
||||||
kind: "addon",
|
|
||||||
relatedKey: "primary",
|
|
||||||
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
|
|
||||||
quantity: Number(addon?.quantity ?? 0),
|
|
||||||
related_item_id: "__PRIMARY__",
|
|
||||||
price: Number(addonProduct?.price ?? addon?.price ?? 0),
|
|
||||||
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
|
|
||||||
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const additionalShapes = (transactionItems.additionalItems.value || [])
|
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
|
||||||
.map((item: any) => ({
|
|
||||||
kind: "additional",
|
|
||||||
relatedKey: null,
|
|
||||||
product_id: Number(item?.id ?? 0),
|
|
||||||
quantity: Number(item?.quantity ?? 0),
|
|
||||||
related_item_id: null,
|
|
||||||
price: Number(item?.price ?? 0),
|
|
||||||
notes: String(item?.notes ?? ""),
|
|
||||||
skip_price_override: item?.skip_price_override === true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [primaryShape, ...addonShapes, ...additionalShapes];
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeExistingOrderItemShapes = (items: any[]) => {
|
|
||||||
const primaryItems = items.filter(
|
|
||||||
(item: any) => item?.related_item_id === null || item?.related_item_id === undefined
|
|
||||||
);
|
|
||||||
if (primaryItems.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const additionalItems = primaryItems.filter(
|
|
||||||
(item: any) =>
|
|
||||||
Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(transactionItems.primaryItem.value?.id ?? 0)
|
|
||||||
);
|
|
||||||
const primaryItemShape = normalizeOrderItemShape(primaryItems[0]);
|
|
||||||
const addonShapes = items
|
|
||||||
.filter((item: any) => item?.related_item_id === primaryItems[0]?.id)
|
|
||||||
.map(normalizeOrderItemShape);
|
|
||||||
const additionalShapes = additionalItems.map(normalizeOrderItemShape);
|
|
||||||
|
|
||||||
return [primaryItemShape, ...addonShapes, ...additionalShapes];
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortComparableLastWashShapes = (shapes: Array<{ kind: string; product_id: number; quantity: number }>) =>
|
const sortComparableLastWashShapes = (shapes: Array<{ kind: string; product_id: number; quantity: number }>) =>
|
||||||
shapes.slice().sort((left, right) => {
|
shapes.slice().sort((left, right) => {
|
||||||
@@ -988,8 +924,8 @@ const buildCurrentSelectionComparableShapes = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addonShapes = sortComparableLastWashShapes(
|
const addonShapes = sortComparableLastWashShapes(
|
||||||
(transactionItems.primaryItem.value.addons || [])
|
(transactionItems.primaryItem.value.addons || [])
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||||
.map((addon: any) => ({
|
.map((addon: any) => ({
|
||||||
kind: "addon",
|
kind: "addon",
|
||||||
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
||||||
@@ -998,8 +934,8 @@ const buildCurrentSelectionComparableShapes = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const additionalShapes = sortComparableLastWashShapes(
|
const additionalShapes = sortComparableLastWashShapes(
|
||||||
(transactionItems.additionalItems.value || [])
|
(transactionItems.additionalItems.value || [])
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||||
.map((item: any) => ({
|
.map((item: any) => ({
|
||||||
kind: "additional",
|
kind: "additional",
|
||||||
product_id: Number(item?.id ?? 0),
|
product_id: Number(item?.id ?? 0),
|
||||||
@@ -1074,65 +1010,19 @@ const syncCurrentTransactionToOrder = async () => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingItemsResponse = await getOrderItems(normalizedOrderId);
|
// The sync helper owns the create / rollback / allSettled logic and lives
|
||||||
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
|
// in tests/unit/pos-mobile-step-2-addon-sync.spec.js. Returning its result
|
||||||
|
// unchanged preserves the existing contract: true = synced (or already in
|
||||||
const desiredShapes = buildDesiredOrderItemShapes();
|
// sync), false = primary product blocked, throw = partial failure.
|
||||||
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
|
await syncMobileOrderItems({
|
||||||
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
|
orderId: normalizedOrderId,
|
||||||
const comparableDesiredShapes = desiredShapes.map(({
|
primaryItem: transactionItems.primaryItem.value,
|
||||||
kind: _kind,
|
additionalItems: transactionItems.additionalItems.value || [],
|
||||||
relatedKey: _relatedKey,
|
isAddonRestricted: isMobileAddonRestricted,
|
||||||
skip_price_override: _skipPriceOverride,
|
isStandaloneRestricted: isStandaloneAdditionalItemRestricted,
|
||||||
...shape
|
isPrimaryRestricted: (item) => getProductRestriction(item).restricted,
|
||||||
}) => shape);
|
api: { createOrderItem, getOrderItems, removeOrderItem },
|
||||||
|
});
|
||||||
if (!shouldForceRecreateForRepricing && JSON.stringify(currentShapes) === JSON.stringify(comparableDesiredShapes)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(existingItems.map((item: any) => removeOrderItem(item.id)));
|
|
||||||
|
|
||||||
const createdPrimaryItemResponse = await createOrderItem(
|
|
||||||
normalizedOrderId,
|
|
||||||
transactionItems.primaryItem.value.id,
|
|
||||||
1,
|
|
||||||
null,
|
|
||||||
transactionItems.primaryItem.value?.notes || "",
|
|
||||||
transactionItems.primaryItem.value.skip_price_override === true ? null : transactionItems.primaryItem.value.price
|
|
||||||
);
|
|
||||||
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
|
|
||||||
|
|
||||||
const addonPromises = (transactionItems.primaryItem.value.addons || [])
|
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
|
||||||
.map((addon: any) => {
|
|
||||||
const addonProduct = addon?.product ?? addon;
|
|
||||||
return createOrderItem(
|
|
||||||
normalizedOrderId,
|
|
||||||
addonProduct.id,
|
|
||||||
Number(addon.quantity),
|
|
||||||
createdPrimaryItemId,
|
|
||||||
addonProduct?.notes || addon?.notes || "",
|
|
||||||
addonProduct?.skip_price_override === true || addon?.skip_price_override === true
|
|
||||||
? null
|
|
||||||
: addonProduct.price ?? addon.price
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const additionalPromises = (transactionItems.additionalItems.value || [])
|
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
|
||||||
.map((item: any) =>
|
|
||||||
createOrderItem(
|
|
||||||
normalizedOrderId,
|
|
||||||
item.id,
|
|
||||||
Number(item.quantity),
|
|
||||||
null,
|
|
||||||
item?.notes || "",
|
|
||||||
item?.skip_price_override === true ? null : item.price
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all([...addonPromises, ...additionalPromises]);
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1140,8 +1030,7 @@ const normalizeText = (value: unknown) => String(value ?? "").trim();
|
|||||||
|
|
||||||
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
|
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
|
||||||
|
|
||||||
const getProductId = (product: any) =>
|
const getProductId = (product: any) => Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
|
||||||
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
|
|
||||||
|
|
||||||
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
|
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
|
||||||
|
|
||||||
@@ -1152,13 +1041,13 @@ const productRequiresOrderItemNote = (product: any) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
|
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
|
||||||
|
AUDITED_ORDER_ITEM_PRODUCT_IDS.has(getProductId(product)) ||
|
||||||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
|
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
|
||||||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
|
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const productHasOrderItemNote = (product: any) =>
|
const productHasOrderItemNote = (product: any) => normalizeText(product?.notes ?? product?.product?.notes).length > 0;
|
||||||
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
|
|
||||||
|
|
||||||
const getSelectedProductsMissingRequiredNotes = () => {
|
const getSelectedProductsMissingRequiredNotes = () => {
|
||||||
const missingProducts: any[] = [];
|
const missingProducts: any[] = [];
|
||||||
@@ -1168,8 +1057,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
|
|||||||
missingProducts.push(primaryProduct);
|
missingProducts.push(primaryProduct);
|
||||||
}
|
}
|
||||||
|
|
||||||
(primaryProduct?.addons || [])
|
(primaryProduct?.addons || [])
|
||||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||||
.forEach((addon: any) => {
|
.forEach((addon: any) => {
|
||||||
const addonProduct = addon?.product ?? addon;
|
const addonProduct = addon?.product ?? addon;
|
||||||
if (
|
if (
|
||||||
@@ -1181,8 +1070,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
(transactionItems.additionalItems.value || [])
|
(transactionItems.additionalItems.value || [])
|
||||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||||
.forEach((item: any) => {
|
.forEach((item: any) => {
|
||||||
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
|
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
|
||||||
missingProducts.push(item);
|
missingProducts.push(item);
|
||||||
@@ -1538,15 +1427,15 @@ const filteredAddons = computed(() => {
|
|||||||
<i class="fa fa-search"></i>
|
<i class="fa fa-search"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- Registration numbers -->
|
<!-- Registration numbers -->
|
||||||
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
||||||
<p
|
<p
|
||||||
v-if="restrictionWarningMessageKey"
|
v-if="restrictionWarningMessageKey"
|
||||||
class="notification is-warning is-light py-2 px-3 mb-0"
|
class="notification is-warning is-light py-2 px-3 mb-0"
|
||||||
data-testid="pos-mobile-restriction-warning"
|
data-testid="pos-mobile-restriction-warning"
|
||||||
>
|
>
|
||||||
{{ t(restrictionWarningMessageKey) }}
|
{{ t(restrictionWarningMessageKey) }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-if="customer_attributes_status === 'error'"
|
v-if="customer_attributes_status === 'error'"
|
||||||
class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between py-2 px-3 mb-0"
|
class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between py-2 px-3 mb-0"
|
||||||
@@ -1562,8 +1451,8 @@ const filteredAddons = computed(() => {
|
|||||||
{{ t("common.retry") }}
|
{{ t("common.retry") }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Product -->
|
<!-- Product -->
|
||||||
<PosDepartmentStepMobile2Product
|
<PosDepartmentStepMobile2Product
|
||||||
v-on:pointerdown="onPrimaryProductPointerDown"
|
v-on:pointerdown="onPrimaryProductPointerDown"
|
||||||
v-on:pointermove="onPrimaryProductPointerMove"
|
v-on:pointermove="onPrimaryProductPointerMove"
|
||||||
v-on:pointerup="onPrimaryProductPointerUp"
|
v-on:pointerup="onPrimaryProductPointerUp"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from "vue";
|
||||||
import Swal from "sweetalert2";
|
import Swal from "sweetalert2";
|
||||||
import PayWithStripeButton from "@/components/displays/department/pos/displays/PayWithStripeButton.vue";
|
import PayWithStripeButton from "@/components/displays/department/pos/displays/PayWithStripeButton.vue";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,9 @@ import {
|
|||||||
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
|
||||||
import { finalizeCurrentMobileOrder } from "@/components/displays/department/pos/steps/mobile/objects/mobileOrderCompletion.js";
|
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 isCompleting = ref(false);
|
||||||
const completionError = ref(null);
|
const completionError = ref(null);
|
||||||
const completedOrderId = ref(null);
|
const completedOrderId = ref(null);
|
||||||
@@ -37,8 +40,12 @@ const normalizedDepartmentId = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const currentPaymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
|
const currentPaymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
|
||||||
const paymentIntentState = computed(() => StripeModule.paymentIntents.getPaymentIntentState(currentPaymentIntent.value));
|
const paymentIntentState = computed(() =>
|
||||||
const hasActivePaymentIntent = computed(() => StripeModule.paymentIntents.isPaymentIntentActive(currentPaymentIntent.value));
|
StripeModule.paymentIntents.getPaymentIntentState(currentPaymentIntent.value)
|
||||||
|
);
|
||||||
|
const hasActivePaymentIntent = computed(() =>
|
||||||
|
StripeModule.paymentIntents.isPaymentIntentActive(currentPaymentIntent.value)
|
||||||
|
);
|
||||||
|
|
||||||
const orderItemsTotal = computed(() => {
|
const orderItemsTotal = computed(() => {
|
||||||
return (Array.isArray(order_items.value) ? order_items.value : []).reduce((sum, item) => {
|
return (Array.isArray(order_items.value) ? order_items.value : []).reduce((sum, item) => {
|
||||||
@@ -56,18 +63,23 @@ const mobileOrderTotal = computed(() => {
|
|||||||
return orderItemsTotal.value;
|
return orderItemsTotal.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const orderReference = computed(() => metadata.getReference?.() || '');
|
const orderReference = computed(() => metadata.getReference?.() || "");
|
||||||
const orderNotes = computed(() => metadata.getNotes?.() || '');
|
const orderNotes = computed(() => metadata.getNotes?.() || "");
|
||||||
const orderRegistration = computed(() => reg_1.value || 'Unknown vehicle');
|
const orderRegistration = computed(() => reg_1.value || "Unknown vehicle");
|
||||||
const isBackDisabled = computed(() => isCompleting.value || paymentIntentState.value === 'succeeded');
|
const isBackDisabled = computed(() => isCompleting.value || paymentIntentState.value === "succeeded");
|
||||||
|
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
return new Intl.NumberFormat('da-DK', {
|
return new Intl.NumberFormat("da-DK", {
|
||||||
style: 'currency',
|
style: "currency",
|
||||||
currency: 'DKK',
|
currency: "DKK",
|
||||||
}).format(Number(amount || 0));
|
}).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 = () => {
|
const navigateToItems = () => {
|
||||||
setStep(2);
|
setStep(2);
|
||||||
if (normalizedOrderId.value) {
|
if (normalizedOrderId.value) {
|
||||||
@@ -83,7 +95,7 @@ const clearSuccessfulMobileCardFlow = () => {
|
|||||||
order_id.value = 0;
|
order_id.value = 0;
|
||||||
popups.clear();
|
popups.clear();
|
||||||
resetPos();
|
resetPos();
|
||||||
}, 3000);
|
}, getSuccessResetDelayMs());
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeMobileCardPayment = async () => {
|
const completeMobileCardPayment = async () => {
|
||||||
@@ -100,21 +112,21 @@ const completeMobileCardPayment = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await finalizeCurrentMobileOrder();
|
await finalizeCurrentMobileOrder();
|
||||||
popups.select('completed_transaction', {
|
popups.select("completed_transaction", {
|
||||||
message: `Order #${normalizedOrderId.value} successfully created.`,
|
message: `Order #${normalizedOrderId.value} successfully created.`,
|
||||||
});
|
});
|
||||||
clearSuccessfulMobileCardFlow();
|
clearSuccessfulMobileCardFlow();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
completedOrderId.value = null;
|
completedOrderId.value = null;
|
||||||
completionError.value = SessionUser.functions.parseErrorMessage(error) || 'Unable to complete card payment.';
|
completionError.value = SessionUser.functions.parseErrorMessage(error) || "Unable to complete card payment.";
|
||||||
console.error('Failed to complete mobile card payment:', error);
|
console.error("Failed to complete mobile card payment:", error);
|
||||||
} finally {
|
} finally {
|
||||||
isCompleting.value = false;
|
isCompleting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onBackClick = async () => {
|
const onBackClick = async () => {
|
||||||
if (paymentIntentState.value === 'succeeded') {
|
if (paymentIntentState.value === "succeeded") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,14 +136,14 @@ const onBackClick = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await Swal.fire({
|
const result = await Swal.fire({
|
||||||
title: 'Payment in progress',
|
title: "Payment in progress",
|
||||||
text: 'Resume the current payment or cancel it before returning to the item step.',
|
text: "Resume the current payment or cancel it before returning to the item step.",
|
||||||
icon: 'warning',
|
icon: "warning",
|
||||||
showDenyButton: true,
|
showDenyButton: true,
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
confirmButtonText: 'Resume payment',
|
confirmButtonText: "Resume payment",
|
||||||
denyButtonText: 'Cancel payment',
|
denyButtonText: "Cancel payment",
|
||||||
cancelButtonText: 'Stay here',
|
cancelButtonText: "Stay here",
|
||||||
reverseButtons: true,
|
reverseButtons: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,7 +156,7 @@ const onBackClick = async () => {
|
|||||||
await StripeModule.paymentIntents.deletePaymentIntent(normalizedOrderId.value);
|
await StripeModule.paymentIntents.deletePaymentIntent(normalizedOrderId.value);
|
||||||
navigateToItems();
|
navigateToItems();
|
||||||
} catch (error) {
|
} 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 box">
|
||||||
<div class="mobile-card-payment-view__summary-row">
|
<div class="mobile-card-payment-view__summary-row">
|
||||||
<span>Order</span>
|
<span>Order</span>
|
||||||
<strong data-testid="pos-mobile-step-3-order-id">
|
<strong data-testid="pos-mobile-step-3-order-id"> #{{ normalizedOrderId || "Pending" }} </strong>
|
||||||
#{{ normalizedOrderId || 'Pending' }}
|
|
||||||
</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-card-payment-view__summary-row">
|
<div class="mobile-card-payment-view__summary-row">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
@@ -216,9 +226,7 @@ const onBackClick = async () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mobile-card-payment-view__description">
|
<p class="mobile-card-payment-view__description">Use the selected card terminal for this order.</p>
|
||||||
Use the selected Stripe Terminal reader or send a Stripe payment link by email for this order.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<PayWithStripeButton
|
<PayWithStripeButton
|
||||||
:department-id="normalizedDepartmentId"
|
:department-id="normalizedDepartmentId"
|
||||||
|
|||||||
@@ -469,6 +469,13 @@ const onClick = async () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Next-step action was interrupted:", 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 {
|
} finally {
|
||||||
isProcessingClick.value = false;
|
isProcessingClick.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* Pure, side-effect-free (apart from the injected API calls) helper that
|
||||||
|
* reconciles the in-memory mobile POS transaction with the server-side
|
||||||
|
* order_items table.
|
||||||
|
*
|
||||||
|
* Behavior contract:
|
||||||
|
* - Every primary product add-on with quantity > 0 that is not
|
||||||
|
* customer-rule-restricted becomes its own order_items row, linked to
|
||||||
|
* the freshly-created primary row by related_item_id.
|
||||||
|
* - Every additional (standalone) item with quantity > 0 that is not
|
||||||
|
* customer-rule-restricted becomes its own order_items row.
|
||||||
|
* - The old "Promise.all over parallel POSTs" fan-out silently dropped
|
||||||
|
* rows on a single rejection: the rows that already landed stayed on
|
||||||
|
* the server while the operator saw only a generic failure popup.
|
||||||
|
* This helper delegates the add-on / additional-item fan-out to the
|
||||||
|
* shared `addOrderItemAddons` helper, which uses Promise.allSettled,
|
||||||
|
* collects every per-product failure, and rolls back every
|
||||||
|
* order_items row created during this attempt before throwing, so a
|
||||||
|
* retry starts from a clean state.
|
||||||
|
*
|
||||||
|
* The shared error class + helpers live in
|
||||||
|
* `src/components/displays/department/pos/utils/orderItemsPartialSync.js`
|
||||||
|
* so the desktop `addAddonsToOrderMiddleware` can throw the same shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
OrderItemsPartialSyncError,
|
||||||
|
extractErrorMessage,
|
||||||
|
formatFailureFragment,
|
||||||
|
} from "@/components/displays/department/pos/utils/orderItemsPartialSync.js";
|
||||||
|
import { addOrderItemAddons } from "@/components/displays/department/pos/utils/addOrderItemAddons.js";
|
||||||
|
|
||||||
|
// Re-export for back-compat with existing tests / call sites.
|
||||||
|
export { OrderItemsPartialSyncError, extractErrorMessage, formatFailureFragment };
|
||||||
|
|
||||||
|
const PLACEHOLDER_PRIMARY_RELATED_ITEM_ID = "__PRIMARY__";
|
||||||
|
|
||||||
|
const toInteger = (value) => {
|
||||||
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||||
|
return Number.isInteger(parsed) ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toPositiveInteger = (value) => {
|
||||||
|
const parsed = toInteger(value);
|
||||||
|
return parsed !== null && parsed > 0 ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNonNegativeNumber = (value) => {
|
||||||
|
const parsed = Number(value ?? 0);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const safeGet = (object, ...keys) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const candidate = object?.[key];
|
||||||
|
if (candidate !== undefined && candidate !== null && candidate !== "") {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a single order item from the server (returned by GET
|
||||||
|
* /order/items) into the comparison shape used by the idempotency check.
|
||||||
|
*
|
||||||
|
* related_item_id is intentionally excluded from the comparison shape so
|
||||||
|
* that "desired add-on with placeholder related_item_id" can be matched
|
||||||
|
* against "existing add-on with real numeric related_item_id". The other
|
||||||
|
* fields (product_id, quantity, price, notes) fully characterize the row.
|
||||||
|
*/
|
||||||
|
export const normalizeExistingOrderItemShape = (item) => ({
|
||||||
|
product_id: Number(safeGet(item, "product_id", "product_id") ?? 0),
|
||||||
|
quantity: toNonNegativeNumber(safeGet(item, "quantity", "quantity")),
|
||||||
|
price: Number(safeGet(item, "price", "price") ?? 0),
|
||||||
|
notes: String(safeGet(item, "notes", "notes") ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group raw server items into [primaryShape, ...addonShapes, ...additionalShapes]
|
||||||
|
* so we can compare the server state with what we want the server to look like.
|
||||||
|
*
|
||||||
|
* - `primaryItems` is every item with related_item_id === null. The first one
|
||||||
|
* is treated as the primary; subsequent ones are standalone "additional"
|
||||||
|
* items unless their product id matches the in-memory primary product.
|
||||||
|
* - `addonShapes` are items whose related_item_id equals the primary's id.
|
||||||
|
*/
|
||||||
|
export const normalizeExistingOrderItemShapes = (existingItems, primaryProductId) => {
|
||||||
|
const items = Array.isArray(existingItems) ? existingItems : [];
|
||||||
|
const primaryItems = items.filter((item) => item?.related_item_id === null || item?.related_item_id === undefined);
|
||||||
|
if (primaryItems.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryItem = primaryItems[0];
|
||||||
|
const primaryItemId = Number(primaryItem?.id ?? 0);
|
||||||
|
const primaryShape = normalizeExistingOrderItemShape(primaryItem);
|
||||||
|
|
||||||
|
const additionalItems = primaryItems
|
||||||
|
.filter((item) => Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(primaryProductId ?? 0))
|
||||||
|
.map(normalizeExistingOrderItemShape);
|
||||||
|
|
||||||
|
const addonShapes =
|
||||||
|
primaryItemId > 0
|
||||||
|
? items
|
||||||
|
.filter((item) => Number(item?.related_item_id ?? 0) === primaryItemId)
|
||||||
|
.map(normalizeExistingOrderItemShape)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [primaryShape, ...addonShapes, ...additionalItems];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the desired shapes from the in-memory primaryItem + additionalItems.
|
||||||
|
* Uses a stable placeholder ("__PRIMARY__") for the add-on related_item_id
|
||||||
|
* because the real primary id is not known until the primary POST resolves.
|
||||||
|
*/
|
||||||
|
export const buildDesiredOrderItemShapes = ({
|
||||||
|
primaryItem,
|
||||||
|
additionalItems,
|
||||||
|
isAddonRestricted = () => false,
|
||||||
|
isStandaloneRestricted = () => false,
|
||||||
|
}) => {
|
||||||
|
if (!primaryItem) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryShape = {
|
||||||
|
kind: "primary",
|
||||||
|
relatedKey: "primary",
|
||||||
|
product_id: Number(primaryItem.id ?? 0),
|
||||||
|
quantity: 1,
|
||||||
|
related_item_id: null,
|
||||||
|
price: Number(primaryItem.price ?? 0),
|
||||||
|
notes: String(primaryItem.notes ?? ""),
|
||||||
|
skip_price_override: primaryItem.skip_price_override === true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const addonShapes = (Array.isArray(primaryItem.addons) ? primaryItem.addons : [])
|
||||||
|
.filter((addon) => Number(addon?.quantity ?? 0) > 0 && !isAddonRestricted(addon))
|
||||||
|
.map((addon) => {
|
||||||
|
const addonProduct = addon?.product ?? addon;
|
||||||
|
return {
|
||||||
|
kind: "addon",
|
||||||
|
relatedKey: "primary",
|
||||||
|
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
|
||||||
|
quantity: Number(addon?.quantity ?? 0),
|
||||||
|
related_item_id: PLACEHOLDER_PRIMARY_RELATED_ITEM_ID,
|
||||||
|
price: Number(addonProduct?.price ?? addon?.price ?? 0),
|
||||||
|
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
|
||||||
|
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const additionalShapes = (Array.isArray(additionalItems) ? additionalItems : [])
|
||||||
|
.filter((item) => Number(item?.quantity ?? 0) > 0 && !isStandaloneRestricted(item))
|
||||||
|
.map((item) => ({
|
||||||
|
kind: "additional",
|
||||||
|
relatedKey: null,
|
||||||
|
product_id: Number(item?.id ?? 0),
|
||||||
|
quantity: Number(item?.quantity ?? 0),
|
||||||
|
related_item_id: null,
|
||||||
|
price: Number(item?.price ?? 0),
|
||||||
|
notes: String(item?.notes ?? ""),
|
||||||
|
skip_price_override: item?.skip_price_override === true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [primaryShape, ...addonShapes, ...additionalShapes];
|
||||||
|
};
|
||||||
|
|
||||||
|
const stripKindMarkers = (shape) => {
|
||||||
|
const {
|
||||||
|
kind: _kind,
|
||||||
|
relatedKey: _relatedKey,
|
||||||
|
skip_price_override: _skip,
|
||||||
|
// related_item_id is intentionally stripped: in the desired shapes it is
|
||||||
|
// the placeholder "__PRIMARY__" for add-ons (the real primary id is not
|
||||||
|
// known until the primary POST resolves), while in the existing shapes
|
||||||
|
// it is the real numeric primary id. Without stripping, the comparison
|
||||||
|
// would always fail and the helper would rebuild the order on every
|
||||||
|
// Fuldfør click.
|
||||||
|
related_item_id: _relatedItemId,
|
||||||
|
...rest
|
||||||
|
} = shape;
|
||||||
|
return rest;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shallowEqual = (left, right) => {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!left || !right) return false;
|
||||||
|
const leftKeys = Object.keys(left);
|
||||||
|
const rightKeys = Object.keys(right);
|
||||||
|
if (leftKeys.length !== rightKeys.length) return false;
|
||||||
|
for (const key of leftKeys) {
|
||||||
|
if (left[key] !== right[key]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const arraysEqual = (left, right) => {
|
||||||
|
if (left === right) return true;
|
||||||
|
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
||||||
|
if (left.length !== right.length) return false;
|
||||||
|
for (let i = 0; i < left.length; i += 1) {
|
||||||
|
if (!shallowEqual(left[i], right[i])) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} OrderItemApi
|
||||||
|
* @property {(orderId, productId, quantity, relatedItemId, notes, price) => Promise<{data:{data:{id:number}}}>} createOrderItem
|
||||||
|
* @property {(orderId) => Promise<{data:{data:Array<object>}}>} getOrderItems
|
||||||
|
* @property {(orderItemId) => Promise<unknown>} removeOrderItem
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the in-memory transaction against the server's order_items.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {number} args.orderId
|
||||||
|
* @param {object|null} args.primaryItem
|
||||||
|
* @param {Array<object>} [args.additionalItems]
|
||||||
|
* @param {(addon:object) => boolean} [args.isAddonRestricted]
|
||||||
|
* @param {(item:object) => boolean} [args.isStandaloneRestricted]
|
||||||
|
* @param {(primary:object) => boolean} [args.isPrimaryRestricted]
|
||||||
|
* @param {OrderItemApi} args.api
|
||||||
|
* @returns {Promise<{createdIds: number[], createdPrimaryItemId: number|null}>}
|
||||||
|
* @throws {OrderItemsPartialSyncError} when any add-on or additional POST fails
|
||||||
|
* after the partial rollback has already run.
|
||||||
|
*/
|
||||||
|
export const syncMobileOrderItems = async ({
|
||||||
|
orderId,
|
||||||
|
primaryItem,
|
||||||
|
additionalItems = [],
|
||||||
|
isAddonRestricted = () => false,
|
||||||
|
isStandaloneRestricted = () => false,
|
||||||
|
isPrimaryRestricted = () => false,
|
||||||
|
api,
|
||||||
|
}) => {
|
||||||
|
const normalizedOrderId = toPositiveInteger(orderId);
|
||||||
|
if (!normalizedOrderId) {
|
||||||
|
throw new Error("Order ID is required");
|
||||||
|
}
|
||||||
|
if (!primaryItem) {
|
||||||
|
throw new Error("No primary item selected");
|
||||||
|
}
|
||||||
|
if (isPrimaryRestricted(primaryItem)) {
|
||||||
|
return { createdIds: [], createdPrimaryItemId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingItemsResponse = await api.getOrderItems(normalizedOrderId);
|
||||||
|
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
|
||||||
|
|
||||||
|
const desiredShapes = buildDesiredOrderItemShapes({
|
||||||
|
primaryItem,
|
||||||
|
additionalItems,
|
||||||
|
isAddonRestricted,
|
||||||
|
isStandaloneRestricted,
|
||||||
|
});
|
||||||
|
const currentShapes = normalizeExistingOrderItemShapes(existingItems, primaryItem?.id);
|
||||||
|
const comparableDesiredShapes = desiredShapes.map(stripKindMarkers);
|
||||||
|
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
|
||||||
|
|
||||||
|
if (!shouldForceRecreateForRepricing && arraysEqual(currentShapes, comparableDesiredShapes)) {
|
||||||
|
return { createdIds: [], createdPrimaryItemId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tear down whatever the server currently has for this order, including
|
||||||
|
// any half-synced rows left over from a previous failed attempt.
|
||||||
|
await Promise.allSettled(existingItems.map((item) => api.removeOrderItem(Number(item.id))));
|
||||||
|
|
||||||
|
let primaryCreate;
|
||||||
|
try {
|
||||||
|
primaryCreate = await api.createOrderItem(
|
||||||
|
normalizedOrderId,
|
||||||
|
Number(primaryItem.id ?? 0),
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
String(primaryItem.notes ?? ""),
|
||||||
|
primaryItem.skip_price_override === true ? null : Number(primaryItem.price ?? 0)
|
||||||
|
);
|
||||||
|
} catch (reason) {
|
||||||
|
// The primary row never landed, so there is nothing to roll back. Wrap
|
||||||
|
// the rejection so the UI layer gets the same error shape for both
|
||||||
|
// primary and add-on failures.
|
||||||
|
throw new OrderItemsPartialSyncError(
|
||||||
|
extractErrorMessage(reason),
|
||||||
|
[{ productId: Number(primaryItem.id ?? 0), message: extractErrorMessage(reason) }],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const createdPrimaryItemId = toPositiveInteger(primaryCreate?.data?.data?.id);
|
||||||
|
|
||||||
|
const addonCandidates = (Array.isArray(primaryItem.addons) ? primaryItem.addons : [])
|
||||||
|
.filter((addon) => Number(addon?.quantity ?? 0) > 0 && !isAddonRestricted(addon))
|
||||||
|
.map((addon) => ({ ...addon, priceOverride: true }));
|
||||||
|
const additionalCandidates = (Array.isArray(additionalItems) ? additionalItems : [])
|
||||||
|
.filter((item) => Number(item?.quantity ?? 0) > 0 && !isStandaloneRestricted(item))
|
||||||
|
.map((item) => ({ ...item, priceOverride: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createdIds: childIds } = await addOrderItemAddons({
|
||||||
|
orderId: normalizedOrderId,
|
||||||
|
primaryItemId: createdPrimaryItemId,
|
||||||
|
addons: addonCandidates,
|
||||||
|
additionalItems: additionalCandidates,
|
||||||
|
api,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
createdIds: [createdPrimaryItemId, ...childIds].filter(Boolean),
|
||||||
|
createdPrimaryItemId,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// The shared helper has already rolled back every add-on / additional
|
||||||
|
// row that landed during this attempt. Roll back the primary too so a
|
||||||
|
// retry starts from a clean state, then re-throw with the same error
|
||||||
|
// shape (`OrderItemsPartialSyncError`) the rest of the UI expects.
|
||||||
|
// Preserve the helper's aggregated failures and grow the
|
||||||
|
// rolledBackIds list to include the primary for callers / tests that
|
||||||
|
// inspect the error.
|
||||||
|
if (createdPrimaryItemId) {
|
||||||
|
await Promise.allSettled([api.removeOrderItem(createdPrimaryItemId)]);
|
||||||
|
}
|
||||||
|
if (error && typeof error === "object" && error.name === "OrderItemsPartialSyncError") {
|
||||||
|
const rolledBackIds = Array.isArray(error.rolledBackIds) ? error.rolledBackIds.slice() : [];
|
||||||
|
if (createdPrimaryItemId && !rolledBackIds.includes(createdPrimaryItemId)) {
|
||||||
|
rolledBackIds.push(createdPrimaryItemId);
|
||||||
|
}
|
||||||
|
throw new OrderItemsPartialSyncError(error.message, error.failures, rolledBackIds);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Compatibility shim for the now-removed XL Vask autopilot UI helpers.
|
||||||
|
*
|
||||||
|
* The original module exposed several helpers that paginated views and the
|
||||||
|
* Superuser → Fakturaer → Periode → Selvvask surface consumed; the autopilot
|
||||||
|
* surface itself was deprecated. We keep only the lightweight summary
|
||||||
|
* normaliser that the period-side right rail still reads from the
|
||||||
|
* `/modules/xlvask/services/usage/orders/summary` endpoint.
|
||||||
|
*
|
||||||
|
* Any caller that previously imported removed helpers (e.g.
|
||||||
|
* `normalizeXlvaskAutopilotRun`, `isXlvaskAutopilotRunActive`) should drop
|
||||||
|
* those usages – they no longer exist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const XLVASK_RESOLUTION_STATES = [
|
||||||
|
"already_linked",
|
||||||
|
"auto_linked",
|
||||||
|
"auto_created",
|
||||||
|
"needs_review",
|
||||||
|
"blocked",
|
||||||
|
"ignored",
|
||||||
|
"failed",
|
||||||
|
];
|
||||||
|
|
||||||
|
const XLVASK_IMPORT_STATES = ["new", "updated", "unchanged", "invalid"];
|
||||||
|
const XLVASK_CERTAINTY_STATES = ["certain", "uncertain", "none"];
|
||||||
|
|
||||||
|
const nonNegativeInteger = (value) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyXlvaskAutopilotSummary = () => ({
|
||||||
|
total: 0,
|
||||||
|
...Object.fromEntries(
|
||||||
|
[...XLVASK_IMPORT_STATES, ...XLVASK_RESOLUTION_STATES, ...XLVASK_CERTAINTY_STATES].map((key) => [key, 0]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const normalizeXlvaskAutopilotSummary = (rawSummary) => {
|
||||||
|
const normalized = emptyXlvaskAutopilotSummary();
|
||||||
|
if (!rawSummary || typeof rawSummary !== "object") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
Object.keys(normalized).forEach((key) => {
|
||||||
|
normalized[key] = nonNegativeInteger(rawSummary[key]);
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
@@ -5,15 +5,6 @@ export const isUsageOrderAttachedToOrder = (object) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const automation = object?.automation;
|
|
||||||
const automationStatus = automation?.status;
|
|
||||||
if (
|
|
||||||
["auto_accepted", "accepted"].includes(automationStatus) &&
|
|
||||||
["attach_order", "create_order"].includes(automation?.action)
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
|
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
|
||||||
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
|
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Pure, side-effect-free (apart from the injected API calls) helper that
|
||||||
|
* fans out a batch of `createOrderItem` POSTs for primary-product add-ons
|
||||||
|
* (and, optionally, additional standalone items) and rolls back every
|
||||||
|
* successful row when any one of the POSTs rejects.
|
||||||
|
*
|
||||||
|
* The same fan-out + rollback pattern is shared between the desktop
|
||||||
|
* (`SelectProductsFormPOS.vue → addAddonsToOrderMiddleware`) and the mobile
|
||||||
|
* (`PosDepartmentStepMobile2.vue → syncMobileOrderItems`) reconciliation
|
||||||
|
* paths. The desktop path passes a `primaryItemId` of the row it has just
|
||||||
|
* created; the mobile helper uses the same helper for its add-on /
|
||||||
|
* additional-item fan-out after the primary POST resolves.
|
||||||
|
*
|
||||||
|
* Why Promise.allSettled: a single rejection inside Promise.all would
|
||||||
|
* short-circuit the rest of the batch while the rows that already landed
|
||||||
|
* stayed on the server. On retry the operator saw only "some" of the
|
||||||
|
* selected add-ons persisted and a generic failure popup. allSettled
|
||||||
|
* collects every outcome, and the rollback restores a clean server state
|
||||||
|
* for the next attempt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
OrderItemsPartialSyncError,
|
||||||
|
extractErrorMessage,
|
||||||
|
formatFailureFragment,
|
||||||
|
} from "./orderItemsPartialSync.js";
|
||||||
|
|
||||||
|
const toPositiveInteger = (value) => {
|
||||||
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||||
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNonNegativeNumber = (value) => {
|
||||||
|
const parsed = Number(value ?? 0);
|
||||||
|
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the createOrderItem arguments for one add-on row. The caller passes
|
||||||
|
* either an addon-shaped object (with `option_id` / `addon_id` / nested
|
||||||
|
* `product`) or a plain object with `product_id` + `quantity`; this helper
|
||||||
|
* normalizes both shapes.
|
||||||
|
*
|
||||||
|
* The `price` field is only included when the caller has explicitly set
|
||||||
|
* `priceOverride: true` on the addon. The mobile path always sets the
|
||||||
|
* override (it computes the customer-discounted price locally and wants
|
||||||
|
* the server to record it); the desktop path does not (it lets the server
|
||||||
|
* fall back to the product's default price). This matches the original
|
||||||
|
* desktop behaviour pre-PR-289 fix, where the existing `createOrderItem`
|
||||||
|
* call was made with five arguments and the server-side price was left
|
||||||
|
* untouched.
|
||||||
|
*
|
||||||
|
* @param {object} addon
|
||||||
|
* @param {number} primaryItemId The order_items.id of the freshly-created
|
||||||
|
* primary row that the add-on should be linked to via related_item_id.
|
||||||
|
*/
|
||||||
|
export const buildAddonCreateOrderItemArgs = (addon, primaryItemId) => {
|
||||||
|
const addonProduct = addon?.product ?? addon ?? {};
|
||||||
|
const productId = Number(addonProduct?.id ?? addon?.option_id ?? addon?.addon_id ?? addon?.id ?? 0);
|
||||||
|
const quantity = toNonNegativeNumber(addon?.quantity ?? addonProduct?.quantity);
|
||||||
|
const relatedItemId = toPositiveInteger(primaryItemId);
|
||||||
|
const notes = String(addonProduct?.notes ?? addon?.notes ?? "");
|
||||||
|
const skipPriceOverride =
|
||||||
|
addonProduct?.skip_price_override === true || addon?.skip_price_override === true;
|
||||||
|
const shouldOverridePrice = addon?.priceOverride === true;
|
||||||
|
const price = shouldOverridePrice
|
||||||
|
? skipPriceOverride
|
||||||
|
? null
|
||||||
|
: Number(addonProduct?.price ?? addon?.price ?? 0)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
productId,
|
||||||
|
quantity,
|
||||||
|
relatedItemId,
|
||||||
|
notes,
|
||||||
|
skipPriceOverride,
|
||||||
|
overridePrice: shouldOverridePrice,
|
||||||
|
price,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} AddOrderItemAddonsApi
|
||||||
|
* @property {(orderId, productId, quantity, relatedItemId, notes, price) => Promise<{data:{data:{id:number}}}>} createOrderItem
|
||||||
|
* @property {(orderItemId) => Promise<unknown>} removeOrderItem
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan out `createOrderItem` for every add-on (and additional item) and
|
||||||
|
* rollback any rows that landed if any POST rejects.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {number} args.orderId
|
||||||
|
* @param {number} args.primaryItemId The order_items.id of the freshly-
|
||||||
|
* created primary row. Every add-on POST links to it via
|
||||||
|
* related_item_id.
|
||||||
|
* @param {Array<object>} [args.addons] Primary-product add-ons to POST.
|
||||||
|
* @param {Array<object>} [args.additionalItems] Standalone additional
|
||||||
|
* items to POST (related_item_id is null).
|
||||||
|
* @param {AddOrderItemAddonsApi} args.api
|
||||||
|
* @returns {Promise<{createdIds: number[], createdAddonIds: number[], createdAdditionalIds: number[]}>}
|
||||||
|
* @throws {OrderItemsPartialSyncError} when any add-on or additional POST
|
||||||
|
* fails, after the partial rollback has already run.
|
||||||
|
*/
|
||||||
|
export const addOrderItemAddons = async ({ orderId, primaryItemId, addons = [], additionalItems = [], api }) => {
|
||||||
|
const normalizedOrderId = toPositiveInteger(orderId);
|
||||||
|
if (!normalizedOrderId) {
|
||||||
|
throw new Error("Order ID is required");
|
||||||
|
}
|
||||||
|
const normalizedPrimaryItemId = toPositiveInteger(primaryItemId);
|
||||||
|
if (!normalizedPrimaryItemId) {
|
||||||
|
throw new Error("Primary order item ID is required");
|
||||||
|
}
|
||||||
|
if (!api || typeof api.createOrderItem !== "function" || typeof api.removeOrderItem !== "function") {
|
||||||
|
throw new Error("createOrderItem and removeOrderItem are required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const addonCandidates = Array.isArray(addons) ? addons.filter((addon) => toNonNegativeNumber(addon?.quantity) > 0) : [];
|
||||||
|
const additionalCandidates = Array.isArray(additionalItems)
|
||||||
|
? additionalItems.filter((item) => toNonNegativeNumber(item?.quantity) > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const addonPromises = addonCandidates.map((addon) => {
|
||||||
|
const args = buildAddonCreateOrderItemArgs(addon, normalizedPrimaryItemId);
|
||||||
|
return Promise.resolve(
|
||||||
|
api.createOrderItem(
|
||||||
|
normalizedOrderId,
|
||||||
|
args.productId,
|
||||||
|
args.quantity,
|
||||||
|
args.relatedItemId,
|
||||||
|
args.notes,
|
||||||
|
args.price
|
||||||
|
)
|
||||||
|
).then((response) => ({
|
||||||
|
productId: args.productId,
|
||||||
|
relatedItemId: args.relatedItemId,
|
||||||
|
response,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const additionalPromises = additionalCandidates.map((item) => {
|
||||||
|
const productId = Number(item?.id ?? item?.product_id ?? 0);
|
||||||
|
const quantity = toNonNegativeNumber(item?.quantity);
|
||||||
|
const notes = String(item?.notes ?? "");
|
||||||
|
const skipPriceOverride = item?.skip_price_override === true;
|
||||||
|
const shouldOverridePrice = item?.priceOverride === true;
|
||||||
|
const price = shouldOverridePrice ? (skipPriceOverride ? null : Number(item?.price ?? 0)) : null;
|
||||||
|
return Promise.resolve(
|
||||||
|
api.createOrderItem(normalizedOrderId, productId, quantity, null, notes, price)
|
||||||
|
).then((response) => ({
|
||||||
|
productId,
|
||||||
|
relatedItemId: null,
|
||||||
|
response,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([...addonPromises, ...additionalPromises]);
|
||||||
|
|
||||||
|
const createdAddonIds = [];
|
||||||
|
const createdAdditionalIds = [];
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
const productId =
|
||||||
|
index < addonPromises.length
|
||||||
|
? Number(addonCandidates[index]?.product?.id ?? addonCandidates[index]?.id ?? 0)
|
||||||
|
: Number(additionalCandidates[index - addonPromises.length]?.id ?? 0);
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
const id = toPositiveInteger(result.value?.response?.data?.data?.id);
|
||||||
|
if (id) {
|
||||||
|
if (index < addonPromises.length) {
|
||||||
|
createdAddonIds.push(id);
|
||||||
|
} else {
|
||||||
|
createdAdditionalIds.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failures.push({
|
||||||
|
productId,
|
||||||
|
message: extractErrorMessage(result.reason),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (failures.length === 0) {
|
||||||
|
return {
|
||||||
|
createdIds: [...createdAddonIds, ...createdAdditionalIds],
|
||||||
|
createdAddonIds,
|
||||||
|
createdAdditionalIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roll back every row we created in this attempt so the next attempt
|
||||||
|
// starts from a clean server state. Use allSettled so a rollback
|
||||||
|
// rejection doesn't mask the original failure.
|
||||||
|
const createdIdsToRollBack = [...createdAddonIds, ...createdAdditionalIds];
|
||||||
|
await Promise.allSettled(createdIdsToRollBack.map((id) => Promise.resolve(api.removeOrderItem(id))));
|
||||||
|
|
||||||
|
const aggregated = failures.map(formatFailureFragment).join("; ");
|
||||||
|
throw new OrderItemsPartialSyncError(aggregated, failures, createdIdsToRollBack);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { OrderItemsPartialSyncError };
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import Swal from "sweetalert2";
|
||||||
|
|
||||||
|
export const EXTRA_SALE_PRODUCT_ID = 27;
|
||||||
|
export const EXTRA_SALE_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
|
||||||
|
|
||||||
|
export const EXTRA_SALE_REASONS = [
|
||||||
|
{ code: "customer_request", label: "Kunde ønskede ekstra tid", commentRequired: false },
|
||||||
|
{ code: "operational_delay", label: "Driftsforsinkelse i vaskehal", commentRequired: false },
|
||||||
|
{ code: "rewash_quality", label: "Omkørsel/kvalitet", commentRequired: false },
|
||||||
|
{ code: "other", label: "Anden godkendt årsag", commentRequired: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isExtraSaleAuditProduct = (product) => {
|
||||||
|
if (!product || typeof product !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.requires_extra_sale_audit === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(product.id ?? product.product_id ?? 0) === EXTRA_SALE_PRODUCT_ID) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(product.name ?? "").trim() === EXTRA_SALE_PRODUCT_NAME;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isExtraSaleCommentRequired = (reasonCode) => {
|
||||||
|
return EXTRA_SALE_REASONS.some((reason) => reason.code === reasonCode && reason.commentRequired);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extraSaleAuditPayload = ({ reasonCode = null, comment = null } = {}) => ({
|
||||||
|
extra_sale_reason_code: String(reasonCode || "").trim() || null,
|
||||||
|
extra_sale_comment: String(comment || "").trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getExtraSaleAuditFromOrderItem = (orderItem = {}) =>
|
||||||
|
extraSaleAuditPayload({
|
||||||
|
reasonCode: orderItem.extra_sale_reason_code,
|
||||||
|
comment: orderItem.extra_sale_comment,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const promptExtraSaleAuditIfRequired = async (product, initial = {}) => {
|
||||||
|
if (!isExtraSaleAuditProduct(product)) {
|
||||||
|
return extraSaleAuditPayload(initial);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialReason = String(initial.reasonCode ?? initial.extra_sale_reason_code ?? "").trim();
|
||||||
|
const initialComment = String(initial.comment ?? initial.extra_sale_comment ?? "").trim();
|
||||||
|
const optionsMarkup = EXTRA_SALE_REASONS.map((reason) => {
|
||||||
|
const selected = reason.code === initialReason ? " selected" : "";
|
||||||
|
return `<option value="${reason.code}"${selected}>${reason.label}</option>`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
const result = await Swal.fire({
|
||||||
|
title: "Godkend 10 min ekstra",
|
||||||
|
html: `
|
||||||
|
<label class="label has-text-left" for="extra-sale-reason-code">Årsag</label>
|
||||||
|
<div class="select is-fullwidth mb-3">
|
||||||
|
<select id="extra-sale-reason-code" class="swal2-select" style="display:block;width:100%;margin:0;">
|
||||||
|
<option value="">Vælg godkendt årsag</option>
|
||||||
|
${optionsMarkup}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label class="label has-text-left" for="extra-sale-comment">Kommentar</label>
|
||||||
|
<textarea id="extra-sale-comment" class="swal2-textarea" rows="4" style="display:block;width:100%;margin:0;" placeholder="Uddyb når årsagen kræver det">${initialComment}</textarea>
|
||||||
|
`,
|
||||||
|
showCancelButton: true,
|
||||||
|
confirmButtonText: "Godkend",
|
||||||
|
focusConfirm: false,
|
||||||
|
preConfirm: () => {
|
||||||
|
const reasonCode = document.getElementById("extra-sale-reason-code")?.value || "";
|
||||||
|
const comment = document.getElementById("extra-sale-comment")?.value || "";
|
||||||
|
|
||||||
|
if (!reasonCode) {
|
||||||
|
Swal.showValidationMessage("Vælg en godkendt årsag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExtraSaleCommentRequired(reasonCode) && !String(comment || "").trim()) {
|
||||||
|
Swal.showValidationMessage("Kommentar er påkrævet for denne årsag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return extraSaleAuditPayload({ reasonCode, comment });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.isConfirmed ? result.value : null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Shared error class + helpers for the POS order_items partial-sync pattern.
|
||||||
|
*
|
||||||
|
* Both the desktop (SelectProductsFormPOS.vue → addAddonsToOrderMiddleware)
|
||||||
|
* and the mobile (PosDepartmentStepMobile2.vue → syncMobileOrderItems)
|
||||||
|
* reconciliation paths use the same fan-out strategy:
|
||||||
|
*
|
||||||
|
* 1. Fan out every pending createOrderItem POST via Promise.allSettled so
|
||||||
|
* no single rejection short-circuits the batch.
|
||||||
|
* 2. If any POST rejects, roll back the rows that did land via
|
||||||
|
* Promise.allSettled(removeOrderItem) so a retry starts from a clean
|
||||||
|
* server state instead of the half-synced snapshot that caused the
|
||||||
|
* "only some of the selected primary product add-ons were persisted"
|
||||||
|
* bug originally filed for the mobile path.
|
||||||
|
* 3. Throw OrderItemsPartialSyncError so the UI can surface an aggregated
|
||||||
|
* error message that names every failed product.
|
||||||
|
*
|
||||||
|
* Keeping this contract in one place lets both call sites produce the same
|
||||||
|
* error shape and rollback guarantees.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map an axios-like rejection to the human-readable string the operator
|
||||||
|
* should see. Falls back to the error message itself.
|
||||||
|
*/
|
||||||
|
export const extractErrorMessage = (reason) => {
|
||||||
|
const message =
|
||||||
|
reason?.response?.data?.data?.message ?? reason?.response?.data?.message ?? reason?.message ?? "Unknown error";
|
||||||
|
return String(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a per-product failure into a human-readable fragment. Used to
|
||||||
|
* aggregate multiple add-on / additional-item rejections into one operator
|
||||||
|
* message.
|
||||||
|
*/
|
||||||
|
export const formatFailureFragment = ({ productId, message }) => {
|
||||||
|
if (!Number.isFinite(Number(productId)) || Number(productId) <= 0) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
return `Product ${productId}: ${message}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when one or more order_items POSTs fail during a fan-out. Carries
|
||||||
|
* the aggregated message and the per-product failure list so the UI layer
|
||||||
|
* can either display it directly or surface structured details.
|
||||||
|
*/
|
||||||
|
export class OrderItemsPartialSyncError extends Error {
|
||||||
|
constructor(message, failures, rolledBackIds) {
|
||||||
|
super(message);
|
||||||
|
this.name = "OrderItemsPartialSyncError";
|
||||||
|
this.failures = failures;
|
||||||
|
this.rolledBackIds = rolledBackIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ const pricingData = ref(null);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
|
const pricingConflict = ref(false);
|
||||||
|
|
||||||
const departmentId = computed(() => {
|
const departmentId = computed(() => {
|
||||||
const parsed = Number.parseInt(String(props.departmentId || ""), 10);
|
const parsed = Number.parseInt(String(props.departmentId || ""), 10);
|
||||||
@@ -64,7 +65,8 @@ const parsedCustomerNumber = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const canLoadPricing = 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 : []));
|
const categories = computed(() => (Array.isArray(pricingData.value?.categories) ? pricingData.value.categories : []));
|
||||||
@@ -130,12 +132,23 @@ const requestLoad = () =>
|
|||||||
|
|
||||||
const requestSave = (overrides) =>
|
const requestSave = (overrides) =>
|
||||||
props.scope === "limited"
|
props.scope === "limited"
|
||||||
? updateLimitedBackofficeDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides)
|
? updateLimitedBackofficeDepartmentCustomerPricing(
|
||||||
: updateSuperuserDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides);
|
departmentId.value,
|
||||||
|
customerIdentifier.value,
|
||||||
|
overrides,
|
||||||
|
pricingData.value?.revision ?? null
|
||||||
|
)
|
||||||
|
: updateSuperuserDepartmentCustomerPricing(
|
||||||
|
departmentId.value,
|
||||||
|
customerIdentifier.value,
|
||||||
|
overrides,
|
||||||
|
pricingData.value?.revision ?? null
|
||||||
|
);
|
||||||
|
|
||||||
const resetPricingData = () => {
|
const resetPricingData = () => {
|
||||||
pricingData.value = null;
|
pricingData.value = null;
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
|
pricingConflict.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadPricing = async () => {
|
const loadPricing = async () => {
|
||||||
@@ -146,6 +159,7 @@ const loadPricing = async () => {
|
|||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
|
pricingConflict.value = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await requestLoad();
|
const response = await requestLoad();
|
||||||
@@ -193,16 +207,17 @@ const saveOverride = async (nextOverride) => {
|
|||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
|
pricingConflict.value = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await requestSave(normalizedOverrides(nextOverride));
|
const response = await requestSave(normalizedOverrides(nextOverride));
|
||||||
pricingData.value = unwrapDepartmentCustomerPricingResponse(response);
|
pricingData.value = unwrapDepartmentCustomerPricingResponse(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value =
|
const payload = error?.response?.data?.data || error?.response?.data || {};
|
||||||
error?.response?.data?.data?.message ||
|
pricingConflict.value = Number(error?.response?.status) === 409 && payload?.code === "pricing_revision_conflict";
|
||||||
error?.response?.data?.message ||
|
errorMessage.value = pricingConflict.value
|
||||||
error?.message ||
|
? t("templates.limited_backoffice.errors.pricing_conflict")
|
||||||
t("departments.customer_pricing.errors.save");
|
: payload?.message || error?.message || t("departments.customer_pricing.errors.save");
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
@@ -317,7 +332,11 @@ watch(
|
|||||||
:class="{ 'department-customer-pricing--tiles': tilePresentation }"
|
:class="{ 'department-customer-pricing--tiles': tilePresentation }"
|
||||||
data-testid="department-customer-pricing-editor"
|
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") }}
|
{{ t("departments.customer_pricing.no_permission") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -337,44 +356,44 @@ watch(
|
|||||||
:subtitle="tilePresentation ? t('departments.customer_pricing.open') : undefined"
|
:subtitle="tilePresentation ? t('departments.customer_pricing.open') : undefined"
|
||||||
data-testid="department-customer-pricing-lookup"
|
data-testid="department-customer-pricing-lookup"
|
||||||
>
|
>
|
||||||
<div class="department-customer-pricing__toolbar">
|
<div class="department-customer-pricing__toolbar">
|
||||||
<div class="field department-customer-pricing__customer-field">
|
<div class="field department-customer-pricing__customer-field">
|
||||||
<label class="label" for="department-customer-pricing-customer-number">
|
<label class="label" for="department-customer-pricing-customer-number">
|
||||||
{{ t("departments.customer_pricing.customer_number") }}
|
{{ t("departments.customer_pricing.customer_number") }}
|
||||||
</label>
|
</label>
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input
|
<input
|
||||||
id="department-customer-pricing-customer-number"
|
id="department-customer-pricing-customer-number"
|
||||||
v-model="customerNumber"
|
v-model="customerNumber"
|
||||||
class="input"
|
class="input"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
step="1"
|
step="1"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
:placeholder="t('departments.customer_pricing.customer_number')"
|
:placeholder="t('departments.customer_pricing.customer_number')"
|
||||||
data-testid="department-customer-pricing-customer-number"
|
data-testid="department-customer-pricing-customer-number"
|
||||||
@keydown.enter.prevent="loadPricing"
|
@keydown.enter.prevent="loadPricing"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<b-tooltip :label="t('departments.customer_pricing.load_customer')" position="is-bottom" type="is-dark">
|
<b-tooltip :label="t('departments.customer_pricing.load_customer')" position="is-bottom" type="is-dark">
|
||||||
<button
|
<button
|
||||||
class="button is-info"
|
class="button is-info"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!canLoadPricing || loading"
|
:disabled="!canLoadPricing || loading"
|
||||||
data-testid="department-customer-pricing-load"
|
data-testid="department-customer-pricing-load"
|
||||||
@click="loadPricing"
|
@click="loadPricing"
|
||||||
>
|
>
|
||||||
<span class="icon is-small">
|
<span class="icon is-small">
|
||||||
<i class="fas" :class="loading ? 'fa-spinner fa-spin' : 'fa-search'" aria-hidden="true"></i>
|
<i class="fas" :class="loading ? 'fa-spinner fa-spin' : 'fa-search'" aria-hidden="true"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</b-tooltip>
|
</b-tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</component>
|
</component>
|
||||||
|
|
||||||
<div v-if="loading" class="notification is-light" data-testid="department-customer-pricing-loading">
|
<div v-if="loading" class="notification is-light" data-testid="department-customer-pricing-loading">
|
||||||
@@ -382,7 +401,16 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="department-customer-pricing-error">
|
<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>
|
||||||
|
|
||||||
<div v-if="pricingData" class="department-customer-pricing__content">
|
<div v-if="pricingData" class="department-customer-pricing__content">
|
||||||
@@ -393,39 +421,48 @@ watch(
|
|||||||
:subtitle="tilePresentation ? pricingData.department?.name : undefined"
|
:subtitle="tilePresentation ? pricingData.department?.name : undefined"
|
||||||
data-testid="department-customer-pricing-customer-panel"
|
data-testid="department-customer-pricing-customer-panel"
|
||||||
>
|
>
|
||||||
<div class="level department-customer-pricing__summary">
|
<div class="level department-customer-pricing__summary">
|
||||||
<div class="level-left">
|
<div class="level-left">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="title is-5" data-testid="department-customer-pricing-customer">
|
<h2 class="title is-5" data-testid="department-customer-pricing-customer">
|
||||||
{{ customerDisplay }}
|
{{ customerDisplay }}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="subtitle is-6">{{ pricingData.department?.name }}</p>
|
<p class="subtitle is-6">{{ pricingData.department?.name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="level-right">
|
||||||
|
<b-tooltip
|
||||||
|
:label="
|
||||||
|
props.canEdit
|
||||||
|
? t('departments.customer_pricing.edit_global_discount')
|
||||||
|
: t('departments.customer_pricing.edit_disabled')
|
||||||
|
"
|
||||||
|
position="is-left"
|
||||||
|
type="is-dark"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<button
|
||||||
|
class="button is-small"
|
||||||
|
type="button"
|
||||||
|
:disabled="!props.canEdit || saving"
|
||||||
|
data-testid="department-customer-pricing-global-discount"
|
||||||
|
@click="editDiscount(true, 'global', t('departments.customer_pricing.global_discount'))"
|
||||||
|
>
|
||||||
|
<span class="icon is-small"><i class="fas fa-percent" aria-hidden="true"></i></span>
|
||||||
|
<span>{{ getDiscountDisplay(getOverride(true, "global")) }}</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</b-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="level-right">
|
|
||||||
<b-tooltip
|
|
||||||
:label="props.canEdit ? t('departments.customer_pricing.edit_global_discount') : t('departments.customer_pricing.edit_disabled')"
|
|
||||||
position="is-left"
|
|
||||||
type="is-dark"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<button
|
|
||||||
class="button is-small"
|
|
||||||
type="button"
|
|
||||||
:disabled="!props.canEdit || saving"
|
|
||||||
data-testid="department-customer-pricing-global-discount"
|
|
||||||
@click="editDiscount(true, 'global', t('departments.customer_pricing.global_discount'))"
|
|
||||||
>
|
|
||||||
<span class="icon is-small"><i class="fas fa-percent" aria-hidden="true"></i></span>
|
|
||||||
<span>{{ getDiscountDisplay(getOverride(true, "global")) }}</span>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</b-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</component>
|
</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") }}
|
{{ t("departments.customer_pricing.saving") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -444,7 +481,11 @@ watch(
|
|||||||
<div class="department-customer-pricing__category-header">
|
<div class="department-customer-pricing__category-header">
|
||||||
<h3 class="title is-6">{{ category.name }}</h3>
|
<h3 class="title is-6">{{ category.name }}</h3>
|
||||||
<b-tooltip
|
<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"
|
position="is-left"
|
||||||
type="is-dark"
|
type="is-dark"
|
||||||
>
|
>
|
||||||
@@ -489,7 +530,11 @@ watch(
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<b-tooltip
|
<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"
|
position="is-bottom"
|
||||||
type="is-dark"
|
type="is-dark"
|
||||||
>
|
>
|
||||||
@@ -509,7 +554,11 @@ watch(
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<b-tooltip
|
<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"
|
position="is-bottom"
|
||||||
type="is-dark"
|
type="is-dark"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed, onMounted, provide, ref, watch } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import {
|
||||||
|
PaginatedListKey,
|
||||||
|
usePaginatedList,
|
||||||
|
} from "@/components/pagination/paginatedList.vue";
|
||||||
|
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
||||||
|
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
||||||
|
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
||||||
|
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
|
||||||
|
import PaginationDisplayTemplateDates
|
||||||
|
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
|
||||||
|
import ShowErrorField from "@/components/global/ShowErrorField.vue";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
|
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 { removeError } from "@/components/request/HandleGlobalError.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
autoLoad: {
|
autoLoad: {
|
||||||
type: Boolean, default: true
|
type: Boolean, default: true
|
||||||
@@ -19,29 +38,12 @@ const props = defineProps({
|
|||||||
type: Boolean, default: false
|
type: Boolean, default: false
|
||||||
}, highlightUsageLogId: {
|
}, highlightUsageLogId: {
|
||||||
type: Number, default: 0
|
type: Number, default: 0
|
||||||
|
}, departmentId: {
|
||||||
|
type: Number, default: 0
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
import {computed, provide, ref, watch } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import {
|
|
||||||
PaginatedListKey,
|
|
||||||
usePaginatedList,
|
|
||||||
} from "@/components/pagination/paginatedList.vue";
|
|
||||||
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
|
|
||||||
|
|
||||||
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
|
const { t } = useI18n();
|
||||||
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
|
|
||||||
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
|
|
||||||
import PaginationDisplayTemplateDates
|
|
||||||
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
|
|
||||||
import ShowErrorField from "@/components/global/ShowErrorField.vue";
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { BSwitch } from "buefy";
|
|
||||||
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
|
|
||||||
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
|
|
||||||
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const paginatedList = usePaginatedList();
|
const paginatedList = usePaginatedList();
|
||||||
provide(PaginatedListKey, paginatedList);
|
provide(PaginatedListKey, paginatedList);
|
||||||
@@ -61,9 +63,11 @@ const {
|
|||||||
setOrder,
|
setOrder,
|
||||||
hideSearchField,
|
hideSearchField,
|
||||||
setHideSearchField,
|
setHideSearchField,
|
||||||
|
lastError,
|
||||||
} = paginatedList;
|
} = paginatedList;
|
||||||
|
|
||||||
setEndpoint("/modules/xlvask/services/usage/orders", false);
|
const XLVASK_USAGE_ORDERS_ENDPOINT = "/modules/xlvask/services/usage/orders";
|
||||||
|
setEndpoint(XLVASK_USAGE_ORDERS_ENDPOINT, false);
|
||||||
if (props.loadAllAtOnce) {
|
if (props.loadAllAtOnce) {
|
||||||
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
|
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
|
||||||
}
|
}
|
||||||
@@ -89,68 +93,73 @@ const applyInitialPeriodFilters = () => {
|
|||||||
const shouldShowLocalFilters = computed(() => !props.inheritPeriodFilters);
|
const shouldShowLocalFilters = computed(() => !props.inheritPeriodFilters);
|
||||||
|
|
||||||
const titleText = computed(() => props.title || SessionUser.objects.orders.meta.title);
|
const titleText = computed(() => props.title || SessionUser.objects.orders.meta.title);
|
||||||
// If the customer filter is set, filter the orders by the customer number
|
|
||||||
if (props.setCustomerFilter > 0) {
|
if (props.setCustomerFilter > 0) {
|
||||||
setFilter("customer_number", props.setCustomerFilter);
|
setFilter("customer_number", props.setCustomerFilter);
|
||||||
console.log("Setting customer filter to: " + props.setCustomerFilter);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the search field
|
|
||||||
if (props.hideSearch) {
|
if (props.hideSearch) {
|
||||||
setHideSearchField(true);
|
setHideSearchField(true);
|
||||||
console.log("Hiding search field");
|
|
||||||
} else {
|
} else {
|
||||||
setHideSearchField(false);
|
setHideSearchField(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the departmentId is set, in the route, filter the orders by the departmentId
|
const routeDepartmentId = Number.parseInt(
|
||||||
if (router.currentRoute.value.params.departmentId) {
|
String(router.currentRoute.value.params.departmentId ?? ""),
|
||||||
setOrder("StartTime", "desc");
|
10
|
||||||
//setFilter("department", router.currentRoute.value.params.departmentId, false);
|
);
|
||||||
} else {
|
const effectiveDepartmentId =
|
||||||
setOrder("StartTime", "desc");
|
props.departmentId > 0
|
||||||
|
? props.departmentId
|
||||||
|
: Number.isInteger(routeDepartmentId) && routeDepartmentId > 0
|
||||||
|
? routeDepartmentId
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (effectiveDepartmentId > 0) {
|
||||||
|
setFilter("HallId", effectiveDepartmentId, false);
|
||||||
}
|
}
|
||||||
const isImportLoading = ref(false);
|
|
||||||
|
|
||||||
const buildImportUsageParams = () => {
|
setOrder("StartTime", "desc");
|
||||||
if (!props.inheritPeriodFilters) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const extractResponseData = (response) => response?.data?.data ?? response?.data ?? {};
|
||||||
...(props.initialDateFrom ? { dateFrom: props.initialDateFrom } : {}),
|
|
||||||
...(props.initialDateTo ? { dateTo: props.initialDateTo } : {}),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const importUsageButton = async () => {
|
const summary = ref({});
|
||||||
isImportLoading.value = true;
|
const summaryLoading = ref(false);
|
||||||
|
const summaryError = ref("");
|
||||||
|
let summaryRequestSequence = 0;
|
||||||
|
|
||||||
|
const buildUsagePaginationParams = (extra = {}) => ({
|
||||||
|
...extra,
|
||||||
|
dateFrom: props.initialDateFrom || formatLocalDateOnly(dateFrom.value),
|
||||||
|
dateTo: props.initialDateTo || formatLocalDateOnly(dateTo.value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadSummary = async () => {
|
||||||
|
const sequence = ++summaryRequestSequence;
|
||||||
|
const params = buildUsagePaginationParams();
|
||||||
|
summaryLoading.value = true;
|
||||||
|
summaryError.value = "";
|
||||||
try {
|
try {
|
||||||
await SessionUser.request(
|
const response = await SessionUser.request(
|
||||||
'/modules/xlvask/tasks/import-usage',
|
"/modules/xlvask/services/usage/orders/summary",
|
||||||
'GET',
|
"GET",
|
||||||
buildImportUsageParams(),
|
params,
|
||||||
function (error) {
|
|
||||||
// This function is called when the request fails
|
|
||||||
console.error("Failed to import orders.", error);
|
|
||||||
},
|
|
||||||
function (response) {
|
|
||||||
// This function is called when the request succeeds
|
|
||||||
console.log("Orders imported successfully.", response);
|
|
||||||
// Reload the list after import
|
|
||||||
loadList();
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
if (sequence !== summaryRequestSequence) return;
|
||||||
|
summary.value = extractResponseData(response).summary || {};
|
||||||
|
} catch (error) {
|
||||||
|
if (sequence !== summaryRequestSequence) return;
|
||||||
|
summary.value = {};
|
||||||
|
summaryError.value = t("invoicing_period.xlvask_review.summary_error");
|
||||||
|
console.error("Failed to load XL-Vask usage summary.", error);
|
||||||
} finally {
|
} finally {
|
||||||
isImportLoading.value = false;
|
if (sequence === summaryRequestSequence) summaryLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateFrom = ref(parseInitialDate(props.initialDateFrom));
|
const dateFrom = ref(parseInitialDate(props.initialDateFrom));
|
||||||
const dateTo = ref(parseInitialDate(props.initialDateTo));
|
const dateTo = ref(parseInitialDate(props.initialDateTo));
|
||||||
|
|
||||||
const parsedDate = (date) => {
|
const parsedDate = (date) => parseLocalDateOnly(date);
|
||||||
return parseLocalDateOnly(date);
|
|
||||||
};
|
|
||||||
|
|
||||||
const reloadScheduled = ref(false);
|
const reloadScheduled = ref(false);
|
||||||
const scheduleReload = () => {
|
const scheduleReload = () => {
|
||||||
@@ -183,9 +192,9 @@ const actions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
applyInitialPeriodFilters();
|
applyInitialPeriodFilters();
|
||||||
// Load the list automatically if the autoLoad prop is set
|
|
||||||
if (props.autoLoad) {
|
if (props.autoLoad) {
|
||||||
loadList();
|
loadList();
|
||||||
|
loadSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -201,19 +210,79 @@ watch(
|
|||||||
|
|
||||||
if (props.autoLoad) {
|
if (props.autoLoad) {
|
||||||
loadList();
|
loadList();
|
||||||
|
loadSummary();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("xlvask-usage-order-updated", () => {
|
||||||
|
loadSummary();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const filterValues = ref({
|
||||||
|
import_state: "",
|
||||||
|
resolution_state: "",
|
||||||
|
certainty: "",
|
||||||
|
planned_action: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyReviewFilter = (key, value) => {
|
||||||
|
filterValues.value[key] = value;
|
||||||
|
setFilter(key, value, false);
|
||||||
|
setPage(1);
|
||||||
|
loadList();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReviewFilters = () => {
|
||||||
|
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 ?? 0, tone: "is-info" },
|
||||||
|
{ key: "updated", count: summary.value.updated ?? 0, tone: "is-info" },
|
||||||
|
{ key: "unchanged", count: summary.value.unchanged ?? 0, tone: "is-light" },
|
||||||
|
{ key: "already_linked", count: summary.value.already_linked ?? 0, tone: "is-success" },
|
||||||
|
{ key: "auto_linked", count: summary.value.auto_linked ?? 0, tone: "is-success" },
|
||||||
|
{ key: "auto_created", count: summary.value.auto_created ?? 0, tone: "is-success" },
|
||||||
|
{ key: "certain", count: summary.value.certain ?? 0, tone: "is-success" },
|
||||||
|
{ key: "uncertain", count: summary.value.uncertain ?? 0, tone: "is-warning" },
|
||||||
|
{ key: "needs_review", count: summary.value.needs_review ?? 0, tone: "is-warning" },
|
||||||
|
{ key: "blocked", count: summary.value.blocked ?? 0, tone: "is-danger" },
|
||||||
|
{ key: "invalid", count: summary.value.invalid ?? 0, tone: "is-danger" },
|
||||||
|
{ key: "ignored", count: summary.value.ignored ?? 0, tone: "is-light" },
|
||||||
|
{ key: "failed", count: summary.value.failed ?? 0, tone: "is-danger" },
|
||||||
|
]);
|
||||||
|
|
||||||
const showOnlyUnattachedVehicle = ref(false);
|
const showOnlyUnattachedVehicle = ref(false);
|
||||||
const visibleObjectsCount = computed(() => {
|
const visibleObjectsCount = computed(() => {
|
||||||
const currentList = Array.isArray(list.value) ? list.value : [];
|
const currentList = Array.isArray(list.value) ? list.value : [];
|
||||||
if (!showOnlyUnattachedVehicle.value) {
|
if (!showOnlyUnattachedVehicle.value) {
|
||||||
return currentList.length;
|
return currentList.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentList.filter((object) => !isUsageOrderAttachedToOrder(object)).length;
|
return currentList.filter((object) => !isUsageOrderAttachedToOrder(object)).length;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 404 on the orders endpoint means the API surface has not been
|
||||||
|
// implemented yet. Show a friendly notice instead of the generic error
|
||||||
|
// popper the rest of the paginated surfaces surface.
|
||||||
|
const apiEndpointNotImplemented = computed(() => (
|
||||||
|
lastError.value?.status === 404
|
||||||
|
&& lastError.value?.endpoint === XLVASK_USAGE_ORDERS_ENDPOINT
|
||||||
|
));
|
||||||
|
|
||||||
|
watch(apiEndpointNotImplemented, (isNotImplemented) => {
|
||||||
|
if (isNotImplemented) {
|
||||||
|
removeError("paginatedGetRequest");
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -227,18 +296,75 @@ const visibleObjectsCount = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="level-right">
|
<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>
|
|
||||||
<div class="level-item">
|
<div class="level-item">
|
||||||
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{SessionUser.objects.global.language.reload}}</LoadButtonWhileAwait>
|
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{SessionUser.objects.global.language.reload}}</LoadButtonWhileAwait>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="xlvask-review-summary mb-4" aria-live="polite" data-testid="xlvask-review-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_review.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-review-summary-chip"
|
||||||
|
:class="card.tone"
|
||||||
|
:data-testid="'xlvask-summary-' + card.key"
|
||||||
|
>
|
||||||
|
{{ t(`invoicing_period.xlvask_review.states.${card.key}`) }}: {{ card.count }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="box xlvask-review-filters mb-4" data-testid="xlvask-review-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_review.filters.import_state') }}</label>
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select id="xlvask-import-state-filter" :value="filterValues.import_state" @change="applyReviewFilter('import_state', $event.target.value)">
|
||||||
|
<option value="">{{ t('invoicing_period.xlvask_review.filters.all') }}</option>
|
||||||
|
<option v-for="state in ['new', 'updated', 'unchanged', 'invalid']" :key="state" :value="state">{{ t(`invoicing_period.xlvask_review.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_review.filters.resolution_state') }}</label>
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select id="xlvask-resolution-state-filter" :value="filterValues.resolution_state" @change="applyReviewFilter('resolution_state', $event.target.value)">
|
||||||
|
<option value="">{{ t('invoicing_period.xlvask_review.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_review.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_review.filters.certainty') }}</label>
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select id="xlvask-certainty-filter" :value="filterValues.certainty" @change="applyReviewFilter('certainty', $event.target.value)">
|
||||||
|
<option value="">{{ t('invoicing_period.xlvask_review.filters.all') }}</option>
|
||||||
|
<option v-for="state in ['certain', 'uncertain', 'none']" :key="state" :value="state">{{ t(`invoicing_period.xlvask_review.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_review.filters.planned_action') }}</label>
|
||||||
|
<div class="select is-small is-fullwidth">
|
||||||
|
<select id="xlvask-action-filter" :value="filterValues.planned_action" @change="applyReviewFilter('planned_action', $event.target.value)">
|
||||||
|
<option value="">{{ t('invoicing_period.xlvask_review.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_review.actions.${action}`) }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="button is-small is-light" type="button" @click="clearReviewFilters">
|
||||||
|
{{ t('invoicing_period.xlvask_review.filters.clear') }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_transactions')" v-if="!hideSearchField && shouldShowLocalFilters"/>
|
<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">
|
<PaginationDisplay v-if="shouldShowLocalFilters" :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
|
||||||
<template #paginationColumns>
|
<template #paginationColumns>
|
||||||
<!-- Sort by created_at -->
|
|
||||||
<div class="column is-narrow my-3">
|
<div class="column is-narrow my-3">
|
||||||
<label class="label">{{ t('pagination.order_direction') }}</label>
|
<label class="label">{{ t('pagination.order_direction') }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
@@ -257,36 +383,34 @@ const visibleObjectsCount = computed(() => {
|
|||||||
@update:endDate="actions.date.to.select"
|
@update:endDate="actions.date.to.select"
|
||||||
/>
|
/>
|
||||||
<div class="column is-narrow my-3">
|
<div class="column is-narrow my-3">
|
||||||
<label class="label">Filtre</label>
|
<label class="label">{{ t('invoicing_period.xlvask_review.filters.title') }}</label>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<b-switch
|
<b-switch
|
||||||
size="is-small"
|
size="is-small"
|
||||||
type="is-link"
|
type="is-link"
|
||||||
v-model="showOnlyUnattachedVehicle"
|
v-model="showOnlyUnattachedVehicle"
|
||||||
>
|
>
|
||||||
Vis kun ikke tilknyttede vaske
|
{{ t('invoicing_period.xlvask_review.filters.unattached_only') }}
|
||||||
</b-switch>
|
</b-switch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!--
|
|
||||||
<PaginationDisplayTemplateDate
|
|
||||||
:label="SessionUser.objects.global.language.date_from"
|
|
||||||
@update:date="actions.date.from.select"
|
|
||||||
v-bind:model-value="dateFrom"
|
|
||||||
/>
|
|
||||||
<PaginationDisplayTemplateDate
|
|
||||||
:label="SessionUser.objects.global.language.date_to"
|
|
||||||
@update:date="actions.date.to.select"
|
|
||||||
v-bind:model-value="dateTo"
|
|
||||||
/>
|
|
||||||
-->
|
|
||||||
</template>
|
</template>
|
||||||
</PaginationDisplay>
|
</PaginationDisplay>
|
||||||
|
<div
|
||||||
|
v-else-if="apiEndpointNotImplemented"
|
||||||
|
class="notification is-info is-light mb-4"
|
||||||
|
data-testid="xlvask-api-not-implemented"
|
||||||
|
>
|
||||||
|
{{ t('invoicing_period.xlvask_review.errors.api_endpoint_not_implemented') }}
|
||||||
|
</div>
|
||||||
<ShowErrorField v-else error="paginatedGetRequest"/>
|
<ShowErrorField v-else error="paginatedGetRequest"/>
|
||||||
<XlvaskUsageOrdersTable
|
<XlvaskUsageOrdersTable
|
||||||
:objects="list"
|
:objects="list"
|
||||||
:show-only-unattached-vehicle="showOnlyUnattachedVehicle"
|
:show-only-unattached-vehicle="showOnlyUnattachedVehicle"
|
||||||
:highlight-usage-log-id="props.highlightUsageLogId"
|
:highlight-usage-log-id="props.highlightUsageLogId"
|
||||||
|
:allow-select-multiple="true"
|
||||||
|
:allow-review-actions="true"
|
||||||
|
:allow-adjudication-actions="false"
|
||||||
/>
|
/>
|
||||||
<PaginationNavigation
|
<PaginationNavigation
|
||||||
v-if="!props.loadAllAtOnce"
|
v-if="!props.loadAllAtOnce"
|
||||||
@@ -299,5 +423,17 @@ const visibleObjectsCount = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.xlvask-review-summary .tags {
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-review-summary-chip {
|
||||||
|
height: auto;
|
||||||
|
min-height: 1.75rem;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xlvask-review-filters {
|
||||||
|
padding: 0.9rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BCheckbox } from "buefy";
|
|||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||||
|
import { permissionCapabilityLabel } from "@/components/session/subuser/subuserPermissionLabels.js";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
grant: {
|
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 templates = ref<PermissionTemplate[]>([]);
|
||||||
const selectedPermissions = ref<string[]>([]);
|
const selectedPermissions = ref<string[]>([]);
|
||||||
const selectedTemplateKey = ref<string>(props.templateKey || props.grant.permission_template_key || "custom");
|
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) =>
|
const templateDescription = (template: PermissionTemplate) =>
|
||||||
t(`superuser.driver_access.templates.${template.key}.description`);
|
t(`superuser.driver_access.templates.${template.key}.description`);
|
||||||
const groupLabel = (key: string) => t(`superuser.driver_access.groups.${key}`);
|
const groupLabel = (key: string) => t(`superuser.driver_access.groups.${key}`);
|
||||||
const permissionLabel = (permission: string) => {
|
const permissionLabel = (permission: string) => permissionCapabilityLabel(permission);
|
||||||
const capabilityKey = LEGACY_PERMISSION_LABEL_KEYS[permission] || permission;
|
|
||||||
return t(`superuser.driver_access.capabilities.${capabilityKey}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.permissions,
|
() => props.permissions,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { getCurrentInstance, ref, watch } from 'vue';
|
||||||
import {parseError, getError} from "@/components/request/HandleGlobalError.vue";
|
import {parseError, getError} from "@/components/request/HandleGlobalError.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -24,6 +24,7 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isEditting = ref(false);
|
const isEditting = ref(false);
|
||||||
|
const inputId = `configuration-input-${getCurrentInstance()?.uid}`;
|
||||||
|
|
||||||
const v_model = ref(props.value);
|
const v_model = ref(props.value);
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ const parseErrorOnSave = (error) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="field">
|
<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 -->
|
<!-- If the value is not set, show a warning message -->
|
||||||
<div v-if="!props.value && showEmptyWarning && !isEditting"
|
<div v-if="!props.value && showEmptyWarning && !isEditting"
|
||||||
class="message is-warning">
|
class="message is-warning">
|
||||||
@@ -58,6 +59,7 @@ const parseErrorOnSave = (error) => {
|
|||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
<div class="control is-expanded">
|
<div class="control is-expanded">
|
||||||
<input
|
<input
|
||||||
|
:id="inputId"
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
v-model="v_model"
|
v-model="v_model"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { parseError, getError } from "@/components/request/HandleGlobalError.vue";
|
import { parseError, getError } from "@/components/request/HandleGlobalError.vue";
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
onSave: Function, // Function to call when the secret key is changed
|
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
|
icon: String, // The icon of the select
|
||||||
color: String, // The color 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.
|
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 v_model = ref(props.value);
|
||||||
const isEditting = ref(false);
|
const isEditting = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
const errorId = ref(null);
|
const errorId = ref(null);
|
||||||
|
|
||||||
@@ -24,6 +42,32 @@ const parseErrorOnSave = (error) => {
|
|||||||
parseError(error, unique_id);
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -33,8 +77,8 @@ const parseErrorOnSave = (error) => {
|
|||||||
<!-- If the secret key is not set, show a warning -->
|
<!-- If the secret key is not set, show a warning -->
|
||||||
<div v-if="!props.isSet" class="message is-warning">
|
<div v-if="!props.isSet" class="message is-warning">
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
<strong>Warning:</strong>
|
<strong>{{ resolvedWarningTitle() }}:</strong>
|
||||||
The secret key is not set, please set it before enabling the integration.
|
{{ resolvedWarningMessage() }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
@@ -52,11 +96,16 @@ const parseErrorOnSave = (error) => {
|
|||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
v-model="v_model"
|
v-model="v_model"
|
||||||
:disabled="disabled || !isEditting"
|
:disabled="disabled || !isEditting || saving"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<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">
|
<span class="icon">
|
||||||
<i class="fas fa-save"></i>
|
<i class="fas fa-save"></i>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -50,7 +50,16 @@ const getOrders = (object) => {
|
|||||||
let invoice_orders = invoice.objects;
|
let invoice_orders = invoice.objects;
|
||||||
orders = orders.concat(invoice_orders);
|
orders = orders.concat(invoice_orders);
|
||||||
}
|
}
|
||||||
return orders;
|
return orders
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftId = Number.parseInt(String(left?.id ?? ""), 10);
|
||||||
|
const rightId = Number.parseInt(String(right?.id ?? ""), 10);
|
||||||
|
if (Number.isInteger(leftId) && Number.isInteger(rightId)) {
|
||||||
|
return leftId - rightId;
|
||||||
|
}
|
||||||
|
return String(left?.id ?? "").localeCompare(String(right?.id ?? ""));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getDepartments();
|
getDepartments();
|
||||||
|
|||||||
@@ -47,9 +47,35 @@ const emit = defineEmits(["flagStatusChanged"]);
|
|||||||
|
|
||||||
const orderItems = ref([]);
|
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 (api PR), but we sort defensively here so a
|
||||||
|
// stale cache or older API proxy cannot regress the render order (which previously
|
||||||
|
// made it look like only Trailer/Dolly were attached to a Trækker order because
|
||||||
|
// Spot-free and Undervognsskyld were listed above the primary and visually buried).
|
||||||
|
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 () => {
|
const loadOrderItems = async () => {
|
||||||
if (props.useLocalOrderItems) {
|
if (props.useLocalOrderItems) {
|
||||||
orderItems.value = props.localOrderItems;
|
orderItems.value = sortOrderItemsForDisplay(props.localOrderItems);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await SessionUser.request(
|
await SessionUser.request(
|
||||||
@@ -59,7 +85,7 @@ const loadOrderItems = async () => {
|
|||||||
order_id: props.orderId,
|
order_id: props.orderId,
|
||||||
}
|
}
|
||||||
).then((response) => {
|
).then((response) => {
|
||||||
orderItems.value = response.data.data;
|
orderItems.value = sortOrderItemsForDisplay(response.data.data);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
|
|||||||