Improve superuser invoicing and system status views
This commit is contained in:
@@ -11,6 +11,36 @@ on:
|
||||
description: Store build number/version code
|
||||
required: false
|
||||
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:
|
||||
tags:
|
||||
- "mobile-v*"
|
||||
@@ -26,40 +56,81 @@ permissions:
|
||||
contents: read
|
||||
|
||||
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
|
||||
|
||||
jobs:
|
||||
android:
|
||||
name: Android AAB
|
||||
name: Android AAB and Play upload
|
||||
if: >
|
||||
github.event_name != 'workflow_run' ||
|
||||
(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: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-24.04
|
||||
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:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Setup Java
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
|
||||
- name: Setup Android SDK
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install Android SDK packages
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -67,10 +138,11 @@ jobs:
|
||||
sdkmanager "platforms;android-36" "build-tools;36.0.0"
|
||||
|
||||
- name: Resolve mobile version
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION_NAME: ${{ inputs.version_name }}
|
||||
INPUT_VERSION_CODE: ${{ inputs.version_code }}
|
||||
INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
|
||||
INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_name="$INPUT_VERSION_NAME"
|
||||
@@ -84,10 +156,22 @@ jobs:
|
||||
echo "MOBILE_VERSION_NAME=$version_name" >> "$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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Decode Android signing key
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
@@ -96,10 +180,6 @@ jobs:
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: |
|
||||
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"
|
||||
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"
|
||||
@@ -108,48 +188,98 @@ jobs:
|
||||
echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build and sync Android shell
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: |
|
||||
npm run mobile:android:sync
|
||||
npm run mobile:permissions:check
|
||||
npm run mobile:android:signing:check
|
||||
|
||||
- name: Build signed Android App Bundle
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
working-directory: android
|
||||
run: ./gradlew --no-daemon bundleRelease
|
||||
|
||||
- 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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
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
|
||||
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:
|
||||
name: iOS IPA
|
||||
if: github.event_name != 'workflow_run'
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 60
|
||||
name: iOS IPA and App Store upload
|
||||
if: >
|
||||
github.event_name != 'workflow_run' ||
|
||||
(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:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Resolve mobile version
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
INPUT_VERSION_NAME: ${{ inputs.version_name }}
|
||||
INPUT_VERSION_CODE: ${{ inputs.version_code }}
|
||||
INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
|
||||
INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_name="$INPUT_VERSION_NAME"
|
||||
@@ -163,16 +293,32 @@ jobs:
|
||||
echo "MOBILE_VERSION_NAME=$version_name" >> "$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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build and sync iOS shell
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: |
|
||||
npm run build
|
||||
npx cap sync ios
|
||||
npm run mobile:permissions:check
|
||||
|
||||
- name: Install Apple signing assets
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
|
||||
@@ -182,12 +328,6 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
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"
|
||||
profile_path="$RUNNER_TEMP/app-store.mobileprovision"
|
||||
keychain_path="$RUNNER_TEMP/app-signing.keychain-db"
|
||||
@@ -214,14 +354,35 @@ jobs:
|
||||
echo "IOS_PROFILE_UUID=$profile_uuid" >> "$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
|
||||
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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
run: |
|
||||
xcodebuild \
|
||||
-project ios/App/App.xcodeproj \
|
||||
-scheme App \
|
||||
-project "$IOS_PROJECT_PATH" \
|
||||
-scheme "$IOS_SCHEME" \
|
||||
-configuration Release \
|
||||
-destination "generic/platform=iOS" \
|
||||
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
|
||||
@@ -234,6 +395,7 @@ jobs:
|
||||
CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE"
|
||||
|
||||
- name: Export iOS IPA
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -255,7 +417,7 @@ jobs:
|
||||
<string>$APPLE_TEAM_ID</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>io.truckwash.app</key>
|
||||
<key>$IOS_BUNDLE_ID</key>
|
||||
<string>$IOS_PROFILE_NAME</string>
|
||||
</dict>
|
||||
<key>stripSwiftSymbols</key>
|
||||
@@ -270,15 +432,43 @@ jobs:
|
||||
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
|
||||
-exportPath "$RUNNER_TEMP/ios-export" \
|
||||
-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
|
||||
if: steps.release-guard.outputs.current == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
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
|
||||
if-no-files-found: error
|
||||
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
|
||||
if: always()
|
||||
shell: bash
|
||||
@@ -289,3 +479,6 @@ jobs:
|
||||
if [[ -n "${IOS_PROFILE_UUID:-}" ]]; then
|
||||
rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROFILE_UUID.mobileprovision"
|
||||
fi
|
||||
if [[ -n "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]]; then
|
||||
rm -f "$APP_STORE_CONNECT_API_KEY_PATH"
|
||||
fi
|
||||
|
||||
@@ -15,6 +15,7 @@ dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
dev-dist
|
||||
.playwright-cli/
|
||||
|
||||
# Mobile build and signing outputs
|
||||
/app/build/
|
||||
|
||||
@@ -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`,
|
||||
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
|
||||
|
||||
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
|
||||
|
||||
@@ -16,19 +16,25 @@ Capacitor app.
|
||||
- Category: Business
|
||||
- Price: Free
|
||||
- Initial availability: Denmark
|
||||
- Keep the GitHub environment `app-store-production` protected and store iOS
|
||||
signing plus App Store Connect API secrets there.
|
||||
- Keep the GitHub environment `mobile-store-production` configured with the
|
||||
iOS signing, App Store Connect, Android signing, and Google Play upload
|
||||
secrets used by the mobile workflow.
|
||||
|
||||
## Build And Upload
|
||||
|
||||
1. Merge the release commit to `master`.
|
||||
2. Confirm `Automated Tests` and `Frontend Release` are green for that commit.
|
||||
3. Create a release tag such as `mobile-v1.0.0`.
|
||||
4. The `Mobile Store Artifacts` workflow builds Android and iOS artifacts. For
|
||||
iOS, it archives, exports, validates the IPA with App Store Connect, and
|
||||
uploads it when the run is tag-triggered.
|
||||
5. For a manual upload, dispatch `Mobile Store Artifacts` with
|
||||
`upload_to_app_store=true`, `version_name`, and `version_code`.
|
||||
4. The `Mobile Store Artifacts` workflow builds Android and iOS artifacts from
|
||||
the tested commit. By default it uploads Android to the Google Play
|
||||
production track and uploads the iOS IPA to App Store Connect.
|
||||
5. For a manual upload, dispatch `Mobile Store Artifacts` with `version_name`
|
||||
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:
|
||||
|
||||
@@ -41,6 +47,12 @@ The iOS workflow expects these environment secrets:
|
||||
- `APP_STORE_CONNECT_ISSUER_ID`
|
||||
- `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
|
||||
|
||||
- Support URL: `https://truckwash.io/support`
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
# 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`.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
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_KEYSTORE_BASE64`
|
||||
- `ANDROID_KEYSTORE_PASSWORD`
|
||||
- `ANDROID_KEY_ALIAS`
|
||||
- `ANDROID_KEY_PASSWORD`
|
||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64`
|
||||
|
||||
iOS:
|
||||
|
||||
@@ -26,6 +44,11 @@ iOS:
|
||||
- `IOS_PROVISION_PROFILE_BASE64`
|
||||
- `IOS_KEYCHAIN_PASSWORD`
|
||||
- `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
|
||||
|
||||
@@ -57,6 +80,21 @@ The signed Android bundle is written to:
|
||||
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`.
|
||||
|
||||
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
|
||||
|
||||
@@ -61,9 +61,11 @@
|
||||
"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: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: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:play-upload": "node scripts/mobile/upload-google-play.mjs",
|
||||
"mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check",
|
||||
"playstore:graphics": "node scripts/playstore/generate-graphics.mjs"
|
||||
},
|
||||
|
||||
@@ -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.");
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
getSuperuserSystemStatus,
|
||||
} 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 {
|
||||
listEdgeGatewayDepartments,
|
||||
listEdgeGateways,
|
||||
unwrapEdgeGatewayMeta,
|
||||
} from "@/services/edgeGateways.js";
|
||||
import { listEdgeGatewayDepartments, listEdgeGateways, unwrapEdgeGatewayMeta } from "@/services/edgeGateways.js";
|
||||
|
||||
const { t, te, locale } = useI18n();
|
||||
|
||||
const MAX_GATEWAY_CARDS = 8;
|
||||
const DEFAULT_DASHBOARD_TAB = "infrastructure";
|
||||
const MODULE_PLACEHOLDER_CARDS = [0, 1, 2];
|
||||
const gatewayStatusPriority = Object.freeze({
|
||||
OFFLINE: 0,
|
||||
DEGRADED: 1,
|
||||
@@ -80,6 +78,8 @@ const gatewayDepartments = ref({});
|
||||
const gatewayDepartmentsLoaded = ref(false);
|
||||
const activeDashboardTab = ref(DEFAULT_DASHBOARD_TAB);
|
||||
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(() => [
|
||||
DEFAULT_DASHBOARD_TAB,
|
||||
...(showGatewaySection.value ? ["gateways"] : []),
|
||||
@@ -192,7 +192,7 @@ const isStale = computed(() => {
|
||||
if (!lastLoadedAt.value) {
|
||||
return false;
|
||||
}
|
||||
return nowTick.value - lastLoadedAt.value.getTime() > (refreshAfterSeconds.value * 2000);
|
||||
return nowTick.value - lastLoadedAt.value.getTime() > refreshAfterSeconds.value * 2000;
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -206,10 +206,7 @@ watch(
|
||||
);
|
||||
|
||||
const loadStatus = async ({ force = false } = {}) => {
|
||||
const [snapshotValue] = await Promise.all([
|
||||
getSuperuserSystemStatus({ force }),
|
||||
loadGatewayFleet({ force }),
|
||||
]);
|
||||
const [snapshotValue] = await Promise.all([getSuperuserSystemStatus({ force }), loadGatewayFleet({ force })]);
|
||||
|
||||
return snapshotValue;
|
||||
};
|
||||
@@ -330,6 +327,10 @@ function moduleReasonText(module) {
|
||||
);
|
||||
}
|
||||
|
||||
function systemStatusErrorMessage() {
|
||||
return error.value?.message || t("system_status.states.error_generic");
|
||||
}
|
||||
|
||||
function createEmptyGatewayFleetUsage() {
|
||||
return {
|
||||
total: 0,
|
||||
@@ -408,12 +409,7 @@ async function ensureGatewayDepartmentsLoaded() {
|
||||
}
|
||||
|
||||
function warningText(warning) {
|
||||
return translateSystemStatusText(
|
||||
"warnings",
|
||||
warning?.key,
|
||||
warning?.params,
|
||||
warning?.message || ""
|
||||
);
|
||||
return translateSystemStatusText("warnings", warning?.key, warning?.params, warning?.message || "");
|
||||
}
|
||||
|
||||
function sessionDisplayName(session) {
|
||||
@@ -443,7 +439,9 @@ function formatDeviceType(deviceType) {
|
||||
}
|
||||
|
||||
function normalizeGatewayStatus(status) {
|
||||
const normalizedStatus = String(status || "").trim().toUpperCase();
|
||||
const normalizedStatus = String(status || "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
return normalizedStatus || "UNKNOWN";
|
||||
}
|
||||
|
||||
@@ -460,16 +458,13 @@ function gatewayToneClass(status) {
|
||||
|
||||
function gatewayStatusLabel(status) {
|
||||
const normalizedStatus = normalizeGatewayStatus(status).toLowerCase();
|
||||
return translateSystemStatusText(
|
||||
"gateways.status",
|
||||
normalizedStatus,
|
||||
{},
|
||||
t("system_status.gateways.status.unknown")
|
||||
);
|
||||
return translateSystemStatusText("gateways.status", normalizedStatus, {}, t("system_status.gateways.status.unknown"));
|
||||
}
|
||||
|
||||
function gatewayDiscoveryLabel(status) {
|
||||
const normalizedStatus = String(status || "unknown").trim().toLowerCase();
|
||||
const normalizedStatus = String(status || "unknown")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return translateSystemStatusText(
|
||||
"gateways.discovery_status",
|
||||
normalizedStatus,
|
||||
@@ -514,7 +509,180 @@ function formatNumber(value) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -682,7 +850,8 @@ function compareGateways(left, right) {
|
||||
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) {
|
||||
return heartbeatDifference;
|
||||
}
|
||||
@@ -749,10 +918,7 @@ function modulePath(key) {
|
||||
<div v-if="warningEntries.length" class="notification is-warning is-light">
|
||||
<strong>{{ $t("system_status.labels.warnings") }}</strong>
|
||||
<ul class="warning-list">
|
||||
<li
|
||||
v-for="warning in warningEntries"
|
||||
:key="`${warning.key || 'warning'}-${warning.message}`"
|
||||
>
|
||||
<li v-for="warning in warningEntries" :key="`${warning.key || 'warning'}-${warning.message}`">
|
||||
{{ warningText(warning) }}
|
||||
</li>
|
||||
</ul>
|
||||
@@ -805,7 +971,6 @@ function modulePath(key) {
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</b-tab-item>
|
||||
|
||||
<b-tab-item v-if="showGatewaySection" value="gateways">
|
||||
@@ -816,10 +981,7 @@ function modulePath(key) {
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<section
|
||||
class="system-status-section"
|
||||
data-testid="system-status-gateways"
|
||||
>
|
||||
<section class="system-status-section" data-testid="system-status-gateways">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h3>{{ $t("system_status.sections.gateways") }}</h3>
|
||||
@@ -851,10 +1013,7 @@ function modulePath(key) {
|
||||
{{ $t("system_status.gateways.error") }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="gatewayLoading && !gatewayCards.length"
|
||||
class="notification is-light gateway-notification"
|
||||
>
|
||||
<div v-if="gatewayLoading && !gatewayCards.length" class="notification is-light gateway-notification">
|
||||
{{ $t("system_status.gateways.loading") }}
|
||||
</div>
|
||||
|
||||
@@ -875,9 +1034,7 @@ function modulePath(key) {
|
||||
</div>
|
||||
|
||||
<div class="gateway-card__meta">
|
||||
<span>
|
||||
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
|
||||
</span>
|
||||
<span> {{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }} </span>
|
||||
<span>
|
||||
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
|
||||
</span>
|
||||
@@ -896,15 +1053,10 @@ function modulePath(key) {
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!gatewayLoading && !gatewayError"
|
||||
class="gateway-empty"
|
||||
data-testid="gateway-empty-state"
|
||||
>
|
||||
<div v-else-if="!gatewayLoading && !gatewayError" class="gateway-empty" data-testid="gateway-empty-state">
|
||||
{{ $t("system_status.gateways.empty") }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</b-tab-item>
|
||||
|
||||
<b-tab-item value="modules">
|
||||
@@ -919,7 +1071,114 @@ function modulePath(key) {
|
||||
<div class="section-heading">
|
||||
<h3>{{ $t("system_status.sections.modules") }}</h3>
|
||||
</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
|
||||
v-for="module in modules"
|
||||
:key="module.key"
|
||||
@@ -928,59 +1187,170 @@ function modulePath(key) {
|
||||
:data-testid="`module-card-${module.key}`"
|
||||
>
|
||||
<div class="module-card__top">
|
||||
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
|
||||
<div class="module-card__identity">
|
||||
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
|
||||
<small
|
||||
class="module-card__version-line"
|
||||
:class="{ 'module-card__version-line--empty': !moduleVersion(module) }"
|
||||
:title="
|
||||
moduleVersion(module) ? `${$t('system_status.labels.version')}: ${moduleVersion(module)}` : null
|
||||
"
|
||||
:aria-hidden="moduleVersion(module) ? null : 'true'"
|
||||
:data-testid="`module-version-${module.key}`"
|
||||
>
|
||||
<template v-if="moduleVersion(module)">
|
||||
{{ $t("system_status.labels.version") }}: {{ moduleVersion(module) }}
|
||||
</template>
|
||||
</small>
|
||||
</div>
|
||||
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
|
||||
</div>
|
||||
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
|
||||
<p
|
||||
class="module-card__reason module-card__reason--text"
|
||||
:title="moduleReasonText(module)"
|
||||
:data-testid="`module-reason-${module.key}`"
|
||||
>
|
||||
{{ moduleReasonText(module) }}
|
||||
</p>
|
||||
<div class="module-card__meta">
|
||||
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
|
||||
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
|
||||
<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"
|
||||
v-if="showModuleUsageSkeleton(module)"
|
||||
class="module-card__usage-list"
|
||||
:data-testid="`module-usage-${module.key}`"
|
||||
>
|
||||
<div class="module-card__usage-top">
|
||||
<span>{{ $t("system_status.labels.quota_usage") }}</span>
|
||||
<strong>{{ formatUsage(module.usage.usage_percent) }}</strong>
|
||||
</div>
|
||||
<progress
|
||||
class="progress module-card__usage-progress"
|
||||
:class="usageProgressClass(module.usage.usage_percent)"
|
||||
:value="boundedUsagePercent(module.usage.usage_percent)"
|
||||
max="100"
|
||||
<div
|
||||
class="module-card__usage module-card__usage--loading"
|
||||
:data-testid="`module-usage-skeleton-${module.key}`"
|
||||
>
|
||||
{{ 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 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>
|
||||
<small v-if="module.usage.version" class="module-card__usage-version">
|
||||
{{ $t("system_status.labels.version") }}: {{ module.usage.version }}
|
||||
</small>
|
||||
</div>
|
||||
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
|
||||
{{ $t("system_status.actions.open_config") }}
|
||||
</RouterLink>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</b-tab-item>
|
||||
|
||||
<b-tab-item value="sessions">
|
||||
@@ -1011,7 +1381,10 @@ function modulePath(key) {
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
|
||||
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
|
||||
@@ -1034,7 +1407,6 @@ function modulePath(key) {
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</b-tab-item>
|
||||
</b-tabs>
|
||||
</div>
|
||||
@@ -1099,7 +1471,6 @@ function modulePath(key) {
|
||||
}
|
||||
|
||||
.status-card-grid,
|
||||
.module-grid,
|
||||
.gateway-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -1107,6 +1478,13 @@ function modulePath(key) {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.module-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.gateway-summary-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
@@ -1158,7 +1536,6 @@ function modulePath(key) {
|
||||
.status-card__secondary,
|
||||
.status-card__detail,
|
||||
.module-card small,
|
||||
.module-card__meta,
|
||||
.session-context {
|
||||
color: #475569;
|
||||
}
|
||||
@@ -1170,8 +1547,10 @@ function modulePath(key) {
|
||||
.module-card {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
grid-template-rows: 3.55rem 5.1rem 10.8rem 2.35rem;
|
||||
height: 100%;
|
||||
align-content: start;
|
||||
min-height: 26.35rem;
|
||||
align-content: stretch;
|
||||
}
|
||||
|
||||
.gateway-card {
|
||||
@@ -1188,6 +1567,8 @@ function modulePath(key) {
|
||||
justify-content: stretch;
|
||||
column-gap: 0.75rem;
|
||||
row-gap: 0.35rem;
|
||||
min-height: 3.55rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gateway-card__top {
|
||||
@@ -1206,6 +1587,32 @@ function modulePath(key) {
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.35;
|
||||
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 {
|
||||
@@ -1230,12 +1637,20 @@ function modulePath(key) {
|
||||
color: #475569;
|
||||
line-height: 1.65;
|
||||
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;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
gap: 0.35rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.gateway-card__meta {
|
||||
@@ -1252,6 +1667,34 @@ function modulePath(key) {
|
||||
border: 1px solid #d7dde7;
|
||||
border-radius: 8px;
|
||||
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 {
|
||||
@@ -1291,8 +1734,102 @@ function modulePath(key) {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.module-card__usage-version {
|
||||
color: #64748b;
|
||||
.module-card__usage-warning {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
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: {
|
||||
details: async (collectedInvoiceId) => {
|
||||
return authenticatedRequest('/collected-invoices/economic/v2/details', 'GET', {
|
||||
|
||||
@@ -4292,6 +4292,196 @@
|
||||
"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?",
|
||||
"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": {
|
||||
|
||||
@@ -4402,6 +4402,196 @@
|
||||
"split_success_title": "Monatsaufteilung abgeschlossen",
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -4123,6 +4123,196 @@
|
||||
"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?",
|
||||
"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": {
|
||||
|
||||
@@ -2145,8 +2145,8 @@
|
||||
"loading": "Loading cron tasks...",
|
||||
"messages": {
|
||||
"queued": "{task} was queued for the cron worker.",
|
||||
"worker_deploy_queued": "Cron worker deployment was queued.",
|
||||
"worker_update_queued": "Cron worker deployment update was queued."
|
||||
"worker_deploy_queued": "Cron worker deployment was queued (#{id}).",
|
||||
"worker_update_queued": "Cron worker deployment update was queued (#{id})."
|
||||
},
|
||||
"nav": "@:{'cron.title'}",
|
||||
"replication": "Replication",
|
||||
@@ -2156,12 +2156,18 @@
|
||||
"enabled": "@:common.enabled"
|
||||
},
|
||||
"status": {
|
||||
"degraded": "Degraded",
|
||||
"deployed": "Deployed",
|
||||
"deploying": "Deploying",
|
||||
"failed": "Failed",
|
||||
"healthy": "Healthy",
|
||||
"needs_deploy": "Needs deploy",
|
||||
"queued": "Queued",
|
||||
"running": "Running",
|
||||
"skipped": "Skipped",
|
||||
"succeeded": "Succeeded",
|
||||
"timed_out": "Timed out",
|
||||
"waiting_for_heartbeat": "Waiting for heartbeat",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"subtitle": "Configure schedules, run tasks manually, and inspect recent cron execution.",
|
||||
@@ -2184,15 +2190,22 @@
|
||||
},
|
||||
"title": "Cron tasks",
|
||||
"workers": {
|
||||
"api_target": "API target",
|
||||
"deploy": "Deploy worker",
|
||||
"deploy_to_coolify": "Deploy to Coolify",
|
||||
"deployment": "Deployment",
|
||||
"deployment_label": "{status} #{id}",
|
||||
"empty": "No cron workers found.",
|
||||
"empty_with_target": "Cron worker deployment target exists, but no worker heartbeat has been recorded yet.",
|
||||
"heartbeat": "Heartbeat",
|
||||
"last_run_count": "Last run count",
|
||||
"latest_deployment": "Latest deployment",
|
||||
"loading": "Loading cron workers...",
|
||||
"name": "Worker",
|
||||
"repair_deployment": "Repair Coolify deployment",
|
||||
"running": "@:{'cron.status.running'}",
|
||||
"source": "@:{'cron.history.source'}",
|
||||
"state": "State",
|
||||
"stale": "Stale",
|
||||
"status": "@:{'cron.history.status'}",
|
||||
"target": "@:{'security.firewall.target'}",
|
||||
@@ -3448,6 +3461,196 @@
|
||||
"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'}",
|
||||
"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": {
|
||||
@@ -5934,15 +6137,21 @@
|
||||
"configured": "@:{'templates.generated.compat.system_status.labels.configured'}",
|
||||
"buckets": "@.capitalize:{'words.generated.buckets'}",
|
||||
"database_index": "@:replication.fields.database_index",
|
||||
"detail": "Detail",
|
||||
"enabled": "@:{'templates.generated.compat.system_status.labels.enabled'}",
|
||||
"endpoint": "@.capitalize:{'words.generated.endpoint'}",
|
||||
"latency": "@:{'templates.generated.compat.system_status.labels.latency'}",
|
||||
"no_reason": "@:{'templates.generated.compat.system_status.labels.no_reason'}",
|
||||
"quota_calls": "@:{'templates.generated.compat.system_status.labels.quota_calls'}",
|
||||
"quota_usage": "@:{'templates.generated.compat.system_status.labels.quota_usage'}",
|
||||
"limit": "Limit",
|
||||
"remaining": "Remaining",
|
||||
"replication_percent": "@:{'templates.generated.compat.system_status.labels.replication_percent'}",
|
||||
"runtime_source": "@:{'templates.generated.compat.system_status.labels.runtime_source'}",
|
||||
"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'}",
|
||||
"warnings": "@:{'templates.generated.compat.system_status.labels.warnings'}"
|
||||
},
|
||||
@@ -5968,6 +6177,12 @@
|
||||
"workfeed": "@:{'templates.generated.compat.system_status.modules.workfeed'}",
|
||||
"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": {
|
||||
"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'}",
|
||||
@@ -5992,6 +6207,7 @@
|
||||
"licenseplaterecognizer_usage_unreadable": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_usage_unreadable'}",
|
||||
"missing_config": "@:{'templates.generated.compat.system_status.reasons.missing_config'}",
|
||||
"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_unreadable_payload": "@:{'templates.generated.compat.system_status.reasons.recaptcha_unreadable_payload'}",
|
||||
"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_generic": "@:{'templates.generated.compat.system_status.states.error_generic'}",
|
||||
"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'}",
|
||||
"stale": "@:{'templates.generated.compat.system_status.states.stale'}"
|
||||
},
|
||||
|
||||
@@ -4405,6 +4405,196 @@
|
||||
"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?",
|
||||
"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": {
|
||||
|
||||
@@ -4455,6 +4455,196 @@
|
||||
"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?",
|
||||
"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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,8 @@
|
||||
"loading": "Loading cron tasks...",
|
||||
"messages": {
|
||||
"queued": "{task} was queued for the cron worker.",
|
||||
"worker_deploy_queued": "Cron worker deployment was queued.",
|
||||
"worker_update_queued": "Cron worker deployment update was queued."
|
||||
"worker_deploy_queued": "Cron worker deployment was queued (#{id}).",
|
||||
"worker_update_queued": "Cron worker deployment update was queued (#{id})."
|
||||
},
|
||||
"nav": "@:{'cron.title'}",
|
||||
"replication": "Replication",
|
||||
@@ -44,12 +44,18 @@
|
||||
"enabled": "@:common.enabled"
|
||||
},
|
||||
"status": {
|
||||
"degraded": "Degraded",
|
||||
"deployed": "Deployed",
|
||||
"deploying": "Deploying",
|
||||
"failed": "Failed",
|
||||
"healthy": "Healthy",
|
||||
"needs_deploy": "Needs deploy",
|
||||
"queued": "Queued",
|
||||
"running": "Running",
|
||||
"skipped": "Skipped",
|
||||
"succeeded": "Succeeded",
|
||||
"timed_out": "Timed out",
|
||||
"waiting_for_heartbeat": "Waiting for heartbeat",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"subtitle": "Configure schedules, run tasks manually, and inspect recent cron execution.",
|
||||
@@ -72,15 +78,22 @@
|
||||
},
|
||||
"title": "Cron tasks",
|
||||
"workers": {
|
||||
"api_target": "API target",
|
||||
"deploy": "Deploy worker",
|
||||
"deploy_to_coolify": "Deploy to Coolify",
|
||||
"deployment": "Deployment",
|
||||
"deployment_label": "{status} #{id}",
|
||||
"empty": "No cron workers found.",
|
||||
"empty_with_target": "Cron worker deployment target exists, but no worker heartbeat has been recorded yet.",
|
||||
"heartbeat": "Heartbeat",
|
||||
"last_run_count": "Last run count",
|
||||
"latest_deployment": "Latest deployment",
|
||||
"loading": "Loading cron workers...",
|
||||
"name": "Worker",
|
||||
"repair_deployment": "Repair Coolify deployment",
|
||||
"running": "@:{'cron.status.running'}",
|
||||
"source": "@:{'cron.history.source'}",
|
||||
"state": "State",
|
||||
"stale": "Stale",
|
||||
"status": "@:{'cron.history.status'}",
|
||||
"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'}",
|
||||
"buckets": "@.capitalize:{'terms.glossary.buckets'}",
|
||||
"database_index": "@:replication.fields.database_index",
|
||||
"detail": "Detail",
|
||||
"enabled": "@:{'phrases.compat.system_status.labels.enabled'}",
|
||||
"endpoint": "@.capitalize:{'terms.glossary.endpoint'}",
|
||||
"latency": "@:{'phrases.compat.system_status.labels.latency'}",
|
||||
"no_reason": "@:{'phrases.compat.system_status.labels.no_reason'}",
|
||||
"quota_calls": "@:{'phrases.compat.system_status.labels.quota_calls'}",
|
||||
"quota_usage": "@:{'phrases.compat.system_status.labels.quota_usage'}",
|
||||
"limit": "Limit",
|
||||
"remaining": "Remaining",
|
||||
"replication_percent": "@:{'phrases.compat.system_status.labels.replication_percent'}",
|
||||
"runtime_source": "@:{'phrases.compat.system_status.labels.runtime_source'}",
|
||||
"server_version": "@:{'phrases.compat.system_status.labels.server_version'}",
|
||||
"status": "Status",
|
||||
"used": "Used",
|
||||
"usage_unavailable": "Usage unavailable",
|
||||
"version": "@:{'phrases.compat.system_status.labels.version'}",
|
||||
"warnings": "@:{'phrases.compat.system_status.labels.warnings'}"
|
||||
},
|
||||
@@ -98,6 +104,12 @@
|
||||
"workfeed": "@:{'phrases.compat.system_status.modules.workfeed'}",
|
||||
"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": {
|
||||
"backup_connectivity_confirmed": "@:{'phrases.compat.system_status.reasons.backup_connectivity_confirmed'}",
|
||||
"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'}",
|
||||
"missing_config": "@:{'phrases.compat.system_status.reasons.missing_config'}",
|
||||
"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_unreadable_payload": "@:{'phrases.compat.system_status.reasons.recaptcha_unreadable_payload'}",
|
||||
"recaptcha_validation_errors": "@:{'phrases.compat.system_status.reasons.recaptcha_validation_errors'}",
|
||||
@@ -157,6 +170,8 @@
|
||||
"error": "@:{'phrases.compat.system_status.states.error'}",
|
||||
"error_generic": "@:{'phrases.compat.system_status.states.error_generic'}",
|
||||
"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'}",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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));
|
||||
@@ -8,7 +8,11 @@ export const listCronRuns = ({ taskId = null, limit = 50 } = {}) =>
|
||||
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 = {}) =>
|
||||
authenticatedRequest("/superuser/cron/workers/deploy", "POST", payload);
|
||||
|
||||
+3021
File diff suppressed because it is too large
Load Diff
+474
@@ -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;
|
||||
};
|
||||
+6
-14
@@ -21,6 +21,7 @@ import InvoicingBillingPeriodStatistics from "@/views/dashboards/superUserDashbo
|
||||
import InvoicingBillingPeriodFilters from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodFilters.vue";
|
||||
import InvoicingBillingPeriodCustomerAttributes from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.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 {
|
||||
buildMultiMonthInvoiceContext,
|
||||
@@ -1254,26 +1255,17 @@ const getTransactionQueryParameters = () => {
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<InvoiceOrdersPagination
|
||||
:set-customer-filter="customer.customer_number"
|
||||
:hide-search="true"
|
||||
:hide-filter="true"
|
||||
:invoice-view="true"
|
||||
:apply-default-filters="false"
|
||||
:group-invoice-collection="true"
|
||||
:limit-results="false"
|
||||
:hide-pagination="true"
|
||||
<InvoicingPeriodObjectTree
|
||||
:customer="customer"
|
||||
:transactions="getTransactionsInView(customer)"
|
||||
:excluded-order-ids="getExcludedTransactionIds(customer)"
|
||||
:auto-expand-all="view.variables.currentView.value === 'invoice_per_order'"
|
||||
:query-parameters="getTransactionQueryParameters()"
|
||||
:dates="{
|
||||
dateFrom: dates.computed.formattedStartDate.value,
|
||||
dateTo: dates.computed.formattedEndDate.value,
|
||||
}"
|
||||
:show-only-with-ids="getTransactionIds(customer)"
|
||||
:excluded-order-ids="getExcludedTransactionIds(customer)"
|
||||
:invoice-period-flags="getCustomerScopedFlags(customer)"
|
||||
@flagStatusChanged="(flag) => onFlagStatusChanged(customer, flag)"
|
||||
@flagCreated="(flag) => onFlagCreated(customer, flag)"
|
||||
@refresh="reloadPeriodPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import ConfigurationSelect from "@/components/displays/superuser/configuration/C
|
||||
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
|
||||
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
|
||||
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
|
||||
import ModuleUsageMeter from "@/components/displays/superuser/configuration/ModuleUsageMeter.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { t } = useI18n();
|
||||
@@ -170,6 +171,7 @@ const showConversionRates = () => {
|
||||
:value="parseInt(getModuleConfigValue('daily_limit'))"
|
||||
:on-save="SessionUser.superUser.modules.fxratesapi.config.keys.daily_limit.set"
|
||||
/>
|
||||
<ModuleUsageMeter module-key="fxratesapi" metric-key="rate_fetch_calls" />
|
||||
</ConfigurationCategory>
|
||||
<div class="buttons">
|
||||
<button class="button is-dark" @click="showGetConversionRate">
|
||||
@@ -192,4 +194,4 @@ const showConversionRates = () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,7 @@ import ConfigurationSelect from "@/components/displays/superuser/configuration/C
|
||||
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
|
||||
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
|
||||
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
|
||||
import ModuleUsageMeter from "@/components/displays/superuser/configuration/ModuleUsageMeter.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { t } = useI18n();
|
||||
@@ -162,6 +163,7 @@ const showGetLicensePlate = () => {
|
||||
:value="parseInt(getModuleConfigValue('daily_limit'))"
|
||||
:on-save="SessionUser.superUser.modules.motorapi.config.keys.daily_limit.set"
|
||||
/>
|
||||
<ModuleUsageMeter module-key="motorapi" metric-key="lookup_calls" />
|
||||
</ConfigurationCategory>
|
||||
<button class="button is-dark" @click="showGetLicensePlate">
|
||||
<span class="icon">
|
||||
@@ -176,4 +178,4 @@ const showGetLicensePlate = () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,7 @@ import ConfigurationSelect from "@/components/displays/superuser/configuration/C
|
||||
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
|
||||
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
|
||||
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
|
||||
import ModuleUsageMeter from "@/components/displays/superuser/configuration/ModuleUsageMeter.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { t } = useI18n();
|
||||
@@ -194,6 +195,7 @@ const showSearchCompany = () => {
|
||||
:value="parseInt(getModuleConfigValue('monthly_limit'))"
|
||||
:on-save="SessionUser.superUser.modules.virkdata.config.keys.monthly_limit.set"
|
||||
/>
|
||||
<ModuleUsageMeter module-key="virkdata" metric-key="company_search_calls" />
|
||||
</ConfigurationCategory>
|
||||
<div class="buttons">
|
||||
<button class="button is-dark" @click="showSearchCompany">
|
||||
@@ -210,4 +212,4 @@ const showSearchCompany = () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -17,13 +17,19 @@ import {
|
||||
const { t, locale } = useI18n();
|
||||
const RUN_POLL_INTERVAL_MS = 2500;
|
||||
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 WORKER_PENDING_STATES = new Set(["deploying", "waiting_for_heartbeat"]);
|
||||
|
||||
const tasks = ref([]);
|
||||
const runs = ref([]);
|
||||
const workers = ref([]);
|
||||
const workerStatus = ref(null);
|
||||
const workerChannels = ref([]);
|
||||
const workerSummary = ref({ total: 0, running: 0, stale: 0 });
|
||||
const workerDeployment = ref(null);
|
||||
const selectedWorkerChannelId = ref(null);
|
||||
const summary = ref({ total: 0, enabled: 0, due: 0 });
|
||||
const intervalDrafts = ref({});
|
||||
const loading = ref(false);
|
||||
@@ -55,6 +61,9 @@ const nextTask = 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_resource_uuid ||
|
||||
workerDeployment.value?.target?.id ||
|
||||
@@ -62,12 +71,48 @@ const workerTargetLabel = computed(
|
||||
);
|
||||
const hasWorkerDeploymentTarget = computed(
|
||||
() =>
|
||||
Boolean(workerStatus.value?.cron_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_resource_uuid)
|
||||
);
|
||||
const workerDeploymentActionLabel = computed(() =>
|
||||
hasWorkerDeploymentTarget.value ? t("cron.workers.update_deployment") : t("cron.workers.deploy_to_coolify")
|
||||
const workerDeploymentAction = computed(
|
||||
() =>
|
||||
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;
|
||||
@@ -107,10 +152,21 @@ async function loadRuns() {
|
||||
async function loadWorkers() {
|
||||
workersLoading.value = true;
|
||||
try {
|
||||
const data = responseData(await listCronWorkers(), {});
|
||||
const data = responseData(
|
||||
await listCronWorkers({
|
||||
channelId: selectedWorkerChannelId.value,
|
||||
includeProvider: true,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
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;
|
||||
}
|
||||
} finally {
|
||||
workersLoading.value = false;
|
||||
}
|
||||
@@ -153,11 +209,22 @@ async function deployWorkers() {
|
||||
queuedMessage.value = "";
|
||||
const updatingDeployment = hasWorkerDeploymentTarget.value;
|
||||
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();
|
||||
const deploymentId = data.deployment?.id || data.applied?.[0]?.deployment_id || null;
|
||||
queuedMessage.value = updatingDeployment
|
||||
? t("cron.messages.worker_update_queued")
|
||||
: t("cron.messages.worker_deploy_queued");
|
||||
? t("cron.messages.worker_update_queued", { id: deploymentId || t("cron.empty_value") })
|
||||
: t("cron.messages.worker_deploy_queued", { id: deploymentId || t("cron.empty_value") });
|
||||
void pollWorkerDeployment();
|
||||
} catch (error) {
|
||||
errorMessage.value = parseError(error);
|
||||
} 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) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
pollTimeouts.delete(timeout);
|
||||
resolve();
|
||||
}, RUN_POLL_INTERVAL_MS);
|
||||
}, delay);
|
||||
pollTimeouts.add(timeout);
|
||||
});
|
||||
}
|
||||
@@ -286,9 +378,24 @@ function scheduleLabel(task) {
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
if (status === "deployed") {
|
||||
return t("cron.status.deployed");
|
||||
}
|
||||
if (status === "deploying") {
|
||||
return t("cron.status.deploying");
|
||||
}
|
||||
if (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") {
|
||||
return t("cron.status.queued");
|
||||
}
|
||||
@@ -304,25 +411,58 @@ function statusLabel(status) {
|
||||
if (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");
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === "succeeded") {
|
||||
if (status === "succeeded" || status === "healthy" || status === "deployed") {
|
||||
return "is-success";
|
||||
}
|
||||
if (status === "failed" || status === "timed_out") {
|
||||
return "is-danger";
|
||||
}
|
||||
if (status === "running") {
|
||||
if (status === "running" || status === "deploying" || status === "waiting_for_heartbeat") {
|
||||
return "is-info";
|
||||
}
|
||||
if (status === "queued") {
|
||||
if (status === "queued" || status === "degraded" || status === "needs_deploy") {
|
||||
return "is-warning";
|
||||
}
|
||||
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) {
|
||||
return formatDate(run.started_at || run.scheduled_for || run.created_at);
|
||||
}
|
||||
@@ -474,17 +614,45 @@ onBeforeUnmount(() => {
|
||||
|
||||
<section class="cron-worker-panel" data-testid="cron-worker-panel">
|
||||
<div class="cron-section-heading">
|
||||
<h2>{{ t("cron.workers.title") }}</h2>
|
||||
<button
|
||||
class="button is-small is-light"
|
||||
type="button"
|
||||
:disabled="!canDeployWorkers || deployingWorker"
|
||||
:class="{ 'is-loading': deployingWorker }"
|
||||
@click="deployWorkers"
|
||||
data-testid="cron-worker-deploy"
|
||||
<div>
|
||||
<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
|
||||
class="button is-small is-light"
|
||||
type="button"
|
||||
:disabled="!canDeployWorkers || deployingWorker || !workerDeploymentCanDeploy"
|
||||
:class="{ 'is-loading': deployingWorker }"
|
||||
@click="deployWorkers"
|
||||
data-testid="cron-worker-deploy"
|
||||
>
|
||||
{{ workerDeploymentActionLabel }}
|
||||
</button>
|
||||
</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)"
|
||||
>
|
||||
{{ workerDeploymentActionLabel }}
|
||||
</button>
|
||||
{{ issue.message }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="cron-stats" aria-live="polite">
|
||||
<div class="cron-stat" data-testid="cron-worker-total">
|
||||
@@ -499,10 +667,44 @@ onBeforeUnmount(() => {
|
||||
<span>{{ t("cron.workers.stale") }}</span>
|
||||
<strong>{{ workerSummary.stale ?? 0 }}</strong>
|
||||
</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">
|
||||
<span>{{ t("cron.workers.target") }}</span>
|
||||
<strong>{{ workerTargetLabel }}</strong>
|
||||
</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 class="table-container cron-table-container">
|
||||
<table class="table is-fullwidth is-striped">
|
||||
@@ -520,7 +722,13 @@ onBeforeUnmount(() => {
|
||||
<td colspan="5">{{ t("cron.workers.loading") }}</td>
|
||||
</tr>
|
||||
<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 v-for="worker in workers" :key="worker.worker_id">
|
||||
<td>
|
||||
@@ -629,6 +837,33 @@ onBeforeUnmount(() => {
|
||||
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 {
|
||||
border: 1px solid #d7dde5;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -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({
|
||||
resolvedManualFlagIds = [],
|
||||
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) {
|
||||
await expect(locator).toBeVisible();
|
||||
const box = await locator.boundingBox();
|
||||
@@ -1562,6 +1675,78 @@ test.describe("Invoicing period tab", () => {
|
||||
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 }) => {
|
||||
await openPeriodView(page);
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ test.describe("Superuser cron operations", () => {
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
status: 202,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(cronListPayload(currentTasks)),
|
||||
});
|
||||
@@ -94,7 +94,7 @@ test.describe("Superuser cron operations", () => {
|
||||
|
||||
await page.route(apiPathPattern("/superuser/cron/runs"), async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
status: 202,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
@@ -118,6 +118,31 @@ test.describe("Superuser cron operations", () => {
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
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: [
|
||||
{
|
||||
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_run_count: 1,
|
||||
commit_sha: "abcdef1234567890",
|
||||
release_channel_id: 1,
|
||||
release_target_id: 71,
|
||||
coolify_resource_uuid: "cron-worker-uuid",
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
total: 1,
|
||||
running: 1,
|
||||
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: {
|
||||
ok: true,
|
||||
state: "healthy",
|
||||
channel: {
|
||||
id: 1,
|
||||
slug: "stable",
|
||||
name: "Stable",
|
||||
},
|
||||
api_target: {
|
||||
id: 17,
|
||||
app: "api",
|
||||
},
|
||||
target: {
|
||||
id: 71,
|
||||
app: "cron",
|
||||
coolify_service_uuid: "cron-worker-uuid",
|
||||
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: {},
|
||||
@@ -166,12 +226,63 @@ test.describe("Superuser cron operations", () => {
|
||||
success: true,
|
||||
data: {
|
||||
ok: true,
|
||||
deployment: {
|
||||
id: 89,
|
||||
app: "cron",
|
||||
status: "deployed",
|
||||
provider_operation_id: "deploy-89",
|
||||
},
|
||||
target: {
|
||||
id: 71,
|
||||
app: "cron",
|
||||
coolify_service_uuid: "cron-worker-uuid",
|
||||
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: {},
|
||||
includes: {},
|
||||
@@ -253,7 +364,7 @@ test.describe("Superuser cron operations", () => {
|
||||
),
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -320,20 +431,89 @@ test.describe("Superuser cron operations", () => {
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
state: deploymentTarget ? "waiting_for_heartbeat" : "needs_deploy",
|
||||
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: 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,
|
||||
latest_deployment: deploymentTarget
|
||||
? {
|
||||
id: 72,
|
||||
app: "cron",
|
||||
status: "deployed",
|
||||
provider_operation_id: "deploy-72",
|
||||
started_at: "2026-07-09 12:01:00",
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
meta: {},
|
||||
@@ -357,7 +537,54 @@ test.describe("Superuser cron operations", () => {
|
||||
success: true,
|
||||
data: {
|
||||
ok: true,
|
||||
deployment: {
|
||||
id: 72,
|
||||
app: "cron",
|
||||
status: "deployed",
|
||||
provider_operation_id: "deploy-72",
|
||||
},
|
||||
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: {},
|
||||
includes: {},
|
||||
@@ -376,8 +603,253 @@ test.describe("Superuser cron operations", () => {
|
||||
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-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-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("Kald tilbage");
|
||||
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(),
|
||||
title.boundingBox(),
|
||||
pill.boundingBox(),
|
||||
virkdataCard.boundingBox(),
|
||||
usage.boundingBox(),
|
||||
footer.boundingBox(),
|
||||
versionLine.boundingBox(),
|
||||
]);
|
||||
|
||||
expect(cardBox).not.toBeNull();
|
||||
expect(titleBox).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 + 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 + 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(
|
||||
0,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,22 @@ const periodViewAllSource = readFileSync(
|
||||
join(root, "src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue"),
|
||||
"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(
|
||||
join(
|
||||
root,
|
||||
@@ -122,22 +138,22 @@ const localeMessages = ["da", "en", "sv", "de", "no"].map((locale) => ({
|
||||
|
||||
describe("superuser invoices route wiring", () => {
|
||||
it("maps /superuser/invoices to CollectedOrderInvoices view", () => {
|
||||
expect(routerSource).toContain("name: 'collectedorderinvoices'");
|
||||
expect(routerSource).toContain("path: '/superuser/invoices'");
|
||||
expect(routerSource).toMatch(/name:\s*["']collectedorderinvoices["']/);
|
||||
expect(routerSource).toMatch(/path:\s*["']\/superuser\/invoices["']/);
|
||||
expect(routerSource).toContain("component: CollectedOrderInvoices");
|
||||
});
|
||||
|
||||
it("maps monthly distribution routes to InvoiceDistributionMonthView", () => {
|
||||
expect(routerSource).toContain("name: 'collectedorderinvoicesdistribution'");
|
||||
expect(routerSource).toContain("path: '/superuser/invoices/distribution/:year/:month'");
|
||||
expect(routerSource).toContain("name: 'collectedorderinvoicesdistributiontab'");
|
||||
expect(routerSource).toContain("path: '/superuser/invoices/distribution/:year/:month/:tab'");
|
||||
expect(routerSource).toMatch(/name:\s*["']collectedorderinvoicesdistribution["']/);
|
||||
expect(routerSource).toMatch(/path:\s*["']\/superuser\/invoices\/distribution\/:year\/:month["']/);
|
||||
expect(routerSource).toMatch(/name:\s*["']collectedorderinvoicesdistributiontab["']/);
|
||||
expect(routerSource).toMatch(/path:\s*["']\/superuser\/invoices\/distribution\/:year\/:month\/:tab["']/);
|
||||
expect(routerSource).toContain("component: InvoiceDistributionMonthView");
|
||||
});
|
||||
|
||||
it("keeps details route for a single collected invoice", () => {
|
||||
expect(routerSource).toContain("name: 'collectedorderinvoice'");
|
||||
expect(routerSource).toContain("path: '/superuser/invoices/:collectedOrderInvoiceId'");
|
||||
expect(routerSource).toMatch(/name:\s*["']collectedorderinvoice["']/);
|
||||
expect(routerSource).toMatch(/path:\s*["']\/superuser\/invoices\/:collectedOrderInvoiceId["']/);
|
||||
expect(routerSource).toContain("component: CollectedOrderInvoice");
|
||||
});
|
||||
});
|
||||
@@ -606,6 +622,103 @@ describe("Periode tab contract", () => {
|
||||
expect(periodRightSource).toContain('typeName !== "possible_duplicates"');
|
||||
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", () => {
|
||||
|
||||
@@ -111,6 +111,67 @@ const createSnapshot = (overrides = {}) => ({
|
||||
status_reason: "Bird API returned HTTP 503.",
|
||||
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",
|
||||
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", () => {
|
||||
expect(routerSource).toContain("path: '/superuser'");
|
||||
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(superUserDashboardSource).toContain("<SystemStatusDashboard />");
|
||||
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.");
|
||||
|
||||
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"]');
|
||||
expect(usage.text()).toContain("Quota usage");
|
||||
expect(usage.text()).toContain("90.0%");
|
||||
expect(usage.text()).toContain("Calls used");
|
||||
expect(usage.text()).toContain("2,250");
|
||||
expect(usage.text()).not.toContain("2,250 calls");
|
||||
expect(usage.text()).toContain("Quota");
|
||||
expect(usage.text()).toContain("2,500");
|
||||
expect(usage.text()).not.toContain("2,500 calls");
|
||||
expect(usage.text()).toContain("Calls remaining");
|
||||
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(wrapper.get('[data-testid="module-version-licenseplaterecognizer"]').text()).toBe("Version: 1.54.0");
|
||||
|
||||
await openDashboardTab(wrapper, "sessions");
|
||||
expect(wrapper.text()).toContain("Acme Logistics");
|
||||
@@ -346,6 +452,125 @@ describe("superuser system status dashboard", () => {
|
||||
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 () => {
|
||||
installDashboardMocks({
|
||||
snapshot: createSnapshot({
|
||||
|
||||
Reference in New Issue
Block a user