diff --git a/.github/workflows/mobile-artifacts.yml b/.github/workflows/mobile-artifacts.yml index e5bb8646..6b9e7156 100644 --- a/.github/workflows/mobile-artifacts.yml +++ b/.github/workflows/mobile-artifacts.yml @@ -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: $APPLE_TEAM_ID provisioningProfiles - io.truckwash.app + $IOS_BUNDLE_ID $IOS_PROFILE_NAME stripSwiftSymbols @@ -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 diff --git a/.gitignore b/.gitignore index eb5d6045..7a4d0e96 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist-ssr coverage *.local dev-dist +.playwright-cli/ # Mobile build and signing outputs /app/build/ diff --git a/README.md b/README.md index 9bc12222..acc51a0e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/app-store-release.md b/docs/app-store-release.md index 0b3ac355..fc772c06 100644 --- a/docs/app-store-release.md +++ b/docs/app-store-release.md @@ -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` diff --git a/docs/mobile-artifacts.md b/docs/mobile-artifacts.md index 2004f944..76e06a4e 100644 --- a/docs/mobile-artifacts.md +++ b/docs/mobile-artifacts.md @@ -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 diff --git a/package.json b/package.json index 31155c69..e1240089 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/mobile/check-store-upload-env.mjs b/scripts/mobile/check-store-upload-env.mjs new file mode 100644 index 00000000..cc19ca8e --- /dev/null +++ b/scripts/mobile/check-store-upload-env.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."); diff --git a/scripts/mobile/upload-google-play.mjs b/scripts/mobile/upload-google-play.mjs new file mode 100644 index 00000000..18e5beb8 --- /dev/null +++ b/scripts/mobile/upload-google-play.mjs @@ -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); +}); diff --git a/src/components/buefy/tree/BuefyTree.vue b/src/components/buefy/tree/BuefyTree.vue new file mode 100644 index 00000000..89012aee --- /dev/null +++ b/src/components/buefy/tree/BuefyTree.vue @@ -0,0 +1,339 @@ + + + + + diff --git a/src/components/buefy/tree/BuefyTreeNode.vue b/src/components/buefy/tree/BuefyTreeNode.vue new file mode 100644 index 00000000..864e0529 --- /dev/null +++ b/src/components/buefy/tree/BuefyTreeNode.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/src/components/displays/superuser/configuration/ModuleUsageMeter.vue b/src/components/displays/superuser/configuration/ModuleUsageMeter.vue new file mode 100644 index 00000000..49dfb289 --- /dev/null +++ b/src/components/displays/superuser/configuration/ModuleUsageMeter.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/components/displays/superuser/system/SystemStatusDashboard.vue b/src/components/displays/superuser/system/SystemStatusDashboard.vue index ac630d67..705a25ec 100644 --- a/src/components/displays/superuser/system/SystemStatusDashboard.vue +++ b/src/components/displays/superuser/system/SystemStatusDashboard.vue @@ -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) {
{{ $t("system_status.labels.warnings") }} @@ -805,7 +971,6 @@ function modulePath(key) {
- @@ -816,10 +981,7 @@ function modulePath(key) { -
+

{{ $t("system_status.sections.gateways") }}

@@ -851,10 +1013,7 @@ function modulePath(key) { {{ $t("system_status.gateways.error") }}
-
+
{{ $t("system_status.gateways.loading") }}
@@ -875,9 +1034,7 @@ function modulePath(key) {
- - {{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }} - + {{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }} {{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }} @@ -896,15 +1053,10 @@ function modulePath(key) {
-
+
{{ $t("system_status.gateways.empty") }}
- @@ -919,7 +1071,114 @@ function modulePath(key) {

{{ $t("system_status.sections.modules") }}

-
+
+
+
+
+ + +
+ +
+
+ + +
+
+
+
+ + +
+ +
+ + + +
+
+
+ +
+
+ +
+
+
+
+

{{ $t("system_status.sections.modules") }}

+ +
+ {{ $t("system_status.states.error") }} +
+

+ {{ systemStatusErrorMessage() }} +

+
+
+
+ {{ $t("system_status.labels.usage_unavailable") }} +
+
+ {{ $t("system_status.labels.status") }}: {{ $t("system_status.states.error") }} + {{ systemStatusErrorMessage() }} +
+
+
+ +
+
+ +
-

{{ moduleLabel(module.key) }}

+
+

{{ moduleLabel(module.key) }}

+ + + +
{{ statusLabel(module.status) }}
-

+

{{ moduleReasonText(module) }}

-
- {{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }} - {{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }} - {{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }} -
-
- {{ $t("system_status.labels.quota_usage") }} - {{ formatUsage(module.usage.usage_percent) }} -
- - {{ formatUsage(module.usage.usage_percent) }} - -
- - {{ $t("system_status.labels.calls_used") }} - {{ formatNumber(module.usage.calls_used) }} - - - {{ $t("system_status.labels.quota_calls") }} - {{ formatNumber(module.usage.quota_calls) }} - - - {{ $t("system_status.labels.calls_remaining") }} - {{ formatNumber(module.usage.calls_remaining) }} - +
+ + +
+ +
+ + + +
- - {{ $t("system_status.labels.version") }}: {{ module.usage.version }} -
- - {{ $t("system_status.actions.open_config") }} - +
+
+
+ {{ $t("system_status.labels.usage_unavailable") }} +
+
+ {{ $t("system_status.labels.status") }}: {{ $t("system_status.states.error") }} + {{ moduleUsageErrorMessage(module) }} +
+
+
+
+
+
+ {{ moduleUsageLabel(metric) }} + {{ formatUsage(metric.usage_percent) }} +
+
+ {{ $t("system_status.labels.status") }}: {{ $t("system_status.status.unknown") }} + {{ moduleUsageUnavailableReason(metric) }} +
+ + {{ formatUsage(metric.usage_percent) }} + +
+ + {{ moduleUsageUsedLabel(metric) }} + {{ formatMetricValue(metric.used, metric.unit) }} + + + {{ moduleUsageLimitLabel(metric) }} + {{ formatMetricValue(metric.limit, metric.unit) }} + + + {{ moduleUsageRemainingLabel(metric) }} + {{ formatMetricValue(metric.remaining, metric.unit) }} + +
+
+
+
+
+
+ {{ $t("system_status.labels.usage_unavailable") }} +
+
+ {{ $t("system_status.labels.status") }}: {{ $t("system_status.status.unknown") }} + {{ $t("system_status.states.module_usage_unavailable") }} +
+
+
+
-
@@ -1011,7 +1381,10 @@ function modulePath(key) { - + {{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }} @@ -1034,7 +1407,6 @@ function modulePath(key) { - @@ -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 { diff --git a/src/components/displays/superuser/system/SystemStatusModuleIndicator.vue b/src/components/displays/superuser/system/SystemStatusModuleIndicator.vue new file mode 100644 index 00000000..cf46fe92 --- /dev/null +++ b/src/components/displays/superuser/system/SystemStatusModuleIndicator.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue b/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue index 50f9efe2..7025b1c1 100644 --- a/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue +++ b/src/components/session/token/SessionUser/Objects/CollectedOrderInvoices.vue @@ -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', { diff --git a/src/i18n/generated/da-v2.json b/src/i18n/generated/da-v2.json index 63f28677..e23b7bab 100644 --- a/src/i18n/generated/da-v2.json +++ b/src/i18n/generated/da-v2.json @@ -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": { diff --git a/src/i18n/generated/de-v2.json b/src/i18n/generated/de-v2.json index e6bae9d6..aec75f68 100644 --- a/src/i18n/generated/de-v2.json +++ b/src/i18n/generated/de-v2.json @@ -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": { diff --git a/src/i18n/generated/en-v2.json b/src/i18n/generated/en-v2.json index ba866b8d..fc51251a 100644 --- a/src/i18n/generated/en-v2.json +++ b/src/i18n/generated/en-v2.json @@ -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": { diff --git a/src/i18n/generated/global-v2.json b/src/i18n/generated/global-v2.json index f2ce7a43..7520028e 100644 --- a/src/i18n/generated/global-v2.json +++ b/src/i18n/generated/global-v2.json @@ -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'}" }, diff --git a/src/i18n/generated/no-v2.json b/src/i18n/generated/no-v2.json index 25fbb482..5bd45518 100644 --- a/src/i18n/generated/no-v2.json +++ b/src/i18n/generated/no-v2.json @@ -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": { diff --git a/src/i18n/generated/sv-v2.json b/src/i18n/generated/sv-v2.json index f6e44a50..0772429d 100644 --- a/src/i18n/generated/sv-v2.json +++ b/src/i18n/generated/sv-v2.json @@ -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": { diff --git a/src/i18n/source/da/phrases/compat/invoicing_period/object_tree.json b/src/i18n/source/da/phrases/compat/invoicing_period/object_tree.json new file mode 100644 index 00000000..ea26341b --- /dev/null +++ b/src/i18n/source/da/phrases/compat/invoicing_period/object_tree.json @@ -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" + } + } + } + } +} diff --git a/src/i18n/source/de/phrases/compat/invoicing_period/object_tree.json b/src/i18n/source/de/phrases/compat/invoicing_period/object_tree.json new file mode 100644 index 00000000..461b1aab --- /dev/null +++ b/src/i18n/source/de/phrases/compat/invoicing_period/object_tree.json @@ -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" + } + } + } + } +} diff --git a/src/i18n/source/en/phrases/compat/invoicing_period/object_tree.json b/src/i18n/source/en/phrases/compat/invoicing_period/object_tree.json new file mode 100644 index 00000000..e71404d9 --- /dev/null +++ b/src/i18n/source/en/phrases/compat/invoicing_period/object_tree.json @@ -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" + } + } + } + } +} diff --git a/src/i18n/source/global/shared/cron/index.json b/src/i18n/source/global/shared/cron/index.json index c67b4740..3b498fcc 100644 --- a/src/i18n/source/global/shared/cron/index.json +++ b/src/i18n/source/global/shared/cron/index.json @@ -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'}", diff --git a/src/i18n/source/global/shared/invoicing_period/object_tree.json b/src/i18n/source/global/shared/invoicing_period/object_tree.json new file mode 100644 index 00000000..5592b928 --- /dev/null +++ b/src/i18n/source/global/shared/invoicing_period/object_tree.json @@ -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'}" + } + } + } +} diff --git a/src/i18n/source/global/shared/system_status/index.json b/src/i18n/source/global/shared/system_status/index.json index c8e5a18b..ba6fde96 100644 --- a/src/i18n/source/global/shared/system_status/index.json +++ b/src/i18n/source/global/shared/system_status/index.json @@ -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'}" }, diff --git a/src/i18n/source/no/phrases/compat/invoicing_period/object_tree.json b/src/i18n/source/no/phrases/compat/invoicing_period/object_tree.json new file mode 100644 index 00000000..1aa62967 --- /dev/null +++ b/src/i18n/source/no/phrases/compat/invoicing_period/object_tree.json @@ -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" + } + } + } + } +} diff --git a/src/i18n/source/sv/phrases/compat/invoicing_period/object_tree.json b/src/i18n/source/sv/phrases/compat/invoicing_period/object_tree.json new file mode 100644 index 00000000..f9bc75db --- /dev/null +++ b/src/i18n/source/sv/phrases/compat/invoicing_period/object_tree.json @@ -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" + } + } + } + } +} diff --git a/src/services/attachmentPreview.js b/src/services/attachmentPreview.js new file mode 100644 index 00000000..22ed23bf --- /dev/null +++ b/src/services/attachmentPreview.js @@ -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); +}; diff --git a/src/services/moduleUsage.js b/src/services/moduleUsage.js new file mode 100644 index 00000000..042c5f86 --- /dev/null +++ b/src/services/moduleUsage.js @@ -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)); diff --git a/src/services/superuserCron.js b/src/services/superuserCron.js index 9245f4ce..a668dcbd 100644 --- a/src/services/superuserCron.js +++ b/src/services/superuserCron.js @@ -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); diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue new file mode 100644 index 00000000..ec55d071 --- /dev/null +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue @@ -0,0 +1,3021 @@ + + + + + diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeNodes.js b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeNodes.js new file mode 100644 index 00000000..d8f274b7 --- /dev/null +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/services/invoicingPeriodTreeNodes.js @@ -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; +}; diff --git a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue index 8f277cf5..417aacb0 100644 --- a/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue +++ b/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewAll.vue @@ -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 = () => { /> diff --git a/src/views/dashboards/superUserDashboard/configuration/ConfigurationFXRatesAPI.vue b/src/views/dashboards/superUserDashboard/configuration/ConfigurationFXRatesAPI.vue index 8c3bf7cd..9c65734a 100644 --- a/src/views/dashboards/superUserDashboard/configuration/ConfigurationFXRatesAPI.vue +++ b/src/views/dashboards/superUserDashboard/configuration/ConfigurationFXRatesAPI.vue @@ -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" /> +
+
+ + +
+ +
+ - {{ workerDeploymentActionLabel }} - + {{ issue.message }} +
@@ -499,10 +667,44 @@ onBeforeUnmount(() => { {{ t("cron.workers.stale") }} {{ workerSummary.stale ?? 0 }}
+
+ {{ t("cron.workers.state") }} + + + {{ statusLabel(workerState) }} + + +
{{ t("cron.workers.target") }} {{ workerTargetLabel }}
+
+ {{ t("cron.workers.api_target") }} + {{ workerApiTargetLabel }} +
+
+ {{ t("cron.workers.latest_deployment") }} + {{ deploymentLabel(workerLatestDeployment) }} +
+
+
+ + + + + + + + + + + + + + + +
{{ t("cron.workers.deployment") }}{{ t("cron.history.status") }}{{ t("cron.history.started") }}
{{ deployment.provider_operation_id || deployment.id }}{{ statusLabel(deployment.status) }}{{ formatDate(deployment.started_at || deployment.created_at) }}
@@ -520,7 +722,13 @@ onBeforeUnmount(() => { - +
{{ t("cron.workers.loading") }}
{{ t("cron.workers.empty") }} + {{ + hasWorkerDeploymentTarget + ? t("cron.workers.empty_with_target") + : t("cron.workers.empty") + }} +
@@ -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; diff --git a/tests/e2e/invoicing-period.smoke.spec.js b/tests/e2e/invoicing-period.smoke.spec.js index 7b2e3a99..5a8d9718 100644 --- a/tests/e2e/invoicing-period.smoke.spec.js +++ b/tests/e2e/invoicing-period.smoke.spec.js @@ -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); diff --git a/tests/e2e/superuser-cron.spec.ts b/tests/e2e/superuser-cron.spec.ts index a67d4a24..c486c3e7 100644 --- a/tests/e2e/superuser-cron.spec.ts +++ b/tests/e2e/superuser-cron.spec.ts @@ -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> = []; + 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); + 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"); }); diff --git a/tests/e2e/superuser-system-status.smoke.spec.js b/tests/e2e/superuser-system-status.smoke.spec.js index c1f1989e..50b202ca 100644 --- a/tests/e2e/superuser-system-status.smoke.spec.js +++ b/tests/e2e/superuser-system-status.smoke.spec.js @@ -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, diff --git a/tests/unit/buefy-tree-selection.spec.js b/tests/unit/buefy-tree-selection.spec.js new file mode 100644 index 00000000..9fdeb200 --- /dev/null +++ b/tests/unit/buefy-tree-selection.spec.js @@ -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: ` + + `, + }, +})); + +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([]); + }); +}); diff --git a/tests/unit/invoicing-period-object-tree.spec.js b/tests/unit/invoicing-period-object-tree.spec.js new file mode 100644 index 00000000..af921051 --- /dev/null +++ b/tests/unit/invoicing-period-object-tree.spec.js @@ -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: ` + + `, + }, +})); + +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: `{{ parseFunction ? parseFunction(object?.[column]) : object?.[column] }}`, + }, +})); + +vi.mock("@/components/displays/buttons/ActionSettingsWheelButton.vue", () => ({ + default: { + name: "ActionSettingsWheelButton", + props: ["menuSections", "order_id", "department_id", "invoice_collection_id", "customer_number"], + template: ` + + `, + }, +})); + +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"); + }); +}); diff --git a/tests/unit/invoicing-period-tree-nodes.spec.js b/tests/unit/invoicing-period-tree-nodes.spec.js new file mode 100644 index 00000000..85db52ee --- /dev/null +++ b/tests/unit/invoicing-period-tree-nodes.spec.js @@ -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, + }, + }); + }); +}); diff --git a/tests/unit/superuser-invoices-view.spec.js b/tests/unit/superuser-invoices-view.spec.js index 064e5907..bd70d5a8 100644 --- a/tests/unit/superuser-invoices-view.spec.js +++ b/tests/unit/superuser-invoices-view.spec.js @@ -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(" { + 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(' { + [ + "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"); + 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", () => { diff --git a/tests/unit/superuser-system-status-dashboard.spec.js b/tests/unit/superuser-system-status-dashboard.spec.js index 6b2dbeb0..285c3e8a 100644 --- a/tests/unit/superuser-system-status-dashboard.spec.js +++ b/tests/unit/superuser-system-status-dashboard.spec.js @@ -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(""); 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({