Compare commits

..
Author SHA1 Message Date
Jeppe B 8c073f7efb Fix customer additional-service restrictions 2026-07-15 14:36:15 +02:00
Jeppe B bed6030e2b Stabilize invoicing period date fixtures 2026-07-15 02:45:02 +02:00
Jeppe B 328a85bad1 Review object tree dynamic i18n helper
Add the invoicing period object tree translation wrapper to the reviewed non-literal call list; it mirrors the existing flag-list helper pattern.
2026-07-15 01:30:18 +02:00
Jeppe B 394375e429 Fix user profile two-factor test id
Keep the profile two-factor wrapper test id stable and move spacing tokens to class so the mobile profile security E2E can find the status element.
2026-07-15 00:17:34 +02:00
Jeppe B ca744adfd9 Link security status filter i18n label
Reuse the canonical system status label for the security incident status filter so the v2 duplicate raw phrase gate stays green.
2026-07-14 23:35:53 +02:00
Jeppe B 5e42f55570 Fix invoicing period object-tree flag handling
Keep expanded period rows on InvoicingPeriodObjectTree while rendering order-scoped flags in a compact expanded flag list. Update the invoicing-period E2E fixtures and source contract for the object-tree path.
2026-07-14 22:39:33 +02:00
Jeppe B 021da1a074 Fix invoicing period expanded order details (#170) 2026-07-14 21:02:22 +02:00
Jeppe B 0e55f45e91 Fix invoicing queue-state unit spec 2026-07-14 16:05:04 +02:00
Jeppe B 9749837506 Format frontend test files 2026-07-14 15:55:17 +02:00
Jeppe B 4fbb0b98c3 Improve superuser invoicing and system status views 2026-07-14 15:41:05 +02:00
Jeppe B 0edf8afcaf Regenerate Danish i18n runtime 2026-07-14 13:52:23 +02:00
gold-dev e2ee54578c Feat: 202607 - truckwash.dk V15_sent slide 8 2026-07-14 12:28:56 +03:00
gold-dev b35e4484de Merge remote-tracking branch 'origin/master' 2026-07-14 12:15:00 +03:00
gold-dev c8368612eb Feat: 202607 - truckwash.dk V15_sent slide 6, 7 2026-07-14 12:14:47 +03:00
Jeppe B 26266e724b Stabilize customer booking and i18n checks 2026-07-14 10:34:29 +02:00
Jeppe B 6ccfea9eb4 Limit security nav assertion to desktop layout 2026-07-14 02:15:44 +02:00
Jeppe B d4349c20a7 Format subuser management spec 2026-07-14 00:59:12 +02:00
Jeppe Bundgaard 63f1cc2450 Fix custom subuser Dognvask updates 2026-07-13 22:30:18 +02:00
Jeppe Bundgaard acec2a694a Add loading screen for session confirmation and enhance vehicle management labels 2026-07-13 22:11:51 +02:00
Jeppe B f15d7224e0 Make failover wiring source check formatter tolerant 2026-07-13 21:50:28 +02:00
Jeppe B 236f552a22 Stabilize subuser QR auth smoke assertion 2026-07-13 20:57:30 +02:00
Jeppe B 321b6f2c64 Format auth smoke session bootstrap test 2026-07-13 20:36:03 +02:00
Jeppe B 218ead9a31 Fix subuser QR session bootstrap 2026-07-13 20:33:48 +02:00
Jeppe B 60b6e82c88 Dedupe cron worker target i18n key 2026-07-13 20:32:03 +02:00
Jeppe B bbe4ad938a Add cron worker controls and align superuser specs 2026-07-13 19:47:08 +02:00
Jeppe B 9e27380a10 Fix i18n v2 catalog integrity drift
Fill missing backup compatibility keys for Danish and Norwegian and centralize repeated shared/support labels through existing i18n links.
2026-07-13 17:59:51 +02:00
Jeppe Bundgaard baf83fd079 Restore gateway smoke helpers after route spec cleanup
Keep the gateway health-card smoke test runnable after removing the stale replication-management helper block.
2026-07-13 15:02:15 +00:00
Jeppe Bundgaard dfe3163692 Align frontend smoke tests with current route permissions
Update self-serve mocked customer permissions for the guarded wash routes and replace removed replication-management E2E coverage with dashboard redirect assertions.
2026-07-13 16:41:08 +02:00
Jeppe Bundgaard 4452bd4088 Update system status route contract test 2026-07-13 15:57:42 +02:00
Jeppe Bundgaard 6f93b840a9 Relax superuser module wiring assertions 2026-07-13 15:45:59 +02:00
Jeppe Bundgaard 443f50c144 Enhance subuser management contact and action flows 2026-07-13 15:11:49 +02:00
Jeppe Bundgaard 5a7f902d01 Enhance subuser action menu with new sections and labels for improved user experience 2026-07-13 10:42:13 +02:00
Jeppe Bundgaard 02cc82cee1 Enhance pagination filter dropdown styling and update subuser management tests 2026-07-13 10:27:05 +02:00
Jeppe Bundgaard fa9b05958d Enhance subuser management functionality and improve UI responsiveness 2026-07-13 10:25:20 +02:00
Jeppe Bundgaard 6fb2c9e7df Update self-serve permissions and enhance UI components for customer interactions 2026-07-13 10:21:18 +02:00
Jeppe Bundgaard 54e59b4b3e Add system status displays for Minio and Redis, and enhance backup configuration 2026-07-13 10:07:56 +02:00
Jeppe B 8a0ea6cae5 Fix cron status translation literals 2026-07-13 08:48:45 +02:00
Jeppe B 1bbd816e83 Fix session bootstrap heading assertion
Target the system status heading in the delayed session bootstrap test so Playwright strict mode does not match both the navigation link and page heading.
2026-07-09 16:17:33 +02:00
Jeppe B 4e56191b7e Fix cron due i18n link target
Add locale glossary entries for the cron due label and link the source alias through terms.glossary.due so generated runtime links resolve across active locales.
2026-07-09 14:51:17 +02:00
Jeppe B 4404fb49d3 Fix cron i18n duplicate aliases
Link repeated cron labels through existing cron/shared i18n aliases and regenerate global-v2.json so i18n integrity checks pass.
2026-07-09 13:00:25 +02:00
244 changed files with 230181 additions and 6065 deletions
+223 -30
View File
@@ -11,6 +11,36 @@ on:
description: Store build number/version code
required: false
type: string
upload_android_to_play:
description: Upload the signed Android App Bundle to Google Play
required: false
type: boolean
default: true
upload_ios_to_app_store:
description: Upload the signed iOS IPA to App Store Connect
required: false
type: boolean
default: true
android_track:
description: Google Play track for manual dispatches
required: false
type: choice
default: production
options:
- production
- beta
- alpha
- internal
android_release_status:
description: Google Play release status for manual dispatches
required: false
type: choice
default: completed
options:
- completed
- draft
- inProgress
- halted
push:
tags:
- "mobile-v*"
@@ -26,40 +56,81 @@ permissions:
contents: read
concurrency:
group: mobile-store-artifacts-${{ github.ref_name }}
group: mobile-store-artifacts-${{ github.event.workflow_run.head_branch || github.ref_name || github.run_id }}
cancel-in-progress: true
jobs:
android:
name: Android AAB
name: Android AAB and Play upload
if: >
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == github.event.repository.default_branch)
runs-on: ubuntu-latest
timeout-minutes: 45
runs-on: ubuntu-24.04
environment: mobile-store-production
timeout-minutes: 60
env:
ANDROID_PACKAGE_NAME: ${{ vars.ANDROID_PACKAGE_NAME || 'io.truckwash.twa' }}
ANDROID_AAB_PATH: ${{ vars.ANDROID_AAB_PATH || 'android/app/build/outputs/bundle/release/app-release.aab' }}
PLAY_STORE_TRACK: ${{ inputs.android_track || vars.PLAY_STORE_TRACK || 'production' }}
PLAY_STORE_RELEASE_STATUS: ${{ inputs.android_release_status || vars.PLAY_STORE_RELEASE_STATUS || 'completed' }}
PLAY_STORE_USER_FRACTION: ${{ vars.PLAY_STORE_USER_FRACTION || '' }}
UPLOAD_ANDROID_TO_PLAY: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_android_to_play }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Guard current master release
id: release-guard
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
current=true
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
current=false
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
else
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
fi
else
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Setup Java
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
- name: Setup Android SDK
if: steps.release-guard.outputs.current == 'true'
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
if: steps.release-guard.outputs.current == 'true'
shell: bash
run: |
set -euo pipefail
@@ -67,10 +138,11 @@ jobs:
sdkmanager "platforms;android-36" "build-tools;36.0.0"
- name: Resolve mobile version
if: steps.release-guard.outputs.current == 'true'
shell: bash
env:
INPUT_VERSION_NAME: ${{ inputs.version_name }}
INPUT_VERSION_CODE: ${{ inputs.version_code }}
INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
run: |
set -euo pipefail
version_name="$INPUT_VERSION_NAME"
@@ -84,10 +156,22 @@ jobs:
echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV"
echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV"
- name: Check Android store environment
if: steps.release-guard.outputs.current == 'true'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: node scripts/mobile/check-store-upload-env.mjs --android
- name: Install dependencies
if: steps.release-guard.outputs.current == 'true'
run: npm ci --legacy-peer-deps
- name: Decode Android signing key
if: steps.release-guard.outputs.current == 'true'
shell: bash
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
@@ -96,10 +180,6 @@ jobs:
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
set -euo pipefail
test -n "$ANDROID_KEYSTORE_BASE64"
test -n "$ANDROID_KEYSTORE_PASSWORD"
test -n "$ANDROID_KEY_ALIAS"
test -n "$ANDROID_KEY_PASSWORD"
keystore_path="$RUNNER_TEMP/android-release.keystore"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.ANDROID_KEYSTORE_BASE64, 'base64'))" "$keystore_path"
echo "ANDROID_KEYSTORE_FILE=$keystore_path" >> "$GITHUB_ENV"
@@ -108,48 +188,98 @@ jobs:
echo "ANDROID_KEY_PASSWORD=$ANDROID_KEY_PASSWORD" >> "$GITHUB_ENV"
- name: Build and sync Android shell
if: steps.release-guard.outputs.current == 'true'
run: |
npm run mobile:android:sync
npm run mobile:permissions:check
npm run mobile:android:signing:check
- name: Build signed Android App Bundle
if: steps.release-guard.outputs.current == 'true'
working-directory: android
run: ./gradlew --no-daemon bundleRelease
- name: Verify Android App Bundle signature
run: jarsigner -verify -certs -verbose android/app/build/outputs/bundle/release/app-release.aab >/dev/null
if: steps.release-guard.outputs.current == 'true'
run: jarsigner -verify -certs -verbose "$ANDROID_AAB_PATH" >/dev/null
- name: Upload Android artifact
if: steps.release-guard.outputs.current == 'true'
uses: actions/upload-artifact@v4
with:
name: truck-wash-android-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
path: android/app/build/outputs/bundle/release/app-release.aab
path: ${{ env.ANDROID_AAB_PATH }}
if-no-files-found: error
retention-days: 14
- name: Upload Android App Bundle to Google Play
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_ANDROID_TO_PLAY == 'true'
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 }}
run: npm run mobile:android:play-upload
ios:
name: iOS IPA
if: github.event_name != 'workflow_run'
runs-on: macos-latest
timeout-minutes: 60
name: iOS IPA and App Store upload
if: >
github.event_name != 'workflow_run' ||
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch == github.event.repository.default_branch)
runs-on: macos-15
environment: mobile-store-production
timeout-minutes: 90
env:
IOS_PROJECT_PATH: ios/App/App.xcodeproj
IOS_SCHEME: App
IOS_BUNDLE_ID: io.truckwash.app
UPLOAD_IOS_TO_APP_STORE: ${{ github.event_name != 'workflow_dispatch' || inputs.upload_ios_to_app_store }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
ref: ${{ github.sha }}
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Guard current master release
id: release-guard
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch || github.ref_name }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
current=true
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
latest_sha="$(git ls-remote origin "refs/heads/$DEFAULT_BRANCH" | awk '{print $1}')"
if [[ -z "$latest_sha" ]]; then
echo "Could not resolve origin/$DEFAULT_BRANCH." >&2
exit 1
fi
if [[ "$latest_sha" != "$EXPECTED_SHA" ]]; then
current=false
echo "Skipping stale mobile upload for $EXPECTED_SHA; origin/$DEFAULT_BRANCH is $latest_sha."
else
echo "Mobile upload commit is current for $DEFAULT_BRANCH."
fi
else
echo "Mobile release guard passed for $EVENT_NAME on $RELEASE_BRANCH."
fi
echo "current=$current" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
if: steps.release-guard.outputs.current == 'true'
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
- name: Resolve mobile version
if: steps.release-guard.outputs.current == 'true'
shell: bash
env:
INPUT_VERSION_NAME: ${{ inputs.version_name }}
INPUT_VERSION_CODE: ${{ inputs.version_code }}
INPUT_VERSION_NAME: ${{ inputs.version_name || '' }}
INPUT_VERSION_CODE: ${{ inputs.version_code || '' }}
run: |
set -euo pipefail
version_name="$INPUT_VERSION_NAME"
@@ -163,16 +293,32 @@ jobs:
echo "MOBILE_VERSION_NAME=$version_name" >> "$GITHUB_ENV"
echo "MOBILE_VERSION_CODE=$version_code" >> "$GITHUB_ENV"
- name: Check iOS store environment
if: steps.release-guard.outputs.current == 'true'
env:
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }}
IOS_KEYCHAIN_PASSWORD: ${{ secrets.IOS_KEYCHAIN_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
run: node scripts/mobile/check-store-upload-env.mjs --ios
- name: Install dependencies
if: steps.release-guard.outputs.current == 'true'
run: npm ci --legacy-peer-deps
- name: Build and sync iOS shell
if: steps.release-guard.outputs.current == 'true'
run: |
npm run build
npx cap sync ios
npm run mobile:permissions:check
- name: Install Apple signing assets
if: steps.release-guard.outputs.current == 'true'
shell: bash
env:
IOS_CERTIFICATE_BASE64: ${{ secrets.IOS_CERTIFICATE_BASE64 }}
@@ -182,12 +328,6 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
set -euo pipefail
test -n "$IOS_CERTIFICATE_BASE64"
test -n "$IOS_CERTIFICATE_PASSWORD"
test -n "$IOS_PROVISION_PROFILE_BASE64"
test -n "$IOS_KEYCHAIN_PASSWORD"
test -n "$APPLE_TEAM_ID"
certificate_path="$RUNNER_TEMP/apple-distribution.p12"
profile_path="$RUNNER_TEMP/app-store.mobileprovision"
keychain_path="$RUNNER_TEMP/app-signing.keychain-db"
@@ -214,14 +354,35 @@ jobs:
echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV"
echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV"
- name: Install App Store Connect API key
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
env:
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64: ${{ secrets.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 }}
run: |
set -euo pipefail
private_keys_dir="$RUNNER_TEMP/private_keys"
private_key_path="$private_keys_dir/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
mkdir -p "$private_keys_dir"
node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64, 'base64'))" "$private_key_path"
chmod 600 "$private_key_path"
echo "API_PRIVATE_KEYS_DIR=$private_keys_dir" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_API_KEY_ID=$APP_STORE_CONNECT_API_KEY_ID" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_ISSUER_ID=$APP_STORE_CONNECT_ISSUER_ID" >> "$GITHUB_ENV"
echo "APP_STORE_CONNECT_API_KEY_PATH=$private_key_path" >> "$GITHUB_ENV"
- name: Resolve Swift packages
run: xcodebuild -resolvePackageDependencies -project ios/App/App.xcodeproj -scheme App
if: steps.release-guard.outputs.current == 'true'
run: xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME"
- name: Archive iOS app
if: steps.release-guard.outputs.current == 'true'
run: |
xcodebuild \
-project ios/App/App.xcodeproj \
-scheme App \
-project "$IOS_PROJECT_PATH" \
-scheme "$IOS_SCHEME" \
-configuration Release \
-destination "generic/platform=iOS" \
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
@@ -234,6 +395,7 @@ jobs:
CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE"
- name: Export iOS IPA
if: steps.release-guard.outputs.current == 'true'
shell: bash
run: |
set -euo pipefail
@@ -255,7 +417,7 @@ jobs:
<string>$APPLE_TEAM_ID</string>
<key>provisioningProfiles</key>
<dict>
<key>io.truckwash.app</key>
<key>$IOS_BUNDLE_ID</key>
<string>$IOS_PROFILE_NAME</string>
</dict>
<key>stripSwiftSymbols</key>
@@ -270,15 +432,43 @@ jobs:
-archivePath "$RUNNER_TEMP/TruckWash.xcarchive" \
-exportPath "$RUNNER_TEMP/ios-export" \
-exportOptionsPlist "$export_options"
ipa_path="$(find "$RUNNER_TEMP/ios-export" -name '*.ipa' -print -quit)"
test -n "$ipa_path"
echo "IOS_IPA_PATH=$ipa_path" >> "$GITHUB_ENV"
- name: Upload iOS artifact
if: steps.release-guard.outputs.current == 'true'
uses: actions/upload-artifact@v4
with:
name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.sha }}
name: truck-wash-ios-${{ env.MOBILE_VERSION_NAME }}-${{ github.event.workflow_run.head_sha || github.sha }}
path: ${{ runner.temp }}/ios-export/*.ipa
if-no-files-found: error
retention-days: 14
- name: Validate iOS IPA with App Store Connect
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
run: |
set -euo pipefail
xcrun altool \
--validate-app \
--type ios \
--file "$IOS_IPA_PATH" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"
- name: Upload iOS IPA to App Store Connect
if: steps.release-guard.outputs.current == 'true' && env.UPLOAD_IOS_TO_APP_STORE == 'true'
shell: bash
run: |
set -euo pipefail
xcrun altool \
--upload-app \
--type ios \
--file "$IOS_IPA_PATH" \
--apiKey "$APP_STORE_CONNECT_API_KEY_ID" \
--apiIssuer "$APP_STORE_CONNECT_ISSUER_ID"
- name: Clean up Apple signing assets
if: always()
shell: bash
@@ -289,3 +479,6 @@ jobs:
if [[ -n "${IOS_PROFILE_UUID:-}" ]]; then
rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/$IOS_PROFILE_UUID.mobileprovision"
fi
if [[ -n "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]]; then
rm -f "$APP_STORE_CONNECT_API_KEY_PATH"
fi
+1
View File
@@ -15,6 +15,7 @@ dist-ssr
coverage
*.local
dev-dist
.playwright-cli/
# Mobile build and signing outputs
/app/build/
+11
View File
@@ -183,6 +183,17 @@ the Capacitor Android project. The generator updates `android/app/src/main/res`
launcher assets, `public/icons/icon-192x192.png`, `public/icons/icon-512x512.png`,
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:
+102
View File
@@ -0,0 +1,102 @@
# Apple App Store Release Runbook
This runbook covers the public iOS App Store release path for the Truck Wash
Capacitor app.
## Account And App Record
- Use the Truck Wash ApS Apple Developer account. The Account Holder must accept
the latest Apple agreements before builds can be uploaded.
- Create or verify the App Store Connect app record:
- Platform: iOS
- Name: Truck Wash Kundeportal
- Bundle ID: `io.truckwash.app`
- SKU: `truckwash-ios`
- Primary language: Danish
- Category: Business
- Price: Free
- Initial availability: Denmark
- 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 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:
- `IOS_CERTIFICATE_BASE64`
- `IOS_CERTIFICATE_PASSWORD`
- `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 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`
- Privacy URL: `https://truckwash.io/privacy-policy`
- Subtitle: `Book og start truckvask`
- Promotional text: `Administrer vask, koeretoejer, ordrer og fakturaer fra mobilen.`
- Keywords: `truck wash,lastbilvask,vask,booking,kundeportal`
- Expected age rating: 4+, subject to the App Store Connect questionnaire.
Use real iOS simulator or device screenshots. Provide at least:
- iPhone 6.9-inch portrait screenshots
- iPad 13-inch portrait screenshots
Recommended screenshot scenes: dashboard, booking flow, self-service wash start,
vehicles/orders, and invoices/payment history. Do not include real customer
data, private tokens, or placeholder copy.
## Privacy And Review Notes
App Store Connect privacy labels must match the actual app and backend behavior.
Expected minimum disclosures include account/contact data, identifiers such as
customer number, vehicle/license plate data, order and invoice history, payment
state, approximate/precise location when used, and photos or attachments when
users upload them. Tracking should remain false unless analytics/ad tracking is
introduced.
Review notes must include:
- A demo account and password.
- OTP/2FA/passkey fallback instructions when enabled for the account.
- A clear statement that Stripe/card payments are for physical truck-wash
services consumed outside the app, so Apple in-app purchase is not used.
- Any hardware-dependent functionality that reviewers cannot reproduce, with a
short demo video if needed.
- Confirmation that the backend environment is online for the whole review
window.
## TestFlight And Release
1. Wait for App Store Connect processing to finish.
2. Distribute the processed build to internal TestFlight testers.
3. Run clean-device QA on iPhone and iPad.
4. Fix issues using the same marketing version and an incremented build number.
5. Submit for App Review with manual release after approval.
6. After approval, release to Denmark first and monitor crashes, support mail,
and App Store Connect feedback before expanding availability.
+60
View File
@@ -0,0 +1,60 @@
# Customer attributes refactor and migration plan
## Problem statement
Customer attributes are currently represented as loosely typed string flags and evaluated in several UI, POS, and invoicing paths. This makes product restrictions vulnerable to broad category heuristics. The immediate defect is that `restrictAdditionalServices` ("Begræns tillægsydelser") treats related booking add-ons as additional services, so interior wash add-ons plus trailer/dolly additions are blocked even though that attribute is intended to cover standalone additional services only.
## Target behavior matrix
| Attribute | Canonical intent | Product availability behavior | Invoice/workflow behavior |
| ------------------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `restrictAdditionalServices` | Block standalone additional services/tillægsydelser. | Block standalone additional-service catalog items; do not block related booking add-ons such as interior wash, trailer, or dolly. | Flag only order lines that are standalone additional services. |
| `restrictTankCleaning` | Block tank-cleaning services. | Block products whose category or legacy name identifies tank cleaning. | Flag tank-cleaning order lines. |
| `restrictSpotFree` | Block Spot Free/RO rinse products. | Block canonical Spot Free product IDs and legacy Spot Free/RO naming. | Flag Spot Free order lines. |
| `restrictInteriorCleaning` | Block interior wash services. | Block products whose names/categories explicitly identify interior wash. | Flag interior-wash order lines. |
| `onlyTankCleaning` | Allow only tank-cleaning services. | Block every non-tank-cleaning product while keeping tank-cleaning products available. | Flag non-tank-cleaning order lines. |
| `requiresReferenceNumber` | Require an order reference. | No product filtering. | Flag orders missing a required reference. |
| `requiresRegistrationNumbersInvoice` | Require registration numbers on invoice/order context. | No product filtering. | Flag orders missing required registration numbers. |
| `invoiceAllOrdersIndividually` | Prevent grouped invoicing. | No product filtering. | Split/flag invoice collections containing multiple orders for the customer. |
| `invoiceWithStripe` | Invoice through Stripe workflow. | No product filtering. | Route the customer through Stripe invoicing/payment handling. |
| `showPricesOnBookingPage` | Show customer prices during booking. | No product filtering. | Presentation-only booking behavior. |
| `usePONumbers` | Use/prompt for PO numbers. | No product filtering. | Require or expose PO-number workflow where configured. |
| `exemptFromAdministrationFee` | Do not charge administration fees. | No product filtering. | Suppress/flag administration-fee order lines for this customer. |
## Refactor plan
1. **Create a canonical customer-rule domain module**
- Keep `CUSTOMER_RULE_DEFINITIONS` as the registry of public attributes, but extend each entry with a typed evaluator contract: product predicate, category predicate, invoice predicate, and UI impact metadata.
- Replace scattered string comparisons with registry lookups so every surface uses the same semantics.
- Add explicit names for ambiguous categories: `standaloneAdditionalService`, `relatedAddon`, `primaryProduct`, `tankCleaning`, `spotFree`, and `interiorCleaning`.
2. **Normalize product classification once**
- Build a `classifyCustomerRuleProduct(product, context)` helper returning booleans for each product class.
- Treat related add-ons (`isRelatedAddon`, `relatedItemId`) as context, not as proof that the item is a standalone additional service.
- Reserve `restrictAdditionalServices` for category 8/standalone service context or explicit additional-service labels, not numeric booking add-on category 4.
3. **Migrate rule evaluation paths**
- POS product cards and mobile flows should call `getCustomerProductRestriction` only with the normalized product context.
- Customer-rule tooltips should derive blocked/available products from the same evaluator used by POS.
- Invoicing-period flag generation should use the same classification vocabulary as product availability so historical and current orders are flagged consistently.
4. **Backfill and data migration**
- Keep existing attribute keys unchanged to avoid a destructive migration.
- Add a one-time data audit/report listing customers with `restrictAdditionalServices` and recent orders containing interior wash, trailer, or dolly add-ons. These rows should be verified as no longer violating the rule after deployment.
- If any historical invoice flags were created solely because related add-ons were treated as additional services, provide an idempotent cleanup command to recalculate customer-rule violations for affected invoice periods.
5. **Regression test coverage**
- Unit-test every attribute in the target behavior matrix.
- Add focused cases for the defect: interior wash related add-on, trailer related add-on, and dolly related add-on must remain available under `restrictAdditionalServices`.
- Add invoice-flag fixtures mirroring the same products so invoicing behavior cannot drift from POS behavior.
- Keep tooltip tests aligned with the evaluator, showing standalone additional services under `restrictAdditionalServices` and not showing related add-ons.
6. **Rollout and verification**
- Ship the evaluator patch behind the existing attribute keys.
- Run unit tests and targeted POS/customer-rule e2e tests.
- Verify with production-like catalog data that `restrictAdditionalServices` blocks only standalone additional services while `restrictInteriorCleaning`, `restrictTankCleaning`, `restrictSpotFree`, and `onlyTankCleaning` continue to behave exactly as listed above.
+41 -3
View File
@@ -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
+434 -3
View File
@@ -1,9 +1,12 @@
<!DOCTYPE html>
<html lang="" class="theme-light" data-theme="light">
<html lang="" class="theme-light tw-bootstrap-loading" data-theme="light">
<head>
<meta charset="UTF-8">
<script>
(function () {
window.__TW_LOADER_STARTED_AT__ = window.performance && typeof window.performance.now === 'function'
? window.performance.now()
: Date.now();
var match = window.location.pathname.match(/^\/[^/]+\/frontend(?:\/|$)/);
var href = match ? match[0].replace(/\/+$/, '') + '/' : '/';
var base = document.createElement('base');
@@ -21,9 +24,437 @@
<meta name="mobile-web-app-capable" content="yes">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/css/all.min.css" integrity="sha512-5Hs3dF2AEPkpNAR7UiOHba+lRSJNeM2ECkwxUIxC1Q/FLycGTbNapWXB4tP889k5T5Ju8fs4b1P5z/iB4nMfSQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<title>Truck Wash Kundeportal</title>
<style>
html,
body,
#app {
min-height: 100%;
}
html.tw-bootstrap-loading {
font-size: 16px !important;
}
body {
margin: 0;
background: #fff;
}
body.tw-bootstrap-loading {
background: #041f32;
}
html.tw-bootstrap-loading,
html.tw-loader-scroll-lock,
body.tw-bootstrap-loading,
body.tw-loader-scroll-lock {
overflow: hidden;
scrollbar-width: none;
}
html.tw-bootstrap-loading::-webkit-scrollbar,
html.tw-loader-scroll-lock::-webkit-scrollbar,
body.tw-bootstrap-loading::-webkit-scrollbar,
body.tw-loader-scroll-lock::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.tw-bootstrap-loader {
--tw-loader-navy: #063651;
--tw-loader-blue: #0787bb;
--tw-loader-cyan: #69d7f5;
--tw-loader-foam: #f8fbff;
--tw-loader-warm: #f4c15d;
position: fixed;
inset: 0;
z-index: 100000;
display: grid;
min-height: 100vh;
place-items: center;
overflow: hidden;
padding: 2rem;
box-sizing: border-box;
background:
radial-gradient(circle at 20% 20%, rgba(105, 215, 245, 0.18), transparent 28rem),
radial-gradient(circle at 82% 74%, rgba(244, 193, 93, 0.12), transparent 24rem),
linear-gradient(145deg, #041f32 0%, var(--tw-loader-navy) 52%, #04263d 100%);
color: var(--tw-loader-foam);
font-family: Avenir, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.tw-bootstrap-loader__ambient {
position: absolute;
inset: 0;
pointer-events: none;
}
.tw-bootstrap-loader__ambient::before {
position: absolute;
inset: 14% 9%;
content: "";
border: 1px solid rgba(248, 251, 255, 0.08);
border-radius: 8px;
}
.tw-bootstrap-loader__wash {
position: absolute;
left: 50%;
display: block;
width: 72rem;
height: 8rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgba(105, 215, 245, 0.2), rgba(248, 251, 255, 0.18), transparent);
filter: blur(8px);
transform: translateX(-50%) rotate(-9deg);
}
.tw-bootstrap-loader__wash--wide {
top: 26%;
animation: tw-bootstrap-loader-wash 5.8s ease-in-out var(--tw-loader-wash-wide-delay, 0ms) infinite;
}
.tw-bootstrap-loader__wash--tight {
bottom: 22%;
width: 48rem;
height: 5rem;
animation: tw-bootstrap-loader-wash 6.8s ease-in-out var(--tw-loader-wash-tight-delay, 0ms) infinite reverse;
}
.tw-bootstrap-loader__panel {
position: relative;
display: grid;
width: min(100%, 25rem);
min-height: 27rem;
align-content: center;
justify-items: center;
padding: 2.5rem 2rem 2.25rem;
box-sizing: border-box;
border: 1px solid rgba(248, 251, 255, 0.16);
border-radius: 8px;
background: rgba(4, 31, 50, 0.74);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32);
text-align: center;
backdrop-filter: blur(18px);
}
.tw-bootstrap-loader__panel::after {
position: absolute;
inset: 0;
content: "";
border-radius: inherit;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.08), transparent 38%);
pointer-events: none;
}
.tw-bootstrap-loader__brand,
.tw-bootstrap-loader__motion,
.tw-bootstrap-loader__status,
.tw-bootstrap-loader__hint,
.tw-bootstrap-loader__dots {
position: relative;
z-index: 1;
}
.tw-bootstrap-loader__brand {
display: grid;
width: 13rem;
min-height: 4rem;
place-items: center;
margin-bottom: 2.25rem;
}
.tw-bootstrap-loader__logo {
display: block;
width: 100%;
max-height: 4rem;
object-fit: contain;
}
.tw-bootstrap-loader__motion {
display: grid;
width: 8.5rem;
height: 8.5rem;
place-items: center;
margin-bottom: 2rem;
}
.tw-bootstrap-loader__ring {
position: absolute;
inset: 0;
border: 2px solid rgba(248, 251, 255, 0.12);
border-top-color: var(--tw-loader-cyan);
border-right-color: rgba(7, 135, 187, 0.78);
border-radius: 50%;
animation: tw-bootstrap-loader-spin 1.8s linear var(--tw-loader-spin-delay, 0ms) infinite;
}
.tw-bootstrap-loader__ring::before,
.tw-bootstrap-loader__ring::after {
position: absolute;
content: "";
border-radius: 50%;
}
.tw-bootstrap-loader__ring::before {
inset: 1rem;
border: 1px solid rgba(248, 251, 255, 0.18);
}
.tw-bootstrap-loader__ring::after {
inset: 2.3rem;
background: radial-gradient(circle, rgba(105, 215, 245, 0.38), rgba(7, 135, 187, 0.18) 45%, transparent 70%);
animation: tw-bootstrap-loader-pulse 2.4s ease-in-out var(--tw-loader-pulse-delay, 0ms) infinite;
}
.tw-bootstrap-loader__sweep {
width: 6rem;
height: 1.15rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent 0%, rgba(248, 251, 255, 0.92) 45%, var(--tw-loader-cyan) 100%);
box-shadow: 0 0 24px rgba(105, 215, 245, 0.48);
transform: rotate(-12deg);
animation: tw-bootstrap-loader-sweep 1.8s ease-in-out var(--tw-loader-sweep-delay, 0ms) infinite;
}
.tw-bootstrap-loader__spark {
position: absolute;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--tw-loader-warm);
box-shadow: 0 0 18px rgba(244, 193, 93, 0.76);
opacity: 0.9;
}
.tw-bootstrap-loader__spark--one {
top: 1rem;
right: 1.3rem;
animation: tw-bootstrap-loader-spark 2.6s ease-in-out var(--tw-loader-spark-one-delay, 0ms) infinite;
}
.tw-bootstrap-loader__spark--two {
bottom: 1.4rem;
left: 1rem;
animation: tw-bootstrap-loader-spark 2.6s ease-in-out var(--tw-loader-spark-two-delay, 0.65s) infinite;
}
.tw-bootstrap-loader__status {
margin: 0;
color: var(--tw-loader-foam);
font-size: 1.2rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.25;
}
.tw-bootstrap-loader__hint {
max-width: 18rem;
margin: 0.55rem 0 0;
color: rgba(248, 251, 255, 0.72);
font-size: 0.9rem;
line-height: 1.45;
}
.tw-bootstrap-loader__dots {
display: inline-flex;
gap: 0.45rem;
height: 0.6rem;
margin-top: 1.35rem;
align-items: center;
}
.tw-bootstrap-loader__dots span {
display: block;
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--tw-loader-cyan);
animation: tw-bootstrap-loader-dot 1.35s ease-in-out var(--tw-loader-dot-one-delay, 0ms) infinite;
}
.tw-bootstrap-loader__dots span:nth-child(2) {
animation-delay: var(--tw-loader-dot-two-delay, 0.18s);
}
.tw-bootstrap-loader__dots span:nth-child(3) {
animation-delay: var(--tw-loader-dot-three-delay, 0.36s);
}
@keyframes tw-bootstrap-loader-spin {
to {
transform: rotate(360deg);
}
}
@keyframes tw-bootstrap-loader-sweep {
0%,
100% {
opacity: 0.6;
transform: translateX(-0.65rem) rotate(-12deg);
}
50% {
opacity: 1;
transform: translateX(0.65rem) rotate(-12deg);
}
}
@keyframes tw-bootstrap-loader-pulse {
0%,
100% {
opacity: 0.7;
transform: scale(0.94);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@keyframes tw-bootstrap-loader-spark {
0%,
100% {
opacity: 0.35;
transform: scale(0.7);
}
45% {
opacity: 1;
transform: scale(1);
}
}
@keyframes tw-bootstrap-loader-dot {
0%,
100% {
opacity: 0.35;
transform: translateY(0);
}
45% {
opacity: 1;
transform: translateY(-0.25rem);
}
}
@keyframes tw-bootstrap-loader-wash {
0%,
100% {
opacity: 0.42;
transform: translateX(-54%) rotate(-9deg);
}
50% {
opacity: 0.72;
transform: translateX(-46%) rotate(-9deg);
}
}
@media screen and (max-width: 480px) {
.tw-bootstrap-loader {
padding: 1rem;
}
.tw-bootstrap-loader__panel {
min-height: 25rem;
padding: 2rem 1.4rem;
}
.tw-bootstrap-loader__brand {
width: 11.5rem;
margin-bottom: 1.8rem;
}
.tw-bootstrap-loader__motion {
width: 7.25rem;
height: 7.25rem;
margin-bottom: 1.75rem;
}
.tw-bootstrap-loader__status {
font-size: 1.1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.tw-bootstrap-loader *,
.tw-bootstrap-loader *::before,
.tw-bootstrap-loader *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.tw-bootstrap-loader__sweep {
opacity: 0.95;
transform: rotate(-12deg);
}
.tw-bootstrap-loader__dots span {
opacity: 0.75;
transform: none;
}
}
</style>
</head>
<body>
<div id="app"></div>
<body class="tw-bootstrap-loading">
<div id="app">
<div
class="tw-bootstrap-loader"
data-testid="app-bootstrap-loading"
role="status"
aria-live="polite"
aria-label="Indlæser Truck Wash kundeportal"
>
<div class="tw-bootstrap-loader__ambient" aria-hidden="true">
<span class="tw-bootstrap-loader__wash tw-bootstrap-loader__wash--wide"></span>
<span class="tw-bootstrap-loader__wash tw-bootstrap-loader__wash--tight"></span>
</div>
<div class="tw-bootstrap-loader__panel">
<div class="tw-bootstrap-loader__brand">
<img
src="%BASE_URL%assets/branding/truckwash-banner-white-compressed.png"
alt="Truck Wash"
class="tw-bootstrap-loader__logo"
/>
</div>
<div class="tw-bootstrap-loader__motion" aria-hidden="true">
<span class="tw-bootstrap-loader__ring"></span>
<span class="tw-bootstrap-loader__sweep"></span>
<span class="tw-bootstrap-loader__spark tw-bootstrap-loader__spark--one"></span>
<span class="tw-bootstrap-loader__spark tw-bootstrap-loader__spark--two"></span>
</div>
<p class="tw-bootstrap-loader__status" data-testid="app-bootstrap-loading-status">Indlæser...</p>
<p class="tw-bootstrap-loader__hint">Vi gør kundeportalen klar</p>
<div class="tw-bootstrap-loader__dots" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</div>
<script>
(function () {
var appPath = window.location.pathname.replace(/^\/[^/]+\/frontend(?=\/|$)/, '') || '/';
var hasStoredSession = false;
try {
hasStoredSession = Boolean(window.localStorage && window.localStorage.getItem('token'));
} catch (error) {
hasStoredSession = false;
}
var usesProtectedSessionLoader = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/.test(appPath);
var usesGuestSessionLoader = /^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/.test(appPath);
var usesSessionLoader = usesProtectedSessionLoader || (hasStoredSession && usesGuestSessionLoader);
if (!usesSessionLoader) return;
var loader = document.querySelector('[data-testid="app-bootstrap-loading"]');
var status = document.querySelector('[data-testid="app-bootstrap-loading-status"]');
var hint = loader ? loader.querySelector('.tw-bootstrap-loader__hint') : null;
if (loader) loader.setAttribute('aria-label', 'Bekræfter bruger');
if (status) status.textContent = 'Bekræfter bruger...';
if (hint) hint.textContent = 'Vi indlæser din session';
})();
</script>
<script type="module" src="/src/releaseBootstrap.js"></script>
</body>
</html>
+2
View File
@@ -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"
},
+57
View File
@@ -0,0 +1,57 @@
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_mime.c>
AddType application/manifest+json .webmanifest
</IfModule>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:assets|resources|favicons|icons|img|sounds|\.well-known)/.+)$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^.+/((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ $1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/public/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ public/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{DOCUMENT_ROOT}/dist/$1 -f
RewriteRule ^(?:.*?/)?((?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js))$ dist/$1 [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:assets|resources|favicons|icons|img|sounds|\.well-known)/ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(?:.*?/)?(?:index\.html|manifest\.json|manifest\.webmanifest|favicon\.ico|favicon_default\.ico|pleno-favicon\.ico|release-entry\.json|release-manifest\.json|registerSW\.js|sw\.js|workbox-[^/]+\.js)$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule \.[^/]+$ - [R=404,L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . index.html [L]
</IfModule>
@@ -0,0 +1,12 @@
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "io.truckwash.twa",
"sha256_cert_fingerprints": [
"29:56:F7:8D:BD:A0:2E:A9:32:82:97:28:A3:E2:65:16:23:73:DD:2C:16:F6:7A:97:AD:63:27:14:5C:8C:FB:89"
]
}
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pleno Component Test</title>
<script type="module" crossorigin src="/assets/index-6ljr7rTu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cq5akw1E.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "Truck Wash Kundeportal",
"short_name": "Truck Wash",
"description": "Access your Truck Wash accounts and transactions from anywhere.",
"id": "/",
"icons": [
{
"src": "assets/favicons/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "assets/favicons/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"start_url": "/",
"display": "standalone",
"background_color": "#0787bb",
"theme_color": "#063651",
"orientation": "portrait",
"scope": "/"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+148
View File
@@ -0,0 +1,148 @@
import { existsSync } from "node:fs";
import { argv, env, exit } from "node:process";
const args = new Set(argv.slice(2));
const failures = [];
const requireVariable = (name) => {
if (!env[name]) {
failures.push(`Missing ${name}`);
}
};
const requireOneVariable = (names, label) => {
if (!names.some((name) => env[name])) {
failures.push(`Missing ${label}: set one of ${names.join(", ")}`);
}
};
const decodeBase64 = (name) => {
if (!env[name]) {
return null;
}
try {
const decoded = Buffer.from(env[name], "base64");
if (decoded.length === 0) {
failures.push(`${name} is empty after base64 decoding`);
return null;
}
return decoded;
} catch {
failures.push(`${name} is not valid base64`);
return null;
}
};
const decodeBase64Json = (name) => {
const decoded = decodeBase64(name);
if (!decoded) {
return null;
}
try {
return JSON.parse(decoded.toString("utf8"));
} catch {
failures.push(`${name} is not base64-encoded JSON`);
return null;
}
};
const isEnabled = (name) => !["false", "0", "no"].includes(String(env[name] ?? "true").toLowerCase());
const checkAndroid = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireOneVariable(["ANDROID_KEYSTORE_BASE64", "ANDROID_KEYSTORE_FILE"], "Android release keystore");
requireVariable("ANDROID_KEYSTORE_PASSWORD");
requireVariable("ANDROID_KEY_ALIAS");
requireVariable("ANDROID_KEY_PASSWORD");
if (env.ANDROID_KEYSTORE_FILE && !existsSync(env.ANDROID_KEYSTORE_FILE)) {
failures.push("ANDROID_KEYSTORE_FILE does not point to an existing file");
}
decodeBase64("ANDROID_KEYSTORE_BASE64");
if (!isEnabled("UPLOAD_ANDROID_TO_PLAY")) {
return;
}
requireVariable("ANDROID_PACKAGE_NAME");
requireVariable("ANDROID_AAB_PATH");
requireVariable("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64");
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
if (!validTracks.has(track)) {
failures.push(`PLAY_STORE_TRACK must be one of ${Array.from(validTracks).join(", ")}`);
}
if (!validStatuses.has(status)) {
failures.push(`PLAY_STORE_RELEASE_STATUS must be one of ${Array.from(validStatuses).join(", ")}`);
}
if (status === "inProgress") {
const fraction = Number(env.PLAY_STORE_USER_FRACTION);
if (!(fraction > 0 && fraction < 1)) {
failures.push("PLAY_STORE_USER_FRACTION must be greater than 0 and less than 1 when status is inProgress");
}
}
const serviceAccount = decodeBase64Json("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64");
if (serviceAccount) {
if (!serviceAccount.client_email) {
failures.push("Google Play service account JSON is missing client_email");
}
if (!serviceAccount.private_key) {
failures.push("Google Play service account JSON is missing private_key");
}
}
};
const checkIos = () => {
requireVariable("MOBILE_VERSION_NAME");
requireVariable("MOBILE_VERSION_CODE");
requireVariable("IOS_CERTIFICATE_BASE64");
requireVariable("IOS_CERTIFICATE_PASSWORD");
requireVariable("IOS_PROVISION_PROFILE_BASE64");
requireVariable("IOS_KEYCHAIN_PASSWORD");
requireVariable("APPLE_TEAM_ID");
decodeBase64("IOS_CERTIFICATE_BASE64");
decodeBase64("IOS_PROVISION_PROFILE_BASE64");
if (!isEnabled("UPLOAD_IOS_TO_APP_STORE")) {
return;
}
requireVariable("APP_STORE_CONNECT_API_KEY_ID");
requireVariable("APP_STORE_CONNECT_ISSUER_ID");
requireVariable("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
const privateKey = decodeBase64("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64");
if (privateKey && !privateKey.toString("utf8").includes("PRIVATE KEY")) {
failures.push("APP_STORE_CONNECT_API_PRIVATE_KEY_BASE64 does not look like a .p8 private key");
}
};
if (args.has("--android")) {
checkAndroid();
}
if (args.has("--ios")) {
checkIos();
}
if (!args.has("--android") && !args.has("--ios")) {
failures.push("Pass --android or --ios");
}
if (failures.length > 0) {
console.error("Mobile store upload environment is not configured:");
for (const failure of failures) {
console.error(`- ${failure}`);
}
exit(1);
}
console.log("Mobile store upload environment is configured.");
+260
View File
@@ -0,0 +1,260 @@
import { createSign } from "node:crypto";
import { existsSync, readFileSync, appendFileSync } from "node:fs";
import { env, exit } from "node:process";
const androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher";
const requiredVariables = [
"ANDROID_PACKAGE_NAME",
"ANDROID_AAB_PATH",
"GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64",
"MOBILE_VERSION_NAME",
"MOBILE_VERSION_CODE",
];
const fail = (message) => {
console.error(message);
exit(1);
};
const requireEnvironment = () => {
const missingVariables = requiredVariables.filter((name) => !env[name]);
if (missingVariables.length > 0) {
fail(`Google Play upload is not configured. Missing: ${missingVariables.join(", ")}`);
}
if (!existsSync(env.ANDROID_AAB_PATH)) {
fail(`Android App Bundle not found at ${env.ANDROID_AAB_PATH}`);
}
};
const base64Url = (value) =>
Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
const parseServiceAccount = () => {
let serviceAccount;
try {
serviceAccount = JSON.parse(Buffer.from(env.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64, "base64").toString("utf8"));
} catch {
fail("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64 must be base64-encoded service account JSON.");
}
if (!serviceAccount.client_email || !serviceAccount.private_key) {
fail("Google Play service account JSON must include client_email and private_key.");
}
return serviceAccount;
};
const createJwtAssertion = (serviceAccount) => {
const now = Math.floor(Date.now() / 1000);
const header = {
alg: "RS256",
typ: "JWT",
};
const claims = {
iss: serviceAccount.client_email,
scope: androidPublisherScope,
aud: "https://oauth2.googleapis.com/token",
exp: now + 3600,
iat: now,
};
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(claims))}`;
const signer = createSign("RSA-SHA256");
signer.update(signingInput);
signer.end();
return `${signingInput}.${base64Url(signer.sign(serviceAccount.private_key))}`;
};
const readJsonResponse = async (response, label) => {
const text = await response.text();
let body = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = { raw: text };
}
}
if (!response.ok) {
const detail = body?.error?.message || body?.raw || response.statusText;
throw new Error(`${label} failed with HTTP ${response.status}: ${detail}`);
}
return body;
};
const requestJson = async (url, options, label) => {
const response = await fetch(url, options);
return readJsonResponse(response, label);
};
const getAccessToken = async (serviceAccount) => {
const body = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: createJwtAssertion(serviceAccount),
});
const tokenResponse = await requestJson(
"https://oauth2.googleapis.com/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
},
"Google OAuth token request",
);
if (!tokenResponse?.access_token) {
throw new Error("Google OAuth token response did not include access_token.");
}
return tokenResponse.access_token;
};
const playUrl = (path) => `https://androidpublisher.googleapis.com/androidpublisher/v3/${path}`;
const playUploadUrl = (path) => `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/${path}`;
const authorizedJson = (accessToken, extraHeaders = {}) => ({
Authorization: `Bearer ${accessToken}`,
...extraHeaders,
});
const insertEdit = async (accessToken, packageName) =>
requestJson(
playUrl(`applications/${encodeURIComponent(packageName)}/edits`),
{
method: "POST",
headers: authorizedJson(accessToken),
},
"Google Play edit insert",
);
const deleteEdit = async (accessToken, packageName, editId) => {
const response = await fetch(playUrl(`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}`), {
method: "DELETE",
headers: authorizedJson(accessToken),
});
if (!response.ok && response.status !== 404) {
const body = await response.text();
console.warn(`Could not delete failed Google Play edit ${editId}: HTTP ${response.status} ${body}`);
}
};
const uploadBundle = async (accessToken, packageName, editId, bundlePath) =>
requestJson(
`${playUploadUrl(
`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/bundles`,
)}?uploadType=media`,
{
method: "POST",
headers: authorizedJson(accessToken, {
"Content-Type": "application/octet-stream",
}),
body: readFileSync(bundlePath),
},
"Google Play bundle upload",
);
const updateTrack = async (accessToken, packageName, editId, versionCode) => {
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
const validTracks = new Set(["production", "beta", "alpha", "internal"]);
const validStatuses = new Set(["completed", "draft", "inProgress", "halted"]);
if (!validTracks.has(track)) {
throw new Error(`Unsupported PLAY_STORE_TRACK: ${track}`);
}
if (!validStatuses.has(status)) {
throw new Error(`Unsupported PLAY_STORE_RELEASE_STATUS: ${status}`);
}
const release = {
name: env.PLAY_STORE_RELEASE_NAME || `Truck Wash ${env.MOBILE_VERSION_NAME} (${versionCode})`,
versionCodes: [String(versionCode)],
status,
};
if (status === "inProgress") {
const userFraction = Number(env.PLAY_STORE_USER_FRACTION);
if (!(userFraction > 0 && userFraction < 1)) {
throw new Error("PLAY_STORE_USER_FRACTION must be greater than 0 and less than 1 when status is inProgress.");
}
release.userFraction = userFraction;
}
return requestJson(
playUrl(
`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/tracks/${encodeURIComponent(track)}`,
),
{
method: "PUT",
headers: authorizedJson(accessToken, {
"Content-Type": "application/json",
}),
body: JSON.stringify({
track,
releases: [release],
}),
},
"Google Play track update",
);
};
const commitEdit = async (accessToken, packageName, editId) =>
requestJson(
playUrl(`applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}:commit`),
{
method: "POST",
headers: authorizedJson(accessToken),
},
"Google Play edit commit",
);
const writeStepSummary = (summary) => {
if (!env.GITHUB_STEP_SUMMARY) {
return;
}
appendFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n`);
};
const main = async () => {
requireEnvironment();
const packageName = env.ANDROID_PACKAGE_NAME;
const serviceAccount = parseServiceAccount();
const accessToken = await getAccessToken(serviceAccount);
let editId = null;
try {
const edit = await insertEdit(accessToken, packageName);
editId = edit.id;
if (!editId) {
throw new Error("Google Play edit insert response did not include id.");
}
const bundle = await uploadBundle(accessToken, packageName, editId, env.ANDROID_AAB_PATH);
const versionCode = String(bundle?.versionCode || env.MOBILE_VERSION_CODE);
await updateTrack(accessToken, packageName, editId, versionCode);
await commitEdit(accessToken, packageName, editId);
const track = env.PLAY_STORE_TRACK || "production";
const status = env.PLAY_STORE_RELEASE_STATUS || "completed";
console.log(`Uploaded Android App Bundle ${versionCode} to Google Play ${track} with status ${status}.`);
writeStepSummary(`Android App Bundle ${versionCode} uploaded to Google Play ${track} with status ${status}.`);
} catch (error) {
if (editId) {
await deleteEdit(accessToken, packageName, editId);
}
throw error;
}
};
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
exit(1);
});
+12 -1
View File
@@ -175,10 +175,21 @@ export const sourceMappings = [
},
{
name: "system-status",
patterns: [/system[-/]?status/iu, /systemDatabase/iu, /replication/iu],
patterns: [/system[-/]?status/iu, /system(?:Database|Redis|Minio)/iu, /SystemDependencyDisplay/iu, /replication/iu],
specs: ["tests/e2e/superuser-system-status.smoke.spec.js"],
projects: chromiumProjects,
},
{
name: "superuser-security",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/system\/SystemSecurity\.vue$/u,
/^src\/services\/superuserSecurity\.js$/u,
/superuser[-/]?security/iu,
/system\/security/iu,
],
specs: ["tests/e2e/superuser-security.spec.ts"],
projects: chromiumProjects,
},
{
name: "superuser-department-pricing",
patterns: [
+1 -1
View File
@@ -80,7 +80,6 @@ export const ownedFilesByRole = {
"pos.visual.spec.js",
],
superuser: [
"coolify-infrastructure.spec.js",
"edge-gateways.fleet-outline.spec.js",
"edge-gateways.routes.spec.js",
"edge-gateways.smoke.spec.js",
@@ -114,6 +113,7 @@ export const ownedFilesByRole = {
"superuser-orders-date-filters.spec.ts",
"superuser-products-layout.spec.ts",
"superuser-roles-permissions.spec.ts",
"superuser-security.spec.ts",
"superuser-system-status.smoke.spec.js",
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js",
+95 -16
View File
@@ -6,6 +6,7 @@ import favicon from "@/assets/favicon.ico";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
import {
releaseChannelSwitchedStatus,
releaseChannelUnavailableStatus,
@@ -16,6 +17,20 @@ import {
const route = useRoute();
const { t, te, locale } = useI18n({ useScope: "global" });
const APP_TITLE = "Truck Wash";
const pageRouteLoadingProps = {
title: "Indlæser side...",
subtitle: "Vi gør siden klar",
testId: "app-route-loading",
statusTestId: "app-route-loading-status",
ariaLabel: "Indlæser side",
};
const sessionRouteLoadingProps = {
title: "Bekræfter bruger...",
subtitle: "Vi indlæser din session",
testId: "app-route-loading",
statusTestId: "app-route-loading-status",
ariaLabel: "Bekræfter bruger",
};
const LayoutV2 = defineAsyncComponent(() => import("@/components/page/wrappers/LayoutV2.vue"));
const DefaultPageWrapper = defineAsyncComponent(() => import("@/components/page/wrappers/DefaultPageWrapper.vue"));
const RequestQueueProgress = defineAsyncComponent(() => import("@/components/global/RequestQueueProgress.vue"));
@@ -81,6 +96,42 @@ const shouldRenderReleaseChannelSwitched = computed(
const shouldHideGlobalOverlays = computed(
() => shouldRenderReleaseChannelUnavailable.value || shouldRenderReleaseChannelSwitched.value
);
const sessionLoaderRoutePattern = /^\/(?:user|admin|superuser|backoffice)(?:\/|$)/;
const guestSessionLoaderRoutePattern =
/^\/(?:$|login(?:\/driver|\/qr)?$|admin\/login$|register$|auth\/password-reset(?:\/|$)|qr\/new-(?:customer|driver)$)/;
const currentBrowserPath = () => {
if (typeof window === "undefined") {
return "";
}
return window.location.pathname.replace(/^\/[^/]+\/frontend(?=\/|$)/, "") || "/";
};
const routeMiddlewareList = () =>
route.matched.flatMap((record) => {
const middleware = record.meta?.middleware;
if (!middleware) {
return [];
}
return Array.isArray(middleware) ? middleware : [middleware];
});
const hasRouteMiddleware = (middlewareName) =>
routeMiddlewareList().some((middleware) => middleware?.name === middlewareName);
const shouldUseSessionPathLoader = (path) =>
sessionLoaderRoutePattern.test(path || "") &&
(!guestSessionLoaderRoutePattern.test(path || "") || hasStoredSessionToken());
const shouldUseGuestSessionPathLoader = (path) =>
guestSessionLoaderRoutePattern.test(path || "") && hasStoredSessionToken();
const shouldUseSessionRouteLoader = computed(() =>
route.name !== "default" &&
(shouldUseSessionPathLoader(route.path || "") ||
shouldUseSessionPathLoader(currentBrowserPath()) ||
shouldUseGuestSessionPathLoader(route.path || "") ||
shouldUseGuestSessionPathLoader(currentBrowserPath()) ||
hasRouteMiddleware("authMiddleware") ||
(hasStoredSessionToken() && hasRouteMiddleware("guestMiddleware")))
);
const appRouteLoadingProps = computed(() =>
shouldUseSessionRouteLoader.value ? sessionRouteLoadingProps : pageRouteLoadingProps
);
const initiateStoredSession = async () => {
if (!hasStoredSessionToken()) {
@@ -111,22 +162,50 @@ watch([() => route.fullPath, locale], updateDocumentTitle, { immediate: true });
</script>
<template>
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
<router-view />
</LayoutV2>
<main v-else-if="route.meta?.template === 'clear-main'">
<router-view />
</main>
<template v-else>
<header></header>
<main>
<DefaultPageWrapper>
<router-view />
</DefaultPageWrapper>
</main>
</template>
<Suspense :timeout="0">
<template #default>
<ReleaseChannelUnavailable v-if="shouldRenderReleaseChannelUnavailable" />
<ReleaseChannelSwitched v-else-if="shouldRenderReleaseChannelSwitched" />
<LayoutV2 v-else-if="route.meta?.template === 'with-header'">
<router-view v-slot="{ Component }">
<Suspense :timeout="0">
<component :is="Component" />
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
</router-view>
</LayoutV2>
<main v-else-if="route.meta?.template === 'clear-main'">
<router-view v-slot="{ Component }">
<Suspense :timeout="0">
<component :is="Component" />
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
</router-view>
</main>
<template v-else>
<header></header>
<main>
<DefaultPageWrapper>
<router-view v-slot="{ Component }">
<Suspense :timeout="0">
<component :is="Component" />
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
</router-view>
</DefaultPageWrapper>
</main>
</template>
</template>
<template #fallback>
<BrandedLoadingScreen v-bind="appRouteLoadingProps" />
</template>
</Suspense>
<RequestQueueProgress v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<ErrorReportLauncher v-if="shouldRenderRequestQueueProgress && !shouldHideGlobalOverlays" />
<FrontendMaintenanceMenu />
+339
View File
@@ -0,0 +1,339 @@
<script setup lang="ts">
import { computed, provide, reactive, watch } from "vue";
import BuefyTreeNode from "./BuefyTreeNode.vue";
type TreeNode = Record<string, any>;
const props = withDefaults(defineProps<{
data: TreeNode[];
fields?: Partial<{
id: string;
label: string;
children: string;
isLeaf: string;
disabled: string;
}>;
selectionMode?: "none" | "single" | "multiple" | "checkbox";
selected?: any;
expandedKeys?: any[];
checkedKeys?: any[];
defaultExpandAll?: boolean;
expandOnClickNode?: boolean;
lazy?: boolean;
load?: (node: TreeNode) => Promise<TreeNode[]>;
ariaLabel?: string;
}>(), {
data: () => [],
fields: () => ({}),
selectionMode: "none",
selected: null,
expandedKeys: () => [],
checkedKeys: () => [],
defaultExpandAll: false,
expandOnClickNode: true,
lazy: false,
load: undefined,
ariaLabel: undefined,
});
const emit = defineEmits<{
(event: "update:selected", value: any): void;
(event: "update:expandedKeys", value: any[]): void;
(event: "update:checkedKeys", value: any[]): void;
(event: "select", node: TreeNode, key: any): void;
(event: "check", node: TreeNode, key: any, checkedKeys: any[]): void;
(event: "expand", node: TreeNode, key: any): void;
(event: "collapse", node: TreeNode, key: any): void;
(event: "node-click", node: TreeNode, key: any): void;
(event: "load-start", node: TreeNode, key: any): void;
(event: "load-error", error: unknown, node: TreeNode, key: any): void;
}>();
const resolvedFields = computed(() => ({
id: props.fields?.id || "id",
label: props.fields?.label || "label",
children: props.fields?.children || "children",
isLeaf: props.fields?.isLeaf || "isLeaf",
disabled: props.fields?.disabled || "disabled",
}));
const state = reactive({
selected: props.selected,
expandedKeys: [...props.expandedKeys],
checkedKeys: [...props.checkedKeys],
lazyChildrenCache: {} as Record<string, TreeNode[]>,
loadingKeys: [] as any[],
loadErrorKeys: [] as any[],
});
const keyOf = (node: TreeNode) => node?.[resolvedFields.value.id];
const keyString = (key: any) => String(key ?? "");
const childrenOf = (node: TreeNode) => {
const children = node?.[resolvedFields.value.children];
return Array.isArray(children) ? children : [];
};
const cachedChildrenOf = (node: TreeNode) => state.lazyChildrenCache[keyString(keyOf(node))] || [];
const effectiveChildrenOf = (node: TreeNode) => {
const children = childrenOf(node);
return children.length > 0 ? children : cachedChildrenOf(node);
};
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
const isCheckDisabled = (node: TreeNode) => isDisabled(node) || (!isSelfSelectable(node) && !isBranchCheckable(node));
const isLeaf = (node: TreeNode) => Boolean(node?.[resolvedFields.value.isLeaf]);
const collectKeys = (node: TreeNode): any[] => {
const keys: any[] = [];
const key = keyOf(node);
if (key !== undefined && key !== null && isSelfSelectable(node)) {
keys.push(key);
}
effectiveChildrenOf(node).forEach((child) => {
keys.push(...collectKeys(child));
});
return keys;
};
const collectAllExpandableKeys = (nodes: TreeNode[]): any[] => {
const keys: any[] = [];
nodes.forEach((node) => {
const key = keyOf(node);
if (key !== undefined && key !== null && !isLeaf(node)) {
keys.push(key);
}
keys.push(...collectAllExpandableKeys(childrenOf(node)));
});
return keys;
};
watch(
() => props.selected,
(value) => {
state.selected = value;
}
);
watch(
() => props.expandedKeys,
(value) => {
state.expandedKeys = [...value];
},
{ deep: true }
);
watch(
() => props.checkedKeys,
(value) => {
state.checkedKeys = [...value];
},
{ deep: true }
);
watch(
() => props.data,
(nodes) => {
if (props.defaultExpandAll) {
state.expandedKeys = collectAllExpandableKeys(nodes);
emit("update:expandedKeys", [...state.expandedKeys]);
}
},
{ immediate: true, deep: true }
);
const setExpandedKeys = (keys: any[]) => {
state.expandedKeys = [...keys];
emit("update:expandedKeys", [...state.expandedKeys]);
};
const setCheckedKeys = (keys: any[]) => {
state.checkedKeys = [...new Set(keys)];
emit("update:checkedKeys", [...state.checkedKeys]);
};
const loadNode = async (node: TreeNode) => {
if (!props.lazy || !props.load) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (state.loadingKeys.includes(key)) {
return;
}
state.loadingKeys = [...state.loadingKeys, key];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
emit("load-start", node, key);
try {
state.lazyChildrenCache[keyString(key)] = await props.load(node);
} catch (error) {
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
emit("load-error", error, node, key);
} finally {
state.loadingKeys = state.loadingKeys.filter((current) => current !== key);
}
};
const ensureChildrenLoadedForCheck = async (node: TreeNode) => {
if (!props.lazy || !props.load || isLeaf(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (childrenOf(node).length > 0 || state.lazyChildrenCache[keyString(key)]) {
return;
}
await loadNode(node);
};
const toggleExpand = async (node: TreeNode) => {
if (isDisabled(node) || isLeaf(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
if (state.expandedKeys.includes(key)) {
setExpandedKeys(state.expandedKeys.filter((current) => current !== key));
emit("collapse", node, key);
return;
}
setExpandedKeys([...state.expandedKeys, key]);
emit("expand", node, key);
if (props.lazy && !state.lazyChildrenCache[keyString(key)]) {
await loadNode(node);
}
};
const retryLoad = async (node: TreeNode) => {
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
delete state.lazyChildrenCache[keyString(key)];
state.loadErrorKeys = state.loadErrorKeys.filter((current) => current !== key);
if (!state.expandedKeys.includes(key)) {
setExpandedKeys([...state.expandedKeys, key]);
}
await loadNode(node);
};
const toggleCheck = async (node: TreeNode) => {
if (props.selectionMode !== "checkbox" || isCheckDisabled(node)) {
return;
}
const key = keyOf(node);
if (key === undefined || key === null) {
return;
}
await ensureChildrenLoadedForCheck(node);
const keys = collectKeys(node);
if (keys.length === 0) {
emit("check", node, key, [...state.checkedKeys]);
return;
}
const allChecked = keys.every((current) => state.checkedKeys.includes(current));
if (allChecked) {
const toRemove = new Set(keys);
setCheckedKeys(state.checkedKeys.filter((current) => !toRemove.has(current)));
} else {
setCheckedKeys([...state.checkedKeys, ...keys]);
}
emit("check", node, key, [...state.checkedKeys]);
};
const checkState = (node: TreeNode): "checked" | "unchecked" | "indeterminate" => {
const key = keyOf(node);
const children = effectiveChildrenOf(node);
if (children.length === 0) {
return state.checkedKeys.includes(key) ? "checked" : "unchecked";
}
const states = children.map((child) => checkState(child));
if (states.every((item) => item === "checked")) {
return "checked";
}
if (states.every((item) => item === "unchecked") && !state.checkedKeys.includes(key)) {
return "unchecked";
}
return "indeterminate";
};
const handleNodeClick = async (node: TreeNode) => {
if (isDisabled(node)) {
return;
}
const key = keyOf(node);
emit("node-click", node, key);
if (props.selectionMode === "single") {
state.selected = key;
emit("update:selected", key);
emit("select", node, key);
} else if (props.selectionMode === "multiple") {
const selected = Array.isArray(state.selected) ? state.selected : [];
state.selected = selected.includes(key)
? selected.filter((current) => current !== key)
: [...selected, key];
emit("update:selected", state.selected);
emit("select", node, key);
}
if (props.expandOnClickNode) {
await toggleExpand(node);
}
};
provide("BuefyTreeContext", {
props,
state,
fields: resolvedFields,
keyOf,
childrenOf,
effectiveChildrenOf,
isDisabled,
isCheckDisabled,
isLeaf,
toggleExpand,
retryLoad,
toggleCheck,
checkState,
handleNodeClick,
});
</script>
<template>
<ul class="b-tree" role="tree" :aria-label="ariaLabel">
<BuefyTreeNode
v-for="(node, index) in data"
:key="keyOf(node) ?? index"
:node="node"
:depth="1"
:setsize="data.length"
:posinset="index + 1"
>
<template #default="slotProps">
<slot v-bind="slotProps">
{{ slotProps.node?.[resolvedFields.label] }}
</slot>
</template>
<template #icon="slotProps">
<slot name="icon" v-bind="slotProps"></slot>
</template>
</BuefyTreeNode>
</ul>
</template>
<style scoped>
.b-tree {
list-style: none;
margin: 0;
padding: 0;
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { BCheckbox } from "buefy";
type TreeNode = Record<string, any>;
const props = defineProps<{
node: TreeNode;
depth: number;
setsize: number;
posinset: number;
}>();
const tree = inject<any>("BuefyTreeContext");
if (!tree) {
throw new Error("BuefyTreeNode must be used inside BuefyTree");
}
const keyValue = computed(() => tree.keyOf(props.node));
const keyString = computed(() => String(keyValue.value ?? ""));
const children = computed(() => tree.effectiveChildrenOf(props.node));
const checkState = computed(() => tree.checkState(props.node));
const isExpanded = computed(() => tree.state.expandedKeys.includes(keyValue.value));
const isLoading = computed(() => tree.state.loadingKeys.includes(keyValue.value));
const hasLoadError = computed(() => tree.state.loadErrorKeys.includes(keyValue.value));
const isSelected = computed(() => {
const selected = tree.state.selected;
return Array.isArray(selected) ? selected.includes(keyValue.value) : selected === keyValue.value;
});
const isDisabled = computed(() => tree.isDisabled(props.node));
const isCheckDisabled = computed(() => tree.isCheckDisabled(props.node));
const isLeaf = computed(() => tree.isLeaf(props.node));
const hasExpandToggle = computed(() => {
if (isLeaf.value) {
return false;
}
return tree.props.lazy || children.value.length > 0;
});
const label = computed(() => String(props.node?.[tree.fields.value.label] ?? ""));
</script>
<template>
<li
class="b-tree-node"
:class="{
'is-expanded': isExpanded,
'is-selected': isSelected,
'is-disabled': isDisabled,
'is-loading': isLoading,
'has-load-error': hasLoadError,
}"
role="treeitem"
:aria-expanded="hasExpandToggle ? isExpanded : undefined"
:aria-selected="tree.props.selectionMode !== 'checkbox' && tree.props.selectionMode !== 'none' ? isSelected : undefined"
:aria-checked="tree.props.selectionMode === 'checkbox' ? (checkState === 'indeterminate' ? 'mixed' : checkState === 'checked') : undefined"
:aria-level="depth"
:aria-setsize="setsize"
:aria-posinset="posinset"
:aria-disabled="isDisabled || undefined"
:data-node-key="keyString"
>
<div class="b-tree-node-content" @click.stop="tree.handleNodeClick(node)">
<button
type="button"
class="button is-white is-small b-tree-node-toggle"
:class="{ 'is-invisible': !hasExpandToggle }"
:disabled="isDisabled"
@click.stop="tree.toggleExpand(node)"
>
<span class="icon is-small">
<i v-if="isLoading" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas" :class="isExpanded ? 'fa-caret-down' : 'fa-caret-right'"></i>
</span>
</button>
<span
v-if="tree.props.selectionMode === 'checkbox'"
class="b-tree-node-checkbox"
@click.stop
>
<BCheckbox
:model-value="checkState === 'checked'"
:indeterminate="checkState === 'indeterminate'"
:disabled="isCheckDisabled"
tabindex="-1"
@update:model-value="tree.toggleCheck(node)"
/>
</span>
<span class="b-tree-node-icon">
<slot name="icon" :node="node" :expanded="isExpanded" :loading="isLoading" :error="hasLoadError">
<span class="icon is-small">
<i class="fas" :class="hasExpandToggle ? (isExpanded ? 'fa-folder-open' : 'fa-folder') : 'fa-file'"></i>
</span>
</slot>
</span>
<span class="b-tree-node-label">
<slot
:node="node"
:data="node"
:depth="depth"
:expanded="isExpanded"
:checked="checkState === 'checked'"
:indeterminate="checkState === 'indeterminate'"
:selected="isSelected"
:loading="isLoading"
:error="hasLoadError"
:retry="() => tree.retryLoad(node)"
>
{{ label }}
</slot>
</span>
</div>
<ul v-if="isExpanded && children.length > 0" class="b-tree-children" role="group">
<BuefyTreeNode
v-for="(child, index) in children"
:key="tree.keyOf(child) ?? index"
:node="child"
:depth="depth + 1"
:setsize="children.length"
:posinset="index + 1"
>
<template #default="slotProps">
<slot v-bind="slotProps"></slot>
</template>
<template #icon="slotProps">
<slot name="icon" v-bind="slotProps"></slot>
</template>
</BuefyTreeNode>
</ul>
</li>
</template>
<style scoped>
.b-tree-node {
list-style: none;
}
.b-tree-node-content {
align-items: flex-start;
border-radius: 6px;
cursor: pointer;
display: flex;
gap: 0.35rem;
min-height: 2rem;
padding: 0.25rem 0.35rem;
}
.b-tree-node-content:hover {
background: #f5f7fa;
}
.b-tree-node.is-selected > .b-tree-node-content {
background: #eef4ff;
}
.b-tree-node.is-disabled > .b-tree-node-content {
cursor: default;
opacity: 0.65;
}
.b-tree-node-toggle {
flex: 0 0 1.85rem;
height: 1.75rem;
padding: 0;
width: 1.85rem;
}
.b-tree-node-checkbox {
flex: 0 0 auto;
margin-top: 0.08rem;
}
.b-tree-node-icon {
flex: 0 0 auto;
margin-top: 0.2rem;
}
.b-tree-node-label {
flex: 1 1 auto;
min-width: 0;
}
.b-tree-children {
border-left: 1px solid #dfe4ea;
list-style: none;
margin: 0 0 0 1.05rem;
padding: 0 0 0 0.65rem;
}
.is-invisible {
visibility: hidden;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,10 @@ const props = defineProps({
clickAction: Function,
icon: String,
label: String,
detailValue: {
type: [String, Number],
default: "",
},
disabled: Boolean,
testId: {
type: String,
@@ -107,6 +111,10 @@ const getLabel = () => {
return props.label ?? 'Unavngivet handling';
}
const hasDetailValue = () => {
return props.detailValue !== undefined && props.detailValue !== null && String(props.detailValue).length > 0;
}
const getStyle = () => {
return styles[props.template] ?? styles.default;
}
@@ -140,18 +148,40 @@ const getLabelColor = () => {
<span class="icon">
<i :class="getIcon() + ' ' + getIconColor()"></i>
</span>
<span class="ml-1"
<span class="dropdown-item-action__label ml-1"
:class="getLabelColor()"
>{{ getLabel() }}</span>
<span v-if="hasDetailValue()" class="dropdown-item-action__detail">
{{ props.detailValue }}
</span>
</button>
</template>
<style scoped>
.dropdown-item-action {
width: 100%;
text-align: left;
align-items: center;
background: transparent;
border: 0;
display: flex;
gap: 0.25rem;
text-align: left;
width: 100%;
}
.dropdown-item-action__label {
min-width: 0;
overflow-wrap: anywhere;
}
.dropdown-item-action__detail {
color: #718095;
font-size: 0.86rem;
font-weight: 600;
margin-left: auto;
min-width: 0;
overflow-wrap: anywhere;
padding-left: 0.75rem;
text-align: right;
}
.is-disabled {
@@ -8,6 +8,14 @@ const props = defineProps({
default: undefined,
},
label: String,
detailValue: {
type: [String, Number],
default: "",
},
appearance: {
type: String,
default: "default",
},
template: {
type: String,
default: 'default', // The style of the button (default, danger, success, warning, info, light)
@@ -86,6 +94,10 @@ const getLabel = () => {
return props.label ?? 'Unavngivet handling';
}
const hasDetailValue = () => {
return props.detailValue !== undefined && props.detailValue !== null && String(props.detailValue).length > 0;
}
const getStyle = () => {
return styles[props.template] ?? styles.default;
}
@@ -109,25 +121,55 @@ const getLabelColor = () => {
const hasIcon = () => {
return props.icon !== undefined && props.icon !== null;
}
const isMetadataAppearance = () => props.appearance === "metadata";
</script>
<template>
<a class="dropdown-item is-disabled">
<span class="icon" v-if="hasIcon()">
<i :class="getIcon() + ' ' + getIconColor()"></i>
</span>
<span class="ml-1"
:class="getLabelColor()"
>{{ SessionUser.functions.ucFirst(getLabel()) }}</span>
<a
class="dropdown-item dropdown-item-label is-disabled"
:class="{ 'dropdown-item-label--metadata': isMetadataAppearance() }"
>
<span class="icon" v-if="hasIcon()">
<i :class="getIcon() + ' ' + getIconColor()"></i>
</span>
<span class="dropdown-item-label__text ml-1" :class="getLabelColor()">
{{ SessionUser.functions.ucFirst(getLabel()) }}
</span>
<span v-if="hasDetailValue()" class="dropdown-item-label__detail">
{{ props.detailValue }}
</span>
</a>
</template>
<style scoped>
.is-disabled {
align-items: center;
display: flex;
justify-content: flex-start;
pointer-events: none;
text-align: left;
width: 100%;
opacity: 0.5;
}
.dropdown-item-label__text {
min-width: 0;
overflow-wrap: anywhere;
text-align: left;
}
.dropdown-item-label__detail {
color: #718095;
font-size: 0.86rem;
font-weight: 600;
margin-left: auto;
min-width: 0;
overflow-wrap: anywhere;
padding-left: 0.75rem;
text-align: right;
}
.is-disabled:hover {
background-color: transparent;
}
@@ -135,4 +177,22 @@ const hasIcon = () => {
.is-disabled .icon {
opacity: 0.5;
}
</style>
.dropdown-item-label--metadata {
opacity: 1;
}
.dropdown-item-label--metadata .icon {
opacity: 1;
}
.dropdown-item-label--metadata .dropdown-item-label__text {
color: #4a5b73;
font-weight: 600;
}
.dropdown-item-label--metadata .dropdown-item-label__detail {
color: #25344d;
font-weight: 700;
}
</style>
@@ -0,0 +1,129 @@
<script setup>
import { computed, ref, watch } from "vue";
const props = defineProps({
changeAction: {
type: Function,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: "fas fa-cog",
},
label: {
type: String,
default: "",
},
options: {
type: Array,
default: () => [],
},
testId: {
type: String,
default: "",
},
value: {
type: [String, Number, Boolean],
default: "",
},
});
const root = ref(null);
const normalizeSelectValue = (value) => String(value);
const localValue = ref(normalizeSelectValue(props.value));
const isProcessing = ref(false);
watch(
() => props.value,
(value) => {
localValue.value = normalizeSelectValue(value);
}
);
const isDisabled = computed(() => props.disabled || isProcessing.value);
const optionKey = (option) => `${typeof option?.value}:${normalizeSelectValue(option?.value)}`;
const getInputValue = (value) => value?.target?.value ?? value;
const onInput = async (value) => {
const nextValue = normalizeSelectValue(getInputValue(value));
if (isDisabled.value || nextValue === normalizeSelectValue(props.value)) {
return;
}
isProcessing.value = true;
try {
if (props.changeAction) {
await props.changeAction(nextValue);
}
root.value?.dispatchEvent(new CustomEvent("dropdown-action-selected", { bubbles: true }));
} finally {
isProcessing.value = false;
}
};
</script>
<template>
<div
ref="root"
class="dropdown-item action-settings-wheel-select-item"
:class="{ 'is-disabled': isDisabled }"
:data-testid="props.testId || undefined"
>
<span class="icon action-settings-wheel-select-item__icon">
<i :class="props.icon"></i>
</span>
<span class="action-settings-wheel-select-item__label">
{{ props.label }}
</span>
<b-select
v-model="localValue"
size="is-small"
:disabled="isDisabled"
:aria-label="props.label"
:data-testid="props.testId ? `${props.testId}-select` : undefined"
@click.stop
@mousedown.stop
@update:modelValue="onInput"
>
<option v-for="option in props.options" :key="optionKey(option)" :value="normalizeSelectValue(option.value)">
{{ option.label }}
</option>
</b-select>
</div>
</template>
<style scoped>
.action-settings-wheel-select-item {
align-items: center;
display: flex;
gap: 0.35rem;
width: 100%;
}
.action-settings-wheel-select-item__icon {
flex: 0 0 auto;
}
.action-settings-wheel-select-item__label {
color: #25344d;
flex: 1 1 auto;
font-weight: 500;
min-width: 0;
overflow-wrap: anywhere;
}
.action-settings-wheel-select-item :deep(.select),
.action-settings-wheel-select-item :deep(select) {
max-width: 9.5rem;
}
.action-settings-wheel-select-item.is-disabled {
opacity: 0.55;
}
</style>
@@ -62,7 +62,11 @@ const onClick = async () => {
:title="title"
@click.stop.prevent="onClick"
>
<span class="action-settings-wheel-toggle-item__label">{{ label }}</span>
<span class="action-settings-wheel-toggle-item__label">
<slot name="label" :label="label">
{{ label }}
</slot>
</span>
<span class="action-settings-wheel-toggle-item__switch" :class="{ 'is-checked': checked }" aria-hidden="true">
<span class="action-settings-wheel-toggle-item__knob"></span>
</span>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { IS_DEV } from "@/config.js";
import { computed, ref, watch } from "vue";
import { computed, ref, useSlots, watch } from "vue";
import { useWindowSize } from "@vueuse/core";
import { BTooltip } from "buefy";
import { useI18n } from "vue-i18n";
@@ -124,6 +124,7 @@ const props = defineProps({
default: "dots",
},
});
const slots = useSlots();
const visibility = computed(() => ({
showStartDate: true,
@@ -150,6 +151,16 @@ const monthFieldRef = ref<InstanceType<typeof BuefyMonthField> | null>(null);
const isPendingEmit = ref(false);
const isMobileLayout = computed(() => width.value <= 768 || SessionUser.functions.device.isMobile());
const showMobileAdvancedControls = computed(() => visibility.value.showMonthSelector || visibility.value.showYearSelector);
const showDesktopSelectionControls = computed(() => (
Boolean(visibility.value.showStartDate)
|| Boolean(visibility.value.showEndDate)
|| Boolean(!visibility.value.showStartDate && !visibility.value.showEndDate && visibility.value.showToLabel)
|| Boolean(visibility.value.showMonthSelector)
|| Boolean(visibility.value.showYearSelector)
|| Boolean(visibility.value.showSelectionValidity)
|| Boolean(slots.left)
|| Boolean(slots.right)
));
const isMobileAdvancedExpanded = ref(false);
const isMobileOtherExpanded = ref(false);
const isOtherMonthModalOpen = ref(false);
@@ -537,7 +548,7 @@ const handleShortcutSelection = (event) => {
</div>
<template v-else>
<div class="level is-align-items-center">
<div v-if="showDesktopSelectionControls" class="level is-align-items-center">
<div class="level-left">
<div class="level-item" v-if="visibility.showStartDate">
<BuefyDateField
@@ -4,6 +4,7 @@ import {
customer_id,
customer_data,
customer_attributes,
department_id,
loadCustomerAttributes,
hideDiscountsCatalog,
hidePricesCatalog,
@@ -20,17 +21,11 @@ import "bulma-switch/dist/css/bulma-switch.min.css";
import "bulma-block-list/src/block-list.scss";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from "vue-i18n";
import {
popperBox,
popper,
removePopperIfOpen,
showPopperWithContent,
showPopper,
} from "@/components/displays/PopperDefault.vue";
import RequiresPermission from "@/components/displays/permissionbased/RequiresPermission.vue";
import CustomerDiscountsDepartmentDisplay from "@/components/displays/department/pos/displays/CustomerDiscountsDepartmentDisplay.vue";
import ExpandableContentBox from "@/components/displays/boxes/ExpandableContentBox.vue";
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
import CustomerRuleTooltip from "@/features/customer/CustomerRuleTooltip.vue";
const { t } = useI18n();
const emit = defineEmits(["update:activeTab"]);
@@ -276,12 +271,15 @@ const customer_data_has_empty_details = () => {
<span class="panel-icon pos-selected-customer__icon">
<i :class="attribute.icon" aria-hidden="true"></i>
</span>
<span
class="pos-selected-customer__label"
@mouseover="showPopper(popperBox(attribute.name, attribute.description), $event.target)"
@mouseleave="removePopperIfOpen()"
>{{ attribute.name }}</span
<CustomerRuleTooltip
:attribute="attribute.attribute"
:active="hasAttribute(attribute.prop)"
:customer-number="customer_id"
:department-id="department_id"
:test-id="`pos-customer-rule-tooltip-${attribute.attribute}`"
>
<span class="pos-selected-customer__label">{{ attribute.name }}</span>
</CustomerRuleTooltip>
<span class="pos-selected-customer__value">
<span
v-if="hasAttribute(attribute.prop)"
@@ -63,10 +63,10 @@ onBeforeUnmount(() => {
:class="{ 'is-active': isOpen }"
:data-testid="testId"
>
<div class="dropdown-trigger">
<div class="dropdown-trigger pagination-other-filters__trigger">
<button
type="button"
class="button is-light is-small pagination-other-filters__button"
class="button is-light pagination-other-filters__button"
:aria-expanded="isOpen ? 'true' : 'false'"
aria-haspopup="true"
:aria-label="label"
@@ -77,14 +77,14 @@ onBeforeUnmount(() => {
<i class="fas fa-filter" aria-hidden="true"></i>
</span>
<span>{{ label }}</span>
<span
v-if="normalizedActiveCount > 0"
class="tag is-info is-rounded is-small pagination-other-filters__count"
:data-testid="`${testId}-count`"
>
{{ normalizedActiveCount }}
</span>
</button>
<span
v-if="normalizedActiveCount > 0"
class="tag is-info is-rounded is-small pagination-other-filters__count"
:data-testid="`${testId}-count`"
>
{{ normalizedActiveCount }}
</span>
</div>
<div
@@ -93,20 +93,39 @@ onBeforeUnmount(() => {
:data-testid="`${testId}-menu`"
>
<div class="dropdown-content pagination-other-filters__content">
<slot />
<div class="pagination-other-filters__body">
<slot />
</div>
<div
v-if="$slots.footer"
class="pagination-other-filters__footer"
>
<slot name="footer" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
.pagination-other-filters__button {
.pagination-other-filters {
overflow: visible;
padding-right: 1.15rem;
position: relative;
z-index: 40;
}
.pagination-other-filters__button {
height: 2.25em;
padding-right: 1.15rem;
white-space: nowrap;
}
.pagination-other-filters__trigger {
display: inline-flex;
overflow: visible;
position: relative;
}
.pagination-other-filters__count {
align-items: center;
border: 2px solid #fff;
@@ -115,9 +134,12 @@ onBeforeUnmount(() => {
justify-content: center;
min-width: 1.15rem;
padding: 0 0.3rem;
pointer-events: none;
position: absolute;
right: -0.35rem;
top: -0.45rem;
right: 0;
top: 0;
transform: translate(50%, -50%);
z-index: 50;
}
.pagination-other-filters__menu {
@@ -127,8 +149,22 @@ onBeforeUnmount(() => {
.pagination-other-filters__content {
border: 1px solid #dbdbdb;
border-radius: 6px;
overflow: hidden;
padding: 0;
}
.pagination-other-filters__body {
max-height: min(70vh, 36rem);
overflow-y: auto;
padding: 0.75rem;
}
.pagination-other-filters__footer {
align-items: center;
background: #fff;
border-top: 1px solid #dbdbdb;
display: flex;
justify-content: flex-end;
padding: 0.5rem 0.75rem;
}
</style>
@@ -323,6 +323,25 @@ const activeHiddenOrderFiltersCount = computed(() => hiddenOrderFilterDefinition
return isActiveHiddenOrderFilterValue(value);
}).length);
const hasActiveHiddenOrderFilters = computed(() => activeHiddenOrderFiltersCount.value > 0);
const clearHiddenOrderFilters = () => {
if (!hasActiveHiddenOrderFilters.value) {
return;
}
hiddenOrderFilterDefinitions.value.forEach((filterDefinition) => {
if (filterDefinition.type === "order") {
setOrder("created_at", filterDefinition.defaultValue);
return;
}
setFilter(filterDefinition.filterKey, "*", false);
});
loadList();
};
const doesEndpointMatch = (matcher) => {
// Check if the endpoint matches the current endpoint
return endpoint.value === matcher;
@@ -454,56 +473,67 @@ watch(orderDateEventSignature, () => {
</div>
</div>
</template>
<template #rightFilterActions>
<div class="column is-narrow invoice-orders-other-filters-column">
<PaginationOtherFiltersDropdown
:label="t('pagination.other_filters')"
:active-count="activeHiddenOrderFiltersCount"
test-id="invoice-orders-other-filters"
>
<div class="invoice-orders-other-filters__fields">
<div
v-for="filterDefinition in hiddenOrderFilterDefinitions"
:key="filterDefinition.key"
class="field invoice-orders-other-filters__field"
<template #rightPaginationColumns>
<div class="column is-12 invoice-orders-shortcuts-column">
<div class="invoice-orders-shortcut-actions">
<div class="invoice-orders-other-filters-trigger">
<PaginationOtherFiltersDropdown
:label="t('pagination.other_filters')"
:active-count="activeHiddenOrderFiltersCount"
test-id="invoice-orders-other-filters"
>
<label
class="label is-small"
:for="`invoice-orders-other-filter-${filterDefinition.key}`"
>
{{ filterDefinition.label }}
</label>
<div class="control">
<div class="select is-fullwidth">
<select
:id="`invoice-orders-other-filter-${filterDefinition.key}`"
:value="getHiddenOrderFilterValue(filterDefinition)"
:data-testid="`invoice-orders-other-filter-${filterDefinition.key}`"
@change="applyHiddenOrderFilter(filterDefinition, $event.target.value)"
<div class="invoice-orders-other-filters__fields">
<div
v-for="filterDefinition in hiddenOrderFilterDefinitions"
:key="filterDefinition.key"
class="field invoice-orders-other-filters__field"
>
<label
class="label is-small"
:for="`invoice-orders-other-filter-${filterDefinition.key}`"
>
<option
v-for="option in filterDefinition.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
{{ filterDefinition.label }}
</label>
<div class="control">
<div class="select is-fullwidth">
<select
:id="`invoice-orders-other-filter-${filterDefinition.key}`"
:value="getHiddenOrderFilterValue(filterDefinition)"
:data-testid="`invoice-orders-other-filter-${filterDefinition.key}`"
@change="applyHiddenOrderFilter(filterDefinition, $event.target.value)"
>
<option
v-for="option in filterDefinition.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<template #footer>
<button
type="button"
class="button is-text is-small invoice-orders-other-filters__clear"
data-testid="invoice-orders-other-filters-clear"
:disabled="!hasActiveHiddenOrderFilters"
@click="clearHiddenOrderFilters"
>
{{ t('pagination.clear_all') }}
</button>
</template>
</PaginationOtherFiltersDropdown>
</div>
</PaginationOtherFiltersDropdown>
</div>
</template>
<template #rightPaginationColumns>
<div class="column is-12">
<!-- Shortcuts for date filters -->
<DatePeriodSelector :on-selection-change="onDateRangeSelected"
:visibility="{ showDailySelector: false, showWeeklySelector: false, showMultipleMonthWarning: false, showUpdateButton: false, showMonthSelector: false, showStartDate: false, showEndDate: false, showSelectionValidity: false, showYearSelector: false, showToLabel: false }"
v-bind:allow-empty-selection="true"
v-bind:events="orderDateEvents"
v-bind:selection="{ startDate: date_from ? parseLocalDateOnly(date_from) : null, endDate: date_to ? parseLocalDateOnly(date_to) : null }"/>
<!-- Shortcuts for date filters -->
<DatePeriodSelector :on-selection-change="onDateRangeSelected"
:visibility="{ showDailySelector: false, showWeeklySelector: false, showMultipleMonthWarning: false, showUpdateButton: false, showMonthSelector: false, showStartDate: false, showEndDate: false, showSelectionValidity: false, showYearSelector: false, showToLabel: false }"
v-bind:allow-empty-selection="true"
v-bind:events="orderDateEvents"
v-bind:selection="{ startDate: date_from ? parseLocalDateOnly(date_from) : null, endDate: date_to ? parseLocalDateOnly(date_to) : null }"/>
</div>
</div>
</template>
<template #default>
@@ -530,9 +560,46 @@ watch(orderDateEventSignature, () => {
margin-bottom: 0.25rem;
}
.invoice-orders-other-filters-column {
align-self: flex-end;
margin-left: auto;
.invoice-orders-shortcuts-column {
display: flex;
justify-content: flex-end;
}
.invoice-orders-shortcut-actions {
align-items: flex-start;
display: flex;
flex-wrap: wrap;
column-gap: 0;
justify-content: flex-end;
margin-top: -0.5rem;
padding-top: 0.5rem;
row-gap: 0.5rem;
overflow: visible;
width: 100%;
}
.invoice-orders-shortcut-actions :deep(.date-period-selector .navbar-item) {
padding-left: 0.5rem;
}
.invoice-orders-other-filters-trigger {
display: flex;
justify-content: flex-end;
overflow: visible;
position: relative;
z-index: 40;
}
@media screen and (min-width: 769px) {
.invoice-orders-other-filters-trigger {
margin-top: 0.625rem;
}
}
.invoice-orders-other-filters__clear {
height: auto;
min-height: 1.75rem;
padding: 0;
}
.invoice-orders-other-filters__field {
@@ -1,11 +1,9 @@
<script setup>
import { provide } from "vue";
import { useI18n } from "vue-i18n";
import SubusersTable from "@/components/displays/superuser/tables/SubusersTable.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import TableLabeledPagination from "@/components/displays/pagination/TableLabeledPagination.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const props = defineProps({
hideSearch: {
@@ -29,22 +27,15 @@ const props = defineProps({
default: null,
},
});
const { t } = useI18n();
const emit = defineEmits(["mutated"]);
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoading,
list,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setPage,
search,
setOrder,
hideSearchField,
setHideSearchField,
@@ -62,60 +53,25 @@ if (props.hideSearch) {
if (props.autoLoad) {
loadList();
}
const onTableMutated = () => {
emit("mutated");
};
</script>
<template>
<div class="columns is-vcentered is-multiline">
<div class="column">
<input
v-if="!hideSearchField"
class="input"
type="text"
:placeholder="$t('global.search_driver')"
@input="search($event.target.value)"
/>
</div>
<div class="column is-narrow pl-0">
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">
{{ t('pagination.reload') }}
</LoadButtonWhileAwait>
</div>
</div>
<PaginationDisplay
:metaItemsPerPage="metaItemsPerPage"
:loadFunction="loadList"
:isLoading="isLoading"
:setMetaItemsPerPage="setMetaItemsPerPage"
<TableLabeledPagination
:label="SessionUser.objects.subusers.meta.title"
:hide-search="hideSearchField"
:search-placeholder="$t('global.search_driver')"
>
<template #paginationColumns>
<div class="column is-narrow my-3">
<label class="label is-small">{{ t("pagination.order_direction") }}</label>
<div class="control">
<div class="select">
<select @change="setOrder('created_at', $event.target.value); loadList();">
<option value="asc">{{ t("pagination.ascending") }}</option>
<option value="desc" selected>{{ t("pagination.descending") }}</option>
</select>
</div>
</div>
</div>
</template>
</PaginationDisplay>
<SubusersTable
:objects="list"
:show-customer="showCustomer"
:user-scoped-user-id="userScopedUserId"
/>
<PaginationNavigation
:currentPage="metaCurrentPage"
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
:loadFunction="loadList"
:setPage="setPage"
:isLoading="isLoading"
/>
<SubusersTable
:objects="list"
:show-customer="showCustomer"
:user-scoped-user-id="userScopedUserId"
@mutated="onTableMutated"
/>
</TableLabeledPagination>
</template>
<style scoped>
@@ -0,0 +1,116 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import { listModuleUsageSummary } from "@/services/moduleUsage.js";
const props = defineProps({
moduleKey: {
type: String,
required: true,
},
metricKey: {
type: String,
required: true,
},
});
const { t, locale } = useI18n();
const loading = ref(false);
const metric = ref(null);
const usagePercent = computed(() => Number(metric.value?.usage_percent ?? 0));
const hasMetric = computed(() => metric.value && (metric.value.used !== null || metric.value.limit !== null));
const load = async () => {
loading.value = true;
try {
const response = await listModuleUsageSummary({ module: props.moduleKey });
const metrics = Array.isArray(response?.data?.data?.metrics) ? response.data.data.metrics : [];
metric.value = metrics.find((row) => row.metric_key === props.metricKey) || null;
} finally {
loading.value = false;
}
};
function formatNumber(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "--";
}
return new Intl.NumberFormat(locale.value || "da").format(Number(value));
}
function formatPercent(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "--";
}
return `${Number(value).toFixed(1)}%`;
}
function progressClass(value) {
const percent = Number(value);
if (!Number.isFinite(percent)) {
return "";
}
if (percent >= 100) {
return "is-danger";
}
if (percent >= 90) {
return "is-warning";
}
return "is-success";
}
onMounted(load);
</script>
<template>
<div v-if="loading || hasMetric" class="module-usage-meter" :data-testid="`module-usage-meter-${moduleKey}-${metricKey}`">
<div class="module-usage-meter__top">
<span>{{ metric?.metric_label || $t("system_status.labels.quota_usage") }}</span>
<strong>{{ loading ? "--" : formatPercent(metric?.usage_percent) }}</strong>
</div>
<progress
class="progress module-usage-meter__progress"
:class="progressClass(usagePercent)"
:value="Math.max(0, Math.min(100, usagePercent))"
max="100"
/>
<div class="module-usage-meter__stats">
<span>{{ t("system_status.labels.used") }}: {{ formatNumber(metric?.used) }}</span>
<span>{{ t("system_status.labels.limit") }}: {{ formatNumber(metric?.limit) }}</span>
<span>{{ t("system_status.labels.remaining") }}: {{ formatNumber(metric?.remaining) }}</span>
</div>
</div>
</template>
<style scoped>
.module-usage-meter {
display: grid;
gap: 0.4rem;
margin-top: 0.75rem;
padding: 0.75rem;
border: 1px solid #d7dde7;
border-radius: 8px;
background: #f8fafc;
}
.module-usage-meter__top {
display: flex;
justify-content: space-between;
gap: 0.75rem;
color: #334155;
}
.module-usage-meter__progress {
height: 0.55rem;
margin: 0;
}
.module-usage-meter__stats {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: #475569;
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,7 @@
<script setup>
import SystemDependencyDisplay from "@/components/displays/superuser/system/SystemDependencyDisplay.vue";
</script>
<template>
<SystemDependencyDisplay dependency-key="minio" title-key="system_status.cards.minio" />
</template>
@@ -0,0 +1,7 @@
<script setup>
import SystemDependencyDisplay from "@/components/displays/superuser/system/SystemDependencyDisplay.vue";
</script>
<template>
<SystemDependencyDisplay dependency-key="redis" title-key="system_status.cards.redis" />
</template>
@@ -0,0 +1,210 @@
<script setup>
import { computed, onMounted } from "vue";
import { useI18n } from "vue-i18n";
import {
SuperUserSystemStatusObject,
getSystemDependencyStatus,
} from "@/components/session/token/superUser/systemStatus.vue";
const props = defineProps({
dependencyKey: {
type: String,
required: true,
validator: (value) => ["database", "redis", "minio"].includes(value),
},
titleKey: {
type: String,
required: true,
},
});
const { t } = useI18n();
const dependencyStatus = computed(() => SuperUserSystemStatusObject.dependencies.value?.[props.dependencyKey] ?? null);
const loading = computed(() => SuperUserSystemStatusObject.loading.value);
const error = computed(() => SuperUserSystemStatusObject.error.value);
const testId = computed(() => `${props.dependencyKey}-status-card`);
const bucketRows = computed(() => {
if (props.dependencyKey !== "minio" || !Array.isArray(dependencyStatus.value?.buckets)) {
return [];
}
return dependencyStatus.value.buckets;
});
const rows = computed(() => {
const status = dependencyStatus.value;
if (!status) {
return [];
}
if (props.dependencyKey === "database") {
return [
statusRow(status),
row("database", t("system_status.cards.database"), status.database || "MySQL"),
row("server_version", t("system_status.labels.server_version"), status.server_version || "--"),
row("latency", t("system_status.labels.latency"), formatLatency(status.latency_ms)),
row("checked_at", t("system_status.labels.checked_at"), status.checked_at || "--"),
row("error", t("system_status.states.error"), status.error || "--"),
];
}
if (props.dependencyKey === "redis") {
return [
statusRow(status),
row("database_index", t("system_status.labels.database_index"), status.database ?? "--"),
row("latency", t("system_status.labels.latency"), formatLatency(status.latency_ms)),
row("checked_at", t("system_status.labels.checked_at"), status.checked_at || "--"),
row("error", t("system_status.states.error"), status.error || "--"),
];
}
return [
statusRow(status),
row("endpoint", t("system_status.labels.endpoint"), status.endpoint || "--"),
row("buckets-summary", t("system_status.labels.buckets"), formatMinioBuckets(status.buckets)),
row("latency", t("system_status.labels.latency"), formatLatency(status.latency_ms)),
row("checked_at", t("system_status.labels.checked_at"), status.checked_at || "--"),
row("error", t("system_status.states.error"), status.error || "--"),
];
});
const refresh = async ({ force = false } = {}) => {
await getSystemDependencyStatus(props.dependencyKey, { force });
};
onMounted(async () => {
await refresh();
});
function row(key, label, value) {
return { key, label, value };
}
function statusRow(status) {
return row("overall", t("system_status.summary.overall"), t(`system_status.status.${status.status || "unknown"}`));
}
function formatLatency(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "--";
}
return `${Number(value).toFixed(1)} ms`;
}
function formatMinioBuckets(buckets) {
if (!Array.isArray(buckets) || buckets.length === 0) {
return "--";
}
const available = buckets.filter((bucket) => bucket.status === "ok").length;
return t("system_status.labels.buckets_available", { available, total: buckets.length });
}
</script>
<template>
<section class="system-dependency-card" :data-testid="testId">
<div class="system-dependency-card__top">
<div>
<p class="system-dependency-card__eyebrow">{{ $t("system_status.sections.infrastructure") }}</p>
<h2>{{ $t(titleKey) }}</h2>
</div>
<button class="button is-dark is-small" type="button" @click="refresh({ force: true })">
{{ $t("system_status.actions.refresh") }}
</button>
</div>
<div v-if="loading && !dependencyStatus" class="notification is-light">
{{ $t("system_status.states.loading") }}
</div>
<div v-if="error" class="notification is-danger is-light">
<strong>{{ $t("system_status.states.error") }}</strong>
<span>{{ error?.message || $t("system_status.states.error_generic") }}</span>
</div>
<div v-if="dependencyStatus" class="system-dependency-card__grid">
<article v-for="entry in rows" :key="entry.key" :data-testid="`${dependencyKey}-status-${entry.key}`">
<span>{{ entry.label }}</span>
<strong>{{ entry.value }}</strong>
</article>
</div>
<div v-if="bucketRows.length" class="system-dependency-card__buckets" data-testid="minio-status-buckets">
<article v-for="bucket in bucketRows" :key="bucket.name || bucket.status">
<span>{{ bucket.name || "--" }}</span>
<strong>{{ $t(`system_status.status.${bucket.status || "unknown"}`) }}</strong>
</article>
</div>
</section>
</template>
<style scoped>
.system-dependency-card {
border: 1px solid #d7dde7;
border-radius: 8px;
padding: 1rem 1.1rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.06);
display: grid;
gap: 1rem;
}
.system-dependency-card__top {
display: flex;
justify-content: space-between;
align-items: start;
gap: 1rem;
}
.system-dependency-card__eyebrow {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.72rem;
color: #64748b;
margin-bottom: 0.25rem;
}
.system-dependency-card__top h2 {
margin: 0;
color: #102a43;
}
.system-dependency-card__grid,
.system-dependency-card__buckets {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.system-dependency-card__grid article,
.system-dependency-card__buckets article {
display: grid;
gap: 0.25rem;
}
.system-dependency-card__grid span,
.system-dependency-card__buckets span {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.72rem;
color: #64748b;
}
.system-dependency-card__grid strong,
.system-dependency-card__buckets strong {
color: #0f172a;
overflow-wrap: anywhere;
}
.system-dependency-card__buckets {
border-top: 1px solid #e2e8f0;
padding-top: 1rem;
}
@media (max-width: 768px) {
.system-dependency-card__top {
flex-direction: column;
align-items: start;
}
}
</style>
@@ -6,17 +6,15 @@ import {
SuperUserSystemStatusObject,
getSuperuserSystemStatus,
} from "@/components/session/token/superUser/systemStatus.vue";
import SystemStatusModuleIndicator from "@/components/displays/superuser/system/SystemStatusModuleIndicator.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
listEdgeGatewayDepartments,
listEdgeGateways,
unwrapEdgeGatewayMeta,
} from "@/services/edgeGateways.js";
import { listEdgeGatewayDepartments, listEdgeGateways, unwrapEdgeGatewayMeta } from "@/services/edgeGateways.js";
const { t, te, locale } = useI18n();
const MAX_GATEWAY_CARDS = 8;
const DEFAULT_DASHBOARD_TAB = "infrastructure";
const MODULE_PLACEHOLDER_CARDS = [0, 1, 2];
const gatewayStatusPriority = Object.freeze({
OFFLINE: 0,
DEGRADED: 1,
@@ -80,6 +78,8 @@ const gatewayDepartments = ref({});
const gatewayDepartmentsLoaded = ref(false);
const activeDashboardTab = ref(DEFAULT_DASHBOARD_TAB);
const showGatewaySection = computed(() => canViewGateways.value && !gatewaySectionSuppressed.value);
const showModuleSkeletonCards = computed(() => loading.value && modules.value.length === 0);
const showModuleErrorCards = computed(() => Boolean(error.value) && modules.value.length === 0);
const dashboardTabKeys = computed(() => [
DEFAULT_DASHBOARD_TAB,
...(showGatewaySection.value ? ["gateways"] : []),
@@ -192,7 +192,7 @@ const isStale = computed(() => {
if (!lastLoadedAt.value) {
return false;
}
return nowTick.value - lastLoadedAt.value.getTime() > (refreshAfterSeconds.value * 2000);
return nowTick.value - lastLoadedAt.value.getTime() > refreshAfterSeconds.value * 2000;
});
watch(
@@ -206,10 +206,7 @@ watch(
);
const loadStatus = async ({ force = false } = {}) => {
const [snapshotValue] = await Promise.all([
getSuperuserSystemStatus({ force }),
loadGatewayFleet({ force }),
]);
const [snapshotValue] = await Promise.all([getSuperuserSystemStatus({ force }), loadGatewayFleet({ force })]);
return snapshotValue;
};
@@ -330,6 +327,10 @@ function moduleReasonText(module) {
);
}
function systemStatusErrorMessage() {
return error.value?.message || t("system_status.states.error_generic");
}
function createEmptyGatewayFleetUsage() {
return {
total: 0,
@@ -408,12 +409,7 @@ async function ensureGatewayDepartmentsLoaded() {
}
function warningText(warning) {
return translateSystemStatusText(
"warnings",
warning?.key,
warning?.params,
warning?.message || ""
);
return translateSystemStatusText("warnings", warning?.key, warning?.params, warning?.message || "");
}
function sessionDisplayName(session) {
@@ -443,7 +439,9 @@ function formatDeviceType(deviceType) {
}
function normalizeGatewayStatus(status) {
const normalizedStatus = String(status || "").trim().toUpperCase();
const normalizedStatus = String(status || "")
.trim()
.toUpperCase();
return normalizedStatus || "UNKNOWN";
}
@@ -460,16 +458,13 @@ function gatewayToneClass(status) {
function gatewayStatusLabel(status) {
const normalizedStatus = normalizeGatewayStatus(status).toLowerCase();
return translateSystemStatusText(
"gateways.status",
normalizedStatus,
{},
t("system_status.gateways.status.unknown")
);
return translateSystemStatusText("gateways.status", normalizedStatus, {}, t("system_status.gateways.status.unknown"));
}
function gatewayDiscoveryLabel(status) {
const normalizedStatus = String(status || "unknown").trim().toLowerCase();
const normalizedStatus = String(status || "unknown")
.trim()
.toLowerCase();
return translateSystemStatusText(
"gateways.discovery_status",
normalizedStatus,
@@ -506,6 +501,212 @@ function formatUsage(value) {
return `${Number(value).toFixed(1)}%`;
}
function formatNumber(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return "--";
}
return new Intl.NumberFormat(locale.value || "da").format(Number(value));
}
function hasModuleUsage(module) {
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) {
const percent = Number(value);
if (!Number.isFinite(percent)) {
return 0;
}
return Math.max(0, Math.min(100, percent));
}
function usageProgressClass(value) {
const percent = Number(value);
if (!Number.isFinite(percent)) {
return "";
}
if (percent >= 100) {
return "is-danger";
}
if (percent >= 90) {
return "is-warning";
}
return "is-success";
}
function formatReplicationLabel(replication) {
if (!replication || typeof replication !== "object") {
return null;
@@ -649,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;
}
@@ -716,10 +918,7 @@ function modulePath(key) {
<div v-if="warningEntries.length" class="notification is-warning is-light">
<strong>{{ $t("system_status.labels.warnings") }}</strong>
<ul class="warning-list">
<li
v-for="warning in warningEntries"
:key="`${warning.key || 'warning'}-${warning.message}`"
>
<li v-for="warning in warningEntries" :key="`${warning.key || 'warning'}-${warning.message}`">
{{ warningText(warning) }}
</li>
</ul>
@@ -772,7 +971,6 @@ function modulePath(key) {
</article>
</div>
</section>
</b-tab-item>
<b-tab-item v-if="showGatewaySection" value="gateways">
@@ -783,10 +981,7 @@ function modulePath(key) {
</span>
</template>
<section
class="system-status-section"
data-testid="system-status-gateways"
>
<section class="system-status-section" data-testid="system-status-gateways">
<div class="section-heading">
<div>
<h3>{{ $t("system_status.sections.gateways") }}</h3>
@@ -818,10 +1013,7 @@ function modulePath(key) {
{{ $t("system_status.gateways.error") }}
</div>
<div
v-if="gatewayLoading && !gatewayCards.length"
class="notification is-light gateway-notification"
>
<div v-if="gatewayLoading && !gatewayCards.length" class="notification is-light gateway-notification">
{{ $t("system_status.gateways.loading") }}
</div>
@@ -842,9 +1034,7 @@ function modulePath(key) {
</div>
<div class="gateway-card__meta">
<span>
{{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }}
</span>
<span> {{ $t("system_status.gateways.labels.discovery") }}: {{ gateway.discoveryLabel }} </span>
<span>
{{ $t("system_status.gateways.labels.last_heartbeat") }}: {{ gateway.lastHeartbeatLabel }}
</span>
@@ -863,15 +1053,10 @@ function modulePath(key) {
</article>
</div>
<div
v-else-if="!gatewayLoading && !gatewayError"
class="gateway-empty"
data-testid="gateway-empty-state"
>
<div v-else-if="!gatewayLoading && !gatewayError" class="gateway-empty" data-testid="gateway-empty-state">
{{ $t("system_status.gateways.empty") }}
</div>
</section>
</b-tab-item>
<b-tab-item value="modules">
@@ -886,7 +1071,114 @@ function modulePath(key) {
<div class="section-heading">
<h3>{{ $t("system_status.sections.modules") }}</h3>
</div>
<div class="module-grid">
<div v-if="showModuleSkeletonCards" class="module-grid" data-testid="system-status-modules-loading">
<article
v-for="placeholder in MODULE_PLACEHOLDER_CARDS"
:key="`module-skeleton-${placeholder}`"
class="module-card module-card--placeholder"
:data-testid="`module-card-skeleton-${placeholder}`"
>
<div class="module-card__top">
<div class="module-card__identity">
<b-skeleton width="46%" height="1.25rem" />
<small class="module-card__version-line module-card__version-line--empty" aria-hidden="true"></small>
</div>
<b-skeleton width="88px" height="1.65rem" />
</div>
<div class="module-card__reason module-card__reason--skeleton">
<b-skeleton width="100%" height="0.95rem" />
<b-skeleton width="72%" height="0.95rem" />
</div>
<div class="module-card__usage-list">
<div
class="module-card__usage module-card__usage--loading"
:data-testid="`module-usage-skeleton-${placeholder}`"
>
<div class="module-card__usage-top">
<b-skeleton width="58%" height="1rem" />
<b-skeleton width="48px" height="1rem" />
</div>
<b-skeleton width="100%" height="0.55rem" />
<div class="module-card__usage-grid">
<span><b-skeleton width="86%" height="2.2rem" /></span>
<span><b-skeleton width="86%" height="2.2rem" /></span>
<span><b-skeleton width="86%" height="2.2rem" /></span>
</div>
</div>
</div>
<div
class="module-card__footer module-card__footer--skeleton"
:data-testid="`module-footer-skeleton-${placeholder}`"
>
<div class="module-card__indicator-group" aria-hidden="true">
<span class="module-card__indicator-placeholder"></span>
<span class="module-card__indicator-placeholder"></span>
<span class="module-card__indicator-placeholder"></span>
</div>
<span class="module-card__config-action module-card__config-action--placeholder" aria-hidden="true">
<i class="fas fa-arrow-right"></i>
</span>
</div>
</article>
</div>
<div v-else-if="showModuleErrorCards" class="module-grid" data-testid="system-status-modules-error">
<article
v-for="placeholder in MODULE_PLACEHOLDER_CARDS"
:key="`module-error-${placeholder}`"
class="module-card module-card--placeholder is-down"
:data-testid="`module-card-error-${placeholder}`"
>
<div class="module-card__top">
<div class="module-card__identity">
<p class="module-card__title">{{ $t("system_status.sections.modules") }}</p>
<small class="module-card__version-line module-card__version-line--empty" aria-hidden="true"></small>
</div>
<span class="status-pill is-down">{{ $t("system_status.states.error") }}</span>
</div>
<p class="module-card__reason module-card__reason--text" :title="systemStatusErrorMessage()">
{{ systemStatusErrorMessage() }}
</p>
<div class="module-card__usage-list">
<div
class="module-card__usage module-card__usage--error"
:data-testid="`module-usage-error-placeholder-${placeholder}`"
>
<div class="module-card__usage-top">
<span>{{ $t("system_status.labels.usage_unavailable") }}</span>
</div>
<div class="module-card__usage-warning">
<small>{{ $t("system_status.labels.status") }}: {{ $t("system_status.states.error") }}</small>
<strong>{{ systemStatusErrorMessage() }}</strong>
</div>
</div>
</div>
<div class="module-card__footer" :data-testid="`module-footer-error-${placeholder}`">
<div class="module-card__indicator-group">
<SystemStatusModuleIndicator
icon-class="fas fa-power-off"
:label="`${$t('system_status.labels.enabled')}: --`"
:test-id="`module-indicator-error-${placeholder}-enabled`"
/>
<SystemStatusModuleIndicator
icon-class="fas fa-cog"
:label="`${$t('system_status.labels.configured')}: --`"
:test-id="`module-indicator-error-${placeholder}-configured`"
/>
<SystemStatusModuleIndicator
icon-class="fas fa-clipboard-check"
:label="`${$t('system_status.labels.checked_at')}: --`"
:test-id="`module-indicator-error-${placeholder}-checked`"
/>
</div>
<span class="module-card__config-action module-card__config-action--placeholder" aria-hidden="true">
<i class="fas fa-arrow-right"></i>
</span>
</div>
</article>
</div>
<div v-else class="module-grid">
<article
v-for="module in modules"
:key="module.key"
@@ -895,24 +1187,170 @@ function modulePath(key) {
:data-testid="`module-card-${module.key}`"
>
<div class="module-card__top">
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
<div class="module-card__identity">
<p class="module-card__title">{{ moduleLabel(module.key) }}</p>
<small
class="module-card__version-line"
:class="{ 'module-card__version-line--empty': !moduleVersion(module) }"
:title="
moduleVersion(module) ? `${$t('system_status.labels.version')}: ${moduleVersion(module)}` : null
"
:aria-hidden="moduleVersion(module) ? null : 'true'"
:data-testid="`module-version-${module.key}`"
>
<template v-if="moduleVersion(module)">
{{ $t("system_status.labels.version") }}: {{ moduleVersion(module) }}
</template>
</small>
</div>
<span class="status-pill" :class="statusClass(module.status)">{{ statusLabel(module.status) }}</span>
</div>
<p class="module-card__reason" :data-testid="`module-reason-${module.key}`">
<p
class="module-card__reason module-card__reason--text"
:title="moduleReasonText(module)"
:data-testid="`module-reason-${module.key}`"
>
{{ moduleReasonText(module) }}
</p>
<div class="module-card__meta">
<span>{{ $t("system_status.labels.enabled") }}: {{ module.enabled ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.configured") }}: {{ module.configured ? $t("system_status.status.yes") : $t("system_status.status.no") }}</span>
<span>{{ $t("system_status.labels.checked_at") }}: {{ formatDate(module.checked_at) }}</span>
<div
v-if="showModuleUsageSkeleton(module)"
class="module-card__usage-list"
:data-testid="`module-usage-${module.key}`"
>
<div
class="module-card__usage module-card__usage--loading"
:data-testid="`module-usage-skeleton-${module.key}`"
>
<div class="module-card__usage-top">
<b-skeleton width="58%" height="1rem" />
<b-skeleton width="48px" height="1rem" />
</div>
<b-skeleton width="100%" height="0.55rem" />
<div class="module-card__usage-grid">
<span><b-skeleton width="86%" height="2.2rem" /></span>
<span><b-skeleton width="86%" height="2.2rem" /></span>
<span><b-skeleton width="86%" height="2.2rem" /></span>
</div>
</div>
</div>
<div
v-else-if="moduleUsageErrorMessage(module)"
class="module-card__usage-list"
:data-testid="`module-usage-${module.key}`"
>
<div
class="module-card__usage module-card__usage--error"
:data-testid="`module-usage-error-${module.key}`"
>
<div class="module-card__usage-top">
<span>{{ $t("system_status.labels.usage_unavailable") }}</span>
</div>
<div class="module-card__usage-warning">
<small>{{ $t("system_status.labels.status") }}: {{ $t("system_status.states.error") }}</small>
<strong>{{ moduleUsageErrorMessage(module) }}</strong>
</div>
</div>
</div>
<div
v-else-if="hasModuleUsage(module)"
class="module-card__usage-list"
:data-testid="`module-usage-${module.key}`"
>
<div
v-for="metric in visibleModuleUsageMetrics(module)"
:key="`${metric.module_key || module.key}-${metric.metric_key}`"
class="module-card__usage"
:class="{ 'module-card__usage--error': isModuleUsageUnavailable(metric) }"
:data-testid="`module-usage-${module.key}-${metric.metric_key}`"
>
<div class="module-card__usage-top">
<span>{{ moduleUsageLabel(metric) }}</span>
<strong v-if="!isModuleUsageUnavailable(metric)">{{ formatUsage(metric.usage_percent) }}</strong>
</div>
<div v-if="isModuleUsageUnavailable(metric)" class="module-card__usage-warning">
<small>{{ $t("system_status.labels.status") }}: {{ $t("system_status.status.unknown") }}</small>
<strong>{{ moduleUsageUnavailableReason(metric) }}</strong>
</div>
<progress
v-else
class="progress module-card__usage-progress"
:class="usageProgressClass(metric.usage_percent)"
:value="boundedUsagePercent(metric.usage_percent)"
max="100"
>
{{ formatUsage(metric.usage_percent) }}
</progress>
<div v-if="!isModuleUsageUnavailable(metric)" class="module-card__usage-grid">
<span>
<small>{{ moduleUsageUsedLabel(metric) }}</small>
<strong>{{ formatMetricValue(metric.used, metric.unit) }}</strong>
</span>
<span>
<small>{{ moduleUsageLimitLabel(metric) }}</small>
<strong>{{ formatMetricValue(metric.limit, metric.unit) }}</strong>
</span>
<span>
<small>{{ moduleUsageRemainingLabel(metric) }}</small>
<strong>{{ formatMetricValue(metric.remaining, metric.unit) }}</strong>
</span>
</div>
</div>
</div>
<div
v-else-if="showModuleUsageUnavailablePlaceholder(module)"
class="module-card__usage-list"
:data-testid="`module-usage-${module.key}`"
>
<div
class="module-card__usage module-card__usage--warning"
:data-testid="`module-usage-unavailable-${module.key}`"
>
<div class="module-card__usage-top">
<span>{{ $t("system_status.labels.usage_unavailable") }}</span>
</div>
<div class="module-card__usage-warning">
<small>{{ $t("system_status.labels.status") }}: {{ $t("system_status.status.unknown") }}</small>
<strong>{{ $t("system_status.states.module_usage_unavailable") }}</strong>
</div>
</div>
</div>
<div class="module-card__footer" :data-testid="`module-footer-${module.key}`">
<div class="module-card__indicator-group">
<SystemStatusModuleIndicator
v-bind="moduleEnabledIndicator(module)"
:test-id="`module-indicator-${module.key}-enabled`"
/>
<SystemStatusModuleIndicator
v-bind="moduleConfiguredIndicator(module)"
:test-id="`module-indicator-${module.key}-configured`"
/>
<SystemStatusModuleIndicator
v-bind="moduleCheckedIndicator(module)"
:test-id="`module-indicator-${module.key}-checked`"
/>
</div>
<RouterLink
v-if="modulePath(module.key)"
:to="modulePath(module.key)"
class="module-card__config-action"
:aria-label="moduleConfigActionLabel(module)"
:title="moduleConfigActionLabel(module)"
:data-testid="`module-config-action-${module.key}`"
>
<i class="fas fa-arrow-right" aria-hidden="true"></i>
</RouterLink>
<span
v-else
class="module-card__config-action module-card__config-action--placeholder"
aria-hidden="true"
:data-testid="`module-config-action-${module.key}`"
>
<i class="fas fa-arrow-right"></i>
</span>
</div>
<RouterLink v-if="modulePath(module.key)" :to="modulePath(module.key)" class="module-card__link">
{{ $t("system_status.actions.open_config") }}
</RouterLink>
</article>
</div>
</section>
</b-tab-item>
<b-tab-item value="sessions">
@@ -943,7 +1381,10 @@ function modulePath(key) {
</tr>
</thead>
<tbody>
<tr v-for="session in sessions.recent_sessions" :key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`">
<tr
v-for="session in sessions.recent_sessions"
:key="`${session.session_kind}-${session.principal_id}-${session.last_seen_at}`"
>
<td>
<span class="status-pill" :class="session.active ? 'is-ok' : 'is-down'">
{{ session.active ? $t("system_status.status.active") : $t("system_status.status.inactive") }}
@@ -966,7 +1407,6 @@ function modulePath(key) {
</table>
</div>
</section>
</b-tab-item>
</b-tabs>
</div>
@@ -1031,7 +1471,6 @@ function modulePath(key) {
}
.status-card-grid,
.module-grid,
.gateway-grid {
display: grid;
gap: 1rem;
@@ -1039,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;
@@ -1090,7 +1536,6 @@ function modulePath(key) {
.status-card__secondary,
.status-card__detail,
.module-card small,
.module-card__meta,
.session-context {
color: #475569;
}
@@ -1102,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 {
@@ -1120,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 {
@@ -1138,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 {
@@ -1162,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 {
@@ -1177,6 +1660,178 @@ function modulePath(key) {
color: #475569;
}
.module-card__usage {
display: grid;
gap: 0.45rem;
padding: 0.75rem;
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 {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: baseline;
color: #334155;
font-size: 0.85rem;
}
.module-card__usage-progress {
margin: 0;
height: 0.55rem;
}
.module-card__usage-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.module-card__usage-grid span {
min-width: 0;
}
.module-card__usage-grid small {
display: block;
color: #64748b;
font-size: 0.72rem;
line-height: 1.25;
}
.module-card__usage-grid strong {
display: block;
color: #0f172a;
overflow-wrap: anywhere;
}
.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 {
margin: 0;
color: #7c2d12;
@@ -0,0 +1,124 @@
<script setup>
defineProps({
iconClass: {
type: String,
required: true,
},
badgeIconClass: {
type: String,
default: "",
},
tone: {
type: String,
default: "neutral",
},
badgeTone: {
type: String,
default: "neutral",
},
label: {
type: String,
required: true,
},
testId: {
type: String,
default: "",
},
});
</script>
<template>
<span
class="system-status-module-indicator"
:class="`is-${tone}`"
role="img"
:aria-label="label"
:title="label"
:data-testid="testId || undefined"
>
<span class="system-status-module-indicator__icon" aria-hidden="true">
<i :class="iconClass"></i>
</span>
<span
v-if="badgeIconClass"
class="system-status-module-indicator__badge"
:class="`is-${badgeTone}`"
aria-hidden="true"
>
<i :class="badgeIconClass"></i>
</span>
</span>
</template>
<style scoped>
.system-status-module-indicator {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.15rem;
height: 2.15rem;
border: 1px solid currentColor;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: #64748b;
cursor: help;
transition: background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
}
.system-status-module-indicator:hover,
.system-status-module-indicator:focus-visible {
background: #ffffff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
transform: translateY(-1px);
}
.system-status-module-indicator__icon {
font-size: 0.98rem;
line-height: 1;
}
.system-status-module-indicator__badge {
position: absolute;
right: -0.22rem;
bottom: -0.18rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border: 2px solid #ffffff;
border-radius: 999px;
background: #64748b;
color: #ffffff;
font-size: 0.56rem;
line-height: 1;
}
.system-status-module-indicator.is-ok {
color: #166534;
background: rgba(22, 163, 74, 0.1);
}
.system-status-module-indicator.is-down {
color: #b91c1c;
background: rgba(220, 38, 38, 0.1);
}
.system-status-module-indicator.is-degraded {
color: #b45309;
background: rgba(245, 158, 11, 0.12);
}
.system-status-module-indicator__badge.is-ok {
background: #16a34a;
}
.system-status-module-indicator__badge.is-down {
background: #dc2626;
}
.system-status-module-indicator__badge.is-degraded {
background: #d97706;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -58,7 +58,9 @@ const reload = () => {
const canViewVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "list");
const canEditVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "edit");
const canDeleteVehicle = () => SessionUser.canAccessCustomerFeature("vehicles", "delete");
const canShowVehicleRowActions = () => canViewVehicle() || canDeleteVehicle();
const usesBuiltInVehicleWheelActions = (vehicle) =>
SessionUser.canAccessSuperUser() && String(vehicle?.reg || "").trim().length > 0;
const canShowVehicleRowActions = (vehicle) => usesBuiltInVehicleWheelActions(vehicle) || canViewVehicle() || canDeleteVehicle();
const isUserScoped = () => Boolean(props.userScopedUserId);
const redirectVehiclePage = (vehicle) => {
@@ -260,12 +262,12 @@ const deleteVehicle = (id) => {
<td v-if="!props.compact">
<!-- Actions stay grouped behind the wheel menu. -->
<ActionSettingsWheelButton
v-if="canShowVehicleRowActions()"
v-if="canShowVehicleRowActions(object)"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
>
<template #actions>
<template v-if="!usesBuiltInVehicleWheelActions(object)" #actions>
<!-- View (Redirect to the vehicle page) -->
<ActionSettingsWheelItem
v-if="canViewVehicle()"
@@ -0,0 +1,516 @@
<script setup>
import { computed, onBeforeUnmount, watch } from "vue";
import truckWashLogo from "@/assets/branding/truckwash-banner-white-compressed.png";
const props = defineProps({
title: {
type: String,
default: "Loading...",
},
subtitle: {
type: String,
default: "",
},
testId: {
type: String,
default: "branded-loading-screen",
},
statusTestId: {
type: String,
default: "",
},
ariaLabel: {
type: String,
default: "",
},
lockScroll: {
type: Boolean,
default: true,
},
});
const resolvedAriaLabel = computed(() => props.ariaLabel || props.title || "Loading");
const SCROLL_LOCK_CLASS = "tw-loader-scroll-lock";
const SCROLL_LOCK_COUNT_KEY = "twLoaderScrollLocks";
const LOADER_START_GLOBAL_KEY = "__TW_LOADER_STARTED_AT__";
let hasScrollLock = false;
const currentTime = () => {
if (typeof performance !== "undefined" && typeof performance.now === "function") {
return performance.now();
}
return Date.now();
};
const sharedLoaderStart = () => {
if (typeof window === "undefined") {
return currentTime();
}
const startedAt = Number(window[LOADER_START_GLOBAL_KEY]);
if (Number.isFinite(startedAt)) {
return startedAt;
}
const fallbackStartedAt = currentTime();
window[LOADER_START_GLOBAL_KEY] = fallbackStartedAt;
return fallbackStartedAt;
};
const animationDelay = (extraDelayMs = 0) => `${extraDelayMs - Math.max(0, currentTime() - sharedLoaderStart())}ms`;
const animationPhaseStyle = computed(() => ({
"--branded-loader-spin-delay": animationDelay(),
"--branded-loader-sweep-delay": animationDelay(),
"--branded-loader-pulse-delay": animationDelay(),
"--branded-loader-wash-wide-delay": animationDelay(),
"--branded-loader-wash-tight-delay": animationDelay(),
"--branded-loader-spark-one-delay": animationDelay(),
"--branded-loader-spark-two-delay": animationDelay(650),
"--branded-loader-dot-one-delay": animationDelay(),
"--branded-loader-dot-two-delay": animationDelay(180),
"--branded-loader-dot-three-delay": animationDelay(360),
}));
const pageRoot = () => (typeof document === "undefined" ? null : document.documentElement);
const scrollLockElements = () => {
if (typeof document === "undefined") {
return [];
}
return [document.documentElement, document.body].filter(Boolean);
};
const currentLockCount = (root) => {
const count = Number.parseInt(root?.dataset?.[SCROLL_LOCK_COUNT_KEY] || "0", 10);
return Number.isFinite(count) && count > 0 ? count : 0;
};
const lockPageScroll = () => {
const root = pageRoot();
if (!root || hasScrollLock) {
return;
}
root.dataset[SCROLL_LOCK_COUNT_KEY] = String(currentLockCount(root) + 1);
scrollLockElements().forEach((element) => element.classList.add(SCROLL_LOCK_CLASS));
hasScrollLock = true;
};
const unlockPageScroll = () => {
const root = pageRoot();
if (!root || !hasScrollLock) {
return;
}
const nextCount = Math.max(0, currentLockCount(root) - 1);
if (nextCount > 0) {
root.dataset[SCROLL_LOCK_COUNT_KEY] = String(nextCount);
} else {
delete root.dataset[SCROLL_LOCK_COUNT_KEY];
scrollLockElements().forEach((element) => element.classList.remove(SCROLL_LOCK_CLASS));
}
hasScrollLock = false;
};
watch(
() => props.lockScroll,
(shouldLock) => {
if (shouldLock) {
lockPageScroll();
} else {
unlockPageScroll();
}
},
{ immediate: true }
);
onBeforeUnmount(unlockPageScroll);
</script>
<template>
<div
class="branded-loading-screen"
:data-testid="testId"
role="status"
aria-live="polite"
:aria-label="resolvedAriaLabel"
:style="animationPhaseStyle"
>
<div class="branded-loading-screen__ambient" aria-hidden="true">
<span class="branded-loading-screen__wash branded-loading-screen__wash--wide"></span>
<span class="branded-loading-screen__wash branded-loading-screen__wash--tight"></span>
</div>
<div class="branded-loading-screen__panel">
<div class="branded-loading-screen__brand">
<img :src="truckWashLogo" alt="Truck Wash" class="branded-loading-screen__logo" />
</div>
<div class="branded-loading-screen__motion" aria-hidden="true">
<span class="branded-loading-screen__ring"></span>
<span class="branded-loading-screen__sweep"></span>
<span class="branded-loading-screen__spark branded-loading-screen__spark--one"></span>
<span class="branded-loading-screen__spark branded-loading-screen__spark--two"></span>
</div>
<p class="branded-loading-screen__status" :data-testid="statusTestId || undefined">
{{ title }}
</p>
<p v-if="subtitle" class="branded-loading-screen__hint">{{ subtitle }}</p>
<div class="branded-loading-screen__dots" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
</template>
<style scoped>
:global(html.tw-loader-scroll-lock),
:global(body.tw-loader-scroll-lock) {
overflow: hidden;
scrollbar-width: none;
}
:global(html.tw-loader-scroll-lock) {
font-size: 16px !important;
}
:global(html.tw-loader-scroll-lock::-webkit-scrollbar),
:global(body.tw-loader-scroll-lock::-webkit-scrollbar) {
display: none;
width: 0;
height: 0;
}
.branded-loading-screen {
--branded-loader-navy: #063651;
--branded-loader-blue: #0787bb;
--branded-loader-cyan: #69d7f5;
--branded-loader-foam: #f8fbff;
--branded-loader-warm: #f4c15d;
position: fixed;
inset: 0;
z-index: 100000;
display: grid;
min-height: 100vh;
place-items: center;
overflow: hidden;
padding: 2rem;
box-sizing: border-box;
background:
radial-gradient(circle at 20% 20%, rgba(105, 215, 245, 0.18), transparent 28rem),
radial-gradient(circle at 82% 74%, rgba(244, 193, 93, 0.12), transparent 24rem),
linear-gradient(145deg, #041f32 0%, var(--branded-loader-navy) 52%, #04263d 100%);
color: var(--branded-loader-foam);
font-family: Avenir, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.branded-loading-screen__ambient {
position: absolute;
inset: 0;
pointer-events: none;
}
.branded-loading-screen__ambient::before {
position: absolute;
inset: 14% 9%;
content: "";
border: 1px solid rgba(248, 251, 255, 0.08);
border-radius: 8px;
}
.branded-loading-screen__wash {
position: absolute;
left: 50%;
display: block;
width: 72rem;
height: 8rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgba(105, 215, 245, 0.2), rgba(248, 251, 255, 0.18), transparent);
filter: blur(8px);
transform: translateX(-50%) rotate(-9deg);
}
.branded-loading-screen__wash--wide {
top: 26%;
animation: branded-loader-wash 5.8s ease-in-out var(--branded-loader-wash-wide-delay, 0ms) infinite;
}
.branded-loading-screen__wash--tight {
bottom: 22%;
width: 48rem;
height: 5rem;
animation: branded-loader-wash 6.8s ease-in-out var(--branded-loader-wash-tight-delay, 0ms) infinite reverse;
}
.branded-loading-screen__panel {
position: relative;
display: grid;
width: min(100%, 25rem);
min-height: 27rem;
align-content: center;
justify-items: center;
padding: 2.5rem 2rem 2.25rem;
box-sizing: border-box;
border: 1px solid rgba(248, 251, 255, 0.16);
border-radius: 8px;
background: rgba(4, 31, 50, 0.74);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32) !important;
text-align: center;
backdrop-filter: blur(18px);
}
.branded-loading-screen__panel::after {
position: absolute;
inset: 0;
content: "";
border-radius: inherit;
background: linear-gradient(180deg, rgba(248, 251, 255, 0.08), transparent 38%);
pointer-events: none;
}
.branded-loading-screen__brand,
.branded-loading-screen__motion,
.branded-loading-screen__status,
.branded-loading-screen__hint,
.branded-loading-screen__dots {
position: relative;
z-index: 1;
}
.branded-loading-screen__brand {
display: grid;
width: 13rem;
min-height: 4rem;
place-items: center;
margin-bottom: 2.25rem;
}
.branded-loading-screen__logo {
display: block;
width: 100%;
max-height: 4rem;
object-fit: contain;
}
.branded-loading-screen__motion {
display: grid;
width: 8.5rem;
height: 8.5rem;
place-items: center;
margin-bottom: 2rem;
}
.branded-loading-screen__ring {
position: absolute;
inset: 0;
border: 2px solid rgba(248, 251, 255, 0.12);
border-top-color: var(--branded-loader-cyan);
border-right-color: rgba(7, 135, 187, 0.78);
border-radius: 50%;
animation: branded-loader-spin 1.8s linear var(--branded-loader-spin-delay, 0ms) infinite;
}
.branded-loading-screen__ring::before,
.branded-loading-screen__ring::after {
position: absolute;
content: "";
border-radius: 50%;
}
.branded-loading-screen__ring::before {
inset: 1rem;
border: 1px solid rgba(248, 251, 255, 0.18);
}
.branded-loading-screen__ring::after {
inset: 2.3rem;
background: radial-gradient(circle, rgba(105, 215, 245, 0.38), rgba(7, 135, 187, 0.18) 45%, transparent 70%);
animation: branded-loader-pulse 2.4s ease-in-out var(--branded-loader-pulse-delay, 0ms) infinite;
}
.branded-loading-screen__sweep {
width: 6rem;
height: 1.15rem;
border-radius: 999px;
background: linear-gradient(90deg, transparent 0%, rgba(248, 251, 255, 0.92) 45%, var(--branded-loader-cyan) 100%);
box-shadow: 0 0 24px rgba(105, 215, 245, 0.48);
transform: rotate(-12deg);
animation: branded-loader-sweep 1.8s ease-in-out var(--branded-loader-sweep-delay, 0ms) infinite;
}
.branded-loading-screen__spark {
position: absolute;
width: 0.55rem;
height: 0.55rem;
border-radius: 50%;
background: var(--branded-loader-warm);
box-shadow: 0 0 18px rgba(244, 193, 93, 0.76);
opacity: 0.9;
}
.branded-loading-screen__spark--one {
top: 1rem;
right: 1.3rem;
animation: branded-loader-spark 2.6s ease-in-out var(--branded-loader-spark-one-delay, 0ms) infinite;
}
.branded-loading-screen__spark--two {
bottom: 1.4rem;
left: 1rem;
animation: branded-loader-spark 2.6s ease-in-out var(--branded-loader-spark-two-delay, 0.65s) infinite;
}
.branded-loading-screen__status {
margin: 0;
color: var(--branded-loader-foam);
font-size: 1.2rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.25;
}
.branded-loading-screen__hint {
max-width: 18rem;
margin: 0.55rem 0 0;
color: rgba(248, 251, 255, 0.72);
font-size: 0.9rem;
line-height: 1.45;
}
.branded-loading-screen__dots {
display: inline-flex;
gap: 0.45rem;
height: 0.6rem;
margin-top: 1.35rem;
align-items: center;
}
.branded-loading-screen__dots span {
display: block;
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--branded-loader-cyan);
animation: branded-loader-dot 1.35s ease-in-out var(--branded-loader-dot-one-delay, 0ms) infinite;
}
.branded-loading-screen__dots span:nth-child(2) {
animation-delay: var(--branded-loader-dot-two-delay, 0.18s);
}
.branded-loading-screen__dots span:nth-child(3) {
animation-delay: var(--branded-loader-dot-three-delay, 0.36s);
}
@keyframes branded-loader-spin {
to {
transform: rotate(360deg);
}
}
@keyframes branded-loader-sweep {
0%,
100% {
opacity: 0.6;
transform: translateX(-0.65rem) rotate(-12deg);
}
50% {
opacity: 1;
transform: translateX(0.65rem) rotate(-12deg);
}
}
@keyframes branded-loader-pulse {
0%,
100% {
opacity: 0.7;
transform: scale(0.94);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@keyframes branded-loader-spark {
0%,
100% {
opacity: 0.35;
transform: scale(0.7);
}
45% {
opacity: 1;
transform: scale(1);
}
}
@keyframes branded-loader-dot {
0%,
100% {
opacity: 0.35;
transform: translateY(0);
}
45% {
opacity: 1;
transform: translateY(-0.25rem);
}
}
@keyframes branded-loader-wash {
0%,
100% {
opacity: 0.42;
transform: translateX(-54%) rotate(-9deg);
}
50% {
opacity: 0.72;
transform: translateX(-46%) rotate(-9deg);
}
}
@media screen and (max-width: 480px) {
.branded-loading-screen {
padding: 1rem;
}
.branded-loading-screen__panel {
min-height: 25rem;
padding: 2rem 1.4rem;
}
.branded-loading-screen__brand {
width: 11.5rem;
margin-bottom: 1.8rem;
}
.branded-loading-screen__motion {
width: 7.25rem;
height: 7.25rem;
margin-bottom: 1.75rem;
}
.branded-loading-screen__status {
font-size: 1.1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.branded-loading-screen *,
.branded-loading-screen *::before,
.branded-loading-screen *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
.branded-loading-screen__sweep {
opacity: 0.95;
transform: rotate(-12deg);
}
.branded-loading-screen__dots span {
opacity: 0.75;
transform: none;
}
}
</style>
@@ -1,7 +1,8 @@
<script setup>
import MenuDefault from "@/components/menus/MenuDefault.vue";
import {ref, watch} from "vue";
import {SessionUser} from "@/components/session/token/SessionUser.vue";
import { ref, watch } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import i18n from "@/i18n";
// Define the isLoaded function
// This is here, because the departments need to be loaded before the menu is shown,
@@ -9,249 +10,292 @@ import {SessionUser} from "@/components/session/token/SessionUser.vue";
const isLoaded = ref(true);
const doesRouteStartWith = (route) => {
return window.location.pathname.startsWith(route);
}
};
const t = (key, fallback = key) => {
const value = i18n.global.t(key);
return value === key ? fallback : value;
};
const menu_items = ref([
{
name: SessionUser.objects.departments.meta.title,
route: '/superuser' +SessionUser.objects.departments.meta.endpoint,
route: "/superuser" + SessionUser.objects.departments.meta.endpoint,
icon: SessionUser.objects.departments.meta.icon,
children: []
children: [],
},
{
name: 'Nummerplade scanning',
route: '/superuser/scanners',
icon: 'fas fa-cogs',
children: []
name: "Nummerplade scanning",
route: "/superuser/scanners",
icon: "fas fa-cogs",
children: [],
},
{
name: 'Kassesystem',
permissions: [
'canAccessSuperUser'
],
icon: 'fas fa-cogs',
name: "Kassesystem",
permissions: ["canAccessSuperUser"],
icon: "fas fa-cogs",
options: [
{
label: SessionUser.objects.collectedOrderInvoices.meta.title,
value: SessionUser.objects.collectedOrderInvoices.meta.endpoint,
icon: SessionUser.objects.collectedOrderInvoices.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.objects.products.meta.title,
value: SessionUser.objects.products.meta.endpoint,
icon: SessionUser.objects.products.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.objects.orders.meta.title,
value: SessionUser.objects.orders.meta.endpoint,
icon: SessionUser.objects.orders.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.objects.categories.meta.title,
value: SessionUser.objects.categories.meta.endpoint,
icon: SessionUser.objects.categories.meta.icon,
children: [],
hidden: false
hidden: false,
},
],
onSelect: async (value) => {
SessionUser.functions.redirectTo.superUser(value);
},
hidden: false
hidden: false,
},
{
name: 'Brugere',
permissions: [
'canAccessSuperUser'
],
icon: 'fas fa-cogs',
name: "Brugere",
permissions: ["canAccessSuperUser"],
icon: "fas fa-cogs",
options: [
{
label: 'Kunder',
value: '/customers',
icon: 'fas fa-users',
label: "Kunder",
value: "/customers",
icon: "fas fa-users",
children: [],
hidden: false
hidden: false,
},
{
label: 'Medarbejdere',
value: '/users',
icon: 'fas fa-users-cog',
label: "Medarbejdere",
value: "/users",
icon: "fas fa-users-cog",
children: [],
hidden: false
hidden: false,
},
{
label: 'Chauffører',
value: '/subusers',
icon: 'fas fa-id-card',
label: "Chauffører",
value: "/subusers",
icon: "fas fa-id-card",
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.objects.roles.meta.title,
value: SessionUser.objects.roles.meta.endpoint,
icon: SessionUser.objects.roles.meta.icon,
children: [],
hidden: false
hidden: false,
},
],
onSelect: async (value) => {
SessionUser.functions.redirectTo.superUser(value);
},
hidden: false
hidden: false,
},
{
name: 'Konfiguration',
permissions: [
'canAccessSuperUser'
],
icon: 'fas fa-cogs',
name: "Konfiguration",
permissions: ["canAccessSuperUser"],
icon: "fas fa-cogs",
options: [
{
label: SessionUser.superUser.modules.economic.meta.title,
value: SessionUser.superUser.modules.economic.meta.config_endpoint,
icon: SessionUser.superUser.modules.economic.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.superUser.modules.reCAPTCHA.meta.title,
value: SessionUser.superUser.modules.reCAPTCHA.meta.config_endpoint,
icon: SessionUser.superUser.modules.reCAPTCHA.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.superUser.modules.email.meta.title,
value: SessionUser.superUser.modules.email.meta.config_endpoint,
icon: SessionUser.superUser.modules.email.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.superUser.modules.backups.meta.title,
value: SessionUser.superUser.modules.backups.meta.config_endpoint,
icon: SessionUser.superUser.modules.backups.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.superUser.modules.coolify.meta.title,
value: SessionUser.superUser.modules.coolify.meta.config_endpoint,
icon: SessionUser.superUser.modules.coolify.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.superUser.modules.releasemanager.meta.title,
value: SessionUser.superUser.modules.releasemanager.meta.config_endpoint,
icon: SessionUser.superUser.modules.releasemanager.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.superUser.modules.motorapi.meta.title,
value: SessionUser.superUser.modules.motorapi.meta.config_endpoint,
icon: SessionUser.superUser.modules.motorapi.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.superUser.modules.stripe.meta.title,
value: SessionUser.superUser.modules.stripe.meta.config_endpoint,
icon: SessionUser.superUser.modules.stripe.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.superUser.modules.weatherapi.meta.title,
value: SessionUser.superUser.modules.weatherapi.meta.config_endpoint,
icon: SessionUser.superUser.modules.weatherapi.meta.icon,
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.superUser.modules.workfeed.meta.title,
value: SessionUser.superUser.modules.workfeed.meta.config_endpoint,
icon: SessionUser.superUser.modules.workfeed.meta.icon,
children: [],
hidden: false
hidden: false,
},
],
onSelect: async (value) => {
SessionUser.functions.redirectTo.superUser(value);
},
hidden: false
hidden: false,
},
{
name: 'XL Vask',
permissions: [
'canAccessSuperUser'
],
icon: 'fas fa-cogs',
name: t("cron.system_nav", "System"),
permissions: ["canAccessSuperUser"],
icon: "fas fa-shield-alt",
options: [
{
label: 'Dashboard',
value: '/xlvask',
icon: 'fas fa-tachometer-alt',
label: t("system_status.title", "System status"),
value: null,
icon: "fas fa-heartbeat",
children: [],
hidden: false
hidden: false,
},
{
label: t("cron.nav", "Cron tasks"),
value: "/system/cron",
icon: "fas fa-clock",
children: [],
hidden: false,
},
{
label: t("system_status.cards.database", "Database"),
value: "/system/database",
icon: "fas fa-database",
children: [],
hidden: false,
},
{
label: t("system_status.cards.minio", "MinIO"),
value: "/system/minio",
icon: "fas fa-archive",
children: [],
hidden: false,
},
{
label: t("system_status.cards.redis", "Redis"),
value: "/system/redis",
icon: "fas fa-memory",
children: [],
hidden: false,
},
{
label: t("security.nav", "Security"),
value: "/system/security/overview",
icon: "fas fa-shield-alt",
children: [],
hidden: false,
},
],
onSelect: async (value) => {
SessionUser.functions.redirectTo.superUser(value);
},
hidden: false,
},
{
name: "XL Vask",
permissions: ["canAccessSuperUser"],
icon: "fas fa-cogs",
options: [
{
label: "Dashboard",
value: "/xlvask",
icon: "fas fa-tachometer-alt",
children: [],
hidden: false,
},
{
label: SessionUser.objects.global.language.customers,
value: '/xlvask/customers',
icon: 'fas fa-users',
value: "/xlvask/customers",
icon: "fas fa-users",
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.objects.global.language.usage_log,
value: '/xlvask/usagelogs',
icon: 'fas fa-users',
value: "/xlvask/usagelogs",
icon: "fas fa-users",
children: [],
hidden: false
hidden: false,
},
{
label: SessionUser.objects.global.language.configuration,
value: SessionUser.superUser.modules.xlvask.meta.config_endpoint,
icon: SessionUser.superUser.modules.xlvask.meta.icon,
children: [],
hidden: false
hidden: false,
},
],
onSelect: async (value) => {
SessionUser.functions.redirectTo.superUser(value);
},
hidden: false
hidden: false,
},
]);
const selectDepartment = (department) => {
// Get the current path (After /admin/:department_id)
const currentPath = window.location.pathname.split('/').slice(3).join('/');
console.log('Selected department: ' + department);
const currentPath = window.location.pathname.split("/").slice(3).join("/");
console.log("Selected department: " + department);
menu_items.value[1].selected = department;
// Hide the menu
menu_items.value[1].isExpanded = false;
// Redirect to the department
SessionUser.functions.redirectTo.department(department, currentPath);
}
};
</script>
<template>
<MenuDefault v-if="isLoaded"
v-bind:menu_items="menu_items"
:show_icons="false"
/>
<MenuDefault v-if="isLoaded" v-bind:menu_items="menu_items" :show_icons="false" />
</template>
<style scoped>
</style>
<style scoped></style>
@@ -471,7 +471,9 @@ const items = computed<NavigationItemProps[]>(() => [
{ label: t("system_status.title"), to: "/superuser" },
{ label: t("cron.nav"), to: "/superuser/system/cron" },
{ label: t("system_status.cards.database"), to: "/superuser/system/database" },
{ label: t("cron.replication"), to: "/superuser/system/replication" },
{ label: t("system_status.cards.minio"), to: "/superuser/system/minio" },
{ label: t("system_status.cards.redis"), to: "/superuser/system/redis" },
{ label: t("security.nav"), to: "/superuser/system/security/overview" },
],
},
// Other
@@ -4,6 +4,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Swal from "sweetalert2";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { pingApiServer } from "@/services/apiHealth.js";
import BrandedLoadingScreen from "@/components/global/BrandedLoadingScreen.vue";
const props = defineProps({
hasPermission: {
@@ -76,11 +77,14 @@ onBeforeUnmount(clearSessionTimeout);
<template>
<div>
<div v-if="!isSessionInitiated">
<div class="pageloader is-active is-dark">
<span class="title">Bekræfter bruger...</span>
</div>
</div>
<BrandedLoadingScreen
v-if="!isSessionInitiated"
title="Bekræfter bruger..."
subtitle="Vi indlæser din session"
test-id="restricted-auth-loading"
status-test-id="restricted-auth-loading-status"
aria-label="Bekræfter bruger"
/>
<slot v-else-if="props.hasPermission" />
<div v-else>
<div class="my-6">
@@ -131,5 +135,3 @@ onBeforeUnmount(clearSessionTimeout);
</div>
</div>
</template>
<style scoped></style>
@@ -6,6 +6,8 @@ import { sortByDepartmentPriorityOrder } from "@/services/departmentVisibility.j
// Define the departments
export const departments = ref([]);
export const isLoading = ref(true);
export const timeBookingDepartments = ref([]);
export const timeBookingDepartmentsIsLoading = ref(false);
// Get the departments from the API
export const getDepartments = async () => {
@@ -57,4 +59,19 @@ export const getDepartmentsGuest = async (queryParams = {}) => {
isLoading.value = false;
}
};
export const getTimeBookingDepartments = async (queryParams = {}) => {
timeBookingDepartmentsIsLoading.value = true;
try {
const queryString = new URLSearchParams(queryParams).toString();
const request = await unauthenticatedRequest(
"/department/timebookings/departments/public" + (queryString ? `?${queryString}` : ""),
"get"
);
timeBookingDepartments.value = sortByDepartmentPriorityOrder(request.data.data || []);
return timeBookingDepartments.value;
} finally {
timeBookingDepartmentsIsLoading.value = false;
}
};
</script>
+66 -28
View File
@@ -64,6 +64,7 @@ import {
clearPeriodCache,
clearSelfWashCountsCache,
} from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportPaging.js";
import { markStoredSessionAsSubuser } from "@/services/sessionStorage.js";
const normalizePositiveInteger = (value) => {
const parsedValue = Number.parseInt(String(value ?? ""), 10);
@@ -278,8 +279,14 @@ const resetSessionState = () => {
SessionUser.subuser.username.value = null;
SessionUser.subuser.name.value = null;
SessionUser.subuser.email.value = null;
SessionUser.subuser.email_verified.value = false;
SessionUser.subuser.email_verified_at.value = null;
SessionUser.subuser.phone.country_code.value = null;
SessionUser.subuser.phone.number.value = null;
SessionUser.subuser.phone.verified.value = false;
SessionUser.subuser.phone.verified_at.value = null;
SessionUser.subuser.verification_state.value = null;
SessionUser.subuser.verification.value = null;
SessionUser.subuser.grants.value = [];
SessionUser.subuser.created_at.value = null;
SessionUser.subuser.updated_at.value = null;
@@ -397,6 +404,12 @@ export const isInvalidSessionError = (error) => {
);
};
const isUserEndpointSubuserTokenMismatch = (error) => {
const status = Number.parseInt(String(error?.response?.status ?? ""), 10);
const message = extractErrorMessage(error).toLowerCase();
return [400, 401].includes(status) && message.includes("user not found");
};
const redirectToConnectivityIssue = () => {
if (typeof window === "undefined" || window.location.pathname === "/connectivity-issue") {
return false;
@@ -406,9 +419,10 @@ const redirectToConnectivityIssue = () => {
return true;
};
export const handleSessionBootstrapFailure = async (error, { title, text } = {}) => {
export const handleSessionBootstrapFailure = async (error, { title, text, subuserSession = false } = {}) => {
if (isInvalidSessionError(error)) {
const wasSubuserSession = SessionUser.isSubuser.value || getStoredSessionSnapshot()?.isSubuser === true;
const wasSubuserSession =
Boolean(subuserSession) || SessionUser.isSubuser.value || getStoredSessionSnapshot()?.isSubuser === true;
const pingResult = await pingApiServer();
if (!pingResult.ok) {
console.warn("Preserving stored session because API ping failed during invalid-session handling.", pingResult);
@@ -567,6 +581,44 @@ export const authenticateSubuser = async (credentials) => {
});
};
const applySubuserSessionPayload = async (payload) => {
const data = payload || {};
SessionUser.isSubuser.value = true;
markStoredSessionAsSubuser();
SessionUser.subuser.id.value = data.id;
SessionUser.subuser.username.value = data.username;
SessionUser.subuser.name.value = data.name;
SessionUser.subuser.email.value = data.email;
SessionUser.subuser.email_verified.value = Boolean(data.email_verified);
SessionUser.subuser.email_verified_at.value = data.email_verified_at || null;
SessionUser.subuser.phone.country_code.value = data.phone_country_code;
SessionUser.subuser.phone.number.value = data.phone;
SessionUser.subuser.phone.verified.value = Boolean(data.phone_verified);
SessionUser.subuser.phone.verified_at.value = data.phone_verified_at || null;
SessionUser.subuser.verification_state.value = data.verification_state || data.verification?.state || null;
SessionUser.subuser.verification.value = data.verification || null;
SessionUser.subuser.grants.value = data.grants || [];
reconcileSelectedSubuserGrant(SessionUser.subuser.grants.value);
SessionUser.subuser.created_at.value = data.created_at;
SessionUser.subuser.updated_at.value = data.updated_at;
SessionUser.subuser.suspended_at.value = data.suspended_at;
SessionUser.subuser.cached_at.value = new Date();
setReleaseChannelSwitchNoticePrincipal(`subuser:${data.id || data.username || data.email || "unknown"}`);
// Build permissions from grants
const allPermissions = [];
if (data.grants && Array.isArray(data.grants)) {
data.grants.forEach((grant) => {
if (grant.permissions && Array.isArray(grant.permissions)) {
allPermissions.push(...grant.permissions);
}
});
}
SessionUser.permissions.value = [...new Set(allPermissions)];
await refreshReleaseRuntime();
SessionUser.initiated.value = true;
};
/**
* Get the subuser's session data from /subusers/me
* @returns {Promise<void>}
@@ -575,32 +627,7 @@ export const getSubuserSessionData = async () => {
return await authenticatedRequest("/subusers/me", "GET")
.then(async (response) => {
const data = response.data.data || response.data;
SessionUser.subuser.id.value = data.id;
SessionUser.subuser.username.value = data.username;
SessionUser.subuser.name.value = data.name;
SessionUser.subuser.email.value = data.email;
SessionUser.subuser.phone.country_code.value = data.phone_country_code;
SessionUser.subuser.phone.number.value = data.phone;
SessionUser.subuser.grants.value = data.grants || [];
reconcileSelectedSubuserGrant(SessionUser.subuser.grants.value);
SessionUser.subuser.created_at.value = data.created_at;
SessionUser.subuser.updated_at.value = data.updated_at;
SessionUser.subuser.suspended_at.value = data.suspended_at;
SessionUser.subuser.cached_at.value = new Date();
setReleaseChannelSwitchNoticePrincipal(`subuser:${data.id || data.username || data.email || "unknown"}`);
// Build permissions from grants
const allPermissions = [];
if (data.grants && Array.isArray(data.grants)) {
data.grants.forEach((grant) => {
if (grant.permissions && Array.isArray(grant.permissions)) {
allPermissions.push(...grant.permissions);
}
});
}
SessionUser.permissions.value = [...new Set(allPermissions)];
await refreshReleaseRuntime();
SessionUser.initiated.value = true;
await applySubuserSessionPayload(data);
})
.catch(async (error) => {
if (isReleaseChannelApiAvailabilityError(error)) {
@@ -612,6 +639,7 @@ export const getSubuserSessionData = async () => {
return await handleSessionBootstrapFailure(error, {
title: "Fejl ved hentning af subbrugerdata",
text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.",
subuserSession: true,
});
});
};
@@ -680,6 +708,10 @@ export const getSessionData = async () => {
return;
}
if (isUserEndpointSubuserTokenMismatch(error)) {
return await getSubuserSessionData();
}
return await handleSessionBootstrapFailure(error, {
title: "Fejl ved hentning af brugerdata",
text: "Der opstod en fejl ved hentning af dine brugerdata. Prøv at logge ind igen.",
@@ -819,10 +851,16 @@ export const SessionUser = {
username: ref(null),
name: ref(null),
email: ref(null),
email_verified: ref(false),
email_verified_at: ref(null),
phone: {
country_code: ref(null),
number: ref(null),
verified: ref(false),
verified_at: ref(null),
},
verification_state: ref(null),
verification: ref(null),
grants: ref([]),
created_at: ref(null),
updated_at: ref(null),
@@ -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', {
@@ -2,8 +2,18 @@
import Swal from "sweetalert2";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import i18n from "@/i18n";
import { getSubuserPasswordPolicyError } from "@/services/subuserPasswordPolicy.js";
const t = (key, values = undefined) => i18n.global.t(key, values);
let qrCodeModulePromise;
const loadQRCodeModule = () => {
if (!qrCodeModulePromise) {
qrCodeModulePromise = import("qrcode");
}
return qrCodeModulePromise;
};
const escapeHtml = (value) => String(value ?? "")
.replace(/&/g, "&amp;")
@@ -12,6 +22,137 @@ const escapeHtml = (value) => String(value ?? "")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const apiErrorMessage = (error, fallback) =>
error?.response?.data?.data?.message || error?.response?.data?.message || error?.message || fallback;
const absoluteLoginLink = (path) => {
try {
return new URL(path, window.location.origin).toString();
} catch (_error) {
return String(path || "");
}
};
const directLoginPayload = (context = {}) => ({
...(context.customer_number ? { customer_number: context.customer_number } : {}),
...(context.grant_id ? { grant_id: context.grant_id } : {}),
});
const driverContactText = (key, values = undefined) =>
t(`templates.generated.compat.superuser.driver_contact.${key}`, values);
const showDeliveryFeedback = async (response, options = {}) => {
const payload = response?.data?.data || response?.data || {};
const delivery = payload.delivery || {};
const sent = delivery.status === "sent";
await Swal.fire({
title: sent
? (options.sentTitle || driverContactText("link_sent_title"))
: (options.notSentTitle || driverContactText("link_not_sent_title")),
text: delivery.message || (sent
? driverContactText("link_sent_default")
: driverContactText("link_not_sent_default")),
icon: sent ? "success" : "warning",
confirmButtonText: driverContactText("close"),
});
};
const copyText = async (text) => {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch (_error) {
// Fall back below when clipboard permission is denied.
}
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
};
const buildAdminContactFormHtml = (subuser = {}) => `
<div class="subuser-form">
<div class="field">
<label class="label" for="subuser-admin-email">E-mail</label>
<div class="control">
<input id="subuser-admin-email" class="input" type="email" value="${escapeHtml(subuser.email || "")}" placeholder="chauffor@example.com" />
</div>
</div>
<div class="columns">
<div class="column is-4">
<div class="field">
<label class="label" for="subuser-admin-phone-country-code">Landekode</label>
<div class="control">
<input id="subuser-admin-phone-country-code" class="input" type="number" inputmode="numeric" value="${escapeHtml(subuser.phone_country_code || 45)}" />
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label" for="subuser-admin-phone">Telefon</label>
<div class="control">
<input id="subuser-admin-phone" class="input" type="number" inputmode="numeric" value="${escapeHtml(subuser.phone || "")}" />
</div>
</div>
</div>
</div>
</div>
`;
const readAdminContactFormValues = () => {
const email = document.getElementById("subuser-admin-email")?.value?.trim() || "";
const phoneCountryCode = document.getElementById("subuser-admin-phone-country-code")?.value?.trim() || "";
const phone = document.getElementById("subuser-admin-phone")?.value?.trim() || "";
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
Swal.showValidationMessage("E-mail er ugyldig.");
return null;
}
if (!/^\d{1,3}$/.test(phoneCountryCode)) {
Swal.showValidationMessage("Landekode skal være 1-3 cifre.");
return null;
}
if (!/^\d{4,15}$/.test(phone)) {
Swal.showValidationMessage("Telefon skal være 4-15 cifre.");
return null;
}
return {
email: email || null,
phone_country_code: Number.parseInt(phoneCountryCode, 10),
phone: Number.parseInt(phone, 10),
};
};
const buildPasswordFormHtml = () => `
<div class="subuser-form">
<div class="field">
<label class="label" for="subuser-admin-password">Ny adgangskode</label>
<div class="control">
<input id="subuser-admin-password" class="input" type="password" autocomplete="new-password" />
</div>
<p class="help">Minimum 8 tegn med store bogstaver, små bogstaver og tal.</p>
</div>
<div class="field">
<label class="label" for="subuser-admin-password-confirm">Gentag adgangskode</label>
<div class="control">
<input id="subuser-admin-password-confirm" class="input" type="password" autocomplete="new-password" />
</div>
</div>
</div>
`;
const buildAccessTemplateOptions = (templates = [], selectedKey = "driver") => templates
.filter((template) => template?.key && template.key !== "custom")
.map((template) => `
@@ -192,6 +333,259 @@ export const Subusers = {
const normalized = permissions.filter(Boolean);
return customSummary(t("superuser.driver_access.summary.permission_count", { count: normalized.length }));
},
async updateAdminProfile(subuserId, payload) {
return authenticatedRequest(`/superuser/subusers/${encodeURIComponent(subuserId)}`, "PATCH", payload);
},
async setAdminPassword(subuserId, password) {
return authenticatedRequest(`/superuser/subusers/${encodeURIComponent(subuserId)}/password`, "POST", { password });
},
async generateAdminLoginLink(subuserId, context = {}) {
return authenticatedRequest(
`/superuser/subusers/${encodeURIComponent(subuserId)}/login-link`,
"POST",
directLoginPayload(context)
);
},
async setAdminVerificationState(subuserId, channel, verified) {
try {
return await authenticatedRequest(
`/superuser/subusers/${encodeURIComponent(subuserId)}/verification/${encodeURIComponent(channel)}`,
"PATCH",
{ verified: Boolean(verified) }
);
} catch (error) {
await Swal.fire({
title: driverContactText("verification_update_failed_title"),
text: apiErrorMessage(error, driverContactText("verification_update_failed")),
icon: "error",
confirmButtonText: driverContactText("close"),
});
throw error;
}
},
async sendAdminPasswordGuideLink(subuserId, channel, context = {}) {
try {
const response = await authenticatedRequest(
`/superuser/subusers/${encodeURIComponent(subuserId)}/password-guide/${encodeURIComponent(channel)}/send`,
"POST",
directLoginPayload(context)
);
await showDeliveryFeedback(response, {
sentTitle: driverContactText("password_guide_sent_title"),
notSentTitle: driverContactText("password_guide_not_sent_title"),
});
return response;
} catch (error) {
await Swal.fire({
title: driverContactText("password_guide_failed_title"),
text: apiErrorMessage(error, driverContactText("link_send_failed")),
icon: "error",
confirmButtonText: driverContactText("close"),
});
throw error;
}
},
async sendAdminLoginLink(subuserId, channel, context = {}) {
try {
const response = await authenticatedRequest(
`/superuser/subusers/${encodeURIComponent(subuserId)}/login-link/${encodeURIComponent(channel)}/send`,
"POST",
directLoginPayload(context)
);
await showDeliveryFeedback(response, {
sentTitle: driverContactText("login_link_sent_title"),
notSentTitle: driverContactText("login_link_not_sent_title"),
});
return response;
} catch (error) {
await Swal.fire({
title: driverContactText("login_link_failed_title"),
text: apiErrorMessage(error, driverContactText("link_send_failed")),
icon: "error",
confirmButtonText: driverContactText("close"),
});
throw error;
}
},
async resendVerificationCode(subuser, channel, refreshCallback = null, options = {}) {
const superuser = Boolean(options.superuser);
const endpoint = superuser
? `/superuser/subusers/${encodeURIComponent(subuser.id)}/verification/${encodeURIComponent(channel)}/send`
: `/subusers/${encodeURIComponent(subuser.id)}/verification/${encodeURIComponent(channel)}/send`;
try {
const response = await authenticatedRequest(endpoint, "POST");
const payload = response?.data?.data || response?.data || {};
const delivery = payload.delivery || {};
const channelLabel = channel === "email" ? "e-mail" : "telefon";
await Swal.fire({
title: delivery.status === "sent" ? "Kode sendt" : "Kode kunne ikke sendes",
text: delivery.message || (delivery.status === "sent"
? `Verifikationskode er sendt til chaufførens ${channelLabel}.`
: `Verifikationskode kunne ikke sendes til chaufførens ${channelLabel}.`),
icon: delivery.status === "sent" ? "success" : "warning",
confirmButtonText: "Luk",
});
if (typeof refreshCallback === "function") {
await refreshCallback();
}
return response;
} catch (error) {
await Swal.fire({
title: "Kode kunne ikke sendes",
text: apiErrorMessage(error, "Der opstod en fejl."),
icon: "error",
confirmButtonText: "Luk",
});
throw error;
}
},
async showEditNameForm(subuser, refreshCallback = null) {
const result = await Swal.fire({
title: "Redigér chaufførnavn",
input: "text",
inputLabel: "Navn",
inputValue: subuser?.name || "",
showCancelButton: true,
confirmButtonText: "Gem",
cancelButtonText: "Annuller",
inputValidator: (value) => {
const name = String(value || "").trim();
if (name.length < 3) {
return "Navn skal være mindst 3 tegn.";
}
if (name.length > 255) {
return "Navn må højst være 255 tegn.";
}
return null;
},
preConfirm: async (value) => {
try {
return await this.updateAdminProfile(subuser.id, { name: String(value || "").trim() });
} catch (error) {
Swal.showValidationMessage(apiErrorMessage(error, "Kunne ikke gemme navnet."));
return false;
}
},
});
if (result.isConfirmed && typeof refreshCallback === "function") {
await refreshCallback();
}
return result.value || null;
},
async showEditContactForm(subuser, refreshCallback = null) {
const result = await Swal.fire({
title: "Redigér kontaktoplysninger",
html: buildAdminContactFormHtml(subuser),
width: 640,
focusConfirm: false,
showCancelButton: true,
confirmButtonText: "Gem",
cancelButtonText: "Annuller",
preConfirm: async () => {
const values = readAdminContactFormValues();
if (!values) {
return false;
}
try {
return await this.updateAdminProfile(subuser.id, values);
} catch (error) {
Swal.showValidationMessage(apiErrorMessage(error, "Kunne ikke gemme kontaktoplysningerne."));
return false;
}
},
});
if (result.isConfirmed && typeof refreshCallback === "function") {
await refreshCallback();
}
return result.value || null;
},
async showSetPasswordForm(subuser, refreshCallback = null) {
const result = await Swal.fire({
title: "Sæt adgangskode",
html: buildPasswordFormHtml(),
width: 560,
focusConfirm: false,
showCancelButton: true,
confirmButtonText: "Gem",
cancelButtonText: "Annuller",
preConfirm: async () => {
const password = document.getElementById("subuser-admin-password")?.value || "";
const passwordConfirm = document.getElementById("subuser-admin-password-confirm")?.value || "";
const policyError = getSubuserPasswordPolicyError(password);
if (policyError) {
Swal.showValidationMessage(policyError);
return false;
}
if (password !== passwordConfirm) {
Swal.showValidationMessage("Adgangskoderne matcher ikke.");
return false;
}
try {
return await this.setAdminPassword(subuser.id, password);
} catch (error) {
Swal.showValidationMessage(apiErrorMessage(error, "Kunne ikke gemme adgangskoden."));
return false;
}
},
});
if (result.isConfirmed && typeof refreshCallback === "function") {
await refreshCallback();
}
return result.value || null;
},
async showDirectLoginQrCode(subuser, context = {}) {
try {
const response = await this.generateAdminLoginLink(subuser.id, context);
const payload = response?.data?.data || response?.data || {};
const loginPath = String(payload.login_path || "");
if (!loginPath) {
throw new Error("Loginlink mangler i API-svaret.");
}
const loginUrl = absoluteLoginLink(loginPath);
const { toDataURL } = await loadQRCodeModule();
const qrSrc = await toDataURL(loginUrl, { margin: 2, width: 240 });
return Swal.fire({
title: "Direkte login",
html: `
<div class="has-text-centered">
<img src="${escapeHtml(qrSrc)}" alt="Direkte login QR-kode" width="240" height="240" />
<input id="subuser-direct-login-link" class="input mt-4" type="text" readonly value="${escapeHtml(loginUrl)}" />
</div>
`,
showCancelButton: true,
confirmButtonText: "Kopiér link",
cancelButtonText: "Luk",
preConfirm: async () => {
await copyText(loginUrl);
return loginUrl;
},
});
} catch (error) {
await Swal.fire({
title: "Kunne ikke oprette loginlink",
text: apiErrorMessage(error, "Der opstod en fejl."),
icon: "error",
confirmButtonText: "Luk",
});
return null;
}
},
async showInviteForm(refreshCallback = null, options = {}) {
const superuser = Boolean(options.superuser);
const userId = options.userId || null;
@@ -30,12 +30,47 @@ export const Backups = {
}
)
},
getJob: async (jobId) => {
return authenticatedRequest(
`/modules/backup/jobs/${jobId}`,
"GET"
)
},
listBackups: async () => {
return authenticatedRequest(
Backups.meta.endpoint,
"GET"
)
},
verifyBackup: async (backupUuid) => {
return authenticatedRequest(
`${Backups.meta.endpoint}/${backupUuid}/verify`,
"POST"
)
},
previewRestore: async (backupUuid) => {
return authenticatedRequest(
`${Backups.meta.endpoint}/${backupUuid}/restore/preview`,
"POST"
)
},
restoreBackup: async (backupUuid, previewId, confirmationPhrase, reason) => {
return authenticatedRequest(
`${Backups.meta.endpoint}/${backupUuid}/restore`,
"POST",
{
preview_id: previewId,
confirmation_phrase: confirmationPhrase,
reason: reason
}
)
},
restoreAudit: async () => {
return authenticatedRequest(
"/modules/backup/restore-audit",
"GET"
)
},
};
</script>
</script>
@@ -42,7 +42,28 @@ export const Config = {
set: async (enabled) => {
return Config.set("enabled", enabled);
},
},
},
retentionRecentHours: {
set: async (value) => Config.set("retention_recent_hours", value),
},
retentionDailyDays: {
set: async (value) => Config.set("retention_daily_days", value),
},
retentionWeeklyWeeks: {
set: async (value) => Config.set("retention_weekly_weeks", value),
},
retentionMonthlyMonths: {
set: async (value) => Config.set("retention_monthly_months", value),
},
appDataEnabled: {
set: async (enabled) => Config.set("app_data_enabled", enabled),
},
verificationRequired: {
set: async (enabled) => Config.set("verification_required", enabled),
},
restoreEnabled: {
set: async (enabled) => Config.set("restore_enabled", enabled),
},
};
</script>
</script>
@@ -34,6 +34,14 @@ export const Config = {
return Config.set("invoiceLayoutNumber", layout);
},
},
invoiceDiscountLayoutNumber: {
get: async () => {
return Config.get("invoiceDiscountLayoutNumber");
},
set: async (layout) => {
return Config.set("invoiceDiscountLayoutNumber", layout);
},
},
},
fees: {
adminFeeMonthly: {
@@ -2,7 +2,12 @@
export {
SuperUserSystemStatusObject,
DatabaseSystemObject,
RedisSystemObject,
MinioSystemObject,
getSuperuserSystemStatus,
getSystemDependencyStatus,
getSystemDatabaseStatus,
getSystemRedisStatus,
getSystemMinioStatus,
} from "@/components/session/token/superUser/systemStatus.vue";
</script>
@@ -0,0 +1,8 @@
<script>
export {
SuperUserSystemStatusObject,
MinioSystemObject,
getSuperuserSystemStatus,
getSystemMinioStatus,
} from "@/components/session/token/superUser/systemStatus.vue";
</script>
@@ -0,0 +1,8 @@
<script>
export {
SuperUserSystemStatusObject,
RedisSystemObject,
getSuperuserSystemStatus,
getSystemRedisStatus,
} from "@/components/session/token/superUser/systemStatus.vue";
</script>
@@ -13,12 +13,15 @@ export const SuperUserSystemStatusObject = {
runtime: computed(() => SuperUserSystemStatusObject.snapshot.value?.runtime ?? {}),
dependencies: computed(() => SuperUserSystemStatusObject.snapshot.value?.dependencies ?? {}),
modules: computed(() => SuperUserSystemStatusObject.snapshot.value?.modules ?? []),
sessions: computed(() => SuperUserSystemStatusObject.snapshot.value?.sessions ?? {
active_window_minutes: 15,
active_users: 0,
active_sessions: 0,
recent_sessions: [],
}),
sessions: computed(
() =>
SuperUserSystemStatusObject.snapshot.value?.sessions ?? {
active_window_minutes: 15,
active_users: 0,
active_sessions: 0,
recent_sessions: [],
}
),
warnings: computed(() => SuperUserSystemStatusObject.snapshot.value?.warnings ?? []),
refreshAfterSeconds: computed(() => Number(SuperUserSystemStatusObject.snapshot.value?.refresh_after_seconds ?? 30)),
};
@@ -49,15 +52,23 @@ export const getSuperuserSystemStatus = async ({ force = false } = {}) => {
return pendingRequest.value;
};
export const DatabaseSystemObject = {
status: computed(() => SuperUserSystemStatusObject.dependencies.value?.database ?? null),
const createDependencySystemObject = (key) => ({
status: computed(() => SuperUserSystemStatusObject.dependencies.value?.[key] ?? null),
loading: computed(() => SuperUserSystemStatusObject.loading.value),
error: computed(() => SuperUserSystemStatusObject.error.value),
lastLoadedAt: computed(() => SuperUserSystemStatusObject.lastLoadedAt.value),
});
export const DatabaseSystemObject = createDependencySystemObject("database");
export const RedisSystemObject = createDependencySystemObject("redis");
export const MinioSystemObject = createDependencySystemObject("minio");
export const getSystemDependencyStatus = async (key, { force = false } = {}) => {
const snapshot = await getSuperuserSystemStatus({ force });
return snapshot?.dependencies?.[key] ?? null;
};
export const getSystemDatabaseStatus = async ({ force = false } = {}) => {
const snapshot = await getSuperuserSystemStatus({ force });
return snapshot?.dependencies?.database ?? null;
};
export const getSystemDatabaseStatus = (options = {}) => getSystemDependencyStatus("database", options);
export const getSystemRedisStatus = (options = {}) => getSystemDependencyStatus("redis", options);
export const getSystemMinioStatus = (options = {}) => getSystemDependencyStatus("minio", options);
</script>
+116 -61
View File
@@ -2,7 +2,11 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { parseError } from "@/components/request/HandleGlobalError.vue";
import { clearEdgeGatewayWorkspaceCache } from "@/services/edgeGateways.js";
import { DatabaseSystemObject } from "@/components/session/token/superUser/systemDatabase.vue";
import {
DatabaseSystemObject,
RedisSystemObject,
MinioSystemObject,
} from "@/components/session/token/superUser/systemDatabase.vue";
import { Cron } from "@/components/session/token/superUser/cron.vue";
import { Economic } from "@/components/session/token/superUser/modules/Economic/Economic.vue";
import { reCAPTCHA } from "@/components/session/token/superUser/modules/reCAPTCHA/reCAPTCHA.vue";
@@ -20,7 +24,7 @@ import { Limble } from "@/components/session/token/superUser/modules/limble/Limb
import { OcrSpace } from "@/components/session/token/superUser/modules/ocrSpace/OcrSpace.vue";
import { OpenAI } from "@/components/session/token/superUser/modules/openAI/OpenAI.vue";
import { LicensePlateRecognizer } from "@/components/session/token/superUser/modules/licensePlateRecognizer/LicensePlateRecognizer.vue";
import { VirkData} from "@/components/session/token/superUser/modules/virkdata/VirkData.vue";
import { VirkData } from "@/components/session/token/superUser/modules/virkdata/VirkData.vue";
import { Shelly } from "@/components/session/token/superUser/modules/shelly/Shelly.vue";
import { SelfServe } from "@/components/session/token/superUser/modules/selfserve/SelfServe.vue";
import { Bird } from "@/components/session/token/superUser/modules/bird/Bird.vue";
@@ -40,40 +44,97 @@ const loadQRCodeModule = async () => {
return qrCodeModulePromise;
};
/**
* The superuser object
*/
export const SuperUserObject = {
system: {
get database() { return DatabaseSystemObject; },
get database() {
return DatabaseSystemObject;
},
get redis() {
return RedisSystemObject;
},
get minio() {
return MinioSystemObject;
},
},
modules: {
get economic() { return Economic; },
get reCAPTCHA() { return reCAPTCHA; },
get email() { return Email; },
get backups() { return Backups; },
get motorapi() { return MotorAPI; },
get stripe() { return Stripe; },
get fxratesapi() { return FXRatesAPI; },
get weatherapi() { return WeatherAPI; },
get workfeed() { return Workfeed; },
get gatewayapi() { return GatewayAPI; },
get xlvask() { return XLVask; },
get entra() { return Entra; },
get limble() { return Limble; },
get ocrspace() { return OcrSpace; },
get openai() { return OpenAI; },
get licenseplaterecognizer() { return LicensePlateRecognizer; },
get virkdata() { return VirkData; },
get shelly() { return Shelly; },
get selfserve() { return SelfServe; },
get bird() { return Bird; },
get edgegateway() { return EdgeGateway; },
get failover() { return Failover; },
get coolify() { return Coolify; },
get releasemanager() { return ReleaseManager; },
get slack() { return Slack; },
get economic() {
return Economic;
},
get reCAPTCHA() {
return reCAPTCHA;
},
get email() {
return Email;
},
get backups() {
return Backups;
},
get motorapi() {
return MotorAPI;
},
get stripe() {
return Stripe;
},
get fxratesapi() {
return FXRatesAPI;
},
get weatherapi() {
return WeatherAPI;
},
get workfeed() {
return Workfeed;
},
get gatewayapi() {
return GatewayAPI;
},
get xlvask() {
return XLVask;
},
get entra() {
return Entra;
},
get limble() {
return Limble;
},
get ocrspace() {
return OcrSpace;
},
get openai() {
return OpenAI;
},
get licenseplaterecognizer() {
return LicensePlateRecognizer;
},
get virkdata() {
return VirkData;
},
get shelly() {
return Shelly;
},
get selfserve() {
return SelfServe;
},
get bird() {
return Bird;
},
get edgegateway() {
return EdgeGateway;
},
get failover() {
return Failover;
},
get coolify() {
return Coolify;
},
get releasemanager() {
return ReleaseManager;
},
get slack() {
return Slack;
},
},
/** Intimidate a user */
intimidate: {
@@ -83,18 +144,14 @@ export const SuperUserObject = {
* @returns {Promise<string>} The impersonation token
*/
getImpersonationToken: async (userId) => {
return await authenticatedRequest("/su/intimidate", "POST", {user_id: userId})
.then(
(response) => {
// Return the token
return response.data.data.token;
}
)
.catch(
(error) => {
parseError(error, 'auth');
}
);
return await authenticatedRequest("/su/intimidate", "POST", { user_id: userId })
.then((response) => {
// Return the token
return response.data.data.token;
})
.catch((error) => {
parseError(error, "auth");
});
},
/**
* Intimidate a user
@@ -102,24 +159,20 @@ export const SuperUserObject = {
* @returns {Promise<void>}
*/
intimidateUser: async (userId) => {
return await authenticatedRequest("/su/intimidate", "POST", {user_id: userId})
.then(
(response) => {
// Save the current token, so we can restore it after the user has been intimidated
const currentToken = localStorage.getItem("token");
localStorage.setItem("superuser_token", currentToken);
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", response.data.data.token);
// Redirect the user to the dashboard
window.location = "/";
}
)
.catch(
(error) => {
parseError(error, 'auth');
}
);
return await authenticatedRequest("/su/intimidate", "POST", { user_id: userId })
.then((response) => {
// Save the current token, so we can restore it after the user has been intimidated
const currentToken = localStorage.getItem("token");
localStorage.setItem("superuser_token", currentToken);
// Set the token in the local storage
clearEdgeGatewayWorkspaceCache();
localStorage.setItem("token", response.data.data.token);
// Redirect the user to the dashboard
window.location = "/";
})
.catch((error) => {
parseError(error, "auth");
});
},
/**
* Can the current superuser intimidate another superuser?
@@ -160,8 +213,10 @@ export const SuperUserObject = {
*/
isIntimidated: () => {
return localStorage.getItem("superuser_token") !== null;
}
},
},
get cron() {
return Cron;
},
get cron() { return Cron; }
};
</script>
@@ -0,0 +1,43 @@
import { computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { hasStoredSessionToken } from "@/services/sessionStorage.js";
const resolveSafeRedirectPath = (redirectValue) => {
const normalizedRedirectValue = Array.isArray(redirectValue) ? redirectValue[0] : redirectValue;
if (!normalizedRedirectValue || typeof window === "undefined") {
return null;
}
try {
const redirectUrl = new URL(normalizedRedirectValue, window.location.origin);
if (redirectUrl.origin !== window.location.origin) {
return null;
}
return `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
} catch {
return null;
}
};
export const useStoredSessionGuestRedirect = ({ fallbackRoute = { name: "default" } } = {}) => {
const route = useRoute();
const router = useRouter();
const hasPendingStoredSession = computed(() => hasStoredSessionToken());
const redirectStoredSession = () => {
if (!hasStoredSessionToken()) {
return false;
}
const redirectPath = resolveSafeRedirectPath(route.query?.redirect);
void router.replace(redirectPath || fallbackRoute);
return true;
};
onMounted(redirectStoredSession);
return {
hasPendingStoredSession,
redirectStoredSession,
};
};
@@ -0,0 +1,400 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { BTooltip } from "buefy";
import { useI18n } from "vue-i18n";
import { getCustomerRuleTooltipModel, groupHasProducts } from "@/features/customer/customerRuleProductImpact.js";
import { loadCustomerRuleProductCatalog } from "@/features/customer/customerRuleProductCatalog.js";
const props = defineProps({
active: {
type: Boolean,
default: false,
},
attribute: {
type: String,
required: true,
},
customerNumber: {
type: [Number, String],
default: null,
},
departmentId: {
type: [Number, String],
default: null,
},
position: {
type: String,
default: "is-bottom",
},
products: {
type: Array,
default: null,
},
testId: {
type: String,
default: "",
},
});
const { t } = useI18n({ useScope: "global" });
const catalogProducts = ref([]);
const isLoading = ref(false);
const loadFailed = ref(false);
const isTooltipOpen = ref(false);
const triggerHovered = ref(false);
const contentHovered = ref(false);
const focusWithinTooltip = ref(false);
const tooltipRef = ref(null);
let loadRequestId = 0;
let closeTimer = null;
const hasProvidedProducts = computed(() => Array.isArray(props.products));
const effectiveProducts = computed(() => (hasProvidedProducts.value ? props.products : catalogProducts.value));
const tooltipModel = computed(() => getCustomerRuleTooltipModel(props.attribute, {
active: props.active,
products: effectiveProducts.value,
}));
const hasTooltip = computed(() => tooltipModel.value !== null);
const shouldLoadProducts = computed(() => (
hasTooltip.value &&
tooltipModel.value.hasProductImpact &&
!hasProvidedProducts.value &&
!isLoading.value &&
catalogProducts.value.length === 0 &&
!loadFailed.value
));
const blockedTitleKey = computed(() => (
props.active
? "customer_rules.tooltip.blocked_now"
: "customer_rules.tooltip.blocked_if_enabled"
));
const availableTitleKey = computed(() => (
props.active
? "customer_rules.tooltip.available_while_enabled"
: "customer_rules.tooltip.available_if_enabled"
));
const listTestId = (sectionKey, groupKey) => (
props.testId
? `${props.testId}-${sectionKey}-${groupKey}`
: `customer-rule-tooltip-${props.attribute}-${sectionKey}-${groupKey}`
);
const ensureProductsLoaded = async () => {
if (!shouldLoadProducts.value) {
return;
}
const requestId = ++loadRequestId;
isLoading.value = true;
loadFailed.value = false;
try {
const products = await loadCustomerRuleProductCatalog({
departmentId: props.departmentId,
customerNumber: props.customerNumber,
});
if (requestId === loadRequestId) {
catalogProducts.value = products;
}
} catch (error) {
console.warn("Unable to load products for customer rule tooltip", error);
if (requestId === loadRequestId) {
loadFailed.value = true;
}
} finally {
if (requestId === loadRequestId) {
isLoading.value = false;
}
}
};
const clearCloseTimer = () => {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
};
const updateTooltipPosition = async () => {
await nextTick();
tooltipRef.value?.updateAppendToBody?.();
};
const openTooltip = () => {
clearCloseTimer();
isTooltipOpen.value = true;
ensureProductsLoaded();
updateTooltipPosition();
};
const closeTooltip = () => {
clearCloseTimer();
triggerHovered.value = false;
contentHovered.value = false;
focusWithinTooltip.value = false;
isTooltipOpen.value = false;
};
const scheduleTooltipClose = () => {
clearCloseTimer();
closeTimer = setTimeout(() => {
closeTimer = null;
if (!triggerHovered.value && !contentHovered.value && !focusWithinTooltip.value) {
isTooltipOpen.value = false;
}
}, 180);
};
const onTriggerMouseEnter = () => {
triggerHovered.value = true;
openTooltip();
};
const onTriggerMouseLeave = () => {
triggerHovered.value = false;
scheduleTooltipClose();
};
const onContentMouseEnter = () => {
contentHovered.value = true;
openTooltip();
};
const onContentMouseLeave = () => {
contentHovered.value = false;
scheduleTooltipClose();
};
const onTooltipFocusIn = () => {
focusWithinTooltip.value = true;
openTooltip();
};
const onTooltipFocusOut = () => {
focusWithinTooltip.value = false;
scheduleTooltipClose();
};
watch(
() => [props.attribute, props.customerNumber, props.departmentId],
() => {
closeTooltip();
if (!hasProvidedProducts.value) {
catalogProducts.value = [];
loadFailed.value = false;
}
}
);
onBeforeUnmount(() => {
clearCloseTimer();
});
</script>
<template>
<BTooltip
v-if="hasTooltip"
ref="tooltipRef"
class="customer-rule-tooltip"
:active="isTooltipOpen"
:triggers="[]"
:position="position"
content-class="customer-rule-tooltip__panel"
type="is-dark"
multilined
append-to-body
always
:auto-close="false"
>
<template #content>
<div
class="customer-rule-tooltip__content"
tabindex="-1"
:data-testid="testId ? `${testId}-content` : `customer-rule-tooltip-${attribute}`"
@mouseenter="onContentMouseEnter"
@mouseleave="onContentMouseLeave"
@focusin="onTooltipFocusIn"
@focusout="onTooltipFocusOut"
@keydown.escape.stop.prevent="closeTooltip"
>
<div class="customer-rule-tooltip__section">
<strong class="customer-rule-tooltip__heading">{{ t("customer_rules.tooltip.changes") }}</strong>
<p>{{ t(tooltipModel.descriptionKey) }}</p>
</div>
<template v-if="tooltipModel.hasProductImpact">
<div v-if="isLoading" class="customer-rule-tooltip__state">
{{ t("customer_rules.tooltip.loading_products") }}
</div>
<div v-else-if="loadFailed" class="customer-rule-tooltip__state customer-rule-tooltip__state--error">
{{ t("customer_rules.tooltip.load_failed") }}
</div>
<template v-else>
<div class="customer-rule-tooltip__section">
<strong class="customer-rule-tooltip__heading">{{ t(blockedTitleKey) }}</strong>
<template v-if="groupHasProducts(tooltipModel.blocked)">
<div
v-for="group in tooltipModel.groups"
:key="`blocked-${group.key}`"
class="customer-rule-tooltip__group"
>
<template v-if="(tooltipModel.blocked[group.key] || []).length > 0">
<span class="customer-rule-tooltip__group-title">{{ t(group.labelKey) }}</span>
<ul
class="customer-rule-tooltip__blocked-list"
:data-testid="listTestId('blocked', group.key)"
>
<li
v-for="product in tooltipModel.blocked[group.key]"
:key="`${group.key}-${product.id}`"
class="customer-rule-tooltip__blocked-product"
>
<span class="customer-rule-tooltip__blocked-prefix" aria-hidden="true">-</span>
<span>{{ product.name || product.product_name }}</span>
</li>
</ul>
</template>
</div>
</template>
<p v-else>{{ t("customer_rules.tooltip.no_affected_products") }}</p>
</div>
<div v-if="groupHasProducts(tooltipModel.available)" class="customer-rule-tooltip__section">
<strong class="customer-rule-tooltip__heading">{{ t(availableTitleKey) }}</strong>
<div
v-for="group in tooltipModel.groups"
:key="`available-${group.key}`"
class="customer-rule-tooltip__group"
>
<template v-if="(tooltipModel.available[group.key] || []).length > 0">
<span class="customer-rule-tooltip__group-title">{{ t(group.labelKey) }}</span>
<ul :data-testid="listTestId('available', group.key)">
<li v-for="product in tooltipModel.available[group.key]" :key="`${group.key}-${product.id}`">
{{ product.name || product.product_name }}
</li>
</ul>
</template>
</div>
</div>
</template>
</template>
</div>
</template>
<span
class="customer-rule-tooltip__trigger"
tabindex="0"
:data-testid="testId || undefined"
@mouseenter="onTriggerMouseEnter"
@mouseleave="onTriggerMouseLeave"
@focusin="onTooltipFocusIn"
@focusout="onTooltipFocusOut"
@keydown.escape.stop.prevent="closeTooltip"
>
<slot />
</span>
</BTooltip>
<slot v-else />
</template>
<style scoped>
.customer-rule-tooltip {
display: inline-flex;
max-width: 100%;
}
.customer-rule-tooltip :deep(.tooltip-trigger) {
display: inline-flex;
max-width: 100%;
}
.customer-rule-tooltip__trigger {
display: inline-flex;
max-width: 100%;
outline-offset: 2px;
}
:global(.b-tooltip .tooltip-content.customer-rule-tooltip__panel) {
padding: 0;
pointer-events: auto;
}
.customer-rule-tooltip__content {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 0.7rem;
max-height: min(26rem, 70vh);
min-width: 16rem;
max-width: min(28rem, 90vw);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.35rem 0.75rem;
scrollbar-width: thin;
text-align: left;
}
.customer-rule-tooltip__section {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.customer-rule-tooltip__heading {
color: #ffffff;
font-size: 0.82rem;
letter-spacing: 0;
}
.customer-rule-tooltip__group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.customer-rule-tooltip__group-title {
color: #d7e3f3;
font-size: 0.76rem;
font-weight: 700;
}
.customer-rule-tooltip__content p,
.customer-rule-tooltip__content ul {
margin: 0;
}
.customer-rule-tooltip__content ul {
padding-left: 1.1rem;
}
.customer-rule-tooltip__blocked-list {
display: flex;
flex-direction: column;
gap: 0.15rem;
list-style: none;
padding-left: 0;
}
.customer-rule-tooltip__blocked-product {
color: #ff8a8a;
display: flex;
gap: 0.35rem;
}
.customer-rule-tooltip__blocked-prefix {
flex: 0 0 auto;
font-weight: 700;
}
.customer-rule-tooltip__state {
color: #d7e3f3;
}
.customer-rule-tooltip__state--error {
color: #ffd6d6;
}
</style>
+52 -30
View File
@@ -1,6 +1,9 @@
const ADDON_CATEGORY_ID = 4;
const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID = 8;
const TANK_CLEANING_TERMS = ["tank cleaning", "tankcleaning", "tankrens", "tank rens"];
const ADDITIONAL_SERVICE_TERMS = ["add-on", "add on", "addon", "tilvalg"];
const ADDITIONAL_SERVICE_TERMS = ["additional service", "additional services", "tillægsydelse", "tillægsydelser"];
const SPOT_FREE_PRODUCT_IDS = [23, 24];
const SPOT_FREE_TERMS = ["spot free", "spotfree", "skylning med ro"];
const CUSTOMER_PRODUCT_RULE_MESSAGE_KEYS = {
restrictAdditionalServices: "pos.restrictions.addons_not_allowed",
@@ -38,7 +41,10 @@ export const hasCustomerAttribute = (attributes, attributeName) => {
return attributes.some((attribute) => normalizeAttributeName(attribute) === attributeName);
};
const normalizedText = (value) => String(value ?? "").trim().toLowerCase();
const normalizedText = (value) =>
String(value ?? "")
.trim()
.toLowerCase();
const textContainsAny = (value, terms) => {
const text = normalizedText(value);
@@ -54,7 +60,11 @@ export const isTankCleaningCategory = (category) => {
};
export const isAddonCategory = (category, categoryName = null, options = {}) => {
if (String(category ?? "").trim().toLowerCase() === "addons") {
if (
String(category ?? "")
.trim()
.toLowerCase() === "addons"
) {
return true;
}
@@ -65,6 +75,25 @@ export const isAddonCategory = (category, categoryName = null, options = {}) =>
return textContainsAny([category, categoryName].join(" "), ADDITIONAL_SERVICE_TERMS);
};
export const isStandaloneAdditionalServiceCatalogProduct = (product) => {
if (!product) {
return false;
}
const category = product.category ?? product.product_category;
if (Number(category) === STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID) {
return true;
}
return (
isAddonCategory(category, product.category_name ?? product.categoryName) ||
textContainsAny(
[product.name, product.product_name, product.category_name, product.categoryName].join(" "),
ADDITIONAL_SERVICE_TERMS
)
);
};
export const isTankCleaningProduct = (product) => {
if (!product) {
return false;
@@ -75,16 +104,27 @@ export const isTankCleaningProduct = (product) => {
}
return textContainsAny(
[
product.name,
product.product_name,
product.category_name,
product.categoryName,
].join(" "),
[product.name, product.product_name, product.category_name, product.categoryName].join(" "),
TANK_CLEANING_TERMS
);
};
export const isSpotFreeProduct = (product) => {
if (!product) {
return false;
}
const productId = Number(product.id ?? product.product_id ?? 0);
if (SPOT_FREE_PRODUCT_IDS.includes(productId)) {
return true;
}
return textContainsAny(
[product.name, product.product_name, product.category_name, product.categoryName].join(" "),
SPOT_FREE_TERMS
);
};
export const getProductCategoryRestrictionForCustomer = (category, attributes = [], options = {}) => {
if (
hasCustomerAttribute(attributes, "restrictAdditionalServices") &&
@@ -113,28 +153,13 @@ export const isProductCategoryRestrictedForCustomer = (category, attributes = []
};
export const isAdditionalServiceProduct = (product, options = {}) => {
if (options.isRelatedAddon === true || Number(options.relatedItemId ?? 0) > 0) {
return true;
}
if (options.isStandaloneAdditionalService === true || options.hasExistingStandaloneOrderItem === true) {
return true;
}
const category = product?.category ?? product?.product_category;
const categoryName = product?.category_name ?? product?.categoryName ?? options.categoryName;
if (isAddonCategory(category, categoryName, options)) {
return true;
}
return textContainsAny(
[
product?.name,
product?.product_name,
categoryName,
].join(" "),
ADDITIONAL_SERVICE_TERMS
);
return textContainsAny([product?.name, product?.product_name, categoryName].join(" "), ADDITIONAL_SERVICE_TERMS);
};
export const getCustomerProductRestriction = (product, attributes = [], options = {}) => {
@@ -144,13 +169,10 @@ export const getCustomerProductRestriction = (product, attributes = [], options
const productName = normalizedText(product.name ?? product.product_name);
if (
hasCustomerAttribute(attributes, "restrictAdditionalServices") &&
isAdditionalServiceProduct(product, options)
) {
if (hasCustomerAttribute(attributes, "restrictAdditionalServices") && isAdditionalServiceProduct(product, options)) {
return restrictedByRule("restrictAdditionalServices");
}
if (hasCustomerAttribute(attributes, "restrictSpotFree") && productName.includes("spot free")) {
if (hasCustomerAttribute(attributes, "restrictSpotFree") && isSpotFreeProduct(product)) {
return restrictedByRule("restrictSpotFree");
}
if (
@@ -0,0 +1,74 @@
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const productCatalogCache = new Map();
const productCatalogRequests = new Map();
const normalizePositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const normalizeProductsResponse = (response) => {
if (Array.isArray(response?.data?.data)) {
return response.data.data;
}
if (Array.isArray(response?.data)) {
return response.data;
}
if (Array.isArray(response)) {
return response;
}
return [];
};
export const getCustomerRuleProductCatalogCacheKey = ({ departmentId = null, customerNumber = null } = {}) => {
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
const normalizedCustomerNumber = normalizePositiveInteger(customerNumber);
return [
normalizedDepartmentId ? `department:${normalizedDepartmentId}` : "department:global",
normalizedCustomerNumber ? `customer:${normalizedCustomerNumber}` : "customer:global",
].join("|");
};
export const loadCustomerRuleProductCatalog = async ({ departmentId = null, customerNumber = null } = {}) => {
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
const normalizedCustomerNumber = normalizePositiveInteger(customerNumber);
const cacheKey = getCustomerRuleProductCatalogCacheKey({
departmentId: normalizedDepartmentId,
customerNumber: normalizedCustomerNumber,
});
if (productCatalogCache.has(cacheKey)) {
return productCatalogCache.get(cacheKey);
}
if (productCatalogRequests.has(cacheKey)) {
return productCatalogRequests.get(cacheKey);
}
const request = SessionUser.objects.products.get.all({
...(normalizedDepartmentId ? { department_id: normalizedDepartmentId } : {}),
...(normalizedCustomerNumber ? { customer_id: normalizedCustomerNumber } : {}),
final_price: false,
})
.then((response) => {
const products = normalizeProductsResponse(response);
productCatalogCache.set(cacheKey, products);
return products;
})
.finally(() => {
productCatalogRequests.delete(cacheKey);
});
productCatalogRequests.set(cacheKey, request);
return request;
};
export const clearCustomerRuleProductCatalogCache = () => {
productCatalogCache.clear();
productCatalogRequests.clear();
};
@@ -0,0 +1,168 @@
import {
getCustomerProductRestriction,
isStandaloneAdditionalServiceCatalogProduct,
isTankCleaningProduct,
} from "@/features/customer/customerProductRules.js";
import { getCustomerRuleDefinition } from "@/features/customer/customerRuleRegistry.js";
const PRODUCT_GROUPS = Object.freeze([
{
key: "primaryProducts",
labelKey: "customer_rules.tooltip.groups.primary_products",
},
{
key: "relatedAddons",
labelKey: "customer_rules.tooltip.groups.related_addons",
},
{
key: "standaloneAdditionalServices",
labelKey: "customer_rules.tooltip.groups.standalone_additional_services",
},
]);
const normalizeProductId = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const normalizeProductName = (product) => String(product?.name ?? product?.product_name ?? "").trim();
const sortProductsByDisplayOrder = (products) =>
[...products].sort((left, right) => {
const leftPriority = Number(left?.order_priority ?? 0);
const rightPriority = Number(right?.order_priority ?? 0);
if (leftPriority !== rightPriority) {
return leftPriority - rightPriority;
}
return normalizeProductName(left).localeCompare(normalizeProductName(right));
});
export const normalizeRuleProductList = (products = []) => {
if (!Array.isArray(products)) {
return [];
}
const seen = new Set();
return sortProductsByDisplayOrder(products).filter((product) => {
const productId = normalizeProductId(product?.id ?? product?.product_id);
if (productId === null || seen.has(productId)) {
return false;
}
seen.add(productId);
return normalizeProductName(product) !== "";
});
};
const addonToProduct = (addon, parentProduct) => {
const addonProduct = addon?.product || {};
const addonId = normalizeProductId(addon?.option_id ?? addonProduct.id ?? addon?.id);
return {
...addonProduct,
id: addonId ?? addonProduct.id ?? addon?.id,
name: addon?.name || addonProduct.name,
category: addonProduct.category ?? addon?.category,
category_name: addonProduct.category_name ?? addon?.category_name,
order_priority: addonProduct.order_priority ?? addon?.order_priority ?? parentProduct?.order_priority,
parentProductName: normalizeProductName(parentProduct),
};
};
const flattenRelatedAddons = (products) =>
products.flatMap((product) =>
Array.isArray(product?.addons) ? product.addons.map((addon) => addonToProduct(addon, product)) : []
);
const buildAttributeSet = (attribute) => [{ attribute }];
const productIsRestrictedByAttribute = (product, attribute, options = {}) =>
getCustomerProductRestriction(product, buildAttributeSet(attribute), options).rule === attribute;
const emptyGroups = () => ({
primaryProducts: [],
relatedAddons: [],
standaloneAdditionalServices: [],
});
const groupedRestrictedProducts = (attribute, products) => {
const normalizedProducts = normalizeRuleProductList(products);
const relatedAddons = normalizeRuleProductList(flattenRelatedAddons(normalizedProducts));
const standaloneCandidates = normalizeRuleProductList(
normalizedProducts.filter((product) => isStandaloneAdditionalServiceCatalogProduct(product))
);
return {
primaryProducts: normalizedProducts.filter((product) => productIsRestrictedByAttribute(product, attribute)),
relatedAddons:
attribute === "restrictAdditionalServices"
? []
: relatedAddons.filter((product) =>
productIsRestrictedByAttribute(product, attribute, {
includeNumericAddonCategory: true,
isRelatedAddon: true,
})
),
standaloneAdditionalServices: standaloneCandidates.filter((product) =>
productIsRestrictedByAttribute(product, attribute, {
includeNumericAddonCategory: true,
isStandaloneAdditionalService: true,
})
),
};
};
const groupedAllowedOnlyTankCleaningProducts = (products) => ({
primaryProducts: normalizeRuleProductList(products).filter((product) => isTankCleaningProduct(product)),
relatedAddons: normalizeRuleProductList(flattenRelatedAddons(products)).filter((product) =>
isTankCleaningProduct(product)
),
standaloneAdditionalServices: [],
});
export const getCustomerRuleProductImpact = (attribute, products = []) => {
const definition = getCustomerRuleDefinition(attribute);
if (!definition?.productImpact) {
return {
hasProductImpact: false,
blocked: emptyGroups(),
available: emptyGroups(),
};
}
const normalizedProducts = normalizeRuleProductList(products);
const blocked = groupedRestrictedProducts(attribute, normalizedProducts);
const available =
attribute === "onlyTankCleaning" ? groupedAllowedOnlyTankCleaningProducts(normalizedProducts) : emptyGroups();
return {
hasProductImpact: true,
groups: PRODUCT_GROUPS,
blocked,
available,
};
};
export const groupHasProducts = (groupedProducts = {}) =>
PRODUCT_GROUPS.some((group) => (groupedProducts[group.key] || []).length > 0);
export const getCustomerRuleTooltipModel = (attribute, options = {}) => {
const definition = getCustomerRuleDefinition(attribute);
if (!definition) {
return null;
}
const productImpact = getCustomerRuleProductImpact(attribute, options.products || []);
return {
attribute,
active: options.active === true,
definition,
descriptionKey: definition.descriptionKey,
hasProductImpact: productImpact.hasProductImpact,
groups: productImpact.groups || PRODUCT_GROUPS,
blocked: productImpact.blocked,
available: productImpact.available,
};
};
@@ -8,6 +8,7 @@ export const CUSTOMER_RULE_DEFINITIONS = Object.freeze([
descriptionKey: "customer_rules.attributes.restrictAdditionalServices.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
productImpact: true,
},
{
attribute: "restrictTankCleaning",
@@ -18,6 +19,7 @@ export const CUSTOMER_RULE_DEFINITIONS = Object.freeze([
descriptionKey: "customer_rules.attributes.restrictTankCleaning.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
productImpact: true,
},
{
attribute: "restrictSpotFree",
@@ -28,6 +30,7 @@ export const CUSTOMER_RULE_DEFINITIONS = Object.freeze([
descriptionKey: "customer_rules.attributes.restrictSpotFree.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
productImpact: true,
},
{
attribute: "restrictInteriorCleaning",
@@ -38,6 +41,7 @@ export const CUSTOMER_RULE_DEFINITIONS = Object.freeze([
descriptionKey: "customer_rules.attributes.restrictInteriorCleaning.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
productImpact: true,
},
{
attribute: "onlyTankCleaning",
@@ -48,6 +52,7 @@ export const CUSTOMER_RULE_DEFINITIONS = Object.freeze([
descriptionKey: "customer_rules.attributes.onlyTankCleaning.description",
addPermission: "add_customer_attribute",
deletePermission: "delete_customer_attribute",
productImpact: true,
},
{
attribute: "requiresReferenceNumber",
+324 -3
View File
@@ -167,6 +167,7 @@
"blive": "blive",
"bliver": "bliver",
"bloker": "Bloker",
"block": "Block",
"bn": "bn",
"bogføres": "bogføres",
"bogført": "bogført",
@@ -259,6 +260,7 @@
"download": "download",
"du": "du",
"dubletter": "dubletter",
"due": "Forfalden",
"duration": "duration",
"e": "e",
"economic": "Economic",
@@ -1143,6 +1145,24 @@
}
},
"templates": {
"support": {
"title": "@.capitalize:{'words.generated.support'}",
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.og'} kontaktoplysninger @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
"eyebrow": "@:{'words.generated.truck'} @:{'words.generated.wash'}",
"intro": "@.capitalize:{'words.generated.kontakt'} os, hvis du har brug @:{'words.generated.for'} @:{'words.generated.hjælp'} @:{'words.generated.til'} @:{'words.generated.booking'}, @:{'words.generated.selvvask'}, @:{'words.generated.betalinger'} eller @:{'words.generated.adgang'} @:{'words.generated.til'} kundeportalen.",
"contact_title": "@.capitalize:{'words.generated.kontakt'}",
"email_label": "Email",
"phone_label": "Telefon",
"company_label": "Virksomhed",
"help_title": "Vi kan hjælpe med",
"help_booking": "@.capitalize:{'words.generated.booking'} @:{'words.generated.og'} ændring @:{'words.generated.af'} vasketider.",
"help_self_wash": "Start @:{'words.generated.og'} afslutning @:{'words.generated.af'} @:{'words.generated.selvvask'}.",
"help_payments": "Ordrer, fakturaer @:{'words.generated.og'} kortbetalinger.",
"help_account": "Login, @:{'words.generated.adgang'} @:{'words.generated.og'} køretøjer på @:{'words.generated.din'} konto.",
"privacy_prefix": "Læs også vores",
"privacy_link": "privatlivspolitik",
"privacy_suffix": "@:{'words.generated.for'} oplysninger om @:{'words.generated.data'} @:{'words.generated.og'} rettigheder."
},
"common": {
"step_of": "@.capitalize:{'words.generated.trin'} {current} @:{'words.generated.af'} {total}",
"page_of": "@.capitalize:{'words.generated.side'} {current} @:{'words.generated.af'} {total}",
@@ -1924,7 +1944,9 @@
"view_lane_new_tab": "@:{'words.generated.abn'} @:{'words.generated.bane'} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_lane_setup_new_tab": "@:{'words.generated.abn'} baneopsætning @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_order_new_tab": "@:{'words.generated.abn'} @:{'words.generated.ordre'} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_vehicle_new_tab": "Åbn køretøjet {reg} i ny fane",
"manage_vehicle": "Administrer køretøj",
"view_vehicle_new_tab": "Åbn køretøj i ny fane",
"delete_vehicle": "Slet køretøj",
"accept_self_serve_wash": "Godkend selvbetjent vask",
"accept_self_serve_wash_confirm": "Flyt denne kladde til kunde #{customerNumber} med selvbetjeningsdetaljerne?",
"accept_self_serve_wash_success": "Den selvbetjente vask blev godkendt.",
@@ -2499,21 +2521,69 @@
},
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "@.capitalize:{'words.generated.backup'} @:{'words.generated.oprettet'}",
"backup_created_success": "@.capitalize:{'words.generated.backup'} @:{'words.generated.blev'} @:{'words.generated.oprettet'} @:{'words.generated.succesfuldt'}.",
"backup_error": "@.capitalize:{'words.generated.der'} @:{'words.generated.opstod'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.fejl'} @:{'words.generated.under'} @:{'words.generated.oprettelse'} @:{'words.generated.af'} @:{'words.generated.backup'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "Sikkerhedskopieringsplan",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@:{'words.generated.opret'} @:{'words.generated.backup'}",
"create_backup_confirm": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'} @:{'words.generated.pa'}, @:{'words.generated.at'} @:{'words.generated.du'} @:{'words.generated.vil'} @:{'words.generated.lave'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.sikkerhedskopi'}?",
"delete_backup": "@.capitalize:{'words.generated.slet'} @:{'words.generated.backup'}",
"disabled": "Disabled",
"download_backup": "@.capitalize:{'words.generated.download'} @:{'words.generated.sikkerhedskopi'}",
"enable_system": "@:{'words.generated.aktiver'} backupsystem",
"enable_system_desc": "@.capitalize:{'words.generated.aktiver_2'} @:{'words.generated.eller'} @:{'words.generated.deaktiver'} @:{'words.generated.backup'} @:{'words.generated.systemet'}.",
"enabled": "Enabled",
"general_settings_desc": "@.capitalize:{'words.generated.de'} @:{'words.generated.generelle'} @:{'words.generated.indstillinger'} @:{'words.generated.for'} @:{'words.generated.backup'} @:{'words.generated.systemet'}.",
"last_backup": "@.capitalize:{'words.generated.seneste'} @:{'words.generated.sikkerhedskopi'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "@:{'words.generated.gendan'} @:{'words.generated.sikkerhedskopi'}",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@.capitalize:{'words.generated.konfiguration'} @:{'words.generated.af'} backupsystemet.",
"title": "Backupkonfiguration"
"title": "Backupkonfiguration",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@.capitalize:{'words.generated.administrations'}- @:{'words.generated.og'} @:{'words.generated.miljøgebyr'} @:{'words.generated.indstillinger'} @:{'words.generated.for'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.integrationen'}.",
@@ -3426,6 +3496,21 @@
"toggle_unavailable": "Du har ikke tilladelse til at ændre denne kunderegel.",
"unavailable": "Du har ikke tilladelse til at se kunderegler.",
"unknown_active": "Ukendte aktive regler"
},
"tooltip": {
"available_if_enabled": "Forbliver tilgængelige hvis aktiveret",
"available_while_enabled": "Tilgængelige mens aktiv",
"blocked_if_enabled": "Blokeres hvis aktiveret",
"blocked_now": "Blokeret mens aktiv",
"changes": "Ændringer",
"groups": {
"primary_products": "Primære produkter",
"related_addons": "Tilknyttede add-ons",
"standalone_additional_services": "Selvstændige tillægsydelser"
},
"load_failed": "Berørte produkter kunne ikke indlæses.",
"loading_products": "Indlæser berørte produkter...",
"no_affected_products": "Ingen berørte produkter fundet i det aktuelle produktkatalog."
}
},
"customers": {
@@ -4207,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": {
@@ -4645,6 +4920,7 @@
},
"pagination": {
"archive": "Arkiv",
"clear_all": "Ryd alle",
"ascending": "Stigende (Ældste @:{'words.generated.først'})",
"booking_status": "Bogføringsstatus",
"bookings_overview": "@:{'templates.generated.compat.objects.bookings.description'}",
@@ -5591,6 +5867,38 @@
"custom_summary": "Tilpasset: {summary}"
}
},
"driver_contact": {
"close": "Luk",
"email_missing": "E-mail mangler",
"link_not_sent_default": "Linket kunne ikke sendes.",
"link_not_sent_title": "Link kunne ikke sendes",
"link_send_failed": "Linket kunne ikke sendes.",
"link_sent_default": "Linket er sendt.",
"link_sent_title": "Link sendt",
"login_link_failed_title": "Loginlink kunne ikke sendes",
"login_link_not_sent_title": "Loginlink kunne ikke sendes",
"login_link_sent_title": "Loginlink sendt",
"missing": "Mangler",
"password_guide_failed_title": "Guide kunne ikke sendes",
"password_guide_not_sent_title": "Guide kunne ikke sendes",
"password_guide_sent_title": "Guide sendt",
"phone_missing": "Telefon mangler",
"send_login_link": "Send forhåndsgodkendt loginlink",
"send_password_guide": "Send guide til ny adgangskode",
"status": "Status",
"unverified": "Ikke verificeret",
"verification_update_failed": "Verifikationsstatus kunne ikke opdateres.",
"verification_update_failed_title": "Status kunne ikke opdateres",
"verified": "Verificeret"
},
"driver_wheel": {
"access_section": "Adgange",
"account_section": "Konto",
"contact_section": "Kontakt",
"details_section": "Detaljer",
"identity_subsection": "Identitet",
"system_subsection": "System"
},
"invoicing_billing_period": {
"dashboard_right": "@.capitalize:{'words.generated.dashboard'} @.capitalize:{'words.generated.højre'} @.capitalize:{'words.generated.visning'}",
"fetch_period_data": "@:{'words.generated.hent'} periodedata"
@@ -5880,14 +6188,19 @@
"labels": {
"activity_window": "@:{'words.generated.aktiv'} @:{'words.generated.inden'} @:{'words.generated.for'} @:{'words.generated.de'} @:{'words.generated.seneste'} {minutes} @:{'words.generated.minutter'}",
"buckets_available": "{available} @:{'words.generated.af'} {total} @:{'words.generated.buckets'} @:{'words.generated.tilgængelige'}",
"calls_remaining": "Kald tilbage",
"calls_used": "Brugte kald",
"checked_at": "@.capitalize:{'words.generated.kontrolleret'}",
"configured": "@.capitalize:{'words.generated.konfigureret'}",
"enabled": "@:{'templates.generated.compat.common.enabled'}",
"latency": "Responstid",
"no_reason": "@.capitalize:{'words.generated.ingen'} @:{'words.generated.yderligere'} @:{'words.generated.detaljer'}",
"quota_calls": "Kvote",
"quota_usage": "Kvoteudnyttelse",
"replication_percent": "{percent}% @:{'words.generated.replikering'} ({status})",
"runtime_source": "@.capitalize:{'words.generated.runtime'}-@:{'words.generated.kilde'}",
"server_version": "Serverversion",
"version": "Version",
"warnings": "@:{'templates.generated.compat.global.warnings'}"
},
"modules": {
@@ -5920,6 +6233,11 @@
"http_status": "{label} @:{'words.generated.returnerede'} @:{'words.generated.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'words.generated.returnerede'} @:{'words.generated.et_2'} uventet @:{'words.generated.http'}-@:{'words.generated.svar'}.",
"invalid_probe_payload": "@.capitalize:{'words.generated.proben'} @:{'words.generated.returnerede'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.ugyldig'} @:{'words.generated.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer-kvoten er opbrugt ({used} af {quota} kald brugt).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer-kvoten er næsten opbrugt: {percent}% ({used} af {quota} kald brugt).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-forbrug og kvote er tilgængelige: {used} af {quota} kald brugt.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer returnerede ikke brugbare kvoteværdier.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returnerede et ulæseligt forbrugspayload.",
"missing_config": "@:{'words.generated.manglende'} @:{'words.generated.pakrævet'} @:{'words.generated.konfiguration'}: {variables}.",
"module_disabled": "Modulet @:{'words.generated.er'} @:{'words.generated.deaktiveret'}.",
"recaptcha_credentials_rejected": "{label}-@:{'words.generated.legitimationsoplysninger'} @:{'words.generated.blev'} @:{'words.generated.afvist'} @:{'words.generated.af'} @:{'words.generated.google'}.",
@@ -5931,7 +6249,10 @@
"selfserve_minute_product_missing": "@:{'words.generated.self'}-@:{'words.generated.serve'}-minutprodukt #{productId} @:{'words.generated.findes'} @:{'words.generated.ikke'}.",
"selfserve_minutes_invalid": "@.capitalize:{'words.generated.konfigurationen'} @:{'words.generated.af'} maskinvaskminutter @:{'words.generated.er'} @:{'words.generated.ugyldig'}.",
"selfserve_probe_failed": "@:{'words.generated.self'}-@:{'words.generated.serve'}-@:{'words.generated.proben'} @:{'words.generated.mislykkedes'}: {error}.",
"shelly_no_device_id": "@:{'words.generated.shelly'} Cloud-@:{'words.generated.legitimationsoplysninger'} @:{'words.generated.er'} @:{'words.generated.konfigureret'}, @:{'words.generated.men'} @:{'words.generated.der'} @:{'words.generated.findes'} @:{'words.generated.ikke'} @:{'words.generated.et_2'} kendt device-@:{'words.generated.id'} @:{'words.generated.til'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.sikker'} @:{'words.generated.skrivebeskyttet'} @:{'words.generated.probe'}."
"shelly_no_device_id": "@:{'words.generated.shelly'} Cloud-@:{'words.generated.legitimationsoplysninger'} @:{'words.generated.er'} @:{'words.generated.konfigureret'}, @:{'words.generated.men'} @:{'words.generated.der'} @:{'words.generated.findes'} @:{'words.generated.ikke'} @:{'words.generated.et_2'} kendt device-@:{'words.generated.id'} @:{'words.generated.til'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.sikker'} @:{'words.generated.skrivebeskyttet'} @:{'words.generated.probe'}.",
"backup_encryption_key_missing": "Backup encryption is not ready: {error}.",
"backup_latest_verified_stale": "Latest verified backup is stale.",
"backup_no_verified_backup": "No verified backup is available for restore."
},
"sections": {
"gateways": "@.capitalize:{'words.generated.edge'} @:{'words.generated.gateways'}",
+322 -2
View File
@@ -328,6 +328,7 @@
"draft": "draft",
"drucken": "drucken",
"duplikate": "Duplikate",
"due": "Fällig",
"duration": "duration",
"durchgef": "durchgef",
"durchschn": "Durchschn",
@@ -1254,6 +1255,24 @@
}
},
"templates": {
"support": {
"title": "@:{'words.generated.support'}",
"meta_description": "@:{'words.generated.support'}- @:{'words.generated.und'} Kontaktinformationen @:{'words.generated.fuer'} @:{'words.generated.truck'} @.capitalize:{'words.generated.wash'}.",
"eyebrow": "@:{'words.generated.truck'} @.capitalize:{'words.generated.wash'}",
"intro": "Kontaktieren @.capitalize:{'words.generated.sie'} uns, wenn @.capitalize:{'words.generated.sie'} @:{'words.generated.hilfe'} @:{'words.generated.bei'} Buchungen, Selbstbedienungswaesche, Zahlungen oder dem Zugang zum Kundenportal benoetigen.",
"contact_title": "@:{'words.generated.kontakt'}",
"email_label": "E-Mail",
"phone_label": "@:{'words.generated.telefon'}",
"company_label": "@:{'words.generated.unternehmen'}",
"help_title": "Wir helfen @:{'words.generated.bei'}",
"help_booking": "Buchung @:{'words.generated.und'} Aenderung @:{'words.generated.von'} Waschzeiten.",
"help_self_wash": "Start @:{'words.generated.und'} Abschluss @:{'words.generated.von'} Selbstbedienungswaeschen.",
"help_payments": "Bestellungen, @:{'words.generated.rechnungen'} @:{'words.generated.und'} Kartenzahlungen.",
"help_account": "Login, Zugriff und Fahrzeuge in Ihrem Konto.",
"privacy_prefix": "Lesen Sie auch unsere",
"privacy_link": "Datenschutzerklaerung",
"privacy_suffix": "@:{'words.generated.fuer'} @:{'words.generated.informationen'} zu Daten @:{'words.generated.und'} Rechten."
},
"common": {
"step_of": "@:{'words.generated.schritt'} {current} @:{'words.generated.von'} {total}",
"page_of": "@:{'words.generated.seite'} {current} @:{'words.generated.von'} {total}",
@@ -2035,7 +2054,9 @@
"view_lane_new_tab": "@:{'words.generated.spur'} @:{'words.generated.in'} @:{'words.generated.neuem'} @:{'words.generated.tab'} @:{'words.generated.anzeigen'}",
"view_lane_setup_new_tab": "Spurkonfiguration @:{'words.generated.in'} @:{'words.generated.neuem'} @:{'words.generated.tab'} @:{'words.generated.anzeigen'}",
"view_order_new_tab": "@:{'words.generated.auftrag'} @:{'words.generated.in'} @:{'words.generated.neuem'} @:{'words.generated.tab'} @:{'words.generated.anzeigen'}",
"view_vehicle_new_tab": "@:{'words.generated.fahrzeug'} {reg} @:{'words.generated.in'} @:{'words.generated.neuem'} @:{'words.generated.tab'} @:{'words.generated.anzeigen'}",
"manage_vehicle": "Fahrzeug verwalten",
"view_vehicle_new_tab": "Fahrzeug in neuem Tab öffnen",
"delete_vehicle": "Fahrzeug löschen",
"accept_self_serve_wash": "Self-Service-Wäsche akzeptieren",
"accept_self_serve_wash_confirm": "Diesen Entwurf mit den Self-Service-Details zu Kunde #{customerNumber} verschieben?",
"accept_self_serve_wash_success": "Die Self-Service-Wäsche wurde akzeptiert.",
@@ -2610,21 +2631,69 @@
},
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "@:{'words.generated.backup'} @:{'words.generated.erstellt'}",
"backup_created_success": "@:{'words.generated.backup'} @:{'words.generated.wurde'} @:{'words.generated.erfolgreich'} @:{'words.generated.erstellt'}.",
"backup_error": "@.capitalize:{'words.generated.beim'} @.capitalize:{'words.generated.erstellen'} @:{'words.generated.des'} @:{'words.generated.backups'} @:{'words.generated.ist'} @:{'words.generated.ein'} @:{'words.generated.fehler'} @:{'words.generated.aufgetreten'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "@:{'words.generated.backup'}-Zeitplan",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@:{'words.generated.backup'} @:{'words.generated.erstellen'}",
"create_backup_confirm": "@.capitalize:{'words.generated.sind'} @.capitalize:{'words.generated.sie'} @:{'words.generated.sicher'}, @:{'words.generated.dass'} @.capitalize:{'words.generated.sie'} @:{'words.generated.ein'} @:{'words.generated.backup'} @:{'words.generated.erstellen'} @:{'words.generated.m'}?@:{'words.generated.chten'}?",
"delete_backup": "@:{'words.generated.backup'} @:{'words.generated.l'}?@:{'words.generated.schen'}",
"disabled": "Disabled",
"download_backup": "@:{'words.generated.backup'} @:{'words.generated.herunterladen'}",
"enable_system": "@:{'words.generated.backup'}-@.capitalize:{'words.generated.system'} @:{'words.generated.aktivieren'}",
"enable_system_desc": "@:{'words.generated.backup'}-@.capitalize:{'words.generated.system'} @:{'words.generated.aktivieren'} @:{'words.generated.oder'} @:{'words.generated.deaktivieren'}.",
"enabled": "Enabled",
"general_settings_desc": "@:{'words.generated.allgemeine'} @:{'words.generated.einstellungen'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.das'} @:{'words.generated.backup'}-@.capitalize:{'words.generated.system'}.",
"last_backup": "Letztes @:{'words.generated.backup'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "@:{'words.generated.backup'} wiederherstellen",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@:{'words.generated.konfiguration'} @:{'words.generated.des'} @:{'words.generated.backup'}-@:{'words.generated.systems'}.",
"title": "@:{'words.generated.backup'}-@:{'words.generated.konfiguration'}"
"title": "@:{'words.generated.backup'}-@:{'words.generated.konfiguration'}",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@:{'words.generated.einstellungen'} @:{'words.generated.f'}?@:{'words.generated.r'} @:{'words.generated.verwaltungs'}- @:{'words.generated.und'} @:{'words.generated.umweltgeb'}?@:{'words.generated.hr'} @:{'words.generated.der'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}-@.capitalize:{'words.generated.integration'}.",
@@ -3537,6 +3606,21 @@
"toggle_unavailable": "Sie haben keine Berechtigung, diese Kundenregel zu ändern.",
"unavailable": "Sie haben keine Berechtigung, Kundenregeln anzuzeigen.",
"unknown_active": "Unbekannte aktive Regeln"
},
"tooltip": {
"available_if_enabled": "Bleibt verfügbar, wenn aktiviert",
"available_while_enabled": "Verfügbar, solange aktiv",
"blocked_if_enabled": "Wird blockiert, wenn aktiviert",
"blocked_now": "Blockiert, solange aktiv",
"changes": "Änderungen",
"groups": {
"primary_products": "Primäre Produkte",
"related_addons": "Verknüpfte Add-ons",
"standalone_additional_services": "Eigenständige Zusatzleistungen"
},
"load_failed": "Betroffene Produkte konnten nicht geladen werden.",
"loading_products": "Betroffene Produkte werden geladen...",
"no_affected_products": "Im aktuellen Produktkatalog wurden keine betroffenen Produkte gefunden."
}
},
"customers": {
@@ -4318,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": {
@@ -4756,6 +5030,7 @@
},
"pagination": {
"archive": "Archiv",
"clear_all": "Alle leeren",
"ascending": "Aufsteigend (?lteste @:{'words.generated.zuerst'})",
"booking_status": "Buchungsstatus",
"bookings_overview": "@:{'words.generated.buchungs'}?@:{'words.generated.bersicht'}",
@@ -5946,6 +6221,38 @@
"permission_count": "{count} permissions",
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
}
},
"system_status": {
@@ -5991,14 +6298,19 @@
"labels": {
"activity_window": "@.capitalize:{'words.generated.active'} within @:{'words.generated.the'} @:{'words.generated.last'} {minutes} @:{'words.generated.minutes'}",
"buckets_available": "{available} @:{'words.generated.of'} {total} @:{'words.generated.buckets'} @:{'words.generated.available'}",
"calls_remaining": "Verbleibende Aufrufe",
"calls_used": "Verwendete Aufrufe",
"checked_at": "@.capitalize:{'words.generated.checked'} @:{'words.generated.at'}",
"configured": "@.capitalize:{'words.generated.configured'}",
"enabled": "@.capitalize:{'words.generated.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'words.generated.no'} @:{'words.generated.additional'} @:{'words.generated.details'}",
"quota_calls": "Kontingent",
"quota_usage": "Kontingentnutzung",
"replication_percent": "{percent}% @:{'words.generated.replication'} ({status})",
"runtime_source": "@.capitalize:{'words.generated.runtime'} @:{'words.generated.source'}",
"server_version": "@.capitalize:{'words.generated.server'} @:{'words.generated.version'}",
"version": "Version",
"warnings": "Warnings"
},
"modules": {
@@ -6019,6 +6331,9 @@
},
"reasons": {
"backup_connectivity_confirmed": "@:{'words.generated.backup'} @:{'words.generated.store'} @:{'words.generated.connectivity'} @:{'words.generated.confirmed'}.",
"backup_encryption_key_missing": "Backup encryption is not ready: {error}.",
"backup_latest_verified_stale": "Latest verified backup is stale.",
"backup_no_verified_backup": "No verified backup is available for restore.",
"backup_probe_failed": "@:{'words.generated.backup'} @:{'words.generated.probe'} @:{'words.generated.failed'}: {error}.",
"economic_credentials_missing": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.credentials'} @:{'words.generated.are'} @:{'words.generated.missing'} @:{'words.generated.from'} @:{'words.generated.runtime'} environment @:{'words.generated.configuration'}.",
"email_delivery_not_implemented": "@:{'words.generated.mailersend_mailersend'} @:{'words.generated.must'} @:{'words.generated.be'} @:{'words.generated.enabled'} @:{'words.generated.because'} default @:{'words.generated.smtp'} delivery @:{'words.generated.is'} @:{'words.generated.not'} implemented.",
@@ -6031,6 +6346,11 @@
"http_status": "{label} @:{'words.generated.returned'} @:{'words.generated.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.unexpected'} @:{'words.generated.http'} @:{'words.generated.response'}.",
"invalid_probe_payload": "@.capitalize:{'words.generated.probe'} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.invalid'} @:{'words.generated.payload'}.",
"licenseplaterecognizer_quota_exhausted": "Das License Plate Recognizer-Kontingent ist aufgebraucht ({used} von {quota} Aufrufen verwendet).",
"licenseplaterecognizer_quota_near_limit": "Die License Plate Recognizer-Kontingentnutzung ist nahe am Limit: {percent}% ({used} von {quota} Aufrufen verwendet).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-Nutzung und Kontingent sind verfügbar: {used} von {quota} Aufrufen verwendet.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer hat keine verwendbaren Kontingentwerte zurückgegeben.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer hat ein unlesbares Nutzungspayload zurückgegeben.",
"missing_config": "@.capitalize:{'words.generated.missing'} @:{'words.generated.required'} @:{'words.generated.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'words.generated.module'} @:{'words.generated.is'} @:{'words.generated.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'words.generated.credentials'} @:{'words.generated.were'} rejected @:{'words.generated.by'} @:{'words.generated.google'}.",
+322 -2
View File
@@ -382,6 +382,7 @@
"hauliers": "hauliers",
"have": "have",
"health": "health",
"help": "help",
"here": "here",
"hide": "Hide",
"historical": "historical",
@@ -975,6 +976,24 @@
}
},
"templates": {
"support": {
"title": "@.capitalize:{'words.generated.support'}",
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.and'} @:{'words.generated.contact'} @:{'words.generated.information'} @:{'words.generated.for'} @.capitalize:{'words.generated.truck'} @.capitalize:{'words.generated.wash'}.",
"eyebrow": "@.capitalize:{'words.generated.truck'} @.capitalize:{'words.generated.wash'}",
"intro": "@.capitalize:{'words.generated.contact'} us if you need @:{'words.generated.help'} @:{'words.generated.with'} @:{'words.generated.bookings'}, @:{'words.generated.self'}-@:{'words.generated.service'} @:{'words.generated.wash'}, @:{'words.generated.payments'}, @:{'words.generated.or'} @:{'words.generated.access'} @:{'words.generated.to'} the @:{'words.generated.customer'} @:{'words.generated.portal'}.",
"contact_title": "@.capitalize:{'words.generated.contact'}",
"email_label": "Email",
"phone_label": "Phone",
"company_label": "Company",
"help_title": "We can @:{'words.generated.help'} @:{'words.generated.with'}",
"help_booking": "@.capitalize:{'words.generated.booking'} @:{'words.generated.and'} changing @:{'words.generated.wash'} appointments.",
"help_self_wash": "Starting @:{'words.generated.and'} ending @:{'words.generated.self'}-@:{'words.generated.service'} @:{'words.generated.wash'} sessions.",
"help_payments": "@.capitalize:{'words.generated.orders'}, @:{'words.generated.invoices'}, @:{'words.generated.and'} @:{'words.generated.card'} @:{'words.generated.payments'}.",
"help_account": "Login, @:{'words.generated.access'}, @:{'words.generated.and'} @:{'words.generated.vehicles'} on your @:{'words.generated.account'}.",
"privacy_prefix": "Also read our",
"privacy_link": "privacy policy",
"privacy_suffix": "@:{'words.generated.for'} @:{'words.generated.details'} @:{'words.generated.about'} @:{'words.generated.data'} @:{'words.generated.and'} @:{'words.generated.rights'}."
},
"common": {
"step_of": "@.capitalize:{'words.generated.step'} {current} @:{'words.generated.of'} {total}",
"page_of": "@.capitalize:{'words.generated.page'} {current} @:{'words.generated.of'} {total}",
@@ -1756,7 +1775,9 @@
"view_lane_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.lane'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_lane_setup_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.lane'} @:{'words.generated.setup'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_order_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_vehicle_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.vehicle'} {reg} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"manage_vehicle": "Manage vehicle",
"view_vehicle_new_tab": "Open vehicle in new tab",
"delete_vehicle": "Delete vehicle",
"accept_self_serve_wash": "Accept self-serve wash",
"accept_self_serve_wash_confirm": "Move this draft to customer #{customerNumber} with the self-serve details?",
"accept_self_serve_wash_success": "The self-serve wash was accepted.",
@@ -2331,21 +2352,69 @@
},
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "@.capitalize:{'words.generated.backup'} @:{'words.generated.created'}",
"backup_created_success": "@.capitalize:{'words.generated.backup'} @:{'words.generated.created'} @:{'words.generated.successfully'}.",
"backup_error": "@.capitalize:{'words.generated.an'} @:{'words.generated.error'} @:{'words.generated.occurred'} @:{'words.generated.while'} @:{'words.generated.creating'} @:{'words.generated.a'} @:{'words.generated.backup'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "@.capitalize:{'words.generated.backup'} schedule",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@.capitalize:{'words.generated.create'} @:{'words.generated.backup'}",
"create_backup_confirm": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.sure'} @:{'words.generated.you'} @:{'words.generated.want'} @:{'words.generated.to'} @:{'words.generated.create'} @:{'words.generated.a'} @:{'words.generated.backup'}?",
"delete_backup": "@.capitalize:{'words.generated.delete'} @:{'words.generated.backup'}",
"disabled": "Disabled",
"download_backup": "@.capitalize:{'words.generated.download'} @:{'words.generated.backup'}",
"enable_system": "@.capitalize:{'words.generated.enable'} @:{'words.generated.backup'} @:{'words.generated.system'}",
"enable_system_desc": "@.capitalize:{'words.generated.enable'} @:{'words.generated.or'} @:{'words.generated.disable'} @:{'words.replication.article.host_mention'} @:{'words.generated.backup'} @:{'words.generated.system'}.",
"enabled": "Enabled",
"general_settings_desc": "@.capitalize:{'words.replication.article.host_mention'} @:{'words.generated.general'} @:{'words.generated.settings'} @:{'words.generated.for'} @:{'words.replication.article.host_mention'} @:{'words.generated.backup'} @:{'words.generated.system'}.",
"last_backup": "Latest @:{'words.generated.backup'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "Restore @:{'words.generated.backup'}",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@.capitalize:{'words.generated.configuration'} @:{'words.generated.of'} @:{'words.replication.article.host_mention'} @:{'words.generated.backup'} @:{'words.generated.system'}.",
"title": "@.capitalize:{'words.generated.backup'} @:{'words.generated.configuration'}"
"title": "@.capitalize:{'words.generated.backup'} @:{'words.generated.configuration'}",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@.capitalize:{'words.generated.administration'} @:{'words.generated.and'} @:{'words.generated.environmental'} @:{'words.generated.fee'} @:{'words.generated.settings'} @:{'words.generated.for'} @:{'words.replication.article.host_mention'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.integration'}.",
@@ -3258,6 +3327,21 @@
"toggle_unavailable": "You do not have permission to change this customer rule.",
"unavailable": "You do not have permission to view customer rules.",
"unknown_active": "Unknown active rules"
},
"tooltip": {
"available_if_enabled": "Would remain available if enabled",
"available_while_enabled": "Available while active",
"blocked_if_enabled": "Would be blocked if enabled",
"blocked_now": "Blocked while active",
"changes": "Changes",
"groups": {
"primary_products": "Primary products",
"related_addons": "Related add-ons",
"standalone_additional_services": "Standalone additional services"
},
"load_failed": "Could not load affected products.",
"loading_products": "Loading affected products...",
"no_affected_products": "No affected products found in the current product catalog."
}
},
"customers": {
@@ -4039,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": {
@@ -4477,6 +4751,7 @@
},
"pagination": {
"archive": "Archive",
"clear_all": "Clear all",
"ascending": "Ascending (Oldest @:{'words.generated.first'})",
"booking_status": "@.capitalize:{'words.generated.booking'} @:{'words.generated.status'}",
"bookings_overview": "@.capitalize:{'words.generated.bookings'} @:{'words.generated.overview'}",
@@ -5423,6 +5698,38 @@
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
},
"invoicing_billing_period": {
"dashboard_right": "@.capitalize:{'words.generated.dashboard'} @.capitalize:{'words.generated.right'} @.capitalize:{'words.generated.display'}",
"fetch_period_data": "@:{'words.generated.fetch'} @.capitalize:{'words.generated.period'} @.capitalize:{'words.generated.data'}"
@@ -5712,14 +6019,19 @@
"labels": {
"activity_window": "@.capitalize:{'words.generated.active'} @:{'words.generated.within'} @:{'words.replication.article.host_mention'} @:{'words.generated.last'} {minutes} @:{'words.generated.minutes'}",
"buckets_available": "{available} @:{'words.generated.of'} {total} @:{'words.generated.buckets'} @:{'words.generated.available'}",
"calls_remaining": "Calls remaining",
"calls_used": "Calls used",
"checked_at": "@.capitalize:{'words.generated.checked'} @:{'words.generated.at'}",
"configured": "@.capitalize:{'words.generated.configured'}",
"enabled": "@:{'templates.generated.compat.common.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'words.generated.no'} @:{'words.generated.additional'} @:{'words.generated.details'}",
"quota_calls": "Quota",
"quota_usage": "Quota usage",
"replication_percent": "{percent}% @:{'words.generated.replication'} ({status})",
"runtime_source": "@.capitalize:{'words.generated.runtime'} @:{'words.generated.source'}",
"server_version": "@.capitalize:{'words.generated.server'} @:{'words.generated.version'}",
"version": "Version",
"warnings": "@:{'templates.generated.compat.global.warnings'}"
},
"modules": {
@@ -5740,6 +6052,9 @@
},
"reasons": {
"backup_connectivity_confirmed": "@.capitalize:{'words.generated.backup'} @:{'words.generated.store'} @:{'words.generated.connectivity'} @:{'words.generated.confirmed'}.",
"backup_encryption_key_missing": "Backup encryption is not ready: {error}.",
"backup_latest_verified_stale": "Latest verified backup is stale.",
"backup_no_verified_backup": "No verified backup is available for restore.",
"backup_probe_failed": "@.capitalize:{'words.generated.backup'} @:{'words.generated.probe'} @:{'words.generated.failed'}: {error}.",
"economic_credentials_missing": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.credentials'} @:{'words.generated.are'} @:{'words.generated.missing'} @:{'words.generated.from'} @:{'words.generated.runtime'} environment @:{'words.generated.configuration'}.",
"email_delivery_not_implemented": "@:{'words.generated.mailersend_mailersend'} @:{'words.generated.must'} @:{'words.generated.be'} @:{'words.generated.enabled'} @:{'words.generated.because'} @:{'words.generated.default'} @:{'words.generated.smtp'} delivery @:{'words.generated.is'} @:{'words.generated.not'} @:{'words.generated.implemented'}.",
@@ -5752,6 +6067,11 @@
"http_status": "{label} @:{'words.generated.returned'} @:{'words.generated.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.unexpected'} @:{'words.generated.http'} @:{'words.generated.response'}.",
"invalid_probe_payload": "@.capitalize:{'words.generated.probe'} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.invalid'} @:{'words.generated.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer quota is exhausted ({used} of {quota} calls used).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer quota usage is near the limit: {percent}% ({used} of {quota} calls used).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer usage and quota are available: {used} of {quota} calls used.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer did not return usable quota values.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returned an unreadable usage payload.",
"missing_config": "@.capitalize:{'words.generated.missing'} @:{'words.generated.required'} @:{'words.generated.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'words.generated.module'} @:{'words.generated.is'} @:{'words.generated.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'words.generated.credentials'} @:{'words.generated.were'} @:{'words.generated.rejected'} @:{'words.generated.by'} @:{'words.generated.google'}.",
+515 -8
View File
@@ -511,6 +511,7 @@
"delete_booking": "@:{'templates.generated.compat.admin.pos.settings_wheel.delete_booking'}",
"delete_order": "@:pos.order.delete_order",
"delete_user": "@:{'templates.generated.compat.admin.pos.settings_wheel.delete_user'}",
"delete_vehicle": "@:{'templates.generated.compat.admin.pos.settings_wheel.delete_vehicle'}",
"download_invoice": "@:{'templates.generated.compat.admin.pos.settings_wheel.download_invoice'}",
"edit_permissions": "@:{'templates.generated.compat.admin.pos.settings_wheel.edit_permissions'}",
"enter_password": "@:{'templates.generated.compat.admin.pos.settings_wheel.enter_password'}",
@@ -524,6 +525,7 @@
"login_as_customer": "@:{'templates.generated.compat.admin.pos.settings_wheel.login_as_customer'}",
"login_as_user": "@:{'templates.generated.compat.admin.pos.settings_wheel.login_as_user'}",
"login_as_user_qr": "@:{'templates.generated.compat.admin.pos.settings_wheel.login_as_user_qr'}",
"manage_vehicle": "@:{'templates.generated.compat.admin.pos.settings_wheel.manage_vehicle'}",
"mark_as_completed": "@:global.mark_as_completed",
"no_actions_defined": "@:{'templates.generated.compat.admin.pos.settings_wheel.no_actions_defined'}",
"open_attached_file": "@:{'templates.generated.compat.admin.pos.settings_wheel.open_attached_file'}",
@@ -1188,22 +1190,70 @@
},
"configuration": {
"backups": {
"active_jobs": "@:{'templates.generated.compat.configuration.backups.active_jobs'}",
"actions": "@:{'templates.generated.compat.configuration.backups.actions'}",
"app_data_enabled": "@:{'templates.generated.compat.configuration.backups.app_data_enabled'}",
"app_data_enabled_desc": "@:{'templates.generated.compat.configuration.backups.app_data_enabled_desc'}",
"backup": "@:{'templates.generated.compat.configuration.backups.backup'}",
"backup_created": "@:{'templates.generated.compat.configuration.backups.backup_created'}",
"backup_created_success": "@:{'templates.generated.compat.configuration.backups.backup_created_success'}",
"backup_error": "@:{'templates.generated.compat.configuration.backups.backup_error'}",
"backup_queued": "@:{'templates.generated.compat.configuration.backups.backup_queued'}",
"backup_queued_success": "@:{'templates.generated.compat.configuration.backups.backup_queued_success'}",
"backup_schedule": "@:{'templates.generated.compat.configuration.backups.backup_schedule'}",
"backups": "@:{'templates.generated.compat.configuration.backups.backups'}",
"completed_at": "@:{'templates.generated.compat.configuration.backups.completed_at'}",
"create_backup": "@:{'templates.generated.compat.configuration.backups.create_backup'}",
"create_backup_confirm": "@:{'templates.generated.compat.configuration.backups.create_backup_confirm'}",
"delete_backup": "@:{'templates.generated.compat.configuration.backups.delete_backup'}",
"disabled": "@:{'templates.generated.compat.configuration.backups.disabled'}",
"download_backup": "@:{'templates.generated.compat.configuration.backups.download_backup'}",
"enable_system": "@:{'templates.generated.compat.configuration.backups.enable_system'}",
"enable_system_desc": "@:{'templates.generated.compat.configuration.backups.enable_system_desc'}",
"enabled": "@:{'templates.generated.compat.configuration.backups.enabled'}",
"general_settings": "@:{'templates.generated.compat.configuration.general.title'}",
"general_settings_desc": "@:{'templates.generated.compat.configuration.general.title_desc'}",
"last_backup": "@:{'templates.generated.compat.configuration.backups.last_backup'}",
"latest_backup": "@:{'templates.generated.compat.configuration.backups.latest_backup'}",
"latest_verified": "@:{'templates.generated.compat.configuration.backups.latest_verified'}",
"legacy_backups": "@:{'templates.generated.compat.configuration.backups.legacy_backups'}",
"name": "@:{'templates.generated.compat.configuration.backups.name'}",
"no_backups": "@:{'templates.generated.compat.configuration.backups.no_backups'}",
"no_restore_audit": "@:{'templates.generated.compat.configuration.backups.no_restore_audit'}",
"not_available": "@:{'templates.generated.compat.configuration.backups.not_available'}",
"objects": "@:{'templates.generated.compat.configuration.backups.objects'}",
"refresh": "@:{'templates.generated.compat.configuration.backups.refresh'}",
"restore_audit": "@:{'templates.generated.compat.configuration.backups.restore_audit'}",
"restore_backup": "@:{'templates.generated.compat.configuration.backups.restore_backup'}",
"restore_confirm_intro": "@:{'templates.generated.compat.configuration.backups.restore_confirm_intro'}",
"restore_confirmation": "@:{'templates.generated.compat.configuration.backups.restore_confirmation'}",
"restore_enabled": "@:{'templates.generated.compat.configuration.backups.restore_enabled'}",
"restore_enabled_desc": "@:{'templates.generated.compat.configuration.backups.restore_enabled_desc'}",
"restore_error": "@:{'templates.generated.compat.configuration.backups.restore_error'}",
"restore_queued": "@:{'templates.generated.compat.configuration.backups.restore_queued'}",
"restore_queued_success": "@:{'templates.generated.compat.configuration.backups.restore_queued_success'}",
"restore_reason": "@:{'templates.generated.compat.configuration.backups.restore_reason'}",
"restore_state": "@:{'templates.generated.compat.configuration.backups.restore_state'}",
"restore_state_desc": "@:{'templates.generated.compat.configuration.backups.restore_state_desc'}",
"restore_validation_error": "@:{'templates.generated.compat.configuration.backups.restore_validation_error'}",
"retention": "@:{'templates.generated.compat.configuration.backups.retention'}",
"retention_daily_days": "@:{'templates.generated.compat.configuration.backups.retention_daily_days'}",
"retention_daily_days_desc": "@:{'templates.generated.compat.configuration.backups.retention_daily_days_desc'}",
"retention_monthly_months": "@:{'templates.generated.compat.configuration.backups.retention_monthly_months'}",
"retention_monthly_months_desc": "@:{'templates.generated.compat.configuration.backups.retention_monthly_months_desc'}",
"retention_recent_hours": "@:{'templates.generated.compat.configuration.backups.retention_recent_hours'}",
"retention_recent_hours_desc": "@:{'templates.generated.compat.configuration.backups.retention_recent_hours_desc'}",
"retention_weekly_weeks": "@:{'templates.generated.compat.configuration.backups.retention_weekly_weeks'}",
"retention_weekly_weeks_desc": "@:{'templates.generated.compat.configuration.backups.retention_weekly_weeks_desc'}",
"size": "@:{'templates.generated.compat.configuration.backups.size'}",
"started_at": "@:{'templates.generated.compat.configuration.backups.started_at'}",
"status": "@:{'templates.generated.compat.configuration.backups.status'}",
"subtitle": "@:{'templates.generated.compat.configuration.backups.subtitle'}",
"title": "@:{'templates.generated.compat.configuration.backups.title'}"
"title": "@:{'templates.generated.compat.configuration.backups.title'}",
"verification_required": "@:{'templates.generated.compat.configuration.backups.verification_required'}",
"verification_required_desc": "@:{'templates.generated.compat.configuration.backups.verification_required_desc'}",
"verified_at": "@:{'templates.generated.compat.configuration.backups.verified_at'}",
"verify": "@:{'templates.generated.compat.configuration.backups.verify'}"
},
"departments": {
"subtitle": "@:departments.subtitle",
@@ -2064,9 +2114,9 @@
"cron": {
"actions": {
"edit_interval": "Edit interval",
"refresh": "Refresh",
"refresh": "@:common.refresh",
"run_now": "Run now",
"save": "Save",
"save": "@:common.save",
"toggle": "Toggle cron task"
},
"duration": {
@@ -2083,7 +2133,7 @@
"empty": "No cron runs found.",
"source": "Source",
"started": "Started",
"status": "Status",
"status": "@:common.status",
"task": "Task",
"title": "Run history"
},
@@ -2093,20 +2143,31 @@
"seconds": "Every {value} s"
},
"loading": "Loading cron tasks...",
"messages": {
"queued": "{task} was queued for the cron worker.",
"worker_deploy_queued": "Cron worker deployment was queued (#{id}).",
"worker_update_queued": "Cron worker deployment update was queued (#{id})."
},
"nav": "@:{'cron.title'}",
"replication": "Replication",
"states": {
"disabled": "Disabled",
"disabled": "@:common.disabled",
"due_now": "Due now",
"enabled": "Enabled"
"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.",
@@ -2118,7 +2179,7 @@
},
"system_nav": "System",
"table": {
"actions": "Actions",
"actions": "@:common.actions",
"enabled": "@:{'cron.states.enabled'}",
"estimate": "Estimate",
"module": "Module",
@@ -2127,7 +2188,31 @@
"status": "@:{'cron.history.status'}",
"task": "@:{'cron.history.task'}"
},
"title": "Cron tasks"
"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'}",
"title": "Cron workers",
"total": "Workers",
"update_deployment": "Update Coolify deployment"
}
},
"customer_creation": {
"customer": {
@@ -2226,6 +2311,21 @@
"toggle_unavailable": "@:{'templates.generated.compat.customer_rules.manager.toggle_unavailable'}",
"unavailable": "@:{'templates.generated.compat.customer_rules.manager.unavailable'}",
"unknown_active": "@:{'templates.generated.compat.customer_rules.manager.unknown_active'}"
},
"tooltip": {
"available_if_enabled": "@:{'templates.generated.compat.customer_rules.tooltip.available_if_enabled'}",
"available_while_enabled": "@:{'templates.generated.compat.customer_rules.tooltip.available_while_enabled'}",
"blocked_if_enabled": "@:{'templates.generated.compat.customer_rules.tooltip.blocked_if_enabled'}",
"blocked_now": "@:{'templates.generated.compat.customer_rules.tooltip.blocked_now'}",
"changes": "@:{'templates.generated.compat.customer_rules.tooltip.changes'}",
"groups": {
"primary_products": "@:{'templates.generated.compat.customer_rules.tooltip.groups.primary_products'}",
"related_addons": "@:{'templates.generated.compat.customer_rules.tooltip.groups.related_addons'}",
"standalone_additional_services": "@:{'templates.generated.compat.customer_rules.tooltip.groups.standalone_additional_services'}"
},
"load_failed": "@:{'templates.generated.compat.customer_rules.tooltip.load_failed'}",
"loading_products": "@:{'templates.generated.compat.customer_rules.tooltip.loading_products'}",
"no_affected_products": "@:{'templates.generated.compat.customer_rules.tooltip.no_affected_products'}"
}
},
"customers": {
@@ -3361,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": {
@@ -3928,6 +4218,7 @@
"pagination": {
"all": "@:common.all",
"archive": "@:{'templates.generated.compat.pagination.archive'}",
"clear_all": "@:{'templates.generated.compat.pagination.clear_all'}",
"ascending": "@:{'templates.generated.compat.pagination.ascending'}",
"booked": "@:global.booked",
"booking_status": "@:{'templates.generated.compat.pagination.booking_status'}",
@@ -4589,6 +4880,141 @@
"subtitle": "@:{'templates.generated.compat.roles.subtitle'}",
"title": "@:{'templates.generated.compat.common.roles'}"
},
"security": {
"actions": {
"acknowledge": "Acknowledge",
"add_note": "Add note",
"clear": "@:common.clear",
"create": "Create",
"delete": "@:common.delete",
"disable": "Disable",
"edit": "@:common.edit",
"enable": "@:words.generated.enable",
"false_positive": "False positive",
"open_incidents": "Open incidents",
"refresh": "@:common.refresh",
"reopen": "Reopen",
"resolve": "Resolve",
"save": "@:common.save",
"search": "Search",
"update": "Update"
},
"empty_value": "@:cron.empty_value",
"errors": {
"generic": "Unable to load security controls."
},
"firewall": {
"action": "Action",
"actions": {
"allow": "Allow",
"block": "@:words.generated.block",
"watch": "Watch"
},
"confirm_delete": "Delete this firewall rule?",
"disabled": "@:common.disabled",
"empty": "No firewall rules found.",
"enabled": "@:common.enabled",
"expires_at": "Expires at",
"management": "Firewall management",
"priority": "@:objects.columns.priority",
"reason": "Reason",
"route_pattern": "Route pattern",
"target": "Target",
"target_type": "Target type",
"target_types": {
"cidr": "CIDR",
"customer": "Customer",
"ip": "IP address",
"route": "@:roles.permissions.columns.route",
"user": "@:common.user"
},
"target_value": "Target value",
"title": "Security firewall"
},
"incidents": {
"add_note": "@:security.actions.add_note",
"customer_number": "@:objects.columns.customer_number",
"empty": "No security incidents found.",
"management": "Incident management",
"no_notes": "No notes have been added.",
"note_placeholder": "Add investigation notes or resolution context",
"notes": "Notes",
"route": "@:roles.permissions.columns.route",
"search": "Search incidents",
"select_prompt": "Select an incident to inspect details.",
"source_ip": "Source IP",
"status_filter": "@:system_status.labels.status",
"title": "Security incidents",
"type": "Type"
},
"loading": "Loading security controls...",
"messages": {
"firewall_created": "Firewall rule created.",
"firewall_deleted": "Firewall rule deleted.",
"firewall_updated": "Firewall rule updated.",
"incident_updated": "Incident updated.",
"note_added": "Incident note added.",
"settings_saved": "Security settings saved."
},
"metrics": {
"acknowledged_incidents": "Acknowledged incidents",
"active_firewall_rules": "Active firewall rules",
"block_rules": "Block rules",
"open_incidents": "@:security.actions.open_incidents"
},
"nav": "Security",
"overview": {
"recent_incidents": "Recent incidents",
"title": "Security overview"
},
"rule_descriptions": {
"bookings_created": "Observe high booking creation volume for a customer.",
"failed_login_attempts": "Observe repeated failed sign-in attempts for the same principal.",
"requests_per_customer": "Observe high API request volume for one customer context.",
"requests_per_ip": "Observe high API request volume from one source IP.",
"vehicles_created": "Observe high vehicle creation volume for a customer."
},
"rules": {
"bookings_created": "Max bookings",
"failed_login_attempts": "Max failed login attempts",
"requests_per_customer": "Max requests per customer",
"requests_per_ip": "Max requests per IP",
"vehicles_created": "Max vehicle creations"
},
"settings": {
"enabled": "@:common.enabled",
"exemptions": "Permission node exemptions",
"exemptions_placeholder": "permission_a, permission_b",
"management": "@:security.settings.title",
"observe_mode": "Observe mode",
"threshold": "Maximum",
"title": "Security settings",
"window_seconds": "Window seconds"
},
"status": {
"acknowledged": "Acknowledged",
"all": "@:common.all",
"false_positive": "@:security.actions.false_positive",
"open": "@:common.open",
"resolved": "Resolved"
},
"subtitle": "Application firewall rules, observe-mode thresholds, permission exemptions, and incident handling.",
"table": {
"actions": "@:common.actions",
"count": "@:tables.bookings.quantity",
"last_seen": "Last seen",
"severity": "Severity",
"status": "@:common.status",
"title": "Title"
},
"tabs": {
"firewall": "Firewall",
"incidents": "Incidents",
"overview": "@:nav.overview",
"settings": "Settings"
},
"title": "System security"
},
"self_wash": {
"add_as_new_vehicle": "@:{'templates.generated.compat.self_wash.add_as_new_vehicle'}",
"answer_questions": "@:{'templates.generated.compat.self_wash.answer_questions'}",
@@ -5281,6 +5707,38 @@
"permission_count": "@:{'templates.generated.compat.superuser.driver_access.summary.permission_count'}"
}
},
"driver_contact": {
"close": "@:{'templates.generated.compat.superuser.driver_contact.close'}",
"email_missing": "@:{'templates.generated.compat.superuser.driver_contact.email_missing'}",
"link_not_sent_default": "@:{'templates.generated.compat.superuser.driver_contact.link_not_sent_default'}",
"link_not_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.link_not_sent_title'}",
"link_send_failed": "@:{'templates.generated.compat.superuser.driver_contact.link_send_failed'}",
"link_sent_default": "@:{'templates.generated.compat.superuser.driver_contact.link_sent_default'}",
"link_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.link_sent_title'}",
"login_link_failed_title": "@:{'templates.generated.compat.superuser.driver_contact.login_link_failed_title'}",
"login_link_not_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.login_link_not_sent_title'}",
"login_link_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.login_link_sent_title'}",
"missing": "@:{'templates.generated.compat.superuser.driver_contact.missing'}",
"password_guide_failed_title": "@:{'templates.generated.compat.superuser.driver_contact.password_guide_failed_title'}",
"password_guide_not_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.password_guide_not_sent_title'}",
"password_guide_sent_title": "@:{'templates.generated.compat.superuser.driver_contact.password_guide_sent_title'}",
"phone_missing": "@:{'templates.generated.compat.superuser.driver_contact.phone_missing'}",
"send_login_link": "@:{'templates.generated.compat.superuser.driver_contact.send_login_link'}",
"send_password_guide": "@:{'templates.generated.compat.superuser.driver_contact.send_password_guide'}",
"status": "@:{'templates.generated.compat.superuser.driver_contact.status'}",
"unverified": "@:{'templates.generated.compat.superuser.driver_contact.unverified'}",
"verification_update_failed": "@:{'templates.generated.compat.superuser.driver_contact.verification_update_failed'}",
"verification_update_failed_title": "@:{'templates.generated.compat.superuser.driver_contact.verification_update_failed_title'}",
"verified": "@:{'templates.generated.compat.superuser.driver_contact.verified'}"
},
"driver_wheel": {
"access_section": "@:{'templates.generated.compat.superuser.driver_wheel.access_section'}",
"account_section": "@:{'templates.generated.compat.superuser.driver_wheel.account_section'}",
"contact_section": "@:{'templates.generated.compat.superuser.driver_wheel.contact_section'}",
"details_section": "@:{'templates.generated.compat.superuser.driver_wheel.details_section'}",
"identity_subsection": "@:{'templates.generated.compat.superuser.driver_wheel.identity_subsection'}",
"system_subsection": "@:{'templates.generated.compat.superuser.driver_wheel.system_subsection'}"
},
"form": {
"locked": "@:{'templates.generated.compat.objects.columns.suspended'}",
"required": "@:{'templates.generated.compat.global.required'}"
@@ -5387,6 +5845,7 @@
},
"employees": {
"create": "@:{'templates.generated.compat.superuser.pages.employees.create'}",
"migrate_to_limited_backoffice": "@:{'templates.generated.compat.superuser.pages.employees.migrate_to_limited_backoffice'}",
"subtitle": "@:{'templates.generated.compat.superuser.pages.employees.subtitle'}",
"title": "@:superuser.nav.employees"
},
@@ -5595,6 +6054,24 @@
"objects": "@:{'templates.generated.compat.superuser.xlvask.objects'}"
}
},
"support": {
"title": "@:templates.support.title",
"meta_description": "@:templates.support.meta_description",
"eyebrow": "@:templates.support.eyebrow",
"intro": "@:templates.support.intro",
"contact_title": "@:templates.support.contact_title",
"email_label": "@:templates.support.email_label",
"phone_label": "@:templates.support.phone_label",
"company_label": "@:templates.support.company_label",
"help_title": "@:templates.support.help_title",
"help_booking": "@:templates.support.help_booking",
"help_self_wash": "@:templates.support.help_self_wash",
"help_payments": "@:templates.support.help_payments",
"help_account": "@:templates.support.help_account",
"privacy_prefix": "@:templates.support.privacy_prefix",
"privacy_link": "@:templates.support.privacy_link",
"privacy_suffix": "@:templates.support.privacy_suffix"
},
"system_status": {
"actions": {
"open_config": "@:{'templates.generated.compat.system_status.actions.open_config'}",
@@ -5654,15 +6131,28 @@
"labels": {
"activity_window": "@:{'templates.generated.compat.system_status.labels.activity_window'}",
"buckets_available": "@:{'templates.generated.compat.system_status.labels.buckets_available'}",
"calls_remaining": "@:{'templates.generated.compat.system_status.labels.calls_remaining'}",
"calls_used": "@:{'templates.generated.compat.system_status.labels.calls_used'}",
"checked_at": "@:{'templates.generated.compat.system_status.labels.checked_at'}",
"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'}"
},
"modules": {
@@ -5687,8 +6177,17 @@
"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'}",
"backup_latest_verified_stale": "@:{'templates.generated.compat.system_status.reasons.backup_latest_verified_stale'}",
"backup_no_verified_backup": "@:{'templates.generated.compat.system_status.reasons.backup_no_verified_backup'}",
"backup_probe_failed": "@:{'templates.generated.compat.system_status.reasons.backup_probe_failed'}",
"economic_credentials_missing": "@:{'templates.generated.compat.system_status.reasons.economic_credentials_missing'}",
"email_delivery_not_implemented": "@:{'templates.generated.compat.system_status.reasons.email_delivery_not_implemented'}",
@@ -5701,8 +6200,14 @@
"http_status": "@:{'templates.generated.compat.system_status.reasons.http_status'}",
"http_unexpected_response": "@:{'templates.generated.compat.system_status.reasons.http_unexpected_response'}",
"invalid_probe_payload": "@:{'templates.generated.compat.system_status.reasons.invalid_probe_payload'}",
"licenseplaterecognizer_quota_exhausted": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_quota_exhausted'}",
"licenseplaterecognizer_quota_near_limit": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_quota_near_limit'}",
"licenseplaterecognizer_usage_available": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_usage_available'}",
"licenseplaterecognizer_usage_missing": "@:{'templates.generated.compat.system_status.reasons.licenseplaterecognizer_usage_missing'}",
"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'}",
@@ -5738,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'}"
},
+323 -3
View File
@@ -288,6 +288,7 @@
"driver": "driver",
"du": "du",
"duplikater": "duplikater",
"due": "Forfalt",
"duration": "duration",
"e": "e",
"edge": "edge",
@@ -1257,6 +1258,24 @@
}
},
"templates": {
"support": {
"title": "@.capitalize:{'words.generated.support'}",
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.og'} kontaktinformasjon @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
"eyebrow": "@:{'words.generated.truck'} @:{'words.generated.wash'}",
"intro": "@.capitalize:{'words.generated.kontakt'} oss hvis du trenger @:{'words.generated.hjelp'} @:{'words.generated.med'} @:{'words.generated.booking'}, @:{'words.generated.selvvask'}, @:{'words.generated.betalinger'} eller @:{'words.generated.tilgang'} til kundeportalen.",
"contact_title": "@.capitalize:{'words.generated.kontakt'}",
"email_label": "E-post",
"phone_label": "Telefon",
"company_label": "Selskap",
"help_title": "Vi kan hjelpe @:{'words.generated.med'}",
"help_booking": "@.capitalize:{'words.generated.booking'} @:{'words.generated.og'} endring @:{'words.generated.av'} vasketider.",
"help_self_wash": "Start @:{'words.generated.og'} avslutning @:{'words.generated.av'} @:{'words.generated.selvvask'}.",
"help_payments": "@.capitalize:{'words.generated.ordrer'}, @:{'words.generated.fakturaer'} @:{'words.generated.og'} kortbetalinger.",
"help_account": "@.capitalize:{'words.generated.innlogging'}, @:{'words.generated.tilgang'} @:{'words.generated.og'} kjoretoy paa kontoen @:{'words.generated.din'}.",
"privacy_prefix": "Les ogsaa vaar",
"privacy_link": "personvernerklaering",
"privacy_suffix": "@:{'words.generated.for'} @:{'words.generated.informasjon'} om @:{'words.generated.data'} @:{'words.generated.og'} rettigheter."
},
"common": {
"step_of": "@.capitalize:{'words.generated.trinn'} {current} @:{'words.generated.av'} {total}",
"page_of": "@.capitalize:{'words.generated.side'} {current} @:{'words.generated.av'} {total}",
@@ -2038,7 +2057,9 @@
"view_lane_new_tab": "@.capitalize:{'words.generated.se'} kjørefelt @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_lane_setup_new_tab": "@.capitalize:{'words.generated.se'} kjørefeltoppsett @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_order_new_tab": "@.capitalize:{'words.generated.se'} @:{'words.generated.ordre'} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"view_vehicle_new_tab": "@.capitalize:{'words.generated.se'} @:{'words.generated.kjøretøy'} {reg} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.fane'}",
"manage_vehicle": "Administrer kjøretøy",
"view_vehicle_new_tab": "Åpne kjøretøy i ny fane",
"delete_vehicle": "Slett kjøretøy",
"accept_self_serve_wash": "Godta selvbetjent vask",
"accept_self_serve_wash_confirm": "Flytt denne kladden til kunde #{customerNumber} med selvbetjeningsdetaljene?",
"accept_self_serve_wash_success": "Den selvbetjente vasken ble godtatt.",
@@ -2613,21 +2634,69 @@
},
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "Sikkerhetskopiering @:{'words.generated.opprettet'}",
"backup_created_success": "@.capitalize:{'words.generated.sikkerhetskopien'} @:{'words.generated.ble'} @:{'words.generated.opprettet'}.",
"backup_error": "@.capitalize:{'words.generated.det'} @:{'words.generated.oppstod'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.feil'} @:{'words.generated.under'} @:{'words.generated.oppretting'} @:{'words.generated.av'} @:{'words.generated.sikkerhetskopien'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "Sikkerhetskopieringsplan",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@:{'words.generated.lag'} @:{'words.generated.sikkerhetskopi'}",
"create_backup_confirm": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.sikker'} @:{'words.generated.pa'} @:{'words.generated.at'} @:{'words.generated.du'} @:{'words.generated.vil'} @:{'words.generated.lage'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.sikkerhetskopi'}?",
"delete_backup": "@.capitalize:{'words.generated.slett'} @:{'words.generated.sikkerhetskopi'}",
"disabled": "Disabled",
"download_backup": "Ladda ner @:{'words.generated.sakerhetskopia'}",
"enable_system": "@:{'words.generated.aktiver'} @:{'words.generated.backup'}-@:{'words.generated.systemet'}",
"enable_system_desc": "@:{'words.generated.aktiver'} @:{'words.generated.eller'} @:{'words.generated.deaktiver'} @:{'words.generated.backup'}-@:{'words.generated.systemet'}.",
"enabled": "Enabled",
"general_settings_desc": "@.capitalize:{'words.generated.de'} @:{'words.generated.generelle'} @:{'words.generated.innstillingene'} @:{'words.generated.for'} sikkerhetskopieringssystemet.",
"last_backup": "@:{'words.generated.siste'} @:{'words.generated.sikkerhetskopi'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "@:{'words.generated.aterstall'} @:{'words.generated.sakerhetskopia'}",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@.capitalize:{'words.generated.konfigurasjon'} @:{'words.generated.av'} @:{'words.generated.backup'}-@:{'words.generated.systemet'}.",
"title": "Sikkerhetskopieringskonfigurasjon"
"title": "Sikkerhetskopieringskonfigurasjon",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@.capitalize:{'words.generated.administrasjons'}- @:{'words.generated.og'} miljøgebyrinnstillinger @:{'words.generated.for'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}-@:{'words.generated.integrasjonen'}.",
@@ -3540,6 +3609,21 @@
"toggle_unavailable": "Du har ikke tillatelse til å endre denne kunderegelen.",
"unavailable": "Du har ikke tillatelse til å se kunderegler.",
"unknown_active": "Ukjente aktive regler"
},
"tooltip": {
"available_if_enabled": "Forblir tilgjengelige hvis aktivert",
"available_while_enabled": "Tilgjengelige mens aktiv",
"blocked_if_enabled": "Blokkeres hvis aktivert",
"blocked_now": "Blokkert mens aktiv",
"changes": "Endringer",
"groups": {
"primary_products": "Primære produkter",
"related_addons": "Tilknyttede add-ons",
"standalone_additional_services": "Selvstendige tilleggstjenester"
},
"load_failed": "Berørte produkter kunne ikke lastes.",
"loading_products": "Laster berørte produkter...",
"no_affected_products": "Ingen berørte produkter funnet i den gjeldende produktkatalogen."
}
},
"customers": {
@@ -4321,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": {
@@ -4759,6 +5033,7 @@
},
"pagination": {
"archive": "Arkiv",
"clear_all": "Tøm alle",
"ascending": "Stigende (eldst @:{'words.generated.først'})",
"booking_status": "Bestillingsstatus",
"bookings_overview": "Bestillingsoversikt",
@@ -5949,6 +6224,38 @@
"permission_count": "{count} permissions",
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
}
},
"system_status": {
@@ -5994,14 +6301,19 @@
"labels": {
"activity_window": "@.capitalize:{'words.generated.active'} within @:{'words.generated.the'} @:{'words.generated.last'} {minutes} @:{'words.generated.minutes'}",
"buckets_available": "{available} @:{'words.generated.of'} {total} @:{'words.generated.buckets'} @:{'words.generated.available'}",
"calls_remaining": "Gjenstående kall",
"calls_used": "Brukte kall",
"checked_at": "@.capitalize:{'words.generated.checked'} @:{'words.generated.at'}",
"configured": "@.capitalize:{'words.generated.configured'}",
"enabled": "@.capitalize:{'words.generated.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'words.generated.no'} @:{'words.generated.additional'} details",
"quota_calls": "Kvote",
"quota_usage": "Kvote bruk",
"replication_percent": "{percent}% @:{'words.generated.replication'} ({status})",
"runtime_source": "@.capitalize:{'words.generated.runtime'} @:{'words.generated.source'}",
"server_version": "@.capitalize:{'words.generated.server'} version",
"version": "Versjon",
"warnings": "Warnings"
},
"modules": {
@@ -6034,6 +6346,11 @@
"http_status": "{label} @:{'words.generated.returned'} @:{'words.generated.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.unexpected'} @:{'words.generated.http'} @:{'words.generated.response'}.",
"invalid_probe_payload": "@.capitalize:{'words.generated.probe'} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.invalid'} @:{'words.generated.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer-kvoten er oppbrukt ({used} av {quota} kall brukt).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer-kvoten er nær grensen: {percent}% ({used} av {quota} kall brukt).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-bruk og kvote er tilgjengelig: {used} av {quota} kall brukt.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer returnerte ikke brukbare kvoteverdier.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returnerte et uleselig brukspayload.",
"missing_config": "@.capitalize:{'words.generated.missing'} @:{'words.generated.required'} @:{'words.generated.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'words.generated.module'} @:{'words.generated.is'} @:{'words.generated.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'words.generated.credentials'} @:{'words.generated.were'} rejected @:{'words.generated.by'} @:{'words.generated.google'}.",
@@ -6045,7 +6362,10 @@
"selfserve_minute_product_missing": "@.capitalize:{'words.generated.self'}-@:{'words.generated.serve'} @:{'words.generated.minute'} @:{'words.generated.product'} #{productId} @:{'words.generated.does'} @:{'words.generated.not'} @:{'words.generated.exist'}.",
"selfserve_minutes_invalid": "@.capitalize:{'words.generated.self'}-@:{'words.generated.serve'} @:{'words.generated.machine'} @:{'words.generated.minutes'} @:{'words.generated.configuration'} @:{'words.generated.is'} @:{'words.generated.invalid'}.",
"selfserve_probe_failed": "@.capitalize:{'words.generated.self'}-@:{'words.generated.serve'} @:{'words.generated.probe'} @:{'words.generated.failed'}: {error}.",
"shelly_no_device_id": "@:{'words.generated.shelly'} cloud @:{'words.generated.credentials'} @:{'words.generated.are'} @:{'words.generated.configured'}, @:{'words.generated.but'} @:{'words.generated.no'} known @:{'words.generated.device'} @:{'words.generated.id'} @:{'words.generated.is'} @:{'words.generated.available'} @:{'words.generated.for'} @:{'words.generated.a_2'} @:{'words.generated.safe'} @:{'words.generated.read'}-@:{'words.generated.only'} @:{'words.generated.probe'}."
"shelly_no_device_id": "@:{'words.generated.shelly'} cloud @:{'words.generated.credentials'} @:{'words.generated.are'} @:{'words.generated.configured'}, @:{'words.generated.but'} @:{'words.generated.no'} known @:{'words.generated.device'} @:{'words.generated.id'} @:{'words.generated.is'} @:{'words.generated.available'} @:{'words.generated.for'} @:{'words.generated.a_2'} @:{'words.generated.safe'} @:{'words.generated.read'}-@:{'words.generated.only'} @:{'words.generated.probe'}.",
"backup_encryption_key_missing": "Backup encryption is not ready: {error}.",
"backup_latest_verified_stale": "Latest verified backup is stale.",
"backup_no_verified_backup": "No verified backup is available for restore."
},
"sections": {
"gateways": "@.capitalize:{'words.generated.edge'} @:{'words.generated.gateways'}",
+322 -2
View File
@@ -328,6 +328,7 @@
"du": "du",
"dubbletter": "dubbletter",
"duplicate": "duplicate",
"due": "Förfallen",
"duration": "duration",
"dygntvatt": "Dygntvätt",
"e": "e",
@@ -1307,6 +1308,24 @@
}
},
"templates": {
"support": {
"title": "@.capitalize:{'words.generated.support'}",
"meta_description": "@.capitalize:{'words.generated.support'} @:{'words.generated.och'} kontaktinformation @:{'words.generated.for_2'} @:{'words.generated.truck'} @:{'words.generated.wash'}.",
"eyebrow": "@:{'words.generated.truck'} @:{'words.generated.wash'}",
"intro": "Kontakta oss @:{'words.generated.om'} du behover @:{'words.generated.hjalp'} @:{'words.generated.med'} bokningar, @:{'words.generated.sjalvtvatt'}, @:{'words.generated.betalningar'} eller @:{'words.generated.atkomst'} @:{'words.generated.till'} kundportalen.",
"contact_title": "@.capitalize:{'words.generated.kontakt'}",
"email_label": "E-post",
"phone_label": "Telefon",
"company_label": "Foretag",
"help_title": "Vi kan hjalpa @:{'words.generated.till'} @:{'words.generated.med'}",
"help_booking": "Bokning @:{'words.generated.och'} andring @:{'words.generated.av'} tvattider.",
"help_self_wash": "Start @:{'words.generated.och'} avslut @:{'words.generated.av'} @:{'words.generated.sjalvtvatt'}.",
"help_payments": "Ordrar, fakturor @:{'words.generated.och'} kortbetalningar.",
"help_account": "@.capitalize:{'words.generated.inloggning'}, @:{'words.generated.atkomst'} @:{'words.generated.och'} @:{'words.generated.fordon'} pa ditt @:{'words.generated.konto'}.",
"privacy_prefix": "Las ocksa var",
"privacy_link": "integritetspolicy",
"privacy_suffix": "@:{'words.generated.for_2'} @:{'words.generated.information'} @:{'words.generated.om'} @:{'words.generated.data'} @:{'words.generated.och'} rattigheter."
},
"common": {
"step_of": "@.capitalize:{'words.generated.steg'} {current} @:{'words.generated.av'} {total}",
"page_of": "@.capitalize:{'words.generated.sida'} {current} @:{'words.generated.av'} {total}",
@@ -2088,7 +2107,9 @@
"view_lane_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.lane'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_lane_setup_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.lane'} @:{'words.generated.setup'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_order_new_tab": "@.capitalize:{'words.generated.view'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.new'} @:{'words.generated.tab'}",
"view_vehicle_new_tab": "@.capitalize:{'words.generated.visa'} @:{'words.generated.fordon'} {reg} @:{'words.generated.i'} @:{'words.generated.ny'} @:{'words.generated.flik'}",
"manage_vehicle": "Hantera fordon",
"view_vehicle_new_tab": "Öppna fordon i ny flik",
"delete_vehicle": "Ta bort fordon",
"accept_self_serve_wash": "Godkänn självbetjäningstvätt",
"accept_self_serve_wash_confirm": "Flytta detta utkast till kund #{customerNumber} med självbetjäningsdetaljerna?",
"accept_self_serve_wash_success": "Självbetjäningstvätten godkändes.",
@@ -2663,21 +2684,69 @@
},
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "@.capitalize:{'words.generated.backup'} oprettet",
"backup_created_success": "Säkerhetskopian @:{'words.generated.skapades'}.",
"backup_error": "@.capitalize:{'words.generated.der'} opstod @:{'words.replication.host_definite_suffix'} @:{'words.generated.fejl'} @:{'words.generated.under'} oprettelse @:{'words.generated.af'} @:{'words.generated.backup'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "Säkerhetskopieringsschema",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@:{'words.generated.opret'} @:{'words.generated.backup'}",
"create_backup_confirm": "@.capitalize:{'words.generated.ar'} @:{'words.generated.du'} @:{'words.generated.saker'} @:{'words.generated.pa'} @:{'words.generated.att'} @:{'words.generated.du'} @:{'words.generated.vill'} @:{'words.generated.skapa'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.sakerhetskopia'}?",
"delete_backup": "@.capitalize:{'words.generated.radera'} @:{'words.generated.sakerhetskopia'}",
"disabled": "Disabled",
"download_backup": "@.capitalize:{'words.generated.ladda'} @:{'words.generated.ner'} @:{'words.generated.sakerhetskopia'}",
"enable_system": "@:{'words.generated.aktiver'} @:{'words.generated.backup'} @:{'words.generated.system'}",
"enable_system_desc": "@:{'words.generated.aktiver'} @:{'words.generated.eller'} @:{'words.generated.deaktiver'} @:{'words.generated.backup'} @:{'words.generated.systemet'}.",
"enabled": "Enabled",
"general_settings_desc": "@.capitalize:{'words.generated.de'} @:{'words.generated.generelle'} @:{'words.generated.indstillinger'} @:{'words.generated.for_2'} @:{'words.generated.backup'} @:{'words.generated.systemet'}.",
"last_backup": "@.capitalize:{'words.generated.senaste'} @:{'words.generated.sakerhetskopia'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "@:{'words.generated.aterstall'} @:{'words.generated.sakerhetskopia'}",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@.capitalize:{'words.generated.konfiguration'} @:{'words.generated.af'} @:{'words.generated.backup'} @:{'words.generated.systemet'}.",
"title": "@.capitalize:{'words.generated.backup'} @:{'words.generated.konfiguration'}"
"title": "@.capitalize:{'words.generated.backup'} @:{'words.generated.konfiguration'}",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@.capitalize:{'words.generated.installningar'} @:{'words.generated.for'} @:{'words.generated.administrations'}- @:{'words.generated.och'} @:{'words.generated.miljoavgift'} @:{'words.generated.for'} @.upper:{'words.generated.e'}-@:{'words.generated.conomic'}-@:{'words.generated.integrationen'}.",
@@ -3590,6 +3659,21 @@
"toggle_unavailable": "Du har inte behörighet att ändra denna kundregel.",
"unavailable": "Du har inte behörighet att visa kundregler.",
"unknown_active": "Okända aktiva regler"
},
"tooltip": {
"available_if_enabled": "Förblir tillgängliga om aktiverad",
"available_while_enabled": "Tillgängliga medan aktiv",
"blocked_if_enabled": "Blockeras om aktiverad",
"blocked_now": "Blockerad medan aktiv",
"changes": "Ändringar",
"groups": {
"primary_products": "Primära produkter",
"related_addons": "Kopplade add-ons",
"standalone_additional_services": "Fristående tilläggstjänster"
},
"load_failed": "Berörda produkter kunde inte läsas in.",
"loading_products": "Läser in berörda produkter...",
"no_affected_products": "Inga berörda produkter hittades i den aktuella produktkatalogen."
}
},
"customers": {
@@ -4371,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": {
@@ -4809,6 +5083,7 @@
},
"pagination": {
"archive": "Archive",
"clear_all": "Rensa alla",
"ascending": "Ascending (Oldest @:{'words.generated.first'})",
"booking_status": "Bokningsstatus",
"bookings_overview": "Bokningsäversikt",
@@ -5999,6 +6274,38 @@
"permission_count": "{count} permissions",
"custom_summary": "Custom: {summary}"
}
},
"driver_contact": {
"close": "Close",
"email_missing": "E-mail missing",
"link_not_sent_default": "The link could not be sent.",
"link_not_sent_title": "Link could not be sent",
"link_send_failed": "The link could not be sent.",
"link_sent_default": "The link was sent.",
"link_sent_title": "Link sent",
"login_link_failed_title": "Login link could not be sent",
"login_link_not_sent_title": "Login link could not be sent",
"login_link_sent_title": "Login link sent",
"missing": "Missing",
"password_guide_failed_title": "Guide could not be sent",
"password_guide_not_sent_title": "Guide could not be sent",
"password_guide_sent_title": "Guide sent",
"phone_missing": "Phone missing",
"send_login_link": "Send pre-authorized login link",
"send_password_guide": "Send new password guide",
"status": "Status",
"unverified": "Not verified",
"verification_update_failed": "Verification status could not be updated.",
"verification_update_failed_title": "Status could not be updated",
"verified": "Verified"
},
"driver_wheel": {
"access_section": "Access",
"account_section": "Account",
"contact_section": "Contact",
"details_section": "Details",
"identity_subsection": "Identity",
"system_subsection": "System"
}
},
"system_status": {
@@ -6044,14 +6351,19 @@
"labels": {
"activity_window": "@.capitalize:{'words.generated.active'} @:{'words.generated.within'} @:{'words.generated.the'} @:{'words.generated.last_2'} {minutes} @:{'words.generated.minutes'}",
"buckets_available": "{available} @:{'words.generated.of'} {total} @:{'words.generated.buckets'} @:{'words.generated.available'}",
"calls_remaining": "Återstående anrop",
"calls_used": "Använda anrop",
"checked_at": "@.capitalize:{'words.generated.checked'} @:{'words.generated.at'}",
"configured": "@.capitalize:{'words.generated.configured'}",
"enabled": "@.capitalize:{'words.generated.enabled'}",
"latency": "Latency",
"no_reason": "@.capitalize:{'words.generated.no'} @:{'words.generated.additional'} @:{'words.generated.details'}",
"quota_calls": "Kvot",
"quota_usage": "Kvotanvändning",
"replication_percent": "{percent}% @:{'words.generated.replication'} ({status})",
"runtime_source": "@.capitalize:{'words.generated.runtime'} @:{'words.generated.source'}",
"server_version": "@.capitalize:{'words.generated.server'} @:{'words.generated.version'}",
"version": "Version",
"warnings": "@:{'templates.generated.compat.global.warnings'}"
},
"modules": {
@@ -6072,6 +6384,9 @@
},
"reasons": {
"backup_connectivity_confirmed": "@.capitalize:{'words.generated.backup'} @:{'words.generated.store'} @:{'words.generated.connectivity'} @:{'words.generated.confirmed'}.",
"backup_encryption_key_missing": "Backup encryption is not ready: {error}.",
"backup_latest_verified_stale": "Latest verified backup is stale.",
"backup_no_verified_backup": "No verified backup is available for restore.",
"backup_probe_failed": "@.capitalize:{'words.generated.backup'} @:{'words.generated.probe'} @:{'words.generated.failed'}: {error}.",
"economic_credentials_missing": "@.upper:{'words.generated.e'}-@:{'words.generated.conomic'} @:{'words.generated.credentials'} @:{'words.generated.are'} @:{'words.generated.missing'} @:{'words.generated.from'} @:{'words.generated.runtime'} environment @:{'words.generated.configuration'}.",
"email_delivery_not_implemented": "@:{'words.generated.mailersend_mailersend'} @:{'words.generated.must'} @:{'words.generated.be'} @:{'words.generated.enabled'} @:{'words.generated.because'} @:{'words.generated.default'} @:{'words.generated.smtp'} delivery @:{'words.generated.is'} @:{'words.generated.not'} implemented.",
@@ -6084,6 +6399,11 @@
"http_status": "{label} @:{'words.generated.returned'} @:{'words.generated.http'} {httpStatus}.",
"http_unexpected_response": "{label} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.unexpected'} @:{'words.generated.http'} @:{'words.generated.response'}.",
"invalid_probe_payload": "@.capitalize:{'words.generated.probe'} @:{'words.generated.returned'} @:{'words.generated.an'} @:{'words.generated.invalid'} @:{'words.generated.payload'}.",
"licenseplaterecognizer_quota_exhausted": "License Plate Recognizer-kvoten är slut ({used} av {quota} anrop använda).",
"licenseplaterecognizer_quota_near_limit": "License Plate Recognizer-kvoten är nära gränsen: {percent}% ({used} av {quota} anrop använda).",
"licenseplaterecognizer_usage_available": "License Plate Recognizer-användning och kvot är tillgängliga: {used} av {quota} anrop använda.",
"licenseplaterecognizer_usage_missing": "License Plate Recognizer returnerade inga användbara kvotvärden.",
"licenseplaterecognizer_usage_unreadable": "License Plate Recognizer returnerade en oläsbar användningspayload.",
"missing_config": "@.capitalize:{'words.generated.missing'} @:{'words.generated.required'} @:{'words.generated.configuration'}: {variables}.",
"module_disabled": "@.capitalize:{'words.generated.module'} @:{'words.generated.is'} @:{'words.generated.disabled'}.",
"recaptcha_credentials_rejected": "{label} @:{'words.generated.credentials'} @:{'words.generated.were'} rejected @:{'words.generated.by'} @:{'words.generated.google'}.",
@@ -29,7 +29,9 @@
"view_lane_new_tab": "@:{'terms.glossary.abn'} @:{'terms.glossary.bane'} @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_lane_setup_new_tab": "@:{'terms.glossary.abn'} baneopsætning @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_order_new_tab": "@:{'terms.glossary.abn'} @:{'terms.glossary.ordre'} @:{'terms.glossary.i'} @:{'terms.glossary.ny'} @:{'terms.glossary.fane'}",
"view_vehicle_new_tab": "Åbn køretøjet {reg} i ny fane"
"manage_vehicle": "Administrer køretøj",
"view_vehicle_new_tab": "Åbn køretøj i ny fane",
"delete_vehicle": "Slet køretøj"
},
"show_details": "@:{'terms.glossary.vis'} @:{'terms.glossary.detaljer'}",
"show_order_details": "@:{'terms.glossary.vis'} @:{'terms.glossary.ordredetaljer'}",
@@ -2,21 +2,69 @@
"compat": {
"configuration": {
"backups": {
"active_jobs": "Active jobs",
"actions": "Actions",
"app_data_enabled": "Include app data",
"app_data_enabled_desc": "Back up app-owned object buckets together with the database.",
"backup": "Backup",
"backup_created": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.oprettet'}",
"backup_created_success": "@.capitalize:{'terms.glossary.backup'} @:{'terms.glossary.blev'} @:{'terms.glossary.oprettet'} @:{'terms.glossary.succesfuldt'}.",
"backup_error": "@.capitalize:{'terms.glossary.der'} @:{'terms.glossary.opstod'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.fejl'} @:{'terms.glossary.under'} @:{'terms.glossary.oprettelse'} @:{'terms.glossary.af'} @:{'terms.glossary.backup'}.",
"backup_queued": "Backup queued",
"backup_queued_success": "The backup job has been queued.",
"backup_schedule": "Sikkerhedskopieringsplan",
"backups": "Backups",
"completed_at": "Completed",
"create_backup": "@:{'terms.glossary.opret'} @:{'terms.glossary.backup'}",
"create_backup_confirm": "@.capitalize:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.sikker'} @:{'terms.glossary.pa'}, @:{'terms.glossary.at'} @:{'terms.glossary.du'} @:{'terms.glossary.vil'} @:{'terms.glossary.lave'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.sikkerhedskopi'}?",
"delete_backup": "@.capitalize:{'terms.glossary.slet'} @:{'terms.glossary.backup'}",
"disabled": "Disabled",
"download_backup": "@.capitalize:{'terms.glossary.download'} @:{'terms.glossary.sikkerhedskopi'}",
"enable_system": "@:{'terms.glossary.aktiver'} backupsystem",
"enable_system_desc": "@.capitalize:{'terms.glossary.aktiver_2'} @:{'terms.glossary.eller'} @:{'terms.glossary.deaktiver'} @:{'terms.glossary.backup'} @:{'terms.glossary.systemet'}.",
"enabled": "Enabled",
"general_settings_desc": "@.capitalize:{'terms.glossary.de'} @:{'terms.glossary.generelle'} @:{'terms.glossary.indstillinger'} @:{'terms.glossary.for'} @:{'terms.glossary.backup'} @:{'terms.glossary.systemet'}.",
"last_backup": "@.capitalize:{'terms.glossary.seneste'} @:{'terms.glossary.sikkerhedskopi'}",
"latest_backup": "Latest backup",
"latest_verified": "Latest verified backup",
"legacy_backups": "Legacy backups",
"name": "Name",
"no_backups": "No backups found.",
"no_restore_audit": "No restore audit entries found.",
"not_available": "Not available",
"objects": "Objects",
"refresh": "Refresh",
"restore_audit": "Restore audit",
"restore_backup": "@:{'terms.glossary.gendan'} @:{'terms.glossary.sikkerhedskopi'}",
"restore_confirm_intro": "Restoring a backup will replace the production database and app data. Review the checks and type the exact confirmation phrase before continuing.",
"restore_confirmation": "Confirmation phrase",
"restore_enabled": "Allow production restore",
"restore_enabled_desc": "Allow superusers to queue production restores after preview and typed confirmation.",
"restore_error": "Could not queue the restore job.",
"restore_queued": "Restore queued",
"restore_queued_success": "The restore job has been queued.",
"restore_reason": "Restore reason",
"restore_state": "Restore",
"restore_state_desc": "Production restore requires this setting plus preview confirmation.",
"restore_validation_error": "Enter a reason and the exact confirmation phrase.",
"retention": "Retention",
"retention_daily_days": "Daily retention days",
"retention_daily_days_desc": "Keep one verified backup per day for this many days.",
"retention_monthly_months": "Monthly retention months",
"retention_monthly_months_desc": "Keep one verified backup per month for this many months.",
"retention_recent_hours": "Recent retention hours",
"retention_recent_hours_desc": "Keep all verified backups from this recent hourly window.",
"retention_weekly_weeks": "Weekly retention weeks",
"retention_weekly_weeks_desc": "Keep one verified backup per week for this many weeks.",
"size": "Size",
"started_at": "Started",
"status": "Status",
"subtitle": "@.capitalize:{'terms.glossary.konfiguration'} @:{'terms.glossary.af'} backupsystemet.",
"title": "Backupkonfiguration"
"title": "Backupkonfiguration",
"verification_required": "Require verification",
"verification_required_desc": "Mark backups as available only after verification succeeds.",
"verified_at": "Verified",
"verify": "Verify"
},
"economic": {
"admin_fee_desc": "@.capitalize:{'terms.glossary.administrations'}- @:{'terms.glossary.og'} @:{'terms.glossary.miljøgebyr'} @:{'terms.glossary.indstillinger'} @:{'terms.glossary.for'} @.upper:{'terms.glossary.e'}-@:{'terms.glossary.conomic'} @:{'terms.glossary.integrationen'}.",
@@ -60,6 +60,21 @@
"toggle_unavailable": "Du har ikke tilladelse til at ændre denne kunderegel.",
"unavailable": "Du har ikke tilladelse til at se kunderegler.",
"unknown_active": "Ukendte aktive regler"
},
"tooltip": {
"available_if_enabled": "Forbliver tilgængelige hvis aktiveret",
"available_while_enabled": "Tilgængelige mens aktiv",
"blocked_if_enabled": "Blokeres hvis aktiveret",
"blocked_now": "Blokeret mens aktiv",
"changes": "Ændringer",
"groups": {
"primary_products": "Primære produkter",
"related_addons": "Tilknyttede add-ons",
"standalone_additional_services": "Selvstændige tillægsydelser"
},
"load_failed": "Berørte produkter kunne ikke indlæses.",
"loading_products": "Indlæser berørte produkter...",
"no_affected_products": "Ingen berørte produkter fundet i det aktuelle produktkatalog."
}
}
}
@@ -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"
}
}
}
}
}
@@ -2,6 +2,7 @@
"compat": {
"pagination": {
"archive": "Arkiv",
"clear_all": "Ryd alle",
"ascending": "Stigende (Ældste @:{'terms.glossary.først'})",
"booking_status": "Bogføringsstatus",
"bookings_overview": "@:{'phrases.compat.objects.bookings.description'}",
@@ -84,6 +84,38 @@
"custom_summary": "Tilpasset: {summary}"
}
},
"driver_contact": {
"close": "Luk",
"email_missing": "E-mail mangler",
"link_not_sent_default": "Linket kunne ikke sendes.",
"link_not_sent_title": "Link kunne ikke sendes",
"link_send_failed": "Linket kunne ikke sendes.",
"link_sent_default": "Linket er sendt.",
"link_sent_title": "Link sendt",
"login_link_failed_title": "Loginlink kunne ikke sendes",
"login_link_not_sent_title": "Loginlink kunne ikke sendes",
"login_link_sent_title": "Loginlink sendt",
"missing": "Mangler",
"password_guide_failed_title": "Guide kunne ikke sendes",
"password_guide_not_sent_title": "Guide kunne ikke sendes",
"password_guide_sent_title": "Guide sendt",
"phone_missing": "Telefon mangler",
"send_login_link": "Send forhåndsgodkendt loginlink",
"send_password_guide": "Send guide til ny adgangskode",
"status": "Status",
"unverified": "Ikke verificeret",
"verification_update_failed": "Verifikationsstatus kunne ikke opdateres.",
"verification_update_failed_title": "Status kunne ikke opdateres",
"verified": "Verificeret"
},
"driver_wheel": {
"access_section": "Adgange",
"account_section": "Konto",
"contact_section": "Kontakt",
"details_section": "Detaljer",
"identity_subsection": "Identitet",
"system_subsection": "System"
},
"invoicing_billing_period": {
"dashboard_right": "@.capitalize:{'terms.glossary.dashboard'} @.capitalize:{'terms.glossary.højre'} @.capitalize:{'terms.glossary.visning'}",
"fetch_period_data": "@:{'terms.glossary.hent'} periodedata"

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