Compare commits

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

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

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

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

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

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

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

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

@@ -1,49 +0,0 @@
{
"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
}
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

@@ -1,85 +0,0 @@
{
"schemaVersion": 1,
"kind": "VisualEvidenceManifest",
"taskId": "xlvask-autopilot-20260803",
"repository": "copenhagentruckwash/pleno-vue",
"baseSha": "f995440098f9e3f3b5ff122a55c8c4e018cc716e",
"subjectSha": "d5c291e6f7f81d6992aa63375d77521c2983f8e5",
"views": [
{
"id": "invoice-period-self-wash",
"name": "Invoice period XL-Vask autopilot",
"description": "Shows the previous self-wash import beside the new state-separated, evidence-led autopilot review workspace.",
"route": "/superuser/invoices?tab=period&periodView=self_wash&startDate=2026-03-01&endDate=2026-03-31",
"fixture": "Playwright mocked March 2026 invoice period with one uncertain match and one failed match",
"comparisons": {
"mobile": {
"width": 1081,
"height": 1999,
"before": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-mobile.png",
"sha256": "8c67915dd48847ef553db32f3fc2481e58af7490dab9a41c49728003b84df5a7",
"bytes": 141379,
"width": 1081,
"height": 1999,
"mimeType": "image/png",
"alt": "Mobile self-wash period import before the autopilot review workspace"
},
"after": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-mobile.png",
"sha256": "02b5d6560566350cbf702ff995602084a70ffdda13290c3754438885c3730a20",
"bytes": 142027,
"width": 1081,
"height": 1999,
"mimeType": "image/png",
"alt": "Mobile XL-Vask autopilot summary, filters, and visible review states"
}
},
"tablet": {
"width": 1536,
"height": 2048,
"before": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-tablet.png",
"sha256": "5a20daa875e211e1bfbfdd4e17282956720954ce14a9e76f1002b1355f795dd4",
"bytes": 179803,
"width": 1536,
"height": 2048,
"mimeType": "image/png",
"alt": "Tablet self-wash period import before the autopilot review workspace"
},
"after": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-tablet.png",
"sha256": "1916b97a6d3f7c0084130b3514fe67b5a8f729e23bca37c1ad5c7ed75f821d44",
"bytes": 198536,
"width": 1536,
"height": 2048,
"mimeType": "image/png",
"alt": "Tablet XL-Vask autopilot summary, filters, rows, and pagination"
}
},
"desktop": {
"width": 1280,
"height": 720,
"before": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/before-desktop.png",
"sha256": "617095bcb916dd2f25b5cd0e8d77e7b0b05f1b5db662aeb810b52d606bcbe0af",
"bytes": 94026,
"width": 1280,
"height": 720,
"mimeType": "image/png",
"alt": "Desktop self-wash period import before the autopilot review workspace"
},
"after": {
"path": "docs/pr-previews/xlvask-autopilot-20260803/invoice-period-self-wash/after-desktop.png",
"sha256": "dd33f7e07ca426ee63c4708d9994e2403375bae9a86c119d0d296f52e5e90a9a",
"bytes": 117715,
"width": 1280,
"height": 720,
"mimeType": "image/png",
"alt": "Desktop XL-Vask autopilot summary and state-separated review rows"
}
}
}
}
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"marketingVersion": "1.0.1",
"marketingVersion": "1.0.0",
"bundleId": "io.truckwash.app",
"minimumIosVersion": "15.0"
}
-28
View File
@@ -3534,19 +3534,6 @@ paths:
properties:
enabled:
type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404':
$ref: '#/components/responses/NotFound'
put:
@@ -3579,21 +3566,6 @@ paths:
properties:
message:
type: string
enabled:
type: boolean
auto_deactivation:
type: object
required: [at, timezone, label]
properties:
at:
type: string
format: date-time
nullable: true
timezone:
type: string
example: Europe/Copenhagen
label:
type: string
'404':
$ref: '#/components/responses/NotFound'
-142
View File
@@ -1,142 +0,0 @@
#!/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;
});
+20 -118
View File
@@ -1,5 +1,4 @@
import { execSync } from "node:child_process";
import { fileURLToPath } from "node:url";
function gitCommit() {
try {
@@ -9,140 +8,43 @@ function gitCommit() {
}
}
async function boundedJson(response, limit = 64 * 1024) {
const declared = Number(response.headers.get("content-length"));
if (Number.isFinite(declared) && declared > limit) throw new Error("Server version response is too large.");
const reader = response.body?.getReader();
const chunks = [];
let bytes = 0;
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > limit) {
await reader.cancel().catch(() => {});
throw new Error("Server version response is too large.");
}
chunks.push(Buffer.from(value));
}
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
export async function updateServerVersion(env = process.env, fetchImpl = globalThis.fetch) {
const serverToken = String(env.SERVER_UPDATE_TOKEN || "").trim();
const releaseGateToken = String(env.RELEASE_MANAGER_GATE_TOKEN || "").trim();
const token = serverToken || releaseGateToken;
const useReleaseGate = !serverToken && Boolean(releaseGateToken);
const required = env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
async function main() {
const token = process.env.SERVER_UPDATE_TOKEN;
const required = process.env.RELEASE_VERSION_UPDATE_REQUIRED === "true";
if (!token) {
if (required) {
throw new Error("SERVER_UPDATE_TOKEN or RELEASE_MANAGER_GATE_TOKEN is required after deploy verification.");
throw new Error("SERVER_UPDATE_TOKEN is required after deploy verification.");
}
console.log("No server version credential is set. Skipping server version update.");
console.log("SERVER_UPDATE_TOKEN is not set. Skipping server version update.");
return;
}
const version = String(
env.RELEASE_VERSION || env.RELEASE_EXPECTED_COMMIT || env.GITHUB_SHA || gitCommit()
).toLowerCase();
if (!/^[a-f0-9]{40}$/.test(version)) {
throw new Error("Server release version must be a full lowercase commit SHA.");
const version = process.env.RELEASE_VERSION || process.env.RELEASE_EXPECTED_COMMIT || process.env.GITHUB_SHA || gitCommit();
if (!version) {
throw new Error("Could not determine release version for server update.");
}
const baseUrl =
env.SERVER_UPDATE_URL ||
(useReleaseGate
? "https://api-v2.truckwash.io/master/api/release/gate/frontend-version"
: "https://api-v2.truckwash.io/master/api/worker/update-version");
const baseUrl = process.env.SERVER_UPDATE_URL || "https://api-v2.truckwash.io/master/api/worker/update-version";
const url = new URL(baseUrl);
if (url.protocol !== "https:" || url.username || url.password) {
throw new Error("SERVER_UPDATE_URL must be an HTTPS URL without embedded credentials.");
}
const request = {
method: useReleaseGate ? "POST" : "GET",
redirect: "manual",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${token}`,
"Cache-Control": "no-cache",
},
};
if (useReleaseGate) {
const repository = String(env.GITHUB_REPOSITORY || "copenhagentruckwash/pleno-vue").trim();
const branch = String(env.RELEASE_BRANCH || "master")
.trim()
.toLowerCase();
const buildId = String(env.RELEASE_BUILD_ID || env.RELEASE_EXPECTED_BUILD_ID || "").trim();
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
throw new Error("Frontend release repository is invalid.");
}
if (branch !== "master") {
throw new Error("Frontend release branch must be master.");
}
if (!/^[1-9][0-9]*-[1-9][0-9]*$/.test(buildId)) {
throw new Error("Frontend release build id is invalid.");
}
request.headers["Content-Type"] = "application/json";
request.body = JSON.stringify({ version, repository, branch, build_id: buildId });
} else {
url.searchParams.set("version", version);
}
url.searchParams.set("version", version);
const response = await fetchImpl(url, request);
const body = await boundedJson(response).catch(() => ({}));
if (!response.ok) {
throw new Error(`Server version update failed with HTTP ${response.status}.`);
}
const readUrl = new URL(
env.SERVER_VERSION_READ_URL ||
(useReleaseGate
? "https://api-v2.truckwash.io/master/api/release/gate/frontend-version"
: "https://api-v2.truckwash.io/master/api/worker/version")
);
if (
readUrl.protocol !== "https:" ||
readUrl.origin !== url.origin ||
readUrl.username ||
readUrl.password ||
readUrl.search ||
readUrl.hash
) {
throw new Error("SERVER_VERSION_READ_URL must be an exact HTTPS URL on the update origin.");
}
readUrl.searchParams.set("verify", `${Date.now()}`);
const verification = await fetchImpl(readUrl, {
const response = await fetch(url, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(30_000),
headers: {
Authorization: `Bearer ${token}`,
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
if (!verification.ok) {
throw new Error(`Server version read-back failed with HTTP ${verification.status}.`);
}
const verified = await boundedJson(verification);
const observed = String(verified?.data?.version ?? verified?.version ?? "").toLowerCase();
if (observed !== version) {
throw new Error(`Server version read-back did not match ${version}.`);
const body = await response.text();
if (!response.ok) {
throw new Error(`Server version update failed with HTTP ${response.status}: ${body}`);
}
console.log(`Server version updated to ${version}.`);
return { version, update: body, observed };
}
async function main() {
await updateServerVersion();
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack || error.message : error);
process.exit(1);
});
-2
View File
@@ -49,7 +49,6 @@ export const ownedFilesByRole = {
"userVehicles.spec.ts",
],
subuser: [
"driverAuth.spec.ts",
"subuserCompleteRegistration.spec.ts",
"subuserProfileContact.spec.ts",
"subuserProfileGrant.spec.ts",
@@ -122,7 +121,6 @@ export const ownedFilesByRole = {
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js",
"workfeed-config.smoke.spec.js",
"xlvask-flag-to-selvvash-navigation.spec.ts",
],
};
+1 -1
View File
@@ -98,7 +98,7 @@ const shouldHideGlobalOverlays = computed(
);
const sessionLoaderRoutePattern = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/;
const guestSessionLoaderRoutePattern =
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|subuser-access\/decision$|qr\/new-(?:customer|driver)$)/;
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/;
const currentBrowserPath = () => {
if (typeof window === "undefined") {
return "";
-27
View File
@@ -144,30 +144,3 @@ body:not(.pleno-large-table-headers) .table thead th {
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);
}
+86 -237
View File
@@ -1,59 +1,44 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, provide, reactive, readonly, ref, watch } from "vue";
import { computed, provide, reactive, watch } from "vue";
import BuefyTreeNode from "./BuefyTreeNode.vue";
type TreeNode = Record<string, any>;
type LoadNodeResult = {
children: TreeNode[];
failed: boolean;
cancelled: boolean;
};
type ExpandAllResult = {
expandedKeys: any[];
failedKeys: any[];
cancelled: boolean;
};
const EXPAND_ALL_CONCURRENCY = 4;
const props = withDefaults(
defineProps<{
data: TreeNode[];
fields?: Partial<{
id: string;
label: string;
children: string;
isLeaf: string;
disabled: string;
}>;
selectionMode?: "none" | "single" | "multiple" | "checkbox";
selected?: any;
expandedKeys?: any[];
checkedKeys?: any[];
defaultExpandAll?: boolean;
expandOnClickNode?: boolean;
lazy?: boolean;
load?: (_node: TreeNode) => Promise<TreeNode[]>;
progressiveBatchSize?: number;
loadMoreLabel?: string;
ariaLabel?: string;
}>(),
{
data: () => [],
fields: () => ({}),
selectionMode: "none",
selected: null,
expandedKeys: () => [],
checkedKeys: () => [],
defaultExpandAll: false,
expandOnClickNode: true,
lazy: false,
load: undefined,
progressiveBatchSize: 0,
loadMoreLabel: "Show {count} more",
ariaLabel: undefined,
}
);
const props = withDefaults(defineProps<{
data: TreeNode[];
fields?: Partial<{
id: string;
label: string;
children: string;
isLeaf: string;
disabled: string;
}>;
selectionMode?: "none" | "single" | "multiple" | "checkbox";
selected?: any;
expandedKeys?: any[];
checkedKeys?: any[];
defaultExpandAll?: boolean;
expandOnClickNode?: boolean;
lazy?: boolean;
load?: (_node: TreeNode) => Promise<TreeNode[]>;
progressiveBatchSize?: number;
loadMoreLabel?: string;
ariaLabel?: string;
}>(), {
data: () => [],
fields: () => ({}),
selectionMode: "none",
selected: null,
expandedKeys: () => [],
checkedKeys: () => [],
defaultExpandAll: false,
expandOnClickNode: true,
lazy: false,
load: undefined,
progressiveBatchSize: 0,
loadMoreLabel: "Show {count} more",
ariaLabel: undefined,
});
const emit = defineEmits<{
"update:selected": [value: any];
@@ -85,11 +70,6 @@ const state = reactive({
loadErrorKeys: [] as any[],
renderedLimits: {} as Record<string, number>,
});
const bulkExpanding = ref(false);
const inFlightLoads = new Map<string, Promise<LoadNodeResult>>();
let activeBulkExpansion: Promise<ExpandAllResult> | null = null;
let bulkOperationVersion = 0;
let dataVersion = 0;
const keyOf = (node: TreeNode) => node?.[resolvedFields.value.id];
const keyString = (key: any) => String(key ?? "");
@@ -98,8 +78,6 @@ const childrenOf = (node: TreeNode) => {
return Array.isArray(children) ? children : [];
};
const cachedChildrenOf = (node: TreeNode) => state.lazyChildrenCache[keyString(keyOf(node))] || [];
const hasCachedChildren = (node: TreeNode) =>
Object.prototype.hasOwnProperty.call(state.lazyChildrenCache, keyString(keyOf(node)));
const effectiveChildrenOf = (node: TreeNode) => {
const children = childrenOf(node);
return children.length > 0 ? children : cachedChildrenOf(node);
@@ -114,8 +92,7 @@ const visibleChildrenOf = (node: TreeNode) => {
const limit = state.renderedLimits[key] || progressiveBatchSize.value;
return children.slice(0, limit);
};
const remainingChildrenCount = (node: TreeNode) =>
Math.max(0, effectiveChildrenOf(node).length - visibleChildrenOf(node).length);
const remainingChildrenCount = (node: TreeNode) => Math.max(0, effectiveChildrenOf(node).length - visibleChildrenOf(node).length);
const hasMoreChildren = (node: TreeNode) => remainingChildrenCount(node) > 0;
const showMoreChildren = (node: TreeNode) => {
if (!hasMoreChildren(node)) {
@@ -125,8 +102,10 @@ const showMoreChildren = (node: TreeNode) => {
const currentLimit = state.renderedLimits[key] || progressiveBatchSize.value;
state.renderedLimits[key] = Math.min(effectiveChildrenOf(node).length, currentLimit + progressiveBatchSize.value);
};
const loadMoreLabelFor = (node: TreeNode) =>
props.loadMoreLabel.replace("{count}", String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node))));
const loadMoreLabelFor = (node: TreeNode) => props.loadMoreLabel.replace(
"{count}",
String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node)))
);
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
@@ -145,6 +124,18 @@ const collectKeys = (node: TreeNode): any[] => {
return keys;
};
const collectAllExpandableKeys = (nodes: TreeNode[]): any[] => {
const keys: any[] = [];
nodes.forEach((node) => {
const key = keyOf(node);
if (key !== undefined && key !== null && !isLeaf(node)) {
keys.push(key);
}
keys.push(...collectAllExpandableKeys(childrenOf(node)));
});
return keys;
};
watch(
() => props.selected,
(value) => {
@@ -168,6 +159,18 @@ watch(
{ deep: true }
);
watch(
() => props.data,
(nodes) => {
state.renderedLimits = {};
if (props.defaultExpandAll) {
state.expandedKeys = collectAllExpandableKeys(nodes);
emit("update:expandedKeys", [...state.expandedKeys]);
}
},
{ immediate: true, deep: true }
);
const setExpandedKeys = (keys: any[]) => {
state.expandedKeys = [...keys];
emit("update:expandedKeys", [...state.expandedKeys]);
@@ -178,146 +181,33 @@ const setCheckedKeys = (keys: any[]) => {
emit("update:checkedKeys", [...state.checkedKeys]);
};
const loadNode = async (node: TreeNode): Promise<LoadNodeResult> => {
const loadNode = async (node: TreeNode) => {
if (!props.lazy || !props.load) {
return { children: effectiveChildrenOf(node), failed: false, cancelled: false };
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return { children: [], failed: false, cancelled: false };
return;
}
const cacheKey = keyString(key);
if (childrenOf(node).length > 0 || hasCachedChildren(node)) {
return { children: effectiveChildrenOf(node), failed: false, cancelled: false };
}
const inFlight = inFlightLoads.get(cacheKey);
if (inFlight) {
return inFlight;
if (state.loadingKeys.includes(key)) {
return;
}
const requestedDataVersion = dataVersion;
let loadPromise: Promise<LoadNodeResult>;
loadPromise = (async () => {
state.loadingKeys = [...new Set([...state.loadingKeys, key])];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
emit("load-start", node, key);
state.loadingKeys = [...state.loadingKeys, key];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
emit("load-start", node, key);
try {
const loadedChildren = await props.load(node);
if (requestedDataVersion !== dataVersion) {
return { children: [], failed: false, cancelled: true };
}
const children = Array.isArray(loadedChildren) ? loadedChildren : [];
state.lazyChildrenCache[cacheKey] = children;
if (progressiveBatchSize.value > 0) {
state.renderedLimits[cacheKey] = progressiveBatchSize.value;
}
return { children, failed: false, cancelled: false };
} catch (error) {
if (requestedDataVersion !== dataVersion) {
return { children: [], failed: false, cancelled: true };
}
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
emit("load-error", error, node, key);
return { children: [], failed: true, cancelled: false };
} finally {
if (requestedDataVersion === dataVersion) {
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
}
if (inFlightLoads.get(cacheKey) === loadPromise) {
inFlightLoads.delete(cacheKey);
}
try {
state.lazyChildrenCache[keyString(key)] = await props.load(node);
if (progressiveBatchSize.value > 0) {
state.renderedLimits[keyString(key)] = progressiveBatchSize.value;
}
})();
inFlightLoads.set(cacheKey, loadPromise);
return loadPromise;
};
const mapWithConcurrency = async <T>(items: T[], concurrency: number, callback: (item: T) => Promise<void>) => {
let nextIndex = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (nextIndex < items.length) {
const item = items[nextIndex];
nextIndex += 1;
await callback(item);
}
});
await Promise.all(workers);
};
const expandAll = (): Promise<ExpandAllResult> => {
if (activeBulkExpansion) {
return activeBulkExpansion;
} catch (error) {
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
emit("load-error", error, node, key);
} finally {
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
}
const operationVersion = ++bulkOperationVersion;
bulkExpanding.value = true;
const expansion = (async (): Promise<ExpandAllResult> => {
const expandedKeys: any[] = [];
const failedKeys: any[] = [];
const visited = new Set<string>();
let currentLevel = [...props.data];
while (currentLevel.length > 0 && operationVersion === bulkOperationVersion) {
const nextLevel: TreeNode[] = [];
await mapWithConcurrency(currentLevel, EXPAND_ALL_CONCURRENCY, async (node) => {
if (operationVersion !== bulkOperationVersion || isDisabled(node) || isLeaf(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
const normalizedKey = keyString(key);
if (visited.has(normalizedKey)) {
return;
}
visited.add(normalizedKey);
expandedKeys.push(key);
const result = await loadNode(node);
if (operationVersion !== bulkOperationVersion || result.cancelled) {
return;
}
if (result.failed) {
failedKeys.push(key);
return;
}
state.renderedLimits[normalizedKey] = result.children.length;
nextLevel.push(...result.children);
});
currentLevel = nextLevel;
}
const cancelled = operationVersion !== bulkOperationVersion;
if (!cancelled) {
setExpandedKeys(expandedKeys);
}
return {
expandedKeys: cancelled ? [...state.expandedKeys] : expandedKeys,
failedKeys,
cancelled,
};
})();
activeBulkExpansion = expansion;
void expansion.finally(() => {
if (activeBulkExpansion === expansion) {
activeBulkExpansion = null;
}
if (operationVersion === bulkOperationVersion) {
bulkExpanding.value = false;
}
});
return expansion;
};
const collapseAll = () => {
bulkOperationVersion += 1;
activeBulkExpansion = null;
bulkExpanding.value = false;
state.renderedLimits = {};
setExpandedKeys([]);
};
const ensureChildrenLoadedForCheck = async (node: TreeNode) => {
@@ -422,7 +312,9 @@ const handleNodeClick = async (node: TreeNode) => {
emit("select", node, key);
} else if (props.selectionMode === "multiple") {
const selected = Array.isArray(state.selected) ? state.selected : [];
state.selected = selected.includes(key) ? selected.filter((current) => current !== key) : [...selected, key];
state.selected = selected.includes(key)
? selected.filter((current) => current !== key)
: [...selected, key];
emit("update:selected", state.selected);
emit("select", node, key);
}
@@ -431,49 +323,6 @@ const handleNodeClick = async (node: TreeNode) => {
}
};
watch(
() => props.data,
() => {
dataVersion += 1;
bulkOperationVersion += 1;
activeBulkExpansion = null;
bulkExpanding.value = false;
inFlightLoads.clear();
state.lazyChildrenCache = {};
state.loadingKeys = [];
state.loadErrorKeys = [];
state.renderedLimits = {};
setExpandedKeys([]);
if (props.defaultExpandAll) {
void nextTick(() => expandAll());
}
},
{ immediate: true }
);
watch(
() => props.defaultExpandAll,
(shouldExpand, wasExpanded) => {
if (shouldExpand && !wasExpanded) {
void expandAll();
}
}
);
defineExpose({
bulkExpanding: readonly(bulkExpanding),
collapseAll,
expandAll,
});
onBeforeUnmount(() => {
dataVersion += 1;
bulkOperationVersion += 1;
activeBulkExpansion = null;
bulkExpanding.value = false;
inFlightLoads.clear();
});
provide("BuefyTreeContext", {
props,
state,
@@ -498,7 +347,7 @@ provide("BuefyTreeContext", {
</script>
<template>
<ul class="b-tree" role="tree" :aria-label="ariaLabel" :aria-busy="bulkExpanding || undefined">
<ul class="b-tree" role="tree" :aria-label="ariaLabel">
<BuefyTreeNode
v-for="(node, index) in data"
:key="keyOf(node) ?? index"
+1 -2
View File
@@ -96,7 +96,6 @@ onBeforeUnmount(disconnectLoadMoreObserver);
>
<div class="b-tree-node-content" @click.stop="tree.handleNodeClick(node)">
<button
v-if="node.hideToggle !== true"
type="button"
class="button is-white is-small b-tree-node-toggle"
:class="{ 'is-invisible': !hasExpandToggle }"
@@ -123,7 +122,7 @@ onBeforeUnmount(disconnectLoadMoreObserver);
/>
</span>
<span v-if="node.hideIcon !== true" class="b-tree-node-icon">
<span class="b-tree-node-icon">
<slot name="icon" :node="node" :expanded="isExpanded" :loading="isLoading" :error="hasLoadError">
<span class="icon is-small">
<i class="fas" :class="hasExpandToggle ? (isExpanded ? 'fa-folder-open' : 'fa-folder') : 'fa-file'"></i>
@@ -62,8 +62,6 @@ const onInput = async (value) => {
await props.changeAction(nextValue);
}
root.value?.dispatchEvent(new CustomEvent("dropdown-action-selected", { bubbles: true }));
} catch {
localValue.value = normalizeSelectValue(props.value);
} finally {
isProcessing.value = false;
}
@@ -6,17 +6,20 @@ import { StripeModule } from "@/components/stripe/StripeModule.vue";
import { Colors } from "@/ThemeConfig.vue";
import NextStep from "@/components/forms/department/pos/buttons/NextStep.vue";
import PrintInvoiceFromOrderItems from "@/components/forms/department/pos/buttons/PrintInvoiceFromOrderItems.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import StripeTerminalReaderSelect from "@/components/displays/department/pos/displays/StripeTerminalReaderSelect.vue";
import { getOrder } from "@/components/shop/Orders.vue";
import {
getStripeTerminalReaderById,
normalizeStripeTerminalReaders,
selectPreferredStripeTerminalReaderId,
STRIPE_TERMINAL_STATUS,
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
import { normalizeStripeInvoice } from "@/components/displays/department/pos/displays/stripeEmailInvoice.js";
const POLLING_INTERVAL_MS = 5000;
const STRIPE_TERMINAL_SETUP_REQUIRED_CODE = 'stripe_terminal_setup_required';
const CARD_PAYMENT_TAX_PERCENTAGE = 25;
const STRIPE_EMAIL_POLLING_INTERVAL_OVERRIDE_KEY = '__TW_POS_STRIPE_EMAIL_POLLING_INTERVAL_MS__';
const props = defineProps({
departmentId: {
@@ -47,6 +50,16 @@ const props = defineProps({
const { t } = useI18n();
const stripeText = (key, params = {}) => t(`admin.pos.stripe.${key}`, params);
const getStripeEmailPollingIntervalMs = () => {
if (typeof window === 'undefined') {
return POLLING_INTERVAL_MS;
}
const override = Number(window[STRIPE_EMAIL_POLLING_INTERVAL_OVERRIDE_KEY]);
return Number.isFinite(override) && override > 0
? override
: POLLING_INTERVAL_MS;
};
const readers = ref([]);
const readersError = ref(null);
@@ -58,15 +71,229 @@ const selectedReaderId = ref('');
const loadingDeleteButton = ref(false);
const isNotifyingPaymentSuccess = ref(false);
const notifiedPaymentIntentId = ref(null);
const autoCapturePaymentIntentId = ref(null);
const operationState = ref('idle');
const pollingEnabled = ref(true);
const stripeInvoice = ref(null);
const emailAddress = ref('');
const emailPanelState = ref('idle');
const emailPaymentError = ref(null);
const isCompletingPaidEmailOrder = ref(false);
const taxRates = computed(() => ([
{ id: 1, display_name: stripeText('tax_rates.danish_customer'), percentage: 25 },
{ id: 2, display_name: stripeText('tax_rates.eu_customer'), percentage: 0 },
]));
const selectedTaxRate = ref(1);
const paymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const isTerminalPaymentCaptured = computed(() => StripeModule.paymentIntents.isPaymentIntentAmountReceived(paymentIntent.value));
const shouldShowDesktopCompletion = computed(() => isTerminalPaymentCaptured.value);
const hasStripeEmailInvoice = computed(() => stripeInvoice.value !== null);
const isStripeEmailInvoicePaid = computed(() => stripeInvoice.value?.paid === true);
const isStripeEmailInvoiceTerminalState = computed(() => {
const status = String(stripeInvoice.value?.status ?? '');
return ['paid', 'void', 'uncollectible', 'deleted'].includes(status);
});
const isEmailFormVisible = computed(() => {
return ['composing', 'sending'].includes(emailPanelState.value) && !hasStripeEmailInvoice.value;
});
const isSendingEmailPayment = computed(() => emailPanelState.value === 'sending');
const isCancellingEmailPayment = computed(() => emailPanelState.value === 'cancelling');
const shouldShowEmailActionButton = computed(() => emailPanelState.value === 'idle' && !hasStripeEmailInvoice.value);
const canCancelEmailPayment = computed(() => {
if (isSendingEmailPayment.value || isCancellingEmailPayment.value) {
return false;
}
if (hasStripeEmailInvoice.value) {
return !isStripeEmailInvoicePaid.value && !isStripeEmailInvoiceTerminalState.value;
}
return emailPanelState.value === 'composing';
});
const canCompletePaidEmailOrder = computed(() => {
return props.mobileMode && isStripeEmailInvoicePaid.value && typeof props.onPaymentSuccess === 'function';
});
const stripeEmailStatusToneClass = computed(() => {
switch (String(stripeInvoice.value?.status ?? 'unknown')) {
case 'paid':
return 'is-success';
case 'open':
case 'draft':
return 'is-warning';
case 'void':
case 'uncollectible':
case 'deleted':
return 'is-light';
default:
return 'is-light';
}
});
const stripeEmailStatusMessage = computed(() => {
if (!hasStripeEmailInvoice.value) {
return stripeText('email.help');
}
if (isStripeEmailInvoicePaid.value) {
return stripeText('email.messages.paid');
}
if (isStripeEmailInvoiceTerminalState.value) {
return stripeText('email.messages.closed');
}
return stripeText('email.messages.sent');
});
const shouldShowDesktopCompletion = computed(() => {
return isTerminalPaymentCaptured.value || isStripeEmailInvoicePaid.value;
});
const shouldShowDesktopReceipt = computed(() => isTerminalPaymentCaptured.value);
const getStripeEmailStatusLabel = () => {
switch (String(stripeInvoice.value?.status ?? 'unknown')) {
case 'open':
return stripeText('email.states.open');
case 'paid':
return stripeText('email.states.paid');
case 'void':
return stripeText('email.states.void');
case 'uncollectible':
return stripeText('email.states.uncollectible');
case 'deleted':
return stripeText('email.states.deleted');
case 'draft':
return stripeText('email.states.draft');
default:
return stripeText('email.states.unknown');
}
};
const isValidEmailAddress = (value) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value ?? '').trim());
};
const loadStripeInvoiceState = async () => {
if (!props.order_id) {
stripeInvoice.value = null;
return null;
}
try {
const response = await getOrder(props.order_id, true);
const nextInvoice = normalizeStripeInvoice(response?.data?.includes?.stripeModuleOrders, props.order_id);
stripeInvoice.value = nextInvoice;
if (nextInvoice) {
emailPanelState.value = 'tracking';
} else if (emailPanelState.value === 'tracking') {
emailPanelState.value = 'idle';
}
emailPaymentError.value = null;
return nextInvoice;
} catch (requestError) {
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.load');
return stripeInvoice.value;
}
};
const openEmailPaymentPanel = () => {
emailPaymentError.value = null;
emailPanelState.value = 'composing';
};
const resetEmailPaymentPanel = () => {
stripeInvoice.value = null;
emailAddress.value = '';
emailPaymentError.value = null;
emailPanelState.value = 'idle';
isCompletingPaidEmailOrder.value = false;
};
const cancelEmailPayment = async () => {
if (!hasStripeEmailInvoice.value) {
emailAddress.value = '';
emailPaymentError.value = null;
emailPanelState.value = 'idle';
return;
}
emailPanelState.value = 'cancelling';
emailPaymentError.value = null;
try {
await authenticatedRequest('/modules/stripe/invoice', 'DELETE', {
order_id: props.order_id,
});
resetEmailPaymentPanel();
} catch (requestError) {
const responseData = requestError?.response?.data?.data || {};
stripeInvoice.value = normalizeStripeInvoice(responseData?.stripeModuleOrders) || stripeInvoice.value;
emailPanelState.value = 'tracking';
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.cancel');
}
};
const sendStripeInvoiceByEmail = async () => {
const normalizedEmail = String(emailAddress.value ?? '').trim();
if (!isValidEmailAddress(normalizedEmail)) {
emailPaymentError.value = stripeText('email.errors.invalid_email');
emailPanelState.value = 'composing';
return;
}
emailPanelState.value = 'sending';
emailPaymentError.value = null;
try {
const response = await authenticatedRequest('/modules/stripe/invoice', 'POST', {
email: normalizedEmail,
order_id: props.order_id,
});
emailAddress.value = '';
const refreshedInvoice = await loadStripeInvoiceState();
if (!refreshedInvoice) {
stripeInvoice.value = normalizeStripeInvoice({
id: props.order_id,
invoice_id: response?.data?.data?.id ?? null,
customer_id: response?.data?.data?.customer ?? null,
url: response?.data?.data?.hosted_invoice_url ?? null,
paid: Boolean(response?.data?.data?.paid),
status: response?.data?.data?.status ?? 'open',
amount_due: response?.data?.data?.amount_due ?? 0,
amount_paid: response?.data?.data?.amount_paid ?? 0,
});
emailPanelState.value = stripeInvoice.value ? 'tracking' : 'idle';
}
} catch (requestError) {
const responseData = requestError?.response?.data?.data || {};
if (requestError?.response?.status === 409 && responseData?.code === 'stripe_invoice_exists') {
stripeInvoice.value = normalizeStripeInvoice(responseData?.stripeModuleOrders) || await loadStripeInvoiceState();
emailPanelState.value = stripeInvoice.value ? 'tracking' : 'idle';
emailPaymentError.value = stripeText('email.messages.active');
return;
}
emailPanelState.value = 'composing';
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.send');
}
};
const completePaidEmailOrder = async () => {
if (!canCompletePaidEmailOrder.value) {
return;
}
isCompletingPaidEmailOrder.value = true;
emailPaymentError.value = null;
try {
await Promise.resolve(props.onPaymentSuccess(stripeInvoice.value));
} catch (requestError) {
emailPaymentError.value = SessionUser.functions.parseErrorMessage(requestError) || stripeText('email.errors.complete');
} finally {
isCompletingPaidEmailOrder.value = false;
}
};
const normalizeErrorState = (errorLike, fallbackMessage, source) => {
const responsePayload = errorLike?.response?.data?.data;
const directPayload = errorLike?.data?.data ?? errorLike?.data;
@@ -88,6 +315,11 @@ const normalizeErrorState = (errorLike, fallbackMessage, source) => {
};
};
const getTaxRatePercentage = (taxRateId) => {
const taxRate = taxRates.value.find((rate) => rate.id === taxRateId);
return taxRate ? taxRate.percentage : 0;
};
const clearErrorState = (...sources) => {
const sourceSet = new Set(sources);
if (sourceSet.has('readers')) {
@@ -125,7 +357,7 @@ const paymentIntentReaderId = computed(() => {
const paymentIntentTaxPercentage = computed(() => {
const value = paymentIntent.value?.metadata?.tax_percentage;
return value === undefined || value === null
? CARD_PAYMENT_TAX_PERCENTAGE
? getTaxRatePercentage(selectedTaxRate.value)
: Number(value);
});
@@ -257,7 +489,18 @@ const selectedReaderLabel = computed(() => {
return stripeText('reader.no_selection');
});
const formatTaxRateLabel = (taxRate) => {
return stripeText('tax_rates.option', {
name: taxRate.display_name,
percentage: taxRate.percentage,
});
};
const paymentTaxSummary = computed(() => {
const matchingRate = taxRates.value.find((rate) => Number(rate.percentage) === Number(paymentIntentTaxPercentage.value));
if (matchingRate) {
return formatTaxRateLabel(matchingRate);
}
return stripeText('tax_rates.fallback', { percentage: paymentIntentTaxPercentage.value });
});
@@ -276,7 +519,9 @@ const primaryActionLabel = computed(() => {
case 'waiting_for_reader':
return stripeText('actions.refresh_payment_status');
case 'ready_to_capture':
return stripeText('states.capturing');
return paymentAmountSummary.value
? stripeText('actions.capture_payment_with_amount', { amount: paymentAmountSummary.value })
: stripeText('actions.capture_payment');
case 'succeeded':
return stripeText('actions.show_payment_details');
case 'failed':
@@ -297,8 +542,9 @@ const primaryActionTestId = computed(() => {
case 'reader_unavailable':
return 'pos-stripe-no-readers';
case 'waiting_for_reader':
case 'ready_to_capture':
return 'pos-stripe-payment-in-progress';
case 'ready_to_capture':
return 'pos-stripe-capture-intent';
case 'succeeded':
return 'pos-stripe-payment-succeeded';
case 'failed':
@@ -324,6 +570,7 @@ const canCancelPaymentIntent = computed(() => {
});
const shouldShowReaderSelection = computed(() => paymentIntent.value === null);
const shouldShowTaxSelection = computed(() => paymentIntent.value === null);
const attemptAutomaticReaderSelection = (readerList) => {
selectedReaderId.value = selectPreferredStripeTerminalReaderId(
@@ -410,7 +657,7 @@ const onClickCreatePaymentIntent = async () => {
const response = await StripeModule.paymentIntents.createPaymentIntent(
selectedReader.value.reader,
props.order_id,
CARD_PAYMENT_TAX_PERCENTAGE,
getTaxRatePercentage(selectedTaxRate.value),
);
if (response?.status !== 200) {
@@ -436,7 +683,6 @@ const onClickCapturePaymentIntent = async () => {
try {
const response = await StripeModule.paymentIntents.capturePaymentIntent(props.order_id);
if (response?.status !== 200) {
autoCapturePaymentIntentId.value = null;
actionError.value = normalizeErrorState(response, stripeText('errors.capture_payment_intent'), 'action');
return;
}
@@ -444,7 +690,6 @@ const onClickCapturePaymentIntent = async () => {
clearErrorState('payment');
await getStripeReaders();
} catch (requestError) {
autoCapturePaymentIntentId.value = null;
actionError.value = normalizeErrorState(requestError, stripeText('errors.capture_payment_intent'), 'action');
console.error('Error capturing payment intent:', requestError);
} finally {
@@ -510,6 +755,7 @@ const onPrimaryAction = async () => {
await retryLoadState();
return;
case 'ready_to_capture':
await onClickCapturePaymentIntent();
return;
case 'succeeded':
showPaymentDetails(paymentIntent.value);
@@ -550,38 +796,26 @@ const maybeNotifyPaymentSuccess = async (stripePaymentIntent) => {
watch(paymentIntent, (nextPaymentIntent) => {
if (!nextPaymentIntent) {
notifiedPaymentIntentId.value = null;
autoCapturePaymentIntentId.value = null;
return;
}
maybeNotifyPaymentSuccess(nextPaymentIntent);
}, { deep: true });
watch([currentIntentState, operationState, currentError], ([nextState]) => {
if (
nextState !== 'ready_to_capture'
|| operationState.value !== 'idle'
|| currentError.value
|| !paymentIntent.value?.id
|| autoCapturePaymentIntentId.value === paymentIntent.value.id
) {
return;
}
autoCapturePaymentIntentId.value = paymentIntent.value.id;
onClickCapturePaymentIntent();
}, { immediate: true });
watch(() => props.order_id, async () => {
clearErrorState('readers', 'payment', 'action');
operationState.value = 'idle';
pollingEnabled.value = true;
notifiedPaymentIntentId.value = null;
autoCapturePaymentIntentId.value = null;
await syncCurrentState();
resetEmailPaymentPanel();
await Promise.all([
syncCurrentState(),
loadStripeInvoiceState(),
]);
});
let pollingIntervalId = null;
let emailPollingIntervalId = null;
const clearPollingInterval = () => {
if (pollingIntervalId !== null) {
@@ -590,6 +824,13 @@ const clearPollingInterval = () => {
}
};
const clearEmailPollingInterval = () => {
if (emailPollingIntervalId !== null) {
window.clearInterval(emailPollingIntervalId);
emailPollingIntervalId = null;
}
};
const shouldPoll = computed(() => {
return pollingEnabled.value
&& paymentFlowState.value !== 'succeeded'
@@ -613,6 +854,25 @@ watch(shouldPoll, (nextShouldPoll) => {
}, POLLING_INTERVAL_MS);
}, { immediate: true });
const shouldPollStripeEmailInvoice = computed(() => {
return hasStripeEmailInvoice.value
&& emailPanelState.value === 'tracking'
&& !isStripeEmailInvoiceTerminalState.value
&& !isCancellingEmailPayment.value;
});
watch(shouldPollStripeEmailInvoice, (nextShouldPoll) => {
clearEmailPollingInterval();
if (!nextShouldPoll) {
return;
}
emailPollingIntervalId = window.setInterval(() => {
loadStripeInvoiceState();
}, getStripeEmailPollingIntervalMs());
}, { immediate: true });
watch(paymentFlowState, (nextState) => {
if (nextState === 'succeeded') {
pollingEnabled.value = false;
@@ -622,11 +882,15 @@ watch(paymentFlowState, (nextState) => {
onMounted(async () => {
pollingEnabled.value = true;
await syncCurrentState();
await Promise.all([
syncCurrentState(),
loadStripeInvoiceState(),
]);
});
onUnmounted(() => {
clearPollingInterval();
clearEmailPollingInterval();
});
</script>
@@ -667,6 +931,19 @@ onUnmounted(() => {
/>
</div>
<div v-if="shouldShowTaxSelection" class="field">
<label class="label is-size-7">{{ stripeText('tax_profile') }}</label>
<div class="select is-fullwidth">
<select v-model="selectedTaxRate" data-testid="pos-stripe-tax-select">
<template v-for="taxRate in taxRates" :key="taxRate.id">
<option :value="taxRate.id">
{{ formatTaxRateLabel(taxRate) }}
</option>
</template>
</select>
</div>
</div>
<div v-if="currentErrorMessage" class="notification is-danger is-light" data-testid="pos-stripe-error-message">
{{ currentErrorMessage }}
</div>
@@ -700,6 +977,123 @@ onUnmounted(() => {
{{ stripeText('actions.cancel_payment') }}
</button>
<div class="mobile-stripe-payment__email">
<div class="pos-stripe-email-panel" data-testid="pos-stripe-email-panel">
<button
v-if="shouldShowEmailActionButton"
class="button is-fullwidth pos-stripe-email-panel__trigger"
type="button"
data-testid="pos-stripe-email-action"
@click="openEmailPaymentPanel"
>
<span class="icon is-small">
<i class="fas fa-envelope"></i>
</span>
<span>{{ stripeText('email.action') }}</span>
</button>
<div v-else class="pos-stripe-email-panel__body">
<div class="pos-stripe-email-panel__header">
<div>
<p class="pos-stripe-email-panel__eyebrow">{{ stripeText('email.eyebrow') }}</p>
<p class="pos-stripe-email-panel__title">{{ stripeText('email.title') }}</p>
</div>
<span
v-if="hasStripeEmailInvoice"
class="tag pos-stripe-email-panel__status-tag"
:class="stripeEmailStatusToneClass"
data-testid="pos-stripe-email-status"
>
{{ getStripeEmailStatusLabel() }}
</span>
</div>
<p class="pos-stripe-email-panel__help">
{{ stripeEmailStatusMessage }}
</p>
<div v-if="isEmailFormVisible" class="pos-stripe-email-panel__form">
<label class="label is-size-7" for="pos-stripe-email-input-mobile">
{{ stripeText('email.address_label') }}
</label>
<div class="field has-addons pos-stripe-email-panel__field">
<div class="control is-expanded">
<input
id="pos-stripe-email-input-mobile"
v-model="emailAddress"
class="input"
type="email"
:placeholder="stripeText('email.address_placeholder')"
autocomplete="email"
data-testid="pos-stripe-email-input"
@keydown.enter.prevent="sendStripeInvoiceByEmail"
>
</div>
<div class="control">
<button
class="button is-link"
type="button"
data-testid="pos-stripe-email-submit"
:class="{ 'is-loading': isSendingEmailPayment }"
:disabled="isSendingEmailPayment"
@click="sendStripeInvoiceByEmail"
>
{{ stripeText('email.submit') }}
</button>
</div>
</div>
</div>
<div v-else-if="hasStripeEmailInvoice" class="pos-stripe-email-panel__meta">
<div class="pos-stripe-email-panel__meta-row">
<span>{{ stripeText('email.status_label') }}</span>
<strong>{{ getStripeEmailStatusLabel() }}</strong>
</div>
<div v-if="stripeInvoice?.url" class="pos-stripe-email-panel__meta-row">
<span>{{ stripeText('email.invoice_link_label') }}</span>
<a :href="stripeInvoice.url" target="_blank" rel="noopener noreferrer">
{{ stripeText('email.invoice_link_action') }}
</a>
</div>
</div>
<p
v-if="emailPaymentError"
class="help is-danger pos-stripe-email-panel__error"
data-testid="pos-stripe-email-error"
>
{{ emailPaymentError }}
</p>
<div
v-if="canCancelEmailPayment || canCompletePaidEmailOrder"
class="pos-stripe-email-panel__actions"
>
<button
v-if="canCancelEmailPayment"
class="button is-light is-fullwidth"
type="button"
data-testid="pos-stripe-email-cancel"
:class="{ 'is-loading': isCancellingEmailPayment }"
@click="cancelEmailPayment"
>
{{ t('common.cancel') }}
</button>
<button
v-if="canCompletePaidEmailOrder"
class="button is-success is-fullwidth"
type="button"
data-testid="pos-stripe-email-complete-order"
:class="{ 'is-loading': isCompletingPaidEmailOrder }"
@click="completePaidEmailOrder"
>
{{ t('admin.pos.complete') }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -759,6 +1153,20 @@ onUnmounted(() => {
</div>
<template v-else>
<template v-if="shouldShowTaxSelection">
<div class="pos-stripe-desktop__item" data-testid="pos-stripe-desktop-tax-field">
<div class="select is-fullwidth">
<select v-model="selectedTaxRate" data-testid="pos-stripe-tax-select">
<template v-for="taxRate in taxRates" :key="taxRate.id">
<option :value="taxRate.id">
{{ formatTaxRateLabel(taxRate) }}
</option>
</template>
</select>
</div>
</div>
</template>
<template v-if="shouldShowReaderSelection">
<div class="pos-stripe-desktop__item" data-testid="pos-stripe-desktop-reader-field">
<StripeTerminalReaderSelect
@@ -814,6 +1222,21 @@ onUnmounted(() => {
</button>
</template>
<template v-else-if="paymentFlowState === 'ready_to_capture'">
<button
class="button is-fullwidth"
type="button"
data-testid="pos-stripe-capture-intent"
:style="{ 'background-color': Colors.buttons.success.backgroundColor, 'color': Colors.buttons.success.textColor }"
@click="onClickCapturePaymentIntent"
>
<span class="icon is-small">
<i class="fas fa-credit-card"></i>
</span>
<span>{{ primaryActionLabel }}</span>
</button>
</template>
<template v-else-if="paymentFlowState === 'succeeded'">
<button
class="button is-text is-fullwidth pos-stripe-desktop__text-button"
@@ -866,6 +1289,124 @@ onUnmounted(() => {
</template>
</template>
<div class="pos-stripe-desktop__item">
<div class="pos-stripe-email-panel" data-testid="pos-stripe-email-panel">
<button
v-if="shouldShowEmailActionButton"
class="button is-fullwidth pos-stripe-email-panel__trigger"
type="button"
data-testid="pos-stripe-email-action"
@click="openEmailPaymentPanel"
>
<span class="icon is-small">
<i class="fas fa-envelope"></i>
</span>
<span>{{ stripeText('email.action') }}</span>
</button>
<div v-else class="pos-stripe-email-panel__body">
<div class="pos-stripe-email-panel__header">
<div>
<p class="pos-stripe-email-panel__eyebrow">{{ stripeText('email.eyebrow') }}</p>
<p class="pos-stripe-email-panel__title">{{ stripeText('email.title') }}</p>
</div>
<span
v-if="hasStripeEmailInvoice"
class="tag pos-stripe-email-panel__status-tag"
:class="stripeEmailStatusToneClass"
data-testid="pos-stripe-email-status"
>
{{ getStripeEmailStatusLabel() }}
</span>
</div>
<p class="pos-stripe-email-panel__help">
{{ stripeEmailStatusMessage }}
</p>
<div v-if="isEmailFormVisible" class="pos-stripe-email-panel__form">
<label class="label is-size-7" for="pos-stripe-email-input-desktop">
{{ stripeText('email.address_label') }}
</label>
<div class="field has-addons pos-stripe-email-panel__field">
<div class="control is-expanded">
<input
id="pos-stripe-email-input-desktop"
v-model="emailAddress"
class="input"
type="email"
:placeholder="stripeText('email.address_placeholder')"
autocomplete="email"
data-testid="pos-stripe-email-input"
@keydown.enter.prevent="sendStripeInvoiceByEmail"
>
</div>
<div class="control">
<button
class="button is-link"
type="button"
data-testid="pos-stripe-email-submit"
:class="{ 'is-loading': isSendingEmailPayment }"
:disabled="isSendingEmailPayment"
@click="sendStripeInvoiceByEmail"
>
{{ stripeText('email.submit') }}
</button>
</div>
</div>
</div>
<div v-else-if="hasStripeEmailInvoice" class="pos-stripe-email-panel__meta">
<div class="pos-stripe-email-panel__meta-row">
<span>{{ stripeText('email.status_label') }}</span>
<strong>{{ getStripeEmailStatusLabel() }}</strong>
</div>
<div v-if="stripeInvoice?.url" class="pos-stripe-email-panel__meta-row">
<span>{{ stripeText('email.invoice_link_label') }}</span>
<a :href="stripeInvoice.url" target="_blank" rel="noopener noreferrer">
{{ stripeText('email.invoice_link_action') }}
</a>
</div>
</div>
<p
v-if="emailPaymentError"
class="help is-danger pos-stripe-email-panel__error"
data-testid="pos-stripe-email-error"
>
{{ emailPaymentError }}
</p>
<div
v-if="canCancelEmailPayment || canCompletePaidEmailOrder"
class="pos-stripe-email-panel__actions"
>
<button
v-if="canCancelEmailPayment"
class="button is-light is-fullwidth"
type="button"
data-testid="pos-stripe-email-cancel"
:class="{ 'is-loading': isCancellingEmailPayment }"
@click="cancelEmailPayment"
>
{{ t('common.cancel') }}
</button>
<button
v-if="canCompletePaidEmailOrder"
class="button is-success is-fullwidth"
type="button"
data-testid="pos-stripe-email-complete-order"
:class="{ 'is-loading': isCompletingPaidEmailOrder }"
@click="completePaidEmailOrder"
>
{{ t('admin.pos.complete') }}
</button>
</div>
</div>
</div>
</div>
<template v-if="shouldShowDesktopCompletion">
<div class="pos-stripe-desktop__item">
<NextStep class="is-fullwidth" :label="t('admin.pos.complete')" />
@@ -943,6 +1484,10 @@ onUnmounted(() => {
margin-top: 0.5rem;
}
.mobile-stripe-payment__email {
margin-top: 0.75rem;
}
.select.is-fullwidth,
.select.is-fullwidth select {
width: 100%;
@@ -1006,4 +1551,92 @@ onUnmounted(() => {
justify-content: center;
}
.pos-stripe-email-panel {
width: 100%;
}
.pos-stripe-email-panel__trigger {
font-weight: 700;
}
.pos-stripe-email-panel__body {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 16px;
padding: 0.85rem;
background: rgba(248, 250, 252, 0.9);
}
.pos-stripe-email-panel__header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
}
.pos-stripe-email-panel__eyebrow {
margin: 0;
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6b7280;
}
.pos-stripe-email-panel__title {
margin: 0.15rem 0 0;
font-size: 1rem;
font-weight: 700;
color: #0f172a;
}
.pos-stripe-email-panel__status-tag {
margin-left: auto;
}
.pos-stripe-email-panel__help {
margin: 0.65rem 0 0;
color: #475569;
line-height: 1.45;
}
.pos-stripe-email-panel__form,
.pos-stripe-email-panel__meta,
.pos-stripe-email-panel__actions {
margin-top: 0.75rem;
}
.pos-stripe-email-panel__field {
margin-bottom: 0;
}
.pos-stripe-email-panel__meta {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.pos-stripe-email-panel__meta-row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
font-size: 0.92rem;
}
.pos-stripe-email-panel__meta-row span {
color: #64748b;
}
.pos-stripe-email-panel__meta-row a {
font-weight: 600;
}
.pos-stripe-email-panel__error {
margin-top: 0.75rem;
}
.pos-stripe-email-panel__actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
</style>
@@ -506,12 +506,4 @@ onBeforeUnmount(() => {
.stripe-terminal-select--mobile .stripe-terminal-select__trigger {
min-height: 3rem;
}
.stripe-terminal-select--mobile {
flex-direction: column;
}
.stripe-terminal-select--mobile .stripe-terminal-select__menu {
position: static;
}
</style>
@@ -2,12 +2,7 @@
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { buildAuditedOrderItemReasonPayload, editOrderItem } from "@/components/shop/OrdersItems.vue";
import {
EXTRA_SALE_REASONS,
isExtraSaleAuditProduct,
isExtraSaleCommentRequired,
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
import { editOrderItem } from "@/components/shop/OrdersItems.vue";
const props = defineProps({
modelValue: {
@@ -34,8 +29,6 @@ const form = reactive({
notes: '',
reference: '',
quantity: '1',
extraSaleReasonCode: '',
extraSaleComment: '',
});
const isSubmitting = ref(false);
@@ -52,8 +45,6 @@ const syncForm = () => {
form.notes = String(props.orderItem?.notes ?? '');
form.reference = String(props.orderItem?.reference ?? '');
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 = '';
};
@@ -69,27 +60,8 @@ const isQuantityValid = computed(() => {
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(() => {
return Boolean(props.orderItem)
&& props.canEdit
&& !isSubmitting.value
&& isPriceValid.value
&& isQuantityValid.value
&& isExtraSaleAuditValid.value;
return Boolean(props.orderItem) && props.canEdit && !isSubmitting.value && isPriceValid.value && isQuantityValid.value;
});
const closeModal = () => {
@@ -113,16 +85,7 @@ const saveChanges = async () => {
Number(form.price),
form.notes,
form.reference,
Number(form.quantity),
buildAuditedOrderItemReasonPayload(props.orderItem.product_id ?? props.orderItem.product?.id, form.notes, {
reason_code: props.orderItem.reason_code,
reason_label_snapshot: props.orderItem.reason_label_snapshot,
reason_comment: form.notes,
}),
{
extra_sale_reason_code: form.extraSaleReasonCode || null,
extra_sale_comment: form.extraSaleComment.trim() || null,
}
Number(form.quantity)
);
emits('saved');
} catch (error) {
@@ -209,41 +172,6 @@ const saveChanges = async () => {
></textarea>
</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">
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
<input
@@ -12,10 +12,6 @@ import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/Invoi
import { INVOICE_COLLECTION_BULK_ACTIONS } from "@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js";
import SuperuserInvoiceRowActions from "@/components/displays/superuser/tables/SuperuserInvoiceRowActions.vue";
import { useLargeTableHeaders } from "@/services/tableHeaderPreferences.js";
import {
normalizeInvoiceCollectionActionPreview,
renderInvoiceCollectionActionPreviewHtml,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoiceCollectionActionPreview.ts";
const props = defineProps({
orders: {
@@ -222,6 +218,14 @@ const normalizeInvoiceCollectionId = (invoiceCollectionId) => {
const getApiPayload = (response) => response?.data?.data ?? response?.data ?? response ?? {};
const escapeHtml = (value) =>
String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const getInvoiceCollectionResponseOrders = (response) => {
if (Array.isArray(response?.orders)) {
return response.orders;
@@ -499,11 +503,84 @@ const selectableInvoiceCollectionCount = computed(() => getUniqueInvoiceCollecti
const getBulkActionLabel = (action) => t(`invoicing_period.invoice_collection_actions.actions.${action}`);
const renderBulkActionPreviewHtml = (preview) => {
return renderInvoiceCollectionActionPreviewHtml({
preview: normalizeInvoiceCollectionActionPreview(preview, getSelectedInvoiceCollectionIds().length),
t,
formatCurrency: (value) => SessionUser.functions.currency.toLocal(value),
});
const summary = preview?.summary || {};
const collections = Number(summary.collections ?? preview?.invoice_collection_ids?.length ?? 0);
const changedCount = Number(summary.changed_count ?? summary.order_items ?? summary.orders_to_move ?? 0);
const skippedCount = Number(summary.skipped_count ?? 0);
const blockers = Array.isArray(preview?.blockers) ? preview.blockers : [];
const orderItems = Array.isArray(preview?.order_items) ? preview.order_items : [];
const orders = Array.isArray(preview?.orders) ? preview.orders : [];
const splitItems = Array.isArray(preview?.items) ? preview.items : [];
const detailItems = [
...orderItems.map((item) =>
t("invoicing_period.invoice_collection_actions.preview.order_item_line", {
collection: item.invoice_collection_id,
order: item.order_id,
product: item.product_name || item.product_id,
})
),
...orders.map((item) =>
t("invoicing_period.invoice_collection_actions.preview.order_move_line", {
order: item.order_id,
source: item.source_invoice_collection_id,
target: item.target_invoice_collection_id,
})
),
...splitItems.map((item) =>
t("invoicing_period.invoice_collection_actions.preview.collection_status_line", {
collection: item.invoice_collection_id,
status: item.status,
})
),
].slice(0, 10);
const summaryLines = [
t("invoicing_period.invoice_collection_actions.preview.collections", { count: collections }),
t("invoicing_period.invoice_collection_actions.preview.changed", { count: changedCount }),
...(skippedCount > 0
? [t("invoicing_period.invoice_collection_actions.preview.skipped", { count: skippedCount })]
: []),
...(preview?.target_invoice_collection_id
? [
t("invoicing_period.invoice_collection_actions.preview.merge_target", {
id: preview.target_invoice_collection_id,
}),
]
: []),
];
const html = [
`<p>${escapeHtml(
t("invoicing_period.invoice_collection_actions.preview.requires_confirmation", {
phrase: preview?.confirmation_phrase || "",
})
)}</p>`,
`<ul class="has-text-left">${summaryLines.map((line) => `<li>${escapeHtml(line)}</li>`).join("")}</ul>`,
];
if (detailItems.length > 0) {
html.push(
`<hr><p class="has-text-left has-text-weight-bold">${escapeHtml(
t("invoicing_period.invoice_collection_actions.preview.affected_examples")
)}</p>`
);
html.push(`<ul class="has-text-left">${detailItems.map((line) => `<li>${escapeHtml(line)}</li>`).join("")}</ul>`);
}
if (blockers.length > 0) {
html.push(
`<hr><p class="has-text-left has-text-weight-bold has-text-danger">${escapeHtml(
t("invoicing_period.invoice_collection_actions.preview.blockers")
)}</p>`
);
html.push(
`<ul class="has-text-left">${blockers
.map((blocker) => `<li>${escapeHtml(blocker.message || blocker.code)}</li>`)
.join("")}</ul>`
);
}
return html.join("");
};
const chooseMergeTargetInvoiceCollection = async (invoiceCollectionIds) => {
@@ -38,23 +38,17 @@ import {
reg_2,
reg_3,
department_id,
customer_id,
customer_id,
customer_attributes,
customer_attributes_status,
customer_name,
getAddonRestriction,
getProductRestriction,
customer_name,
getAddonRestriction,
getProductRestriction,
retryCustomerAttributes,
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.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";
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
@@ -64,7 +58,6 @@ import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
import { useI18n } from "vue-i18n";
import { promptExtraSaleAuditIfRequired } from "@/components/displays/department/pos/utils/extraSaleAudit.js";
const { t } = useI18n();
@@ -352,14 +345,14 @@ const applyPendingBookingFromSelection = async () => {
effectivePrimaryProduct = firstWash;
}
}
effectivePrimaryProduct.addons = preparedAddons as any;
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value.addons = preparedAddons as any;
}
sanitizeRestrictedTransactionItems();
effectivePrimaryProduct.addons = preparedAddons as any;
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
if (transactionItems.primaryItem.value) {
transactionItems.primaryItem.value.addons = preparedAddons as any;
}
sanitizeRestrictedTransactionItems();
lastAppliedBookingId.value = booking.id;
lastAppliedBookingId.value = booking.id;
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
@@ -892,12 +885,83 @@ watch(
{ immediate: true }
);
// The pre-existing helpers buildDesiredOrderItemShapes and
// normalizeExistingOrderItemShapes (and their shared normalizeOrderItemShape
// normalizer) used to live here. They have been extracted to
// src/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js
// so they can be unit-tested in isolation and so the same comparison logic
// is used by syncCurrentTransactionToOrder and any future caller.
const normalizeOrderItemShape = (item: any) => ({
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
quantity: Number(item?.quantity ?? 0),
related_item_id:
item?.related_item_id === null || item?.related_item_id === undefined ? null : Number(item.related_item_id),
price: Number(item?.price ?? 0),
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 }>) =>
shapes.slice().sort((left, right) => {
@@ -924,8 +988,8 @@ const buildCurrentSelectionComparableShapes = () => {
};
const addonShapes = sortComparableLastWashShapes(
(transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
(transactionItems.primaryItem.value.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.map((addon: any) => ({
kind: "addon",
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
@@ -934,8 +998,8 @@ const buildCurrentSelectionComparableShapes = () => {
);
const additionalShapes = sortComparableLastWashShapes(
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.map((item: any) => ({
kind: "additional",
product_id: Number(item?.id ?? 0),
@@ -1010,19 +1074,65 @@ const syncCurrentTransactionToOrder = async () => {
return false;
}
// The sync helper owns the create / rollback / allSettled logic and lives
// in tests/unit/pos-mobile-step-2-addon-sync.spec.js. Returning its result
// unchanged preserves the existing contract: true = synced (or already in
// sync), false = primary product blocked, throw = partial failure.
await syncMobileOrderItems({
orderId: normalizedOrderId,
primaryItem: transactionItems.primaryItem.value,
additionalItems: transactionItems.additionalItems.value || [],
isAddonRestricted: isMobileAddonRestricted,
isStandaloneRestricted: isStandaloneAdditionalItemRestricted,
isPrimaryRestricted: (item) => getProductRestriction(item).restricted,
api: { createOrderItem, getOrderItems, removeOrderItem },
});
const existingItemsResponse = await getOrderItems(normalizedOrderId);
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
const desiredShapes = buildDesiredOrderItemShapes();
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
const comparableDesiredShapes = desiredShapes.map(({
kind: _kind,
relatedKey: _relatedKey,
skip_price_override: _skipPriceOverride,
...shape
}) => shape);
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;
};
@@ -1030,7 +1140,8 @@ const normalizeText = (value: unknown) => String(value ?? "").trim();
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
const getProductId = (product: any) => Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductId = (product: any) =>
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
@@ -1041,13 +1152,13 @@ const productRequiresOrderItemNote = (product: any) => {
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
AUDITED_ORDER_ITEM_PRODUCT_IDS.has(getProductId(product)) ||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
};
const productHasOrderItemNote = (product: any) => normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const productHasOrderItemNote = (product: any) =>
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
const getSelectedProductsMissingRequiredNotes = () => {
const missingProducts: any[] = [];
@@ -1057,8 +1168,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
missingProducts.push(primaryProduct);
}
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
(primaryProduct?.addons || [])
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
.forEach((addon: any) => {
const addonProduct = addon?.product ?? addon;
if (
@@ -1070,8 +1181,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
}
});
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
(transactionItems.additionalItems.value || [])
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
.forEach((item: any) => {
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
missingProducts.push(item);
@@ -1427,15 +1538,15 @@ const filteredAddons = computed(() => {
<i class="fa fa-search"></i>
</span>
</button>
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<p
v-if="restrictionWarningMessageKey"
class="notification is-warning is-light py-2 px-3 mb-0"
data-testid="pos-mobile-restriction-warning"
>
{{ t(restrictionWarningMessageKey) }}
</p>
<!-- Registration numbers -->
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
<p
v-if="restrictionWarningMessageKey"
class="notification is-warning is-light py-2 px-3 mb-0"
data-testid="pos-mobile-restriction-warning"
>
{{ t(restrictionWarningMessageKey) }}
</p>
<div
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"
@@ -1451,8 +1562,8 @@ const filteredAddons = computed(() => {
{{ t("common.retry") }}
</button>
</div>
<!-- Product -->
<PosDepartmentStepMobile2Product
<!-- Product -->
<PosDepartmentStepMobile2Product
v-on:pointerdown="onPrimaryProductPointerDown"
v-on:pointermove="onPrimaryProductPointerMove"
v-on:pointerup="onPrimaryProductPointerUp"
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from "vue";
import { computed, ref } from 'vue';
import Swal from "sweetalert2";
import PayWithStripeButton from "@/components/displays/department/pos/displays/PayWithStripeButton.vue";
import {
@@ -22,9 +22,6 @@ import {
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import { finalizeCurrentMobileOrder } from "@/components/displays/department/pos/steps/mobile/objects/mobileOrderCompletion.js";
const SUCCESS_RESET_DELAY_MS = 3000;
const SUCCESS_RESET_DELAY_OVERRIDE_KEY = "__TW_POS_MOBILE_SUCCESS_RESET_DELAY_MS__";
const isCompleting = ref(false);
const completionError = ref(null);
const completedOrderId = ref(null);
@@ -40,12 +37,8 @@ const normalizedDepartmentId = computed(() => {
});
const currentPaymentIntent = computed(() => StripeModule.paymentIntents.paymentIntent.value);
const paymentIntentState = computed(() =>
StripeModule.paymentIntents.getPaymentIntentState(currentPaymentIntent.value)
);
const hasActivePaymentIntent = computed(() =>
StripeModule.paymentIntents.isPaymentIntentActive(currentPaymentIntent.value)
);
const paymentIntentState = computed(() => StripeModule.paymentIntents.getPaymentIntentState(currentPaymentIntent.value));
const hasActivePaymentIntent = computed(() => StripeModule.paymentIntents.isPaymentIntentActive(currentPaymentIntent.value));
const orderItemsTotal = computed(() => {
return (Array.isArray(order_items.value) ? order_items.value : []).reduce((sum, item) => {
@@ -63,23 +56,18 @@ const mobileOrderTotal = computed(() => {
return orderItemsTotal.value;
});
const orderReference = computed(() => metadata.getReference?.() || "");
const orderNotes = computed(() => metadata.getNotes?.() || "");
const orderRegistration = computed(() => reg_1.value || "Unknown vehicle");
const isBackDisabled = computed(() => isCompleting.value || paymentIntentState.value === "succeeded");
const orderReference = computed(() => metadata.getReference?.() || '');
const orderNotes = computed(() => metadata.getNotes?.() || '');
const orderRegistration = computed(() => reg_1.value || 'Unknown vehicle');
const isBackDisabled = computed(() => isCompleting.value || paymentIntentState.value === 'succeeded');
const formatCurrency = (amount) => {
return new Intl.NumberFormat("da-DK", {
style: "currency",
currency: "DKK",
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: 'DKK',
}).format(Number(amount || 0));
};
const getSuccessResetDelayMs = () => {
const override = Number(window[SUCCESS_RESET_DELAY_OVERRIDE_KEY]);
return Number.isFinite(override) && override >= 0 ? override : SUCCESS_RESET_DELAY_MS;
};
const navigateToItems = () => {
setStep(2);
if (normalizedOrderId.value) {
@@ -95,7 +83,7 @@ const clearSuccessfulMobileCardFlow = () => {
order_id.value = 0;
popups.clear();
resetPos();
}, getSuccessResetDelayMs());
}, 3000);
};
const completeMobileCardPayment = async () => {
@@ -112,21 +100,21 @@ const completeMobileCardPayment = async () => {
try {
await finalizeCurrentMobileOrder();
popups.select("completed_transaction", {
popups.select('completed_transaction', {
message: `Order #${normalizedOrderId.value} successfully created.`,
});
clearSuccessfulMobileCardFlow();
} catch (error) {
completedOrderId.value = null;
completionError.value = SessionUser.functions.parseErrorMessage(error) || "Unable to complete card payment.";
console.error("Failed to complete mobile card payment:", error);
completionError.value = SessionUser.functions.parseErrorMessage(error) || 'Unable to complete card payment.';
console.error('Failed to complete mobile card payment:', error);
} finally {
isCompleting.value = false;
}
};
const onBackClick = async () => {
if (paymentIntentState.value === "succeeded") {
if (paymentIntentState.value === 'succeeded') {
return;
}
@@ -136,14 +124,14 @@ const onBackClick = async () => {
}
const result = await Swal.fire({
title: "Payment in progress",
text: "Resume the current payment or cancel it before returning to the item step.",
icon: "warning",
title: 'Payment in progress',
text: 'Resume the current payment or cancel it before returning to the item step.',
icon: 'warning',
showDenyButton: true,
showCancelButton: true,
confirmButtonText: "Resume payment",
denyButtonText: "Cancel payment",
cancelButtonText: "Stay here",
confirmButtonText: 'Resume payment',
denyButtonText: 'Cancel payment',
cancelButtonText: 'Stay here',
reverseButtons: true,
});
@@ -156,7 +144,7 @@ const onBackClick = async () => {
await StripeModule.paymentIntents.deletePaymentIntent(normalizedOrderId.value);
navigateToItems();
} catch (error) {
completionError.value = SessionUser.functions.parseErrorMessage(error) || "Unable to cancel the current payment.";
completionError.value = SessionUser.functions.parseErrorMessage(error) || 'Unable to cancel the current payment.';
}
}
};
@@ -183,7 +171,9 @@ const onBackClick = async () => {
<div class="mobile-card-payment-view__summary box">
<div class="mobile-card-payment-view__summary-row">
<span>Order</span>
<strong data-testid="pos-mobile-step-3-order-id"> #{{ normalizedOrderId || "Pending" }} </strong>
<strong data-testid="pos-mobile-step-3-order-id">
#{{ normalizedOrderId || 'Pending' }}
</strong>
</div>
<div class="mobile-card-payment-view__summary-row">
<span>Total</span>
@@ -226,7 +216,9 @@ const onBackClick = async () => {
</button>
</div>
<p class="mobile-card-payment-view__description">Use the selected card terminal for this order.</p>
<p class="mobile-card-payment-view__description">
Use the selected Stripe Terminal reader or send a Stripe payment link by email for this order.
</p>
<PayWithStripeButton
:department-id="normalizedDepartmentId"
@@ -469,13 +469,6 @@ const onClick = async () => {
}
} catch (error) {
console.warn("Next-step action was interrupted:", error);
// Surface the failure to the operator so silent API rejections don't look
// like the button did nothing. Backend validation errors typically carry a
// human-readable message on error.response.data.data.message.
const parsedError =
SessionUser.functions.parseErrorMessage(error) || error?.message || "Unknown error";
errors.value.push(error);
popups.select("error", { message: parsedError });
} finally {
isProcessingClick.value = false;
}
@@ -1,335 +0,0 @@
/**
* 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;
}
};
@@ -1,49 +0,0 @@
/**
* 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,6 +5,15 @@ export const isUsageOrderAttachedToOrder = (object) => {
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 : [];
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
};
File diff suppressed because it is too large Load Diff
@@ -1,204 +0,0 @@
/**
* 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 };
@@ -1,91 +0,0 @@
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;
};
@@ -1,56 +0,0 @@
/**
* 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,7 +52,6 @@ const pricingData = ref(null);
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const pricingConflict = ref(false);
const departmentId = computed(() => {
const parsed = Number.parseInt(String(props.departmentId || ""), 10);
@@ -65,8 +64,7 @@ const parsedCustomerNumber = computed(() => {
});
const canLoadPricing = computed(
() =>
props.canRead && props.customPricingEnabled && departmentId.value !== null && parsedCustomerNumber.value !== null
() => props.canRead && props.customPricingEnabled && departmentId.value !== null && parsedCustomerNumber.value !== null
);
const categories = computed(() => (Array.isArray(pricingData.value?.categories) ? pricingData.value.categories : []));
@@ -132,23 +130,12 @@ const requestLoad = () =>
const requestSave = (overrides) =>
props.scope === "limited"
? updateLimitedBackofficeDepartmentCustomerPricing(
departmentId.value,
customerIdentifier.value,
overrides,
pricingData.value?.revision ?? null
)
: updateSuperuserDepartmentCustomerPricing(
departmentId.value,
customerIdentifier.value,
overrides,
pricingData.value?.revision ?? null
);
? updateLimitedBackofficeDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides)
: updateSuperuserDepartmentCustomerPricing(departmentId.value, customerIdentifier.value, overrides);
const resetPricingData = () => {
pricingData.value = null;
errorMessage.value = "";
pricingConflict.value = false;
};
const loadPricing = async () => {
@@ -159,7 +146,6 @@ const loadPricing = async () => {
loading.value = true;
errorMessage.value = "";
pricingConflict.value = false;
try {
const response = await requestLoad();
@@ -207,17 +193,16 @@ const saveOverride = async (nextOverride) => {
saving.value = true;
errorMessage.value = "";
pricingConflict.value = false;
try {
const response = await requestSave(normalizedOverrides(nextOverride));
pricingData.value = unwrapDepartmentCustomerPricingResponse(response);
} catch (error) {
const payload = error?.response?.data?.data || error?.response?.data || {};
pricingConflict.value = Number(error?.response?.status) === 409 && payload?.code === "pricing_revision_conflict";
errorMessage.value = pricingConflict.value
? t("templates.limited_backoffice.errors.pricing_conflict")
: payload?.message || error?.message || t("departments.customer_pricing.errors.save");
errorMessage.value =
error?.response?.data?.data?.message ||
error?.response?.data?.message ||
error?.message ||
t("departments.customer_pricing.errors.save");
} finally {
saving.value = false;
}
@@ -332,11 +317,7 @@ watch(
:class="{ 'department-customer-pricing--tiles': tilePresentation }"
data-testid="department-customer-pricing-editor"
>
<div
v-if="!props.canRead"
class="notification is-danger is-light"
data-testid="department-customer-pricing-forbidden"
>
<div v-if="!props.canRead" class="notification is-danger is-light" data-testid="department-customer-pricing-forbidden">
{{ t("departments.customer_pricing.no_permission") }}
</div>
@@ -356,44 +337,44 @@ watch(
:subtitle="tilePresentation ? t('departments.customer_pricing.open') : undefined"
data-testid="department-customer-pricing-lookup"
>
<div class="department-customer-pricing__toolbar">
<div class="field department-customer-pricing__customer-field">
<label class="label" for="department-customer-pricing-customer-number">
{{ t("departments.customer_pricing.customer_number") }}
</label>
<div class="field has-addons">
<div class="control is-expanded">
<input
id="department-customer-pricing-customer-number"
v-model="customerNumber"
class="input"
type="number"
min="1"
step="1"
inputmode="numeric"
:placeholder="t('departments.customer_pricing.customer_number')"
data-testid="department-customer-pricing-customer-number"
@keydown.enter.prevent="loadPricing"
/>
</div>
<div class="control">
<b-tooltip :label="t('departments.customer_pricing.load_customer')" position="is-bottom" type="is-dark">
<button
class="button is-info"
type="button"
:disabled="!canLoadPricing || loading"
data-testid="department-customer-pricing-load"
@click="loadPricing"
>
<span class="icon is-small">
<i class="fas" :class="loading ? 'fa-spinner fa-spin' : 'fa-search'" aria-hidden="true"></i>
</span>
</button>
</b-tooltip>
</div>
<div class="department-customer-pricing__toolbar">
<div class="field department-customer-pricing__customer-field">
<label class="label" for="department-customer-pricing-customer-number">
{{ t("departments.customer_pricing.customer_number") }}
</label>
<div class="field has-addons">
<div class="control is-expanded">
<input
id="department-customer-pricing-customer-number"
v-model="customerNumber"
class="input"
type="number"
min="1"
step="1"
inputmode="numeric"
:placeholder="t('departments.customer_pricing.customer_number')"
data-testid="department-customer-pricing-customer-number"
@keydown.enter.prevent="loadPricing"
/>
</div>
<div class="control">
<b-tooltip :label="t('departments.customer_pricing.load_customer')" position="is-bottom" type="is-dark">
<button
class="button is-info"
type="button"
:disabled="!canLoadPricing || loading"
data-testid="department-customer-pricing-load"
@click="loadPricing"
>
<span class="icon is-small">
<i class="fas" :class="loading ? 'fa-spinner fa-spin' : 'fa-search'" aria-hidden="true"></i>
</span>
</button>
</b-tooltip>
</div>
</div>
</div>
</div>
</component>
<div v-if="loading" class="notification is-light" data-testid="department-customer-pricing-loading">
@@ -401,16 +382,7 @@ watch(
</div>
<div v-if="errorMessage" class="notification is-danger is-light" data-testid="department-customer-pricing-error">
<span>{{ errorMessage }}</span>
<button
v-if="pricingConflict"
type="button"
class="button is-small is-danger is-light ml-3"
data-testid="department-customer-pricing-conflict-reload"
@click="loadPricing"
>
{{ t("templates.limited_backoffice.reload_pricing") }}
</button>
{{ errorMessage }}
</div>
<div v-if="pricingData" class="department-customer-pricing__content">
@@ -421,48 +393,39 @@ watch(
:subtitle="tilePresentation ? pricingData.department?.name : undefined"
data-testid="department-customer-pricing-customer-panel"
>
<div class="level department-customer-pricing__summary">
<div class="level-left">
<div>
<h2 class="title is-5" data-testid="department-customer-pricing-customer">
{{ customerDisplay }}
</h2>
<p class="subtitle is-6">{{ pricingData.department?.name }}</p>
</div>
</div>
<div class="level-right">
<b-tooltip
:label="
props.canEdit
? t('departments.customer_pricing.edit_global_discount')
: t('departments.customer_pricing.edit_disabled')
"
position="is-left"
type="is-dark"
>
<span>
<button
class="button is-small"
type="button"
:disabled="!props.canEdit || saving"
data-testid="department-customer-pricing-global-discount"
@click="editDiscount(true, 'global', t('departments.customer_pricing.global_discount'))"
>
<span class="icon is-small"><i class="fas fa-percent" aria-hidden="true"></i></span>
<span>{{ getDiscountDisplay(getOverride(true, "global")) }}</span>
</button>
</span>
</b-tooltip>
<div class="level department-customer-pricing__summary">
<div class="level-left">
<div>
<h2 class="title is-5" data-testid="department-customer-pricing-customer">
{{ customerDisplay }}
</h2>
<p class="subtitle is-6">{{ pricingData.department?.name }}</p>
</div>
</div>
<div class="level-right">
<b-tooltip
:label="props.canEdit ? t('departments.customer_pricing.edit_global_discount') : t('departments.customer_pricing.edit_disabled')"
position="is-left"
type="is-dark"
>
<span>
<button
class="button is-small"
type="button"
:disabled="!props.canEdit || saving"
data-testid="department-customer-pricing-global-discount"
@click="editDiscount(true, 'global', t('departments.customer_pricing.global_discount'))"
>
<span class="icon is-small"><i class="fas fa-percent" aria-hidden="true"></i></span>
<span>{{ getDiscountDisplay(getOverride(true, "global")) }}</span>
</button>
</span>
</b-tooltip>
</div>
</div>
</component>
<div
v-if="saving"
class="notification is-light py-2"
role="status"
data-testid="department-customer-pricing-saving"
>
<div v-if="saving" class="notification is-light py-2" role="status" data-testid="department-customer-pricing-saving">
{{ t("departments.customer_pricing.saving") }}
</div>
@@ -481,11 +444,7 @@ watch(
<div class="department-customer-pricing__category-header">
<h3 class="title is-6">{{ category.name }}</h3>
<b-tooltip
:label="
props.canEdit
? t('departments.customer_pricing.edit_category_discount')
: t('departments.customer_pricing.edit_disabled')
"
:label="props.canEdit ? t('departments.customer_pricing.edit_category_discount') : t('departments.customer_pricing.edit_disabled')"
position="is-left"
type="is-dark"
>
@@ -530,11 +489,7 @@ watch(
</td>
<td>
<b-tooltip
:label="
props.canEdit
? t('departments.customer_pricing.edit_fixed_price')
: t('departments.customer_pricing.edit_disabled')
"
:label="props.canEdit ? t('departments.customer_pricing.edit_fixed_price') : t('departments.customer_pricing.edit_disabled')"
position="is-bottom"
type="is-dark"
>
@@ -554,11 +509,7 @@ watch(
</td>
<td>
<b-tooltip
:label="
props.canEdit
? t('departments.customer_pricing.edit_item_discount')
: t('departments.customer_pricing.edit_disabled')
"
:label="props.canEdit ? t('departments.customer_pricing.edit_item_discount') : t('departments.customer_pricing.edit_disabled')"
position="is-bottom"
type="is-dark"
>
@@ -1,24 +1,5 @@
<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 { 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({
autoLoad: {
type: Boolean, default: true
@@ -38,12 +19,29 @@ const props = defineProps({
type: Boolean, default: false
}, highlightUsageLogId: {
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";
const { t } = useI18n();
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import XlvaskUsageOrdersTable from "@/components/displays/department/pos/sync/xlvaskUsageOrdersTable.vue";
import PaginationDisplayTemplateDates
from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue";
import ShowErrorField from "@/components/global/ShowErrorField.vue";
import { useI18n } from 'vue-i18n'
import { BSwitch } from "buefy";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
const { t } = useI18n()
const router = useRouter();
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
@@ -63,11 +61,9 @@ const {
setOrder,
hideSearchField,
setHideSearchField,
lastError,
} = paginatedList;
const XLVASK_USAGE_ORDERS_ENDPOINT = "/modules/xlvask/services/usage/orders";
setEndpoint(XLVASK_USAGE_ORDERS_ENDPOINT, false);
setEndpoint("/modules/xlvask/services/usage/orders", false);
if (props.loadAllAtOnce) {
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
}
@@ -93,73 +89,68 @@ const applyInitialPeriodFilters = () => {
const shouldShowLocalFilters = computed(() => !props.inheritPeriodFilters);
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) {
setFilter("customer_number", props.setCustomerFilter);
console.log("Setting customer filter to: " + props.setCustomerFilter);
}
// Hide the search field
if (props.hideSearch) {
setHideSearchField(true);
console.log("Hiding search field");
} else {
setHideSearchField(false);
}
const routeDepartmentId = Number.parseInt(
String(router.currentRoute.value.params.departmentId ?? ""),
10
);
const effectiveDepartmentId =
props.departmentId > 0
? props.departmentId
: Number.isInteger(routeDepartmentId) && routeDepartmentId > 0
? routeDepartmentId
: 0;
if (effectiveDepartmentId > 0) {
setFilter("HallId", effectiveDepartmentId, false);
// If the departmentId is set, in the route, filter the orders by the departmentId
if (router.currentRoute.value.params.departmentId) {
setOrder("StartTime", "desc");
//setFilter("department", router.currentRoute.value.params.departmentId, false);
} else {
setOrder("StartTime", "desc");
}
const isImportLoading = ref(false);
setOrder("StartTime", "desc");
const extractResponseData = (response) => response?.data?.data ?? response?.data ?? {};
const summary = ref({});
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 {
const response = await SessionUser.request(
"/modules/xlvask/services/usage/orders/summary",
"GET",
params,
);
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 {
if (sequence === summaryRequestSequence) summaryLoading.value = false;
const buildImportUsageParams = () => {
if (!props.inheritPeriodFilters) {
return {};
}
return {
...(props.initialDateFrom ? { dateFrom: props.initialDateFrom } : {}),
...(props.initialDateTo ? { dateTo: props.initialDateTo } : {}),
};
};
const importUsageButton = async () => {
isImportLoading.value = true;
try {
await SessionUser.request(
'/modules/xlvask/tasks/import-usage',
'GET',
buildImportUsageParams(),
function (error) {
// This function is called when the request fails
console.error("Failed to import orders.", error);
},
function (response) {
// This function is called when the request succeeds
console.log("Orders imported successfully.", response);
// Reload the list after import
loadList();
}
);
} finally {
isImportLoading.value = false;
}
};
const dateFrom = ref(parseInitialDate(props.initialDateFrom));
const dateTo = ref(parseInitialDate(props.initialDateTo));
const parsedDate = (date) => parseLocalDateOnly(date);
const parsedDate = (date) => {
return parseLocalDateOnly(date);
};
const reloadScheduled = ref(false);
const scheduleReload = () => {
@@ -192,9 +183,9 @@ const actions = {
};
applyInitialPeriodFilters();
// Load the list automatically if the autoLoad prop is set
if (props.autoLoad) {
loadList();
loadSummary();
}
watch(
@@ -210,79 +201,19 @@ watch(
if (props.autoLoad) {
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 visibleObjectsCount = computed(() => {
const currentList = Array.isArray(list.value) ? list.value : [];
if (!showOnlyUnattachedVehicle.value) {
return currentList.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>
<template>
@@ -296,75 +227,18 @@ watch(apiEndpointNotImplemented, (isNotImplemented) => {
</div>
</div>
<div class="level-right">
<div class="level-item">
<LoadButtonWhileAwait class="is-dark" :isLoading="isImportLoading" :loadFunction="importUsageButton" icon="fas fa-file-import">{{SessionUser.objects.global.language.import}}</LoadButtonWhileAwait>
</div>
<div class="level-item">
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{SessionUser.objects.global.language.reload}}</LoadButtonWhileAwait>
</div>
</div>
</div>
<section 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"/>
<PaginationDisplay v-if="shouldShowLocalFilters" :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<template #paginationColumns>
<!-- Sort by created_at -->
<div class="column is-narrow my-3">
<label class="label">{{ t('pagination.order_direction') }}</label>
<div class="control">
@@ -383,34 +257,36 @@ watch(apiEndpointNotImplemented, (isNotImplemented) => {
@update:endDate="actions.date.to.select"
/>
<div class="column is-narrow my-3">
<label class="label">{{ t('invoicing_period.xlvask_review.filters.title') }}</label>
<label class="label">Filtre</label>
<div class="control">
<b-switch
size="is-small"
type="is-link"
v-model="showOnlyUnattachedVehicle"
>
{{ t('invoicing_period.xlvask_review.filters.unattached_only') }}
Vis kun ikke tilknyttede vaske
</b-switch>
</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>
</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"/>
<XlvaskUsageOrdersTable
:objects="list"
:show-only-unattached-vehicle="showOnlyUnattachedVehicle"
:highlight-usage-log-id="props.highlightUsageLogId"
:allow-select-multiple="true"
:allow-review-actions="true"
:allow-adjudication-actions="false"
/>
<PaginationNavigation
v-if="!props.loadAllAtOnce"
@@ -423,17 +299,5 @@ watch(apiEndpointNotImplemented, (isNotImplemented) => {
</template>
<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>
@@ -3,7 +3,6 @@ import { BCheckbox } from "buefy";
import { computed, onMounted, ref, watch } from "vue";
import i18n from "@/i18n";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { permissionCapabilityLabel } from "@/components/session/subuser/subuserPermissionLabels.js";
const props = defineProps({
grant: {
@@ -65,6 +64,27 @@ const LEGACY_PERMISSION_GROUPS: PermissionGroup[] = [
},
];
const LEGACY_PERMISSION_LABEL_KEYS: Record<string, string> = {
VEHICLES_LIST: "view_vehicles",
VEHICLES_EDIT: "edit_vehicles",
VEHICLES_DELETE: "delete_vehicles",
VEHICLES_ADD: "add_vehicles",
SELFSERVE_LIST: "view_selfserve",
SELFSERVE_EDIT: "edit_selfserve",
SELFSERVE_DELETE: "delete_selfserve",
SELFSERVE_ADD: "start_selfserve",
BOOKINGS_LIST: "view_bookings",
BOOKINGS_EDIT: "edit_bookings",
BOOKINGS_DELETE: "delete_bookings",
BOOKINGS_ADD: "add_bookings",
ORDERS_LIST: "view_orders",
ORDERS_EDIT: "edit_orders",
SUBUSERS_LIST: "view_drivers",
SUBUSERS_EDIT: "edit_driver_access",
SUBUSERS_DELETE: "disable_driver_access",
SUBUSERS_ADD: "invite_drivers",
};
const templates = ref<PermissionTemplate[]>([]);
const selectedPermissions = ref<string[]>([]);
const selectedTemplateKey = ref<string>(props.templateKey || props.grant.permission_template_key || "custom");
@@ -141,7 +161,10 @@ const templateLabel = (key: string, _fallback = "") => t(`superuser.driver_acces
const templateDescription = (template: PermissionTemplate) =>
t(`superuser.driver_access.templates.${template.key}.description`);
const groupLabel = (key: string) => t(`superuser.driver_access.groups.${key}`);
const permissionLabel = (permission: string) => permissionCapabilityLabel(permission);
const permissionLabel = (permission: string) => {
const capabilityKey = LEGACY_PERMISSION_LABEL_KEYS[permission] || permission;
return t(`superuser.driver_access.capabilities.${capabilityKey}`);
};
watch(
() => props.permissions,
@@ -1,5 +1,5 @@
<script setup>
import { getCurrentInstance, ref, watch } from 'vue';
import { ref, watch } from 'vue';
import {parseError, getError} from "@/components/request/HandleGlobalError.vue";
const props = defineProps({
@@ -24,7 +24,6 @@ const props = defineProps({
});
const isEditting = ref(false);
const inputId = `configuration-input-${getCurrentInstance()?.uid}`;
const v_model = ref(props.value);
@@ -46,7 +45,7 @@ const parseErrorOnSave = (error) => {
<template>
<div class="field">
<label class="label" :for="inputId">{{ title }}</label>
<label class="label">{{ title }}</label>
<!-- If the value is not set, show a warning message -->
<div v-if="!props.value && showEmptyWarning && !isEditting"
class="message is-warning">
@@ -59,7 +58,6 @@ const parseErrorOnSave = (error) => {
<div class="field has-addons">
<div class="control is-expanded">
<input
:id="inputId"
class="input"
type="text"
v-model="v_model"
@@ -1,9 +1,6 @@
<script setup>
import { ref } from 'vue';
import { parseError, getError } from "@/components/request/HandleGlobalError.vue";
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps({
onSave: Function, // Function to call when the secret key is changed
@@ -14,25 +11,10 @@ const props = defineProps({
icon: String, // The icon of the select
color: String, // The color of the select
isSet: Boolean, // If the secret key is set or not, this is used to show a warning if the secret key is not set.
warningTitle: { // Optional override for the warning title (defaults to i18n key)
type: String,
default: null,
},
warningMessage: { // Optional override for the warning body (defaults to i18n key)
type: String,
default: null,
},
warningKey: { // Override the i18n key used for the default warning body
type: String,
default: 'configuration.secret_key_not_set',
},
});
const emit = defineEmits(['saved']);
const v_model = ref(props.value);
const isEditting = ref(false);
const saving = ref(false);
const errorId = ref(null);
@@ -42,32 +24,6 @@ const parseErrorOnSave = (error) => {
parseError(error, unique_id);
}
const onClickSave = async () => {
if (!props.onSave) {
isEditting.value = false;
return;
}
saving.value = true;
errorId.value = null;
try {
const result = await props.onSave(v_model.value);
// Treat any resolved value (including undefined) as success — the only signal
// we have for failure is the catch path. The previous implementation did the
// same thing but also clobbered `isEditting = false` synchronously, which
// could mask in-flight failures when the parent awaited silently.
emit('saved', { value: v_model.value, result });
isEditting.value = false;
} catch (error) {
parseErrorOnSave(error);
// Stay in edit mode so the user can correct the input.
} finally {
saving.value = false;
}
};
const resolvedWarningTitle = () => props.warningTitle ?? t('common.warning', 'Warning');
const resolvedWarningMessage = () => props.warningMessage ?? t(props.warningKey, 'The secret key is not set, please set it before enabling the integration.');
</script>
@@ -77,8 +33,8 @@ const resolvedWarningMessage = () => props.warningMessage ?? t(props.warningKey,
<!-- If the secret key is not set, show a warning -->
<div v-if="!props.isSet" class="message is-warning">
<div class="message-body">
<strong>{{ resolvedWarningTitle() }}:</strong>
{{ resolvedWarningMessage() }}
<strong>Warning:</strong>
The secret key is not set, please set it before enabling the integration.
</div>
</div>
<div class="control">
@@ -96,16 +52,11 @@ const resolvedWarningMessage = () => props.warningMessage ?? t(props.warningKey,
class="input"
type="text"
v-model="v_model"
:disabled="disabled || !isEditting || saving"
:disabled="disabled || !isEditting"
/>
</div>
<div class="control">
<a
class="button is-info"
@click="onClickSave"
v-if="isEditting"
:class="{ 'is-loading': saving }"
>
<a class="button is-info" @click="onSave(v_model).catch(parseErrorOnSave); isEditting = false" v-if="isEditting">
<span class="icon">
<i class="fas fa-save"></i>
</span>
@@ -50,16 +50,7 @@ const getOrders = (object) => {
let invoice_orders = invoice.objects;
orders = orders.concat(invoice_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 ?? ""));
});
return orders;
}
getDepartments();
@@ -47,35 +47,9 @@ const emit = defineEmits(["flagStatusChanged"]);
const orderItems = ref([]);
// Stable ordering for the order items table: primary items (related_item_id === 0
// / null) first, then addons grouped by their parent, in insertion order. The
// backend now also orders the SELECT (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 () => {
if (props.useLocalOrderItems) {
orderItems.value = sortOrderItemsForDisplay(props.localOrderItems);
orderItems.value = props.localOrderItems;
return;
}
await SessionUser.request(
@@ -85,7 +59,7 @@ const loadOrderItems = async () => {
order_id: props.orderId,
}
).then((response) => {
orderItems.value = sortOrderItemsForDisplay(response.data.data);
orderItems.value = response.data.data;
}).catch((error) => {
console.error(error);
});

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