Improve superuser invoicing and system status views

This commit is contained in:
Jeppe B
2026-07-14 15:41:05 +02:00
parent 0edf8afcaf
commit 4fbb0b98c3
46 changed files with 10065 additions and 199 deletions
+223 -30
View File
@@ -11,6 +11,36 @@ on:
description: Store build number/version code description: Store build number/version code
required: false required: false
type: string type: string
upload_android_to_play:
description: Upload the signed Android App Bundle to Google Play
required: false
type: boolean
default: true
upload_ios_to_app_store:
description: Upload the signed iOS IPA to App Store Connect
required: false
type: boolean
default: true
android_track:
description: Google Play track for manual dispatches
required: false
type: choice
default: production
options:
- production
- beta
- alpha
- internal
android_release_status:
description: Google Play release status for manual dispatches
required: false
type: choice
default: completed
options:
- completed
- draft
- inProgress
- halted
push: push:
tags: tags:
- "mobile-v*" - "mobile-v*"
@@ -26,40 +56,81 @@ permissions:
contents: read contents: read
concurrency: concurrency:
group: mobile-store-artifacts-${{ github.ref_name }} group: mobile-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
android: android:
name: Android AAB name: Android AAB and Play upload
if: > if: >
github.event_name != 'workflow_run' || github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' && (github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == github.event.repository.default_branch) github.event.workflow_run.head_branch == github.event.repository.default_branch)
runs-on: ubuntu-latest runs-on: ubuntu-24.04
timeout-minutes: 45 environment: mobile-store-production
timeout-minutes: 60
env:
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }}
ANDROID_AAB_PATH: ${{ vars.ANDROID_AAB_PATH || 'android/app/build/outputs/bundle/release/app-release.aab' }}
PLAY_STORE_TRACK: ${{ inputs.android_track || vars.PLAY_STORE_TRACK || 'production' }}
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || vars.PLAY_STORE_RELEASE_STATUS || 'completed' }}
PLAY_STORE_USER_FRACTION: ${{ vars.PLAY_STORE_USER_FRACTION || '' }}
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }} ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Guard current master release
id: release-guard
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
current=true
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
current=false
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
else
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
fi
else
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
- name: Setup Node.js - name: Setup Node.js
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
- name: Setup Java - name: Setup Java
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 21
- name: Setup Android SDK - name: Setup Android SDK
if: steps.release-guard.outputs.current == 'true'
uses: android-actions/setup-android@v3 uses: android-actions/setup-android@v3
- name: Install Android SDK packages - name: Install Android SDK packages
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
@@ -67,10 +138,11 @@ jobs:
sdkmanager "platforms;android-36" "build-tools;36.0.0" sdkmanager "platforms;android-36" "build-tools;36.0.0"
- name: Resolve mobile version - name: Resolve mobile version
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
env: env:
INPUT_VERSION_NAME: ${{ inputs.version_name }} INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
INPUT_VERSION_CODE: ${{ inputs.version_code }} INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
run: | run: |
set -euo pipefail set -euo pipefail
version_name="$INPUT_VERSION_NAME" version_name="$INPUT_VERSION_NAME"
@@ -84,10 +156,22 @@ jobs:
echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV" echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV"
echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV" echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV"
- name: Check Android store environment
if: steps.release-guard.outputs.current == 'true'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: node scripts/mobile/check-store-upload-env.mjs --android
- name: Install dependencies - name: Install dependencies
if: steps.release-guard.outputs.current == 'true'
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Decode Android signing key - name: Decode Android signing key
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
@@ -96,10 +180,6 @@ jobs:
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: | run: |
set -euo pipefail set -euo pipefail
test -n "$ANDROID_KEYSTORE_BASE64"
test -n "$ANDROID_KEYSTORE_PASSWORD"
test -n "$ANDROID_KEY_ALIAS"
test -n "$ANDROID_KEY_PASSWORD"
keystore_path="$RUNNER_TEMP/android-release.keystore" keystore_path="$RUNNER_TEMP/android-release.keystore"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.ANDROID_KEYSTORE_BASE64, 'base64'))" "$keystore_path" node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.ANDROID_KEYSTORE_BASE64, 'base64'))" "$keystore_path"
echo "ANDROID_KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV" echo "ANDROID_KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV"
@@ -108,48 +188,98 @@ jobs:
echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> "$GITHUB_ENV" echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> "$GITHUB_ENV"
- name: Build and sync Android shell - name: Build and sync Android shell
if: steps.release-guard.outputs.current == 'true'
run: | run: |
npm run mobile:android:sync npm run mobile:android:sync
npm run mobile:permissions:check npm run mobile:permissions:check
npm run mobile:android:signing:check npm run mobile:android:signing:check
- name: Build signed Android App Bundle - name: Build signed Android App Bundle
if: steps.release-guard.outputs.current == 'true'
working-directory: android working-directory: android
run: ./gradlew --no-daemon bundleRelease run: ./gradlew --no-daemon bundleRelease
- name: Verify Android App Bundle signature - name: Verify Android App Bundle signature
run: jarsigner -verify -certs -verbose android/app/build/outputs/bundle/release/app-release.aab >/dev/null if: steps.release-guard.outputs.current == 'true'
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
- name: Upload Android artifact - name: Upload Android artifact
if: steps.release-guard.outputs.current == 'true'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }} name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
path: android/app/build/outputs/bundle/release/app-release.aab path: ${{ env.ANDROID_AAB_PATH }}
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
- name: Upload Android App Bundle to Google Play
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: npm run mobile:android:play-upload
ios: ios:
name: iOS IPA name: iOS IPA and App Store upload
if: github.event_name != 'workflow_run' if: >
runs-on: macos-latest github.event_name != 'workflow_run' ||
timeout-minutes: 60 (github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == github.event.repository.default_branch)
runs-on: macos-15
environment: mobile-store-production
timeout-minutes: 90
env:
IOS_PROJECT_PATH: ios/App/App.xcodeproj
IOS_SCHEME: App
IOS_BUNDLE_ID: io.truckwash.app
UPLOAD_IOS_TO_APP_STORE: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_ios_to_app_store }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
ref: ${{ github.sha }} ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Guard current master release
id: release-guard
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
current=true
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
current=false
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
else
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
fi
else
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
- name: Setup Node.js - name: Setup Node.js
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm cache: npm
- name: Resolve mobile version - name: Resolve mobile version
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
env: env:
INPUT_VERSION_NAME: ${{ inputs.version_name }} INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
INPUT_VERSION_CODE: ${{ inputs.version_code }} INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
run: | run: |
set -euo pipefail set -euo pipefail
version_name="$INPUT_VERSION_NAME" version_name="$INPUT_VERSION_NAME"
@@ -163,16 +293,32 @@ jobs:
echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV" echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV"
echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV" echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV"
- name: Check iOS store environment
if: steps.release-guard.outputs.current == 'true'
env:
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
IOS_KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
run: node scripts/mobile/check-store-upload-env.mjs --ios
- name: Install dependencies - name: Install dependencies
if: steps.release-guard.outputs.current == 'true'
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
- name: Build and sync iOS shell - name: Build and sync iOS shell
if: steps.release-guard.outputs.current == 'true'
run: | run: |
npm run build npm run build
npx cap sync ios npx cap sync ios
npm run mobile:permissions:check npm run mobile:permissions:check
- name: Install Apple signing assets - name: Install Apple signing assets
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
env: env:
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }} IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
@@ -182,12 +328,6 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: | run: |
set -euo pipefail set -euo pipefail
test -n "$IOS_CERTIFICATE_BASE64"
test -n "$IOS_CERTIFICATE_PASSWORD"
test -n "$IOS_PROVISION_PROFILE_BASE64"
test -n "$IOS_KEYCHAIN_PASSWORD"
test -n "$APPLE_TEAM_ID"
certificate_path="$RUNNER_TEMP/apple-distribution.p12" certificate_path="$RUNNER_TEMP/apple-distribution.p12"
profile_path="$RUNNER_TEMP/app-store.mobileprovision" profile_path="$RUNNER_TEMP/app-store.mobileprovision"
keychain_path="$RUNNER_TEMP/app-signing.keychain-db" keychain_path="$RUNNER_TEMP/app-signing.keychain-db"
@@ -214,14 +354,35 @@ jobs:
echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV" echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV"
echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV" echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV"
- name: Install App Store Connect API key
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
run: |
set -euo pipefail
private_keys_dir="$RUNNER_TEMP/private_keys"
private_key_path="$private_keys_dir/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
mkdir -p "$private_keys_dir"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64, 'base64'))" "$private_key_path"
chmod 600 "$private_key_path"
echo "API_PRIVATE_KEYS_DIR=$private_keys_dir" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_API_KEY_ID=$APP_STORE_CONNECT_API_KEY_ID" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_ISSUER_ID=$APP_STORE_CONNECT_ISSUER_ID" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_API_KEY_PATH=$private_key_path" >> "$GITHUB_ENV"
- name: Resolve Swift packages - name: Resolve Swift packages
run: xcodebuild -resolvePackageDependencies -project ios/App/App.xcodeproj -scheme App if: steps.release-guard.outputs.current == 'true'
run: xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME"
- name: Archive iOS app - name: Archive iOS app
if: steps.release-guard.outputs.current == 'true'
run: | run: |
xcodebuild \ xcodebuild \
-project ios/App/App.xcodeproj \ -project "$IOS_PROJECT_PATH" \
-scheme App \ -scheme "$IOS_SCHEME" \
-configuration Release \ -configuration Release \
-destination "generic/platform=iOS" \ -destination "generic/platform=iOS" \
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \ -archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
@@ -234,6 +395,7 @@ jobs:
CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE" CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE"
- name: Export iOS IPA - name: Export iOS IPA
if: steps.release-guard.outputs.current == 'true'
shell: bash shell: bash
run: | run: |
set -euo pipefail set -euo pipefail
@@ -255,7 +417,7 @@ jobs:
<string>$APPLE_TEAM_ID</string> <string>$APPLE_TEAM_ID</string>
<key>provisioningProfiles</key> <key>provisioningProfiles</key>
<dict> <dict>
<key>io.truckwash.app</key> <key>$IOS_BUNDLE_ID</key>
<string>$IOS_PROFILE_NAME</string> <string>$IOS_PROFILE_NAME</string>
</dict> </dict>
<key>stripSwiftSymbols</key> <key>stripSwiftSymbols</key>
@@ -270,15 +432,43 @@ jobs:
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \ -archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
-exportPath "$RUNNER_TEMP/ios-export" \ -exportPath "$RUNNER_TEMP/ios-export" \
-exportOptionsPlist "$export_options" -exportOptionsPlist "$export_options"
ipa_path="$(find "$RUNNER_TEMP/ios-export" -name '*.ipa' -print -quit)"
test -n "$ipa_path"
echo "IOS_IPA_PATH=$ipa_path" >> "$GITHUB_ENV"
- name: Upload iOS artifact - name: Upload iOS artifact
if: steps.release-guard.outputs.current == 'true'
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }} name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
path: ${{ runner.temp }}/ios-export/*.ipa path: ${{ runner.temp }}/ios-export/*.ipa
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
- name: Validate iOS IPA with App Store Connect
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
run: |
set -euo pipefail
xcrun altool \
--validate-app \
--type ios \
--file "$IOS_IPA_PATH" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"
- name: Upload iOS IPA to App Store Connect
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
run: |
set -euo pipefail
xcrun altool \
--upload-app \
--type ios \
--file "$IOS_IPA_PATH" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"
- name: Clean up Apple signing assets - name: Clean up Apple signing assets
if: always() if: always()
shell: bash shell: bash
@@ -289,3 +479,6 @@ jobs:
if [[ -n "${IOS_PROFILE_UUID:-}" ]]; then if [[ -n "${IOS_PROFILE_UUID:-}" ]]; then
rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROFILE_UUID.mobileprovision" rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROFILE_UUID.mobileprovision"
fi fi
if [[ -n "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]]; then
rm -f "$APP_STORE_CONNECT_API_KEY_PATH"
fi
+1
View File
@@ -15,6 +15,7 @@ dist-ssr
coverage coverage
*.local *.local
dev-dist dev-dist
.playwright-cli/
# Mobile build and signing outputs # Mobile build and signing outputs
/app/build/ /app/build/
+11
View File
@@ -183,6 +183,17 @@ the Capacitor Android project. The generator updates `android/app/src/main/res`
launcher assets, `public/icons/icon-192x192.png`, `public/icons/icon-512x512.png`, launcher assets, `public/icons/icon-192x192.png`, `public/icons/icon-512x512.png`,
and `store_icon.png`. and `store_icon.png`.
## Mobile Store Releases
Signed Android and iOS store artifacts are built through the GitHub Actions
`Mobile Store Artifacts` workflow. By default, current `master` after green
`Automated Tests` uploads Android to Google Play production and uploads iOS to
App Store Connect.
See `docs/mobile-artifacts.md` for workflow triggers, required secrets, and
local mobile checks. See `docs/app-store-release.md` for App Store Connect
release preparation and review notes.
## Bubblewrap (TWA) Build and Install ## Bubblewrap (TWA) Build and Install
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands: To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
+19 -7
View File
@@ -16,19 +16,25 @@ Capacitor app.
- Category: Business - Category: Business
- Price: Free - Price: Free
- Initial availability: Denmark - Initial availability: Denmark
- Keep the GitHub environment `app-store-production` protected and store iOS - Keep the GitHub environment `mobile-store-production` configured with the
signing plus App Store Connect API secrets there. iOS signing, App Store Connect, Android signing, and Google Play upload
secrets used by the mobile workflow.
## Build And Upload ## Build And Upload
1. Merge the release commit to `master`. 1. Merge the release commit to `master`.
2. Confirm `Automated Tests` and `Frontend Release` are green for that commit. 2. Confirm `Automated Tests` and `Frontend Release` are green for that commit.
3. Create a release tag such as `mobile-v1.0.0`. 3. Create a release tag such as `mobile-v1.0.0`.
4. The `Mobile Store Artifacts` workflow builds Android and iOS artifacts. For 4. The `Mobile Store Artifacts` workflow builds Android and iOS artifacts from
iOS, it archives, exports, validates the IPA with App Store Connect, and the tested commit. By default it uploads Android to the Google Play
uploads it when the run is tag-triggered. production track and uploads the iOS IPA to App Store Connect.
5. For a manual upload, dispatch `Mobile Store Artifacts` with 5. For a manual upload, dispatch `Mobile Store Artifacts` with `version_name`
`upload_to_app_store=true`, `version_name`, and `version_code`. and `version_code`. Leave `upload_ios_to_app_store` enabled for the iOS
upload, or disable it to produce only the signed GitHub artifact.
The same workflow also runs automatically after a successful `Automated Tests`
run on current `master`. It skips stale workflow-run commits if `master` has
advanced before the mobile jobs start.
The iOS workflow expects these environment secrets: The iOS workflow expects these environment secrets:
@@ -41,6 +47,12 @@ The iOS workflow expects these environment secrets:
- `APP_STORE_CONNECT_ISSUER_ID` - `APP_STORE_CONNECT_ISSUER_ID`
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64` - `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64`
The workflow installs the signing certificate and provisioning profile in a
temporary keychain on the `macos-15` runner, archives the Capacitor Xcode
project, exports an App Store IPA, validates it with `xcrun altool`, uploads it
with the App Store Connect API key, and removes temporary signing assets in the
cleanup step.
## Product Page Defaults ## Product Page Defaults
- Support URL: `https://truckwash.io/support` - Support URL: `https://truckwash.io/support`
+41 -3
View File
@@ -1,23 +1,41 @@
# Mobile Store Artifacts # Mobile Store Artifacts
The `Mobile Store Artifacts` workflow builds signed Android and iOS store artifacts from the Vue/Vite web app through Capacitor. The `Mobile Store Artifacts` workflow builds signed Android and iOS store artifacts from the Vue/Vite web app through Capacitor, then uploads them to Google Play and App Store Connect by default.
Use the Capacitor project under `android/` for the Google Play Store package. The Bubblewrap/TWA files at the repository root are not the path used by `mobile:android:bundle`. Use the Capacitor project under `android/` for the Google Play Store package. The Bubblewrap/TWA files at the repository root are not the path used by `mobile:android:bundle`.
## Triggers ## Triggers
- Manual: run `Mobile Store Artifacts` from GitHub Actions and optionally provide `version_name` and `version_code`. - Manual: run `Mobile Store Artifacts` from GitHub Actions and optionally provide `version_name`, `version_code`, upload toggles, and Android track/status overrides.
- Tag: push a tag named `mobile-vX.Y.Z`; the workflow uses `X.Y.Z` as the store version name. - Tag: push a tag named `mobile-vX.Y.Z`; the workflow uses `X.Y.Z` as the store version name.
- Automatic Android Play Store artifact: after the `Automated Tests` workflow completes successfully on `master`, GitHub Actions builds and uploads a signed Android App Bundle from the tested commit. - Automatic store upload: after the `Automated Tests` workflow completes successfully on current `master`, GitHub Actions builds signed Android and iOS artifacts from that tested commit and uploads them to the stores.
- Stale workflow-run protection: if a newer commit reaches `master` before the mobile workflow runs, both store-upload jobs skip the stale commit.
Default upload behavior:
- Android uploads package `io.truckwash.twa` to the Google Play `production` track with release status `completed`.
- iOS uploads bundle `io.truckwash.app` to App Store Connect for TestFlight/App Review processing. Public App Store release still depends on App Store Connect review and release settings.
- Manual dispatch can disable either upload path while still producing signed GitHub artifacts.
## Required Secrets ## Required Secrets
Store secrets are expected in the GitHub environment `mobile-store-production`.
Non-secret environment variables:
- `ANDROID_PACKAGE_NAME=io.truckwash.twa`
- `ANDROID_AAB_PATH=android/app/build/outputs/bundle/release/app-release.aab`
- `PLAY_STORE_TRACK=production`
- `PLAY_STORE_RELEASE_STATUS=completed`
- `PLAY_STORE_USER_FRACTION` only when using `PLAY_STORE_RELEASE_STATUS=inProgress`
Android: Android:
- `ANDROID_KEYSTORE_BASE64` - `ANDROID_KEYSTORE_BASE64`
- `ANDROID_KEYSTORE_PASSWORD` - `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS` - `ANDROID_KEY_ALIAS`
- `ANDROID_KEY_PASSWORD` - `ANDROID_KEY_PASSWORD`
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64`
iOS: iOS:
@@ -26,6 +44,11 @@ iOS:
- `IOS_PROVISION_PROFILE_BASE64` - `IOS_PROVISION_PROFILE_BASE64`
- `IOS_KEYCHAIN_PASSWORD` - `IOS_KEYCHAIN_PASSWORD`
- `APPLE_TEAM_ID` - `APPLE_TEAM_ID`
- `APP_STORE_CONNECT_API_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
- `APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64`
The Google Play secret is a base64-encoded service-account JSON file with Android Publisher API access to the Play Console app. The App Store Connect private key secret is the base64-encoded `.p8` API key file.
## Local Checks ## Local Checks
@@ -57,6 +80,21 @@ The signed Android bundle is written to:
android/app/build/outputs/bundle/release/app-release.aab android/app/build/outputs/bundle/release/app-release.aab
``` ```
Upload a locally built signed App Bundle to Google Play after exporting the Play service-account secret and release metadata:
```sh
export GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64=...
export ANDROID_PACKAGE_NAME=io.truckwash.twa
export ANDROID_AAB_PATH=android/app/build/outputs/bundle/release/app-release.aab
export MOBILE_VERSION_NAME=1.4.0
export MOBILE_VERSION_CODE=10400
export PLAY_STORE_TRACK=production
export PLAY_STORE_RELEASE_STATUS=completed
npm run mobile:android:play-upload
```
Use `PLAY_STORE_RELEASE_STATUS=inProgress` only with `PLAY_STORE_USER_FRACTION` set to a value greater than `0` and less than `1`.
Android artifacts use package id `io.truckwash.twa`. iOS artifacts use bundle id `io.truckwash.app`. Android artifacts use package id `io.truckwash.twa`. iOS artifacts use bundle id `io.truckwash.app`.
The Android project currently targets SDK 36. Google Play requires new apps and updates to target Android 15/API 35 or higher starting August 31, 2025: https://developer.android.com/google/play/requirements/target-sdk The Android project currently targets SDK 36. Google Play requires new apps and updates to target Android 15/API 35 or higher starting August 31, 2025: https://developer.android.com/google/play/requirements/target-sdk
+2
View File
@@ -61,9 +61,11 @@
"mobile:android:icons:check": "node scripts/mobile/generate-android-icons.mjs --check", "mobile:android:icons:check": "node scripts/mobile/generate-android-icons.mjs --check",
"mobile:android:sync": "npm run mobile:android:icons && npm run build && npx cap sync android", "mobile:android:sync": "npm run mobile:android:icons && npm run build && npx cap sync android",
"mobile:permissions:check": "node scripts/mobile/check-permissions.mjs", "mobile:permissions:check": "node scripts/mobile/check-permissions.mjs",
"mobile:store:env-check": "node scripts/mobile/check-store-upload-env.mjs",
"mobile:android:signing:check": "node scripts/mobile/check-android-signing-env.mjs", "mobile:android:signing:check": "node scripts/mobile/check-android-signing-env.mjs",
"mobile:android:bundle": "npm run mobile:android:signing:check && npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease", "mobile:android:bundle": "npm run mobile:android:signing:check && npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease",
"mobile:android:bundle:unsigned": "npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease", "mobile:android:bundle:unsigned": "npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease",
"mobile:android:play-upload": "node scripts/mobile/upload-google-play.mjs",
"mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check", "mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs" "playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
}, },
+148
View File
@@ -0,0 +1,148 @@
import { existsSync } from "node:fs";
import { argv, env, exit } from "node:process";
const args = new Set(argv.slice(2));
const failures = [];
const requireVariable = (name) => {
if (!env[name]) {
failures.push(`Missing ${name}`);
}
};
const requireOneVariable = (names, label) => {
if (!names.some((name) => env[name])) {
failures.push(`Missing ${label}: set one of ${names.join(", ")}`);
}
};
const decodeBase64 = (name) => {
if (!env[name]) {
return null;
}
try {
const decoded = Buffer.from(env[name], "base64");
if (decoded.length === 0) {
failures.push(`${name} is empty after base64 decoding`);
return null;
}
return decoded;
} catch {
failures.push(`${name} is not valid base64`);
return null;
}
};
const decodeBase64Json = (name) => {
const decoded = decodeBase64(name);
if (!decoded) {
return null;
}
try {
return JSON.parse(decoded.toString("utf8"));
} catch {
failures.push(`${name} is not base64-encoded JSON`);
return null;
}
};
const isEnabled = (name) => !["false", "0", "no"].includes(String(env[name] ?? "true").toLowerCase());
const checkAndroid = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireOneVariable(["ANDROID_KEYSTORE_BASE64", "ANDROID_KEYSTORE_FILE"], "Android release keystore");
requireVariable("ANDROID_KEYSTORE_PASSWORD");
requireVariable("ANDROID_KEY_ALIAS");
requireVariable("ANDROID_KEY_PASSWORD");
if (env.ANDROID_KEYSTORE_FILE && !existsSync(env.ANDROID_KEYSTORE_FILE)) {
failures.push("ANDROID_KEYSTORE_FILE does not point to an existing file");
}
decodeBase64("ANDROID_KEYSTORE_BASE64");
if (!isEnabled("UPLOAD_ANDROID_TO_PLAY")) {
return;
}
requireVariable("ANDROID_PACKAGE_NAME");
requireVariable("ANDROID_AAB_PATH");
requireVariable("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64");
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
if (!validTracks.has(track)) {
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
}
if (!validStatuses.has(status)) {
failures.push(`PLAY_STORE_RELEASE_STATUS must be one of ${Array.from(validStatuses).join(", ")}`);
}
if (status === "inProgress") {
const fraction = Number(env.PLAY_STORE_USER_FRACTION);
if (!(fraction > 0 && fraction < 1)) {
failures.push("PLAY_STORE_USER_FRACTION must be greater than 0 and less than 1 when status is inProgress");
}
}
const serviceAccount = decodeBase64Json("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64");
if (serviceAccount) {
if (!serviceAccount.client_email) {
failures.push("Google Play service account JSON is missing client_email");
}
if (!serviceAccount.private_key) {
failures.push("Google Play service account JSON is missing private_key");
}
}
};
const checkIos = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireVariable("IOS_CERTIFICATE_BASE64");
requireVariable("IOS_CERTIFICATE_PASSWORD");
requireVariable("IOS_PROVISION_PROFILE_BASE64");
requireVariable("IOS_KEYCHAIN_PASSWORD");
requireVariable("APPLE_TEAM_ID");
decodeBase64("IOS_CERTIFICATE_BASE64");
decodeBase64("IOS_PROVISION_PROFILE_BASE64");
if (!isEnabled("UPLOAD_IOS_TO_APP_STORE")) {
return;
}
requireVariable("APP_STORE_CONNECT_API_KEY_ID");
requireVariable("APP_STORE_CONNECT_ISSUER_ID");
requireVariable("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
const privateKey = decodeBase64("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
if (privateKey && !privateKey.toString("utf8").includes("PRIVATE KEY")) {
failures.push("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 does not look like a .p8 private key");
}
};
if (args.has("--android")) {
checkAndroid();
}
if (args.has("--ios")) {
checkIos();
}
if (!args.has("--android") && !args.has("--ios")) {
failures.push("Pass --android or --ios");
}
if (failures.length > 0) {
console.error("Mobile store upload environment is not configured:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
exit(1);
}
console.log("Mobile store upload environment is configured.");
+260
View File
@@ -0,0 +1,260 @@
import { createSign } from "node:crypto";
import { existsSync, readFileSync, appendFileSync } from "node:fs";
import { env, exit } from "node:process";
const androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher";
const requiredVariables = [
"ANDROID_PACKAGE_NAME",
"ANDROID_AAB_PATH",
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64",
"MOBILE_VERSION_NAME",
"MOBILE_VERSION_CODE",
];
const fail = (message) => {
console.error(message);
exit(1);
};
const requireEnvironment = () => {
const missingVariables = requiredVariables.filter((name) => !env[name]);
if (missingVariables.length > 0) {
fail(`Google Play upload is not configured. Missing: ${missingVariables.join(", ")}`);
}
if (!existsSync(env.ANDROID_AAB_PATH)) {
fail(`Android App Bundle not found at ${env.ANDROID_AAB_PATH}`);
}
};
const base64Url = (value) =>
Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
const parseServiceAccount = () => {
let serviceAccount;
try {
serviceAccount = JSON.parse(Buffer.from(env.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64, "base64").toString("utf8"));
} catch {
fail("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 must be base64-encoded service account JSON.");
}
if (!serviceAccount.client_email || !serviceAccount.private_key) {
fail("Google Play service account JSON must include client_email and private_key.");
}
return serviceAccount;
};
const createJwtAssertion = (serviceAccount) => {
const now = Math.floor(Date.now() / 1000);
const header = {
alg: "RS256",
typ: "JWT",
};
const claims = {
iss: serviceAccount.client_email,
scope: androidPublisherScope,
aud: "https://oauth2.googleapis.com/token",
exp: now + 3600,
iat: now,
};
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(claims))}`;
const signer = createSign("RSA-SHA256");
signer.update(signingInput);
signer.end();
return `${signingInput}.${base64Url(signer.sign(serviceAccount.private_key))}`;
};
const readJsonResponse = async (response, label) => {
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = { raw: text };
}
}
if (!response.ok) {
const detail = body?.error?.message || body?.raw || response.statusText;
throw new Error(`${label} failed with HTTP ${response.status}: ${detail}`);
}
return body;
};
const requestJson = async (url, options, label) => {
const response = await fetch(url, options);
return readJsonResponse(response, label);
};
const getAccessToken = async (serviceAccount) => {
const body = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: createJwtAssertion(serviceAccount),
});
const tokenResponse = await requestJson(
"https://oauth2.googleapis.com/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
"Google OAuth token request",
);
if (!tokenResponse?.access_token) {
throw new Error("Google OAuth token response did not include access_token.");
}
return tokenResponse.access_token;
};
const playUrl = (path) => `https://androidpublisher.googleapis.com/androidpublisher/v3/${path}`;
const playUploadUrl = (path) => `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/${path}`;
const authorizedJson = (accessToken, extraHeaders = {}) => ({
Authorization: `Bearer ${accessToken}`,
...extraHeaders,
});
const insertEdit = async (accessToken, packageName) =>
requestJson(
playUrl(`applications/${encodeURIComponent(packageName)}/edits`),
{
method: "POST",
headers: authorizedJson(accessToken),
},
"Google Play edit insert",
);
const deleteEdit = async (accessToken, packageName, editId) => {
const response = await fetch(playUrl(`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}`), {
method: "DELETE",
headers: authorizedJson(accessToken),
});
if (!response.ok && response.status !== 404) {
const body = await response.text();
console.warn(`Could not delete failed Google Play edit ${editId}: HTTP ${response.status} ${body}`);
}
};
const uploadBundle = async (accessToken, packageName, editId, bundlePath) =>
requestJson(
`${playUploadUrl(
`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/bundles`,
)}?uploadType=media`,
{
method: "POST",
headers: authorizedJson(accessToken, {
"Content-Type": "application/octet-stream",
}),
body: readFileSync(bundlePath),
},
"Google Play bundle upload",
);
const updateTrack = async (accessToken, packageName, editId, versionCode) => {
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
if (!validTracks.has(track)) {
throw new Error(`Unsupported PLAY_STORE_TRACK: ${track}`);
}
if (!validStatuses.has(status)) {
throw new Error(`Unsupported PLAY_STORE_RELEASE_STATUS: ${status}`);
}
const release = {
name: env.PLAY_STORE_RELEASE_NAME || `Truck Wash ${env.MOBILE_VERSION_NAME} (${versionCode})`,
versionCodes: [String(versionCode)],
status,
};
if (status === "inProgress") {
const userFraction = Number(env.PLAY_STORE_USER_FRACTION);
if (!(userFraction > 0 && userFraction < 1)) {
throw new Error("PLAY_STORE_USER_FRACTION must be greater than 0 and less than 1 when status is inProgress.");
}
release.userFraction = userFraction;
}
return requestJson(
playUrl(
`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/tracks/${encodeURIComponent(track)}`,
),
{
method: "PUT",
headers: authorizedJson(accessToken, {
"Content-Type": "application/json",
}),
body: JSON.stringify({
track,
releases: [release],
}),
},
"Google Play track update",
);
};
const commitEdit = async (accessToken, packageName, editId) =>
requestJson(
playUrl(`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}:commit`),
{
method: "POST",
headers: authorizedJson(accessToken),
},
"Google Play edit commit",
);
const writeStepSummary = (summary) => {
if (!env.GITHUB_STEP_SUMMARY) {
return;
}
appendFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n`);
};
const main = async () => {
requireEnvironment();
const packageName = env.ANDROID_PACKAGE_NAME;
const serviceAccount = parseServiceAccount();
const accessToken = await getAccessToken(serviceAccount);
let editId = null;
try {
const edit = await insertEdit(accessToken, packageName);
editId = edit.id;
if (!editId) {
throw new Error("Google Play edit insert response did not include id.");
}
const bundle = await uploadBundle(accessToken, packageName, editId, env.ANDROID_AAB_PATH);
const versionCode = String(bundle?.versionCode || env.MOBILE_VERSION_CODE);
await updateTrack(accessToken, packageName, editId, versionCode);
await commitEdit(accessToken, packageName, editId);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
console.log(`Uploaded Android App Bundle ${versionCode} to Google Play ${track} with status ${status}.`);
writeStepSummary(`Android App Bundle ${versionCode} uploaded to Google Play ${track} with status ${status}.`);
} catch (error) {
if (editId) {
await deleteEdit(accessToken, packageName, editId);
}
throw error;
}
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(1);
});
+339
View File
@@ -0,0 +1,339 @@
<script setup lang="ts">
import { computed, provide, reactive, watch } from "vue";
import BuefyTreeNode from "./BuefyTreeNode.vue";
type TreeNode = Record<string, any>;
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[]>;
ariaLabel?: string;
}>(), {
data: () => [],
fields: () => ({}),
selectionMode: "none",
selected: null,
expandedKeys: () => [],
checkedKeys: () => [],
defaultExpandAll: false,
expandOnClickNode: true,
lazy: false,
load: undefined,
ariaLabel: undefined,
});
const emit = defineEmits<{
(event: "update:selected", value: any): void;
(event: "update:expandedKeys", value: any[]): void;
(event: "update:checkedKeys", value: any[]): void;
(event: "select", node: TreeNode, key: any): void;
(event: "check", node: TreeNode, key: any, checkedKeys: any[]): void;
(event: "expand", node: TreeNode, key: any): void;
(event: "collapse", node: TreeNode, key: any): void;
(event: "node-click", node: TreeNode, key: any): void;
(event: "load-start", node: TreeNode, key: any): void;
(event: "load-error", error: unknown, node: TreeNode, key: any): void;
}>();
const resolvedFields = computed(() => ({
id: props.fields?.id || "id",
label: props.fields?.label || "label",
children: props.fields?.children || "children",
isLeaf: props.fields?.isLeaf || "isLeaf",
disabled: props.fields?.disabled || "disabled",
}));
const state = reactive({
selected: props.selected,
expandedKeys: [...props.expandedKeys],
checkedKeys: [...props.checkedKeys],
lazyChildrenCache: {} as Record<string, TreeNode[]>,
loadingKeys: [] as any[],
loadErrorKeys: [] as any[],
});
const keyOf = (node: TreeNode) => node?.[resolvedFields.value.id];
const keyString = (key: any) => String(key ?? "");
const childrenOf = (node: TreeNode) => {
const children = node?.[resolvedFields.value.children];
return Array.isArray(children) ? children : [];
};
const cachedChildrenOf = (node: TreeNode) => state.lazyChildrenCache[keyString(keyOf(node))] || [];
const effectiveChildrenOf = (node: TreeNode) => {
const children = childrenOf(node);
return children.length > 0 ? children : cachedChildrenOf(node);
};
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
const isCheckDisabled = (node: TreeNode) => isDisabled(node) || (!isSelfSelectable(node) && !isBranchCheckable(node));
const isLeaf = (node: TreeNode) => Boolean(node?.[resolvedFields.value.isLeaf]);
const collectKeys = (node: TreeNode): any[] => {
const keys: any[] = [];
const key = keyOf(node);
if (key !== undefined && key !== null && isSelfSelectable(node)) {
keys.push(key);
}
effectiveChildrenOf(node).forEach((child) => {
keys.push(...collectKeys(child));
});
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) => {
state.selected = value;
}
);
watch(
() => props.expandedKeys,
(value) => {
state.expandedKeys = [...value];
},
{ deep: true }
);
watch(
() => props.checkedKeys,
(value) => {
state.checkedKeys = [...value];
},
{ deep: true }
);
watch(
() => props.data,
(nodes) => {
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]);
};
const setCheckedKeys = (keys: any[]) => {
state.checkedKeys = [...new Set(keys)];
emit("update:checkedKeys", [...state.checkedKeys]);
};
const loadNode = async (node: TreeNode) => {
if (!props.lazy || !props.load) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (state.loadingKeys.includes(key)) {
return;
}
state.loadingKeys = [...state.loadingKeys, key];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
emit("load-start", node, key);
try {
state.lazyChildrenCache[keyString(key)] = await props.load(node);
} 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 ensureChildrenLoadedForCheck = async (node: TreeNode) => {
if (!props.lazy || !props.load || isLeaf(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (childrenOf(node).length > 0 || state.lazyChildrenCache[keyString(key)]) {
return;
}
await loadNode(node);
};
const toggleExpand = async (node: TreeNode) => {
if (isDisabled(node) || isLeaf(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (state.expandedKeys.includes(key)) {
setExpandedKeys(state.expandedKeys.filter((current) => current !== key));
emit("collapse", node, key);
return;
}
setExpandedKeys([...state.expandedKeys, key]);
emit("expand", node, key);
if (props.lazy && !state.lazyChildrenCache[keyString(key)]) {
await loadNode(node);
}
};
const retryLoad = async (node: TreeNode) => {
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
delete state.lazyChildrenCache[keyString(key)];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
if (!state.expandedKeys.includes(key)) {
setExpandedKeys([...state.expandedKeys, key]);
}
await loadNode(node);
};
const toggleCheck = async (node: TreeNode) => {
if (props.selectionMode !== "checkbox" || isCheckDisabled(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
await ensureChildrenLoadedForCheck(node);
const keys = collectKeys(node);
if (keys.length === 0) {
emit("check", node, key, [...state.checkedKeys]);
return;
}
const allChecked = keys.every((current) => state.checkedKeys.includes(current));
if (allChecked) {
const toRemove = new Set(keys);
setCheckedKeys(state.checkedKeys.filter((current) => !toRemove.has(current)));
} else {
setCheckedKeys([...state.checkedKeys, ...keys]);
}
emit("check", node, key, [...state.checkedKeys]);
};
const checkState = (node: TreeNode): "checked" | "unchecked" | "indeterminate" => {
const key = keyOf(node);
const children = effectiveChildrenOf(node);
if (children.length === 0) {
return state.checkedKeys.includes(key) ? "checked" : "unchecked";
}
const states = children.map((child) => checkState(child));
if (states.every((item) => item === "checked")) {
return "checked";
}
if (states.every((item) => item === "unchecked") && !state.checkedKeys.includes(key)) {
return "unchecked";
}
return "indeterminate";
};
const handleNodeClick = async (node: TreeNode) => {
if (isDisabled(node)) {
return;
}
const key = keyOf(node);
emit("node-click", node, key);
if (props.selectionMode === "single") {
state.selected = key;
emit("update:selected", key);
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];
emit("update:selected", state.selected);
emit("select", node, key);
}
if (props.expandOnClickNode) {
await toggleExpand(node);
}
};
provide("BuefyTreeContext", {
props,
state,
fields: resolvedFields,
keyOf,
childrenOf,
effectiveChildrenOf,
isDisabled,
isCheckDisabled,
isLeaf,
toggleExpand,
retryLoad,
toggleCheck,
checkState,
handleNodeClick,
});
</script>
<template>
<ul class="b-tree" role="tree" :aria-label="ariaLabel">
<BuefyTreeNode
v-for="(node, index) in data"
:key="keyOf(node) ?? index"
:node="node"
:depth="1"
:setsize="data.length"
:posinset="index + 1"
>
<template #default="slotProps">
<slot v-bind="slotProps">
{{ slotProps.node?.[resolvedFields.label] }}
</slot>
</template>
<template #icon="slotProps">
<slot name="icon" v-bind="slotProps"></slot>
</template>
</BuefyTreeNode>
</ul>
</template>
<style scoped>
.b-tree {
list-style: none;
margin: 0;
padding: 0;
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { BCheckbox } from "buefy";
type TreeNode = Record<string, any>;
const props = defineProps<{
node: TreeNode;
depth: number;
setsize: number;
posinset: number;
}>();
const tree = inject<any>("BuefyTreeContext");
if (!tree) {
throw new Error("BuefyTreeNode must be used inside BuefyTree");
}
const keyValue = computed(() => tree.keyOf(props.node));
const keyString = computed(() => String(keyValue.value ?? ""));
const children = computed(() => tree.effectiveChildrenOf(props.node));
const checkState = computed(() => tree.checkState(props.node));
const isExpanded = computed(() => tree.state.expandedKeys.includes(keyValue.value));
const isLoading = computed(() => tree.state.loadingKeys.includes(keyValue.value));
const hasLoadError = computed(() => tree.state.loadErrorKeys.includes(keyValue.value));
const isSelected = computed(() => {
const selected = tree.state.selected;
return Array.isArray(selected) ? selected.includes(keyValue.value) : selected === keyValue.value;
});
const isDisabled = computed(() => tree.isDisabled(props.node));
const isCheckDisabled = computed(() => tree.isCheckDisabled(props.node));
const isLeaf = computed(() => tree.isLeaf(props.node));
const hasExpandToggle = computed(() => {
if (isLeaf.value) {
return false;
}
return tree.props.lazy || children.value.length > 0;
});
const label = computed(() => String(props.node?.[tree.fields.value.label] ?? ""));
</script>
<template>
<li
class="b-tree-node"
:class="{
'is-expanded': isExpanded,
'is-selected': isSelected,
'is-disabled': isDisabled,
'is-loading': isLoading,
'has-load-error': hasLoadError,
}"
role="treeitem"
:aria-expanded="hasExpandToggle ? isExpanded : undefined"
:aria-selected="tree.props.selectionMode !== 'checkbox' && tree.props.selectionMode !== 'none' ? isSelected : undefined"
:aria-checked="tree.props.selectionMode === 'checkbox' ? (checkState === 'indeterminate' ? 'mixed' : checkState === 'checked') : undefined"
:aria-level="depth"
:aria-setsize="setsize"
:aria-posinset="posinset"
:aria-disabled="isDisabled || undefined"
:data-node-key="keyString"
>
<div class="b-tree-node-content" @click.stop="tree.handleNodeClick(node)">
<button
type="button"
class="button is-white is-small b-tree-node-toggle"
:class="{ 'is-invisible': !hasExpandToggle }"
:disabled="isDisabled"
@click.stop="tree.toggleExpand(node)"
>
<span class="icon is-small">
<i v-if="isLoading" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas" :class="isExpanded ? 'fa-caret-down' : 'fa-caret-right'"></i>
</span>
</button>
<span
v-if="tree.props.selectionMode === 'checkbox'"
class="b-tree-node-checkbox"
@click.stop
>
<BCheckbox
:model-value="checkState === 'checked'"
:indeterminate="checkState === 'indeterminate'"
:disabled="isCheckDisabled"
tabindex="-1"
@update:model-value="tree.toggleCheck(node)"
/>
</span>
<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>
</span>
</slot>
</span>
<span class="b-tree-node-label">
<slot
:node="node"
:data="node"
:depth="depth"
:expanded="isExpanded"
:checked="checkState === 'checked'"
:indeterminate="checkState === 'indeterminate'"
:selected="isSelected"
:loading="isLoading"
:error="hasLoadError"
:retry="() => tree.retryLoad(node)"
>
{{ label }}
</slot>
</span>
</div>
<ul v-if="isExpanded && children.length > 0" class="b-tree-children" role="group">
<BuefyTreeNode
v-for="(child, index) in children"
:key="tree.keyOf(child) ?? index"
:node="child"
:depth="depth + 1"
:setsize="children.length"
:posinset="index + 1"
>
<template #default="slotProps">
<slot v-bind="slotProps"></slot>
</template>
<template #icon="slotProps">
<slot name="icon" v-bind="slotProps"></slot>
</template>
</BuefyTreeNode>
</ul>
</li>
</template>
<style scoped>
.b-tree-node {
list-style: none;
}
.b-tree-node-content {
align-items: flex-start;
border-radius: 6px;
cursor: pointer;
display: flex;
gap: 0.35rem;
min-height: 2rem;
padding: 0.25rem 0.35rem;
}
.b-tree-node-content:hover {
background: #f5f7fa;
}
.b-tree-node.is-selected > .b-tree-node-content {
background: #eef4ff;
}
.b-tree-node.is-disabled > .b-tree-node-content {
cursor: default;
opacity: 0.65;
}
.b-tree-node-toggle {
flex: 0 0 1.85rem;
height: 1.75rem;
padding: 0;
width: 1.85rem;
}
.b-tree-node-checkbox {
flex: 0 0 auto;
margin-top: 0.08rem;
}
.b-tree-node-icon {
flex: 0 0 auto;
margin-top: 0.2rem;
}
.b-tree-node-label {
flex: 1 1 auto;
min-width: 0;
}
.b-tree-children {
border-left: 1px solid #dfe4ea;
list-style: none;
margin: 0 0 0 1.05rem;
padding: 0 0 0 0.65rem;
}
.is-invisible {
visibility: hidden;
}
</style>
@@ -0,0 +1,116 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { listModuleUsageSummary } from "@/services/moduleUsage.js";
const props = defineProps({
moduleKey: {
type: String,
required: true,
},
metricKey: {
type: String,
required: true,
},
});
const { t, locale } = useI18n();
const loading = ref(false);
const metric = ref(null);
const usagePercent = computed(() => Number(metric.value?.usage_percent ?? 0));
const hasMetric = computed(() => metric.value && (metric.value.used !== null || metric.value.limit !== null));
const load = async () => {
loading.value = true;
try {
const response = await listModuleUsageSummary({ module: props.moduleKey });
const metrics = Array.isArray(response?.data?.data?.metrics) ? response.data.data.metrics : [];
metric.value = metrics.find((row) => row.metric_key === props.metricKey) || null;
} finally {
loading.value = false;
}
};
function formatNumber(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "--";
}
return new Intl.NumberFormat(locale.value || "da").format(Number(value));
}
function formatPercent(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "--";
}
return `${Number(value).toFixed(1)}%`;
}
function progressClass(value) {
const percent = Number(value);
if (!Number.isFinite(percent)) {
return "";
}
if (percent >= 100) {
return "is-danger";
}
if (percent >= 90) {
return "is-warning";
}
return "is-success";
}
onMounted(load);
</script>
<template>
<div v-if="loading || hasMetric" class="module-usage-meter" :data-testid="`module-usage-meter-${moduleKey}-${metricKey}`">
<div class="module-usage-meter__top">
<span>{{ metric?.metric_label || $t("system_status.labels.quota_usage") }}</span>
<strong>{{ loading ? "--" : formatPercent(metric?.usage_percent) }}</strong>
</div>
<progress
class="progress module-usage-meter__progress"
:class="progressClass(usagePercent)"
:value="Math.max(0, Math.min(100, usagePercent))"
max="100"
/>
<div class="module-usage-meter__stats">
<span>{{ t("system_status.labels.used") }}: {{ formatNumber(metric?.used) }}</span>
<span>{{ t("system_status.labels.limit") }}: {{ formatNumber(metric?.limit) }}</span>
<span>{{ t("system_status.labels.remaining") }}: {{ formatNumber(metric?.remaining) }}</span>
</div>
</div>
</template>
<style scoped>
.module-usage-meter {
display: grid;
gap: 0.4rem;
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px solid #d7dde7;
border-radius: 8px;
background: #f8fafc;
}
.module-usage-meter__top {
display: flex;
justify-content: space-between;
gap: 0.75rem;
color: #334155;
}
.module-usage-meter__progress {
height: 0.55rem;
margin: 0;
}
.module-usage-meter__stats {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: #475569;
font-size: 0.85rem;
}
</style>
@@ -6,17 +6,15 @@ import {
SuperUserSystemStatusObject, SuperUserSystemStatusObject,
getSuperuserSystemStatus, getSuperuserSystemStatus,
} from "@/components/session/token/superUser/systemStatus.vue"; } from "@/components/session/token/superUser/systemStatus.vue";
import SystemStatusModuleIndicator from "@/components/displays/superuser/system/SystemStatusModuleIndicator.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { import { listEdgeGatewayDepartments, listEdgeGateways, unwrapEdgeGatewayMeta } from "@/services/edgeGateways.js";
listEdgeGatewayDepartments,
listEdgeGateways,
unwrapEdgeGatewayMeta,
} from "@/services/edgeGateways.js";
const { t, te, locale } = useI18n(); const { t, te, locale } = useI18n();
const MAX_GATEWAY_CARDS = 8; const MAX_GATEWAY_CARDS = 8;
const DEFAULT_DASHBOARD_TAB = "infrastructure"; const DEFAULT_DASHBOARD_TAB = "infrastructure";
const MODULE_PLACEHOLDER_CARDS = [0, 1, 2];
const gatewayStatusPriority = Object.freeze({ const gatewayStatusPriority = Object.freeze({
OFFLINE: 0, OFFLINE: 0,
DEGRADED: 1, DEGRADED: 1,
@@ -80,6 +78,8 @@ const gatewayDepartments = ref({});
const gatewayDepartmentsLoaded = ref(false); const gatewayDepartmentsLoaded = ref(false);
const activeDashboardTab = ref(DEFAULT_DASHBOARD_TAB); const activeDashboardTab = ref(DEFAULT_DASHBOARD_TAB);
const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value); const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value);
const showModuleSkeletonCards = computed(() => loading.value && modules.value.length === 0);
const showModuleErrorCards = computed(() => Boolean(error.value) && modules.value.length === 0);
const dashboardTabKeys = computed(() => [ const dashboardTabKeys = computed(() => [
DEFAULT_DASHBOARD_TAB, DEFAULT_DASHBOARD_TAB,
...(showGatewaySection.value ? ["gateways"] : []), ...(showGatewaySection.value ? ["gateways"] : []),
@@ -192,7 +192,7 @@ const isStale = computed(() => {
if (!lastLoadedAt.value) { if (!lastLoadedAt.value) {
return false; return false;
} }
return nowTick.value - lastLoadedAt.value.getTime() > (refreshAfterSeconds.value * 2000); return nowTick.value - lastLoadedAt.value.getTime() > refreshAfterSeconds.value * 2000;
}); });
watch( watch(
@@ -206,10 +206,7 @@ watch(
); );
const loadStatus = async ({ force = false } = {}) => { const loadStatus = async ({ force = false } = {}) => {
const [snapshotValue] = await Promise.all([ const [snapshotValue] = await Promise.all([getSuperuserSystemStatus({ force }), loadGatewayFleet({ force })]);
getSuperuserSystemStatus({ force }),
loadGatewayFleet({ force }),
]);
return snapshotValue; return snapshotValue;
}; };
@@ -330,6 +327,10 @@ function moduleReasonText(module) {
); );
} }
function systemStatusErrorMessage() {
return error.value?.message || t("system_status.states.error_generic");
}
function createEmptyGatewayFleetUsage() { function createEmptyGatewayFleetUsage() {
return { return {
total: 0, total: 0,
@@ -408,12 +409,7 @@ async function ensureGatewayDepartmentsLoaded() {
} }
function warningText(warning) { function warningText(warning) {
return translateSystemStatusText( return translateSystemStatusText("warnings", warning?.key, warning?.params, warning?.message || "");
"warnings",
warning?.key,
warning?.params,
warning?.message || ""
);
} }
function sessionDisplayName(session) { function sessionDisplayName(session) {
@@ -443,7 +439,9 @@ function formatDeviceType(deviceType) {
} }
function normalizeGatewayStatus(status) { function normalizeGatewayStatus(status) {
const normalizedStatus = String(status || "").trim().toUpperCase(); const normalizedStatus = String(status || "")
.trim()
.toUpperCase();
return normalizedStatus || "UNKNOWN"; return normalizedStatus || "UNKNOWN";
} }
@@ -460,16 +458,13 @@ function gatewayToneClass(status) {
function gatewayStatusLabel(status) { function gatewayStatusLabel(status) {
const normalizedStatus = normalizeGatewayStatus(status).toLowerCase(); const normalizedStatus = normalizeGatewayStatus(status).toLowerCase();
return translateSystemStatusText( return translateSystemStatusText("gateways.status", normalizedStatus, {}, t("system_status.gateways.status.unknown"));
"gateways.status",
normalizedStatus,
{},
t("system_status.gateways.status.unknown")
);
} }
function gatewayDiscoveryLabel(status) { function gatewayDiscoveryLabel(status) {
const normalizedStatus = String(status || "unknown").trim().toLowerCase(); const normalizedStatus = String(status || "unknown")
.trim()
.toLowerCase();
return translateSystemStatusText( return translateSystemStatusText(
"gateways.discovery_status", "gateways.discovery_status",
normalizedStatus, normalizedStatus,
@@ -514,7 +509,180 @@ function formatNumber(value) {
} }
function hasModuleUsage(module) { function hasModuleUsage(module) {
return module?.usage && typeof module.usage === "object"; return visibleModuleUsageMetrics(module).length > 0;
}
function moduleUsageErrorMessage(module) {
return String(module?.usage_metrics_error || "").trim();
}
function showModuleUsageSkeleton(module) {
return loading.value && !hasModuleUsage(module) && moduleUsageErrorMessage(module) === "";
}
function showModuleUsageUnavailablePlaceholder(module) {
return !loading.value && !hasModuleUsage(module) && moduleUsageErrorMessage(module) === "";
}
function moduleUsageMetrics(module) {
if (Array.isArray(module?.usage_metrics) && module.usage_metrics.length > 0) {
return module.usage_metrics;
}
if (module?.usage && typeof module.usage === "object") {
return [
{
module_key: module.key,
metric_key: module.usage.metric_key || "usage",
metric_label: t("system_status.labels.quota_usage"),
unit: module.usage.unit || "calls",
period: module.usage.period || "provider",
source: module.usage.source || "provider_snapshot",
primary: true,
used: module.usage.calls_used,
limit: module.usage.quota_calls,
remaining: module.usage.calls_remaining,
usage_percent: module.usage.usage_percent,
status: module.usage.status || "ok",
enforce_mode: module.usage.enforce_mode || "observe",
version: module.usage.version,
usage_available: module.usage.usage_available !== false,
unavailable_reason: module.usage.unavailable_reason || null,
},
];
}
return [];
}
function visibleModuleUsageMetrics(module) {
return moduleUsageMetrics(module)
.filter(
(metric) =>
metric.used !== null ||
metric.limit !== null ||
metric.usage_percent !== null ||
isModuleUsageUnavailable(metric)
)
.filter(
(metric) =>
metric.limit !== null ||
metric.usage_percent !== null ||
metric.writable_limit ||
metric.source === "provider_snapshot"
)
.sort((left, right) => {
if ((left.primary || false) !== (right.primary || false)) {
return (right.primary || false) - (left.primary || false);
}
return moduleUsageLabel(left).localeCompare(moduleUsageLabel(right));
});
}
function isModuleUsageUnavailable(metric) {
return metric?.usage_available === false;
}
function moduleUsageUnavailableReason(metric) {
const reason = String(metric?.unavailable_reason || "").trim();
if (reason === "") {
return t("system_status.states.module_usage_error");
}
const translationKey = `system_status.quota_unavailable_reasons.${reason}`;
return te(translationKey) ? t(translationKey) : reason.replaceAll("_", " ");
}
function moduleUsageLabel(metric) {
if (!metric) {
return t("system_status.labels.quota_usage");
}
if (isModuleUsageUnavailable(metric)) {
return t("system_status.labels.usage_unavailable");
}
return metric.metric_label || metric.metric_key || t("system_status.labels.quota_usage");
}
function moduleUsageUsedLabel(metric) {
return metric?.unit === "calls" ? t("system_status.labels.calls_used") : t("system_status.labels.used");
}
function moduleUsageLimitLabel(metric) {
return metric?.unit === "calls" ? t("system_status.labels.quota_calls") : t("system_status.labels.limit");
}
function moduleUsageRemainingLabel(metric) {
return metric?.unit === "calls" ? t("system_status.labels.calls_remaining") : t("system_status.labels.remaining");
}
function moduleBooleanLabel(value) {
return value ? t("system_status.status.yes") : t("system_status.status.no");
}
function moduleEnabledIndicator(module) {
const isEnabled = module?.enabled === true;
return {
iconClass: "fas fa-power-off",
badgeIconClass: isEnabled ? "fas fa-check" : "fas fa-xmark",
tone: isEnabled ? "ok" : "down",
badgeTone: isEnabled ? "ok" : "down",
label: `${t("system_status.labels.enabled")}: ${moduleBooleanLabel(isEnabled)}`,
};
}
function moduleConfiguredIndicator(module) {
const isConfigured = module?.configured === true;
return {
iconClass: "fas fa-cog",
badgeIconClass: isConfigured ? "fas fa-check" : "fas fa-xmark",
tone: isConfigured ? "ok" : "down",
badgeTone: isConfigured ? "ok" : "down",
label: `${t("system_status.labels.configured")}: ${moduleBooleanLabel(isConfigured)}`,
};
}
function moduleCheckedIndicator(module) {
const checkedAt = formatDate(module?.checked_at);
const hasCheckedAt = checkedAt !== "--";
return {
iconClass: "fas fa-clipboard-check",
badgeIconClass: hasCheckedAt ? "fas fa-check" : "fas fa-question",
tone: hasCheckedAt ? "ok" : "neutral",
badgeTone: hasCheckedAt ? "ok" : "neutral",
label: `${t("system_status.labels.checked_at")}: ${hasCheckedAt ? checkedAt : t("system_status.status.unknown")}`,
};
}
function moduleConfigActionLabel(module) {
return `${t("system_status.actions.open_config")}: ${moduleLabel(module?.key)}`;
}
function moduleVersion(module) {
const directVersion = String(module?.version || "").trim();
if (directVersion !== "") {
return directVersion;
}
const usageVersion = String(module?.usage?.version || "").trim();
if (usageVersion !== "") {
return usageVersion;
}
const metricWithVersion = visibleModuleUsageMetrics(module).find(
(metric) => String(metric?.version || "").trim() !== ""
);
return metricWithVersion ? String(metricWithVersion.version).trim() : "";
}
function formatMetricValue(value, unit = "") {
if (unit === "bytes") {
return formatBytes(value);
}
const formatted = formatNumber(value);
if (formatted === "--" || !unit || unit === "calls") {
return formatted;
}
return `${formatted} ${unit}`;
} }
function boundedUsagePercent(value) { function boundedUsagePercent(value) {
@@ -682,7 +850,8 @@ function compareGateways(left, right) {
return rankDifference; return rankDifference;
} }
const heartbeatDifference = gatewayHeartbeatTimestamp(left?.last_heartbeat_at) - gatewayHeartbeatTimestamp(right?.last_heartbeat_at); const heartbeatDifference =
gatewayHeartbeatTimestamp(left?.last_heartbeat_at) - gatewayHeartbeatTimestamp(right?.last_heartbeat_at);
if (heartbeatDifference !== 0) { if (heartbeatDifference !== 0) {
return heartbeatDifference; return heartbeatDifference;
} }
@@ -749,10 +918,7 @@ function modulePath(key) {
<div v-if="warningEntries.length" class="notification is-warning is-light"> <div v-if="warningEntries.length" class="notification is-warning is-light">
<strong>{{ $t("system_status.labels.warnings") }}</strong> <strong>{{ $t("system_status.labels.warnings") }}</strong>
<ul class="warning-list"> <ul class="warning-list">
<li <li v-for="warning in warningEntries" :key="`${warning.key || 'warning'}-${warning.message}`">
v-for="warning in warningEntries"
:key="`${warning.key || 'warning'}-${warning.message}`"
>
{{ warningText(warning) }} {{ warningText(warning) }}
</li> </li>
</ul> </ul>
@@ -805,7 +971,6 @@ function modulePath(key) {
</article> </article>
</div> </div>
</section> </section>
</b-tab-item> </b-tab-item>
<b-tab-item v-if="showGatewaySection" value="gateways"> <b-tab-item v-if="showGatewaySection" value="gateways">
@@ -816,10 +981,7 @@ function modulePath(key) {
</span> </span>
</template> </template>
<section <section class="system-status-section" data-testid="system-status-gateways">
class="system-status-section"
data-testid="system-status-gateways"
>
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h3>{{ $t("system_status.sections.gateways") }}</h3> <h3>{{ $t("system_status.sections.gateways") }}</h3>
@@ -851,10 +1013,7 @@ function modulePath(key) {
{{ $t("system_status.gateways.error") }} {{ $t("system_status.gateways.error") }}
</div> </div>
<div <div v-if="gatewayLoading && !gatewayCards.length" class="notification is-light gateway-notification">
v-if="gatewayLoading && !gatewayCards.length"
class="notification is-light gateway-notification"
>
{{ $t("system_status.gateways.loading") }} {{ $t("system_status.gateways.loading") }}
</div> </div>
@@ -875,9 +1034,7 @@ function modulePath(key) {
</div> </div>
<div class="gateway-card__meta"> <div class="gateway-card__meta">
<span> <span> {{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }} </span>
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
</span>
<span> <span>
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }} {{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
</span> </span>
@@ -896,15 +1053,10 @@ function modulePath(key) {
</article> </article>
</div> </div>
<div <div v-else-if="!gatewayLoading && !gatewayError" class="gateway-empty" data-testid="gateway-empty-state">
v-else-if="!gatewayLoading && !gatewayError"
class="gateway-empty"
data-testid="gateway-empty-state"
>
{{ $t("system_status.gateways.empty") }} {{ $t("system_status.gateways.empty") }}
</div> </div>
</section> </section>
</b-tab-item> </b-tab-item>
<b-tab-item value="modules"> <b-tab-item value="modules">
@@ -919,7 +1071,114 @@ function modulePath(key) {
<div class="section-heading"> <div class="section-heading">
<h3>{{ $t("system_status.sections.modules") }}</h3> <h3>{{ $t("system_status.sections.modules") }}</h3>
</div> </div>
<div class="module-grid"> <div v-if="showModuleSkeletonCards" class="module-grid" data-testid="system-status-modules-loading">
<article
v-for="placeholder in MODULE_PLACEHOLDER_CARDS"
:key="`module-skeleton-${placeholder}`"
class="module-card module-card--placeholder"
:data-testid="`module-card-skeleton-${placeholder}`"
>
<div class="module-card__top">
<div class="module-card__identity">
<b-skeleton width="46%" height="1.25rem" />
<small class="module-card__version-line module-card__version-line--empty" aria-hidden="true"></small>
</div>
<b-skeleton width="88px" height="1.65rem" />
</div>
<div class="module-card__reason module-card__reason--skeleton">
<b-skeleton width="100%" height="0.95rem" />
<b-skeleton width="72%" height="0.95rem" />
</div>
<div class="module-card__usage-list">
<div
class="module-card__usage module-card__usage--loading"
:data-testid="`module-usage-skeleton-${placeholder}`"
>
<div class="module-card__usage-top">
<b-skeleton width="58%" height="1rem" />
<b-skeleton width="48px" height="1rem" />
</div>
<b-skeleton width="100%" height="0.55rem" />
<div class="module-card__usage-grid">
<span><b-skeleton width="86%" height="2.2rem" /></span>
<span><b-skeleton width="86%" height="2.2rem" /></span>
<span><b-skeleton width="86%" height="2.2rem" /></span>
</div>
</div>
</div>
<div
class="module-card__footer module-card__footer--skeleton"
:data-testid="`module-footer-skeleton-${placeholder}`"
>
<div class="module-card__indicator-group" aria-hidden="true">
<span class="module-card__indicator-placeholder"></span>
<span class="module-card__indicator-placeholder"></span>
<span class="module-card__indicator-placeholder"></span>
</div>
<span class="module-card__config-action module-card__config-action--placeholder" aria-hidden="true">
<i class="fas fa-arrow-right"></i>
</span>
</div>
</article>
</div>
<div v-else-if="showModuleErrorCards" class="module-grid" data-testid="system-status-modules-error">
<article
v-for="placeholder in MODULE_PLACEHOLDER_CARDS"
:key="`module-error-${placeholder}`"
class="module-card module-card--placeholder is-down"
:data-testid="`module-card-error-${placeholder}`"
>
<div class="module-card__top">
<div class="module-card__identity">
<p class="module-card__title">{{ $t("system_status.sections.modules") }}</p>
<small class="module-card__version-line module-card__version-line--empty" aria-hidden="true"></small>
</div>
<span class="status-pill is-down">{{ $t("system_status.states.error") }}</span>
</div>
<p class="module-card__reason module-card__reason--text" :title="systemStatusErrorMessage()">
{{ systemStatusErrorMessage() }}
</p>
<div class="module-card__usage-list">
<div
class="module-card__usage module-card__usage--error"
:data-testid="`module-usage-error-placeholder-${placeholder}`"
>
<div class="module-card__usage-top">
<span>{{ $t("system_status.labels.usage_unavailable") }}</span>
</div>
<div class="module-card__usage-warning">
<small>{{ $t("system_status.labels.status") }}: {{ $t("system_status.states.error") }}</small>
<strong>{{ systemStatusErrorMessage() }}</strong>
</div>
</div>
</div>
<div class="module-card__footer" :data-testid="`module-footer-error-${placeholder}`">
<div class="module-card__indicator-group">
<SystemStatusModuleIndicator
icon-class="fas fa-power-off"
:label="`${$t('system_status.labels.enabled')}: --`"
:test-id="`module-indicator-error-${placeholder}-enabled`"
/>
<SystemStatusModuleIndicator
icon-class="fas fa-cog"
:label="`${$t('system_status.labels.configured')}: --`"
:test-id="`module-indicator-error-${placeholder}-configured`"
/>
<SystemStatusModuleIndicator
icon-class="fas fa-clipboard-check"
:label="`${$t('system_status.labels.checked_at')}: --`"
:test-id="`module-indicator-error-${placeholder}-checked`"
/>
</div>
<span class="module-card__config-action module-card__config-action--placeholder" aria-hidden="true">
<i class="fas fa-arrow-right"></i>
</span>
</div>
</article>
</div>
<div v-else class="module-grid">
<article <article
v-for="module in modules" v-for="module in modules"
:key="module.key" :key="module.key"
@@ -928,59 +1187,170 @@ function modulePath(key) {
:data-testid="`module-card-${module.key}`" :data-testid="`module-card-${module.key}`"
> >
<div class="module-card__top"> <div class="module-card__top">
<div class="module-card__identity">
<p class="module-card__title">{{ moduleLabel(module.key) }}</p> <p class="module-card__title">{{ moduleLabel(module.key) }}</p>
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span> <small
</div> class="module-card__version-line"
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`"> :class="{ 'module-card__version-line--empty': !moduleVersion(module) }"
{{ moduleReasonText(module) }} :title="
</p> moduleVersion(module) ? `${$t('system_status.labels.version')}: ${moduleVersion(module)}` : null
<div class="module-card__meta"> "
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span> :aria-hidden="moduleVersion(module) ? null : 'true'"
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span> :data-testid="`module-version-${module.key}`"
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
</div>
<div
v-if="module.key === 'licenseplaterecognizer' && hasModuleUsage(module)"
class="module-card__usage"
:data-testid="`module-usage-${module.key}`"
> >
<div class="module-card__usage-top"> <template v-if="moduleVersion(module)">
<span>{{ $t("system_status.labels.quota_usage") }}</span> {{ $t("system_status.labels.version") }}: {{ moduleVersion(module) }}
<strong>{{ formatUsage(module.usage.usage_percent) }}</strong> </template>
</div>
<progress
class="progress module-card__usage-progress"
:class="usageProgressClass(module.usage.usage_percent)"
:value="boundedUsagePercent(module.usage.usage_percent)"
max="100"
>
{{ formatUsage(module.usage.usage_percent) }}
</progress>
<div class="module-card__usage-grid">
<span>
<small>{{ $t("system_status.labels.calls_used") }}</small>
<strong>{{ formatNumber(module.usage.calls_used) }}</strong>
</span>
<span>
<small>{{ $t("system_status.labels.quota_calls") }}</small>
<strong>{{ formatNumber(module.usage.quota_calls) }}</strong>
</span>
<span>
<small>{{ $t("system_status.labels.calls_remaining") }}</small>
<strong>{{ formatNumber(module.usage.calls_remaining) }}</strong>
</span>
</div>
<small v-if="module.usage.version" class="module-card__usage-version">
{{ $t("system_status.labels.version") }}: {{ module.usage.version }}
</small> </small>
</div> </div>
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link"> <span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
{{ $t("system_status.actions.open_config") }} </div>
<p
class="module-card__reason module-card__reason--text"
:title="moduleReasonText(module)"
:data-testid="`module-reason-${module.key}`"
>
{{ moduleReasonText(module) }}
</p>
<div
v-if="showModuleUsageSkeleton(module)"
class="module-card__usage-list"
:data-testid="`module-usage-${module.key}`"
>
<div
class="module-card__usage module-card__usage--loading"
:data-testid="`module-usage-skeleton-${module.key}`"
>
<div class="module-card__usage-top">
<b-skeleton width="58%" height="1rem" />
<b-skeleton width="48px" height="1rem" />
</div>
<b-skeleton width="100%" height="0.55rem" />
<div class="module-card__usage-grid">
<span><b-skeleton width="86%" height="2.2rem" /></span>
<span><b-skeleton width="86%" height="2.2rem" /></span>
<span><b-skeleton width="86%" height="2.2rem" /></span>
</div>
</div>
</div>
<div
v-else-if="moduleUsageErrorMessage(module)"
class="module-card__usage-list"
:data-testid="`module-usage-${module.key}`"
>
<div
class="module-card__usage module-card__usage--error"
:data-testid="`module-usage-error-${module.key}`"
>
<div class="module-card__usage-top">
<span>{{ $t("system_status.labels.usage_unavailable") }}</span>
</div>
<div class="module-card__usage-warning">
<small>{{ $t("system_status.labels.status") }}: {{ $t("system_status.states.error") }}</small>
<strong>{{ moduleUsageErrorMessage(module) }}</strong>
</div>
</div>
</div>
<div
v-else-if="hasModuleUsage(module)"
class="module-card__usage-list"
:data-testid="`module-usage-${module.key}`"
>
<div
v-for="metric in visibleModuleUsageMetrics(module)"
:key="`${metric.module_key || module.key}-${metric.metric_key}`"
class="module-card__usage"
:class="{ 'module-card__usage--error': isModuleUsageUnavailable(metric) }"
:data-testid="`module-usage-${module.key}-${metric.metric_key}`"
>
<div class="module-card__usage-top">
<span>{{ moduleUsageLabel(metric) }}</span>
<strong v-if="!isModuleUsageUnavailable(metric)">{{ formatUsage(metric.usage_percent) }}</strong>
</div>
<div v-if="isModuleUsageUnavailable(metric)" class="module-card__usage-warning">
<small>{{ $t("system_status.labels.status") }}: {{ $t("system_status.status.unknown") }}</small>
<strong>{{ moduleUsageUnavailableReason(metric) }}</strong>
</div>
<progress
v-else
class="progress module-card__usage-progress"
:class="usageProgressClass(metric.usage_percent)"
:value="boundedUsagePercent(metric.usage_percent)"
max="100"
>
{{ formatUsage(metric.usage_percent) }}
</progress>
<div v-if="!isModuleUsageUnavailable(metric)" class="module-card__usage-grid">
<span>
<small>{{ moduleUsageUsedLabel(metric) }}</small>
<strong>{{ formatMetricValue(metric.used, metric.unit) }}</strong>
</span>
<span>
<small>{{ moduleUsageLimitLabel(metric) }}</small>
<strong>{{ formatMetricValue(metric.limit, metric.unit) }}</strong>
</span>
<span>
<small>{{ moduleUsageRemainingLabel(metric) }}</small>
<strong>{{ formatMetricValue(metric.remaining, metric.unit) }}</strong>
</span>
</div>
</div>
</div>
<div
v-else-if="showModuleUsageUnavailablePlaceholder(module)"
class="module-card__usage-list"
:data-testid="`module-usage-${module.key}`"
>
<div
class="module-card__usage module-card__usage--warning"
:data-testid="`module-usage-unavailable-${module.key}`"
>
<div class="module-card__usage-top">
<span>{{ $t("system_status.labels.usage_unavailable") }}</span>
</div>
<div class="module-card__usage-warning">
<small>{{ $t("system_status.labels.status") }}: {{ $t("system_status.status.unknown") }}</small>
<strong>{{ $t("system_status.states.module_usage_unavailable") }}</strong>
</div>
</div>
</div>
<div class="module-card__footer" :data-testid="`module-footer-${module.key}`">
<div class="module-card__indicator-group">
<SystemStatusModuleIndicator
v-bind="moduleEnabledIndicator(module)"
:test-id="`module-indicator-${module.key}-enabled`"
/>
<SystemStatusModuleIndicator
v-bind="moduleConfiguredIndicator(module)"
:test-id="`module-indicator-${module.key}-configured`"
/>
<SystemStatusModuleIndicator
v-bind="moduleCheckedIndicator(module)"
:test-id="`module-indicator-${module.key}-checked`"
/>
</div>
<RouterLink
v-if="modulePath(module.key)"
:to="modulePath(module.key)"
class="module-card__config-action"
:aria-label="moduleConfigActionLabel(module)"
:title="moduleConfigActionLabel(module)"
:data-testid="`module-config-action-${module.key}`"
>
<i class="fas fa-arrow-right" aria-hidden="true"></i>
</RouterLink> </RouterLink>
<span
v-else
class="module-card__config-action module-card__config-action--placeholder"
aria-hidden="true"
:data-testid="`module-config-action-${module.key}`"
>
<i class="fas fa-arrow-right"></i>
</span>
</div>
</article> </article>
</div> </div>
</section> </section>
</b-tab-item> </b-tab-item>
<b-tab-item value="sessions"> <b-tab-item value="sessions">
@@ -1011,7 +1381,10 @@ function modulePath(key) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`"> <tr
v-for="session in sessions.recent_sessions"
:key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`"
>
<td> <td>
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'"> <span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }} {{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
@@ -1034,7 +1407,6 @@ function modulePath(key) {
</table> </table>
</div> </div>
</section> </section>
</b-tab-item> </b-tab-item>
</b-tabs> </b-tabs>
</div> </div>
@@ -1099,7 +1471,6 @@ function modulePath(key) {
} }
.status-card-grid, .status-card-grid,
.module-grid,
.gateway-grid { .gateway-grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@@ -1107,6 +1478,13 @@ function modulePath(key) {
align-items: stretch; align-items: stretch;
} }
.module-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
align-items: stretch;
}
.gateway-summary-grid { .gateway-summary-grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
@@ -1158,7 +1536,6 @@ function modulePath(key) {
.status-card__secondary, .status-card__secondary,
.status-card__detail, .status-card__detail,
.module-card small, .module-card small,
.module-card__meta,
.session-context { .session-context {
color: #475569; color: #475569;
} }
@@ -1170,8 +1547,10 @@ function modulePath(key) {
.module-card { .module-card {
display: grid; display: grid;
gap: 0.85rem; gap: 0.85rem;
grid-template-rows: 3.55rem 5.1rem 10.8rem 2.35rem;
height: 100%; height: 100%;
align-content: start; min-height: 26.35rem;
align-content: stretch;
} }
.gateway-card { .gateway-card {
@@ -1188,6 +1567,8 @@ function modulePath(key) {
justify-content: stretch; justify-content: stretch;
column-gap: 0.75rem; column-gap: 0.75rem;
row-gap: 0.35rem; row-gap: 0.35rem;
min-height: 3.55rem;
overflow: hidden;
} }
.gateway-card__top { .gateway-card__top {
@@ -1206,6 +1587,32 @@ function modulePath(key) {
letter-spacing: 0.04em; letter-spacing: 0.04em;
line-height: 1.35; line-height: 1.35;
overflow-wrap: anywhere; overflow-wrap: anywhere;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.module-card__identity {
display: grid;
gap: 0.12rem;
align-content: start;
min-width: 0;
}
.module-card__version-line {
display: block;
min-height: 1rem;
color: #64748b;
font-size: 0.7rem;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.module-card__version-line--empty {
visibility: hidden;
} }
.module-card__top .status-pill { .module-card__top .status-pill {
@@ -1230,12 +1637,20 @@ function modulePath(key) {
color: #475569; color: #475569;
line-height: 1.65; line-height: 1.65;
overflow-wrap: anywhere; overflow-wrap: anywhere;
min-height: 5.1rem;
overflow: hidden;
} }
.module-card__meta { .module-card__reason--text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.module-card__reason--skeleton {
display: grid; display: grid;
gap: 0.25rem; gap: 0.35rem;
font-size: 0.85rem; align-content: start;
} }
.gateway-card__meta { .gateway-card__meta {
@@ -1252,6 +1667,34 @@ function modulePath(key) {
border: 1px solid #d7dde7; border: 1px solid #d7dde7;
border-radius: 8px; border-radius: 8px;
background: #f8fafc; background: #f8fafc;
min-height: 10.8rem;
}
.module-card__usage-list {
display: grid;
gap: 0.55rem;
min-height: 10.8rem;
max-height: 10.8rem;
overflow: auto;
scrollbar-width: thin;
}
.module-card__usage-list > .module-card__usage:only-child {
min-height: 10.8rem;
}
.module-card__usage--loading {
align-content: start;
}
.module-card__usage--error {
border-color: rgba(220, 38, 38, 0.22);
background: #fef2f2;
}
.module-card__usage--warning {
border-color: rgba(245, 158, 11, 0.28);
background: #fffbeb;
} }
.module-card__usage-top { .module-card__usage-top {
@@ -1291,8 +1734,102 @@ function modulePath(key) {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.module-card__usage-version { .module-card__usage-warning {
color: #64748b; display: grid;
align-content: center;
gap: 0.35rem;
min-height: 5.4rem;
padding: 0.55rem 0.65rem;
border-radius: 6px;
background: rgba(220, 38, 38, 0.08);
color: #991b1b;
}
.module-card__usage-warning small {
color: #b45309;
font-size: 0.72rem;
line-height: 1.25;
}
.module-card__usage-warning strong {
color: #991b1b;
font-size: 0.9rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.module-card__usage--warning .module-card__usage-warning {
background: rgba(245, 158, 11, 0.12);
}
.module-card__usage--warning .module-card__usage-warning strong {
color: #92400e;
}
.module-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 2.35rem;
padding-top: 0.15rem;
}
.module-card__indicator-group {
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-width: 0;
}
.module-card__indicator-placeholder,
.module-card__config-action {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.15rem;
height: 2.15rem;
flex: 0 0 2.15rem;
border-radius: 999px;
}
.module-card__indicator-placeholder {
border: 1px solid #d7dde7;
background: linear-gradient(90deg, #eef2f7 25%, #f8fafc 50%, #eef2f7 75%);
background-size: 200% 100%;
animation: module-placeholder-pulse 1.4s ease-in-out infinite;
}
.module-card__config-action {
border: 1px solid rgba(15, 76, 129, 0.24);
color: #0f4c81;
background: rgba(15, 76, 129, 0.06);
transition: background-color 140ms ease, box-shadow 140ms ease, color 140ms ease, transform 140ms ease;
}
.module-card__config-action:hover,
.module-card__config-action:focus-visible {
color: #ffffff;
background: #0f4c81;
box-shadow: 0 8px 18px rgba(15, 76, 129, 0.22);
transform: translateX(1px);
}
.module-card__config-action--placeholder {
border-color: #d7dde7;
color: #cbd5e1;
background: #f8fafc;
pointer-events: none;
}
@keyframes module-placeholder-pulse {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
} }
.gateway-card__message { .gateway-card__message {
@@ -0,0 +1,124 @@
<script setup>
defineProps({
iconClass: {
type: String,
required: true,
},
badgeIconClass: {
type: String,
default: "",
},
tone: {
type: String,
default: "neutral",
},
badgeTone: {
type: String,
default: "neutral",
},
label: {
type: String,
required: true,
},
testId: {
type: String,
default: "",
},
});
</script>
<template>
<span
class="system-status-module-indicator"
:class="`is-${tone}`"
role="img"
:aria-label="label"
:title="label"
:data-testid="testId || undefined"
>
<span class="system-status-module-indicator__icon" aria-hidden="true">
<i :class="iconClass"></i>
</span>
<span
v-if="badgeIconClass"
class="system-status-module-indicator__badge"
:class="`is-${badgeTone}`"
aria-hidden="true"
>
<i :class="badgeIconClass"></i>
</span>
</span>
</template>
<style scoped>
.system-status-module-indicator {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.15rem;
height: 2.15rem;
border: 1px solid currentColor;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: #64748b;
cursor: help;
transition: background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
}
.system-status-module-indicator:hover,
.system-status-module-indicator:focus-visible {
background: #ffffff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
transform: translateY(-1px);
}
.system-status-module-indicator__icon {
font-size: 0.98rem;
line-height: 1;
}
.system-status-module-indicator__badge {
position: absolute;
right: -0.22rem;
bottom: -0.18rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border: 2px solid #ffffff;
border-radius: 999px;
background: #64748b;
color: #ffffff;
font-size: 0.56rem;
line-height: 1;
}
.system-status-module-indicator.is-ok {
color: #166534;
background: rgba(22, 163, 74, 0.1);
}
.system-status-module-indicator.is-down {
color: #b91c1c;
background: rgba(220, 38, 38, 0.1);
}
.system-status-module-indicator.is-degraded {
color: #b45309;
background: rgba(245, 158, 11, 0.12);
}
.system-status-module-indicator__badge.is-ok {
background: #16a34a;
}
.system-status-module-indicator__badge.is-down {
background: #dc2626;
}
.system-status-module-indicator__badge.is-degraded {
background: #d97706;
}
</style>
@@ -735,6 +735,18 @@ export const CollectedOrderInvoices = {
throw error; throw error;
}); });
}, },
pdf: async (collectedInvoiceId, type = 'booked') => {
return authenticatedRequest('/collected-invoices/economic/pdf', 'GET', {
collected_invoice_id: parseInt(collectedInvoiceId),
type,
}).then((response) => {
console.warn(response);
return response;
}).catch((error) => {
console.warn(error);
throw error;
});
},
v2: { v2: {
details: async (collectedInvoiceId) => { details: async (collectedInvoiceId) => {
return authenticatedRequest('/collected-invoices/economic/v2/details', 'GET', { return authenticatedRequest('/collected-invoices/economic/v2/details', 'GET', {
+190
View File
@@ -4292,6 +4292,196 @@
"split_success_title": "Månedsopdeling fuldført", "split_success_title": "Månedsopdeling fuldført",
"text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?", "text": "Du er ved at fakturere ordrer fra flere måneder sammen ({months}). Skal de i stedet opdeles efter måned?",
"title": "Ordrer fra flere måneder" "title": "Ordrer fra flere måneder"
},
"object_tree": {
"actions": {
"apply": "Udfør",
"attachments": {
"delete": {
"preview": "{count} vedhæftninger slettes.",
"title": "Slet vedhæftninger"
},
"resend_wash_certificates": {
"preview": "{count} ordre får gensendt vaskecertifikat.",
"title": "Gensend vaskecertifikater"
}
},
"bookings": {
"delete": {
"preview": "{count} bookinger slettes.",
"title": "Slet bookinger"
},
"resend_completion": {
"preview": "{count} afslutningsbekræftelser sendes igen.",
"title": "Gensend afslutningsbekræftelser"
},
"resend_confirmation": {
"preview": "{count} bookingbekræftelser sendes igen.",
"title": "Gensend bookingbekræftelser"
}
},
"order_items": {
"delete": {
"preview": "{count} orderlinjer slettes.",
"title": "Slet orderlinjer"
}
},
"orders": {
"delete": {
"preview": "{count} orders slettes.",
"title": "Slet valgte orders"
},
"exclude_invoice": {
"title": "Ekskluder orders fra faktura"
},
"include_invoice": {
"title": "Inkluder orders på faktura"
},
"invoice_override": {
"preview": "{count} orders opdateres."
},
"mark_completed": {
"preview": "{count} orders markeres som færdige.",
"title": "Marker orders som færdige"
},
"move_collection": {
"input_label": "Mål-fakturasamling ID",
"preview": "{count} orders flyttes til fakturasamling #{id}.",
"title": "Flyt orders til fakturasamling",
"validation": "Angiv et gyldigt fakturasamlings-ID."
},
"unlink_booking": {
"preview": "{count} orders får fjernet booking-link.",
"title": "Fjern booking fra orders"
},
"unlink_xlvask": {
"preview": "{count} orders får fjernet Selvvask-link.",
"title": "Fjern Selvvask fra orders"
}
},
"xlvask": {
"accept": {
"preview": "{count} XL Vask forslag accepteres.",
"title": "Accepter XL Vask forslag"
},
"deny": {
"preview": "{count} XL Vask forslag afvises.",
"title": "Afvis XL Vask forslag"
},
"ignore": {
"preview": "{count} XL Vask rækker ignoreres.",
"title": "Ignorer XL Vask rækker"
}
}
},
"aria_label": "Fakturaperiode objekttræ",
"buttons": {
"accept_xlvask": "Accepter forslag",
"actions": "Handlinger",
"delete": "Slet",
"delete_lines": "Slet linjer",
"deny_xlvask": "Afvis forslag",
"download": "Download",
"exclude_invoice": "Ekskluder",
"ignore": "Ignorer",
"include_invoice": "Inkluder",
"mark_completed": "Marker færdig",
"move_collection": "Flyt samling",
"resend_booking": "Gensend",
"resend_booking_completion": "Gensend afslutning",
"resend_wash_certificate": "Gensend vaskecertifikat",
"retry": "Prøv igen",
"unlink_booking": "Fjern booking",
"unlink_xlvask": "Fjern Selvvask"
},
"categories": {
"agreements": "Betalingsaftaler",
"booking_items": "Booking items",
"bookings": "Bookinger",
"economic": "Fakturaer",
"images": "Billeder",
"order_items": "Order items",
"orders": "Orders",
"other_attachments": "Andre vedhæftninger",
"payments": "Kortbetalinger",
"wash_certificates": "Vaskecertifikater",
"xlvask": "Selvvask",
"xlvask_items": "XL Vask parsed inferred order items"
},
"confirmation": {
"input_label": "Skriv {phrase} for at fortsætte",
"phrase": "Bekræft",
"validation": "Skriv {phrase}"
},
"errors": {
"action_failed": "Handlingen mislykkedes",
"download_failed": "Download mislykkedes",
"load_failed": "Kunne ikke indlæse indholdet."
},
"economic": {
"booked": "Bogført",
"draft": "Kladde",
"economic_total": "E-conomic total",
"internal_total": "Intern total",
"invoice_number": "E-conomic nr.",
"invoice_type": "Type",
"lines": "Linjer"
},
"fields": {
"empty": "Tom",
"hide_empty": "Skjul tomme felter",
"notes": "Noter",
"price": "Pris",
"product_id": "Produkt ID",
"quantity": "Antal",
"reference": "Reference",
"show_empty": "Vis tomme felter"
},
"nodes": {
"attachment_fallback": "Vedhæftning #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Bookinglinje #{id}",
"collection": "Fakturasamling #{id}",
"economic_booked_with_id": "E-conomic faktura #{id}",
"economic_draft": "E-conomic kladde",
"economic_draft_with_id": "E-conomic kladde #{id}",
"fixed_pricing": "Fastpris",
"invoice_for_order": "Faktura for ordre #{id}",
"order": "Ordre #{id}",
"order_item_fallback": "Linje #{id}",
"orders_without_collection": "Orders uden fakturasamling",
"payment_for_order": "Kortbetaling for ordre #{id}",
"vehicle_subscription": "Vaskeabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
"xlvask_item_fallback": "XL Vask linje {index}"
},
"selection": {
"mixed_types": "Vælg kun én objekttype for handlinger",
"no_actions": "Ingen handlinger til de valgte objekter"
},
"preview": {
"download_on_click": "Klik for at downloade",
"economic_invoice": "E-conomic faktura",
"loading": "Indlæser preview"
},
"subtitles": {
"collection": "Samling #{id}",
"order": "Ordre #{id}",
"quantity": "Antal {count}",
"wash_id": "WashId {id}"
},
"success": {
"title": "Handling udført"
},
"types": {
"attachment": "vedhæftninger",
"booking": "bookinger",
"collection": "fakturasamlinger",
"order": "orders",
"order_item": "orderlinjer",
"xlvask": "selvvaske"
}
} }
}, },
"invoicing": { "invoicing": {
+190
View File
@@ -4402,6 +4402,196 @@
"split_success_title": "Monatsaufteilung abgeschlossen", "split_success_title": "Monatsaufteilung abgeschlossen",
"text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?", "text": "Sie sind dabei, Aufträge aus mehreren Monaten gemeinsam abzurechnen ({months}). Sollen sie stattdessen nach Monat aufgeteilt werden?",
"title": "Aufträge aus mehreren Monaten" "title": "Aufträge aus mehreren Monaten"
},
"object_tree": {
"actions": {
"apply": "Ausführen",
"attachments": {
"delete": {
"preview": "{count} Anhänge werden gelöscht.",
"title": "Anhänge löschen"
},
"resend_wash_certificates": {
"preview": "Waschzertifikate werden für {count} Aufträge erneut gesendet.",
"title": "Waschzertifikate erneut senden"
}
},
"bookings": {
"delete": {
"preview": "{count} Buchungen werden gelöscht.",
"title": "Buchungen löschen"
},
"resend_completion": {
"preview": "{count} Abschlussbestätigungen werden erneut gesendet.",
"title": "Abschlussbestätigungen erneut senden"
},
"resend_confirmation": {
"preview": "{count} Buchungsbestätigungen werden erneut gesendet.",
"title": "Buchungsbestätigungen erneut senden"
}
},
"order_items": {
"delete": {
"preview": "{count} Auftragspositionen werden gelöscht.",
"title": "Auftragspositionen löschen"
}
},
"orders": {
"delete": {
"preview": "{count} Aufträge werden gelöscht.",
"title": "Ausgewählte Aufträge löschen"
},
"exclude_invoice": {
"title": "Aufträge von Rechnung ausschließen"
},
"include_invoice": {
"title": "Aufträge auf Rechnung aufnehmen"
},
"invoice_override": {
"preview": "{count} Aufträge werden aktualisiert."
},
"mark_completed": {
"preview": "{count} Aufträge werden als abgeschlossen markiert.",
"title": "Aufträge als abgeschlossen markieren"
},
"move_collection": {
"input_label": "Ziel-Fakturasammlungs-ID",
"preview": "{count} Aufträge werden in Fakturasammlung #{id} verschoben.",
"title": "Aufträge in Fakturasammlung verschieben",
"validation": "Geben Sie eine gültige Fakturasammlungs-ID ein."
},
"unlink_booking": {
"preview": "Buchungslinks werden von {count} Aufträgen entfernt.",
"title": "Buchung von Aufträgen entfernen"
},
"unlink_xlvask": {
"preview": "Selbstwasch-Links werden von {count} Aufträgen entfernt.",
"title": "Selbstwasch von Aufträgen entfernen"
}
},
"xlvask": {
"accept": {
"preview": "{count} XL Vask-Vorschläge werden akzeptiert.",
"title": "XL Vask-Vorschläge akzeptieren"
},
"deny": {
"preview": "{count} XL Vask-Vorschläge werden abgelehnt.",
"title": "XL Vask-Vorschläge ablehnen"
},
"ignore": {
"preview": "{count} XL Vask-Zeilen werden ignoriert.",
"title": "XL Vask-Zeilen ignorieren"
}
}
},
"aria_label": "Objektbaum für Rechnungsperiode",
"buttons": {
"accept_xlvask": "Vorschlag akzeptieren",
"actions": "Aktionen",
"delete": "Löschen",
"delete_lines": "Zeilen löschen",
"deny_xlvask": "Vorschlag ablehnen",
"download": "Download",
"exclude_invoice": "Ausschließen",
"ignore": "Ignorieren",
"include_invoice": "Einschließen",
"mark_completed": "Abschließen",
"move_collection": "Sammlung verschieben",
"resend_booking": "Erneut senden",
"resend_booking_completion": "Abschluss erneut senden",
"resend_wash_certificate": "Waschzertifikat erneut senden",
"retry": "Erneut versuchen",
"unlink_booking": "Buchung entfernen",
"unlink_xlvask": "Selbstwasch entfernen"
},
"categories": {
"agreements": "Zahlungsvereinbarungen",
"booking_items": "Booking items",
"bookings": "Buchungen",
"economic": "Rechnungen",
"images": "Bilder",
"order_items": "Order items",
"orders": "Orders",
"other_attachments": "Andere Anhänge",
"payments": "Kartenzahlungen",
"wash_certificates": "Waschzertifikate",
"xlvask": "Selbstwasch",
"xlvask_items": "XL Vask parsed inferred order items"
},
"confirmation": {
"input_label": "Geben Sie {phrase} ein, um fortzufahren",
"phrase": "Bestätigen",
"validation": "Geben Sie {phrase} ein"
},
"errors": {
"action_failed": "Aktion fehlgeschlagen",
"download_failed": "Download fehlgeschlagen",
"load_failed": "Inhalt konnte nicht geladen werden."
},
"economic": {
"booked": "Gebucht",
"draft": "Entwurf",
"economic_total": "E-conomic gesamt",
"internal_total": "Interne Summe",
"invoice_number": "E-conomic Nr.",
"invoice_type": "Typ",
"lines": "Zeilen"
},
"fields": {
"empty": "Leer",
"hide_empty": "Leere Felder ausblenden",
"notes": "Notizen",
"price": "Preis",
"product_id": "Produkt-ID",
"quantity": "Anzahl",
"reference": "Referenz",
"show_empty": "Leere Felder anzeigen"
},
"nodes": {
"attachment_fallback": "Anhang #{id}",
"booking": "Buchung #{id}",
"booking_item_fallback": "Buchungsposition #{id}",
"collection": "Fakturasammlung #{id}",
"economic_booked_with_id": "E-conomic-Rechnung #{id}",
"economic_draft": "E-conomic-Entwurf",
"economic_draft_with_id": "E-conomic-Entwurf #{id}",
"fixed_pricing": "Festpreis",
"invoice_for_order": "Rechnung für Auftrag #{id}",
"order": "Auftrag #{id}",
"order_item_fallback": "Position #{id}",
"orders_without_collection": "Orders ohne Fakturasammlung",
"payment_for_order": "Kartenzahlung für Auftrag #{id}",
"vehicle_subscription": "Waschabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
"xlvask_item_fallback": "XL Vask-Position {index}"
},
"selection": {
"mixed_types": "Wählen Sie nur einen Objekttyp für Aktionen",
"no_actions": "Keine Aktionen für die ausgewählten Objekte"
},
"preview": {
"download_on_click": "Zum Herunterladen klicken",
"economic_invoice": "E-conomic-Rechnung",
"loading": "Vorschau wird geladen"
},
"subtitles": {
"collection": "Sammlung #{id}",
"order": "Auftrag #{id}",
"quantity": "Anzahl {count}",
"wash_id": "WashId {id}"
},
"success": {
"title": "Aktion abgeschlossen"
},
"types": {
"attachment": "Anhänge",
"booking": "Buchungen",
"collection": "Fakturasammlungen",
"order": "Orders",
"order_item": "Auftragspositionen",
"xlvask": "Selbstwäschen"
}
} }
}, },
"invoicing": { "invoicing": {
+190
View File
@@ -4123,6 +4123,196 @@
"split_success_title": "Monthly split completed", "split_success_title": "Monthly split completed",
"text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?", "text": "You are about to invoice orders from multiple months together ({months}). Should they be split by month instead?",
"title": "Orders from multiple months" "title": "Orders from multiple months"
},
"object_tree": {
"actions": {
"apply": "Apply",
"attachments": {
"delete": {
"preview": "{count} attachments will be deleted.",
"title": "Delete attachments"
},
"resend_wash_certificates": {
"preview": "Wash certificates will be resent for {count} orders.",
"title": "Resend wash certificates"
}
},
"bookings": {
"delete": {
"preview": "{count} bookings will be deleted.",
"title": "Delete bookings"
},
"resend_completion": {
"preview": "{count} completion confirmations will be resent.",
"title": "Resend completion confirmations"
},
"resend_confirmation": {
"preview": "{count} booking confirmations will be resent.",
"title": "Resend booking confirmations"
}
},
"order_items": {
"delete": {
"preview": "{count} order items will be deleted.",
"title": "Delete order items"
}
},
"orders": {
"delete": {
"preview": "{count} orders will be deleted.",
"title": "Delete selected orders"
},
"exclude_invoice": {
"title": "Exclude orders from invoice"
},
"include_invoice": {
"title": "Include orders on invoice"
},
"invoice_override": {
"preview": "{count} orders will be updated."
},
"mark_completed": {
"preview": "{count} orders will be marked completed.",
"title": "Mark orders completed"
},
"move_collection": {
"input_label": "Target invoice collection ID",
"preview": "{count} orders will move to invoice collection #{id}.",
"title": "Move orders to invoice collection",
"validation": "Enter a valid invoice collection ID."
},
"unlink_booking": {
"preview": "Booking links will be removed from {count} orders.",
"title": "Remove booking from orders"
},
"unlink_xlvask": {
"preview": "Self-wash links will be removed from {count} orders.",
"title": "Remove self-wash from orders"
}
},
"xlvask": {
"accept": {
"preview": "{count} XL Vask suggestions will be accepted.",
"title": "Accept XL Vask suggestions"
},
"deny": {
"preview": "{count} XL Vask suggestions will be denied.",
"title": "Deny XL Vask suggestions"
},
"ignore": {
"preview": "{count} XL Vask rows will be ignored.",
"title": "Ignore XL Vask rows"
}
}
},
"aria_label": "Invoice period object tree",
"buttons": {
"accept_xlvask": "Accept suggestion",
"actions": "Actions",
"delete": "Delete",
"delete_lines": "Delete lines",
"deny_xlvask": "Deny suggestion",
"download": "Download",
"exclude_invoice": "Exclude",
"ignore": "Ignore",
"include_invoice": "Include",
"mark_completed": "Mark completed",
"move_collection": "Move collection",
"resend_booking": "Resend",
"resend_booking_completion": "Resend completion",
"resend_wash_certificate": "Resend wash certificate",
"retry": "Retry",
"unlink_booking": "Remove booking",
"unlink_xlvask": "Remove self-wash"
},
"categories": {
"agreements": "Payment agreements",
"booking_items": "Booking items",
"bookings": "Bookings",
"economic": "Invoices",
"images": "Images",
"order_items": "Order items",
"orders": "Orders",
"other_attachments": "Other attachments",
"payments": "Card payments",
"wash_certificates": "Wash certificates",
"xlvask": "Self-wash",
"xlvask_items": "XL Vask parsed inferred order items"
},
"confirmation": {
"input_label": "Type {phrase} to continue",
"phrase": "Confirm",
"validation": "Type {phrase}"
},
"errors": {
"action_failed": "Action failed",
"download_failed": "Download failed",
"load_failed": "Could not load the content."
},
"economic": {
"booked": "Booked",
"draft": "Draft",
"economic_total": "E-conomic total",
"internal_total": "Internal total",
"invoice_number": "E-conomic no.",
"invoice_type": "Type",
"lines": "Lines"
},
"fields": {
"empty": "Empty",
"hide_empty": "Hide empty fields",
"notes": "Notes",
"price": "Price",
"product_id": "Product ID",
"quantity": "Quantity",
"reference": "Reference",
"show_empty": "Show empty fields"
},
"nodes": {
"attachment_fallback": "Attachment #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Booking item #{id}",
"collection": "Invoice collection #{id}",
"economic_booked_with_id": "E-conomic invoice #{id}",
"economic_draft": "E-conomic draft",
"economic_draft_with_id": "E-conomic draft #{id}",
"fixed_pricing": "Fixed pricing",
"invoice_for_order": "Invoice for order #{id}",
"order": "Order #{id}",
"order_item_fallback": "Line #{id}",
"orders_without_collection": "Orders without invoice collection",
"payment_for_order": "Card payment for order #{id}",
"vehicle_subscription": "Wash subscription",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
"xlvask_item_fallback": "XL Vask line {index}"
},
"selection": {
"mixed_types": "Select only one object type for actions",
"no_actions": "No actions for the selected objects"
},
"preview": {
"download_on_click": "Click to download",
"economic_invoice": "E-conomic invoice",
"loading": "Loading preview"
},
"subtitles": {
"collection": "Collection #{id}",
"order": "Order #{id}",
"quantity": "Quantity {count}",
"wash_id": "WashId {id}"
},
"success": {
"title": "Action completed"
},
"types": {
"attachment": "attachments",
"booking": "bookings",
"collection": "invoice collections",
"order": "orders",
"order_item": "order items",
"xlvask": "self-washes"
}
} }
}, },
"invoicing": { "invoicing": {
+220 -2
View File
@@ -2145,8 +2145,8 @@
"loading": "Loading cron tasks...", "loading": "Loading cron tasks...",
"messages": { "messages": {
"queued": "{task} was queued for the cron worker.", "queued": "{task} was queued for the cron worker.",
"worker_deploy_queued": "Cron worker deployment was queued.", "worker_deploy_queued": "Cron worker deployment was queued (#{id}).",
"worker_update_queued": "Cron worker deployment update was queued." "worker_update_queued": "Cron worker deployment update was queued (#{id})."
}, },
"nav": "@:{'cron.title'}", "nav": "@:{'cron.title'}",
"replication": "Replication", "replication": "Replication",
@@ -2156,12 +2156,18 @@
"enabled": "@:common.enabled" "enabled": "@:common.enabled"
}, },
"status": { "status": {
"degraded": "Degraded",
"deployed": "Deployed",
"deploying": "Deploying",
"failed": "Failed", "failed": "Failed",
"healthy": "Healthy",
"needs_deploy": "Needs deploy",
"queued": "Queued", "queued": "Queued",
"running": "Running", "running": "Running",
"skipped": "Skipped", "skipped": "Skipped",
"succeeded": "Succeeded", "succeeded": "Succeeded",
"timed_out": "Timed out", "timed_out": "Timed out",
"waiting_for_heartbeat": "Waiting for heartbeat",
"unknown": "Unknown" "unknown": "Unknown"
}, },
"subtitle": "Configure schedules, run tasks manually, and inspect recent cron execution.", "subtitle": "Configure schedules, run tasks manually, and inspect recent cron execution.",
@@ -2184,15 +2190,22 @@
}, },
"title": "Cron tasks", "title": "Cron tasks",
"workers": { "workers": {
"api_target": "API target",
"deploy": "Deploy worker", "deploy": "Deploy worker",
"deploy_to_coolify": "Deploy to Coolify", "deploy_to_coolify": "Deploy to Coolify",
"deployment": "Deployment",
"deployment_label": "{status} #{id}",
"empty": "No cron workers found.", "empty": "No cron workers found.",
"empty_with_target": "Cron worker deployment target exists, but no worker heartbeat has been recorded yet.",
"heartbeat": "Heartbeat", "heartbeat": "Heartbeat",
"last_run_count": "Last run count", "last_run_count": "Last run count",
"latest_deployment": "Latest deployment",
"loading": "Loading cron workers...", "loading": "Loading cron workers...",
"name": "Worker", "name": "Worker",
"repair_deployment": "Repair Coolify deployment",
"running": "@:{'cron.status.running'}", "running": "@:{'cron.status.running'}",
"source": "@:{'cron.history.source'}", "source": "@:{'cron.history.source'}",
"state": "State",
"stale": "Stale", "stale": "Stale",
"status": "@:{'cron.history.status'}", "status": "@:{'cron.history.status'}",
"target": "@:{'security.firewall.target'}", "target": "@:{'security.firewall.target'}",
@@ -3448,6 +3461,196 @@
"split_success_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}", "split_success_title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.split_success_title'}",
"text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.text'}", "text": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.text'}",
"title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.title'}" "title": "@:{'templates.generated.compat.invoicing_period.multi_month_invoice_warning.title'}"
},
"object_tree": {
"actions": {
"apply": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.apply'}",
"attachments": {
"delete": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.attachments.delete.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.attachments.delete.title'}"
},
"resend_wash_certificates": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.attachments.resend_wash_certificates.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.attachments.resend_wash_certificates.title'}"
}
},
"bookings": {
"delete": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.bookings.delete.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.bookings.delete.title'}"
},
"resend_completion": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.bookings.resend_completion.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.bookings.resend_completion.title'}"
},
"resend_confirmation": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.bookings.resend_confirmation.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.bookings.resend_confirmation.title'}"
}
},
"order_items": {
"delete": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.order_items.delete.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.order_items.delete.title'}"
}
},
"orders": {
"delete": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.delete.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.delete.title'}"
},
"exclude_invoice": {
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.exclude_invoice.title'}"
},
"include_invoice": {
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.include_invoice.title'}"
},
"invoice_override": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.invoice_override.preview'}"
},
"mark_completed": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.mark_completed.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.mark_completed.title'}"
},
"move_collection": {
"input_label": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.move_collection.input_label'}",
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.move_collection.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.move_collection.title'}",
"validation": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.move_collection.validation'}"
},
"unlink_booking": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.unlink_booking.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.unlink_booking.title'}"
},
"unlink_xlvask": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.unlink_xlvask.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.orders.unlink_xlvask.title'}"
}
},
"xlvask": {
"accept": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.xlvask.accept.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.xlvask.accept.title'}"
},
"deny": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.xlvask.deny.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.xlvask.deny.title'}"
},
"ignore": {
"preview": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.xlvask.ignore.preview'}",
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.actions.xlvask.ignore.title'}"
}
}
},
"aria_label": "@:{'templates.generated.compat.invoicing_period.object_tree.aria_label'}",
"buttons": {
"accept_xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.accept_xlvask'}",
"actions": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.actions'}",
"delete": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.delete'}",
"delete_lines": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.delete_lines'}",
"deny_xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.deny_xlvask'}",
"download": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.download'}",
"exclude_invoice": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.exclude_invoice'}",
"ignore": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.ignore'}",
"include_invoice": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.include_invoice'}",
"mark_completed": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.mark_completed'}",
"move_collection": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.move_collection'}",
"resend_booking": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.resend_booking'}",
"resend_booking_completion": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.resend_booking_completion'}",
"resend_wash_certificate": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.resend_wash_certificate'}",
"retry": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.retry'}",
"unlink_booking": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.unlink_booking'}",
"unlink_xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.buttons.unlink_xlvask'}"
},
"categories": {
"agreements": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.agreements'}",
"booking_items": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.booking_items'}",
"bookings": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.bookings'}",
"economic": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.economic'}",
"images": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.images'}",
"order_items": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.order_items'}",
"orders": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.orders'}",
"other_attachments": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.other_attachments'}",
"payments": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.payments'}",
"wash_certificates": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.wash_certificates'}",
"xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.xlvask'}",
"xlvask_items": "@:{'templates.generated.compat.invoicing_period.object_tree.categories.xlvask_items'}"
},
"confirmation": {
"input_label": "@:{'templates.generated.compat.invoicing_period.object_tree.confirmation.input_label'}",
"phrase": "@:{'templates.generated.compat.invoicing_period.object_tree.confirmation.phrase'}",
"validation": "@:{'templates.generated.compat.invoicing_period.object_tree.confirmation.validation'}"
},
"errors": {
"action_failed": "@:{'templates.generated.compat.invoicing_period.object_tree.errors.action_failed'}",
"download_failed": "@:{'templates.generated.compat.invoicing_period.object_tree.errors.download_failed'}",
"load_failed": "@:{'templates.generated.compat.invoicing_period.object_tree.errors.load_failed'}"
},
"economic": {
"booked": "@:{'templates.generated.compat.invoicing_period.object_tree.economic.booked'}",
"draft": "@:{'templates.generated.compat.invoicing_period.object_tree.economic.draft'}",
"economic_total": "@:{'templates.generated.compat.invoicing_period.object_tree.economic.economic_total'}",
"internal_total": "@:{'templates.generated.compat.invoicing_period.object_tree.economic.internal_total'}",
"invoice_number": "@:{'templates.generated.compat.invoicing_period.object_tree.economic.invoice_number'}",
"invoice_type": "@:{'templates.generated.compat.invoicing_period.object_tree.economic.invoice_type'}",
"lines": "@:{'templates.generated.compat.invoicing_period.object_tree.economic.lines'}"
},
"fields": {
"empty": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.empty'}",
"hide_empty": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.hide_empty'}",
"notes": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.notes'}",
"price": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.price'}",
"product_id": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.product_id'}",
"quantity": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.quantity'}",
"reference": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.reference'}",
"show_empty": "@:{'templates.generated.compat.invoicing_period.object_tree.fields.show_empty'}"
},
"nodes": {
"attachment_fallback": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.attachment_fallback'}",
"booking": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.booking'}",
"booking_item_fallback": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.booking_item_fallback'}",
"collection": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.collection'}",
"economic_booked_with_id": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.economic_booked_with_id'}",
"economic_draft": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.economic_draft'}",
"economic_draft_with_id": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.economic_draft_with_id'}",
"fixed_pricing": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.fixed_pricing'}",
"invoice_for_order": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.invoice_for_order'}",
"order": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.order'}",
"order_item_fallback": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.order_item_fallback'}",
"orders_without_collection": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.orders_without_collection'}",
"payment_for_order": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.payment_for_order'}",
"vehicle_subscription": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.vehicle_subscription'}",
"xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.xlvask'}",
"xlvask_empty": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.xlvask_empty'}",
"xlvask_item_fallback": "@:{'templates.generated.compat.invoicing_period.object_tree.nodes.xlvask_item_fallback'}"
},
"selection": {
"mixed_types": "@:{'templates.generated.compat.invoicing_period.object_tree.selection.mixed_types'}",
"no_actions": "@:{'templates.generated.compat.invoicing_period.object_tree.selection.no_actions'}"
},
"preview": {
"download_on_click": "@:{'templates.generated.compat.invoicing_period.object_tree.preview.download_on_click'}",
"economic_invoice": "@:{'templates.generated.compat.invoicing_period.object_tree.preview.economic_invoice'}",
"loading": "@:{'templates.generated.compat.invoicing_period.object_tree.preview.loading'}"
},
"subtitles": {
"collection": "@:{'templates.generated.compat.invoicing_period.object_tree.subtitles.collection'}",
"order": "@:{'templates.generated.compat.invoicing_period.object_tree.subtitles.order'}",
"quantity": "@:{'templates.generated.compat.invoicing_period.object_tree.subtitles.quantity'}",
"wash_id": "@:{'templates.generated.compat.invoicing_period.object_tree.subtitles.wash_id'}"
},
"success": {
"title": "@:{'templates.generated.compat.invoicing_period.object_tree.success.title'}"
},
"types": {
"attachment": "@:{'templates.generated.compat.invoicing_period.object_tree.types.attachment'}",
"booking": "@:{'templates.generated.compat.invoicing_period.object_tree.types.booking'}",
"collection": "@:{'templates.generated.compat.invoicing_period.object_tree.types.collection'}",
"order": "@:{'templates.generated.compat.invoicing_period.object_tree.types.order'}",
"order_item": "@:{'templates.generated.compat.invoicing_period.object_tree.types.order_item'}",
"xlvask": "@:{'templates.generated.compat.invoicing_period.object_tree.types.xlvask'}"
}
} }
}, },
"invoicing": { "invoicing": {
@@ -5934,15 +6137,21 @@
"configured": "@:{'templates.generated.compat.system_status.labels.configured'}", "configured": "@:{'templates.generated.compat.system_status.labels.configured'}",
"buckets": "@.capitalize:{'words.generated.buckets'}", "buckets": "@.capitalize:{'words.generated.buckets'}",
"database_index": "@:replication.fields.database_index", "database_index": "@:replication.fields.database_index",
"detail": "Detail",
"enabled": "@:{'templates.generated.compat.system_status.labels.enabled'}", "enabled": "@:{'templates.generated.compat.system_status.labels.enabled'}",
"endpoint": "@.capitalize:{'words.generated.endpoint'}", "endpoint": "@.capitalize:{'words.generated.endpoint'}",
"latency": "@:{'templates.generated.compat.system_status.labels.latency'}", "latency": "@:{'templates.generated.compat.system_status.labels.latency'}",
"no_reason": "@:{'templates.generated.compat.system_status.labels.no_reason'}", "no_reason": "@:{'templates.generated.compat.system_status.labels.no_reason'}",
"quota_calls": "@:{'templates.generated.compat.system_status.labels.quota_calls'}", "quota_calls": "@:{'templates.generated.compat.system_status.labels.quota_calls'}",
"quota_usage": "@:{'templates.generated.compat.system_status.labels.quota_usage'}", "quota_usage": "@:{'templates.generated.compat.system_status.labels.quota_usage'}",
"limit": "Limit",
"remaining": "Remaining",
"replication_percent": "@:{'templates.generated.compat.system_status.labels.replication_percent'}", "replication_percent": "@:{'templates.generated.compat.system_status.labels.replication_percent'}",
"runtime_source": "@:{'templates.generated.compat.system_status.labels.runtime_source'}", "runtime_source": "@:{'templates.generated.compat.system_status.labels.runtime_source'}",
"server_version": "@:{'templates.generated.compat.system_status.labels.server_version'}", "server_version": "@:{'templates.generated.compat.system_status.labels.server_version'}",
"status": "Status",
"used": "Used",
"usage_unavailable": "Usage unavailable",
"version": "@:{'templates.generated.compat.system_status.labels.version'}", "version": "@:{'templates.generated.compat.system_status.labels.version'}",
"warnings": "@:{'templates.generated.compat.system_status.labels.warnings'}" "warnings": "@:{'templates.generated.compat.system_status.labels.warnings'}"
}, },
@@ -5968,6 +6177,12 @@
"workfeed": "@:{'templates.generated.compat.system_status.modules.workfeed'}", "workfeed": "@:{'templates.generated.compat.system_status.modules.workfeed'}",
"xlvask": "@:superuser.nav.xlvask" "xlvask": "@:superuser.nav.xlvask"
}, },
"quota_unavailable_reasons": {
"invalid_limit": "The provider returned a quota limit that could not be used.",
"missing_limit": "The provider response did not include a recognizable quota limit.",
"missing_usage": "The provider response did not include recognizable usage or remaining quota values.",
"unreadable_payload": "The provider returned a response payload that could not be parsed."
},
"reasons": { "reasons": {
"backup_connectivity_confirmed": "@:{'templates.generated.compat.system_status.reasons.backup_connectivity_confirmed'}", "backup_connectivity_confirmed": "@:{'templates.generated.compat.system_status.reasons.backup_connectivity_confirmed'}",
"backup_encryption_key_missing": "@:{'templates.generated.compat.system_status.reasons.backup_encryption_key_missing'}", "backup_encryption_key_missing": "@:{'templates.generated.compat.system_status.reasons.backup_encryption_key_missing'}",
@@ -5992,6 +6207,7 @@
"licenseplaterecognizer_usage_unreadable": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_usage_unreadable'}", "licenseplaterecognizer_usage_unreadable": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_usage_unreadable'}",
"missing_config": "@:{'templates.generated.compat.system_status.reasons.missing_config'}", "missing_config": "@:{'templates.generated.compat.system_status.reasons.missing_config'}",
"module_disabled": "@:{'templates.generated.compat.system_status.reasons.module_disabled'}", "module_disabled": "@:{'templates.generated.compat.system_status.reasons.module_disabled'}",
"provider_quota_unavailable": "{label} responded, but quota usage could not be read.",
"recaptcha_credentials_rejected": "@:{'templates.generated.compat.system_status.reasons.recaptcha_credentials_rejected'}", "recaptcha_credentials_rejected": "@:{'templates.generated.compat.system_status.reasons.recaptcha_credentials_rejected'}",
"recaptcha_unreadable_payload": "@:{'templates.generated.compat.system_status.reasons.recaptcha_unreadable_payload'}", "recaptcha_unreadable_payload": "@:{'templates.generated.compat.system_status.reasons.recaptcha_unreadable_payload'}",
"recaptcha_validation_errors": "@:{'templates.generated.compat.system_status.reasons.recaptcha_validation_errors'}", "recaptcha_validation_errors": "@:{'templates.generated.compat.system_status.reasons.recaptcha_validation_errors'}",
@@ -6027,6 +6243,8 @@
"error": "@:{'templates.generated.compat.system_status.states.error'}", "error": "@:{'templates.generated.compat.system_status.states.error'}",
"error_generic": "@:{'templates.generated.compat.system_status.states.error_generic'}", "error_generic": "@:{'templates.generated.compat.system_status.states.error_generic'}",
"loading": "@:{'templates.generated.compat.system_status.states.loading'}", "loading": "@:{'templates.generated.compat.system_status.states.loading'}",
"module_usage_error": "Quota and usage data could not be loaded.",
"module_usage_unavailable": "No quota or usage statistics are available for this module.",
"no_sessions": "@:{'templates.generated.compat.system_status.states.no_sessions'}", "no_sessions": "@:{'templates.generated.compat.system_status.states.no_sessions'}",
"stale": "@:{'templates.generated.compat.system_status.states.stale'}" "stale": "@:{'templates.generated.compat.system_status.states.stale'}"
}, },
+190
View File
@@ -4405,6 +4405,196 @@
"split_success_title": "Månedsdeling fullført", "split_success_title": "Månedsdeling fullført",
"text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?", "text": "Du er i ferd med å fakturere ordrer fra flere måneder samlet ({months}). Skal de i stedet deles opp etter måned?",
"title": "Ordrer fra flere måneder" "title": "Ordrer fra flere måneder"
},
"object_tree": {
"actions": {
"apply": "Utfør",
"attachments": {
"delete": {
"preview": "{count} vedlegg slettes.",
"title": "Slett vedlegg"
},
"resend_wash_certificates": {
"preview": "Vaskesertifikater sendes på nytt for {count} ordre.",
"title": "Send vaskesertifikater på nytt"
}
},
"bookings": {
"delete": {
"preview": "{count} bookinger slettes.",
"title": "Slett bookinger"
},
"resend_completion": {
"preview": "{count} fullføringsbekreftelser sendes på nytt.",
"title": "Send fullføringsbekreftelser på nytt"
},
"resend_confirmation": {
"preview": "{count} bookingbekreftelser sendes på nytt.",
"title": "Send bookingbekreftelser på nytt"
}
},
"order_items": {
"delete": {
"preview": "{count} ordrelinjer slettes.",
"title": "Slett ordrelinjer"
}
},
"orders": {
"delete": {
"preview": "{count} ordre slettes.",
"title": "Slett valgte ordre"
},
"exclude_invoice": {
"title": "Ekskluder ordre fra faktura"
},
"include_invoice": {
"title": "Inkluder ordre på faktura"
},
"invoice_override": {
"preview": "{count} ordre oppdateres."
},
"mark_completed": {
"preview": "{count} ordre markeres som fullført.",
"title": "Marker ordre som fullført"
},
"move_collection": {
"input_label": "Mål-fakturasamling ID",
"preview": "{count} ordre flyttes til fakturasamling #{id}.",
"title": "Flytt ordre til fakturasamling",
"validation": "Angi en gyldig fakturasamlings-ID."
},
"unlink_booking": {
"preview": "Booking-link fjernes fra {count} ordre.",
"title": "Fjern booking fra ordre"
},
"unlink_xlvask": {
"preview": "Selvvask-link fjernes fra {count} ordre.",
"title": "Fjern selvvask fra ordre"
}
},
"xlvask": {
"accept": {
"preview": "{count} XL Vask-forslag godtas.",
"title": "Godta XL Vask-forslag"
},
"deny": {
"preview": "{count} XL Vask-forslag avvises.",
"title": "Avvis XL Vask-forslag"
},
"ignore": {
"preview": "{count} XL Vask-rader ignoreres.",
"title": "Ignorer XL Vask-rader"
}
}
},
"aria_label": "Fakturaperiode objekttre",
"buttons": {
"accept_xlvask": "Godta forslag",
"actions": "Handlinger",
"delete": "Slett",
"delete_lines": "Slett linjer",
"deny_xlvask": "Avvis forslag",
"download": "Last ned",
"exclude_invoice": "Ekskluder",
"ignore": "Ignorer",
"include_invoice": "Inkluder",
"mark_completed": "Marker fullført",
"move_collection": "Flytt samling",
"resend_booking": "Send på nytt",
"resend_booking_completion": "Send fullføring på nytt",
"resend_wash_certificate": "Send vaskesertifikat på nytt",
"retry": "Prøv igjen",
"unlink_booking": "Fjern booking",
"unlink_xlvask": "Fjern selvvask"
},
"categories": {
"agreements": "Betalingsavtaler",
"booking_items": "Booking items",
"bookings": "Bookinger",
"economic": "Fakturaer",
"images": "Bilder",
"order_items": "Order items",
"orders": "Orders",
"other_attachments": "Andre vedlegg",
"payments": "Kortbetalinger",
"wash_certificates": "Vaskesertifikater",
"xlvask": "Selvvask",
"xlvask_items": "XL Vask parsed inferred order items"
},
"confirmation": {
"input_label": "Skriv {phrase} for å fortsette",
"phrase": "Bekreft",
"validation": "Skriv {phrase}"
},
"errors": {
"action_failed": "Handlingen mislyktes",
"download_failed": "Nedlasting mislyktes",
"load_failed": "Kunne ikke laste inn innholdet."
},
"economic": {
"booked": "Bokført",
"draft": "Kladd",
"economic_total": "E-conomic total",
"internal_total": "Intern total",
"invoice_number": "E-conomic nr.",
"invoice_type": "Type",
"lines": "Linjer"
},
"fields": {
"empty": "Tom",
"hide_empty": "Skjul tomme felter",
"notes": "Notater",
"price": "Pris",
"product_id": "Produkt-ID",
"quantity": "Antall",
"reference": "Referanse",
"show_empty": "Vis tomme felter"
},
"nodes": {
"attachment_fallback": "Vedlegg #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Bookinglinje #{id}",
"collection": "Fakturasamling #{id}",
"economic_booked_with_id": "E-conomic-faktura #{id}",
"economic_draft": "E-conomic-kladd",
"economic_draft_with_id": "E-conomic-kladd #{id}",
"fixed_pricing": "Fastpris",
"invoice_for_order": "Faktura for ordre #{id}",
"order": "Ordre #{id}",
"order_item_fallback": "Linje #{id}",
"orders_without_collection": "Orders uten fakturasamling",
"payment_for_order": "Kortbetaling for ordre #{id}",
"vehicle_subscription": "Vaskeabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
"xlvask_item_fallback": "XL Vask linje {index}"
},
"selection": {
"mixed_types": "Velg bare én objekttype for handlinger",
"no_actions": "Ingen handlinger for de valgte objektene"
},
"preview": {
"download_on_click": "Klikk for å laste ned",
"economic_invoice": "E-conomic-faktura",
"loading": "Laster forhåndsvisning"
},
"subtitles": {
"collection": "Samling #{id}",
"order": "Ordre #{id}",
"quantity": "Antall {count}",
"wash_id": "WashId {id}"
},
"success": {
"title": "Handling utført"
},
"types": {
"attachment": "vedlegg",
"booking": "bookinger",
"collection": "fakturasamlinger",
"order": "orders",
"order_item": "ordrelinjer",
"xlvask": "selvvaske"
}
} }
}, },
"invoicing": { "invoicing": {
+190
View File
@@ -4455,6 +4455,196 @@
"split_success_title": "Månadsuppdelning klar", "split_success_title": "Månadsuppdelning klar",
"text": "Du håller på att fakturera ordrar från flera månader tillsammans ({months}). Ska de delas upp per månad i stället?", "text": "Du håller på att fakturera ordrar från flera månader tillsammans ({months}). Ska de delas upp per månad i stället?",
"title": "Ordrar från flera månader" "title": "Ordrar från flera månader"
},
"object_tree": {
"actions": {
"apply": "Utför",
"attachments": {
"delete": {
"preview": "{count} bilagor tas bort.",
"title": "Ta bort bilagor"
},
"resend_wash_certificates": {
"preview": "Tvättcertifikat skickas om för {count} order.",
"title": "Skicka tvättcertifikat igen"
}
},
"bookings": {
"delete": {
"preview": "{count} bokningar tas bort.",
"title": "Ta bort bokningar"
},
"resend_completion": {
"preview": "{count} slutförandebekräftelser skickas om.",
"title": "Skicka slutförandebekräftelser igen"
},
"resend_confirmation": {
"preview": "{count} bokningsbekräftelser skickas om.",
"title": "Skicka bokningsbekräftelser igen"
}
},
"order_items": {
"delete": {
"preview": "{count} orderrader tas bort.",
"title": "Ta bort orderrader"
}
},
"orders": {
"delete": {
"preview": "{count} order tas bort.",
"title": "Ta bort valda order"
},
"exclude_invoice": {
"title": "Exkludera order från faktura"
},
"include_invoice": {
"title": "Inkludera order på faktura"
},
"invoice_override": {
"preview": "{count} order uppdateras."
},
"mark_completed": {
"preview": "{count} order markeras som klara.",
"title": "Markera order som klara"
},
"move_collection": {
"input_label": "Mål-fakturasamling ID",
"preview": "{count} order flyttas till fakturasamling #{id}.",
"title": "Flytta order till fakturasamling",
"validation": "Ange ett giltigt fakturasamlings-ID."
},
"unlink_booking": {
"preview": "Bokningslänk tas bort från {count} order.",
"title": "Ta bort bokning från order"
},
"unlink_xlvask": {
"preview": "Självtvättslänk tas bort från {count} order.",
"title": "Ta bort självtvätt från order"
}
},
"xlvask": {
"accept": {
"preview": "{count} XL Vask-förslag accepteras.",
"title": "Acceptera XL Vask-förslag"
},
"deny": {
"preview": "{count} XL Vask-förslag avvisas.",
"title": "Avvisa XL Vask-förslag"
},
"ignore": {
"preview": "{count} XL Vask-rader ignoreras.",
"title": "Ignorera XL Vask-rader"
}
}
},
"aria_label": "Fakturaperiod objektträd",
"buttons": {
"accept_xlvask": "Acceptera förslag",
"actions": "Åtgärder",
"delete": "Ta bort",
"delete_lines": "Ta bort rader",
"deny_xlvask": "Avvisa förslag",
"download": "Ladda ner",
"exclude_invoice": "Exkludera",
"ignore": "Ignorera",
"include_invoice": "Inkludera",
"mark_completed": "Markera klar",
"move_collection": "Flytta samling",
"resend_booking": "Skicka igen",
"resend_booking_completion": "Skicka slutförande igen",
"resend_wash_certificate": "Skicka tvättcertifikat igen",
"retry": "Försök igen",
"unlink_booking": "Ta bort bokning",
"unlink_xlvask": "Ta bort självtvätt"
},
"categories": {
"agreements": "Betalningsavtal",
"booking_items": "Booking items",
"bookings": "Bokningar",
"economic": "Fakturor",
"images": "Bilder",
"order_items": "Order items",
"orders": "Orders",
"other_attachments": "Andra bilagor",
"payments": "Kortbetalningar",
"wash_certificates": "Tvättcertifikat",
"xlvask": "Självtvätt",
"xlvask_items": "XL Vask parsed inferred order items"
},
"confirmation": {
"input_label": "Skriv {phrase} för att fortsätta",
"phrase": "Bekräfta",
"validation": "Skriv {phrase}"
},
"errors": {
"action_failed": "Åtgärden misslyckades",
"download_failed": "Nedladdningen misslyckades",
"load_failed": "Kunde inte läsa in innehållet."
},
"economic": {
"booked": "Bokförd",
"draft": "Utkast",
"economic_total": "E-conomic total",
"internal_total": "Intern total",
"invoice_number": "E-conomic nr.",
"invoice_type": "Typ",
"lines": "Rader"
},
"fields": {
"empty": "Tom",
"hide_empty": "Dölj tomma fält",
"notes": "Anteckningar",
"price": "Pris",
"product_id": "Produkt-ID",
"quantity": "Antal",
"reference": "Referens",
"show_empty": "Visa tomma fält"
},
"nodes": {
"attachment_fallback": "Bilaga #{id}",
"booking": "Bokning #{id}",
"booking_item_fallback": "Bokningsrad #{id}",
"collection": "Fakturasamling #{id}",
"economic_booked_with_id": "E-conomic-faktura #{id}",
"economic_draft": "E-conomic-utkast",
"economic_draft_with_id": "E-conomic-utkast #{id}",
"fixed_pricing": "Fastpris",
"invoice_for_order": "Faktura för order #{id}",
"order": "Order #{id}",
"order_item_fallback": "Rad #{id}",
"orders_without_collection": "Orders utan fakturasamling",
"payment_for_order": "Kortbetalning för order #{id}",
"vehicle_subscription": "Tvättabonnemang",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
"xlvask_item_fallback": "XL Vask rad {index}"
},
"selection": {
"mixed_types": "Välj bara en objekttyp för åtgärder",
"no_actions": "Inga åtgärder för de valda objekten"
},
"preview": {
"download_on_click": "Klicka för att ladda ner",
"economic_invoice": "E-conomic-faktura",
"loading": "Läser in förhandsvisning"
},
"subtitles": {
"collection": "Samling #{id}",
"order": "Order #{id}",
"quantity": "Antal {count}",
"wash_id": "WashId {id}"
},
"success": {
"title": "Åtgärden utförd"
},
"types": {
"attachment": "bilagor",
"booking": "bokningar",
"collection": "fakturasamlingar",
"order": "orders",
"order_item": "orderrader",
"xlvask": "självtvättar"
}
} }
}, },
"invoicing": { "invoicing": {
@@ -0,0 +1,196 @@
{
"compat": {
"invoicing_period": {
"object_tree": {
"actions": {
"apply": "Udfør",
"attachments": {
"delete": {
"preview": "{count} vedhæftninger slettes.",
"title": "Slet vedhæftninger"
},
"resend_wash_certificates": {
"preview": "{count} ordre får gensendt vaskecertifikat.",
"title": "Gensend vaskecertifikater"
}
},
"bookings": {
"delete": {
"preview": "{count} bookinger slettes.",
"title": "Slet bookinger"
},
"resend_completion": {
"preview": "{count} afslutningsbekræftelser sendes igen.",
"title": "Gensend afslutningsbekræftelser"
},
"resend_confirmation": {
"preview": "{count} bookingbekræftelser sendes igen.",
"title": "Gensend bookingbekræftelser"
}
},
"order_items": {
"delete": {
"preview": "{count} orderlinjer slettes.",
"title": "Slet orderlinjer"
}
},
"orders": {
"delete": {
"preview": "{count} orders slettes.",
"title": "Slet valgte orders"
},
"exclude_invoice": {
"title": "Ekskluder orders fra faktura"
},
"include_invoice": {
"title": "Inkluder orders på faktura"
},
"invoice_override": {
"preview": "{count} orders opdateres."
},
"mark_completed": {
"preview": "{count} orders markeres som færdige.",
"title": "Marker orders som færdige"
},
"move_collection": {
"input_label": "Mål-fakturasamling ID",
"preview": "{count} orders flyttes til fakturasamling #{id}.",
"title": "Flyt orders til fakturasamling",
"validation": "Angiv et gyldigt fakturasamlings-ID."
},
"unlink_booking": {
"preview": "{count} orders får fjernet booking-link.",
"title": "Fjern booking fra orders"
},
"unlink_xlvask": {
"preview": "{count} orders får fjernet Selvvask-link.",
"title": "Fjern Selvvask fra orders"
}
},
"xlvask": {
"accept": {
"preview": "{count} XL Vask forslag accepteres.",
"title": "Accepter XL Vask forslag"
},
"deny": {
"preview": "{count} XL Vask forslag afvises.",
"title": "Afvis XL Vask forslag"
},
"ignore": {
"preview": "{count} XL Vask rækker ignoreres.",
"title": "Ignorer XL Vask rækker"
}
}
},
"aria_label": "Fakturaperiode objekttræ",
"buttons": {
"accept_xlvask": "Accepter forslag",
"actions": "Handlinger",
"delete": "Slet",
"delete_lines": "Slet linjer",
"deny_xlvask": "Afvis forslag",
"download": "Download",
"exclude_invoice": "Ekskluder",
"ignore": "Ignorer",
"include_invoice": "Inkluder",
"mark_completed": "Marker færdig",
"move_collection": "Flyt samling",
"resend_booking": "Gensend",
"resend_booking_completion": "Gensend afslutning",
"resend_wash_certificate": "Gensend vaskecertifikat",
"retry": "Prøv igen",
"unlink_booking": "Fjern booking",
"unlink_xlvask": "Fjern Selvvask"
},
"categories": {
"agreements": "Betalingsaftaler",
"booking_items": "Booking items",
"bookings": "Bookinger",
"economic": "Fakturaer",
"images": "Billeder",
"order_items": "Order items",
"orders": "Orders",
"other_attachments": "Andre vedhæftninger",
"payments": "Kortbetalinger",
"wash_certificates": "Vaskecertifikater",
"xlvask": "Selvvask",
"xlvask_items": "XL Vask parsed inferred order items"
},
"confirmation": {
"input_label": "Skriv {phrase} for at fortsætte",
"phrase": "Bekræft",
"validation": "Skriv {phrase}"
},
"errors": {
"action_failed": "Handlingen mislykkedes",
"download_failed": "Download mislykkedes",
"load_failed": "Kunne ikke indlæse indholdet."
},
"economic": {
"booked": "Bogført",
"draft": "Kladde",
"economic_total": "E-conomic total",
"internal_total": "Intern total",
"invoice_number": "E-conomic nr.",
"invoice_type": "Type",
"lines": "Linjer"
},
"fields": {
"empty": "Tom",
"hide_empty": "Skjul tomme felter",
"notes": "Noter",
"price": "Pris",
"product_id": "Produkt ID",
"quantity": "Antal",
"reference": "Reference",
"show_empty": "Vis tomme felter"
},
"nodes": {
"attachment_fallback": "Vedhæftning #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Bookinglinje #{id}",
"collection": "Fakturasamling #{id}",
"economic_booked_with_id": "E-conomic faktura #{id}",
"economic_draft": "E-conomic kladde",
"economic_draft_with_id": "E-conomic kladde #{id}",
"fixed_pricing": "Fastpris",
"invoice_for_order": "Faktura for ordre #{id}",
"order": "Ordre #{id}",
"order_item_fallback": "Linje #{id}",
"orders_without_collection": "Orders uden fakturasamling",
"payment_for_order": "Kortbetaling for ordre #{id}",
"vehicle_subscription": "Vaskeabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
"xlvask_item_fallback": "XL Vask linje {index}"
},
"selection": {
"mixed_types": "Vælg kun én objekttype for handlinger",
"no_actions": "Ingen handlinger til de valgte objekter"
},
"preview": {
"download_on_click": "Klik for at downloade",
"economic_invoice": "E-conomic faktura",
"loading": "Indlæser preview"
},
"subtitles": {
"collection": "Samling #{id}",
"order": "Ordre #{id}",
"quantity": "Antal {count}",
"wash_id": "WashId {id}"
},
"success": {
"title": "Handling udført"
},
"types": {
"attachment": "vedhæftninger",
"booking": "bookinger",
"collection": "fakturasamlinger",
"order": "orders",
"order_item": "orderlinjer",
"xlvask": "selvvaske"
}
}
}
}
}
@@ -0,0 +1,196 @@
{
"compat": {
"invoicing_period": {
"object_tree": {
"actions": {
"apply": "Ausführen",
"attachments": {
"delete": {
"preview": "{count} Anhänge werden gelöscht.",
"title": "Anhänge löschen"
},
"resend_wash_certificates": {
"preview": "Waschzertifikate werden für {count} Aufträge erneut gesendet.",
"title": "Waschzertifikate erneut senden"
}
},
"bookings": {
"delete": {
"preview": "{count} Buchungen werden gelöscht.",
"title": "Buchungen löschen"
},
"resend_completion": {
"preview": "{count} Abschlussbestätigungen werden erneut gesendet.",
"title": "Abschlussbestätigungen erneut senden"
},
"resend_confirmation": {
"preview": "{count} Buchungsbestätigungen werden erneut gesendet.",
"title": "Buchungsbestätigungen erneut senden"
}
},
"order_items": {
"delete": {
"preview": "{count} Auftragspositionen werden gelöscht.",
"title": "Auftragspositionen löschen"
}
},
"orders": {
"delete": {
"preview": "{count} Aufträge werden gelöscht.",
"title": "Ausgewählte Aufträge löschen"
},
"exclude_invoice": {
"title": "Aufträge von Rechnung ausschließen"
},
"include_invoice": {
"title": "Aufträge auf Rechnung aufnehmen"
},
"invoice_override": {
"preview": "{count} Aufträge werden aktualisiert."
},
"mark_completed": {
"preview": "{count} Aufträge werden als abgeschlossen markiert.",
"title": "Aufträge als abgeschlossen markieren"
},
"move_collection": {
"input_label": "Ziel-Fakturasammlungs-ID",
"preview": "{count} Aufträge werden in Fakturasammlung #{id} verschoben.",
"title": "Aufträge in Fakturasammlung verschieben",
"validation": "Geben Sie eine gültige Fakturasammlungs-ID ein."
},
"unlink_booking": {
"preview": "Buchungslinks werden von {count} Aufträgen entfernt.",
"title": "Buchung von Aufträgen entfernen"
},
"unlink_xlvask": {
"preview": "Selbstwasch-Links werden von {count} Aufträgen entfernt.",
"title": "Selbstwasch von Aufträgen entfernen"
}
},
"xlvask": {
"accept": {
"preview": "{count} XL Vask-Vorschläge werden akzeptiert.",
"title": "XL Vask-Vorschläge akzeptieren"
},
"deny": {
"preview": "{count} XL Vask-Vorschläge werden abgelehnt.",
"title": "XL Vask-Vorschläge ablehnen"
},
"ignore": {
"preview": "{count} XL Vask-Zeilen werden ignoriert.",
"title": "XL Vask-Zeilen ignorieren"
}
}
},
"aria_label": "Objektbaum für Rechnungsperiode",
"buttons": {
"accept_xlvask": "Vorschlag akzeptieren",
"actions": "Aktionen",
"delete": "Löschen",
"delete_lines": "Zeilen löschen",
"deny_xlvask": "Vorschlag ablehnen",
"download": "Download",
"exclude_invoice": "Ausschließen",
"ignore": "Ignorieren",
"include_invoice": "Einschließen",
"mark_completed": "Abschließen",
"move_collection": "Sammlung verschieben",
"resend_booking": "Erneut senden",
"resend_booking_completion": "Abschluss erneut senden",
"resend_wash_certificate": "Waschzertifikat erneut senden",
"retry": "Erneut versuchen",
"unlink_booking": "Buchung entfernen",
"unlink_xlvask": "Selbstwasch entfernen"
},
"categories": {
"agreements": "Zahlungsvereinbarungen",
"booking_items": "Booking items",
"bookings": "Buchungen",
"economic": "Rechnungen",
"images": "Bilder",
"order_items": "Order items",
"orders": "Orders",
"other_attachments": "Andere Anhänge",
"payments": "Kartenzahlungen",
"wash_certificates": "Waschzertifikate",
"xlvask": "Selbstwasch",
"xlvask_items": "XL Vask parsed inferred order items"
},
"confirmation": {
"input_label": "Geben Sie {phrase} ein, um fortzufahren",
"phrase": "Bestätigen",
"validation": "Geben Sie {phrase} ein"
},
"errors": {
"action_failed": "Aktion fehlgeschlagen",
"download_failed": "Download fehlgeschlagen",
"load_failed": "Inhalt konnte nicht geladen werden."
},
"economic": {
"booked": "Gebucht",
"draft": "Entwurf",
"economic_total": "E-conomic gesamt",
"internal_total": "Interne Summe",
"invoice_number": "E-conomic Nr.",
"invoice_type": "Typ",
"lines": "Zeilen"
},
"fields": {
"empty": "Leer",
"hide_empty": "Leere Felder ausblenden",
"notes": "Notizen",
"price": "Preis",
"product_id": "Produkt-ID",
"quantity": "Anzahl",
"reference": "Referenz",
"show_empty": "Leere Felder anzeigen"
},
"nodes": {
"attachment_fallback": "Anhang #{id}",
"booking": "Buchung #{id}",
"booking_item_fallback": "Buchungsposition #{id}",
"collection": "Fakturasammlung #{id}",
"economic_booked_with_id": "E-conomic-Rechnung #{id}",
"economic_draft": "E-conomic-Entwurf",
"economic_draft_with_id": "E-conomic-Entwurf #{id}",
"fixed_pricing": "Festpreis",
"invoice_for_order": "Rechnung für Auftrag #{id}",
"order": "Auftrag #{id}",
"order_item_fallback": "Position #{id}",
"orders_without_collection": "Orders ohne Fakturasammlung",
"payment_for_order": "Kartenzahlung für Auftrag #{id}",
"vehicle_subscription": "Waschabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
"xlvask_item_fallback": "XL Vask-Position {index}"
},
"selection": {
"mixed_types": "Wählen Sie nur einen Objekttyp für Aktionen",
"no_actions": "Keine Aktionen für die ausgewählten Objekte"
},
"preview": {
"download_on_click": "Zum Herunterladen klicken",
"economic_invoice": "E-conomic-Rechnung",
"loading": "Vorschau wird geladen"
},
"subtitles": {
"collection": "Sammlung #{id}",
"order": "Auftrag #{id}",
"quantity": "Anzahl {count}",
"wash_id": "WashId {id}"
},
"success": {
"title": "Aktion abgeschlossen"
},
"types": {
"attachment": "Anhänge",
"booking": "Buchungen",
"collection": "Fakturasammlungen",
"order": "Orders",
"order_item": "Auftragspositionen",
"xlvask": "Selbstwäschen"
}
}
}
}
}
@@ -0,0 +1,196 @@
{
"compat": {
"invoicing_period": {
"object_tree": {
"actions": {
"apply": "Apply",
"attachments": {
"delete": {
"preview": "{count} attachments will be deleted.",
"title": "Delete attachments"
},
"resend_wash_certificates": {
"preview": "Wash certificates will be resent for {count} orders.",
"title": "Resend wash certificates"
}
},
"bookings": {
"delete": {
"preview": "{count} bookings will be deleted.",
"title": "Delete bookings"
},
"resend_completion": {
"preview": "{count} completion confirmations will be resent.",
"title": "Resend completion confirmations"
},
"resend_confirmation": {
"preview": "{count} booking confirmations will be resent.",
"title": "Resend booking confirmations"
}
},
"order_items": {
"delete": {
"preview": "{count} order items will be deleted.",
"title": "Delete order items"
}
},
"orders": {
"delete": {
"preview": "{count} orders will be deleted.",
"title": "Delete selected orders"
},
"exclude_invoice": {
"title": "Exclude orders from invoice"
},
"include_invoice": {
"title": "Include orders on invoice"
},
"invoice_override": {
"preview": "{count} orders will be updated."
},
"mark_completed": {
"preview": "{count} orders will be marked completed.",
"title": "Mark orders completed"
},
"move_collection": {
"input_label": "Target invoice collection ID",
"preview": "{count} orders will move to invoice collection #{id}.",
"title": "Move orders to invoice collection",
"validation": "Enter a valid invoice collection ID."
},
"unlink_booking": {
"preview": "Booking links will be removed from {count} orders.",
"title": "Remove booking from orders"
},
"unlink_xlvask": {
"preview": "Self-wash links will be removed from {count} orders.",
"title": "Remove self-wash from orders"
}
},
"xlvask": {
"accept": {
"preview": "{count} XL Vask suggestions will be accepted.",
"title": "Accept XL Vask suggestions"
},
"deny": {
"preview": "{count} XL Vask suggestions will be denied.",
"title": "Deny XL Vask suggestions"
},
"ignore": {
"preview": "{count} XL Vask rows will be ignored.",
"title": "Ignore XL Vask rows"
}
}
},
"aria_label": "Invoice period object tree",
"buttons": {
"accept_xlvask": "Accept suggestion",
"actions": "Actions",
"delete": "Delete",
"delete_lines": "Delete lines",
"deny_xlvask": "Deny suggestion",
"download": "Download",
"exclude_invoice": "Exclude",
"ignore": "Ignore",
"include_invoice": "Include",
"mark_completed": "Mark completed",
"move_collection": "Move collection",
"resend_booking": "Resend",
"resend_booking_completion": "Resend completion",
"resend_wash_certificate": "Resend wash certificate",
"retry": "Retry",
"unlink_booking": "Remove booking",
"unlink_xlvask": "Remove self-wash"
},
"categories": {
"agreements": "Payment agreements",
"booking_items": "Booking items",
"bookings": "Bookings",
"economic": "Invoices",
"images": "Images",
"order_items": "Order items",
"orders": "Orders",
"other_attachments": "Other attachments",
"payments": "Card payments",
"wash_certificates": "Wash certificates",
"xlvask": "Self-wash",
"xlvask_items": "XL Vask parsed inferred order items"
},
"confirmation": {
"input_label": "Type {phrase} to continue",
"phrase": "Confirm",
"validation": "Type {phrase}"
},
"errors": {
"action_failed": "Action failed",
"download_failed": "Download failed",
"load_failed": "Could not load the content."
},
"economic": {
"booked": "Booked",
"draft": "Draft",
"economic_total": "E-conomic total",
"internal_total": "Internal total",
"invoice_number": "E-conomic no.",
"invoice_type": "Type",
"lines": "Lines"
},
"fields": {
"empty": "Empty",
"hide_empty": "Hide empty fields",
"notes": "Notes",
"price": "Price",
"product_id": "Product ID",
"quantity": "Quantity",
"reference": "Reference",
"show_empty": "Show empty fields"
},
"nodes": {
"attachment_fallback": "Attachment #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Booking item #{id}",
"collection": "Invoice collection #{id}",
"economic_booked_with_id": "E-conomic invoice #{id}",
"economic_draft": "E-conomic draft",
"economic_draft_with_id": "E-conomic draft #{id}",
"fixed_pricing": "Fixed pricing",
"invoice_for_order": "Invoice for order #{id}",
"order": "Order #{id}",
"order_item_fallback": "Line #{id}",
"orders_without_collection": "Orders without invoice collection",
"payment_for_order": "Card payment for order #{id}",
"vehicle_subscription": "Wash subscription",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
"xlvask_item_fallback": "XL Vask line {index}"
},
"selection": {
"mixed_types": "Select only one object type for actions",
"no_actions": "No actions for the selected objects"
},
"preview": {
"download_on_click": "Click to download",
"economic_invoice": "E-conomic invoice",
"loading": "Loading preview"
},
"subtitles": {
"collection": "Collection #{id}",
"order": "Order #{id}",
"quantity": "Quantity {count}",
"wash_id": "WashId {id}"
},
"success": {
"title": "Action completed"
},
"types": {
"attachment": "attachments",
"booking": "bookings",
"collection": "invoice collections",
"order": "orders",
"order_item": "order items",
"xlvask": "self-washes"
}
}
}
}
}
+15 -2
View File
@@ -33,8 +33,8 @@
"loading": "Loading cron tasks...", "loading": "Loading cron tasks...",
"messages": { "messages": {
"queued": "{task} was queued for the cron worker.", "queued": "{task} was queued for the cron worker.",
"worker_deploy_queued": "Cron worker deployment was queued.", "worker_deploy_queued": "Cron worker deployment was queued (#{id}).",
"worker_update_queued": "Cron worker deployment update was queued." "worker_update_queued": "Cron worker deployment update was queued (#{id})."
}, },
"nav": "@:{'cron.title'}", "nav": "@:{'cron.title'}",
"replication": "Replication", "replication": "Replication",
@@ -44,12 +44,18 @@
"enabled": "@:common.enabled" "enabled": "@:common.enabled"
}, },
"status": { "status": {
"degraded": "Degraded",
"deployed": "Deployed",
"deploying": "Deploying",
"failed": "Failed", "failed": "Failed",
"healthy": "Healthy",
"needs_deploy": "Needs deploy",
"queued": "Queued", "queued": "Queued",
"running": "Running", "running": "Running",
"skipped": "Skipped", "skipped": "Skipped",
"succeeded": "Succeeded", "succeeded": "Succeeded",
"timed_out": "Timed out", "timed_out": "Timed out",
"waiting_for_heartbeat": "Waiting for heartbeat",
"unknown": "Unknown" "unknown": "Unknown"
}, },
"subtitle": "Configure schedules, run tasks manually, and inspect recent cron execution.", "subtitle": "Configure schedules, run tasks manually, and inspect recent cron execution.",
@@ -72,15 +78,22 @@
}, },
"title": "Cron tasks", "title": "Cron tasks",
"workers": { "workers": {
"api_target": "API target",
"deploy": "Deploy worker", "deploy": "Deploy worker",
"deploy_to_coolify": "Deploy to Coolify", "deploy_to_coolify": "Deploy to Coolify",
"deployment": "Deployment",
"deployment_label": "{status} #{id}",
"empty": "No cron workers found.", "empty": "No cron workers found.",
"empty_with_target": "Cron worker deployment target exists, but no worker heartbeat has been recorded yet.",
"heartbeat": "Heartbeat", "heartbeat": "Heartbeat",
"last_run_count": "Last run count", "last_run_count": "Last run count",
"latest_deployment": "Latest deployment",
"loading": "Loading cron workers...", "loading": "Loading cron workers...",
"name": "Worker", "name": "Worker",
"repair_deployment": "Repair Coolify deployment",
"running": "@:{'cron.status.running'}", "running": "@:{'cron.status.running'}",
"source": "@:{'cron.history.source'}", "source": "@:{'cron.history.source'}",
"state": "State",
"stale": "Stale", "stale": "Stale",
"status": "@:{'cron.history.status'}", "status": "@:{'cron.history.status'}",
"target": "@:{'security.firewall.target'}", "target": "@:{'security.firewall.target'}",
@@ -0,0 +1,194 @@
{
"invoicing_period": {
"object_tree": {
"actions": {
"apply": "@:{'phrases.compat.invoicing_period.object_tree.actions.apply'}",
"attachments": {
"delete": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.attachments.delete.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.attachments.delete.title'}"
},
"resend_wash_certificates": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.attachments.resend_wash_certificates.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.attachments.resend_wash_certificates.title'}"
}
},
"bookings": {
"delete": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.bookings.delete.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.bookings.delete.title'}"
},
"resend_completion": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.bookings.resend_completion.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.bookings.resend_completion.title'}"
},
"resend_confirmation": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.bookings.resend_confirmation.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.bookings.resend_confirmation.title'}"
}
},
"order_items": {
"delete": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.order_items.delete.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.order_items.delete.title'}"
}
},
"orders": {
"delete": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.delete.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.delete.title'}"
},
"exclude_invoice": {
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.exclude_invoice.title'}"
},
"include_invoice": {
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.include_invoice.title'}"
},
"invoice_override": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.invoice_override.preview'}"
},
"mark_completed": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.mark_completed.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.mark_completed.title'}"
},
"move_collection": {
"input_label": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.move_collection.input_label'}",
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.move_collection.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.move_collection.title'}",
"validation": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.move_collection.validation'}"
},
"unlink_booking": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.unlink_booking.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.unlink_booking.title'}"
},
"unlink_xlvask": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.unlink_xlvask.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.orders.unlink_xlvask.title'}"
}
},
"xlvask": {
"accept": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.xlvask.accept.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.xlvask.accept.title'}"
},
"deny": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.xlvask.deny.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.xlvask.deny.title'}"
},
"ignore": {
"preview": "@:{'phrases.compat.invoicing_period.object_tree.actions.xlvask.ignore.preview'}",
"title": "@:{'phrases.compat.invoicing_period.object_tree.actions.xlvask.ignore.title'}"
}
}
},
"aria_label": "@:{'phrases.compat.invoicing_period.object_tree.aria_label'}",
"buttons": {
"accept_xlvask": "@:{'phrases.compat.invoicing_period.object_tree.buttons.accept_xlvask'}",
"actions": "@:{'phrases.compat.invoicing_period.object_tree.buttons.actions'}",
"delete": "@:{'phrases.compat.invoicing_period.object_tree.buttons.delete'}",
"delete_lines": "@:{'phrases.compat.invoicing_period.object_tree.buttons.delete_lines'}",
"deny_xlvask": "@:{'phrases.compat.invoicing_period.object_tree.buttons.deny_xlvask'}",
"download": "@:{'phrases.compat.invoicing_period.object_tree.buttons.download'}",
"exclude_invoice": "@:{'phrases.compat.invoicing_period.object_tree.buttons.exclude_invoice'}",
"ignore": "@:{'phrases.compat.invoicing_period.object_tree.buttons.ignore'}",
"include_invoice": "@:{'phrases.compat.invoicing_period.object_tree.buttons.include_invoice'}",
"mark_completed": "@:{'phrases.compat.invoicing_period.object_tree.buttons.mark_completed'}",
"move_collection": "@:{'phrases.compat.invoicing_period.object_tree.buttons.move_collection'}",
"resend_booking": "@:{'phrases.compat.invoicing_period.object_tree.buttons.resend_booking'}",
"resend_booking_completion": "@:{'phrases.compat.invoicing_period.object_tree.buttons.resend_booking_completion'}",
"resend_wash_certificate": "@:{'phrases.compat.invoicing_period.object_tree.buttons.resend_wash_certificate'}",
"retry": "@:{'phrases.compat.invoicing_period.object_tree.buttons.retry'}",
"unlink_booking": "@:{'phrases.compat.invoicing_period.object_tree.buttons.unlink_booking'}",
"unlink_xlvask": "@:{'phrases.compat.invoicing_period.object_tree.buttons.unlink_xlvask'}"
},
"categories": {
"agreements": "@:{'phrases.compat.invoicing_period.object_tree.categories.agreements'}",
"booking_items": "@:{'phrases.compat.invoicing_period.object_tree.categories.booking_items'}",
"bookings": "@:{'phrases.compat.invoicing_period.object_tree.categories.bookings'}",
"economic": "@:{'phrases.compat.invoicing_period.object_tree.categories.economic'}",
"images": "@:{'phrases.compat.invoicing_period.object_tree.categories.images'}",
"order_items": "@:{'phrases.compat.invoicing_period.object_tree.categories.order_items'}",
"orders": "@:{'phrases.compat.invoicing_period.object_tree.categories.orders'}",
"other_attachments": "@:{'phrases.compat.invoicing_period.object_tree.categories.other_attachments'}",
"payments": "@:{'phrases.compat.invoicing_period.object_tree.categories.payments'}",
"wash_certificates": "@:{'phrases.compat.invoicing_period.object_tree.categories.wash_certificates'}",
"xlvask": "@:{'phrases.compat.invoicing_period.object_tree.categories.xlvask'}",
"xlvask_items": "@:{'phrases.compat.invoicing_period.object_tree.categories.xlvask_items'}"
},
"confirmation": {
"input_label": "@:{'phrases.compat.invoicing_period.object_tree.confirmation.input_label'}",
"phrase": "@:{'phrases.compat.invoicing_period.object_tree.confirmation.phrase'}",
"validation": "@:{'phrases.compat.invoicing_period.object_tree.confirmation.validation'}"
},
"errors": {
"action_failed": "@:{'phrases.compat.invoicing_period.object_tree.errors.action_failed'}",
"download_failed": "@:{'phrases.compat.invoicing_period.object_tree.errors.download_failed'}",
"load_failed": "@:{'phrases.compat.invoicing_period.object_tree.errors.load_failed'}"
},
"economic": {
"booked": "@:{'phrases.compat.invoicing_period.object_tree.economic.booked'}",
"draft": "@:{'phrases.compat.invoicing_period.object_tree.economic.draft'}",
"economic_total": "@:{'phrases.compat.invoicing_period.object_tree.economic.economic_total'}",
"internal_total": "@:{'phrases.compat.invoicing_period.object_tree.economic.internal_total'}",
"invoice_number": "@:{'phrases.compat.invoicing_period.object_tree.economic.invoice_number'}",
"invoice_type": "@:{'phrases.compat.invoicing_period.object_tree.economic.invoice_type'}",
"lines": "@:{'phrases.compat.invoicing_period.object_tree.economic.lines'}"
},
"fields": {
"empty": "@:{'phrases.compat.invoicing_period.object_tree.fields.empty'}",
"hide_empty": "@:{'phrases.compat.invoicing_period.object_tree.fields.hide_empty'}",
"notes": "@:{'phrases.compat.invoicing_period.object_tree.fields.notes'}",
"price": "@:{'phrases.compat.invoicing_period.object_tree.fields.price'}",
"product_id": "@:{'phrases.compat.invoicing_period.object_tree.fields.product_id'}",
"quantity": "@:{'phrases.compat.invoicing_period.object_tree.fields.quantity'}",
"reference": "@:{'phrases.compat.invoicing_period.object_tree.fields.reference'}",
"show_empty": "@:{'phrases.compat.invoicing_period.object_tree.fields.show_empty'}"
},
"nodes": {
"attachment_fallback": "@:{'phrases.compat.invoicing_period.object_tree.nodes.attachment_fallback'}",
"booking": "@:{'phrases.compat.invoicing_period.object_tree.nodes.booking'}",
"booking_item_fallback": "@:{'phrases.compat.invoicing_period.object_tree.nodes.booking_item_fallback'}",
"collection": "@:{'phrases.compat.invoicing_period.object_tree.nodes.collection'}",
"economic_booked_with_id": "@:{'phrases.compat.invoicing_period.object_tree.nodes.economic_booked_with_id'}",
"economic_draft": "@:{'phrases.compat.invoicing_period.object_tree.nodes.economic_draft'}",
"economic_draft_with_id": "@:{'phrases.compat.invoicing_period.object_tree.nodes.economic_draft_with_id'}",
"fixed_pricing": "@:{'phrases.compat.invoicing_period.object_tree.nodes.fixed_pricing'}",
"invoice_for_order": "@:{'phrases.compat.invoicing_period.object_tree.nodes.invoice_for_order'}",
"order": "@:{'phrases.compat.invoicing_period.object_tree.nodes.order'}",
"order_item_fallback": "@:{'phrases.compat.invoicing_period.object_tree.nodes.order_item_fallback'}",
"orders_without_collection": "@:{'phrases.compat.invoicing_period.object_tree.nodes.orders_without_collection'}",
"payment_for_order": "@:{'phrases.compat.invoicing_period.object_tree.nodes.payment_for_order'}",
"vehicle_subscription": "@:{'phrases.compat.invoicing_period.object_tree.nodes.vehicle_subscription'}",
"xlvask": "@:{'phrases.compat.invoicing_period.object_tree.nodes.xlvask'}",
"xlvask_empty": "@:{'phrases.compat.invoicing_period.object_tree.nodes.xlvask_empty'}",
"xlvask_item_fallback": "@:{'phrases.compat.invoicing_period.object_tree.nodes.xlvask_item_fallback'}"
},
"selection": {
"mixed_types": "@:{'phrases.compat.invoicing_period.object_tree.selection.mixed_types'}",
"no_actions": "@:{'phrases.compat.invoicing_period.object_tree.selection.no_actions'}"
},
"preview": {
"download_on_click": "@:{'phrases.compat.invoicing_period.object_tree.preview.download_on_click'}",
"economic_invoice": "@:{'phrases.compat.invoicing_period.object_tree.preview.economic_invoice'}",
"loading": "@:{'phrases.compat.invoicing_period.object_tree.preview.loading'}"
},
"subtitles": {
"collection": "@:{'phrases.compat.invoicing_period.object_tree.subtitles.collection'}",
"order": "@:{'phrases.compat.invoicing_period.object_tree.subtitles.order'}",
"quantity": "@:{'phrases.compat.invoicing_period.object_tree.subtitles.quantity'}",
"wash_id": "@:{'phrases.compat.invoicing_period.object_tree.subtitles.wash_id'}"
},
"success": {
"title": "@:{'phrases.compat.invoicing_period.object_tree.success.title'}"
},
"types": {
"attachment": "@:{'phrases.compat.invoicing_period.object_tree.types.attachment'}",
"booking": "@:{'phrases.compat.invoicing_period.object_tree.types.booking'}",
"collection": "@:{'phrases.compat.invoicing_period.object_tree.types.collection'}",
"order": "@:{'phrases.compat.invoicing_period.object_tree.types.order'}",
"order_item": "@:{'phrases.compat.invoicing_period.object_tree.types.order_item'}",
"xlvask": "@:{'phrases.compat.invoicing_period.object_tree.types.xlvask'}"
}
}
}
}
@@ -64,15 +64,21 @@
"configured": "@:{'phrases.compat.system_status.labels.configured'}", "configured": "@:{'phrases.compat.system_status.labels.configured'}",
"buckets": "@.capitalize:{'terms.glossary.buckets'}", "buckets": "@.capitalize:{'terms.glossary.buckets'}",
"database_index": "@:replication.fields.database_index", "database_index": "@:replication.fields.database_index",
"detail": "Detail",
"enabled": "@:{'phrases.compat.system_status.labels.enabled'}", "enabled": "@:{'phrases.compat.system_status.labels.enabled'}",
"endpoint": "@.capitalize:{'terms.glossary.endpoint'}", "endpoint": "@.capitalize:{'terms.glossary.endpoint'}",
"latency": "@:{'phrases.compat.system_status.labels.latency'}", "latency": "@:{'phrases.compat.system_status.labels.latency'}",
"no_reason": "@:{'phrases.compat.system_status.labels.no_reason'}", "no_reason": "@:{'phrases.compat.system_status.labels.no_reason'}",
"quota_calls": "@:{'phrases.compat.system_status.labels.quota_calls'}", "quota_calls": "@:{'phrases.compat.system_status.labels.quota_calls'}",
"quota_usage": "@:{'phrases.compat.system_status.labels.quota_usage'}", "quota_usage": "@:{'phrases.compat.system_status.labels.quota_usage'}",
"limit": "Limit",
"remaining": "Remaining",
"replication_percent": "@:{'phrases.compat.system_status.labels.replication_percent'}", "replication_percent": "@:{'phrases.compat.system_status.labels.replication_percent'}",
"runtime_source": "@:{'phrases.compat.system_status.labels.runtime_source'}", "runtime_source": "@:{'phrases.compat.system_status.labels.runtime_source'}",
"server_version": "@:{'phrases.compat.system_status.labels.server_version'}", "server_version": "@:{'phrases.compat.system_status.labels.server_version'}",
"status": "Status",
"used": "Used",
"usage_unavailable": "Usage unavailable",
"version": "@:{'phrases.compat.system_status.labels.version'}", "version": "@:{'phrases.compat.system_status.labels.version'}",
"warnings": "@:{'phrases.compat.system_status.labels.warnings'}" "warnings": "@:{'phrases.compat.system_status.labels.warnings'}"
}, },
@@ -98,6 +104,12 @@
"workfeed": "@:{'phrases.compat.system_status.modules.workfeed'}", "workfeed": "@:{'phrases.compat.system_status.modules.workfeed'}",
"xlvask": "@:superuser.nav.xlvask" "xlvask": "@:superuser.nav.xlvask"
}, },
"quota_unavailable_reasons": {
"invalid_limit": "The provider returned a quota limit that could not be used.",
"missing_limit": "The provider response did not include a recognizable quota limit.",
"missing_usage": "The provider response did not include recognizable usage or remaining quota values.",
"unreadable_payload": "The provider returned a response payload that could not be parsed."
},
"reasons": { "reasons": {
"backup_connectivity_confirmed": "@:{'phrases.compat.system_status.reasons.backup_connectivity_confirmed'}", "backup_connectivity_confirmed": "@:{'phrases.compat.system_status.reasons.backup_connectivity_confirmed'}",
"backup_encryption_key_missing": "@:{'phrases.compat.system_status.reasons.backup_encryption_key_missing'}", "backup_encryption_key_missing": "@:{'phrases.compat.system_status.reasons.backup_encryption_key_missing'}",
@@ -122,6 +134,7 @@
"licenseplaterecognizer_usage_unreadable": "@:{'phrases.compat.system_status.reasons.licenseplaterecognizer_usage_unreadable'}", "licenseplaterecognizer_usage_unreadable": "@:{'phrases.compat.system_status.reasons.licenseplaterecognizer_usage_unreadable'}",
"missing_config": "@:{'phrases.compat.system_status.reasons.missing_config'}", "missing_config": "@:{'phrases.compat.system_status.reasons.missing_config'}",
"module_disabled": "@:{'phrases.compat.system_status.reasons.module_disabled'}", "module_disabled": "@:{'phrases.compat.system_status.reasons.module_disabled'}",
"provider_quota_unavailable": "{label} responded, but quota usage could not be read.",
"recaptcha_credentials_rejected": "@:{'phrases.compat.system_status.reasons.recaptcha_credentials_rejected'}", "recaptcha_credentials_rejected": "@:{'phrases.compat.system_status.reasons.recaptcha_credentials_rejected'}",
"recaptcha_unreadable_payload": "@:{'phrases.compat.system_status.reasons.recaptcha_unreadable_payload'}", "recaptcha_unreadable_payload": "@:{'phrases.compat.system_status.reasons.recaptcha_unreadable_payload'}",
"recaptcha_validation_errors": "@:{'phrases.compat.system_status.reasons.recaptcha_validation_errors'}", "recaptcha_validation_errors": "@:{'phrases.compat.system_status.reasons.recaptcha_validation_errors'}",
@@ -157,6 +170,8 @@
"error": "@:{'phrases.compat.system_status.states.error'}", "error": "@:{'phrases.compat.system_status.states.error'}",
"error_generic": "@:{'phrases.compat.system_status.states.error_generic'}", "error_generic": "@:{'phrases.compat.system_status.states.error_generic'}",
"loading": "@:{'phrases.compat.system_status.states.loading'}", "loading": "@:{'phrases.compat.system_status.states.loading'}",
"module_usage_error": "Quota and usage data could not be loaded.",
"module_usage_unavailable": "No quota or usage statistics are available for this module.",
"no_sessions": "@:{'phrases.compat.system_status.states.no_sessions'}", "no_sessions": "@:{'phrases.compat.system_status.states.no_sessions'}",
"stale": "@:{'phrases.compat.system_status.states.stale'}" "stale": "@:{'phrases.compat.system_status.states.stale'}"
}, },
@@ -0,0 +1,196 @@
{
"compat": {
"invoicing_period": {
"object_tree": {
"actions": {
"apply": "Utfør",
"attachments": {
"delete": {
"preview": "{count} vedlegg slettes.",
"title": "Slett vedlegg"
},
"resend_wash_certificates": {
"preview": "Vaskesertifikater sendes på nytt for {count} ordre.",
"title": "Send vaskesertifikater på nytt"
}
},
"bookings": {
"delete": {
"preview": "{count} bookinger slettes.",
"title": "Slett bookinger"
},
"resend_completion": {
"preview": "{count} fullføringsbekreftelser sendes på nytt.",
"title": "Send fullføringsbekreftelser på nytt"
},
"resend_confirmation": {
"preview": "{count} bookingbekreftelser sendes på nytt.",
"title": "Send bookingbekreftelser på nytt"
}
},
"order_items": {
"delete": {
"preview": "{count} ordrelinjer slettes.",
"title": "Slett ordrelinjer"
}
},
"orders": {
"delete": {
"preview": "{count} ordre slettes.",
"title": "Slett valgte ordre"
},
"exclude_invoice": {
"title": "Ekskluder ordre fra faktura"
},
"include_invoice": {
"title": "Inkluder ordre på faktura"
},
"invoice_override": {
"preview": "{count} ordre oppdateres."
},
"mark_completed": {
"preview": "{count} ordre markeres som fullført.",
"title": "Marker ordre som fullført"
},
"move_collection": {
"input_label": "Mål-fakturasamling ID",
"preview": "{count} ordre flyttes til fakturasamling #{id}.",
"title": "Flytt ordre til fakturasamling",
"validation": "Angi en gyldig fakturasamlings-ID."
},
"unlink_booking": {
"preview": "Booking-link fjernes fra {count} ordre.",
"title": "Fjern booking fra ordre"
},
"unlink_xlvask": {
"preview": "Selvvask-link fjernes fra {count} ordre.",
"title": "Fjern selvvask fra ordre"
}
},
"xlvask": {
"accept": {
"preview": "{count} XL Vask-forslag godtas.",
"title": "Godta XL Vask-forslag"
},
"deny": {
"preview": "{count} XL Vask-forslag avvises.",
"title": "Avvis XL Vask-forslag"
},
"ignore": {
"preview": "{count} XL Vask-rader ignoreres.",
"title": "Ignorer XL Vask-rader"
}
}
},
"aria_label": "Fakturaperiode objekttre",
"buttons": {
"accept_xlvask": "Godta forslag",
"actions": "Handlinger",
"delete": "Slett",
"delete_lines": "Slett linjer",
"deny_xlvask": "Avvis forslag",
"download": "Last ned",
"exclude_invoice": "Ekskluder",
"ignore": "Ignorer",
"include_invoice": "Inkluder",
"mark_completed": "Marker fullført",
"move_collection": "Flytt samling",
"resend_booking": "Send på nytt",
"resend_booking_completion": "Send fullføring på nytt",
"resend_wash_certificate": "Send vaskesertifikat på nytt",
"retry": "Prøv igjen",
"unlink_booking": "Fjern booking",
"unlink_xlvask": "Fjern selvvask"
},
"categories": {
"agreements": "Betalingsavtaler",
"booking_items": "Booking items",
"bookings": "Bookinger",
"economic": "Fakturaer",
"images": "Bilder",
"order_items": "Order items",
"orders": "Orders",
"other_attachments": "Andre vedlegg",
"payments": "Kortbetalinger",
"wash_certificates": "Vaskesertifikater",
"xlvask": "Selvvask",
"xlvask_items": "XL Vask parsed inferred order items"
},
"confirmation": {
"input_label": "Skriv {phrase} for å fortsette",
"phrase": "Bekreft",
"validation": "Skriv {phrase}"
},
"errors": {
"action_failed": "Handlingen mislyktes",
"download_failed": "Nedlasting mislyktes",
"load_failed": "Kunne ikke laste inn innholdet."
},
"economic": {
"booked": "Bokført",
"draft": "Kladd",
"economic_total": "E-conomic total",
"internal_total": "Intern total",
"invoice_number": "E-conomic nr.",
"invoice_type": "Type",
"lines": "Linjer"
},
"fields": {
"empty": "Tom",
"hide_empty": "Skjul tomme felter",
"notes": "Notater",
"price": "Pris",
"product_id": "Produkt-ID",
"quantity": "Antall",
"reference": "Referanse",
"show_empty": "Vis tomme felter"
},
"nodes": {
"attachment_fallback": "Vedlegg #{id}",
"booking": "Booking #{id}",
"booking_item_fallback": "Bookinglinje #{id}",
"collection": "Fakturasamling #{id}",
"economic_booked_with_id": "E-conomic-faktura #{id}",
"economic_draft": "E-conomic-kladd",
"economic_draft_with_id": "E-conomic-kladd #{id}",
"fixed_pricing": "Fastpris",
"invoice_for_order": "Faktura for ordre #{id}",
"order": "Ordre #{id}",
"order_item_fallback": "Linje #{id}",
"orders_without_collection": "Orders uten fakturasamling",
"payment_for_order": "Kortbetaling for ordre #{id}",
"vehicle_subscription": "Vaskeabonnement",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
"xlvask_item_fallback": "XL Vask linje {index}"
},
"selection": {
"mixed_types": "Velg bare én objekttype for handlinger",
"no_actions": "Ingen handlinger for de valgte objektene"
},
"preview": {
"download_on_click": "Klikk for å laste ned",
"economic_invoice": "E-conomic-faktura",
"loading": "Laster forhåndsvisning"
},
"subtitles": {
"collection": "Samling #{id}",
"order": "Ordre #{id}",
"quantity": "Antall {count}",
"wash_id": "WashId {id}"
},
"success": {
"title": "Handling utført"
},
"types": {
"attachment": "vedlegg",
"booking": "bookinger",
"collection": "fakturasamlinger",
"order": "orders",
"order_item": "ordrelinjer",
"xlvask": "selvvaske"
}
}
}
}
}
@@ -0,0 +1,196 @@
{
"compat": {
"invoicing_period": {
"object_tree": {
"actions": {
"apply": "Utför",
"attachments": {
"delete": {
"preview": "{count} bilagor tas bort.",
"title": "Ta bort bilagor"
},
"resend_wash_certificates": {
"preview": "Tvättcertifikat skickas om för {count} order.",
"title": "Skicka tvättcertifikat igen"
}
},
"bookings": {
"delete": {
"preview": "{count} bokningar tas bort.",
"title": "Ta bort bokningar"
},
"resend_completion": {
"preview": "{count} slutförandebekräftelser skickas om.",
"title": "Skicka slutförandebekräftelser igen"
},
"resend_confirmation": {
"preview": "{count} bokningsbekräftelser skickas om.",
"title": "Skicka bokningsbekräftelser igen"
}
},
"order_items": {
"delete": {
"preview": "{count} orderrader tas bort.",
"title": "Ta bort orderrader"
}
},
"orders": {
"delete": {
"preview": "{count} order tas bort.",
"title": "Ta bort valda order"
},
"exclude_invoice": {
"title": "Exkludera order från faktura"
},
"include_invoice": {
"title": "Inkludera order på faktura"
},
"invoice_override": {
"preview": "{count} order uppdateras."
},
"mark_completed": {
"preview": "{count} order markeras som klara.",
"title": "Markera order som klara"
},
"move_collection": {
"input_label": "Mål-fakturasamling ID",
"preview": "{count} order flyttas till fakturasamling #{id}.",
"title": "Flytta order till fakturasamling",
"validation": "Ange ett giltigt fakturasamlings-ID."
},
"unlink_booking": {
"preview": "Bokningslänk tas bort från {count} order.",
"title": "Ta bort bokning från order"
},
"unlink_xlvask": {
"preview": "Självtvättslänk tas bort från {count} order.",
"title": "Ta bort självtvätt från order"
}
},
"xlvask": {
"accept": {
"preview": "{count} XL Vask-förslag accepteras.",
"title": "Acceptera XL Vask-förslag"
},
"deny": {
"preview": "{count} XL Vask-förslag avvisas.",
"title": "Avvisa XL Vask-förslag"
},
"ignore": {
"preview": "{count} XL Vask-rader ignoreras.",
"title": "Ignorera XL Vask-rader"
}
}
},
"aria_label": "Fakturaperiod objektträd",
"buttons": {
"accept_xlvask": "Acceptera förslag",
"actions": "Åtgärder",
"delete": "Ta bort",
"delete_lines": "Ta bort rader",
"deny_xlvask": "Avvisa förslag",
"download": "Ladda ner",
"exclude_invoice": "Exkludera",
"ignore": "Ignorera",
"include_invoice": "Inkludera",
"mark_completed": "Markera klar",
"move_collection": "Flytta samling",
"resend_booking": "Skicka igen",
"resend_booking_completion": "Skicka slutförande igen",
"resend_wash_certificate": "Skicka tvättcertifikat igen",
"retry": "Försök igen",
"unlink_booking": "Ta bort bokning",
"unlink_xlvask": "Ta bort självtvätt"
},
"categories": {
"agreements": "Betalningsavtal",
"booking_items": "Booking items",
"bookings": "Bokningar",
"economic": "Fakturor",
"images": "Bilder",
"order_items": "Order items",
"orders": "Orders",
"other_attachments": "Andra bilagor",
"payments": "Kortbetalningar",
"wash_certificates": "Tvättcertifikat",
"xlvask": "Självtvätt",
"xlvask_items": "XL Vask parsed inferred order items"
},
"confirmation": {
"input_label": "Skriv {phrase} för att fortsätta",
"phrase": "Bekräfta",
"validation": "Skriv {phrase}"
},
"errors": {
"action_failed": "Åtgärden misslyckades",
"download_failed": "Nedladdningen misslyckades",
"load_failed": "Kunde inte läsa in innehållet."
},
"economic": {
"booked": "Bokförd",
"draft": "Utkast",
"economic_total": "E-conomic total",
"internal_total": "Intern total",
"invoice_number": "E-conomic nr.",
"invoice_type": "Typ",
"lines": "Rader"
},
"fields": {
"empty": "Tom",
"hide_empty": "Dölj tomma fält",
"notes": "Anteckningar",
"price": "Pris",
"product_id": "Produkt-ID",
"quantity": "Antal",
"reference": "Referens",
"show_empty": "Visa tomma fält"
},
"nodes": {
"attachment_fallback": "Bilaga #{id}",
"booking": "Bokning #{id}",
"booking_item_fallback": "Bokningsrad #{id}",
"collection": "Fakturasamling #{id}",
"economic_booked_with_id": "E-conomic-faktura #{id}",
"economic_draft": "E-conomic-utkast",
"economic_draft_with_id": "E-conomic-utkast #{id}",
"fixed_pricing": "Fastpris",
"invoice_for_order": "Faktura för order #{id}",
"order": "Order #{id}",
"order_item_fallback": "Rad #{id}",
"orders_without_collection": "Orders utan fakturasamling",
"payment_for_order": "Kortbetalning för order #{id}",
"vehicle_subscription": "Tvättabonnemang",
"xlvask": "XL Vask #{id}",
"xlvask_empty": "XL Vask",
"xlvask_item_fallback": "XL Vask rad {index}"
},
"selection": {
"mixed_types": "Välj bara en objekttyp för åtgärder",
"no_actions": "Inga åtgärder för de valda objekten"
},
"preview": {
"download_on_click": "Klicka för att ladda ner",
"economic_invoice": "E-conomic-faktura",
"loading": "Läser in förhandsvisning"
},
"subtitles": {
"collection": "Samling #{id}",
"order": "Order #{id}",
"quantity": "Antal {count}",
"wash_id": "WashId {id}"
},
"success": {
"title": "Åtgärden utförd"
},
"types": {
"attachment": "bilagor",
"booking": "bokningar",
"collection": "fakturasamlingar",
"order": "orders",
"order_item": "orderrader",
"xlvask": "självtvättar"
}
}
}
}
}
+88
View File
@@ -0,0 +1,88 @@
const imageExtensions = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "heic"]);
const officeExtensions = new Set(["doc", "docx", "xls", "xlsx", "ppt", "pptx"]);
const normalizeString = (value) => String(value ?? "").trim();
export const getAttachmentLabel = (attachment = {}) => normalizeString(
attachment.file_name
?? attachment.filename
?? attachment.name
?? attachment.content?.other
?? attachment.other
);
export const getAttachmentExtension = (attachment = {}) => {
const label = getAttachmentLabel(attachment).toLowerCase();
const extension = label.includes(".") ? label.split(".").pop() : "";
return normalizeString(extension);
};
export const getAttachmentPreviewKind = (attachment = {}) => {
const extension = getAttachmentExtension(attachment);
const contentType = normalizeString(attachment.content_type ?? attachment.mime_type ?? attachment.mime).toLowerCase();
if (contentType.startsWith("image/") || imageExtensions.has(extension)) {
return "image";
}
if (contentType === "application/pdf" || extension === "pdf") {
return "pdf";
}
if (officeExtensions.has(extension)) {
return "office";
}
return "download";
};
const createSafePreviewBlob = async (response, previewKind) => {
const blob = await response.blob();
const contentType = normalizeString(blob.type).toLowerCase();
if (previewKind === "image" && contentType.startsWith("image/")) {
return blob;
}
if (previewKind === "pdf" && contentType === "application/pdf") {
return blob;
}
if (!contentType && (previewKind === "image" || previewKind === "pdf")) {
return blob;
}
throw new Error("Unsupported preview response type");
};
export const createEmbeddablePreviewUrl = async (downloadUrl, previewKind) => {
if (!downloadUrl) {
return { url: "", isObjectUrl: false };
}
if (previewKind === "image" || previewKind === "pdf") {
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error("Unable to fetch attachment preview");
}
const blob = await createSafePreviewBlob(response, previewKind);
return {
url: URL.createObjectURL(blob),
isObjectUrl: true,
};
}
if (previewKind === "office") {
return {
url: `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(downloadUrl)}`,
isObjectUrl: false,
};
}
return {
url: downloadUrl,
isObjectUrl: false,
};
};
export const releaseObjectUrl = (url) => {
if (!url || typeof URL === "undefined" || typeof URL.revokeObjectURL !== "function") {
return;
}
URL.revokeObjectURL(url);
};
+9
View File
@@ -0,0 +1,9 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
const cleanParams = (params = {}) =>
Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== null && value !== undefined && String(value) !== "")
);
export const listModuleUsageSummary = (params = {}) =>
authenticatedRequest("/modules/usage/summary", "GET", cleanParams(params));
+5 -1
View File
@@ -8,7 +8,11 @@ export const listCronRuns = ({ taskId = null, limit = 50 } = {}) =>
limit, limit,
}); });
export const listCronWorkers = () => authenticatedRequest("/superuser/cron/workers", "GET", {}); export const listCronWorkers = ({ channelId = null, includeProvider = false } = {}) =>
authenticatedRequest("/superuser/cron/workers", "GET", {
...(channelId ? { channel_id: channelId } : {}),
...(includeProvider ? { include_provider: true } : {}),
});
export const deployCronWorkers = (payload = {}) => export const deployCronWorkers = (payload = {}) =>
authenticatedRequest("/superuser/cron/workers/deploy", "POST", payload); authenticatedRequest("/superuser/cron/workers/deploy", "POST", payload);
@@ -0,0 +1,474 @@
export const TREE_NODE_TYPES = Object.freeze({
CATEGORY: "category",
COLLECTION: "collected_order_invoice",
ORDER: "order",
ORDER_ITEM: "order_item",
ATTACHMENT: "attachment",
BOOKING: "order_booking",
BOOKING_ITEM: "booking_item",
XLVASK_WASH: "xlvask_wash",
XLVASK_INFERRED_ITEM: "xlvask_inferred_item",
PAYMENT: "stripe_payment",
ECONOMIC_INVOICE: "economic_invoice",
AGREEMENT: "agreement",
});
export const TREE_CATEGORY_TYPES = Object.freeze({
COLLECTION_ORDERS: "collection_orders",
COLLECTION_AGREEMENTS: "collection_agreements",
COLLECTION_PAYMENTS: "collection_payments",
COLLECTION_ECONOMIC: "collection_economic",
ORDER_ITEMS: "order_items",
ORDER_ATTACHMENTS_CERTIFICATES: "order_attachments_certificates",
ORDER_ATTACHMENTS_IMAGES: "order_attachments_images",
ORDER_ATTACHMENTS_OTHER: "order_attachments_other",
ORDER_BOOKINGS: "order_bookings",
ORDER_XLVASK: "order_xlvask",
BOOKING_ITEMS: "booking_items",
XLVASK_ITEMS: "xlvask_items",
});
const toPositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const toFiniteNumber = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const toMaybeFiniteNumber = (value) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const normalizeString = (value) => String(value ?? "").trim();
const resolveLabel = (fallback, label) => (typeof label === "function" ? label() : (normalizeString(label) || fallback));
const TREE_AMOUNT_FIELDS = ["amount", "total_net_amount", "net_amount", "total", "price"];
const DEFAULT_LOCALE = "da-DK";
export const getTreeAmount = (object = {}) => {
const values = TREE_AMOUNT_FIELDS
.map((field) => toMaybeFiniteNumber(object?.[field]))
.filter((value) => value !== null);
return values.find((value) => value !== 0) ?? values[0] ?? 0;
};
const WASH_CERTIFICATE_TEXT_PATTERN = /vaskecertifikat|wash certificate|wash_certificate|certificate/i;
export const isWashCertificateOrderItem = (item = {}) => {
const text = [
item?.product_name,
item?.name,
item?.description,
item?.title,
item?.product?.name,
item?.product?.description,
item?.product?.title,
].map(normalizeString).join(" ");
const piktogram = normalizeString(item?.piktogram ?? item?.product?.piktogram).toLowerCase();
return piktogram === "certificate" || WASH_CERTIFICATE_TEXT_PATTERN.test(text);
};
export const hasWashCertificateOrderItem = (items = []) => Array.isArray(items) && items.some(isWashCertificateOrderItem);
export const makeNodeId = (type, id, suffix = null) => [type, id, suffix].filter((item) => item !== null && item !== undefined && item !== "").join(":");
const parseLocalDate = (value) => {
if (!value) {
return null;
}
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
}
const match = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
}
return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
};
const capitalizeFirst = (value) => normalizeString(value).replace(/^./, (letter) => letter.toUpperCase());
const formatMonthName = (date, locale = DEFAULT_LOCALE) => capitalizeFirst(new Intl.DateTimeFormat(locale || DEFAULT_LOCALE, {
month: "long",
}).format(date));
const formatDayMonth = (date, locale = DEFAULT_LOCALE) => `${date.getDate()} ${formatMonthName(date, locale)}`;
const lastDayOfMonth = (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
const isSameMonth = (left, right) => left && right && left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth();
const isWholeMonthRange = (dateFrom, dateTo) => (
dateFrom
&& dateTo
&& isSameMonth(dateFrom, dateTo)
&& dateFrom.getDate() === 1
&& dateTo.getDate() === lastDayOfMonth(dateFrom)
);
const getOrderDate = (order) => parseLocalDate(order?.date ?? order?.created_at ?? order?.closed_at ?? order?.updated_at);
export const buildRelativeCollectionLabel = ({
collectionId,
orders = [],
dateFrom = null,
dateTo = null,
collectionCount = 1,
locale = DEFAULT_LOCALE,
fallbackLabel = null,
} = {}) => {
const rangeStart = parseLocalDate(dateFrom);
const rangeEnd = parseLocalDate(dateTo);
if (Number(collectionCount) === 1 && isWholeMonthRange(rangeStart, rangeEnd)) {
const monthLabel = formatMonthName(rangeStart, locale);
return {
label: monthLabel,
kind: "month",
monthLabel,
collectionId,
};
}
const orderDates = (orders || [])
.map(getOrderDate)
.filter(Boolean)
.sort((left, right) => left.getTime() - right.getTime());
if (orderDates.length === 0) {
return fallbackLabel ? {
label: normalizeString(fallbackLabel),
kind: "fallback",
collectionId,
} : null;
}
const start = orderDates[0];
const end = orderDates[orderDates.length - 1];
const startLabel = formatDayMonth(start, locale);
const endLabel = formatDayMonth(end, locale);
const sameDay = start.getTime() === end.getTime();
return {
label: sameDay ? startLabel : `${startLabel}${endLabel}`,
kind: sameDay ? "day" : "range",
startLabel,
endLabel,
collectionId,
};
};
export const makeCategoryNode = ({
id,
label,
category,
parentType,
parentId,
count = null,
icon = "fa-folder",
checkable = false,
meta = {},
}) => ({
id,
label: resolveLabel("", label),
type: TREE_NODE_TYPES.CATEGORY,
category,
isLeaf: false,
disabled: false,
selectable: false,
checkable,
actionable: false,
icon,
meta: {
parentType,
parentId,
count,
...meta,
},
});
export const makeCollectionNode = (collectionId, orders, customer, meta = {}) => {
const totalNetAmount = orders.reduce((sum, order) => sum + getTreeAmount(order), 0);
return {
id: makeNodeId(TREE_NODE_TYPES.COLLECTION, collectionId),
label: resolveLabel(`Fakturasamling #${collectionId}`, meta.label),
type: TREE_NODE_TYPES.COLLECTION,
isLeaf: false,
selectable: true,
actionable: true,
icon: "fa-layer-group",
meta: {
collectionId,
customerNumber: toPositiveInteger(customer?.customer_number),
customerName: customer?.customer_name ?? customer?.name,
orderCount: orders.length,
totalNetAmount,
orders,
...meta,
},
};
};
export const makeOrderNode = (order, options = {}) => {
const orderId = toPositiveInteger(order?.id);
return {
id: makeNodeId(TREE_NODE_TYPES.ORDER, orderId),
label: resolveLabel(`Ordre #${orderId}`, options.label),
type: TREE_NODE_TYPES.ORDER,
isLeaf: false,
selectable: true,
actionable: true,
icon: "fa-receipt",
meta: {
order,
orderId,
collectionId: toPositiveInteger(order?.invoice_collection_id),
bookingId: toPositiveInteger(order?.booking_id),
washId: normalizeString(order?.wash_id),
createdAt: order?.created_at ?? order?.date,
totalNetAmount: getTreeAmount(order),
customerNumber: toPositiveInteger(order?.customer_id ?? order?.customer_number),
departmentId: toPositiveInteger(order?.department_id),
},
};
};
export const makeOrderItemNode = (item, options = {}) => {
const itemId = toPositiveInteger(item?.id ?? item?.order_item_id);
const productName = normalizeString(item?.product_name ?? item?.name ?? item?.product?.name)
|| resolveLabel(`Linje #${itemId}`, options.fallbackLabel);
return {
id: makeNodeId(TREE_NODE_TYPES.ORDER_ITEM, itemId),
label: productName,
type: TREE_NODE_TYPES.ORDER_ITEM,
isLeaf: true,
selectable: true,
actionable: true,
icon: "fa-list-check",
meta: {
item,
itemId,
orderId: toPositiveInteger(item?.order_id),
productId: toPositiveInteger(item?.product_id),
quantity: toFiniteNumber(item?.quantity ?? item?.amount),
price: toFiniteNumber(item?.price),
totalNetAmount: toFiniteNumber(item?.price) * toFiniteNumber(item?.quantity ?? item?.amount ?? 1),
},
};
};
export const classifyAttachment = (attachment) => {
const rawName = normalizeString(
attachment?.file_name
?? attachment?.filename
?? attachment?.name
?? attachment?.content?.other
?? attachment?.other
);
const contentType = normalizeString(attachment?.content_type ?? attachment?.mime_type ?? attachment?.mime).toLowerCase();
const lowerName = rawName.toLowerCase();
const other = normalizeString(attachment?.content?.other ?? attachment?.other).toLowerCase();
if (other.includes("wash_certificate") || lowerName.includes("vaskecertifikat") || lowerName.includes("wash certificate")) {
return "certificate";
}
if (contentType.startsWith("image/") || /\.(png|jpe?g|gif|webp|heic|bmp)$/i.test(lowerName)) {
return "image";
}
return "other";
};
export const makeAttachmentNode = (attachment, orderId, options = {}) => {
const attachmentId = toPositiveInteger(attachment?.id ?? attachment?.attachment_id);
const label = normalizeString(
attachment?.file_name
?? attachment?.filename
?? attachment?.name
?? attachment?.content?.other
) || resolveLabel(`Vedhæftning #${attachmentId}`, options.fallbackLabel);
return {
id: makeNodeId(TREE_NODE_TYPES.ATTACHMENT, attachmentId, orderId),
label,
type: TREE_NODE_TYPES.ATTACHMENT,
isLeaf: true,
selectable: true,
actionable: true,
icon: classifyAttachment(attachment) === "image" ? "fa-image" : "fa-paperclip",
meta: {
attachment,
attachmentId,
orderId: toPositiveInteger(orderId),
attachmentType: classifyAttachment(attachment),
},
};
};
export const makeEconomicInvoiceNode = ({
collectionId,
invoiceType,
economicInvoiceId,
details = null,
label = null,
} = {}) => {
const normalizedType = invoiceType === "draft" ? "draft" : "booked";
return {
id: makeNodeId(TREE_NODE_TYPES.ECONOMIC_INVOICE, collectionId, `${normalizedType}:${economicInvoiceId}`),
label: resolveLabel(
normalizedType === "draft"
? `E-conomic kladde #${economicInvoiceId}`
: `E-conomic faktura #${economicInvoiceId}`,
label
),
type: TREE_NODE_TYPES.ECONOMIC_INVOICE,
isLeaf: true,
selectable: true,
actionable: false,
icon: normalizedType === "draft" ? "fa-file-alt" : "fa-file-invoice",
meta: {
collectionId: toPositiveInteger(collectionId),
economicType: normalizedType,
economicInvoiceId: toPositiveInteger(economicInvoiceId),
details,
},
};
};
export const makeBookingNode = (booking, options = {}) => {
const bookingId = toPositiveInteger(booking?.id);
return {
id: makeNodeId(TREE_NODE_TYPES.BOOKING, bookingId),
label: resolveLabel(`Booking #${bookingId}`, options.label),
type: TREE_NODE_TYPES.BOOKING,
isLeaf: false,
selectable: true,
actionable: true,
icon: "fa-calendar-check",
meta: {
booking,
bookingId,
orderId: toPositiveInteger(booking?.order_id),
status: booking?.status,
datetime: booking?.datetime,
},
};
};
export const makeBookingItemNode = (item, bookingId, options = {}) => {
const itemId = toPositiveInteger(item?.id ?? item?.product_id) ?? `${bookingId}-${normalizeString(item?.name)}`;
return {
id: makeNodeId(TREE_NODE_TYPES.BOOKING_ITEM, itemId, bookingId),
label: normalizeString(item?.product_name ?? item?.name) || resolveLabel(`Bookinglinje #${itemId}`, options.fallbackLabel),
type: TREE_NODE_TYPES.BOOKING_ITEM,
isLeaf: true,
selectable: true,
actionable: false,
icon: "fa-list",
meta: {
item,
bookingId: toPositiveInteger(bookingId),
itemId,
quantity: toFiniteNumber(item?.quantity ?? item?.amount),
price: toFiniteNumber(item?.price),
},
};
};
export const makeXlvaskNode = (usage, order = null, options = {}) => {
const usageId = toPositiveInteger(usage?.id ?? usage?.usage_log_id) ?? normalizeString(usage?.WashId ?? order?.wash_id);
const washId = normalizeString(usage?.WashId ?? usage?.wash_id ?? order?.wash_id);
return {
id: makeNodeId(TREE_NODE_TYPES.XLVASK_WASH, usageId || washId),
label: washId
? resolveLabel(`XL Vask #${washId}`, options.label)
: resolveLabel("XL Vask", options.emptyLabel),
type: TREE_NODE_TYPES.XLVASK_WASH,
isLeaf: false,
selectable: true,
actionable: true,
icon: "fa-truck-fast",
meta: {
usage,
usageId,
washId,
orderId: toPositiveInteger(order?.id ?? usage?.linked_order_id ?? usage?.order_id),
automation: usage?.automation,
},
};
};
export const makeXlvaskInferredItemNode = (item, washId, index, options = {}) => ({
id: makeNodeId(TREE_NODE_TYPES.XLVASK_INFERRED_ITEM, washId || "unknown", index),
label: normalizeString(item?.Name ?? item?.name ?? item?.Description ?? item?.description)
|| resolveLabel(`XL Vask linje ${index + 1}`, options.fallbackLabel),
type: TREE_NODE_TYPES.XLVASK_INFERRED_ITEM,
isLeaf: true,
selectable: true,
actionable: false,
icon: "fa-list",
meta: {
item,
washId,
quantity: toFiniteNumber(item?.Quantity ?? item?.quantity ?? item?.Amount),
price: toFiniteNumber(item?.Price ?? item?.price ?? item?.UnitNetPrice),
},
});
export const buildCollectionRootNodes = (customer, transactions = [], excludedOrderIds = [], labels = {}) => {
const excluded = new Set((excludedOrderIds || []).map((id) => Number(id)));
const groups = new Map();
const ungrouped = [];
transactions
.filter((transaction) => !excluded.has(Number(transaction?.id)))
.forEach((transaction) => {
const collectionId = toPositiveInteger(transaction?.invoice_collection_id);
if (!collectionId) {
ungrouped.push(transaction);
return;
}
if (!groups.has(collectionId)) {
groups.set(collectionId, []);
}
groups.get(collectionId).push(transaction);
});
const collectionCount = groups.size;
const nodes = Array.from(groups.entries())
.sort(([left], [right]) => left - right)
.map(([collectionId, orders]) => {
const fallbackLabel = labels.collection?.(collectionId);
const relativeLabel = buildRelativeCollectionLabel({
collectionId,
orders,
dateFrom: labels.dateFrom,
dateTo: labels.dateTo,
collectionCount,
locale: labels.locale,
fallbackLabel,
});
return makeCollectionNode(collectionId, orders, customer, {
label: relativeLabel?.label || fallbackLabel,
relativeLabel,
});
});
if (ungrouped.length > 0) {
nodes.push({
id: makeNodeId(TREE_NODE_TYPES.CATEGORY, "orders_without_collection", toPositiveInteger(customer?.customer_number)),
label: resolveLabel("Orders uden fakturasamling", labels.ordersWithoutCollection),
type: TREE_NODE_TYPES.CATEGORY,
category: TREE_CATEGORY_TYPES.COLLECTION_ORDERS,
isLeaf: false,
disabled: false,
selectable: false,
checkable: true,
actionable: false,
icon: "fa-inbox",
meta: {
customerNumber: toPositiveInteger(customer?.customer_number),
orders: ungrouped,
count: ungrouped.length,
},
});
}
return nodes;
};
@@ -21,6 +21,7 @@ import InvoicingBillingPeriodStatistics from "@/views/dashboards/superUserDashbo
import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodFilters.vue"; import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodFilters.vue";
import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue"; import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue";
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue"; import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
import InvoicingPeriodObjectTree from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue"; import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import { import {
buildMultiMonthInvoiceContext, buildMultiMonthInvoiceContext,
@@ -1254,26 +1255,17 @@ const getTransactionQueryParameters = () => {
/> />
</template> </template>
<template v-else> <template v-else>
<InvoiceOrdersPagination <InvoicingPeriodObjectTree
:set-customer-filter="customer.customer_number" :customer="customer"
:hide-search="true" :transactions="getTransactionsInView(customer)"
:hide-filter="true" :excluded-order-ids="getExcludedTransactionIds(customer)"
:invoice-view="true"
:apply-default-filters="false"
:group-invoice-collection="true"
:limit-results="false"
:hide-pagination="true"
:auto-expand-all="view.variables.currentView.value === 'invoice_per_order'" :auto-expand-all="view.variables.currentView.value === 'invoice_per_order'"
:query-parameters="getTransactionQueryParameters()"
:dates="{ :dates="{
dateFrom: dates.computed.formattedStartDate.value, dateFrom: dates.computed.formattedStartDate.value,
dateTo: dates.computed.formattedEndDate.value, dateTo: dates.computed.formattedEndDate.value,
}" }"
:show-only-with-ids="getTransactionIds(customer)"
:excluded-order-ids="getExcludedTransactionIds(customer)"
:invoice-period-flags="getCustomerScopedFlags(customer)" :invoice-period-flags="getCustomerScopedFlags(customer)"
@flagStatusChanged="(flag) => onFlagStatusChanged(customer, flag)" @refresh="reloadPeriodPage"
@flagCreated="(flag) => onFlagCreated(customer, flag)"
/> />
</template> </template>
</div> </div>
@@ -13,6 +13,7 @@ import ConfigurationSelect from "@/components/displays/superuser/configuration/C
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue"; import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue"; import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue"; import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
import ModuleUsageMeter from "@/components/displays/superuser/configuration/ModuleUsageMeter.vue";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
@@ -170,6 +171,7 @@ const showConversionRates = () => {
:value="parseInt(getModuleConfigValue('daily_limit'))" :value="parseInt(getModuleConfigValue('daily_limit'))"
:on-save="SessionUser.superUser.modules.fxratesapi.config.keys.daily_limit.set" :on-save="SessionUser.superUser.modules.fxratesapi.config.keys.daily_limit.set"
/> />
<ModuleUsageMeter module-key="fxratesapi" metric-key="rate_fetch_calls" />
</ConfigurationCategory> </ConfigurationCategory>
<div class="buttons"> <div class="buttons">
<button class="button is-dark" @click="showGetConversionRate"> <button class="button is-dark" @click="showGetConversionRate">
@@ -13,6 +13,7 @@ import ConfigurationSelect from "@/components/displays/superuser/configuration/C
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue"; import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue"; import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue"; import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
import ModuleUsageMeter from "@/components/displays/superuser/configuration/ModuleUsageMeter.vue";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
@@ -162,6 +163,7 @@ const showGetLicensePlate = () => {
:value="parseInt(getModuleConfigValue('daily_limit'))" :value="parseInt(getModuleConfigValue('daily_limit'))"
:on-save="SessionUser.superUser.modules.motorapi.config.keys.daily_limit.set" :on-save="SessionUser.superUser.modules.motorapi.config.keys.daily_limit.set"
/> />
<ModuleUsageMeter module-key="motorapi" metric-key="lookup_calls" />
</ConfigurationCategory> </ConfigurationCategory>
<button class="button is-dark" @click="showGetLicensePlate"> <button class="button is-dark" @click="showGetLicensePlate">
<span class="icon"> <span class="icon">
@@ -13,6 +13,7 @@ import ConfigurationSelect from "@/components/displays/superuser/configuration/C
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue"; import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue"; import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue"; import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
import ModuleUsageMeter from "@/components/displays/superuser/configuration/ModuleUsageMeter.vue";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
@@ -194,6 +195,7 @@ const showSearchCompany = () => {
:value="parseInt(getModuleConfigValue('monthly_limit'))" :value="parseInt(getModuleConfigValue('monthly_limit'))"
:on-save="SessionUser.superUser.modules.virkdata.config.keys.monthly_limit.set" :on-save="SessionUser.superUser.modules.virkdata.config.keys.monthly_limit.set"
/> />
<ModuleUsageMeter module-key="virkdata" metric-key="company_search_calls" />
</ConfigurationCategory> </ConfigurationCategory>
<div class="buttons"> <div class="buttons">
<button class="button is-dark" @click="showSearchCompany"> <button class="button is-dark" @click="showSearchCompany">
@@ -17,13 +17,19 @@ import {
const { t, locale } = useI18n(); const { t, locale } = useI18n();
const RUN_POLL_INTERVAL_MS = 2500; const RUN_POLL_INTERVAL_MS = 2500;
const RUN_POLL_ATTEMPTS = 6; const RUN_POLL_ATTEMPTS = 6;
const WORKER_POLL_INTERVAL_MS = 5000;
const WORKER_POLL_ATTEMPTS = 24;
const IN_PROGRESS_STATUSES = new Set(["queued", "running"]); const IN_PROGRESS_STATUSES = new Set(["queued", "running"]);
const WORKER_PENDING_STATES = new Set(["deploying", "waiting_for_heartbeat"]);
const tasks = ref([]); const tasks = ref([]);
const runs = ref([]); const runs = ref([]);
const workers = ref([]); const workers = ref([]);
const workerStatus = ref(null);
const workerChannels = ref([]);
const workerSummary = ref({ total: 0, running: 0, stale: 0 }); const workerSummary = ref({ total: 0, running: 0, stale: 0 });
const workerDeployment = ref(null); const workerDeployment = ref(null);
const selectedWorkerChannelId = ref(null);
const summary = ref({ total: 0, enabled: 0, due: 0 }); const summary = ref({ total: 0, enabled: 0, due: 0 });
const intervalDrafts = ref({}); const intervalDrafts = ref({});
const loading = ref(false); const loading = ref(false);
@@ -55,6 +61,9 @@ const nextTask = computed(() => {
}); });
const workerTargetLabel = computed( const workerTargetLabel = computed(
() => () =>
workerStatus.value?.cron_target?.coolify_service_uuid ||
workerStatus.value?.cron_target?.coolify_resource_uuid ||
workerStatus.value?.cron_target?.id ||
workerDeployment.value?.target?.coolify_service_uuid || workerDeployment.value?.target?.coolify_service_uuid ||
workerDeployment.value?.target?.coolify_resource_uuid || workerDeployment.value?.target?.coolify_resource_uuid ||
workerDeployment.value?.target?.id || workerDeployment.value?.target?.id ||
@@ -62,12 +71,48 @@ const workerTargetLabel = computed(
); );
const hasWorkerDeploymentTarget = computed( const hasWorkerDeploymentTarget = computed(
() => () =>
Boolean(workerStatus.value?.cron_target?.id) ||
Boolean(workerDeployment.value?.target?.id) || Boolean(workerDeployment.value?.target?.id) ||
Boolean(workerStatus.value?.cron_target?.coolify_service_uuid) ||
Boolean(workerDeployment.value?.target?.coolify_service_uuid) || Boolean(workerDeployment.value?.target?.coolify_service_uuid) ||
Boolean(workerDeployment.value?.target?.coolify_resource_uuid) Boolean(workerDeployment.value?.target?.coolify_resource_uuid)
); );
const workerDeploymentActionLabel = computed(() => const workerDeploymentAction = computed(
hasWorkerDeploymentTarget.value ? t("cron.workers.update_deployment") : t("cron.workers.deploy_to_coolify") () =>
workerStatus.value?.deployment?.action ||
workerDeployment.value?.action ||
(hasWorkerDeploymentTarget.value ? "update" : "create")
);
const workerDeploymentCanDeploy = computed(() => {
const canDeploy = workerStatus.value?.deployment?.can_deploy ?? workerDeployment.value?.can_deploy;
return canDeploy === undefined || canDeploy === null ? true : Boolean(canDeploy);
});
const workerDeploymentActionLabel = computed(() => {
if (workerDeploymentAction.value === "repair") {
return t("cron.workers.repair_deployment");
}
if (workerDeploymentAction.value === "update") {
return t("cron.workers.update_deployment");
}
return t("cron.workers.deploy_to_coolify");
});
const workerState = computed(() => workerStatus.value?.state || workerDeployment.value?.state || "unknown");
const workerIssues = computed(() => (Array.isArray(workerStatus.value?.issues) ? workerStatus.value.issues : []));
const workerLatestDeployment = computed(() => workerStatus.value?.latest_deployment || workerDeployment.value?.latest_deployment || null);
const workerRecentDeployments = computed(() =>
Array.isArray(workerStatus.value?.recent_deployments) ? workerStatus.value.recent_deployments : []
);
const selectedWorkerChannel = computed(
() =>
workerChannels.value.find((channel) => Number(channel.id) === Number(selectedWorkerChannelId.value)) ||
workerStatus.value?.channel ||
null
);
const workerApiTargetLabel = computed(
() =>
workerStatus.value?.api_target?.id ||
workerDeployment.value?.api_target?.id ||
t("cron.empty_value")
); );
const responseData = (response, fallback) => response?.data?.data ?? fallback; const responseData = (response, fallback) => response?.data?.data ?? fallback;
@@ -107,10 +152,21 @@ async function loadRuns() {
async function loadWorkers() { async function loadWorkers() {
workersLoading.value = true; workersLoading.value = true;
try { try {
const data = responseData(await listCronWorkers(), {}); const data = responseData(
await listCronWorkers({
channelId: selectedWorkerChannelId.value,
includeProvider: true,
}),
{}
);
workers.value = Array.isArray(data.workers) ? data.workers : []; workers.value = Array.isArray(data.workers) ? data.workers : [];
workerSummary.value = data.summary || { total: workers.value.length, running: 0, stale: 0 }; workerSummary.value = data.summary || { total: workers.value.length, running: 0, stale: 0 };
workerDeployment.value = data.deployment || null; workerDeployment.value = data.deployment || null;
workerStatus.value = data || null;
workerChannels.value = Array.isArray(data.channels) ? data.channels : workerChannels.value;
if (!selectedWorkerChannelId.value && data.channel?.id) {
selectedWorkerChannelId.value = data.channel.id;
}
} finally { } finally {
workersLoading.value = false; workersLoading.value = false;
} }
@@ -153,11 +209,22 @@ async function deployWorkers() {
queuedMessage.value = ""; queuedMessage.value = "";
const updatingDeployment = hasWorkerDeploymentTarget.value; const updatingDeployment = hasWorkerDeploymentTarget.value;
try { try {
await deployCronWorkers({}); const channelId = selectedWorkerChannelId.value || workerStatus.value?.channel?.id || null;
const data = responseData(
await deployCronWorkers({
...(channelId ? { channel_id: channelId } : {}),
}),
{}
);
if (data.worker_status) {
applyWorkerStatus(data.worker_status);
}
await loadWorkers(); await loadWorkers();
const deploymentId = data.deployment?.id || data.applied?.[0]?.deployment_id || null;
queuedMessage.value = updatingDeployment queuedMessage.value = updatingDeployment
? t("cron.messages.worker_update_queued") ? t("cron.messages.worker_update_queued", { id: deploymentId || t("cron.empty_value") })
: t("cron.messages.worker_deploy_queued"); : t("cron.messages.worker_deploy_queued", { id: deploymentId || t("cron.empty_value") });
void pollWorkerDeployment();
} catch (error) { } catch (error) {
errorMessage.value = parseError(error); errorMessage.value = parseError(error);
} finally { } finally {
@@ -165,12 +232,37 @@ async function deployWorkers() {
} }
} }
function waitForPollDelay() { function applyWorkerStatus(data) {
workers.value = Array.isArray(data.workers) ? data.workers : [];
workerSummary.value = data.summary || { total: workers.value.length, running: 0, stale: 0 };
workerDeployment.value = data.deployment || null;
workerStatus.value = data || null;
workerChannels.value = Array.isArray(data.channels) ? data.channels : workerChannels.value;
if (!selectedWorkerChannelId.value && data.channel?.id) {
selectedWorkerChannelId.value = data.channel.id;
}
}
async function pollWorkerDeployment() {
try {
for (let attempt = 0; attempt < WORKER_POLL_ATTEMPTS; attempt += 1) {
await waitForPollDelay(WORKER_POLL_INTERVAL_MS);
await loadWorkers();
if (!WORKER_PENDING_STATES.has(workerState.value)) {
return;
}
}
} catch (error) {
errorMessage.value = parseError(error);
}
}
function waitForPollDelay(delay = RUN_POLL_INTERVAL_MS) {
return new Promise((resolve) => { return new Promise((resolve) => {
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
pollTimeouts.delete(timeout); pollTimeouts.delete(timeout);
resolve(); resolve();
}, RUN_POLL_INTERVAL_MS); }, delay);
pollTimeouts.add(timeout); pollTimeouts.add(timeout);
}); });
} }
@@ -286,9 +378,24 @@ function scheduleLabel(task) {
} }
function statusLabel(status) { function statusLabel(status) {
if (status === "deployed") {
return t("cron.status.deployed");
}
if (status === "deploying") {
return t("cron.status.deploying");
}
if (status === "failed") { if (status === "failed") {
return t("cron.status.failed"); return t("cron.status.failed");
} }
if (status === "healthy") {
return t("cron.status.healthy");
}
if (status === "degraded") {
return t("cron.status.degraded");
}
if (status === "needs_deploy") {
return t("cron.status.needs_deploy");
}
if (status === "queued") { if (status === "queued") {
return t("cron.status.queued"); return t("cron.status.queued");
} }
@@ -304,25 +411,58 @@ function statusLabel(status) {
if (status === "timed_out") { if (status === "timed_out") {
return t("cron.status.timed_out"); return t("cron.status.timed_out");
} }
if (status === "waiting_for_heartbeat") {
return t("cron.status.waiting_for_heartbeat");
}
return status || t("cron.status.unknown"); return status || t("cron.status.unknown");
} }
function statusClass(status) { function statusClass(status) {
if (status === "succeeded") { if (status === "succeeded" || status === "healthy" || status === "deployed") {
return "is-success"; return "is-success";
} }
if (status === "failed" || status === "timed_out") { if (status === "failed" || status === "timed_out") {
return "is-danger"; return "is-danger";
} }
if (status === "running") { if (status === "running" || status === "deploying" || status === "waiting_for_heartbeat") {
return "is-info"; return "is-info";
} }
if (status === "queued") { if (status === "queued" || status === "degraded" || status === "needs_deploy") {
return "is-warning"; return "is-warning";
} }
return "is-light"; return "is-light";
} }
function issueClass(issue) {
if (issue?.severity === "danger") {
return "is-danger";
}
if (issue?.severity === "warning") {
return "is-warning";
}
return "is-info";
}
function deploymentLabel(deployment) {
if (!deployment) {
return t("cron.empty_value");
}
const id = deployment.provider_operation_id || deployment.id || t("cron.empty_value");
return t("cron.workers.deployment_label", {
id,
status: statusLabel(deployment.status),
});
}
async function changeWorkerChannel() {
errorMessage.value = "";
try {
await loadWorkers();
} catch (error) {
errorMessage.value = parseError(error);
}
}
function runStartedLabel(run) { function runStartedLabel(run) {
return formatDate(run.started_at || run.scheduled_for || run.created_at); return formatDate(run.started_at || run.scheduled_for || run.created_at);
} }
@@ -474,11 +614,28 @@ onBeforeUnmount(() => {
<section class="cron-worker-panel" data-testid="cron-worker-panel"> <section class="cron-worker-panel" data-testid="cron-worker-panel">
<div class="cron-section-heading"> <div class="cron-section-heading">
<div>
<h2>{{ t("cron.workers.title") }}</h2> <h2>{{ t("cron.workers.title") }}</h2>
<small data-testid="cron-worker-channel">
{{ selectedWorkerChannel?.slug || t("cron.empty_value") }}
</small>
</div>
<div class="cron-worker-controls">
<select
v-if="workerChannels.length > 1"
v-model.number="selectedWorkerChannelId"
class="select is-small"
data-testid="cron-worker-channel-select"
@change="changeWorkerChannel"
>
<option v-for="channel in workerChannels" :key="channel.id" :value="channel.id">
{{ channel.slug }}
</option>
</select>
<button <button
class="button is-small is-light" class="button is-small is-light"
type="button" type="button"
:disabled="!canDeployWorkers || deployingWorker" :disabled="!canDeployWorkers || deployingWorker || !workerDeploymentCanDeploy"
:class="{ 'is-loading': deployingWorker }" :class="{ 'is-loading': deployingWorker }"
@click="deployWorkers" @click="deployWorkers"
data-testid="cron-worker-deploy" data-testid="cron-worker-deploy"
@@ -486,6 +643,17 @@ onBeforeUnmount(() => {
{{ workerDeploymentActionLabel }} {{ workerDeploymentActionLabel }}
</button> </button>
</div> </div>
</div>
<div v-if="workerIssues.length > 0" class="cron-worker-issues" data-testid="cron-worker-issues">
<span
v-for="issue in workerIssues"
:key="issue.code || issue.message"
class="tag"
:class="issueClass(issue)"
>
{{ issue.message }}
</span>
</div>
<div class="cron-stats" aria-live="polite"> <div class="cron-stats" aria-live="polite">
<div class="cron-stat" data-testid="cron-worker-total"> <div class="cron-stat" data-testid="cron-worker-total">
<span>{{ t("cron.workers.total") }}</span> <span>{{ t("cron.workers.total") }}</span>
@@ -499,10 +667,44 @@ onBeforeUnmount(() => {
<span>{{ t("cron.workers.stale") }}</span> <span>{{ t("cron.workers.stale") }}</span>
<strong>{{ workerSummary.stale ?? 0 }}</strong> <strong>{{ workerSummary.stale ?? 0 }}</strong>
</div> </div>
<div class="cron-stat" data-testid="cron-worker-state">
<span>{{ t("cron.workers.state") }}</span>
<strong>
<span class="tag" :class="statusClass(workerState)">
{{ statusLabel(workerState) }}
</span>
</strong>
</div>
<div class="cron-stat" data-testid="cron-worker-target"> <div class="cron-stat" data-testid="cron-worker-target">
<span>{{ t("cron.workers.target") }}</span> <span>{{ t("cron.workers.target") }}</span>
<strong>{{ workerTargetLabel }}</strong> <strong>{{ workerTargetLabel }}</strong>
</div> </div>
<div class="cron-stat" data-testid="cron-worker-api-target">
<span>{{ t("cron.workers.api_target") }}</span>
<strong>{{ workerApiTargetLabel }}</strong>
</div>
<div class="cron-stat" data-testid="cron-worker-latest-deployment">
<span>{{ t("cron.workers.latest_deployment") }}</span>
<strong>{{ deploymentLabel(workerLatestDeployment) }}</strong>
</div>
</div>
<div v-if="workerRecentDeployments.length > 0" class="cron-worker-deployments">
<table class="table is-fullwidth is-striped is-narrow" data-testid="cron-worker-deployments">
<thead>
<tr>
<th>{{ t("cron.workers.deployment") }}</th>
<th>{{ t("cron.history.status") }}</th>
<th>{{ t("cron.history.started") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="deployment in workerRecentDeployments" :key="deployment.id">
<td>{{ deployment.provider_operation_id || deployment.id }}</td>
<td><span class="tag" :class="statusClass(deployment.status)">{{ statusLabel(deployment.status) }}</span></td>
<td>{{ formatDate(deployment.started_at || deployment.created_at) }}</td>
</tr>
</tbody>
</table>
</div> </div>
<div class="table-container cron-table-container"> <div class="table-container cron-table-container">
<table class="table is-fullwidth is-striped"> <table class="table is-fullwidth is-striped">
@@ -520,7 +722,13 @@ onBeforeUnmount(() => {
<td colspan="5">{{ t("cron.workers.loading") }}</td> <td colspan="5">{{ t("cron.workers.loading") }}</td>
</tr> </tr>
<tr v-else-if="workers.length === 0"> <tr v-else-if="workers.length === 0">
<td colspan="5">{{ t("cron.workers.empty") }}</td> <td colspan="5">
{{
hasWorkerDeploymentTarget
? t("cron.workers.empty_with_target")
: t("cron.workers.empty")
}}
</td>
</tr> </tr>
<tr v-for="worker in workers" :key="worker.worker_id"> <tr v-for="worker in workers" :key="worker.worker_id">
<td> <td>
@@ -629,6 +837,33 @@ onBeforeUnmount(() => {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.cron-section-heading small {
color: #667085;
display: block;
font-size: 0.8rem;
margin-top: 0.2rem;
}
.cron-worker-controls,
.cron-worker-issues {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.cron-worker-controls select {
min-width: 8rem;
}
.cron-worker-issues {
margin-bottom: 0.75rem;
}
.cron-worker-deployments {
margin: 0.75rem 0;
}
.cron-table-container { .cron-table-container {
border: 1px solid #d7dde5; border: 1px solid #d7dde5;
border-radius: 6px; border-radius: 6px;
+185
View File
@@ -153,6 +153,61 @@ function createPeriodPayload() {
}; };
} }
function createObjectTreePeriodPayload() {
const payload = createPeriodPayload();
const order = {
id: 9001,
customer_id: 4101,
customer_number: 4101,
customer_name: "Object Tree Logistics",
cashier_id: 7,
cashier_name: "Copenhagen",
department_id: 1,
invoice_collection_id: 3001,
invoice_collection: {
id: 3001,
name: "",
notes: "",
po_number: "",
external_id: "",
closed_at: "",
},
date: "2026-07-14T10:00:00.000Z",
created_at: "2026-07-14 10:00:00",
amount: 360,
total_net_amount: 360,
reference: "FORVOGN-REF-9001",
notes: "",
po: "",
reg_1: "OT4101",
reg_2: "",
reg_3: "",
include_in_invoice: null,
attachments: [],
booked: false,
excluded: false,
};
payload.types.all = [
{
id: 91,
customer_number: 4101,
customer_name: "Object Tree Logistics",
requires_action: true,
transactions: [order],
queue: {
has_active_job: false,
statuses: [],
invoice_collection_ids: [],
is_action_blocked: false,
},
meta: {},
},
];
return payload;
}
function createFlaggedPeriodPayload({ function createFlaggedPeriodPayload({
resolvedManualFlagIds = [], resolvedManualFlagIds = [],
resolvedAutomaticFingerprints = [], resolvedAutomaticFingerprints = [],
@@ -1056,6 +1111,64 @@ async function routeFlaggedPeriodOrderItems(page) {
}); });
} }
async function routeObjectTreeOrderEndpoints(page) {
const order = createObjectTreePeriodPayload().types.all[0].transactions[0];
await page.route("**/order**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/order")) {
await route.fallback();
return;
}
await route.fulfill(
json({
success: true,
data: order,
})
);
});
await page.route("**/order/items**", async (route) => {
if (route.request().method() !== "GET" || !matchesApiPath(route.request().url(), "/order/items")) {
await route.fallback();
return;
}
await route.fulfill(
json({
data: [
{
id: 7701,
order_id: 9001,
product_id: 101,
product_name: "Forvogn med ekstra langt produktnavn til visuel afkortning",
reference: "",
notes: "",
price: 240,
quantity: 1,
},
{
id: 7702,
order_id: 9001,
product_id: 102,
product_name: "Bagvogn",
reference: "TRAILER",
notes: "",
price: 120,
quantity: 1,
},
],
})
);
});
}
async function expandTreeNode(page, key) {
const node = page.locator(`[data-node-key="${key}"]`).first();
await expect(node).toBeVisible();
await node.locator(".b-tree-node-toggle").click();
}
async function getBoundingBox(locator, label) { async function getBoundingBox(locator, label) {
await expect(locator).toBeVisible(); await expect(locator).toBeVisible();
const box = await locator.boundingBox(); const box = await locator.boundingBox();
@@ -1562,6 +1675,78 @@ test.describe("Invoicing period tab", () => {
await expect(secondWheel).toHaveAttribute("aria-expanded", "true"); await expect(secondWheel).toHaveAttribute("aria-expanded", "true");
}); });
test("@smoke period object tree aligns row actions and visible fields", async ({ page }) => {
await page.setViewportSize({ width: 1900, height: 900 });
await openPeriodView(page, { payloadFactory: createObjectTreePeriodPayload });
await routeObjectTreeOrderEndpoints(page);
await page.getByTestId("invoicing-period-view-selector-all").click();
const customerRow = page.getByTestId("invoicing-period-customer-4101");
await expect(customerRow).toBeVisible();
await customerRow.getByText("Object Tree Logistics").click();
await expect(page.getByTestId("invoice-period-object-tree")).toBeVisible();
await expandTreeNode(page, "collected_order_invoice:3001");
await expandTreeNode(page, "category:3001:collection_orders");
await expandTreeNode(page, "order:9001");
const wheelLocators = [
page.getByTestId("invoice-period-tree-action-wheel-collected_order_invoice:3001"),
page.getByTestId("invoice-period-tree-action-wheel-order:9001"),
page.getByTestId("invoice-period-tree-action-wheel-order_item:7701"),
];
const wheelBoxes = [];
for (const [index, locator] of wheelLocators.entries()) {
wheelBoxes.push(await getBoundingBox(locator, `object tree action wheel ${index}`));
}
const wheelRightEdges = wheelBoxes.map((box) => Math.round(box.x + box.width));
expect(Math.max(...wheelRightEdges) - Math.min(...wheelRightEdges)).toBeLessThanOrEqual(6);
await expect(page.getByTestId("invoice-period-tree-field-order:9001-notes")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-reg_2")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-reg_3")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-value-order:9001-notes")).toContainText(/Tom|Empty/i);
await expect(page.locator("[data-testid^='invoice-period-tree-field-empty-toggle-']")).toHaveCount(0);
const fieldBoxes = await Promise.all(
["reference", "notes", "po", "reg_1", "reg_2", "reg_3", "include_in_invoice", "total_net_amount"].map((field) =>
getBoundingBox(page.getByTestId(`invoice-period-tree-field-order:9001-${field}`), field)
)
);
const fieldTops = fieldBoxes.map((box) => Math.round(box.y));
expect(Math.max(...fieldTops) - Math.min(...fieldTops)).toBeLessThanOrEqual(2);
const orderWheelRoot = page.getByTestId("invoice-period-tree-action-wheel-order:9001");
await orderWheelRoot.locator(".action-settings-wheel-trigger").click();
await expect(orderWheelRoot.getByTestId("action-settings-wheel-section-order")).toBeVisible();
const orderOpenActionText = /Åbn ordre i ny fane|view order in new tab/i;
const isFlatWheelLayout = (await orderWheelRoot.getByTestId("action-settings-wheel-flyout").count()) === 0;
if (isFlatWheelLayout) {
await expect(orderWheelRoot.locator("button.dropdown-item-action").filter({ hasText: orderOpenActionText })).toBeVisible();
} else {
await orderWheelRoot.getByTestId("action-settings-wheel-section-order").hover();
await expect(
page.getByTestId("action-settings-wheel-submenu-order").locator("button.dropdown-item-action").filter({ hasText: orderOpenActionText })
).toBeVisible();
}
await page.keyboard.press("Escape");
const objectLineMetrics = await page.getByTestId("invoice-period-tree-node-order:9001").evaluate((node) => {
const main = node.querySelector(".invoice-period-tree-node__main");
const subtitle = node.querySelector(".invoice-period-tree-node__subtitle");
const mainStyle = main ? window.getComputedStyle(main) : null;
const subtitleStyle = subtitle ? window.getComputedStyle(subtitle) : null;
return {
mainHeight: main?.getBoundingClientRect().height || 0,
mainLineHeight: Number.parseFloat(mainStyle?.lineHeight || "0"),
subtitleHeight: subtitle?.getBoundingClientRect().height || 0,
subtitleLineHeight: Number.parseFloat(subtitleStyle?.lineHeight || "0"),
};
});
expect(objectLineMetrics.mainHeight).toBeLessThanOrEqual(objectLineMetrics.mainLineHeight * 1.6);
expect(objectLineMetrics.subtitleHeight).toBeLessThanOrEqual(objectLineMetrics.subtitleLineHeight * 1.6);
});
test("@smoke period expanded order filters are hidden until requested", async ({ page }) => { test("@smoke period expanded order filters are hidden until requested", async ({ page }) => {
await openPeriodView(page); await openPeriodView(page);
+484 -12
View File
@@ -86,7 +86,7 @@ test.describe("Superuser cron operations", () => {
return; return;
} }
await route.fulfill({ await route.fulfill({
status: 200, status: 202,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify(cronListPayload(currentTasks)), body: JSON.stringify(cronListPayload(currentTasks)),
}); });
@@ -94,7 +94,7 @@ test.describe("Superuser cron operations", () => {
await page.route(apiPathPattern("/superuser/cron/runs"), async (route) => { await page.route(apiPathPattern("/superuser/cron/runs"), async (route) => {
await route.fulfill({ await route.fulfill({
status: 200, status: 202,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
success: true, success: true,
@@ -118,6 +118,31 @@ test.describe("Superuser cron operations", () => {
body: JSON.stringify({ body: JSON.stringify({
success: true, success: true,
data: { data: {
state: "healthy",
desired_workers: 1,
channel: {
id: 1,
slug: "stable",
name: "Stable",
},
channels: [
{
id: 1,
slug: "stable",
name: "Stable",
default_channel: true,
},
],
api_target: {
id: 17,
app: "api",
},
cron_target: {
id: 71,
app: "cron",
coolify_service_uuid: "cron-worker-uuid",
auto_deploy: false,
},
workers: [ workers: [
{ {
worker_id: "release-stable-cron-worker", worker_id: "release-stable-cron-worker",
@@ -129,26 +154,61 @@ test.describe("Superuser cron operations", () => {
last_heartbeat_at: "2026-07-09 12:01:30", last_heartbeat_at: "2026-07-09 12:01:30",
last_run_count: 1, last_run_count: 1,
commit_sha: "abcdef1234567890", commit_sha: "abcdef1234567890",
release_channel_id: 1,
release_target_id: 71,
coolify_resource_uuid: "cron-worker-uuid",
}, },
], ],
summary: { summary: {
total: 1, total: 1,
running: 1, running: 1,
stale: 0, stale: 0,
desired: 1,
state: "healthy",
}, },
latest_deployment: {
id: 88,
app: "cron",
status: "deployed",
provider_operation_id: "deploy-88",
started_at: "2026-07-09 12:01:00",
},
recent_deployments: [
{
id: 88,
app: "cron",
status: "deployed",
provider_operation_id: "deploy-88",
started_at: "2026-07-09 12:01:00",
},
],
issues: [],
deployment: { deployment: {
ok: true, ok: true,
state: "healthy",
channel: { channel: {
id: 1, id: 1,
slug: "stable", slug: "stable",
name: "Stable", name: "Stable",
}, },
api_target: {
id: 17,
app: "api",
},
target: { target: {
id: 71, id: 71,
app: "cron", app: "cron",
coolify_service_uuid: "cron-worker-uuid", coolify_service_uuid: "cron-worker-uuid",
auto_deploy: false, auto_deploy: false,
}, },
latest_deployment: {
id: 88,
app: "cron",
status: "deployed",
provider_operation_id: "deploy-88",
started_at: "2026-07-09 12:01:00",
},
issues: [],
}, },
}, },
meta: {}, meta: {},
@@ -166,12 +226,63 @@ test.describe("Superuser cron operations", () => {
success: true, success: true,
data: { data: {
ok: true, ok: true,
deployment: {
id: 89,
app: "cron",
status: "deployed",
provider_operation_id: "deploy-89",
},
target: { target: {
id: 71, id: 71,
app: "cron", app: "cron",
coolify_service_uuid: "cron-worker-uuid", coolify_service_uuid: "cron-worker-uuid",
auto_deploy: false, auto_deploy: false,
}, },
worker_status: {
state: "waiting_for_heartbeat",
channel: {
id: 1,
slug: "stable",
name: "Stable",
},
channels: [
{
id: 1,
slug: "stable",
name: "Stable",
default_channel: true,
},
],
workers: [],
summary: {
total: 0,
running: 0,
stale: 0,
desired: 1,
state: "waiting_for_heartbeat",
},
deployment: {
state: "waiting_for_heartbeat",
target: {
id: 71,
app: "cron",
coolify_service_uuid: "cron-worker-uuid",
auto_deploy: false,
},
},
latest_deployment: {
id: 89,
status: "deployed",
provider_operation_id: "deploy-89",
},
issues: [
{
code: "waiting_for_first_heartbeat",
severity: "info",
message: "Coolify accepted the deployment; waiting for the worker to write its first heartbeat.",
},
],
},
}, },
meta: {}, meta: {},
includes: {}, includes: {},
@@ -253,7 +364,7 @@ test.describe("Superuser cron operations", () => {
), ),
page.getByTestId("cron-worker-deploy").click(), page.getByTestId("cron-worker-deploy").click(),
]); ]);
expect(deployPayloads).toEqual([{}]); expect(deployPayloads).toEqual([{ channel_id: 1 }]);
await expect(page.getByTestId("cron-run-queued")).toContainText("deployment update"); await expect(page.getByTestId("cron-run-queued")).toContainText("deployment update");
} }
@@ -320,20 +431,89 @@ test.describe("Superuser cron operations", () => {
body: JSON.stringify({ body: JSON.stringify({
success: true, success: true,
data: { data: {
workers: [], state: deploymentTarget ? "waiting_for_heartbeat" : "needs_deploy",
summary: { desired_workers: 1,
total: 0,
running: 0,
stale: 0,
},
deployment: {
ok: true,
channel: { channel: {
id: 1, id: 1,
slug: "stable", slug: "stable",
name: "Stable", name: "Stable",
}, },
channels: [
{
id: 1,
slug: "stable",
name: "Stable",
default_channel: true,
},
],
api_target: {
id: 17,
app: "api",
},
cron_target: deploymentTarget,
workers: [],
summary: {
total: 0,
running: 0,
stale: 0,
desired: 1,
state: deploymentTarget ? "waiting_for_heartbeat" : "needs_deploy",
},
latest_deployment: deploymentTarget
? {
id: 72,
app: "cron",
status: "deployed",
provider_operation_id: "deploy-72",
started_at: "2026-07-09 12:01:00",
}
: null,
recent_deployments: deploymentTarget
? [
{
id: 72,
app: "cron",
status: "deployed",
provider_operation_id: "deploy-72",
started_at: "2026-07-09 12:01:00",
},
]
: [],
issues: [
deploymentTarget
? {
code: "waiting_for_first_heartbeat",
severity: "info",
message: "Coolify accepted the deployment; waiting for the worker to write its first heartbeat.",
}
: {
code: "missing_cron_target",
severity: "warning",
message: "No cron worker Coolify target exists for this release channel.",
},
],
deployment: {
ok: true,
state: deploymentTarget ? "waiting_for_heartbeat" : "needs_deploy",
channel: {
id: 1,
slug: "stable",
name: "Stable",
},
api_target: {
id: 17,
app: "api",
},
target: deploymentTarget, target: deploymentTarget,
latest_deployment: deploymentTarget
? {
id: 72,
app: "cron",
status: "deployed",
provider_operation_id: "deploy-72",
started_at: "2026-07-09 12:01:00",
}
: null,
}, },
}, },
meta: {}, meta: {},
@@ -357,7 +537,54 @@ test.describe("Superuser cron operations", () => {
success: true, success: true,
data: { data: {
ok: true, ok: true,
deployment: {
id: 72,
app: "cron",
status: "deployed",
provider_operation_id: "deploy-72",
},
target: deploymentTarget, target: deploymentTarget,
worker_status: {
state: "waiting_for_heartbeat",
channel: {
id: 1,
slug: "stable",
name: "Stable",
},
channels: [
{
id: 1,
slug: "stable",
name: "Stable",
default_channel: true,
},
],
cron_target: deploymentTarget,
workers: [],
summary: {
total: 0,
running: 0,
stale: 0,
desired: 1,
state: "waiting_for_heartbeat",
},
deployment: {
state: "waiting_for_heartbeat",
target: deploymentTarget,
},
latest_deployment: {
id: 72,
status: "deployed",
provider_operation_id: "deploy-72",
},
issues: [
{
code: "waiting_for_first_heartbeat",
severity: "info",
message: "Coolify accepted the deployment; waiting for the worker to write its first heartbeat.",
},
],
},
}, },
meta: {}, meta: {},
includes: {}, includes: {},
@@ -376,8 +603,253 @@ test.describe("Superuser cron operations", () => {
page.getByTestId("cron-worker-deploy").click(), page.getByTestId("cron-worker-deploy").click(),
]); ]);
expect(deployPayloads).toEqual([{}]); expect(deployPayloads).toEqual([{ channel_id: 1 }]);
await expect(page.getByTestId("cron-run-queued")).toContainText("deployment was queued"); await expect(page.getByTestId("cron-run-queued")).toContainText("deployment was queued");
await expect(page.getByTestId("cron-worker-state")).toContainText("Waiting for heartbeat");
await expect(page.getByTestId("cron-worker-issues")).toContainText("waiting for the worker");
await expect(page.getByTestId("cron-worker-target")).toContainText("cron-worker-uuid");
await expect(page.getByTestId("cron-worker-deploy")).toHaveText("Update Coolify deployment");
});
test("repairs an orphaned Coolify cron worker target", async ({ page }) => {
const deployPayloads: Array<Record<string, unknown>> = [];
let repaired = false;
const oldTarget = {
id: 71,
app: "cron",
coolify_service_uuid: "missing-cron-worker-uuid",
auto_deploy: false,
};
const repairedTarget = {
id: 71,
app: "cron",
coolify_service_uuid: "cron-worker-uuid",
auto_deploy: false,
};
await page.route(apiPathPattern("/superuser/cron"), async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(cronListPayload()),
});
});
await page.route(apiPathPattern("/superuser/cron/runs"), async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: { runs: [] },
meta: {},
includes: {},
}),
});
});
await page.route(apiPathPattern("/superuser/cron/workers"), async (route) => {
const target = repaired ? repairedTarget : oldTarget;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: {
state: repaired ? "waiting_for_heartbeat" : "failed",
desired_workers: 1,
channel: {
id: 1,
slug: "stable",
name: "Stable",
},
channels: [
{
id: 1,
slug: "stable",
name: "Stable",
default_channel: true,
},
],
api_target: null,
cron_target: target,
workers: [],
summary: {
total: 0,
running: 0,
stale: 0,
desired: 1,
state: repaired ? "waiting_for_heartbeat" : "failed",
},
latest_deployment: repaired
? {
id: 73,
app: "cron",
status: "deployed",
provider_operation_id: "deploy-73",
}
: null,
recent_deployments: [],
provider: {
configured: true,
resource_uuid: target.coolify_service_uuid,
resource_type: "application",
checked: true,
missing: !repaired,
resource: repaired
? {
ok: true,
uuid: "cron-worker-uuid",
name: "release-stable-cron-worker",
}
: {
ok: false,
missing: true,
error: "Coolify API request failed: HTTP 404",
},
},
issues: repaired
? [
{
code: "waiting_for_first_heartbeat",
severity: "info",
message: "Coolify accepted the deployment; waiting for the worker to write its first heartbeat.",
},
]
: [
{
code: "no_worker_heartbeat",
severity: "danger",
message: "Cron worker target exists, but no worker heartbeat has been recorded.",
},
{
code: "missing_coolify_worker_resource",
severity: "danger",
message: "The stored Coolify cron worker resource was not found and must be recreated.",
},
{
code: "repairable_cron_target",
severity: "info",
message: "No API target is configured, but the cron target has enough deployment context to repair itself.",
},
],
deployment: {
ok: true,
state: repaired ? "waiting_for_heartbeat" : "failed",
channel: {
id: 1,
slug: "stable",
name: "Stable",
},
api_target: null,
target,
latest_deployment: repaired
? {
id: 73,
app: "cron",
status: "deployed",
provider_operation_id: "deploy-73",
}
: null,
provider: {
missing: !repaired,
},
action: repaired ? "update" : "repair",
can_deploy: true,
issues: [],
},
},
meta: {},
includes: {},
}),
});
});
await page.route(apiPathPattern("/superuser/cron/workers/deploy"), async (route) => {
deployPayloads.push(route.request().postDataJSON() as Record<string, unknown>);
repaired = true;
await route.fulfill({
status: 202,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: {
ok: true,
deployment: {
id: 73,
app: "cron",
status: "deployed",
provider_operation_id: "deploy-73",
},
target: repairedTarget,
worker_status: {
state: "waiting_for_heartbeat",
channel: {
id: 1,
slug: "stable",
name: "Stable",
},
channels: [
{
id: 1,
slug: "stable",
name: "Stable",
default_channel: true,
},
],
api_target: null,
cron_target: repairedTarget,
workers: [],
summary: {
total: 0,
running: 0,
stale: 0,
desired: 1,
state: "waiting_for_heartbeat",
},
deployment: {
state: "waiting_for_heartbeat",
target: repairedTarget,
action: "update",
can_deploy: true,
},
latest_deployment: {
id: 73,
status: "deployed",
provider_operation_id: "deploy-73",
},
issues: [
{
code: "waiting_for_first_heartbeat",
severity: "info",
message: "Coolify accepted the deployment; waiting for the worker to write its first heartbeat.",
},
],
},
},
meta: {},
includes: {},
}),
});
});
await page.goto("/superuser/system/cron", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("cron-worker-api-target")).toContainText("--");
await expect(page.getByTestId("cron-worker-target")).toContainText("missing-cron-worker-uuid");
await expect(page.getByTestId("cron-worker-issues")).toContainText("must be recreated");
await expect(page.getByTestId("cron-worker-deploy")).toHaveText("Repair Coolify deployment");
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes("/superuser/cron/workers/deploy") && response.request().method() === "POST"
),
page.getByTestId("cron-worker-deploy").click(),
]);
expect(deployPayloads).toEqual([{ channel_id: 1 }]);
await expect(page.getByTestId("cron-worker-state")).toContainText("Waiting for heartbeat");
await expect(page.getByTestId("cron-worker-target")).toContainText("cron-worker-uuid"); await expect(page.getByTestId("cron-worker-target")).toContainText("cron-worker-uuid");
await expect(page.getByTestId("cron-worker-deploy")).toHaveText("Update Coolify deployment"); await expect(page.getByTestId("cron-worker-deploy")).toHaveText("Update Coolify deployment");
}); });
@@ -438,22 +438,52 @@ test.describe("Superuser system status smoke", () => {
await expect(usage).toContainText("2.250"); await expect(usage).toContainText("2.250");
await expect(usage).toContainText("Kald tilbage"); await expect(usage).toContainText("Kald tilbage");
await expect(usage).toContainText("250"); await expect(usage).toContainText("250");
await expect(usage).toContainText("Version: 1.54.0"); await expect(usage).not.toContainText("Version: 1.54.0");
const versionLine = page.getByTestId("module-version-licenseplaterecognizer");
await expect(versionLine).toHaveText("Version: 1.54.0");
await expect(page.getByTestId("module-version-virkdata")).toHaveText("");
await expect(usage.locator(".module-card__usage-grid strong").nth(0)).toHaveText("2.250");
await expect(usage.locator(".module-card__usage-grid strong").nth(1)).toHaveText("2.500");
await expect(usage.locator(".module-card__usage-grid strong").nth(2)).toHaveText("250");
await expect(card).not.toContainText("Åbn");
const footer = page.getByTestId("module-footer-licenseplaterecognizer");
const configAction = page.getByTestId("module-config-action-licenseplaterecognizer");
await expect(footer).toBeVisible();
await expect(configAction).toBeVisible();
await expect(configAction).toHaveAttribute("href", "/superuser/configuration/licenseplaterecognizer");
await expect(configAction.locator(".fa-arrow-right")).toBeVisible();
await expect(card.locator(".module-card__link")).toHaveCount(0);
const [cardBox, titleBox, pillBox] = await Promise.all([ const virkdataCard = page.getByTestId("module-card-virkdata");
const [cardBox, titleBox, pillBox, virkdataCardBox, usageBox, footerBox, versionBox] = await Promise.all([
card.boundingBox(), card.boundingBox(),
title.boundingBox(), title.boundingBox(),
pill.boundingBox(), pill.boundingBox(),
virkdataCard.boundingBox(),
usage.boundingBox(),
footer.boundingBox(),
versionLine.boundingBox(),
]); ]);
expect(cardBox).not.toBeNull(); expect(cardBox).not.toBeNull();
expect(titleBox).not.toBeNull(); expect(titleBox).not.toBeNull();
expect(pillBox).not.toBeNull(); expect(pillBox).not.toBeNull();
expect(virkdataCardBox).not.toBeNull();
expect(usageBox).not.toBeNull();
expect(footerBox).not.toBeNull();
expect(versionBox).not.toBeNull();
expect(titleBox.x).toBeGreaterThanOrEqual(cardBox.x - 1); expect(titleBox.x).toBeGreaterThanOrEqual(cardBox.x - 1);
expect(titleBox.x + titleBox.width).toBeLessThanOrEqual(cardBox.x + cardBox.width + 1); expect(titleBox.x + titleBox.width).toBeLessThanOrEqual(cardBox.x + cardBox.width + 1);
expect(versionBox.y).toBeGreaterThanOrEqual(titleBox.y + titleBox.height - 1);
expect(versionBox.x).toBeGreaterThanOrEqual(cardBox.x - 1);
expect(versionBox.x + versionBox.width).toBeLessThanOrEqual(pillBox.x - 1);
expect(pillBox.x).toBeGreaterThanOrEqual(cardBox.x - 1); expect(pillBox.x).toBeGreaterThanOrEqual(cardBox.x - 1);
expect(pillBox.x + pillBox.width).toBeLessThanOrEqual(cardBox.x + cardBox.width + 1); expect(pillBox.x + pillBox.width).toBeLessThanOrEqual(cardBox.x + cardBox.width + 1);
expect(Math.abs(cardBox.width - virkdataCardBox.width)).toBeLessThanOrEqual(1);
expect(Math.abs(cardBox.height - virkdataCardBox.height)).toBeLessThanOrEqual(1);
expect(footerBox.y).toBeGreaterThanOrEqual(usageBox.y + usageBox.height - 1);
expect(footerBox.y + footerBox.height).toBeLessThanOrEqual(cardBox.y + cardBox.height + 1);
const overlapWidth = Math.max( const overlapWidth = Math.max(
0, 0,
+67
View File
@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import BuefyTree from "@/components/buefy/tree/BuefyTree.vue";
vi.mock("buefy", () => ({
BCheckbox: {
name: "BCheckbox",
props: {
modelValue: Boolean,
indeterminate: Boolean,
disabled: Boolean,
},
emits: ["update:modelValue"],
template: `
<button
class="b-checkbox-stub"
type="button"
:disabled="disabled"
:data-checked="modelValue"
:data-indeterminate="indeterminate"
@click="$emit('update:modelValue', !modelValue)"
></button>
`,
},
}));
describe("BuefyTree checkbox selection", () => {
it("uses non-selectable checkable categories as lazy select-all branch controllers", async () => {
const load = vi.fn(async () => [
{ id: "order:70128", label: "Ordre #70128", selectable: true, isLeaf: true },
{ id: "order:70129", label: "Ordre #70129", selectable: true, isLeaf: true },
]);
const wrapper = mount(BuefyTree, {
props: {
data: [
{
id: "category:orders",
label: "Orders",
selectable: false,
checkable: true,
isLeaf: false,
},
],
selectionMode: "checkbox",
lazy: true,
load,
},
});
await wrapper.find(".b-checkbox-stub").trigger("click");
await flushPromises();
expect(load).toHaveBeenCalledTimes(1);
expect(wrapper.emitted("update:checkedKeys")?.at(-1)?.[0]).toEqual([
"order:70128",
"order:70129",
]);
await wrapper.find(".b-checkbox-stub").trigger("click");
await flushPromises();
expect(load).toHaveBeenCalledTimes(1);
expect(wrapper.emitted("update:checkedKeys")?.at(-1)?.[0]).toEqual([]);
});
});
@@ -0,0 +1,364 @@
// @vitest-environment jsdom
import { flushPromises, mount } from "@vue/test-utils";
import { describe, expect, it, vi, beforeEach } from "vitest";
const mocks = vi.hoisted(() => {
const order = {
id: 9001,
invoice_collection_id: 3001,
department_id: 75,
created_at: "2026-06-18",
total_net_amount: 240,
reference: "REF-9001",
notes: "",
po: "",
reg_1: "AB12345",
};
return {
order,
economicDetails: vi.fn(async () => ({
data: {
data: {
collected_invoice_id: 3001,
economic: {
draft_id: 88,
booked_id: 99,
},
internal: {
normalized: {
totals: {
net_total: 240,
line_count: 1,
},
},
},
draft: {
exists: false,
normalized: null,
},
booked: {
exists: true,
normalized: {
totals: {
net_total: 240,
line_count: 1,
},
},
},
warnings: [],
},
},
})),
economicPdf: vi.fn(async () => ({ data: { data: { url: "https://example.test/invoice.pdf" } } })),
request: vi.fn(async (url, method) => {
if (url === "/order/items" && method === "GET") {
return {
data: {
data: [
{
id: 501,
order_id: 9001,
product_name: "Premium wash",
quantity: 2,
price: 120,
reference: "",
notes: "",
},
],
},
};
}
return { data: { data: [] } };
}),
};
});
vi.mock("buefy", () => ({
BCheckbox: {
name: "BCheckbox",
props: {
modelValue: Boolean,
indeterminate: Boolean,
disabled: Boolean,
},
emits: ["update:modelValue"],
template: `
<button
class="b-checkbox-stub"
type="button"
:disabled="disabled"
:data-checked="modelValue"
:data-indeterminate="indeterminate"
@click="$emit('update:modelValue', !modelValue)"
></button>
`,
},
}));
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(async () => ({ isConfirmed: false })),
},
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key) => key,
locale: { value: "da-DK" },
}),
}));
vi.mock("@/components/displays/buttons/EditableTableColumn.vue", () => ({
default: {
name: "EditableTableColumn",
props: ["object", "column", "parseFunction"],
template: `<span class="editable-table-column-stub">{{ parseFunction ? parseFunction(object?.[column]) : object?.[column] }}</span>`,
},
}));
vi.mock("@/components/displays/buttons/ActionSettingsWheelButton.vue", () => ({
default: {
name: "ActionSettingsWheelButton",
props: ["menuSections", "order_id", "department_id", "invoice_collection_id", "customer_number"],
template: `
<button
type="button"
class="action-settings-wheel-stub"
:data-menu-count="menuSections.length"
:data-action-count="menuSections.reduce((count, section) => count + (section.items?.length || 0), 0)"
:data-order-id="order_id || undefined"
:data-department-id="department_id || undefined"
:data-invoice-collection-id="invoice_collection_id || undefined"
:data-customer-number="customer_number || undefined"
></button>
`,
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: mocks.request,
functions: {
currency: {
toLocal: (value) => `${Number(value || 0)} DKK`,
},
parseErrorMessage: (error) => error?.message || String(error),
},
hasPermission: () => true,
canAccessAdmin: () => true,
canAccessSuperUser: () => true,
canAccessDepartment: () => true,
objects: {
orders: {
columns: {
reference: { label: "Reference" },
notes: { label: "Noter" },
po: { label: "PO" },
reg_1: { label: "Reg 1" },
reg_2: { label: "Reg 2" },
reg_3: { label: "Reg 3" },
include_in_invoice: { label: "Faktura" },
total_net_amount: { label: "Total" },
},
get: {
single: vi.fn(async () => mocks.order),
},
functions: {
fetchAttachments: vi.fn(async () => []),
downloadAttachment: vi.fn(async () => "https://example.test/attachment.pdf"),
removeAttachment: vi.fn(),
resendWashCertificate: vi.fn(),
mark_as_completed: vi.fn(),
},
set: {
include_in_invoice: vi.fn(),
invoice_collection_id: vi.fn(),
booking_id: vi.fn(),
wash_id: vi.fn(),
},
delete: {
single: vi.fn(),
},
showEditObjectFieldForm: vi.fn(),
},
collectedOrderInvoices: {
columns: {
name: { label: "Navn" },
notes: { label: "Noter" },
po_number: { label: "PO" },
customer_number: { label: "Kundenr." },
external_id: { label: "Eksternt ID" },
closed_at: { label: "Lukket" },
total_net_amount: { label: "Total" },
},
functions: {
economic: {
v2: {
details: mocks.economicDetails,
},
pdf: mocks.economicPdf,
},
bulk_action_preview: vi.fn(),
bulk_action_apply: vi.fn(),
},
showEditObjectFieldForm: vi.fn(),
},
order_bookings: {
get: {
single: vi.fn(),
},
functions: {
resendBookingConfirmation: vi.fn(),
resendBookingCompletionConfirmation: vi.fn(),
},
delete: {
single: vi.fn(),
},
},
},
},
}));
vi.mock("@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportInvoiceQueue.vue", () => ({
invoiceQueue: {
addInvoiceCollectionsToQueue: vi.fn(),
processInvoiceCollectionQueue: vi.fn(),
},
default: {},
}));
vi.mock("@/components/displays/department/pos/orders/invoiceCollectionBulkActions.js", () => ({
INVOICE_COLLECTION_BULK_ACTIONS: {
QUEUE_ECONOMIC: "queue_economic",
CLEAN_CUSTOMER_RULES: "clean_customer_rules",
MERGE: "merge",
SPLIT_BY_MONTH: "split_by_month",
RESET_HIDDEN_PRICES: "reset_hidden_prices",
},
}));
import InvoicingPeriodObjectTree from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue";
const mountTree = () => mount(InvoicingPeriodObjectTree, {
props: {
customer: {
customer_number: 2001,
customer_name: "ACME",
draft: {
has_valid_draft: true,
invoice_collection_ids: [3001],
},
},
transactions: [
{
...mocks.order,
booked: true,
},
],
excludedOrderIds: [],
dates: {
dateFrom: "2026-06-01",
dateTo: "2026-06-30",
},
invoicePeriodFlags: [],
},
});
const expandNode = async (wrapper, key) => {
const node = wrapper.find(`[data-node-key="${key}"]`);
expect(node.exists()).toBe(true);
await node.find(".b-tree-node-toggle").trigger("click");
await flushPromises();
};
describe("InvoicingPeriodObjectTree", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, "open", {
configurable: true,
value: vi.fn(),
});
});
it("auto-expands order items when an order node is expanded", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
expect(mocks.request).toHaveBeenCalledWith("/order/items", "GET", { order_id: 9001 });
expect(wrapper.find('[data-node-key="category:9001:order_items"]').classes()).toContain("is-expanded");
expect(wrapper.text()).toContain("Premium wash");
});
it("renders row action wheels for actionable collection, order, and order item nodes", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
const collectionWheel = wrapper.find('[data-testid="invoice-period-tree-action-wheel-collected_order_invoice:3001"]');
const orderWheel = wrapper.find('[data-testid="invoice-period-tree-action-wheel-order:9001"]');
const orderItemWheel = wrapper.find('[data-testid="invoice-period-tree-action-wheel-order_item:501"]');
expect(collectionWheel.exists()).toBe(true);
expect(orderWheel.exists()).toBe(true);
expect(orderItemWheel.exists()).toBe(true);
expect(collectionWheel.attributes("data-action-count")).toBe("4");
expect(collectionWheel.attributes("data-invoice-collection-id")).toBe("3001");
expect(orderWheel.attributes("data-action-count")).toBe("3");
expect(orderWheel.attributes("data-order-id")).toBe("9001");
expect(orderWheel.attributes("data-department-id")).toBe("75");
expect(orderItemWheel.attributes("data-action-count")).toBe("1");
expect(wrapper.find('[data-testid="invoice-period-tree-action-wheel-category:9001:order_items"]').exists()).toBe(false);
});
it("shows empty editable fields without a reveal toggle", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
expect(wrapper.find('[data-testid="invoice-period-tree-field-order:9001-notes"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-field-value-order:9001-notes"]').text()).toContain("Tom");
expect(wrapper.find('[data-testid="invoice-period-tree-field-order:9001-reg_2"]').exists()).toBe(true);
expect(wrapper.find('[data-testid^="invoice-period-tree-field-empty-toggle-"]').exists()).toBe(false);
});
it("omits redundant collection name and customer number fields", async () => {
const wrapper = mountTree();
expect(wrapper.find('[data-testid="invoice-period-tree-field-collected_order_invoice:3001-notes"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-field-collected_order_invoice:3001-name"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="invoice-period-tree-field-collected_order_invoice:3001-customer_number"]').exists()).toBe(false);
});
it("renders only resolved draft/booked e-conomic invoice children", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_economic");
expect(mocks.economicDetails).toHaveBeenCalledWith(3001);
expect(wrapper.text()).not.toContain("E-conomic kladde #88");
expect(wrapper.text()).toContain("E-conomic faktura #99");
});
it("downloads booked e-conomic invoice pdfs for booked invoice nodes", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_economic");
const bookedInvoiceNode = wrapper.find('[data-node-key="economic_invoice:3001:booked:99"]');
expect(bookedInvoiceNode.exists()).toBe(true);
await bookedInvoiceNode.find(".b-tree-node-content").trigger("click");
await flushPromises();
expect(mocks.economicPdf).toHaveBeenCalledWith(3001, "booked");
expect(window.open).toHaveBeenCalledWith("https://example.test/invoice.pdf", "_blank", "noopener,noreferrer");
});
});
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import {
TREE_CATEGORY_TYPES,
TREE_NODE_TYPES,
buildRelativeCollectionLabel,
buildCollectionRootNodes,
classifyAttachment,
getTreeAmount,
hasWashCertificateOrderItem,
makeAttachmentNode,
makeBookingItemNode,
makeBookingNode,
makeCategoryNode,
makeEconomicInvoiceNode,
makeOrderItemNode,
makeOrderNode,
makeXlvaskInferredItemNode,
makeXlvaskNode,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeNodes.js";
describe("invoicing period tree node builders", () => {
it("groups visible orders by invoice collection and keeps ungrouped orders in a category", () => {
const nodes = buildCollectionRootNodes(
{ customer_number: 2001, customer_name: "ACME" },
[
{ id: 11, invoice_collection_id: 3002, total_net_amount: 125 },
{ id: 12, invoice_collection_id: 3001, total_net_amount: 50 },
{ id: 13, invoice_collection_id: null, amount: 75 },
{ id: 14, invoice_collection_id: 3001, amount: 25, total_net_amount: 0 },
],
[11],
{
collection: (id) => `Collection ${id}`,
ordersWithoutCollection: "Loose orders",
}
);
expect(nodes).toHaveLength(2);
expect(nodes[0]).toMatchObject({
id: `${TREE_NODE_TYPES.COLLECTION}:3001`,
label: "Collection 3001",
selectable: true,
actionable: true,
meta: {
collectionId: 3001,
customerNumber: 2001,
orderCount: 2,
totalNetAmount: 75,
},
});
expect(nodes[1]).toMatchObject({
label: "Loose orders",
type: TREE_NODE_TYPES.CATEGORY,
category: TREE_CATEGORY_TYPES.COLLECTION_ORDERS,
selectable: false,
checkable: true,
actionable: false,
meta: {
count: 1,
},
});
});
it("uses relative collection labels for whole-month and multi-collection ranges", () => {
const wholeMonthNodes = buildCollectionRootNodes(
{ customer_number: 2001, customer_name: "ACME" },
[
{ id: 11, invoice_collection_id: 3001, created_at: "2026-06-02", total_net_amount: 125 },
{ id: 12, invoice_collection_id: 3001, created_at: "2026-06-18", total_net_amount: 50 },
],
[],
{
collection: (id) => `Collection ${id}`,
dateFrom: "2026-06-01",
dateTo: "2026-06-30",
locale: "da-DK",
}
);
expect(wholeMonthNodes[0].label).toBe("Juni");
expect(wholeMonthNodes[0].meta.relativeLabel).toMatchObject({
kind: "month",
monthLabel: "Juni",
});
const rangeNodes = buildCollectionRootNodes(
{ customer_number: 2001, customer_name: "ACME" },
[
{ id: 11, invoice_collection_id: 3001, created_at: "2026-06-01", total_net_amount: 125 },
{ id: 12, invoice_collection_id: 3001, created_at: "2026-06-18", total_net_amount: 50 },
{ id: 13, invoice_collection_id: 3002, created_at: "2026-06-19", total_net_amount: 75 },
],
[],
{
collection: (id) => `Collection ${id}`,
dateFrom: "2026-06-01",
dateTo: "2026-06-30",
locale: "da-DK",
}
);
expect(rangeNodes[0].label).toBe("1 Juni → 18 Juni");
expect(rangeNodes[0].meta.relativeLabel).toMatchObject({
kind: "range",
startLabel: "1 Juni",
endLabel: "18 Juni",
});
expect(buildRelativeCollectionLabel({
collectionId: 3003,
orders: [],
fallbackLabel: "Collection 3003",
}).label).toBe("Collection 3003");
});
it("normalizes tree amounts from the first non-zero backend amount field", () => {
expect(getTreeAmount({ amount: 240, total_net_amount: 0 })).toBe(240);
expect(getTreeAmount({ amount: 0, total_net_amount: 140, total: 99 })).toBe(140);
expect(getTreeAmount({ amount: 0, total_net_amount: 0, total: 99 })).toBe(99);
expect(makeOrderNode({ id: 91, amount: 240, total_net_amount: 0 }).meta.totalNetAmount).toBe(240);
});
it("creates non-selectable but checkable category branch nodes for bulk selection", () => {
const node = makeCategoryNode({
id: "category:orders",
label: "Orders",
category: TREE_CATEGORY_TYPES.COLLECTION_ORDERS,
parentType: TREE_NODE_TYPES.COLLECTION,
parentId: "collection:1",
checkable: true,
});
expect(node).toMatchObject({
selectable: false,
checkable: true,
actionable: false,
});
});
it("classifies attachments into certificate, image, and other buckets", () => {
expect(classifyAttachment({ content: { other: "wash_certificate_42.pdf" } })).toBe("certificate");
expect(classifyAttachment({ file_name: "front.jpg", content_type: "application/octet-stream" })).toBe("image");
expect(classifyAttachment({ file_name: "note.txt", content_type: "text/plain" })).toBe("other");
});
it("detects wash certificate order items from product names and pictograms", () => {
expect(hasWashCertificateOrderItem([
{ product_name: "Normal vask" },
{ product: { name: "Vaskecertifikat" } },
])).toBe(true);
expect(hasWashCertificateOrderItem([{ piktogram: "certificate", name: "Seal" }])).toBe(true);
expect(hasWashCertificateOrderItem([{ product_name: "Kassevogn/Varevogn" }])).toBe(false);
});
it("creates selectable object nodes with typed metadata for lazy children and actions", () => {
const orderNode = makeOrderNode(
{
id: 91,
invoice_collection_id: 3001,
booking_id: 77,
wash_id: "W-12",
total_net_amount: 240,
},
{ label: "Order label" }
);
const itemNode = makeOrderItemNode({ id: 501, order_id: 91, product_name: "Premium wash", quantity: 2, price: 120 });
const attachmentNode = makeAttachmentNode({ id: 8, file_name: "photo.png" }, 91);
const bookingNode = makeBookingNode({ id: 77, order_id: 91, items: [{ id: 1, name: "Slot" }] });
const bookingItemNode = makeBookingItemNode({ id: 1, name: "Slot" }, 77);
const xlvaskNode = makeXlvaskNode({ id: 456, WashId: "W-12", WashItems: [{ Name: "Wash" }] }, { id: 91 });
const inferredNode = makeXlvaskInferredItemNode({ Name: "Wash" }, "W-12", 0);
expect(orderNode).toMatchObject({
label: "Order label",
type: TREE_NODE_TYPES.ORDER,
isLeaf: false,
selectable: true,
actionable: true,
meta: {
orderId: 91,
collectionId: 3001,
bookingId: 77,
washId: "W-12",
},
});
expect(itemNode).toMatchObject({ type: TREE_NODE_TYPES.ORDER_ITEM, isLeaf: true, actionable: true });
expect(attachmentNode).toMatchObject({
type: TREE_NODE_TYPES.ATTACHMENT,
meta: { attachmentId: 8, orderId: 91, attachmentType: "image" },
});
expect(bookingNode).toMatchObject({ type: TREE_NODE_TYPES.BOOKING, meta: { bookingId: 77, orderId: 91 } });
expect(bookingItemNode).toMatchObject({ type: TREE_NODE_TYPES.BOOKING_ITEM, actionable: false });
expect(xlvaskNode).toMatchObject({
type: TREE_NODE_TYPES.XLVASK_WASH,
actionable: true,
meta: { usageId: 456, washId: "W-12", orderId: 91 },
});
expect(inferredNode).toMatchObject({ type: TREE_NODE_TYPES.XLVASK_INFERRED_ITEM, actionable: false });
});
it("creates economic invoice nodes with draft/booked metadata", () => {
const node = makeEconomicInvoiceNode({
collectionId: 3001,
invoiceType: "draft",
economicInvoiceId: 812,
details: { economic: { draft_id: 812 } },
});
expect(node).toMatchObject({
id: `${TREE_NODE_TYPES.ECONOMIC_INVOICE}:3001:draft:812`,
label: "E-conomic kladde #812",
type: TREE_NODE_TYPES.ECONOMIC_INVOICE,
isLeaf: true,
actionable: false,
icon: "fa-file-alt",
meta: {
collectionId: 3001,
economicType: "draft",
economicInvoiceId: 812,
},
});
});
});
+121 -8
View File
@@ -89,6 +89,22 @@ const periodViewAllSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue"), join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue"),
"utf8" "utf8"
); );
const periodObjectTreeSource = readFileSync(
join(
root,
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue"
),
"utf8"
);
const periodTreeNodeServiceSource = readFileSync(
join(
root,
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeNodes.js"
),
"utf8"
);
const buefyTreeSource = readFileSync(join(root, "src/components/buefy/tree/BuefyTree.vue"), "utf8");
const buefyTreeNodeSource = readFileSync(join(root, "src/components/buefy/tree/BuefyTreeNode.vue"), "utf8");
const periodViewSelfWashSource = readFileSync( const periodViewSelfWashSource = readFileSync(
join( join(
root, root,
@@ -122,22 +138,22 @@ const localeMessages = ["da", "en", "sv", "de", "no"].map((locale) => ({
describe("superuser invoices route wiring", () => { describe("superuser invoices route wiring", () => {
it("maps /superuser/invoices to CollectedOrderInvoices view", () => { it("maps /superuser/invoices to CollectedOrderInvoices view", () => {
expect(routerSource).toContain("name: 'collectedorderinvoices'"); expect(routerSource).toMatch(/name:\s*["']collectedorderinvoices["']/);
expect(routerSource).toContain("path: '/superuser/invoices'"); expect(routerSource).toMatch(/path:\s*["']\/superuser\/invoices["']/);
expect(routerSource).toContain("component: CollectedOrderInvoices"); expect(routerSource).toContain("component: CollectedOrderInvoices");
}); });
it("maps monthly distribution routes to InvoiceDistributionMonthView", () => { it("maps monthly distribution routes to InvoiceDistributionMonthView", () => {
expect(routerSource).toContain("name: 'collectedorderinvoicesdistribution'"); expect(routerSource).toMatch(/name:\s*["']collectedorderinvoicesdistribution["']/);
expect(routerSource).toContain("path: '/superuser/invoices/distribution/:year/:month'"); expect(routerSource).toMatch(/path:\s*["']\/superuser\/invoices\/distribution\/:year\/:month["']/);
expect(routerSource).toContain("name: 'collectedorderinvoicesdistributiontab'"); expect(routerSource).toMatch(/name:\s*["']collectedorderinvoicesdistributiontab["']/);
expect(routerSource).toContain("path: '/superuser/invoices/distribution/:year/:month/:tab'"); expect(routerSource).toMatch(/path:\s*["']\/superuser\/invoices\/distribution\/:year\/:month\/:tab["']/);
expect(routerSource).toContain("component: InvoiceDistributionMonthView"); expect(routerSource).toContain("component: InvoiceDistributionMonthView");
}); });
it("keeps details route for a single collected invoice", () => { it("keeps details route for a single collected invoice", () => {
expect(routerSource).toContain("name: 'collectedorderinvoice'"); expect(routerSource).toMatch(/name:\s*["']collectedorderinvoice["']/);
expect(routerSource).toContain("path: '/superuser/invoices/:collectedOrderInvoiceId'"); expect(routerSource).toMatch(/path:\s*["']\/superuser\/invoices\/:collectedOrderInvoiceId["']/);
expect(routerSource).toContain("component: CollectedOrderInvoice"); expect(routerSource).toContain("component: CollectedOrderInvoice");
}); });
}); });
@@ -606,6 +622,103 @@ describe("Periode tab contract", () => {
expect(periodRightSource).toContain('typeName !== "possible_duplicates"'); expect(periodRightSource).toContain('typeName !== "possible_duplicates"');
expect(periodRightSource).toContain("buildPossibleDuplicateGroups(entries)"); expect(periodRightSource).toContain("buildPossibleDuplicateGroups(entries)");
}); });
it("uses the invoice period object tree for normal expanded customer rows", () => {
expect(periodViewAllSource).toContain(
'import InvoicingPeriodObjectTree from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue";'
);
expect(periodViewAllSource).toContain("<InvoicingPeriodObjectTree");
expect(periodViewAllSource).toContain(':transactions="getTransactionsInView(customer)"');
expect(periodViewAllSource).toContain(':excluded-order-ids="getExcludedTransactionIds(customer)"');
expect(periodViewAllSource).toContain('@refresh="reloadPeriodPage"');
expect(periodViewAllSource).toContain("<InvoiceOrdersPagination");
expect(periodViewAllSource).toContain("duplicate-comparison-row");
});
it("keeps the object tree lazy, slotted, check-selectable, and error-aware", () => {
expect(buefyTreeSource).toContain("lazyChildrenCache");
expect(buefyTreeSource).toContain("loadErrorKeys");
expect(buefyTreeSource).toContain('selectionMode?: "none" | "single" | "multiple" | "checkbox"');
expect(buefyTreeSource).toContain("node?.selectable !== false");
expect(buefyTreeSource).toContain("node?.checkable === true");
expect(buefyTreeSource).toContain("ensureChildrenLoadedForCheck");
expect(buefyTreeNodeSource).toContain('<slot name="icon"');
expect(buefyTreeNodeSource).toContain(':retry="() => tree.retryLoad(node)"');
expect(periodObjectTreeSource).toContain('selection-mode="checkbox"');
expect(periodObjectTreeSource).toContain(':lazy="true"');
expect(periodObjectTreeSource).toContain("@load-error=");
expect(periodObjectTreeSource).toContain("getNodeFlagCount");
expect(periodObjectTreeSource).toContain("getCachedOrderItemRows");
expect(periodObjectTreeSource).toContain("getCachedAttachmentRows");
expect(periodObjectTreeSource).toContain("shouldShowWashCertificateCategory");
expect(periodObjectTreeSource).not.toContain("order?.attachments === undefined");
expect(periodTreeNodeServiceSource).toContain("getTreeAmount");
expect(periodTreeNodeServiceSource).toContain("hasWashCertificateOrderItem");
expect(periodObjectTreeSource).toContain("fa-flag");
expect(periodObjectTreeSource).toContain('data-testid="invoice-period-tree-toolbar"');
expect(periodObjectTreeSource).toContain('`invoice-period-tree-actions-dropdown-${group.type}`');
});
it("exposes object-specific lazy child categories and multi-select operations", () => {
[
"COLLECTION_ORDERS",
"COLLECTION_AGREEMENTS",
"COLLECTION_PAYMENTS",
"COLLECTION_ECONOMIC",
"ORDER_ITEMS",
"ORDER_ATTACHMENTS_CERTIFICATES",
"ORDER_ATTACHMENTS_IMAGES",
"ORDER_ATTACHMENTS_OTHER",
"ORDER_BOOKINGS",
"ORDER_XLVASK",
"BOOKING_ITEMS",
"XLVASK_ITEMS",
].forEach((category) => {
expect(periodTreeNodeServiceSource).toContain(category);
});
[
"runCollectionBulkAction",
"moveSelectedOrdersToInvoiceCollection",
"unlinkSelectedOrderBookings",
"unlinkSelectedOrderXlvask",
"deleteSelectedOrderItems",
"resendSelectedWashCertificates",
"resendSelectedBookingCompletionConfirmations",
"decideSelectedXlvaskAutomation",
].forEach((operation) => {
expect(periodObjectTreeSource).toContain(operation);
});
expect(periodObjectTreeSource).toContain("bulk_action_preview");
expect(periodObjectTreeSource).toContain("bulk_action_apply");
expect(periodObjectTreeSource).toContain("chooseMergeTargetInvoiceCollection");
expect(periodObjectTreeSource).toContain("checkable: true");
expect(periodObjectTreeSource).toContain("uniqueNodesByKey");
expect(periodObjectTreeSource).toContain("const actionGroups = computed<TreeActionGroup[]>");
expect(periodObjectTreeSource).toContain("actionsForType");
expect(periodObjectTreeSource).toContain("runTreeAction");
expect(periodObjectTreeSource).toContain("activeTreeActionKey");
expect(periodObjectTreeSource).toContain("openActionGroupType");
expect(periodObjectTreeSource).toContain("affectedCount: number");
expect(periodObjectTreeSource).toContain("action.affectedCount === 0");
expect(periodObjectTreeSource).toContain("{{ action.affectedCount }}");
expect(periodObjectTreeSource).toContain("affectedCount: selectedCollectionCount.value > 1 ? selectedCollectionCount.value : 0");
expect(periodObjectTreeSource).toContain("disabled: selectedCollectionCount.value < 2");
expect(periodObjectTreeSource).toContain("affectedCount: selectedOrdersWithBookingCount.value");
expect(periodObjectTreeSource).toContain("affectedCount: selectedOrdersWithWashCount.value");
expect(periodObjectTreeSource).toContain("affectedCount: certificateAttachmentOrderIds.value.length");
expect(periodObjectTreeSource).toContain("affectedCount: actionableXlvaskNodes.value.length");
expect(periodObjectTreeSource).not.toContain("onlySelectedType");
expect(periodObjectTreeSource).not.toContain("selection.mixed_types");
});
it("defines mixed-selection action menu locale labels", () => {
localeMessages.forEach(({ messages }) => {
expect(messages.invoicing_period.object_tree.buttons.actions).toBeTruthy();
expect(messages.invoicing_period.object_tree.selection.no_actions).toBeTruthy();
});
});
}); });
describe("Periode runtime view switching", () => { describe("Periode runtime view switching", () => {
@@ -111,6 +111,67 @@ const createSnapshot = (overrides = {}) => ({
status_reason: "Bird API returned HTTP 503.", status_reason: "Bird API returned HTTP 503.",
checked_at: "2026-04-08T08:45:00.000Z", checked_at: "2026-04-08T08:45:00.000Z",
}, },
{
key: "email",
enabled: true,
configured: true,
probe_supported: true,
status: "degraded",
status_reason: "MailerSend responded, but quota usage could not be read.",
status_reason_key: "provider_quota_unavailable",
status_reason_params: {
label: "MailerSend",
reason: "missing_limit",
},
checked_at: "2026-04-08T08:45:00.000Z",
usage_metrics: [
{
module_key: "email",
metric_key: "mailersend_messages",
metric_label: "MailerSend messages",
unit: "messages",
period: "provider",
source: "provider_snapshot",
primary: true,
writable_limit: false,
used: null,
limit: null,
remaining: null,
usage_percent: null,
status: "unknown",
enforce_mode: "observe",
usage_available: false,
unavailable_reason: "missing_limit",
},
],
},
{
key: "motorapi",
enabled: true,
configured: true,
probe_supported: true,
status: "ok",
status_reason: "MotorAPI connectivity confirmed.",
checked_at: "2026-04-08T08:45:00.000Z",
usage_metrics: [
{
module_key: "motorapi",
metric_key: "lookup_calls",
metric_label: "License plate lookups",
unit: "calls",
period: "day",
source: "internal_counter",
primary: true,
writable_limit: true,
used: 42,
limit: 100,
remaining: 58,
usage_percent: 42,
status: "ok",
enforce_mode: "block",
},
],
},
{ {
key: "licenseplaterecognizer", key: "licenseplaterecognizer",
enabled: true, enabled: true,
@@ -271,6 +332,8 @@ describe("superuser system status route contract", () => {
it("keeps /superuser routed to the main dashboard view and mounts the system dashboard", () => { it("keeps /superuser routed to the main dashboard view and mounts the system dashboard", () => {
expect(routerSource).toContain("path: '/superuser'"); expect(routerSource).toContain("path: '/superuser'");
expect(routerSource).not.toContain("path: '/superuser/system/status'"); expect(routerSource).not.toContain("path: '/superuser/system/status'");
expect(routerSource).not.toContain("path: '/superuser/configuration/module-usage'");
expect(routerSource).not.toContain("ModuleUsageQuotas");
expect(routerSource).toContain("component: SuperUserDashboard"); expect(routerSource).toContain("component: SuperUserDashboard");
expect(superUserDashboardSource).toContain("<SystemStatusDashboard />"); expect(superUserDashboardSource).toContain("<SystemStatusDashboard />");
expect(superUserDashboardSource).toContain("$t('system_status.title')"); expect(superUserDashboardSource).toContain("$t('system_status.title')");
@@ -326,18 +389,61 @@ describe("superuser system status dashboard", () => {
expect(wrapper.text()).toContain("Redis is unavailable; module probe caching is bypassed."); expect(wrapper.text()).toContain("Redis is unavailable; module probe caching is bypassed.");
await openDashboardTab(wrapper, "modules"); await openDashboardTab(wrapper, "modules");
expect(wrapper.get('a[href="/superuser/configuration/openai"]').exists()).toBe(true); expect(wrapper.find('a[href="/superuser/configuration/module-usage"]').exists()).toBe(false);
const openAiCard = wrapper.get('[data-testid="module-card-openai"]');
expect(openAiCard.text()).not.toContain("Enabled:");
expect(openAiCard.text()).not.toContain("Configured:");
expect(openAiCard.text()).not.toContain("Checked at:");
const openAiEnabled = wrapper.get('[data-testid="module-indicator-openai-enabled"]');
expect(openAiEnabled.attributes("aria-label")).toContain("Enabled: Yes");
expect(openAiEnabled.classes()).toContain("is-ok");
expect(openAiEnabled.find(".fa-power-off").exists()).toBe(true);
const openAiConfigured = wrapper.get('[data-testid="module-indicator-openai-configured"]');
expect(openAiConfigured.attributes("aria-label")).toContain("Configured: Yes");
expect(openAiConfigured.find(".fa-cog").exists()).toBe(true);
expect(openAiConfigured.find(".fa-check").exists()).toBe(true);
const openAiChecked = wrapper.get('[data-testid="module-indicator-openai-checked"]');
expect(openAiChecked.attributes("aria-label")).toContain("Checked at:");
expect(openAiChecked.find(".fa-clipboard-check").exists()).toBe(true);
const openAiVersion = wrapper.get('[data-testid="module-version-openai"]');
expect(openAiVersion.text()).toBe("");
const openAiConfigAction = wrapper.get('[data-testid="module-config-action-openai"]');
expect(openAiConfigAction.attributes("href")).toBe("/superuser/configuration/openai");
expect(openAiConfigAction.attributes("aria-label")).toContain("Open configuration: OpenAI");
expect(openAiConfigAction.text()).toBe("");
expect(openAiConfigAction.find(".fa-arrow-right").exists()).toBe(true);
const openAiUsage = wrapper.get('[data-testid="module-usage-unavailable-openai"]');
expect(openAiUsage.classes()).toContain("module-card__usage--warning");
expect(openAiUsage.text()).toContain("No quota or usage statistics are available for this module.");
expect(openAiUsage.find("progress").exists()).toBe(false);
const motorUsage = wrapper.get('[data-testid="module-usage-motorapi-lookup_calls"]');
expect(motorUsage.text()).toContain("License plate lookups");
expect(motorUsage.text()).toContain("42.0%");
expect(motorUsage.text()).toContain("42");
expect(motorUsage.text()).toContain("100");
expect(motorUsage.text()).not.toContain("42 calls");
expect(motorUsage.get("progress").attributes("value")).toBe("42");
const unavailableUsage = wrapper.get('[data-testid="module-usage-email-mailersend_messages"]');
expect(unavailableUsage.classes()).toContain("module-card__usage--error");
expect(unavailableUsage.text()).toContain("Usage unavailable");
expect(unavailableUsage.text()).toContain("The provider response did not include a recognizable quota limit.");
expect(unavailableUsage.find(".module-card__usage-warning").exists()).toBe(true);
expect(unavailableUsage.find("progress").exists()).toBe(false);
const usage = wrapper.get('[data-testid="module-usage-licenseplaterecognizer"]'); const usage = wrapper.get('[data-testid="module-usage-licenseplaterecognizer"]');
expect(usage.text()).toContain("Quota usage"); expect(usage.text()).toContain("Quota usage");
expect(usage.text()).toContain("90.0%"); expect(usage.text()).toContain("90.0%");
expect(usage.text()).toContain("Calls used"); expect(usage.text()).toContain("Calls used");
expect(usage.text()).toContain("2,250"); expect(usage.text()).toContain("2,250");
expect(usage.text()).not.toContain("2,250 calls");
expect(usage.text()).toContain("Quota"); expect(usage.text()).toContain("Quota");
expect(usage.text()).toContain("2,500"); expect(usage.text()).toContain("2,500");
expect(usage.text()).not.toContain("2,500 calls");
expect(usage.text()).toContain("Calls remaining"); expect(usage.text()).toContain("Calls remaining");
expect(usage.text()).toContain("250"); expect(usage.text()).toContain("250");
expect(usage.text()).toContain("Version: 1.54.0"); expect(usage.text()).not.toContain("250 calls");
expect(usage.text()).not.toContain("Version: 1.54.0");
expect(usage.get("progress").attributes("value")).toBe("90"); expect(usage.get("progress").attributes("value")).toBe("90");
expect(wrapper.get('[data-testid="module-version-licenseplaterecognizer"]').text()).toBe("Version: 1.54.0");
await openDashboardTab(wrapper, "sessions"); await openDashboardTab(wrapper, "sessions");
expect(wrapper.text()).toContain("Acme Logistics"); expect(wrapper.text()).toContain("Acme Logistics");
@@ -346,6 +452,125 @@ describe("superuser system status dashboard", () => {
wrapper.unmount(); wrapper.unmount();
}); });
it("keeps module quota skeletons in the module card footprint while the initial snapshot loads", async () => {
let resolveStatusRequest;
authenticatedRequestMock.mockImplementation((path, method) => {
if (path === "/superuser/system/status" && method === "GET") {
return new Promise((resolve) => {
resolveStatusRequest = resolve;
});
}
if (path === "/edge-gateways" && method === "GET") {
return Promise.resolve({
data: {
data: [],
meta: createGatewayFleetMeta([]),
},
});
}
return Promise.reject(new Error(`Unexpected request: ${path} ${method}`));
});
const wrapper = mountWithApp(SystemStatusDashboard, {
messages: { en: enMessages },
});
await flushRendering();
await openDashboardTab(wrapper, "modules");
expect(wrapper.get('[data-testid="system-status-modules-loading"]').exists()).toBe(true);
expect(wrapper.findAll('[data-testid^="module-card-skeleton-"]')).toHaveLength(3);
expect(wrapper.get('[data-testid="module-usage-skeleton-0"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="module-footer-skeleton-0"]').exists()).toBe(true);
resolveStatusRequest({
data: {
data: createSnapshot({ warnings: [] }),
},
});
await flushDashboardLoad();
wrapper.unmount();
});
it("renders disabled, unconfigured, and unchecked module states as footer icons", async () => {
installDashboardMocks({
snapshot: createSnapshot({
warnings: [],
modules: [
{
key: "openai",
enabled: false,
configured: false,
probe_supported: true,
status: "disabled",
status_reason: "Module is disabled.",
checked_at: null,
},
],
}),
});
const wrapper = mountWithApp(SystemStatusDashboard, {
messages: { en: enMessages },
});
await flushDashboardLoad();
await openDashboardTab(wrapper, "modules");
const enabled = wrapper.get('[data-testid="module-indicator-openai-enabled"]');
expect(enabled.classes()).toContain("is-down");
expect(enabled.find(".fa-power-off").exists()).toBe(true);
expect(enabled.find(".fa-xmark").exists()).toBe(true);
const configured = wrapper.get('[data-testid="module-indicator-openai-configured"]');
expect(configured.classes()).toContain("is-down");
expect(configured.find(".fa-cog").exists()).toBe(true);
expect(configured.find(".fa-xmark").exists()).toBe(true);
const checked = wrapper.get('[data-testid="module-indicator-openai-checked"]');
expect(checked.classes()).toContain("is-neutral");
expect(checked.find(".fa-clipboard-check").exists()).toBe(true);
expect(checked.find(".fa-question").exists()).toBe(true);
wrapper.unmount();
});
it("renders module quota errors in the same module-card usage area as quota results", async () => {
installDashboardMocks({
snapshot: createSnapshot({
warnings: [],
modules: [
{
key: "openai",
enabled: true,
configured: true,
probe_supported: true,
status: "degraded",
status_reason: "OpenAI usage metrics could not be loaded.",
checked_at: "2026-04-08T08:45:00.000Z",
usage_metrics_error: "Quota table unavailable",
},
],
}),
});
const wrapper = mountWithApp(SystemStatusDashboard, {
messages: { en: enMessages },
});
await flushDashboardLoad();
await openDashboardTab(wrapper, "modules");
const usageError = wrapper.get('[data-testid="module-usage-error-openai"]');
expect(usageError.classes()).toContain("module-card__usage");
expect(usageError.text()).toContain("Usage unavailable");
expect(usageError.text()).toContain("Quota table unavailable");
expect(wrapper.find('[data-testid="module-usage-openai-api_calls"]').exists()).toBe(false);
wrapper.unmount();
});
it("shows stale state when the snapshot ages past the polling threshold", async () => { it("shows stale state when the snapshot ages past the polling threshold", async () => {
installDashboardMocks({ installDashboardMocks({
snapshot: createSnapshot({ snapshot: createSnapshot({