Forráskód Böngészése

Merge branch 'main' into demo

Bernt Christian Egeland 1 napja
szülő
commit
9c9e65048a
100 módosított fájl, 9358 hozzáadás és 896 törlés
  1. 0 10
      .devcontainer/docker-compose.yml
  2. 0 5
      .env.example
  3. 4 0
      .github/PULL_REQUEST_TEMPLATE.md
  4. 8 1
      .github/workflows/deploy-demo.yml
  5. 44 9
      .github/workflows/deploy-prod.yml
  6. 44 9
      .github/workflows/deploy-staging.yml
  7. 30 2
      .github/workflows/e2e.yml
  8. 6 0
      .vscode/settings.json
  9. 2 1
      e2e/README.md
  10. 19 2
      e2e/specs/auth/account.spec.ts
  11. 139 0
      e2e/specs/auth/devices.spec.ts
  12. 319 0
      e2e/specs/auth/member-role.spec.ts
  13. 12 0
      e2e/specs/auth/sign-in.spec.ts
  14. 13 8
      e2e/specs/auth/sign-up.spec.ts
  15. 89 0
      e2e/specs/auth/single-workshop.spec.ts
  16. 84 14
      e2e/specs/auth/tenancy.spec.ts
  17. 3 3
      e2e/specs/calendar/booking.spec.ts
  18. 37 5
      e2e/specs/cloud/google-sign-in.spec.ts
  19. 198 0
      e2e/specs/cloud/subscription-handoff.spec.ts
  20. 299 0
      e2e/specs/condition-map/dropoff.spec.ts
  21. 280 0
      e2e/specs/condition-map/inspection.spec.ts
  22. 216 0
      e2e/specs/condition-map/printing.spec.ts
  23. 167 0
      e2e/specs/files/deletion.spec.ts
  24. 18 5
      e2e/specs/invoices/designer-drag.spec.ts
  25. 26 35
      e2e/specs/invoices/payments.spec.ts
  26. 24 20
      e2e/specs/invoices/pdf-attachments.spec.ts
  27. 152 0
      e2e/specs/notifications/links.spec.ts
  28. 97 0
      e2e/specs/notifications/quote-response.spec.ts
  29. 2 5
      e2e/specs/payments/checkout.spec.ts
  30. 194 0
      e2e/specs/quotes/lock.spec.ts
  31. 3 0
      e2e/specs/security/admin-only.spec.ts
  32. 13 4
      e2e/specs/security/files.spec.ts
  33. 205 0
      e2e/specs/security/role-standing.spec.ts
  34. 163 0
      e2e/specs/security/whatsapp-webhook.spec.ts
  35. 12 9
      e2e/specs/settings/marine.spec.ts
  36. 78 0
      e2e/specs/settings/mark-types.spec.ts
  37. 78 0
      e2e/specs/status-reports/recorder.spec.ts
  38. 60 35
      e2e/specs/tech/api.spec.ts
  39. 296 0
      e2e/specs/warranty/statement.spec.ts
  40. 0 175
      e2e/specs/work-orders/layout.spec.ts
  41. 12 14
      e2e/specs/work-orders/lifecycle.spec.ts
  42. 169 0
      e2e/specs/work-orders/modern-layout.spec.ts
  43. 510 0
      e2e/specs/work-orders/modern-page.spec.ts
  44. 2 2
      e2e/specs/work-orders/title-template.spec.ts
  45. 0 11
      e2e/specs/work-orders/validation.spec.ts
  46. 20 15
      e2e/support/attachments.ts
  47. 13 0
      e2e/support/cloud.ts
  48. 100 0
      e2e/support/condition-map.ts
  49. 907 25
      e2e/support/db.ts
  50. 49 0
      e2e/support/files.ts
  51. 111 0
      e2e/support/settings.ts
  52. 44 0
      e2e/support/torqvoice-com.ts
  53. 52 0
      e2e/support/warranty.ts
  54. 25 7
      e2e/support/work-order.ts
  55. 182 0
      e2e/torqvoice-com-standin.ts
  56. 3 4
      messages/de/admin.json
  57. 26 12
      messages/de/aiChat.json
  58. 40 12
      messages/de/audit.json
  59. 2 1
      messages/de/auth.json
  60. 4 2
      messages/de/billing.json
  61. 5 5
      messages/de/calendar.json
  62. 22 1
      messages/de/common.json
  63. 154 0
      messages/de/conditionMap.json
  64. 2 2
      messages/de/customers.json
  65. 6 6
      messages/de/dashboard.json
  66. 3 0
      messages/de/dataImport.json
  67. 17 17
      messages/de/documentLock.json
  68. 1870 0
      messages/de/inspectionLibrary.json
  69. 147 46
      messages/de/inspections.json
  70. 126 94
      messages/de/integrations.json
  71. 1 1
      messages/de/inventory.json
  72. 4 4
      messages/de/laborPresets.json
  73. 3 3
      messages/de/messages.json
  74. 3 1
      messages/de/navigation.json
  75. 1 1
      messages/de/notifications.json
  76. 5 0
      messages/de/onboarding.json
  77. 54 8
      messages/de/pdf.json
  78. 3 1
      messages/de/portal.json
  79. 26 18
      messages/de/quotes.json
  80. 4 0
      messages/de/realtime.json
  81. 75 2
      messages/de/reminders.json
  82. 83 81
      messages/de/reports.json
  83. 302 28
      messages/de/service.json
  84. 394 33
      messages/de/settings.json
  85. 15 9
      messages/de/share.json
  86. 7 2
      messages/de/statusReport.json
  87. 3 3
      messages/de/telegram.json
  88. 1 1
      messages/de/telegramMessages.json
  89. 6 0
      messages/de/tireHotel.json
  90. 37 27
      messages/de/vehicles.json
  91. 15 9
      messages/de/workBoard.json
  92. 8 2
      messages/de/workOrders.json
  93. 1 2
      messages/en/admin.json
  94. 26 12
      messages/en/aiChat.json
  95. 40 12
      messages/en/audit.json
  96. 2 1
      messages/en/auth.json
  97. 3 1
      messages/en/billing.json
  98. 22 1
      messages/en/common.json
  99. 154 0
      messages/en/conditionMap.json
  100. 3 0
      messages/en/dataImport.json

+ 0 - 10
.devcontainer/docker-compose.yml

@@ -11,9 +11,6 @@ services:
     command: sleep infinity
     ports:
       - 3000:3000
-    networks:
-      - default
-      - torqvoice-shared
     depends_on:
       db:
         condition: service_healthy
@@ -35,9 +32,6 @@ services:
       interval: 5s
       timeout: 5s
       retries: 5
-    networks:
-      - default
-      - torqvoice-shared
 
   pgadmin:
     image: dpage/pgadmin4:latest
@@ -60,7 +54,3 @@ services:
 volumes:
   postgres-data:
   pgadmin-data:
-
-networks:
-  torqvoice-shared:
-    external: true

+ 0 - 5
.env.example

@@ -39,11 +39,6 @@ INTEGRATIONS_ENCRYPTION_KEY=""
 GOOGLE_INTEGRATION_CLIENT_ID=""
 GOOGLE_INTEGRATION_CLIENT_SECRET=""
 
-# Google sign-in (cloud mode only). A web OAuth client in the Google Cloud console
-# with this authorised redirect URI: <NEXT_PUBLIC_APP_URL>/api/public/auth/callback/google
-# Separate from the calendar connector client above, which requests calendar scopes.
-GOOGLE_AUTH_CLIENT_ID=""
-GOOGLE_AUTH_CLIENT_SECRET=""
 MICROSOFT_INTEGRATION_CLIENT_ID=""
 MICROSOFT_INTEGRATION_CLIENT_SECRET=""
 ZOOM_INTEGRATION_CLIENT_ID=""

+ 4 - 0
.github/PULL_REQUEST_TEMPLATE.md

@@ -0,0 +1,4 @@
+## Summary
+<!-- What changed, in a short short descriptoin-->
+## Why
+<!--  in a short short why -->

+ 8 - 1
.github/workflows/deploy-demo.yml

@@ -76,6 +76,12 @@ jobs:
           VIRTUAL_HOST: "demo.torqvoice.com"
           DATABASE_URL: ${{ secrets.DEMO_DATABASE_URL }}
           BETTER_AUTH_SECRET: ${{ secrets.DEMO_BETTER_AUTH_SECRET }}
+          # Optional. Next encrypts what a server action closes over with a key
+          # made fresh by every build, so a tab left open across a deploy can
+          # no longer be read by the new container. A fixed key keeps those tabs
+          # working. 32 random bytes, base64: `openssl rand -base64 32`. Left
+          # unset, the build's own key is used, as before.
+          NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.DEMO_NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
           APP_URL: "https://demo.torqvoice.com"
           DEMO_USER_EMAIL: "demo@torqvoice.com"
           DEMO_USER_PASSWORD: "demo"
@@ -87,7 +93,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/demo
           cd $HOME/torqvoice-deploy/demo
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|DEMO_|POSTHOG_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|DEMO_|POSTHOG_|NEXT_SERVER_ACTIONS_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -109,6 +115,7 @@ jobs:
                 LETSENCRYPT_HOST: ${VIRTUAL_HOST}
                 DATABASE_URL: ${DATABASE_URL}
                 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
+                NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 NEXT_PUBLIC_APP_URL: ${APP_URL}
                 DEMO_MODE: "true"
                 DEMO_USER_EMAIL: ${DEMO_USER_EMAIL}

+ 44 - 9
.github/workflows/deploy-prod.yml

@@ -40,15 +40,26 @@ jobs:
           DATABASE_URL: ${{ secrets.CLOUD_DATABASE_URL }}
           BETTER_AUTH_SECRET: ${{ secrets.CLOUD_BETTER_AUTH_SECRET }}
           APP_URL: ${{ secrets.CLOUD_APP_URL }}
-          STRIPE_SECRET_KEY: ${{ secrets.CLOUD_STRIPE_SECRET_KEY }}
-          STRIPE_WEBHOOK_SECRET: ${{ secrets.CLOUD_STRIPE_WEBHOOK_SECRET }}
-          STRIPE_PRO_PRICE_ID: ${{ secrets.CLOUD_STRIPE_PRO_PRICE_ID }}
-          STRIPE_ENTERPRISE_PRICE_ID: ${{ secrets.CLOUD_STRIPE_ENTERPRISE_PRICE_ID }}
+          # Plans are sold on torqvoice.com. This secret is the one torqvoice.com
+          # holds as TORQVOICE_SERVICE_SECRET; the app signs checkout handoffs
+          # with it and authenticates billing calls with it. The Stripe keys
+          # themselves live in this app's database and are edited from the
+          # torqvoice.com admin, so they are no longer passed here.
+          TORQVOICE_SERVICE_SECRET: ${{ secrets.TORQVOICE_SERVICE_SECRET }}
+          # Signed by torqvoice.com for APP_URL (scripts/mint-cloud-token.mjs there).
+          # Without it the app ignores TORQVOICE_MODE=cloud and runs self-hosted.
+          TORQVOICE_CLOUD_TOKEN: ${{ secrets.CLOUD_TORQVOICE_CLOUD_TOKEN }}
           DATA_PATH: ${{ secrets.DATA_PATH }}
           POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_INTEGRATIONS_ENCRYPTION_KEY }}
+          # Optional. Next encrypts what a server action closes over with a key
+          # made fresh by every build, so a tab left open across a deploy can
+          # no longer be read by the new container. A fixed key keeps those tabs
+          # working. 32 random bytes, base64: `openssl rand -base64 32`. Left
+          # unset, the build's own key is used, as before.
+          NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.CLOUD_NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
           GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_ID }}
           GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_GOOGLE_INTEGRATION_CLIENT_SECRET }}
           GOOGLE_AUTH_CLIENT_ID: ${{ secrets.CLOUD_GOOGLE_AUTH_CLIENT_ID }}
@@ -63,7 +74,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/prod
           cd $HOME/torqvoice-deploy/prod
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -91,14 +102,14 @@ jobs:
                 # edges, so the visitor address is the one Cloudflare says it is.
                 # Without this every visitor shares the edge's rate-limit bucket.
                 TRUST_CF_CONNECTING_IP: "true"
-                STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
-                STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
-                STRIPE_PRO_PRICE_ID: ${STRIPE_PRO_PRICE_ID}
-                STRIPE_ENTERPRISE_PRICE_ID: ${STRIPE_ENTERPRISE_PRICE_ID}
+                TORQVOICE_SERVICE_SECRET: ${TORQVOICE_SERVICE_SECRET}
+                TORQVOICE_CLOUD_TOKEN: ${TORQVOICE_CLOUD_TOKEN}
+                TORQVOICE_COM_URL: https://torqvoice.com
                 POSTHOG_HOST: ${POSTHOG_HOST}
                 POSTHOG_KEY: ${POSTHOG_KEY}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
+                NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
                 GOOGLE_AUTH_CLIENT_ID: ${GOOGLE_AUTH_CLIENT_ID}
@@ -113,6 +124,30 @@ jobs:
                 - proxy
           COMPOSE
 
+          # Without the cloud token the new container would come up as a
+          # self-hosted install: one workshop for everybody, branding back on.
+          # Stop here, before anything is replaced.
+          if [ -z "$TORQVOICE_CLOUD_TOKEN" ]; then
+            echo "::error title=Cloud token missing::CLOUD_TORQVOICE_CLOUD_TOKEN is empty. Mint one for $APP_URL with scripts/mint-cloud-token.mjs in the torqvoice.com repo."
+            exit 1
+          fi
+
+          # The link to torqvoice.com is what the subscription pages hang on. A
+          # wrong secret or an origin the site does not list only shows up as a
+          # missing page, so the deploy asks the site itself and says so.
+          if [ -n "$TORQVOICE_SERVICE_SECRET" ]; then
+            status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST https://torqvoice.com/api/app/subscription/ping \
+              -H "Authorization: Bearer $TORQVOICE_SERVICE_SECRET" -H 'Content-Type: application/json' \
+              -d "{\"appUrl\":\"$APP_URL\"}" || echo 000)
+            if [ "$status" != "200" ]; then
+              echo "::warning title=torqvoice.com link not confirmed::ping answered $status. Check TORQVOICE_SERVICE_SECRET on both sides and that $APP_URL is TORQVOICE_APP_URL or TORQVOICE_STAGING_APP_URL on torqvoice.com. The subscription page stays hidden until it is."
+            else
+              echo "torqvoice.com link confirmed for $APP_URL"
+            fi
+          else
+            echo "::warning title=torqvoice.com link not configured::TORQVOICE_SERVICE_SECRET is empty, so plans cannot be bought from this deployment."
+          fi
+
       - name: Ensure data directory permissions
         run: |
           DATA_DIR="${{ secrets.DATA_PATH }}/torqvoice_data"

+ 44 - 9
.github/workflows/deploy-staging.yml

@@ -24,15 +24,26 @@ jobs:
           DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}
           BETTER_AUTH_SECRET: ${{ secrets.STAGING_BETTER_AUTH_SECRET }}
           APP_URL: ${{ secrets.STAGING_APP_URL }}
-          STRIPE_SECRET_KEY: ${{ secrets.STAGING_STRIPE_SECRET_KEY }}
-          STRIPE_WEBHOOK_SECRET: ${{ secrets.STAGING_STRIPE_WEBHOOK_SECRET }}
-          STRIPE_PRO_PRICE_ID: ${{ secrets.STAGING_STRIPE_PRO_PRICE_ID }}
-          STRIPE_ENTERPRISE_PRICE_ID: ${{ secrets.STAGING_STRIPE_ENTERPRISE_PRICE_ID }}
+          # Plans are sold on torqvoice.com. This secret is the one torqvoice.com
+          # holds as TORQVOICE_SERVICE_SECRET; the app signs checkout handoffs
+          # with it and authenticates billing calls with it. The Stripe keys
+          # themselves live in this app's database and are edited from the
+          # torqvoice.com admin, so they are no longer passed here.
+          TORQVOICE_SERVICE_SECRET: ${{ secrets.TORQVOICE_SERVICE_SECRET }}
+          # Signed by torqvoice.com for APP_URL (scripts/mint-cloud-token.mjs there).
+          # Without it the app ignores TORQVOICE_MODE=cloud and runs self-hosted.
+          TORQVOICE_CLOUD_TOKEN: ${{ secrets.STAGING_TORQVOICE_CLOUD_TOKEN }}
           DATA_PATH: ${{ secrets.DATA_PATH }}
           POSTHOG_HOST: ${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}
           POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
           BACKUP_HEARTBEAT_TOKEN: ${{ secrets.BACKUP_HEARTBEAT_TOKEN }}
           INTEGRATIONS_ENCRYPTION_KEY: ${{ secrets.STAGING_INTEGRATIONS_ENCRYPTION_KEY }}
+          # Optional. Next encrypts what a server action closes over with a key
+          # made fresh by every build, so a tab left open across a deploy can
+          # no longer be read by the new container. A fixed key keeps those tabs
+          # working. 32 random bytes, base64: `openssl rand -base64 32`. Left
+          # unset, the build's own key is used, as before.
+          NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${{ secrets.STAGING_NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
           GOOGLE_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_INTEGRATION_CLIENT_ID }}
           GOOGLE_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_GOOGLE_INTEGRATION_CLIENT_SECRET }}
           GOOGLE_AUTH_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_AUTH_CLIENT_ID }}
@@ -47,7 +58,7 @@ jobs:
           mkdir -p $HOME/torqvoice-deploy/staging
           cd $HOME/torqvoice-deploy/staging
 
-          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|STRIPE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_)' > .env
+          env | grep -E '^(VIRTUAL_HOST|DATA_PATH|DATABASE_URL|BETTER_AUTH_SECRET|APP_URL|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -73,13 +84,13 @@ jobs:
                 TORQVOICE_MODE: cloud
                 # Staging sits behind Cloudflare like production; see deploy-prod.yml.
                 TRUST_CF_CONNECTING_IP: "true"
-                STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
-                STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET}
-                STRIPE_PRO_PRICE_ID: ${STRIPE_PRO_PRICE_ID}
-                STRIPE_ENTERPRISE_PRICE_ID: ${STRIPE_ENTERPRISE_PRICE_ID}
+                TORQVOICE_SERVICE_SECRET: ${TORQVOICE_SERVICE_SECRET}
+                TORQVOICE_CLOUD_TOKEN: ${TORQVOICE_CLOUD_TOKEN}
+                TORQVOICE_COM_URL: https://torqvoice.com
                 POSTHOG_HOST: ${POSTHOG_HOST}
                 BACKUP_HEARTBEAT_TOKEN: ${BACKUP_HEARTBEAT_TOKEN}
                 INTEGRATIONS_ENCRYPTION_KEY: ${INTEGRATIONS_ENCRYPTION_KEY}
+                NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: ${NEXT_SERVER_ACTIONS_ENCRYPTION_KEY}
                 GOOGLE_INTEGRATION_CLIENT_ID: ${GOOGLE_INTEGRATION_CLIENT_ID}
                 GOOGLE_INTEGRATION_CLIENT_SECRET: ${GOOGLE_INTEGRATION_CLIENT_SECRET}
                 GOOGLE_AUTH_CLIENT_ID: ${GOOGLE_AUTH_CLIENT_ID}
@@ -94,6 +105,30 @@ jobs:
                 - proxy
           COMPOSE
 
+          # Without the cloud token the new container would come up as a
+          # self-hosted install: one workshop for everybody, branding back on.
+          # Stop here, before anything is replaced.
+          if [ -z "$TORQVOICE_CLOUD_TOKEN" ]; then
+            echo "::error title=Cloud token missing::STAGING_TORQVOICE_CLOUD_TOKEN is empty. Mint one for $APP_URL with scripts/mint-cloud-token.mjs in the torqvoice.com repo."
+            exit 1
+          fi
+
+          # The link to torqvoice.com is what the subscription pages hang on. A
+          # wrong secret or an origin the site does not list only shows up as a
+          # missing page, so the deploy asks the site itself and says so.
+          if [ -n "$TORQVOICE_SERVICE_SECRET" ]; then
+            status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST https://torqvoice.com/api/app/subscription/ping \
+              -H "Authorization: Bearer $TORQVOICE_SERVICE_SECRET" -H 'Content-Type: application/json' \
+              -d "{\"appUrl\":\"$APP_URL\"}" || echo 000)
+            if [ "$status" != "200" ]; then
+              echo "::warning title=torqvoice.com link not confirmed::ping answered $status. Check TORQVOICE_SERVICE_SECRET on both sides and that $APP_URL is TORQVOICE_APP_URL or TORQVOICE_STAGING_APP_URL on torqvoice.com. The subscription page stays hidden until it is."
+            else
+              echo "torqvoice.com link confirmed for $APP_URL"
+            fi
+          else
+            echo "::warning title=torqvoice.com link not configured::TORQVOICE_SERVICE_SECRET is empty, so plans cannot be bought from this deployment."
+          fi
+
       - name: Ensure data directory permissions
         run: |
           DATA_DIR="${{ secrets.DATA_PATH }}/torqvoice_staging_data"

+ 30 - 2
.github/workflows/e2e.yml

@@ -58,11 +58,30 @@ jobs:
       # before it does; both come from playwright.config.ts.
       E2E_DATABASE_URL: postgresql://torqvoice:torqvoice@127.0.0.1:5432/torqvoice_e2e
       E2E_MODE: ${{ matrix.mode }}
+      # The cloud job mints a one-day cloud token from it; without the token the
+      # app ignores TORQVOICE_MODE=cloud. Only the cloud job gets it.
+      TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY: ${{ matrix.mode == 'cloud' && secrets.TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY || '' }}
 
     steps:
+      # A pull request from a fork gets no secrets, so the cloud job has no key
+      # to mint its cloud token with and the app would refuse cloud mode. That
+      # says nothing about the contribution, so the job skips instead of failing.
+      # The self-hosted shards need no key and always run.
+      - name: Check for the cloud signing key
+        id: gate
+        run: |
+          if [ "${{ matrix.mode }}" = "cloud" ] && [ -z "$TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY" ]; then
+            echo "::notice title=Cloud specs skipped::No TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY here (a fork or Dependabot pull request), so the cloud specs cannot run."
+            echo "run=false" >> "$GITHUB_OUTPUT"
+          else
+            echo "run=true" >> "$GITHUB_OUTPUT"
+          fi
+
       - uses: actions/checkout@v4
+        if: ${{ steps.gate.outputs.run == 'true' }}
 
       - uses: actions/setup-node@v4
+        if: ${{ steps.gate.outputs.run == 'true' }}
         with:
           node-version: 22
           cache: npm
@@ -70,21 +89,26 @@ jobs:
       # A fresh one per run: the sessions it signs live as long as the job, and
       # a short or guessable value makes better-auth warn on every request.
       - name: Make a session secret for this run
+        if: ${{ steps.gate.outputs.run == 'true' }}
         run: echo "BETTER_AUTH_SECRET=$(openssl rand -base64 32)" >> "$GITHUB_ENV"
 
       - run: npm ci
+        if: ${{ steps.gate.outputs.run == 'true' }}
 
       - run: npx prisma generate
+        if: ${{ steps.gate.outputs.run == 'true' }}
 
       # Keyed on the pinned version, because the image tag and this download
       # have to be the same build.
       - name: Cache the browser
+        if: ${{ steps.gate.outputs.run == 'true' }}
         uses: actions/cache@v4
         with:
           path: ~/.cache/ms-playwright
           key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
 
       - name: Install the browser
+        if: ${{ steps.gate.outputs.run == 'true' }}
         run: npx playwright install --with-deps chromium
 
       # The seed copies bundled vehicle photos when they are in the tree and
@@ -92,6 +116,7 @@ jobs:
       # another, which is minutes of a cold run and the flakiest thing in it.
       # Cached, a warm run copies them off disk instead.
       - name: Cache the seed's photos
+        if: ${{ steps.gate.outputs.run == 'true' }}
         uses: actions/cache@v4
         with:
           # Where prepare-db.ts points the seed's DATA_ROOT, so a test run
@@ -105,6 +130,7 @@ jobs:
       # Every job reads it; only the first shard writes it back, so five jobs
       # do not race to save the same entry.
       - name: Restore the build cache
+        if: ${{ steps.gate.outputs.run == 'true' }}
         uses: actions/cache/restore@v4
         with:
           path: .next/cache
@@ -117,24 +143,26 @@ jobs:
       # refuses a sign-in from an origin the build was not made for, so it has
       # to match the base URL the suite uses.
       - name: Build
+        if: ${{ steps.gate.outputs.run == 'true' }}
         run: npm run build
         env:
           NEXT_PUBLIC_APP_URL: http://127.0.0.1:3100
 
       - name: Save the build cache
-        if: ${{ matrix.id == 'shard-1' }}
+        if: ${{ steps.gate.outputs.run == 'true' && (matrix.id == 'shard-1') }}
         uses: actions/cache/save@v4
         with:
           path: .next/cache
           key: next-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
 
       - name: Run the specs
+        if: ${{ steps.gate.outputs.run == 'true' }}
         run: npx playwright test ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }}
 
       # The blob carries the results with their traces, screenshots and videos;
       # the report job turns every job's blob into one HTML report.
       - name: Upload the blob report
-        if: ${{ !cancelled() }}
+        if: ${{ steps.gate.outputs.run == 'true' && (!cancelled()) }}
         uses: actions/upload-artifact@v4
         with:
           name: blob-report-${{ matrix.id }}

+ 6 - 0
.vscode/settings.json

@@ -15,4 +15,10 @@
     "source.removeUnusedImports": "always",
     "source.organizeImports.biome": "always"
   },
+  "workbench.colorCustomizations": {
+    "titleBar.activeBackground": "#1f4e8c",
+    "titleBar.activeForeground": "#ffffff",
+    "titleBar.inactiveBackground": "#1f4e8c99",
+    "titleBar.inactiveForeground": "#ffffffaa"
+  }
 }

+ 2 - 1
e2e/README.md

@@ -84,7 +84,7 @@ export E2E_DATABASE_URL="postgresql://torqvoice:torqvoice@localhost:5432/torqvoi
 export BETTER_AUTH_SECRET=$(grep -oP '^BETTER_AUTH_SECRET="?\K[^"]+' .env)
 npx tsx e2e/prepare-db.ts
 DATABASE_URL="$E2E_DATABASE_URL" NEXT_PUBLIC_APP_URL=http://127.0.0.1:3100 \
-  DEMO_MODE=false AUTH_RATE_LIMIT=off TORQVOICE_MODE=self-hosted \
+  DEMO_MODE=false AUTH_RATE_LIMIT=off DISABLE_BACKGROUND_JOBS=1 TORQVOICE_MODE=self-hosted \
   SMTP_HOST=127.0.0.1 SMTP_PORT=1025 SMTP_FROM_EMAIL=workshop@e2e.test \
   npm run start -- --port 3100 &
 docker run --rm --network host --user "$(id -u):$(id -g)" -e HOME=/tmp \
@@ -187,6 +187,7 @@ so parity checks compare both.
 | `E2E_SMTP_PORT` | `1025` | Where the mail sink listens for the app |
 | `E2E_MAIL_API_PORT` | `8025` | Where the mail sink answers the specs |
 | `E2E_MAIL_API` | `http://127.0.0.1:8025` | The sink a spec reads from, when it is not the local one |
+| `TORQVOICE_COM_LICENSE_SIGNING_PRIVATE_KEY` | required for `E2E_MODE=cloud` | the torqvoice.com licence signing key (the real one, not a test key); the cloud run mints a one-day cloud token with it, since the app ignores `TORQVOICE_MODE=cloud` without one |
 
 The suite's own server also runs with `TORQVOICE_MODE=self-hosted`, `DEMO_MODE=false` and
 `AUTH_RATE_LIMIT=off`. Pointed at another server, start it the same way or the plan

+ 19 - 2
e2e/specs/auth/account.spec.ts

@@ -40,7 +40,24 @@ async function changePassword(page: Page, from: string, to: string) {
 }
 
 test.describe('password', () => {
-  test('is changed from account settings and works at the door', async ({ page, browser }) => {
+  // One context for both steps, and its session saved afterwards: changing
+  // the password ends every session on the account, the caller's included,
+  // and hands this context a new one. A fresh context from the saved state
+  // would come back with the token the change deleted.
+  let owner: BrowserContext
+  let page: Page
+
+  test.beforeAll(async ({ browser }) => {
+    owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    page = await owner.newPage()
+  })
+
+  test.afterAll(async () => {
+    await owner.storageState({ path: 'e2e/.auth/owner.json' })
+    await owner.close()
+  })
+
+  test('is changed from account settings and works at the door', async ({ browser }) => {
     await changePassword(page, password, changed)
 
     const fresh = await signedOut(browser)
@@ -49,7 +66,7 @@ test.describe('password', () => {
     await fresh.context().close()
   })
 
-  test('is put back for the rest of the suite', async ({ page }) => {
+  test('is put back for the rest of the suite', async () => {
     await changePassword(page, changed, password)
   })
 })

+ 139 - 0
e2e/specs/auth/devices.spec.ts

@@ -0,0 +1,139 @@
+import { type BrowserContext, expect, type Page, test } from '@playwright/test'
+import { deviceCountFor, sessionCountFor } from '../../support/db'
+import { fillSettled } from '../../support/hydration'
+import { mailsTo, waitForMail } from '../../support/mail'
+
+/**
+ * Who is signed in to an account, and how to get them out.
+ *
+ * A sign-in from a browser the account has not seen before earns a mail,
+ * the account page lists every open session as a device the owner can
+ * recognise, any of them can be signed out from there, and a password change
+ * ends all the others by itself. That last one is what makes a password
+ * change worth anything against a stolen session.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const email = process.env.E2E_USER_EMAIL ?? 'demo@torqvoice.com'
+const password = process.env.E2E_USER_PASSWORD ?? 'demo-e2e-pass'
+const stamp = Date.now()
+const changed = `E2e-devices-${stamp}`
+
+/** A phone: no cookies from anywhere, and a user agent the list will name. */
+const PHONE_UA =
+  'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1'
+
+async function signIn(page: Page, secret = password) {
+  await page.goto('/auth/sign-in')
+  await page.locator('#email').fill(email)
+  await page.locator('#password').fill(secret)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+  await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+}
+
+/** Whether a context's session is still honoured, asked past the cookie cache. */
+async function stillSignedIn(context: BrowserContext): Promise<boolean> {
+  const response = await context.request.get('/api/public/auth/get-session?disableCookieCache=true')
+  const body = await response.text()
+  return body !== 'null' && body !== ''
+}
+
+let phone: BrowserContext
+
+test.beforeAll(async ({ browser }) => {
+  phone = await browser.newContext({
+    storageState: { cookies: [], origins: [] },
+    userAgent: PHONE_UA,
+  })
+})
+
+test.afterAll(async () => {
+  await phone.close()
+})
+
+test('a sign-in from a new browser mails the owner', async () => {
+  const before = Date.now()
+  const page = await phone.newPage()
+  await signIn(page)
+
+  const mail = await waitForMail(email, { subject: /new sign-in/i })
+  expect(mail.html).toContain('Safari on iPhone')
+  expect(mail.html).toContain('/settings/account')
+  expect(new Date(mail.receivedAt).getTime()).toBeGreaterThanOrEqual(before - 60_000)
+})
+
+test('the account page lists the phone and signs it out', async ({ page }) => {
+  await page.goto('/settings/account')
+  const list = page.getByTestId('signed-in-devices')
+  const own = list.getByTestId('signed-in-device').filter({ hasText: 'This device' })
+  await expect(own).toHaveCount(1)
+  const iphone = list.getByTestId('signed-in-device').filter({ hasText: 'Safari on iPhone' })
+  await expect(iphone.first()).toBeVisible()
+
+  const sessionsBefore = await sessionCountFor(email)
+  await iphone
+    .first()
+    .getByRole('button', { name: /sign out/i })
+    .click()
+  await expect(page.getByText('Device signed out', { exact: true })).toBeVisible()
+  await expect(
+    list.getByTestId('signed-in-device').filter({ hasText: 'Safari on iPhone' })
+  ).toHaveCount(0)
+
+  expect(await sessionCountFor(email)).toBe(sessionsBefore - 1)
+  expect(await stillSignedIn(phone), 'the phone is out').toBe(false)
+
+  // What the person holding the phone sees: the next page it asks for is the
+  // sign-in page. A row count proved nothing here while the session cookie
+  // cache let a revoked session keep working for five minutes.
+  const held = await phone.newPage()
+  await held.goto('/customers')
+  await expect(held).toHaveURL(/\/auth\/sign-in/, { timeout: 15_000 })
+  await held.close()
+})
+
+test('signing in again on the same phone is not a new device', async () => {
+  // The device cookie outlives the session, so the phone that was just
+  // signed out comes back as itself: one device row, no second mail.
+  const mailsBefore = (await mailsTo(email)).filter((m) => /new sign-in/i.test(m.subject)).length
+  const rowsBefore = await deviceCountFor(email, 'iPhone')
+  const page = await phone.newPage()
+  await signIn(page)
+  await page.waitForTimeout(1_500)
+  const mailsAfter = (await mailsTo(email)).filter((m) => /new sign-in/i.test(m.subject)).length
+  expect(mailsAfter, 'no new-device mail for a device the account knows').toBe(mailsBefore)
+  expect(await deviceCountFor(email, 'iPhone'), 'no second row for the phone').toBe(rowsBefore)
+  await page.close()
+})
+
+test('changing the password ends every other device', async ({ page, browser }) => {
+  const other = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  await signIn(await other.newPage())
+  expect(await stillSignedIn(other)).toBe(true)
+
+  await page.goto('/settings/account')
+  await fillSettled(page.locator('#currentPassword'), password)
+  await fillSettled(page.locator('#newPassword'), changed)
+  await fillSettled(page.locator('#confirmPassword'), changed)
+  await page.getByRole('button', { name: 'Change Password', exact: true }).click()
+  await expect(page.getByText('Password changed', { exact: true })).toBeVisible()
+
+  expect(await stillSignedIn(other), 'the other browser is out').toBe(false)
+  expect(await sessionCountFor(email), 'only the changing browser remains').toBe(1)
+  const held = await other.newPage()
+  await held.goto('/customers')
+  await expect(held, 'the other browser lands on sign-in').toHaveURL(/\/auth\/sign-in/, {
+    timeout: 15_000,
+  })
+  await other.close()
+
+  // Put the seeded password back for the rest of the suite, and save the
+  // session this browser ended up with: each change retired the one before.
+  await fillSettled(page.locator('#currentPassword'), changed)
+  await fillSettled(page.locator('#newPassword'), password)
+  await fillSettled(page.locator('#confirmPassword'), password)
+  await page.getByRole('button', { name: 'Change Password', exact: true }).click()
+  await expect(page.getByText('Password changed', { exact: true }).first()).toBeVisible()
+  await page.context().storageState({ path: 'e2e/.auth/owner.json' })
+})

+ 319 - 0
e2e/specs/auth/member-role.spec.ts

@@ -0,0 +1,319 @@
+import { expect, type Page, test } from '@playwright/test'
+import {
+  customFieldValue,
+  deleteCustomFields,
+  deletePersonWithEmail,
+  forgetWorkshopSetting,
+  membershipOf,
+  ownerOrganizationId,
+  permissionDenialsFor,
+  plantCustomField,
+  roleIdNamed,
+  seededTenantFixtures,
+  setWorkshopSetting,
+  type TenantFixtures,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { linkIn, waitForMail } from '../../support/mail'
+import { saveWorkOrder } from '../../support/work-order'
+
+/**
+ * The role the product hands somebody at the desk.
+ *
+ * Every other permission test in the suite covers an extreme: `roles.spec.ts`
+ * has a member with no role refused everywhere, `admin-only.spec.ts` has a
+ * member with every permission still kept out of the owner's screens, and the
+ * other fifty specs run as the owner, who skips the permission check
+ * altogether. Nothing signed in as the built-in Member role and used the
+ * ordinary pages that role is allowed to open, and two bugs lived in the gap.
+ * Both were silent: nineteen pages read the workshop's currency, units and tax
+ * through a call that needs `read:settings`, were refused, and fell back to the
+ * built-in defaults without a word, and a work order's custom fields needed the
+ * Settings permission too, so a Member saw none of them.
+ *
+ * The guard below is therefore not "assert the currency". A workshop whose
+ * currency is USD hides that bug, and a page that quietly substitutes a
+ * default never turns red. It is: using the pages this role may use writes no
+ * `auth.permissionDenied` row. A refusal on a page the role is meant to reach
+ * is, by definition, a page asking for something the role does not carry,
+ * whatever the missing permission turns out to be.
+ *
+ * Two narrow checks of what the person actually sees sit on top of it, because
+ * an audit table is not a user.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+// The colleague starts as a stranger with no session.
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const stamp = Date.now()
+const MEMBER = `e2e-member-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+const FIELD_LABEL = `E2E Paint code ${stamp}`
+const CURRENCY_KEY = 'workshop.currencyCode'
+
+/**
+ * A currency that cannot be mistaken for the built-in fallback. `USD` is what
+ * every page substitutes when the read is refused, so a workshop on USD proves
+ * nothing; NOK prints "kr" and never "$".
+ */
+const CURRENCY = 'NOK'
+
+let organizationId = ''
+let fixtures: TenantFixtures
+let memberRoleId: string | null = null
+let currencyBefore: string | null = null
+let currencyChanged = false
+const plantedFields: string[] = []
+
+async function signInAsMember(page: Page) {
+  // The sign-in page sends anyone with a session straight on, so a fresh
+  // sign-in has to drop the old cookie first.
+  await page.context().clearCookies()
+  await page.goto('/auth/sign-in')
+  await page.locator('#email').fill(MEMBER)
+  await page.locator('#password').fill(PASSWORD)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+  await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+}
+
+test.afterAll(async () => {
+  // The workshop is left exactly as it was found: the currency back, the
+  // planted field and its values gone, the colleague gone. The Member role
+  // itself stays, because the product made it through its own button and it
+  // is the workshop's now.
+  if (currencyChanged) {
+    if (currencyBefore === null) await forgetWorkshopSetting(organizationId, CURRENCY_KEY)
+    else await setWorkshopSetting(organizationId, CURRENCY_KEY, currencyBefore)
+  }
+  await deleteCustomFields(plantedFields)
+  await deletePersonWithEmail(MEMBER)
+})
+
+test.describe('the built-in Member role', () => {
+  test('is invited with the workshop’s own Member role, and signs up from the mail', async ({
+    page,
+    browser,
+  }) => {
+    organizationId = await ownerOrganizationId()
+    fixtures = await seededTenantFixtures()
+
+    const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    const ownerPage = await owner.newPage()
+    await ownerPage.goto('/settings/team')
+    await settle(ownerPage)
+
+    // The role has to be the one the app makes, not one assembled here: a
+    // permission list written in a test keeps passing after the real role
+    // changes underneath it. The team page offers to create the standard roles
+    // while either is missing, which is the path a workshop really takes.
+    const createRoles = ownerPage.getByRole('button', { name: 'Create the standard roles' })
+    if (await createRoles.isVisible().catch(() => false)) {
+      await createRoles.click()
+      await expect(ownerPage.getByText('Standard roles created')).toBeVisible({ timeout: 30_000 })
+    }
+    memberRoleId = await roleIdNamed(organizationId, 'Member')
+    expect(memberRoleId, 'the workshop has the built-in Member role').toBeTruthy()
+
+    await expect(async () => {
+      await ownerPage.getByRole('button', { name: 'Add', exact: true }).first().click()
+      await expect(ownerPage.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await ownerPage.getByText('Someone in the office').click()
+    await ownerPage.locator('#member-email').fill(MEMBER)
+
+    const dialog = ownerPage.getByRole('dialog').filter({ has: ownerPage.locator('#member-email') })
+    await dialog.getByRole('combobox').click()
+    await ownerPage.getByRole('option', { name: 'Member', exact: true }).click()
+    await ownerPage.getByRole('button', { name: 'Invite', exact: true }).click()
+    await expect(ownerPage.getByText(MEMBER).first()).toBeVisible({ timeout: 30_000 })
+    await owner.close()
+
+    const invitation = await waitForMail(MEMBER)
+    await page.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
+    await page.locator('#name').fill('E2E Desk Colleague')
+    await page.locator('#email').fill(MEMBER)
+    await page.locator('#password').fill(PASSWORD)
+    await page.locator('#terms').click()
+    await page.getByRole('button', { name: /create account/i }).click()
+
+    // Inside the app, not at the door and not in onboarding: they joined a
+    // workshop that already exists.
+    await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+    await expect(page.getByRole('heading', { name: 'No access yet' })).toHaveCount(0)
+
+    // And they carry the built-in role, not an accidental blank membership.
+    const membership = await membershipOf(MEMBER, organizationId)
+    expect(membership.roleId).toBe(memberRoleId)
+    expect(membership.role).toBe('member')
+  })
+
+  test('is refused nothing on any page the role is meant to open', async ({ page }) => {
+    await signInAsMember(page)
+
+    /**
+     * Every page the Member role carries a permission for. Deliberately absent:
+     * settings, billing, reports, inspections and the tire hotel, which this
+     * role is meant to be refused and which the last test pins.
+     */
+    const pages: { url: string; shows: (page: Page) => Promise<void> }[] = [
+      { url: '/', shows: (p) => expect(p.getByText('Dashboard').first()).toBeVisible() },
+      {
+        url: '/work-orders',
+        shows: (p) => expect(p.getByText('All Work Orders').first()).toBeVisible(),
+      },
+      { url: '/vehicles', shows: (p) => expect(p.getByText('All Vehicles').first()).toBeVisible() },
+      {
+        url: `/vehicles/${fixtures.vehicleId}`,
+        // A page that rendered its own record, not an empty shell.
+        shows: (p) => expect(p.getByText(fixtures.vehiclePlate).first()).toBeVisible(),
+      },
+      {
+        url: `/vehicles/${fixtures.vehicleId}/service/${fixtures.serviceRecordId}`,
+        shows: (p) => expect(p.getByTestId('totals')).toBeVisible(),
+      },
+      {
+        url: '/customers',
+        shows: (p) => expect(p.getByText('All Customers').first()).toBeVisible(),
+      },
+      {
+        url: `/customers/${fixtures.customerId}`,
+        shows: (p) => expect(p.getByText(fixtures.customerName).first()).toBeVisible(),
+      },
+      { url: '/quotes', shows: (p) => expect(p.getByText('All Quotes').first()).toBeVisible() },
+      {
+        url: `/quotes/${fixtures.quoteId}`,
+        shows: (p) => expect(p.getByText(fixtures.quoteNumber).first()).toBeVisible(),
+      },
+      { url: '/inventory', shows: (p) => expect(p.getByText('All Parts').first()).toBeVisible() },
+      {
+        url: '/labor-presets',
+        shows: (p) => expect(p.getByText('Labor Presets').first()).toBeVisible(),
+      },
+      { url: '/work-board', shows: (p) => expect(p.getByText('Work Board').first()).toBeVisible() },
+      {
+        url: '/calendar',
+        shows: (p) => expect(p.getByRole('button', { name: 'Today' }).first()).toBeVisible(),
+      },
+    ]
+
+    // A second either side: the audit write is fire-and-forget, and the clock
+    // here is not the clock the row is stamped with.
+    const since = new Date(Date.now() - 1_000)
+    const refusals: string[] = []
+    let counted = 0
+
+    for (const { url, shows } of pages) {
+      await page.goto(url)
+      await settle(page)
+
+      // The refusal screen, an error boundary or an empty shell all mean the
+      // page did not open, and would leave the audit log innocently empty.
+      await expect(
+        page.getByRole('heading', { name: 'No access yet' }),
+        `${url} opens`
+      ).toHaveCount(0)
+      await shows(page)
+
+      await page.waitForTimeout(2_000)
+      const all = await permissionDenialsFor(MEMBER, since)
+      // Attributed to the page that was open when they appeared, which is what
+      // makes a failure here readable rather than a list of bare messages.
+      for (const message of all.slice(counted)) refusals.push(`${url} -> ${message}`)
+      counted = all.length
+    }
+
+    expect(
+      refusals,
+      'a page this role may open asked for a permission the role does not carry'
+    ).toEqual([])
+  })
+
+  test('sees the workshop’s own currency, not the built-in default', async ({ page, browser }) => {
+    currencyBefore = await setWorkshopSetting(organizationId, CURRENCY_KEY, CURRENCY)
+    currencyChanged = true
+
+    const jobUrl = `/vehicles/${fixtures.vehicleId}/service/${fixtures.serviceRecordId}`
+
+    // What the owner sees is the workshop's own answer, whatever the app's
+    // formatting happens to be. The Member has to see the same thing rather
+    // than a shape this test decided on.
+    const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    const ownerPage = await owner.newPage()
+    await ownerPage.goto(jobUrl)
+    await settle(ownerPage)
+    const ownersTotals = (await ownerPage.getByTestId('totals').innerText()).trim()
+    await owner.close()
+
+    expect(ownersTotals, 'the workshop is on NOK for this test').toContain('kr')
+
+    await signInAsMember(page)
+    await page.goto(jobUrl)
+    await settle(page)
+    const membersTotals = (await page.getByTestId('totals').innerText()).trim()
+
+    expect(membersTotals).toBe(ownersTotals)
+    // Said plainly as well, because the comparison above would pass if both
+    // sides broke together.
+    expect(membersTotals).toContain('kr')
+    expect(membersTotals).not.toContain('$')
+    expect(membersTotals).not.toContain('USD')
+
+    // And on a list, which reads the currency through the same call.
+    await page.goto('/work-orders')
+    await settle(page)
+    const list = await page.getByRole('table').first().innerText()
+    expect(list).not.toContain('$')
+  })
+
+  test('sees a custom field on a work order, fills it in, and it stays', async ({ page }) => {
+    const fieldId = await plantCustomField(organizationId, 'service_record', FIELD_LABEL)
+    plantedFields.push(fieldId)
+
+    await signInAsMember(page)
+    const jobUrl = `/vehicles/${fixtures.vehicleId}/service/${fixtures.serviceRecordId}`
+    await page.goto(jobUrl)
+    await settle(page)
+
+    // The label is the whole point: a Member used to see no custom fields at
+    // all, so the workshop's own question never reached the person answering it.
+    const field = page.getByTestId(`custom-field-${fieldId}`)
+    await expect(field).toBeVisible()
+    await expect(field.getByText(FIELD_LABEL)).toBeVisible()
+
+    const value = `LY9C ${stamp}`
+    const input = field.getByRole('textbox')
+    await expect(async () => {
+      await input.fill(value)
+      await expect(input).toHaveValue(value)
+    }).toPass({ timeout: 30_000 })
+
+    await saveWorkOrder(page)
+
+    // Read back from the database as well as the page: the save used to be
+    // refused outright, and an input keeps what was typed into it either way.
+    await expect(async () => {
+      expect(await customFieldValue(fieldId, fixtures.serviceRecordId)).toBe(value)
+    }).toPass({ timeout: 30_000 })
+
+    await page.goto(jobUrl)
+    await settle(page)
+    await expect(page.getByTestId(`custom-field-${fieldId}`).getByRole('textbox')).toHaveValue(
+      value
+    )
+  })
+
+  test('is still kept out of the workshop’s settings', async ({ page }) => {
+    // The other half of the rule, and the guard against widening the role to
+    // make the first test pass: this table holds payment secrets, API keys and
+    // the licence token, and none of it is a Member's business.
+    await signInAsMember(page)
+
+    for (const url of ['/settings', '/settings/company']) {
+      await page.goto(url)
+      await settle(page)
+      await expect(page, `${url} is refused`).not.toHaveURL(new RegExp(`${url}$`))
+    }
+  })
+})

+ 12 - 0
e2e/specs/auth/sign-in.spec.ts

@@ -119,6 +119,18 @@ test.describe('forgotten password', () => {
   // spend it twice. better-auth deletes it on use, which is the point.
   let spent = ''
 
+  // A reset ends every session the owner has, the shared one from setup
+  // included; that is the protection. The rest of the suite still signs in
+  // with the saved state, so a fresh session is saved over it afterwards.
+  test.afterAll(async ({ browser }) => {
+    const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+    const page = await context.newPage()
+    await signIn(page, email, password)
+    await expectSignedIn(page)
+    await context.storageState({ path: 'e2e/.auth/owner.json' })
+    await context.close()
+  })
+
   test('a made-up token cannot set a password', async ({ page }) => {
     await resetWith(page, 'not-a-real-token', replacement)
     // better-auth's own words, or the page's fallback when it has none.

+ 13 - 8
e2e/specs/auth/sign-up.spec.ts

@@ -2,8 +2,13 @@ import { expect, type Page, test } from '@playwright/test'
 import { linkIn, waitForMail } from '../../support/mail'
 
 /**
- * How people arrive: a stranger who opens a workshop of their own, and a
- * colleague who was invited into one that exists.
+ * How people arrive: a stranger, and a colleague who was invited into the
+ * workshop that exists.
+ *
+ * A self-hosted install runs one workshop, so the stranger does not open a
+ * second one: they are told to ask its owner for an invitation
+ * (single-workshop.spec.ts holds the rest of that). The colleague's path is
+ * the one that matters on such an install.
  *
  * Both start signed out. The colleague follows the link out of the invitation
  * mail itself, caught by the harness's mail sink, so the address the app
@@ -22,16 +27,16 @@ async function fillSignUp(page: Page, name: string, email: string, password: str
   await page.getByRole('button', { name: /create account/i }).click()
 }
 
-test('a new account is walked through onboarding into a workshop of its own', async ({ page }) => {
+test('a new account reaches onboarding, where the install says it has its workshop', async ({
+  page,
+}) => {
   await page.goto('/auth/sign-up')
   await fillSignUp(page, 'E2E Founder', `e2e-founder-${stamp}@example.com`, `E2e-pass-${stamp}`)
 
   await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
-  await page.locator('#workshopName').fill(`E2E Garage ${stamp}`)
-  await page.locator('form button[type="submit"]').click()
-
-  await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
-  await expect(page.getByText(`E2E Garage ${stamp}`).first()).toBeVisible()
+  await expect(
+    page.getByRole('heading', { name: 'This installation already has its workshop' })
+  ).toBeVisible()
 })
 
 test('an invited colleague signs up straight into the workshop', async ({ page, browser }) => {

+ 89 - 0
e2e/specs/auth/single-workshop.spec.ts

@@ -0,0 +1,89 @@
+import { expect, test } from '@playwright/test'
+import { deletePersonWithEmail, organizationCount, personWithEmail } from '../../support/db'
+import { settle } from '../../support/hydration'
+
+/**
+ * A self-hosted install runs one workshop.
+ *
+ * The seeded workshop is that one. A stranger who signs up afterwards is not
+ * opening a second workshop: they are told to ask its owner for an
+ * invitation, and the owner's own "Add New Company" is refused with the
+ * pointer to the licence. Both are proved on the count of workshops in the
+ * database, not only on what the screen says. Lifting the limit with a
+ * licence is a signed token from torqvoice.com, which this harness cannot
+ * mint, so that half lives in src/__tests__/lib/features.test.ts.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const STRANGER = `e2e-stranger-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+
+let workshops = 0
+
+test.beforeAll(async () => {
+  workshops = await organizationCount()
+  expect(workshops, 'the install already has its workshop').toBeGreaterThanOrEqual(1)
+})
+
+test.afterAll(async () => {
+  await deletePersonWithEmail(STRANGER)
+})
+
+test.describe('a stranger signing up', () => {
+  test.use({ storageState: { cookies: [], origins: [] } })
+
+  test('is told to ask for an invitation instead of opening a workshop', async ({ page }) => {
+    await page.goto('/auth/sign-up')
+    await settle(page)
+    await page.locator('#name').fill('E2E Stranger')
+    await page.locator('#email').fill(STRANGER)
+    await page.locator('#password').fill(PASSWORD)
+    await page.locator('#terms').click()
+    await page.locator('form button[type="submit"]').click()
+    await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
+
+    await expect(
+      page.getByRole('heading', { name: 'This installation already has its workshop' })
+    ).toBeVisible()
+    await expect(page.getByText(STRANGER)).toBeVisible()
+    await expect(page.locator('#workshopName')).toHaveCount(0)
+
+    // The account exists, so an invitation can reach it; the workshop count did not move.
+    expect((await personWithEmail(STRANGER)).users).toBe(1)
+    expect(await organizationCount()).toBe(workshops)
+
+    await page.getByRole('button', { name: 'Sign out' }).click()
+    await page.waitForURL(/\/auth\/sign-in/, { timeout: 15_000 })
+  })
+
+  test('is sent back to the notice on every visit until invited', async ({ page }) => {
+    await page.goto('/auth/sign-in')
+    await settle(page)
+    await page.locator('#email').fill(STRANGER)
+    await page.locator('#password').fill(PASSWORD)
+    await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+    await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
+    await expect(
+      page.getByRole('heading', { name: 'This installation already has its workshop' })
+    ).toBeVisible()
+  })
+})
+
+test.describe('the owner', () => {
+  test('cannot add a second company without a licence', async ({ page }) => {
+    await page.goto('/settings/company')
+    await settle(page)
+    const dialog = page.getByRole('dialog', { name: 'Add New Company' })
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Add New Company' }).click()
+      await expect(dialog).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await dialog.locator('#settings-org-name').fill(`E2E Second Garage ${stamp}`)
+    await dialog.getByRole('button', { name: 'Create Company' }).click()
+
+    await expect(page.getByText('This installation runs one workshop')).toBeVisible()
+    expect(await organizationCount()).toBe(workshops)
+  })
+})

+ 84 - 14
e2e/specs/auth/tenancy.spec.ts

@@ -1,10 +1,17 @@
 import { expect, type Page, test } from '@playwright/test'
 import { attach } from '../../support/attachments'
 import {
+  deleteTechnicians,
+  deleteWorkBays,
+  insertTechnician,
+  insertWorkBay,
+  jobAssignment,
+  jobCount,
   latestAttachmentUrl,
   organizationIdFor,
   seededTenantFixtures,
   type TenantFixtures,
+  plantWorkshop,
 } from '../../support/db'
 import { shareLink } from '../../support/work-order'
 
@@ -42,7 +49,7 @@ let sharedInvoice = ''
 /** A file that genuinely belongs to the first workshop's own job. */
 let theirFileUrl = ''
 
-/** Signs the outsider in, opening their workshop on the first run. */
+/** Signs the outsider in. */
 async function signInAsOutsider(page: Page) {
   await page.goto('/auth/sign-in')
   await page.locator('#email').fill(OUTSIDER)
@@ -73,19 +80,17 @@ test.beforeAll(async ({ browser }) => {
 })
 
 test.describe('a second workshop', () => {
-  test('is opened by a stranger signing up', async ({ page }) => {
-    await page.goto('/auth/sign-up')
-    await page.locator('#name').fill('E2E Outsider')
-    await page.locator('#email').fill(OUTSIDER)
-    await page.locator('#password').fill(PASSWORD)
-    await page.locator('#terms').click()
-    await page.getByRole('button', { name: /create account/i }).click()
-
-    await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
-    await page.locator('#workshopName').fill(`E2E Outsider Garage ${stamp}`)
-    await page.locator('form button[type="submit"]').click()
-    await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
-
+  test('exists beside the first, with its own owner signed in', async ({ page }) => {
+    // A self-hosted install opens one workshop and sends every later
+    // sign-up to ask for an invitation (single-workshop.spec.ts), so the
+    // outsider's workshop is planted rather than signed up.
+    await plantWorkshop({
+      name: 'E2E Outsider',
+      email: OUTSIDER,
+      password: PASSWORD,
+      workshopName: `E2E Outsider Garage ${stamp}`,
+    })
+    await signInAsOutsider(page)
     await expect(page.getByText(`E2E Outsider Garage ${stamp}`).first()).toBeVisible()
   })
 
@@ -180,3 +185,68 @@ test.describe('a second workshop', () => {
     expect(response.headers()['content-type']).toContain('application/pdf')
   })
 })
+
+/**
+ * A job booked from the work board names the technician and the bay it was
+ * dropped on, by id, in the URL that opens the new job. Those ids have to be
+ * the workshop's own: the technician lookup used to write the id it was given
+ * even when it found nothing, so a job could point at another workshop's
+ * technician, and the bay was never looked up at all.
+ */
+test.describe('a job booked onto a technician and a bay', () => {
+  const made = { technicians: [] as string[], bays: [] as string[] }
+  let theirs = { technicianId: '', workBayId: '' }
+  let ours = { technicianId: '', workBayId: '' }
+
+  test.beforeAll(async () => {
+    const outsiderOrg = await organizationIdFor(OUTSIDER)
+    theirs = {
+      technicianId: await insertTechnician(outsiderOrg, `E2E Their Tech ${stamp}`),
+      workBayId: await insertWorkBay(outsiderOrg, `E2E Their Bay ${stamp}`),
+    }
+    ours = {
+      technicianId: await insertTechnician(seeded.organizationId, `E2E Own Tech ${stamp}`),
+      workBayId: await insertWorkBay(seeded.organizationId, `E2E Own Bay ${stamp}`),
+    }
+    made.technicians.push(theirs.technicianId, ours.technicianId)
+    made.bays.push(theirs.workBayId, ours.workBayId)
+  })
+
+  test.afterAll(async () => {
+    await deleteTechnicians(made.technicians)
+    await deleteWorkBays(made.bays)
+  })
+
+  test('is refused when either belongs to the other workshop', async ({ browser }) => {
+    const owner = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+    const before = await jobCount(seeded.vehicleId)
+    const newJob = `/vehicles/${seeded.vehicleId}/service/new`
+
+    await owner.goto(`${newJob}?boardTech=${theirs.technicianId}&boardBay=${ours.workBayId}`)
+    await expect(owner.getByText('Technician not found')).toBeVisible()
+    await expect(owner).toHaveURL(/\/service\/new/)
+
+    await owner.goto(`${newJob}?boardTech=${ours.technicianId}&boardBay=${theirs.workBayId}`)
+    await expect(owner.getByText('Work bay not found')).toBeVisible()
+    await expect(owner).toHaveURL(/\/service\/new/)
+
+    expect(await jobCount(seeded.vehicleId), 'no job was made').toBe(before)
+    await owner.close()
+  })
+
+  test('opens on the workshop’s own technician and bay', async ({ browser }) => {
+    const owner = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+    await owner.goto(
+      `/vehicles/${seeded.vehicleId}/service/new?boardTech=${ours.technicianId}&boardBay=${ours.workBayId}`
+    )
+    await owner.waitForURL(/\/service\/(?!new)[^/]+$/, { timeout: 30_000 })
+
+    const jobId = new URL(owner.url()).pathname.split('/').pop() as string
+    expect(await jobAssignment(jobId)).toEqual({
+      id: jobId,
+      technicianId: ours.technicianId,
+      workBayId: ours.workBayId,
+    })
+    await owner.close()
+  })
+})

+ 3 - 3
e2e/specs/calendar/booking.spec.ts

@@ -1,6 +1,7 @@
 import { expect, type Page, test } from '@playwright/test'
 import { settle } from '../../support/hydration'
 import { setWorkshopClock } from '../../support/settings'
+import { saveWorkOrder, setTitle } from '../../support/work-order'
 
 /**
  * A booking keeps the time it was made at.
@@ -132,9 +133,8 @@ test.describe('booking a job from the calendar', () => {
       timeout: 30_000,
     })
 
-    await page.locator('input[name="title"]').fill(`E2E booking ${stamp}`)
-    await page.getByRole('button', { name: 'Save', exact: true }).click()
-    await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+    await setTitle(page, `E2E booking ${stamp}`)
+    await saveWorkOrder(page)
   })
 
   test('and still starts at that time after a reload', async ({ page }) => {

+ 37 - 5
e2e/specs/cloud/google-sign-in.spec.ts

@@ -7,7 +7,7 @@ import {
   routeGoogleToStandin,
   signUpWithPassword,
 } from '../../support/cloud'
-import { personWithEmail } from '../../support/db'
+import { markEmailVerified, personWithEmail } from '../../support/db'
 import { settle } from '../../support/hydration'
 
 /**
@@ -20,10 +20,14 @@ import { settle } from '../../support/hydration'
  *
  * The rule most worth a test is account linking. A Google sign-in whose
  * address matches an existing password account is attached to that account,
- * which is what a returning customer expects. But an address Google has not
- * verified proves nothing about who is signing in: anyone can create a Google
- * account with somebody else's address on it. Attached to the account that
- * owns the address, that is a way into another person's workshop.
+ * which is what a returning customer expects. But it takes proof from both
+ * sides. An address Google has not verified proves nothing about who is
+ * signing in: anyone can create a Google account with somebody else's address
+ * on it. And a password account nobody verified proves nothing about who made
+ * it: anyone can sign up with somebody else's address and a password of their
+ * own, and joining Google to that account would sign its real owner into a
+ * stranger's account. Either way round, that is a way into another person's
+ * workshop.
  */
 
 // Signing up and onboarding a workshop in a hook takes longer than a test.
@@ -36,6 +40,7 @@ const stamp = Date.now()
 const NEWCOMER = `e2e-google-new-${stamp}@example.com`
 const RETURNING = `e2e-google-password-${stamp}@example.com`
 const OWNER = `e2e-google-owner-${stamp}@example.com`
+const SQUATTED = `e2e-google-squatted-${stamp}@example.com`
 const PASSWORD = `E2e-pass-${stamp}`
 
 test.beforeAll(async ({ browser }) => {
@@ -53,6 +58,16 @@ test.beforeAll(async ({ browser }) => {
     await completeOnboarding(page, workshop, { sampleData: false })
     await context.close()
   }
+  // The returning person clicked the link in their verification mail. The
+  // e2e database has no verification wall, so this is that click.
+  await markEmailVerified(RETURNING)
+
+  // And a password account somebody opened under an address that is not
+  // theirs, never verified, and left at onboarding.
+  const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const page = await context.newPage()
+  await signUpWithPassword(page, { name: 'Not the owner', email: SQUATTED, password: PASSWORD })
+  await context.close()
 })
 
 test.beforeEach(async ({ context }) => {
@@ -126,6 +141,23 @@ test.describe('Google sign-in', () => {
     expect(person.providers).toEqual(['credential', 'google'])
   })
 
+  test('does not join a password account whose address was never verified', async ({ page }) => {
+    // Google vouches for the person at the keyboard; the password account
+    // under that address was made by somebody else. Joining the two would
+    // sign the real owner of the address into the stranger's account.
+    await registerGoogleAccount({ email: SQUATTED, emailVerified: true })
+    await continueWithGoogle(page, '/auth/sign-in', SQUATTED)
+
+    await page.waitForURL(/\/auth\/sign-in\?error=account_not_linked/, { timeout: 30_000 })
+    await expect(page.getByText(/address has not been verified yet/)).toBeVisible()
+    await expect(page.locator('#email'), 'the password form is the way in').toBeVisible()
+
+    const person = await personWithEmail(SQUATTED)
+    expect(person.users, 'no second person was made either').toBe(1)
+    expect(person.providers, 'the password account stands alone').toEqual(['credential'])
+    expect(person.emailVerified).toBe(false)
+  })
+
   test('does not hand an account to a Google address nobody verified', async ({ page }) => {
     // Somebody made a Google account with the owner's address on it. Google
     // says so: the address is not verified.

+ 198 - 0
e2e/specs/cloud/subscription-handoff.spec.ts

@@ -0,0 +1,198 @@
+import { expect, test } from '@playwright/test'
+import { completeOnboarding, signUpWithPassword } from '../../support/cloud'
+import { giveProPlan, organizationIdFor, removePlan, setCancelAtPeriodEnd } from '../../support/db'
+import { settle } from '../../support/hydration'
+import {
+  clearTorqvoiceComCalls,
+  setTorqvoiceComLinked,
+  torqvoiceComState,
+  torqvoiceComUrl,
+} from '../../support/torqvoice-com'
+
+/**
+ * Plans are sold on torqvoice.com, and this is the app's side of it.
+ *
+ * The site is played by e2e/torqvoice-com-standin.ts, which verifies every
+ * token the way the site does, so landing on its checkout page proves the
+ * handoff was signed with the shared secret, carries this organization and
+ * this buyer, and names this app's origin. The stand-in also refuses the
+ * app on request, which is what a wrong secret looks like: the subscription
+ * page and its navigation entry must then not exist, since a self-hosted
+ * install that copied TORQVOICE_MODE=cloud is in exactly that position.
+ *
+ * A workshop of its own is opened here by signing up, so nothing about the
+ * seeded one changes.
+ */
+
+test.describe.configure({ mode: 'serial', timeout: 180_000 })
+
+const stamp = Date.now()
+const EMAIL = `e2e-billing-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+const WORKSHOP = `E2E Billing Garage ${stamp}`
+const STATE = `e2e/.auth/cloud-billing-${stamp}.json`
+
+let organizationId = ''
+let planId = ''
+
+test.use({ storageState: STATE })
+
+test.beforeAll(async ({ browser }) => {
+  const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const page = await context.newPage()
+  await signUpWithPassword(page, { name: 'E2E Billing Owner', email: EMAIL, password: PASSWORD })
+  await completeOnboarding(page, WORKSHOP, { sampleData: false })
+  await context.storageState({ path: STATE })
+  await context.close()
+
+  organizationId = await organizationIdFor(EMAIL)
+  await clearTorqvoiceComCalls()
+})
+
+test.afterAll(async () => {
+  await setTorqvoiceComLinked(true)
+  if (planId) await removePlan(organizationId, planId)
+})
+
+/** The app's link check is asked again after a second in this harness. */
+async function letTheAppAskAgain(page: import('@playwright/test').Page): Promise<void> {
+  await page.waitForTimeout(1_500)
+  // The first look after the answer may still serve the old one while the
+  // fresh ask completes behind it; a second look sees the new answer.
+  await page.reload()
+  await settle(page)
+  await page.waitForTimeout(500)
+}
+
+test.describe('the link to torqvoice.com', () => {
+  test('hides the subscription page while the site refuses the app', async ({ page }) => {
+    await setTorqvoiceComLinked(false)
+    await page.goto('/settings')
+    await settle(page)
+    await letTheAppAskAgain(page)
+    await page.reload()
+    await settle(page)
+    await expect(page.getByRole('link', { name: 'Subscription' })).toHaveCount(0)
+
+    await page.goto('/settings/subscription')
+    await page.waitForURL((url) => url.pathname === '/settings', { timeout: 15_000 })
+  })
+
+  test('shows it again once the site accepts the app', async ({ page }) => {
+    await setTorqvoiceComLinked(true)
+    await page.goto('/settings')
+    await settle(page)
+    await letTheAppAskAgain(page)
+    await page.reload()
+    await settle(page)
+    await expect(page.getByRole('link', { name: 'Subscription' })).toBeVisible()
+  })
+})
+
+test.describe('buying a plan', () => {
+  test('hands the buyer to torqvoice.com with a signed checkout link', async ({ page }) => {
+    await page.goto('/settings/subscription')
+    await settle(page)
+    await expect(page.getByText('You will be taken to torqvoice.com')).toBeVisible()
+
+    await page.getByRole('button', { name: /Upgrade to Pro/ }).click()
+    await page.waitForURL(`${torqvoiceComUrl()}/checkout?**`, { timeout: 30_000 })
+
+    // The stand-in only renders this page for a token it could verify.
+    await expect(page.getByRole('heading', { name: 'Stand-in checkout' })).toBeVisible()
+    await expect(page.getByTestId('org')).toHaveText(organizationId)
+    await expect(page.getByTestId('plan')).toHaveText('pro')
+    await expect(page.getByTestId('email')).toHaveText(EMAIL)
+    await expect(page.getByTestId('appUrl')).toHaveText(/^http/)
+  })
+
+  test('names this app as the origin of the purchase', async ({ page, baseURL }) => {
+    await page.goto('/settings/subscription')
+    await settle(page)
+    await page
+      .getByRole('button', { name: /Enterprise/ })
+      .first()
+      .click()
+    await page.waitForURL(`${torqvoiceComUrl()}/checkout?**`, { timeout: 30_000 })
+    await expect(page.getByTestId('plan')).toHaveText('enterprise')
+    await expect(page.getByTestId('appUrl')).toHaveText(new URL(baseURL ?? '').origin)
+  })
+})
+
+test.describe('managing a bought plan', () => {
+  test.beforeAll(async () => {
+    planId = await giveProPlan(organizationId, {
+      subscriptionId: `sub_e2e_${stamp}`,
+      customerId: `cus_e2e_${stamp}`,
+    })
+  })
+
+  test('opens the billing portal through torqvoice.com', async ({ page, baseURL }) => {
+    await clearTorqvoiceComCalls()
+    await page.goto('/settings/subscription')
+    await settle(page)
+    await page.getByRole('button', { name: 'Manage Billing' }).click()
+    await page.waitForURL(`${torqvoiceComUrl()}/portal/**`, { timeout: 30_000 })
+    await expect(page.getByTestId('org')).toHaveText(organizationId)
+
+    const { calls } = await torqvoiceComState()
+    const portal = calls.find((c) => c.path === 'portal')
+    expect(portal, 'the app asked torqvoice.com for the portal').toBeTruthy()
+    expect(portal?.authorized).toBe(true)
+    expect(portal?.body).toMatchObject({
+      organizationId,
+      appUrl: new URL(baseURL ?? '').origin,
+    })
+  })
+
+  test('opens the account on torqvoice.com signed in as this person', async ({ page }) => {
+    await page.goto('/settings/subscription')
+    await settle(page)
+    await page.getByRole('button', { name: 'Open your account on torqvoice.com' }).click()
+    await page.waitForURL(`${torqvoiceComUrl()}/api/auth/sso/app-link?**`, { timeout: 30_000 })
+    await expect(page.getByRole('heading', { name: 'Stand-in account' })).toBeVisible()
+    await expect(page.getByTestId('email')).toHaveText(EMAIL)
+    // Signed up with a password and never verified: the site is told so.
+    await expect(page.getByTestId('emailVerified')).toHaveText('false')
+  })
+
+  test('cancels and resumes through torqvoice.com', async ({ page }) => {
+    await clearTorqvoiceComCalls()
+    await page.goto('/settings/subscription')
+    await settle(page)
+    await page.getByRole('button', { name: 'Cancel Subscription' }).click()
+    const dialog = page.getByRole('alertdialog')
+    await expect(dialog).toBeVisible()
+    await dialog.getByRole('button', { name: 'Yes, Cancel' }).click()
+    await expect(page.getByText('Subscription will cancel at end of billing period')).toBeVisible()
+
+    let { calls } = await torqvoiceComState()
+    expect(calls.find((c) => c.path === 'cancel')?.body).toMatchObject({ organizationId })
+
+    // The stand-in writes nothing to the database, so the row is flagged the
+    // way the real site would have flagged it, and the resume is exercised.
+    await setCancelAtPeriodEnd(organizationId, true)
+    await page.reload()
+    await settle(page)
+    await page.getByRole('button', { name: 'Resume Subscription' }).click()
+    await expect(page.getByText('Subscription resumed successfully')).toBeVisible()
+    ;({ calls } = await torqvoiceComState())
+    expect(calls.find((c) => c.path === 'resume')?.body).toMatchObject({ organizationId })
+    await setCancelAtPeriodEnd(organizationId, false)
+  })
+
+  test('shows the site refusing the app as a temporary failure, not as a lost session', async ({
+    page,
+  }) => {
+    await setTorqvoiceComLinked(false)
+    try {
+      await page.goto('/settings/subscription')
+      await settle(page)
+      await page.getByRole('button', { name: 'Manage Billing' }).click()
+      await expect(page.getByText('Billing is temporarily unavailable')).toBeVisible()
+      await expect(page).toHaveURL(/\/settings\/subscription/)
+    } finally {
+      await setTorqvoiceComLinked(true)
+    }
+  })
+})

+ 299 - 0
e2e/specs/condition-map/dropoff.spec.ts

@@ -0,0 +1,299 @@
+import { existsSync } from 'node:fs'
+import path from 'node:path'
+import { expect, test } from '@playwright/test'
+import {
+  addMark,
+  closeEditor,
+  earlierSection,
+  markEditor,
+  openDropoff,
+  ownMarks,
+} from '../../support/condition-map'
+import {
+  conditionMarksOf,
+  ownerOrganizationId,
+  plantConditionJob,
+  plantConditionMark,
+  plantJobOnVehicle,
+  plantOwnMarkKind,
+  removeOwnMarkKind,
+  userIdFor,
+} from '../../support/db'
+import { sharesDiskWithServer } from '../../support/files'
+import { TINY_PNG } from '../../support/pdf'
+
+/**
+ * The drop-off on a work order: the car's condition as it came in, drawn on
+ * the Drop-off tab of the files card. Marks belong to the vehicle, so a mark
+ * from an earlier visit shows grey on the next job, and is cleared there once
+ * it has been repaired.
+ *
+ * Every test plants a car of its own, so nothing another spec or an earlier
+ * run drew is on it.
+ */
+
+const stamp = Date.now()
+let organizationId = ''
+let userId = ''
+
+const DAY = 24 * 60 * 60 * 1000
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  userId = await userIdFor('demo@torqvoice.com')
+})
+
+const jobUrl = (job: { vehicleId: string; serviceRecordId: string }) =>
+  `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`
+
+test('a click on the drawing records a mark, and what is said about it stays', async ({ page }) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E drop-off ${stamp}`)
+  const map = await openDropoff(page, jobUrl(job))
+  await expect(ownMarks(map)).toHaveCount(0)
+
+  const editor = await addMark(page, map, 'left', 'left_front_door')
+  // The click made the mark with the first kind on offer, on the panel clicked.
+  await expect(editor.getByRole('heading')).toContainText('Dent on the Left front door')
+
+  await editor.getByRole('group', { name: 'Type' }).getByRole('button', { name: 'Scratch' }).click()
+  await expect(
+    editor.getByRole('group', { name: 'Type' }).getByRole('button', { name: 'Scratch' })
+  ).toHaveAttribute('aria-pressed', 'true')
+  await editor
+    .getByRole('group', { name: 'Severity' })
+    .getByRole('button', { name: 'Major' })
+    .click()
+  await expect(
+    editor.getByRole('group', { name: 'Severity' }).getByRole('button', { name: 'Major' })
+  ).toHaveAttribute('aria-pressed', 'true')
+  await editor.getByLabel('Note').fill('Long scratch under the handle')
+  await editor.locator('input[type="file"][multiple]').setInputFiles({
+    name: `e2e-scratch-${stamp}.png`,
+    mimeType: 'image/png',
+    buffer: TINY_PNG,
+  })
+  await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(1, {
+    timeout: 30_000,
+  })
+  await closeEditor(page)
+
+  await expect(ownMarks(map)).toHaveCount(1)
+  await expect(ownMarks(map).first()).toHaveAccessibleName(
+    /^Mark 1: Scratch on the Left front door/
+  )
+
+  // What the screen says is what was kept.
+  const [row] = await conditionMarksOf(job.vehicleId)
+  expect(row).toMatchObject({
+    serviceRecordId: job.serviceRecordId,
+    inspectionId: null,
+    view: 'left',
+    panel: 'left_front_door',
+    kind: 'scratch',
+    severity: 'major',
+    note: 'Long scratch under the handle',
+    resolvedAt: null,
+  })
+  expect(row.imageUrls).toHaveLength(1)
+
+  // And a reload draws the same thing, with the tab counting it.
+  const again = await openDropoff(page, jobUrl(job))
+  await expect(ownMarks(again)).toHaveCount(1)
+  await expect(ownMarks(again).first()).toContainText('Long scratch under the handle')
+  await expect(page.getByTestId('condition-map-photos')).toBeVisible()
+})
+
+test('a mark removed is gone from the job and from the vehicle', async ({ page }) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E drop-off remove ${stamp}`)
+  const map = await openDropoff(page, jobUrl(job))
+
+  await addMark(page, map, 'top', 'hood')
+  await closeEditor(page)
+  await addMark(page, map, 'left', 'left_rear_door')
+  await closeEditor(page)
+  await expect(ownMarks(map)).toHaveCount(2)
+
+  await ownMarks(map).first().click()
+  const editor = markEditor(page)
+  await expect(editor).toBeVisible()
+  await editor.getByRole('button', { name: 'Remove mark' }).click()
+  await expect(editor).toBeHidden()
+  await expect(ownMarks(map)).toHaveCount(1)
+
+  expect(await conditionMarksOf(job.vehicleId)).toHaveLength(1)
+  const again = await openDropoff(page, jobUrl(job))
+  await expect(ownMarks(again)).toHaveCount(1)
+  await expect(ownMarks(again).first()).toHaveAccessibleName(/on the Left rear door/)
+})
+
+test.describe('marks from an earlier visit', () => {
+  test('are grey, cannot be changed from here, and clear as repaired', async ({ page }) => {
+    const earlier = await plantConditionJob(
+      organizationId,
+      userId,
+      `E2E earlier visit ${stamp}`,
+      new Date(Date.now() - 30 * DAY)
+    )
+    await plantConditionMark(
+      organizationId,
+      earlier.vehicleId,
+      { serviceRecordId: earlier.serviceRecordId },
+      { view: 'top', panel: 'hood', kind: 'dent', recordedAt: new Date(Date.now() - 30 * DAY) }
+    )
+    // Today's job on the same car.
+    const today = await plantJobOnVehicle(organizationId, earlier.vehicleId, `E2E next ${stamp}`)
+
+    const map = await openDropoff(page, jobUrl(today))
+    await expect(ownMarks(map)).toHaveCount(0)
+    const grey = earlierSection(map)
+    await expect(grey).toContainText('Bonnet')
+
+    // Opened, it is read only: no kind, severity or note to change, no removing it.
+    await grey.getByRole('button', { name: /^Mark 1:/ }).click()
+    const editor = markEditor(page)
+    await expect(editor).toBeVisible()
+    await expect(editor.getByRole('button', { name: 'Scratch' })).toBeDisabled()
+    await expect(editor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
+    await expect(editor).toContainText('Recorded on an earlier visit. Clear it as repaired')
+    await page.keyboard.press('Escape')
+    await expect(editor).toBeHidden()
+
+    await grey.getByRole('button', { name: 'Clear as repaired' }).click()
+    const confirm = page.getByRole('alertdialog', { name: 'Clear as repaired' })
+    await confirm.getByRole('button', { name: 'Clear as repaired' }).click()
+    await expect(confirm).toBeHidden()
+    await expect(earlierSection(map)).toHaveCount(0)
+
+    // Kept in the history, and gone from the page for good.
+    const [row] = await conditionMarksOf(earlier.vehicleId)
+    expect(row.resolvedAt).not.toBeNull()
+    const again = await openDropoff(page, jobUrl(today))
+    await expect(earlierSection(again)).toHaveCount(0)
+  })
+
+  test('only means earlier: a later visit’s mark is not on an older job', async ({ page }) => {
+    const older = await plantConditionJob(
+      organizationId,
+      userId,
+      `E2E older visit ${stamp}`,
+      new Date(Date.now() - 60 * DAY)
+    )
+    await plantConditionMark(
+      organizationId,
+      older.vehicleId,
+      { serviceRecordId: older.serviceRecordId },
+      { view: 'top', panel: 'hood', recordedAt: new Date(Date.now() - 60 * DAY) }
+    )
+    // A later job on the same car found a dent the older one never saw.
+    const later = await plantJobOnVehicle(organizationId, older.vehicleId, `E2E later ${stamp}`)
+    await plantConditionMark(
+      organizationId,
+      older.vehicleId,
+      { serviceRecordId: later.serviceRecordId },
+      { view: 'rear', panel: 'rear_bumper' }
+    )
+
+    const map = await openDropoff(page, jobUrl(older))
+    await expect(ownMarks(map)).toHaveCount(1)
+    await expect(earlierSection(map)).toHaveCount(0)
+    // Not listed anywhere on it: the drawing's own caption still names the panel.
+    await expect(map.getByRole('button', { name: /on the Rear bumper/ })).toHaveCount(0)
+  })
+})
+
+test('a kind of the workshop’s own can be put on a mark', async ({ page }) => {
+  const own = await plantOwnMarkKind(organizationId, `E2E Hail ${stamp}`)
+  try {
+    const job = await plantConditionJob(organizationId, userId, `E2E own kind ${stamp}`)
+    const map = await openDropoff(page, jobUrl(job))
+    const editor = await addMark(page, map, 'top', 'roof')
+    const type = editor.getByRole('group', { name: 'Type' })
+    await type.getByRole('button', { name: own.name }).click()
+    await expect(type.getByRole('button', { name: own.name })).toHaveAttribute(
+      'aria-pressed',
+      'true'
+    )
+    await closeEditor(page)
+    await expect(ownMarks(map).first()).toHaveAccessibleName(new RegExp(own.name))
+
+    const [row] = await conditionMarksOf(job.vehicleId)
+    expect(row.kind).toBe(own.key)
+  } finally {
+    await removeOwnMarkKind(own.id)
+  }
+})
+
+test('a dozen photos at once all reach the mark', async ({ page }) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E many photos ${stamp}`)
+  const map = await openDropoff(page, jobUrl(job))
+  const editor = await addMark(page, map, 'front', 'front_bumper')
+  await editor.locator('input[type="file"][multiple]').setInputFiles(
+    Array.from({ length: 12 }, (_, i) => ({
+      name: `e2e-bumper-${stamp}-${i}.png`,
+      mimeType: 'image/png',
+      buffer: TINY_PNG,
+    }))
+  )
+  await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(12, {
+    timeout: 60_000,
+  })
+  await closeEditor(page)
+  const [row] = await conditionMarksOf(job.vehicleId)
+  expect(row.imageUrls).toHaveLength(12)
+})
+
+test.describe('the photos on a mark, on disk', () => {
+  /** Where the server keeps an uploaded file, from the URL a row stores. */
+  const diskPath = (url: string) =>
+    path.join('data', 'uploads', url.replace('/api/protected/files/', ''))
+
+  async function markWithPhotos(
+    page: import('@playwright/test').Page,
+    label: string,
+    count: number
+  ) {
+    const job = await plantConditionJob(organizationId, userId, `${label} ${stamp}`)
+    const map = await openDropoff(page, jobUrl(job))
+    const editor = await addMark(page, map, 'left', 'left_front_door')
+    await editor.locator('input[type="file"][multiple]').setInputFiles(
+      Array.from({ length: count }, (_, i) => ({
+        name: `e2e-disk-${stamp}-${i}.png`,
+        mimeType: 'image/png',
+        buffer: TINY_PNG,
+      }))
+    )
+    await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(count, {
+      timeout: 30_000,
+    })
+    const [row] = await conditionMarksOf(job.vehicleId)
+    for (const url of row.imageUrls)
+      expect(existsSync(diskPath(url)), `${url} was written`).toBe(true)
+    return { job, map, editor, urls: row.imageUrls }
+  }
+
+  test('a photo taken off a mark leaves the disk', async ({ page }) => {
+    test.skip(!sharesDiskWithServer(), 'the server’s uploads are not on this disk')
+    const { job, editor, urls } = await markWithPhotos(page, 'E2E photo off', 2)
+
+    await editor.getByRole('button', { name: 'Remove photo 1' }).click()
+    await expect(editor.getByRole('img', { name: /^Photos of mark 1/ })).toHaveCount(1)
+    const [row] = await conditionMarksOf(job.vehicleId)
+    const removed = urls.filter((url) => !row.imageUrls.includes(url))
+    expect(removed).toHaveLength(1)
+    await expect.poll(() => existsSync(diskPath(removed[0]))).toBe(false)
+    // The one still on the mark is untouched.
+    expect(existsSync(diskPath(row.imageUrls[0]))).toBe(true)
+  })
+
+  test('a mark removed takes its photos off the disk', async ({ page }) => {
+    test.skip(!sharesDiskWithServer(), 'the server’s uploads are not on this disk')
+    const { job, editor, urls } = await markWithPhotos(page, 'E2E mark off', 2)
+
+    await editor.getByRole('button', { name: 'Remove mark' }).click()
+    await expect(editor).toBeHidden()
+    expect(await conditionMarksOf(job.vehicleId)).toHaveLength(0)
+    for (const url of urls) {
+      await expect.poll(() => existsSync(diskPath(url)), `${url} left the disk`).toBe(false)
+    }
+  })
+})

+ 280 - 0
e2e/specs/condition-map/inspection.spec.ts

@@ -0,0 +1,280 @@
+import { expect, type Page, test } from '@playwright/test'
+import {
+  addMark,
+  certificatePdf,
+  closeEditor,
+  conditionMap,
+  earlierSection,
+  markEditor,
+  openDropoff,
+  ownMarks,
+  printsMap,
+} from '../../support/condition-map'
+import {
+  conditionMarksOf,
+  forgetWorkshopSetting,
+  ownerOrganizationId,
+  plantConditionInspection,
+  plantConditionJob,
+  plantConditionMark,
+  plantJobOnVehicle,
+  setWorkshopSetting,
+  userIdFor,
+  workshopSetting,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+
+/**
+ * The condition map as an inspection check, and how it meets the work order.
+ *
+ * Marks drawn on the inspection are this visit on the job it is linked to
+ * (shown there in colour, changed only on the inspection), and a job's
+ * drop-off marks are grey on the inspection. A completed inspection is a
+ * finished document: its map can no longer be changed, and its certificate
+ * keeps what it said when a mark is later cleared as repaired.
+ *
+ * The certificate only prints the map from a designed certificate, so the
+ * file gives the workshop one and takes it away again afterwards.
+ */
+
+const stamp = Date.now()
+const DESIGN_KEY = 'certificate.layoutConfig'
+let organizationId = ''
+let userId = ''
+let designBefore: string | null = null
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  userId = await userIdFor('demo@torqvoice.com')
+  designBefore = await setWorkshopSetting(
+    organizationId,
+    DESIGN_KEY,
+    JSON.stringify({ version: 2, documentType: 'certificate' })
+  )
+})
+
+test.afterAll(async () => {
+  if (designBefore === null) await forgetWorkshopSetting(organizationId, DESIGN_KEY)
+  else await setWorkshopSetting(organizationId, DESIGN_KEY, designBefore)
+})
+
+const jobUrl = (job: { vehicleId: string; serviceRecordId: string }) =>
+  `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`
+
+async function openInspection(page: Page, inspectionId: string) {
+  await page.goto(`/inspections/${inspectionId}`)
+  await settle(page)
+  const map = conditionMap(page)
+  await expect(map).toBeVisible({ timeout: 30_000 })
+  return map
+}
+
+async function complete(page: Page) {
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Complete', exact: true }).first().click()
+    await expect(page.getByRole('alertdialog', { name: 'Complete this inspection?' })).toBeVisible({
+      timeout: 2_000,
+    })
+  }).toPass({ timeout: 30_000 })
+  const confirm = page.getByRole('alertdialog', { name: 'Complete this inspection?' })
+  await confirm.getByRole('button', { name: 'Complete', exact: true }).click()
+  await expect(page.getByText('Inspection completed').first()).toBeVisible()
+  // Completing offers to share the certificate straight away; not today.
+  const share = page.getByRole('dialog').filter({ hasText: 'Share a read-only view' })
+  const offered = await share
+    .waitFor({ state: 'visible', timeout: 5_000 })
+    .then(() => true)
+    .catch(() => false)
+  if (offered) {
+    await page.keyboard.press('Escape')
+    await expect(share).toBeHidden()
+  }
+}
+
+test('a mark drawn on the inspection is on the linked job, where it cannot be changed', async ({
+  page,
+}) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E linked ${stamp}`)
+  const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`, {
+    serviceRecordId: job.serviceRecordId,
+  })
+
+  const map = await openInspection(page, inspection.inspectionId)
+  const editor = await addMark(page, map, 'right', 'right_front_door')
+  // The inspection offers the workshop's kinds, and names the mark by them.
+  await expect(editor.getByRole('heading')).toContainText('Dent on the Right front door')
+  const kinds = editor.getByRole('group', { name: 'Type' }).getByRole('button')
+  await expect(kinds).toHaveCount(8)
+  await expect(kinds.filter({ hasText: 'Scratch' })).toHaveCount(1)
+  await closeEditor(page)
+  await expect(ownMarks(map)).toHaveCount(1)
+
+  const [row] = await conditionMarksOf(job.vehicleId)
+  expect(row).toMatchObject({
+    inspectionId: inspection.inspectionId,
+    serviceRecordId: null,
+    panel: 'right_front_door',
+  })
+
+  // On the job it is this visit: in colour, said to come from the inspection.
+  const onJob = await openDropoff(page, jobUrl(job))
+  await expect(ownMarks(onJob)).toHaveCount(1)
+  await expect(onJob).toContainText('From the linked inspection, changed there')
+  await ownMarks(onJob).first().click()
+  const onJobEditor = markEditor(page)
+  await expect(onJobEditor.getByRole('button', { name: 'Scratch' })).toBeDisabled()
+  await expect(onJobEditor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
+  // And says where it can be changed, rather than asking to reopen the job.
+  await expect(onJobEditor).toContainText('Drawn on the linked inspection. Change it there.')
+  await expect(onJobEditor).not.toContainText('Reopen to change the condition map')
+})
+
+test('a job’s drop-off mark is grey on the inspection', async ({ page }) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E dropoff grey ${stamp}`)
+  await plantConditionMark(
+    organizationId,
+    job.vehicleId,
+    { serviceRecordId: job.serviceRecordId },
+    { view: 'top', panel: 'hood', recordedAt: new Date(Date.now() - 60_000) }
+  )
+  const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`, {
+    serviceRecordId: job.serviceRecordId,
+  })
+
+  const map = await openInspection(page, inspection.inspectionId)
+  await expect(ownMarks(map)).toHaveCount(0)
+  await expect(earlierSection(map)).toContainText('Bonnet')
+})
+
+test('a second map check on the inspection is the same visit, changed on its own check', async ({
+  page,
+}) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E two checks ${stamp}`)
+  const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`, {
+    secondCheck: true,
+  })
+  await plantConditionMark(
+    organizationId,
+    job.vehicleId,
+    { inspectionId: inspection.inspectionId, inspectionItemId: inspection.secondItemId! },
+    { view: 'rear', panel: 'rear_bumper' }
+  )
+
+  await openInspection(page, inspection.inspectionId)
+  const maps = page.getByTestId('condition-map')
+  await expect(maps).toHaveCount(2)
+  const first = maps.first()
+  // On the first check it is this visit, in colour, said to come from the other check.
+  await expect(ownMarks(first)).toHaveCount(1)
+  await expect(earlierSection(first)).toHaveCount(0)
+  await expect(first).toContainText('From another check on this inspection')
+  await ownMarks(first).first().click()
+  const editor = markEditor(page)
+  await expect(editor).toContainText('Drawn on another check of this inspection. Change it there.')
+  await expect(editor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
+  await page.keyboard.press('Escape')
+
+  // On its own check it can be changed.
+  await ownMarks(maps.last()).first().click()
+  await expect(editor.getByRole('button', { name: 'Remove mark' })).toBeVisible()
+})
+
+test('a completed inspection’s map cannot be changed until it is reopened', async ({ page }) => {
+  const job = await plantConditionJob(organizationId, userId, `E2E completed ${stamp}`)
+  const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`)
+  await plantConditionMark(organizationId, job.vehicleId, inspection, {
+    view: 'left',
+    panel: 'left_rear_door',
+  })
+
+  const map = await openInspection(page, inspection.inspectionId)
+  await complete(page)
+  await expect(map).toContainText('Reopen to change the condition map.')
+
+  // A click on the drawing adds nothing.
+  await map.locator('path[data-view="top"][data-panel="roof"]').click()
+  await expect(markEditor(page)).toBeHidden()
+  expect(await conditionMarksOf(job.vehicleId)).toHaveLength(1)
+})
+
+test.describe('the certificate', () => {
+  test('prints the inspection’s marks', async ({ page }) => {
+    const job = await plantConditionJob(organizationId, userId, `E2E cert ${stamp}`)
+    const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`)
+    await plantConditionMark(organizationId, job.vehicleId, inspection, {
+      view: 'left',
+      panel: 'left_front_door',
+      note: 'Dent by the handle',
+    })
+
+    const pdf = await certificatePdf(page, inspection.inspectionId)
+    expect(printsMap(pdf)).toBe(true)
+    expect(pdf.flat).toContain('Left front door')
+    expect(pdf.flat).toContain('Dent by the handle')
+  })
+
+  test('prints from the default Regulatory design when the workshop designed none', async ({
+    page,
+  }) => {
+    const job = await plantConditionJob(organizationId, userId, `E2E cert default ${stamp}`)
+    const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`)
+    await plantConditionMark(organizationId, job.vehicleId, inspection, {
+      view: 'left',
+      panel: 'left_front_door',
+    })
+    // No certificate design at all: before, this printed the built-in sheet,
+    // which has no condition map.
+    const planted = await workshopSetting(organizationId, DESIGN_KEY)
+    await forgetWorkshopSetting(organizationId, DESIGN_KEY)
+    try {
+      const pdf = await certificatePdf(page, inspection.inspectionId)
+      expect(printsMap(pdf), 'the designed certificate prints the map').toBe(true)
+      expect(pdf.flat).toContain('Left front door')
+    } finally {
+      if (planted) await setWorkshopSetting(organizationId, DESIGN_KEY, planted)
+    }
+  })
+
+  test('prints no map when the inspection recorded no mark of its own', async ({ page }) => {
+    const job = await plantConditionJob(organizationId, userId, `E2E cert bare ${stamp}`)
+    await plantConditionMark(
+      organizationId,
+      job.vehicleId,
+      { serviceRecordId: job.serviceRecordId },
+      { view: 'top', panel: 'hood', recordedAt: new Date(Date.now() - 60_000) }
+    )
+    const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`)
+
+    const pdf = await certificatePdf(page, inspection.inspectionId)
+    expect(printsMap(pdf)).toBe(false)
+    expect(pdf.flat).not.toContain('Bonnet')
+  })
+
+  test('keeps what it said when a mark is later cleared as repaired', async ({ page }) => {
+    const job = await plantConditionJob(organizationId, userId, `E2E cert frozen ${stamp}`)
+    const inspection = await plantConditionInspection(organizationId, job.vehicleId, `E2E ${stamp}`)
+    await plantConditionMark(organizationId, job.vehicleId, inspection, {
+      view: 'left',
+      panel: 'left_front_door',
+      recordedAt: new Date(Date.now() - 60_000),
+    })
+    await openInspection(page, inspection.inspectionId)
+    await complete(page)
+    expect((await certificatePdf(page, inspection.inspectionId)).flat).toContain('Left front door')
+
+    // The next job finds it repaired and clears it: allowed, and the history keeps it.
+    const nextJob = await plantJobOnVehicle(organizationId, job.vehicleId, `E2E next ${stamp}`)
+    const next = await openDropoff(page, jobUrl(nextJob))
+    const grey = earlierSection(next)
+    await grey.getByRole('button', { name: 'Clear as repaired' }).click()
+    await page
+      .getByRole('alertdialog', { name: 'Clear as repaired' })
+      .getByRole('button', { name: 'Clear as repaired' })
+      .click()
+    await expect(earlierSection(next)).toHaveCount(0)
+
+    const after = await certificatePdf(page, inspection.inspectionId)
+    expect(printsMap(after), 'the completed certificate still has its map').toBe(true)
+    expect(after.flat).toContain('Left front door')
+  })
+})

+ 216 - 0
e2e/specs/condition-map/printing.spec.ts

@@ -0,0 +1,216 @@
+import { expect, test } from '@playwright/test'
+import {
+  addMark,
+  closeEditor,
+  invoicePdf,
+  openDropoff,
+  printsMap,
+  workOrderPdf,
+} from '../../support/condition-map'
+import {
+  conditionMapOnInvoice,
+  ownerOrganizationId,
+  plantConditionInspection,
+  plantConditionJob,
+  plantConditionMark,
+  plantJobOnVehicle,
+  userIdFor,
+} from '../../support/db'
+import { shareLink } from '../../support/work-order'
+
+/**
+ * Where the condition map prints, and what it prints there.
+ *
+ * - The work order prints the job's own marks, earlier ones grey beside them,
+ *   and nothing at all when the job recorded none of its own.
+ * - The invoice prints this visit only (the drop-off and the linked
+ *   inspection), is off unless the design or the job's own switch turns it on,
+ *   and once sent it is the copy the customer holds: a later change on the job
+ *   reaches it only when it is sent again.
+ *
+ * Read from the PDFs themselves, as text. Panel names are the app's English
+ * ("Bonnet" for the hood).
+ */
+
+const stamp = Date.now()
+const DAY = 24 * 60 * 60 * 1000
+let organizationId = ''
+let userId = ''
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  userId = await userIdFor('demo@torqvoice.com')
+})
+
+const jobUrl = (job: { vehicleId: string; serviceRecordId: string }) =>
+  `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`
+
+/** A car with a dent on the bonnet from a visit a month ago, and today's job on it. */
+async function carWithHistory(label: string) {
+  const earlier = await plantConditionJob(
+    organizationId,
+    userId,
+    `${label} earlier`,
+    new Date(Date.now() - 30 * DAY)
+  )
+  await plantConditionMark(
+    organizationId,
+    earlier.vehicleId,
+    { serviceRecordId: earlier.serviceRecordId },
+    { view: 'top', panel: 'hood', recordedAt: new Date(Date.now() - 30 * DAY) }
+  )
+  const today = await plantJobOnVehicle(organizationId, earlier.vehicleId, label)
+  return today
+}
+
+test.describe('the work order', () => {
+  test('prints the job’s own marks, with earlier ones marked as such', async ({ page }) => {
+    const job = await carWithHistory(`E2E WO print ${stamp}`)
+    await plantConditionMark(
+      organizationId,
+      job.vehicleId,
+      { serviceRecordId: job.serviceRecordId },
+      { view: 'left', panel: 'left_front_door', kind: 'scratch', note: 'Keyed along the door' }
+    )
+
+    const pdf = await workOrderPdf(page, job.serviceRecordId)
+    expect(printsMap(pdf), 'the section is there').toBe(true)
+    expect(pdf.flat).toContain('Left front door')
+    expect(pdf.flat).toContain('Keyed along the door')
+    expect(pdf.flat).toContain('Bonnet (recorded earlier)')
+  })
+
+  test('prints nothing when the job recorded no mark of its own', async ({ page }) => {
+    const job = await carWithHistory(`E2E WO bare ${stamp}`)
+    const pdf = await workOrderPdf(page, job.serviceRecordId)
+    expect(printsMap(pdf)).toBe(false)
+    expect(pdf.flat).not.toContain('Bonnet')
+  })
+
+  test('prints no mark from a later visit on an older job', async ({ page }) => {
+    const older = await plantConditionJob(
+      organizationId,
+      userId,
+      `E2E WO older ${stamp}`,
+      new Date(Date.now() - 60 * DAY)
+    )
+    await plantConditionMark(
+      organizationId,
+      older.vehicleId,
+      { serviceRecordId: older.serviceRecordId },
+      { view: 'left', panel: 'left_front_door', recordedAt: new Date(Date.now() - 60 * DAY) }
+    )
+    const later = await plantJobOnVehicle(organizationId, older.vehicleId, `E2E WO later ${stamp}`)
+    await plantConditionMark(
+      organizationId,
+      older.vehicleId,
+      { serviceRecordId: later.serviceRecordId },
+      { view: 'rear', panel: 'rear_bumper' }
+    )
+
+    const pdf = await workOrderPdf(page, older.serviceRecordId)
+    expect(printsMap(pdf)).toBe(true)
+    expect(pdf.flat).toContain('Left front door')
+    expect(pdf.flat, 'a dent found later was not there when this job was done').not.toContain(
+      'Rear bumper'
+    )
+  })
+})
+
+test.describe('the invoice', () => {
+  test('leaves the map off until the job’s switch puts it on, and then prints this visit only', async ({
+    page,
+  }) => {
+    const job = await carWithHistory(`E2E invoice map ${stamp}`)
+    await plantConditionMark(
+      organizationId,
+      job.vehicleId,
+      { serviceRecordId: job.serviceRecordId },
+      { view: 'left', panel: 'left_front_door' }
+    )
+    // The linked inspection's marks are this visit too.
+    const inspection = await plantConditionInspection(
+      organizationId,
+      job.vehicleId,
+      `E2E ${stamp}`,
+      {
+        serviceRecordId: job.serviceRecordId,
+      }
+    )
+    await plantConditionMark(organizationId, job.vehicleId, inspection, {
+      view: 'top',
+      panel: 'roof',
+    })
+
+    expect(printsMap(await invoicePdf(page, job.serviceRecordId)), 'off by default').toBe(false)
+
+    await openDropoff(page, jobUrl(job))
+    const onInvoice = page.getByTestId('condition-map-on-invoice')
+    await expect(onInvoice).not.toBeChecked()
+    await expect(async () => {
+      await onInvoice.click()
+      await expect(onInvoice).toBeChecked({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await expect.poll(() => conditionMapOnInvoice(job.serviceRecordId)).toBe(true)
+
+    const pdf = await invoicePdf(page, job.serviceRecordId)
+    expect(printsMap(pdf)).toBe(true)
+    expect(pdf.flat).toContain('Left front door')
+    expect(pdf.flat, 'the linked inspection’s mark').toContain('Roof')
+    expect(pdf.flat, 'an earlier visit is not billed here').not.toContain('Bonnet')
+    expect(pdf.flat).not.toContain('recorded earlier')
+  })
+
+  test('once sent, keeps the map it was sent with until it is sent again', async ({ page }) => {
+    const job = await plantConditionJob(organizationId, userId, `E2E sent map ${stamp}`)
+    await plantConditionMark(
+      organizationId,
+      job.vehicleId,
+      { serviceRecordId: job.serviceRecordId },
+      { view: 'left', panel: 'left_front_door' }
+    )
+
+    await openDropoff(page, jobUrl(job))
+    const onInvoice = page.getByTestId('condition-map-on-invoice')
+    await expect(async () => {
+      await onInvoice.click()
+      await expect(onInvoice).toBeChecked({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await expect.poll(() => conditionMapOnInvoice(job.serviceRecordId)).toBe(true)
+
+    // Sent: the share link issues the invoice.
+    await shareLink(page)
+    await page.keyboard.press('Escape')
+
+    // A dent found after it went out is the job's, not the sent invoice's.
+    const again = await openDropoff(page, jobUrl(job))
+    await addMark(page, again, 'top', 'trunk')
+    await closeEditor(page)
+    const sent = await invoicePdf(page, job.serviceRecordId)
+    expect(printsMap(sent)).toBe(true)
+    expect(sent.flat).toContain('Left front door')
+    expect(sent.flat, 'added after it was sent').not.toContain('Boot lid')
+
+    // The switch stays usable, and says where its change will land.
+    await expect(onInvoice).toBeEnabled()
+    await expect(page.getByTestId('sent-copy-note')).toContainText(
+      'Changes here go on the invoice when it is sent again'
+    )
+
+    // Unticked on the sent invoice, it asks first: the sent copy keeps its map.
+    const asked = page.getByRole('alertdialog', { name: 'This invoice has already been sent' })
+    await onInvoice.click()
+    await expect(asked).toBeVisible()
+    await asked.getByRole('button', { name: 'Cancel' }).click()
+    await expect(asked).toBeHidden()
+    await expect(onInvoice).toBeChecked()
+    expect(await conditionMapOnInvoice(job.serviceRecordId)).toBe(true)
+
+    await onInvoice.click()
+    await asked.getByRole('button', { name: 'Save for the next send' }).click()
+    await expect(onInvoice).not.toBeChecked()
+    await expect.poll(() => conditionMapOnInvoice(job.serviceRecordId)).toBe(false)
+    // Saved for the job; the invoice the customer holds still has its map.
+    expect(printsMap(await invoicePdf(page, job.serviceRecordId))).toBe(true)
+  })
+})

+ 167 - 0
e2e/specs/files/deletion.spec.ts

@@ -0,0 +1,167 @@
+import { expect, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+import {
+  ownerOrganizationId,
+  type PlantedVehicle,
+  plantVehicleWithFiles,
+  removePlantedVehicle,
+  userIdFor,
+} from '../../support/db'
+import {
+  onDisk,
+  type PlantedFile,
+  plantFile,
+  removePlanted,
+  sharesDiskWithServer,
+} from '../../support/files'
+
+/**
+ * What deleting something does to the files on disk. Every delete goes
+ * through the file manager (src/lib/files/manager.ts), which removes a file
+ * only once no row points at it, only inside the workshop's own folder, and
+ * only after the rows are gone. These specs plant real files and the rows
+ * that use them, delete through the page the way a workshop does, and read
+ * the disk:
+ *
+ * - what went with the deleted rows is gone;
+ * - what something else still uses stays: a tire set's photo copied onto a
+ *   job (the set outlives the job and the vehicle), a quote's document (a
+ *   quote outlives its vehicle);
+ * - another workshop's file stays, whatever URL a row holds.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+let organizationId = ''
+let userId = ''
+const foreignOrg = `e2e-foreign-${stamp}`
+const planted: PlantedVehicle[] = []
+const files: PlantedFile[] = []
+
+async function plantAll(label: string, withInspection = true) {
+  const f = {
+    vehicleImage: await plantFile(organizationId, 'vehicles'),
+    jobPhoto: await plantFile(organizationId, 'services'),
+    tireSetPhoto: await plantFile(organizationId, 'tire-hotel'),
+    statusVideo: await plantFile(organizationId, 'services', 'mp4'),
+    inspectionPhoto: await plantFile(organizationId, 'services'),
+    quoteDocument: await plantFile(organizationId, 'quotes', 'pdf'),
+    foreignPhoto: await plantFile(foreignOrg, 'services'),
+  }
+  files.push(...Object.values(f))
+  const vehicle = await plantVehicleWithFiles(
+    organizationId,
+    userId,
+    {
+      vehicleImage: f.vehicleImage.url,
+      jobPhoto: f.jobPhoto.url,
+      tireSetPhoto: f.tireSetPhoto.url,
+      statusVideo: f.statusVideo.url,
+      inspectionPhoto: withInspection ? f.inspectionPhoto.url : undefined,
+      quoteDocument: f.quoteDocument.url,
+      foreignPhoto: f.foreignPhoto.url,
+    },
+    `${label} ${stamp}`
+  )
+  planted.push(vehicle)
+  return { f, vehicle }
+}
+
+async function expectGone(file: PlantedFile, what: string) {
+  await expect.poll(() => onDisk(file), { message: `${what} is deleted` }).toBe(false)
+}
+
+function expectKept(file: PlantedFile, what: string) {
+  expect(onDisk(file), `${what} is kept`).toBe(true)
+}
+
+async function confirmIn(page: Page, dialogTitle: RegExp) {
+  const dialog = page.getByRole('alertdialog', { name: dialogTitle })
+  await expect(dialog).toBeVisible()
+  await dialog.getByRole('button', { name: 'Delete', exact: true }).click()
+}
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  userId = await userIdFor('demo@torqvoice.com')
+})
+
+test.beforeEach(() => {
+  test.skip(!sharesDiskWithServer(), 'the suite does not share a disk with the app server')
+})
+
+test.afterAll(async () => {
+  for (const vehicle of planted) await removePlantedVehicle(vehicle).catch(() => undefined)
+  await removePlanted(files)
+})
+
+test('deleting a vehicle removes its files and keeps what outlives it', async ({ page }) => {
+  const { f, vehicle } = await plantAll('E2E files vehicle')
+  for (const file of Object.values(f)) expect(onDisk(file)).toBe(true)
+
+  await page.goto(`/vehicles/${vehicle.vehicleId}`)
+  await settle(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Open menu' }).click()
+    await page.getByRole('menuitem', { name: 'Delete' }).click({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await confirmIn(page, /Delete vehicle/)
+  await page.waitForURL((url) => !url.pathname.includes(vehicle.vehicleId))
+
+  // Went with the vehicle.
+  await expectGone(f.vehicleImage, "the vehicle's image")
+  await expectGone(f.jobPhoto, "its job's photo")
+  await expectGone(f.statusVideo, "its job's status report video")
+  if (vehicle.inspected) await expectGone(f.inspectionPhoto, "its inspection's photo")
+
+  // Still used: the tire set and the quote are not deleted with the vehicle.
+  expectKept(f.tireSetPhoto, "the tire set's photo, which was also on the job")
+  expectKept(f.quoteDocument, "the quote's document")
+  // Not this workshop's to delete, whatever the job's row said.
+  expectKept(f.foreignPhoto, "another workshop's file")
+})
+
+test("deleting a work order keeps the tire set's photo that was on it", async ({ page }) => {
+  const { f, vehicle } = await plantAll('E2E files job', false)
+
+  await page.goto(`/vehicles/${vehicle.vehicleId}/service/${vehicle.serviceRecordId}`)
+  await settle(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'More actions' }).click()
+    await page.getByRole('menuitem', { name: 'Delete' }).click({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await confirmIn(page, /Delete Service Record/)
+  await page.waitForURL((url) => !url.pathname.includes(vehicle.serviceRecordId))
+
+  await expectGone(f.jobPhoto, "the job's own photo")
+  await expectGone(f.statusVideo, "the job's status report video")
+  // This is the file the old delete took from the tire set.
+  expectKept(f.tireSetPhoto, "the tire set's photo")
+  expectKept(f.vehicleImage, "the vehicle's image")
+  expectKept(f.foreignPhoto, "another workshop's file")
+})
+
+test("deleting a photo on the work order removes its file, and a tire set's copy leaves it", async ({
+  page,
+}) => {
+  const { f, vehicle } = await plantAll('E2E files photo', false)
+
+  await page.goto(`/vehicles/${vehicle.vehicleId}/service/${vehicle.serviceRecordId}`)
+  await settle(page)
+  const tiles = page.getByTestId('files-media').getByTestId('media-tile')
+  const tileOf = (file: PlantedFile) =>
+    tiles.filter({ has: page.locator(`img[src="${file.url}"]`) })
+
+  await expect(tileOf(f.jobPhoto)).toHaveCount(1)
+  await tileOf(f.jobPhoto).getByRole('button', { name: 'Delete' }).click()
+  await expect(tileOf(f.jobPhoto)).toHaveCount(0)
+  await expectGone(f.jobPhoto, 'the deleted photo')
+
+  // The tire set's photo is on the job as a copy; deleting the copy leaves
+  // the set its file.
+  await expect(tileOf(f.tireSetPhoto)).toHaveCount(1)
+  await tileOf(f.tireSetPhoto).getByRole('button', { name: 'Delete' }).click()
+  await expect(tileOf(f.tireSetPhoto)).toHaveCount(0)
+  expectKept(f.tireSetPhoto, "the tire set's photo")
+})

+ 18 - 5
e2e/specs/invoices/designer-drag.spec.ts

@@ -211,23 +211,36 @@ test.describe('dragging in the designer', () => {
     const [paymentBefore, partsBefore] = await printedOrder(page, ['PAYMENT INFORMATION', 'Parts'])
     expect(paymentBefore).toBeGreaterThan(partsBefore)
 
+    // Both rows have to be on screen before the drag starts. The rail
+    // scrolls, and Playwright's drag scrolls the target into view mid-drag,
+    // which takes the dragged row out of it and drops it on nothing; a
+    // person's browser would scroll the rail as the pointer nears its edge.
+    const parts = page.getByTestId('rail-parts_table')
+    const payment = page.getByTestId('rail-bank_account')
+    await parts.evaluate((el) => el.scrollIntoView({ block: 'start' }))
+    await expect(parts).toBeInViewport()
+    await expect(payment).toBeInViewport()
+
     await expect(async () => {
-      await page.getByTestId('rail-bank_account').dragTo(page.getByTestId('rail-parts_table'))
+      await payment.dragTo(parts)
       const positionOf = (id: string) =>
         page
           .getByTestId(`rail-${id}`)
           .evaluate((el) => Array.from(el.parentElement?.children ?? []).indexOf(el))
-      const [payment, parts] = await Promise.all([
+      const [paymentAt, partsAt] = await Promise.all([
         positionOf('bank_account'),
         positionOf('parts_table'),
       ])
-      expect(payment, 'the payment panel sits above the parts in the rail').toBeLessThan(parts)
+      expect(paymentAt, 'the payment panel sits above the parts in the rail').toBeLessThan(partsAt)
     }).toPass({ timeout: 30_000 })
 
     await saveDesign(page)
 
-    const [payment, parts] = await printedOrder(page, ['PAYMENT INFORMATION', 'Parts'])
-    expect(payment, 'and above them on the sheet').toBeLessThan(parts)
+    const [paymentPrinted, partsPrinted] = await printedOrder(page, [
+      'PAYMENT INFORMATION',
+      'Parts',
+    ])
+    expect(paymentPrinted, 'and above them on the sheet').toBeLessThan(partsPrinted)
   })
 
   test('a block dragged across the canvas is left where it was put', async ({ page }) => {

+ 26 - 35
e2e/specs/invoices/payments.spec.ts

@@ -25,32 +25,24 @@ test.describe.configure({ mode: 'serial' })
 
 const stamp = Date.now()
 
-/** The payments box on the work order, and not the badge in the page header. */
+/** The invoice card's payments: what came in, the balance, and the form. */
 function paymentsPanel(page: Page): Locator {
-  return page
-    .getByRole('heading', { name: 'Payments', exact: true })
-    .locator('xpath=ancestor::div[contains(@class,"rounded-lg")][1]')
+  return page.getByTestId('payments-section')
 }
 
-/** The figure beside a label, in whichever panel the label belongs to. */
-function labelledRow(panel: Locator, label: string): Locator {
-  return panel
-    .getByText(label, { exact: true })
-    .first()
-    .locator('xpath=ancestor::div[contains(@class,"justify-between")][1]')
+/** What the invoice card calls the payment state: Unpaid, Partial or Paid. */
+function paymentBadge(page: Page, state: 'Unpaid' | 'Partial' | 'Paid'): Locator {
+  return page.getByTestId('payment-status').filter({ hasText: new RegExp(`^${state}$`) })
 }
 
-/** One line of the Invoice Summary panel: what the customer's copy will say. */
-function summaryRow(page: Page, label: string): Locator {
-  const panel = page
-    .getByRole('heading', { name: 'Invoice Summary', exact: true })
-    .locator('xpath=ancestor::div[1]')
-  return labelledRow(panel, label)
+/** The money bar along the bottom: the total, what is paid, and the balance. */
+function moneyBar(page: Page, figure: 'total' | 'paid' | 'balance'): Locator {
+  return page.getByTestId('money-bar').getByTestId(`money-${figure}`)
 }
 
-/** What the panel calls the payment state: Unpaid, Partial or Paid. */
-function paymentBadge(page: Page, state: 'Unpaid' | 'Partial' | 'Paid'): Locator {
-  return paymentsPanel(page).getByText(state, { exact: true })
+/** One payment in the list, found by its amount. */
+function paymentRow(page: Page, amount: string): Locator {
+  return paymentsPanel(page).getByRole('listitem').filter({ hasText: amount })
 }
 
 /**
@@ -111,10 +103,10 @@ test.describe('paying an invoice', () => {
     await page.goto(jobUrl)
 
     await expect(paymentBadge(page, 'Unpaid')).toBeVisible()
-    await expect(labelledRow(paymentsPanel(page), 'Total Paid')).toContainText('$0.00 / $900.00')
-    // Nothing is owed until something is paid, so the summary shows no balance.
-    await expect(summaryRow(page, 'Total')).toContainText('$900.00')
-    await expect(summaryRow(page, 'Balance Due')).toHaveCount(0)
+    await expect(moneyBar(page, 'total')).toHaveText('$900.00')
+    await expect(moneyBar(page, 'paid')).toHaveText('$0.00')
+    await expect(moneyBar(page, 'balance')).toHaveText('$900.00')
+    await expect(paymentsPanel(page)).toContainText('Nothing paid yet.')
   })
 
   test('part of the money leaves a balance', async ({ page }) => {
@@ -122,15 +114,15 @@ test.describe('paying an invoice', () => {
     await recordPayment(page, 400, 'Cash')
 
     await expect(paymentBadge(page, 'Partial')).toBeVisible()
-    await expect(labelledRow(paymentsPanel(page), 'Total Paid')).toContainText('$400.00 / $900.00')
+    await expect(moneyBar(page, 'paid')).toHaveText('$400.00')
 
     // The payment itself is listed, with the method it came in by.
-    const row = paymentsPanel(page).getByRole('row').filter({ hasText: '$400.00' })
+    const row = paymentRow(page, '$400.00')
     await expect(row).toHaveCount(1)
     await expect(row).toContainText('cash')
 
-    await expect(summaryRow(page, 'Paid')).toContainText('-$400.00')
-    await expect(summaryRow(page, 'Balance Due')).toContainText('$500.00')
+    await expect(moneyBar(page, 'balance')).toHaveText('$500.00')
+    await expect(paymentsPanel(page).getByTestId('balance-due')).toHaveText('$500.00')
   })
 
   test('the rest of it settles the invoice', async ({ page }) => {
@@ -138,9 +130,8 @@ test.describe('paying an invoice', () => {
     await recordPayment(page, 500, 'Card')
 
     await expect(paymentBadge(page, 'Paid')).toBeVisible()
-    await expect(labelledRow(paymentsPanel(page), 'Total Paid')).toContainText('$900.00 / $900.00')
-    // Settled, the balance line stops being a figure and says so.
-    await expect(summaryRow(page, 'Balance Due')).toContainText('PAID')
+    await expect(moneyBar(page, 'paid')).toHaveText('$900.00')
+    await expect(paymentsPanel(page).getByTestId('balance-due')).toHaveText('$0.00')
 
     // Money against a job makes the invoice the customer's document, whether
     // or not it was ever sent, so it carries a number from here on.
@@ -150,7 +141,7 @@ test.describe('paying an invoice', () => {
   test('taking a payment back reopens the balance', async ({ page }) => {
     await page.goto(jobUrl)
 
-    const row = paymentsPanel(page).getByRole('row').filter({ hasText: '$500.00' })
+    const row = paymentRow(page, '$500.00')
     const confirm = page.getByRole('alertdialog', { name: 'Delete Payment' })
     // A click before the page is interactive opens nothing and says nothing.
     await expect(async () => {
@@ -161,7 +152,7 @@ test.describe('paying an invoice', () => {
     await expect(page.getByText('Payment deleted', { exact: true })).toBeVisible()
 
     await expect(paymentBadge(page, 'Partial')).toBeVisible()
-    await expect(summaryRow(page, 'Balance Due')).toContainText('$500.00')
+    await expect(paymentsPanel(page).getByTestId('balance-due')).toHaveText('$500.00')
   })
 
   test('the workshop can declare it paid without a payment', async ({ page }) => {
@@ -179,15 +170,15 @@ test.describe('paying an invoice', () => {
     // Declared paid covers the balance; the payment that was actually taken is
     // still the only row in the table.
     await expect(paymentBadge(page, 'Paid')).toBeVisible()
-    await expect(labelledRow(panel, 'Total Paid')).toContainText('$900.00 / $900.00')
-    await expect(panel.getByRole('row').filter({ hasText: '$400.00' })).toHaveCount(1)
+    await expect(paymentsPanel(page).getByTestId('balance-due')).toHaveText('$0.00')
+    await expect(paymentRow(page, '$400.00')).toHaveCount(1)
 
     await panel.getByRole('button', { name: 'Mark as Unpaid', exact: true }).click()
     await expect(page.getByText('Marked as unpaid', { exact: true })).toBeVisible()
 
     // Back to what was really paid, rather than to nothing.
     await expect(paymentBadge(page, 'Partial')).toBeVisible()
-    await expect(labelledRow(panel, 'Total Paid')).toContainText('$400.00 / $900.00')
+    await expect(paymentsPanel(page).getByTestId('balance-due')).toHaveText('$500.00')
   })
 
   test('the tax settings are put back', async ({ page }) => {

+ 24 - 20
e2e/specs/invoices/pdf-attachments.spec.ts

@@ -77,7 +77,7 @@ test.describe('a job with files attached to it', () => {
 
   test('a photograph gets a page of its own', async ({ page }) => {
     await page.goto(jobUrl)
-    await attach(page, 'Images', { name: PHOTO, mimeType: 'image/png', buffer: TINY_PNG })
+    await attach(page, 'Photos', { name: PHOTO, mimeType: 'image/png', buffer: TINY_PNG })
 
     const pdf = await workshopCopy(page)
     expect(pdf.pages).toBe(barePages + REPORT_PAGES.length + 1)
@@ -101,35 +101,39 @@ test.describe('a job with files attached to it', () => {
   test('a file kept off the invoice stays off it', async ({ page }) => {
     await page.goto(jobUrl)
 
-    /** The documents list, and the row of the report inside it. */
-    const openReportRow = async () => {
+    /** The report's tile on the files card's documents tab. */
+    const openReportTile = async () => {
+      const files = page.getByTestId('files-media')
       await expect(async () => {
-        await page.getByRole('button', { name: /^Documents/ }).click()
-        await expect(page.getByText(REPORT).first()).toBeVisible({ timeout: 2_000 })
+        await files.getByRole('tab', { name: /^Documents/ }).click()
+        await expect(files.getByLabel(REPORT).first()).toBeVisible({ timeout: 2_000 })
       }).toPass({ timeout: 30_000 })
-      return page
-        .getByText(REPORT)
-        .first()
-        .locator('xpath=ancestor::div[.//button[@role="switch"]][1]')
+      return files.getByTestId('media-tile').filter({ has: page.getByLabel(REPORT) })
     }
 
-    // Each attachment carries a switch for whether it prints. Clicked until
-    // it turns: before the page is interactive the click does nothing at all,
-    // and the switch looks exactly the same either way.
-    const toggle = (await openReportRow()).getByRole('switch')
+    // Each attachment carries an eye for whether the customer sees it, which
+    // is whether it prints. Clicked until it turns: before the page is
+    // interactive the click does nothing at all, and looks the same.
+    const toggle = (await openReportTile()).getByRole('button', {
+      name: 'Hide from the customer',
+    })
     await expect(async () => {
       await toggle.click()
-      await expect(toggle).toHaveAttribute('aria-checked', 'false', { timeout: 2_000 })
+      await expect(
+        (await openReportTile()).getByRole('button', { name: 'Show to the customer' })
+      ).toBeVisible({ timeout: 2_000 })
     }).toPass({ timeout: 30_000 })
 
     // It turns before the write lands, so the answer is read back from the
     // server rather than from the screen: asked any sooner, the PDF is built
-    // from rows the click has not reached yet.
+    // from rows the click has not reached yet. The write is waited for first,
+    // because a reload while it is still in flight cancels it, and on a busy
+    // machine the switch then comes back on.
+    await page.waitForLoadState('networkidle')
     await page.reload()
-    await expect((await openReportRow()).getByRole('switch')).toHaveAttribute(
-      'aria-checked',
-      'false'
-    )
+    await expect(
+      (await openReportTile()).getByRole('button', { name: 'Show to the customer' })
+    ).toBeVisible()
 
     const pdf = await workshopCopy(page)
     expect(pdf.pages, 'the appended pages are gone').toBe(barePages + 1)
@@ -142,7 +146,7 @@ test.describe('a job with files attached to it', () => {
     const url = await newWorkOrder(page, vehicleUrl, `E2E broken image ${stamp}`)
     await addPart(page, { name: `E2E hose ${stamp}`, quantity: 1, unitPrice: 120 })
     await saveWorkOrder(page)
-    await attach(page, 'Images', {
+    await attach(page, 'Photos', {
       name: 'e2e-broken.png',
       mimeType: 'image/png',
       buffer: BROKEN_PNG,

+ 152 - 0
e2e/specs/notifications/links.spec.ts

@@ -0,0 +1,152 @@
+import { expect, type Page, test } from '@playwright/test'
+import {
+  deleteMessagesWithBody,
+  deleteNotifications,
+  jobWithCustomer,
+  linkTelegramChat,
+  ownerOrganizationId,
+  type PlantedNotification,
+  plantInboundMessage,
+  plantNotification,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+
+/**
+ * Every notification lands on the thing it is about.
+ *
+ * An audit of the bell (15 Sep 2026) found links that opened the right page
+ * and showed nothing: an inbound SMS, Telegram or WhatsApp message opened the
+ * inbox with no conversation selected, a customer's payment opened the
+ * vehicle instead of the invoice, and feedback on a status report did not
+ * switch tab when the job was already open.
+ *
+ * The notifications are planted with the address the code builds, and in the
+ * old shape where the pages now have to honour links already stored in
+ * people's bells. The triggers themselves (a Twilio webhook, a Telegram
+ * update) need providers the harness cannot play.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const planted: string[] = []
+const bodies: string[] = []
+let organizationId = ''
+let job: Awaited<ReturnType<typeof jobWithCustomer>>
+/** The customer's Telegram chat before the spec linked one, put back afterwards. */
+let previousTelegramChat: string | null | undefined
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  job = await jobWithCustomer(organizationId)
+})
+
+test.afterAll(async () => {
+  await deleteNotifications(planted)
+  if (previousTelegramChat !== undefined) {
+    await linkTelegramChat(job.customerId, previousTelegramChat)
+  }
+  for (const body of bodies) await deleteMessagesWithBody(body)
+})
+
+async function plant(notification: PlantedNotification): Promise<void> {
+  planted.push(await plantNotification(organizationId, notification))
+}
+
+/** Opens the bell and clicks the notification with this title. */
+async function openFromBell(page: Page, title: string): Promise<void> {
+  const panel = page.getByRole('dialog', { name: 'Notifications' })
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Open notifications' }).click()
+    await expect(panel).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await panel.getByRole('button').filter({ hasText: title }).first().click()
+  await expect(panel).toBeHidden()
+}
+
+test('an inbound SMS opens that conversation, not an empty inbox', async ({ page }) => {
+  const body = `E2E SMS from the customer ${stamp}`
+  const title = `E2E new SMS ${stamp}`
+  bodies.push(body)
+  await plantInboundMessage('sms', organizationId, job.customerId, body)
+  await plant({
+    type: 'sms_inbound',
+    title,
+    message: `${job.customerName}: ${body}`,
+    entityType: 'sms_message',
+    entityId: job.customerId,
+    entityUrl: `/messages?customerId=${job.customerId}`,
+  })
+
+  await page.goto('/')
+  await settle(page)
+  await openFromBell(page, title)
+
+  await page.waitForURL(/\/messages\?customerId=/)
+  await expect(page.getByText('Pick a conversation')).toHaveCount(0)
+  // Once in the list's preview and once in the open conversation.
+  await expect(page.getByText(body).filter({ visible: true })).toHaveCount(2)
+})
+
+test('a Telegram message stored with the old link opens its conversation', async ({ page }) => {
+  const body = `E2E Telegram from the customer ${stamp}`
+  const title = `E2E new Telegram ${stamp}`
+  bodies.push(body)
+  previousTelegramChat = await linkTelegramChat(job.customerId, '777000')
+  await plantInboundMessage('telegram', organizationId, job.customerId, body)
+  await plant({
+    type: 'telegram_inbound',
+    title,
+    message: `${job.customerName}: ${body}`,
+    entityType: 'telegram_message',
+    entityId: job.customerId,
+    entityUrl: `/messages?tab=telegram&customerId=${job.customerId}`,
+  })
+
+  await page.goto('/')
+  await settle(page)
+  await openFromBell(page, title)
+
+  await page.waitForURL(/\/messages\?tab=telegram/)
+  await expect(page.getByText('Pick a conversation')).toHaveCount(0)
+  await expect(page.getByText(body).filter({ visible: true })).toHaveCount(2)
+})
+
+test('a payment stored with the old vehicle link opens the invoice', async ({ page }) => {
+  const title = `E2E payment received ${stamp}`
+  await plant({
+    type: 'invoice_payment',
+    title,
+    message: `${job.customerName} paid 100.00`,
+    entityType: 'invoice',
+    entityId: job.serviceRecordId,
+    entityUrl: `/vehicles/${job.vehicleId}?tab=service&record=${job.serviceRecordId}`,
+  })
+
+  await page.goto('/')
+  await settle(page)
+  await openFromBell(page, title)
+
+  await page.waitForURL(`**/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`)
+})
+
+test('feedback on a status report switches the open job to its reports', async ({ page }) => {
+  const title = `E2E status report feedback ${stamp}`
+  await plant({
+    type: 'status_report_feedback',
+    title,
+    message: `${job.customerName} responded to the status report`,
+    entityType: 'ServiceRecord',
+    entityId: job.serviceRecordId,
+    entityUrl: `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}?tab=statusReports`,
+  })
+
+  // The job is already open on its details when the notification is clicked.
+  await page.goto(`/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`)
+  await settle(page)
+  await expect(page.getByRole('button', { name: 'New Status Report' })).toHaveCount(0)
+  await openFromBell(page, title)
+
+  await page.waitForURL(/tab=statusReports/)
+  await expect(page.getByRole('button', { name: 'New Status Report' })).toBeVisible()
+})

+ 97 - 0
e2e/specs/notifications/quote-response.spec.ts

@@ -0,0 +1,97 @@
+import { type Browser, expect, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+import { addQuotePart, newQuote, quoteShareLink, saveQuote } from '../../support/quote'
+
+/**
+ * A customer's answer to a quote, followed from the bell to the quote.
+ *
+ * A workshop reported that clicking "Quote Changes Requested" in the
+ * notification panel did nothing, when they expected the quote to open with
+ * the customer's request on it. Both ways a desk meets the notification are
+ * walked: opened later from another page, and arriving live while the quote
+ * itself is already on screen.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+/** A shared quote the customer can answer, open in the editor. */
+async function sharedQuote(page: Page, title: string) {
+  const quoteUrl = await newQuote(page, title)
+  await addQuotePart(page, { name: `E2E notification part ${stamp}`, quantity: 1, unitPrice: 300 })
+  await saveQuote(page)
+  const shareUrl = await quoteShareLink(page)
+  return { quoteUrl, shareUrl }
+}
+
+/** The customer asks for a change on the public page, signed out. */
+async function requestChanges(browser: Browser, shareUrl: string, request: string) {
+  const customer = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const customerPage = await customer.newPage()
+  await customerPage.goto(shareUrl)
+  await expect(async () => {
+    await customerPage.getByRole('button', { name: 'Request Changes' }).click()
+    await expect(customerPage.getByRole('textbox')).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await customerPage.getByRole('textbox').fill(request)
+  await customerPage.getByRole('button', { name: 'Submit Request' }).click()
+  await expect(customerPage.getByText('Changes Requested').first()).toBeVisible()
+  await customer.close()
+}
+
+/** Opens the bell and clicks the newest notification with this title. */
+async function clickNotification(page: Page, title: string) {
+  const panel = page.getByRole('dialog', { name: 'Notifications' })
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Open notifications' }).click()
+    await expect(panel).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await panel.getByRole('button').filter({ hasText: title }).first().click()
+}
+
+test('opened from the bell, a change request lands on the quote showing the request', async ({
+  page,
+  browser,
+}) => {
+  const request = `Please use a genuine filter ${stamp}`
+  const title = `E2E notified quote ${stamp}`
+  const { quoteUrl, shareUrl } = await sharedQuote(page, title)
+  await requestChanges(browser, shareUrl, request)
+
+  await page.goto('/')
+  await settle(page)
+  await clickNotification(page, 'Quote Changes Requested')
+
+  await page.waitForURL(quoteUrl, { timeout: 15_000 })
+  await expect(page.getByText(request)).toBeVisible()
+
+  // And on the list, under "Needs attention", highlighted as a change request.
+  await page.goto(`/quotes?status=attention&search=${encodeURIComponent(title)}`)
+  await settle(page)
+  const row = page.getByRole('row').filter({ hasText: title })
+  await expect(row).toHaveAttribute('data-attention', 'changes_requested')
+  await expect(page.getByRole('button', { name: /Needs attention/ })).toBeVisible()
+  await page.goto(quoteUrl)
+  // At the top of the quote, seen without scrolling past the lines first.
+  await expect(page.getByText(request)).toBeInViewport()
+})
+
+test('arriving while the quote is open, clicking it shows the request without a reload', async ({
+  page,
+  browser,
+}) => {
+  const request = `Could you add wiper blades ${stamp}`
+  const { quoteUrl, shareUrl } = await sharedQuote(page, `E2E live notified quote ${stamp}`)
+
+  await page.goto(quoteUrl)
+  await settle(page)
+  await requestChanges(browser, shareUrl, request)
+
+  await clickNotification(page, 'Quote Changes Requested')
+  // Still this quote: the newest notification is the one that just arrived.
+  await expect(page).toHaveURL(quoteUrl)
+  await expect(page.getByText(request)).toBeVisible({ timeout: 15_000 })
+  await expect(page.getByText(request)).toBeInViewport()
+  await expect(page.getByText('Changes Requested', { exact: true }).first()).toBeVisible()
+})

+ 2 - 5
e2e/specs/payments/checkout.spec.ts

@@ -52,18 +52,15 @@ async function openInvoice(page: Page, url = invoiceUrl): Promise<void> {
   await settle(page)
 }
 
-/** The badge the work order's payments panel shows: Unpaid, Partial or Paid. */
+/** The badge on the work order's invoice card: Unpaid, Partial or Paid. */
 async function expectWorkOrderPaymentState(
   page: Page,
   state: 'Unpaid' | 'Partial' | 'Paid'
 ): Promise<void> {
   await page.goto(jobUrl)
   await settle(page)
-  const panel = page
-    .getByRole('heading', { name: 'Payments', exact: true })
-    .locator('xpath=ancestor::div[contains(@class,"rounded-lg")][1]')
   await expect(
-    panel.getByText(state, { exact: true }),
+    page.getByTestId('payment-status').filter({ hasText: new RegExp(`^${state}$`) }),
     `the work order reads ${state}`
   ).toBeVisible()
 }

+ 194 - 0
e2e/specs/quotes/lock.spec.ts

@@ -0,0 +1,194 @@
+import { expect, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+import { clearMailbox, waitForMail } from '../../support/mail'
+import { addQuotePart, newQuote, quoteShareLink, saveQuote } from '../../support/quote'
+import { setQuoteLock } from '../../support/settings'
+
+/**
+ * A locked quote still becomes a work order.
+ *
+ * The lock freezes what a quote says it costs, and nothing else: converting it,
+ * copying its link and sending it are not edits. The page used to disable the
+ * whole details tab through one `<fieldset disabled>`, and a disabled fieldset
+ * disables every button inside it too, so "Convert to Work Order" went grey
+ * the moment a customer accepted. A workshop reported it as a glitch: the
+ * quote they had just been told was accepted could not be turned into the job.
+ *
+ * The same report turned up two more things a locked quote has to allow: a
+ * status that keeps it locked (a quote accepted over the phone), and dismissing
+ * the customer's acceptance from the dashboard. Dismissing used to put the
+ * quote back to draft, which erased the acceptance and released the lock, and
+ * the row stayed on screen until the page was reloaded.
+ *
+ * Both triggers are walked, because they lock at different moments and the
+ * button has to survive each of them. The quote setting is off by default and
+ * is put back afterwards.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const CUSTOMER = `e2e-quote-lock-${stamp}@example.com`
+/** The seeded Camry belongs to James Mitchell, who is who the Share dialog notifies. */
+const SEEDED_CUSTOMER_EMAIL = 'james.mitchell@gmail.com'
+
+/** The editor's price fields are the part the lock is for, so they have to be frozen. */
+async function expectPricesFrozen(page: Page, banner: string) {
+  await expect(page.getByText(banner, { exact: true })).toBeVisible()
+  await expect(page.getByPlaceholder('Name *').first()).toBeDisabled()
+}
+
+/** Converts the open quote from its own page and checks the job carries its line. */
+async function convertToWorkOrder(page: Page, quoteUrl: string, partName: string) {
+  const convert = page.getByRole('button', { name: 'Convert to Work Order', exact: true })
+  await expect(convert).toBeEnabled()
+
+  const dialog = page.getByRole('dialog', { name: 'Convert Quote to Work Order' })
+  await expect(async () => {
+    await convert.click()
+    await expect(dialog).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  // The quote's own vehicle is chosen already, so Convert is ready to press.
+  const confirm = dialog.getByRole('button', { name: 'Convert', exact: true })
+  await expect(confirm).toBeEnabled()
+  await confirm.click()
+
+  await page.waitForURL(/\/vehicles\/[^/]+\/service\/[^/]+$/, { timeout: 60_000 })
+  await settle(page)
+  await expect(page.getByPlaceholder('Name *').first()).toHaveValue(partName)
+
+  // Back on the quote, it is marked converted and offers no second conversion.
+  await page.goto(quoteUrl)
+  await settle(page)
+  await expect(page.getByText('Converted', { exact: true }).first()).toBeVisible()
+  await expect(
+    page.getByRole('button', { name: 'Convert to Work Order', exact: true })
+  ).toHaveCount(0)
+}
+
+test.afterAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await setQuoteLock(page, { enabled: false, trigger: 'accepted' })
+  await page.close()
+})
+
+test.describe('a locked quote can still become a work order', () => {
+  test('locked once sent: emailed to the customer, then converted', async ({ page }) => {
+    await setQuoteLock(page, { enabled: true, trigger: 'sent' })
+
+    const part = `E2E lock sent part ${stamp}`
+    const quoteUrl = await newQuote(page, `E2E locked when sent ${stamp}`)
+    await addQuotePart(page, { name: part, quantity: 1, unitPrice: 500 })
+    await saveQuote(page)
+
+    // Sent the way the workshop in the report sent theirs.
+    await clearMailbox()
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Email', exact: true }).click()
+      await expect(page.locator('#email')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.locator('#email').fill(CUSTOMER)
+    await page.getByRole('button', { name: 'Send Email', exact: true }).click()
+    await waitForMail(CUSTOMER, { timeout: 30_000 })
+
+    await page.goto(quoteUrl)
+    await settle(page)
+    await expectPricesFrozen(page, 'This quote is locked because it has been sent')
+
+    // The customer says yes on the phone. A status that keeps the quote locked
+    // is not an edit, and one that would release the lock is not offered.
+    const status = page.getByRole('combobox', { name: 'Status' })
+    await expect(status).toBeEnabled()
+    await expect(async () => {
+      await status.click()
+      await expect(page.getByRole('option', { name: 'Accepted' })).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await expect(page.getByRole('option', { name: 'Draft' })).toHaveAttribute(
+      'aria-disabled',
+      'true'
+    )
+    await page.getByRole('option', { name: 'Accepted' }).click()
+    await expect(status).toContainText('Accepted')
+    // Disabled while the change is written, so enabled again means it landed.
+    await expect(status).toBeEnabled()
+
+    await page.reload()
+    await settle(page)
+    await expect(page.getByRole('combobox', { name: 'Status' })).toContainText('Accepted')
+    await expectPricesFrozen(page, 'This quote is locked because it has been sent')
+
+    await convertToWorkOrder(page, quoteUrl, part)
+  })
+
+  test('locked once accepted: the customer accepts by link, then converted', async ({ page }) => {
+    await setQuoteLock(page, { enabled: true, trigger: 'accepted' })
+
+    const part = `E2E lock accepted part ${stamp}`
+    const title = `E2E locked when accepted ${stamp}`
+    const quoteUrl = await newQuote(page, title)
+    await addQuotePart(page, { name: part, quantity: 1, unitPrice: 700 })
+    await saveQuote(page)
+    const shareUrl = await quoteShareLink(page)
+
+    // Telling the customer from the Share dialog closes it once the mail has
+    // gone, instead of leaving a finished dialog on screen.
+    await clearMailbox()
+    const shareDialog = page.getByRole('dialog', { name: 'Share Quote' })
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Share', exact: true }).click()
+      await expect(shareDialog).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await shareDialog.locator('#notify-email-quote').click()
+    await shareDialog.getByRole('button', { name: 'Send Notification', exact: true }).click()
+    await expect(shareDialog).toBeHidden({ timeout: 30_000 })
+    await waitForMail(SEEDED_CUSTOMER_EMAIL, { timeout: 30_000 })
+
+    // Sharing sends it, and under this trigger a sent quote is still open.
+    await page.goto(quoteUrl)
+    await settle(page)
+    await expect(page.getByPlaceholder('Name *').first()).toBeEnabled()
+
+    // Signed out, the way a customer opens the link in their mail.
+    const customer = await page
+      .context()
+      .browser()!
+      .newContext({
+        storageState: { cookies: [], origins: [] },
+      })
+    const customerPage = await customer.newPage()
+    await customerPage.goto(shareUrl)
+    await expect(async () => {
+      await customerPage.getByRole('button', { name: 'Accept Quote' }).click()
+      await expect(customerPage.getByText('Quote Accepted')).toBeVisible({ timeout: 3_000 })
+    }).toPass({ timeout: 30_000 })
+    await customer.close()
+
+    await page.goto(quoteUrl)
+    await settle(page)
+    await expectPricesFrozen(page, 'This quote is locked because it has been accepted')
+
+    // Copying the link is not an edit either.
+    await expect(page.getByRole('button', { name: 'Copy link', exact: true })).toBeEnabled()
+
+    // Dismissed from the dashboard, the response leaves the list without a
+    // reload and stays gone, while the quote keeps the acceptance and its lock.
+    await page.goto('/')
+    await settle(page)
+    const row = page.getByTestId('quote-response-row').filter({ hasText: title })
+    await expect(row).toBeVisible()
+    await expect(async () => {
+      await row.getByRole('button', { name: 'Dismiss response' }).click()
+      await expect(row).toHaveCount(0, { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.reload()
+    await settle(page)
+    await expect(page.getByTestId('quote-response-row').filter({ hasText: title })).toHaveCount(0)
+
+    await page.goto(quoteUrl)
+    await settle(page)
+    await expectPricesFrozen(page, 'This quote is locked because it has been accepted')
+
+    await convertToWorkOrder(page, quoteUrl, part)
+  })
+})

+ 3 - 0
e2e/specs/security/admin-only.spec.ts

@@ -40,6 +40,9 @@ let organizationId = ''
 let roleId = ''
 
 async function signIn(page: Page, email: string, password: string) {
+  // The sign-in page sends anyone with a session straight on, so a re-sign-in
+  // after a role change has to drop the old session first.
+  await page.context().clearCookies()
   await page.goto('/auth/sign-in')
   await page.locator('#email').fill(email)
   await page.locator('#password').fill(password)

+ 13 - 4
e2e/specs/security/files.spec.ts

@@ -112,18 +112,27 @@ test.describe('a file URL on a record', () => {
 
     for (const [i, { url }] of forged.entries()) {
       const name = `e2e-forged-${i}-${stamp}.txt`
+      // The refusal is a toast, and the last file's is still up when the next
+      // one starts. Left there it answers this file's check at once, the route
+      // below is taken away while its upload is still in flight, and the
+      // handler then fails with "Route is already handled" on a slow runner.
+      await expect(page.getByText(/not an upload of this workshop/i)).toHaveCount(0, {
+        timeout: 30_000,
+      })
       await page.route('**/api/protected/upload/service-files', async (route) => {
         const response = await route.fetch()
         const json = (await response.json()) as Record<string, unknown>
         await route.fulfill({ response, json: { ...json, url } })
       })
 
+      const files = page.getByTestId('files-media')
+      const documents = files.getByRole('tab', { name: /^Documents/ })
       await expect(async () => {
-        await page.getByRole('button', { name: /^Documents/ }).click()
-        await expect(page.locator('input[type="file"]').first()).toBeAttached({ timeout: 2_000 })
+        await documents.click()
+        await expect(documents).toHaveAttribute('aria-selected', 'true', { timeout: 2_000 })
       }).toPass({ timeout: 30_000 })
-      await page
-        .locator('input[type="file"][accept=".pdf,.csv,.txt"]')
+      await files
+        .locator('input[type="file"]')
         .setInputFiles({ name, mimeType: 'text/plain', buffer: Buffer.from('forged') })
 
       await expect(

+ 205 - 0
e2e/specs/security/role-standing.spec.ts

@@ -0,0 +1,205 @@
+import { expect, type Browser, type Page, test } from '@playwright/test'
+import {
+  createAdminRole,
+  createRoleWithEveryPermission,
+  deleteRoles,
+  membershipOf,
+  ownerOrganizationId,
+  setMembership,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { linkIn, waitForMail } from '../../support/mail'
+
+/**
+ * Who may make somebody an admin.
+ *
+ * Inviting as admin is the owner's alone, and so is changing a member's
+ * built-in role on the team page. The role picker's own action asked only
+ * for admin standing, which a custom role can carry, so a person with such a
+ * role could make themself, or anyone, a built-in admin, and from there edit
+ * roles and remove members. The team page never offered them the picker; the
+ * action behind it is what a browser can call, and what this file calls.
+ *
+ * The action's id is not written down anywhere a test could read it, so it is
+ * taken from the request the owner's own click makes, and replayed with the
+ * arguments of the caller's choosing, as a person in the browser could.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+// Everybody starts as a stranger with no session.
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const stamp = Date.now()
+const COLLEAGUE = `e2e-standing-${stamp}@example.com`
+const PEER = `e2e-peer-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+
+const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100'
+
+let organizationId = ''
+let adminRoleId = ''
+let plainRoleId = ''
+/** The `Next-Action` id of assignRole, learnt from the owner's click. */
+let assignRoleAction = ''
+
+async function signIn(page: Page, email: string) {
+  await page.goto('/auth/sign-in')
+  await page.locator('#email').fill(email)
+  await page.locator('#password').fill(PASSWORD)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+  await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+}
+
+/** The owner invites `email`, who signs up from the mail and lands in the workshop. */
+async function joinTeam(browser: Browser, email: string, name: string) {
+  const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+  const page = await owner.newPage()
+  await page.goto('/settings/team')
+  await settle(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Add', exact: true }).first().click()
+    await expect(page.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.getByText('Someone in the office').click()
+  await page.locator('#member-email').fill(email)
+  await page.getByRole('button', { name: 'Invite', exact: true }).click()
+  await expect(page.getByText(email).first()).toBeVisible({ timeout: 30_000 })
+  await owner.close()
+
+  const invitation = await waitForMail(email)
+  const person = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const signup = await person.newPage()
+  await signup.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
+  await signup.locator('#name').fill(name)
+  await signup.locator('#email').fill(email)
+  await signup.locator('#password').fill(PASSWORD)
+  await signup.locator('#terms').click()
+  await signup.getByRole('button', { name: /create account/i }).click()
+  await signup.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), {
+    timeout: 30_000,
+  })
+  await person.close()
+}
+
+/**
+ * Calls assignRole the way the page does: a POST to the page it lives on,
+ * with the action id in the `Next-Action` header and the arguments as the
+ * body. What the page would do with the answer does not matter here; the
+ * membership is read back from the database.
+ */
+async function callAssignRole(
+  page: Page,
+  args: { memberId: string; role: 'admin' | 'member'; roleId: string | null }
+) {
+  return page.request.post(`${baseURL}/settings/team`, {
+    headers: {
+      'next-action': assignRoleAction,
+      'content-type': 'text/plain;charset=UTF-8',
+      accept: 'text/x-component',
+      origin: baseURL,
+    },
+    data: JSON.stringify([args]),
+  })
+}
+
+test.beforeAll(async ({ browser }) => {
+  organizationId = await ownerOrganizationId()
+  await joinTeam(browser, COLLEAGUE, 'E2E Standing Colleague')
+  await joinTeam(browser, PEER, 'E2E Peer')
+  adminRoleId = await createAdminRole(organizationId, `E2E Admin Switch ${stamp}`)
+  plainRoleId = await createRoleWithEveryPermission(organizationId, `E2E Plain ${stamp}`)
+})
+
+test.afterAll(async () => {
+  // Two more ordinary members without a role, and the roles gone.
+  if (organizationId) {
+    for (const email of [COLLEAGUE, PEER]) {
+      await setMembership(email, organizationId, { roleId: null, role: 'member' })
+    }
+  }
+  await deleteRoles([adminRoleId, plainRoleId].filter(Boolean))
+})
+
+test.describe('the role picker’s action', () => {
+  test('is learnt from the owner giving the peer a role', async ({ browser }) => {
+    const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    const page = await owner.newPage()
+    await page.goto('/settings/team')
+    await settle(page)
+
+    // Members are bordered cards, one per person, each with its own picker.
+    // By test id: the page's own cards are bordered and rounded as well, and
+    // one of them holds every member.
+    const row = page.getByTestId('team-member').filter({ hasText: PEER })
+    const action = page.waitForRequest((request) => Boolean(request.headers()['next-action']))
+    await row.getByRole('combobox').click()
+    await page.getByRole('option', { name: `E2E Plain ${stamp}`, exact: true }).click()
+    assignRoleAction = (await action).headers()['next-action']
+    expect(assignRoleAction).toBeTruthy()
+
+    await expect
+      .poll(async () => (await membershipOf(PEER, organizationId)).roleId)
+      .toBe(plainRoleId)
+    await owner.close()
+  })
+
+  test('does not let a custom-role admin make themself or a peer a built-in admin', async ({
+    page,
+  }) => {
+    await setMembership(COLLEAGUE, organizationId, { roleId: adminRoleId, role: 'member' })
+    await signIn(page, COLLEAGUE)
+    const self = await membershipOf(COLLEAGUE, organizationId)
+    const peer = await membershipOf(PEER, organizationId)
+
+    await callAssignRole(page, { memberId: self.id, role: 'admin', roleId: null })
+    await callAssignRole(page, { memberId: peer.id, role: 'admin', roleId: null })
+
+    expect((await membershipOf(COLLEAGUE, organizationId)).role).toBe('member')
+    expect(await membershipOf(PEER, organizationId)).toEqual(peer)
+
+    // The action itself still works for them: a role that grants nothing on
+    // its own is theirs to hand out.
+    await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: null })
+    expect((await membershipOf(PEER, organizationId)).roleId).toBeNull()
+    await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: plainRoleId })
+    expect((await membershipOf(PEER, organizationId)).roleId).toBe(plainRoleId)
+  })
+
+  test('does not let a built-in admin make a peer a built-in admin, or unmake one', async ({
+    page,
+  }) => {
+    await setMembership(COLLEAGUE, organizationId, { roleId: null, role: 'admin' })
+    await signIn(page, COLLEAGUE)
+    const peer = await membershipOf(PEER, organizationId)
+
+    await callAssignRole(page, { memberId: peer.id, role: 'admin', roleId: null })
+    expect((await membershipOf(PEER, organizationId)).role).toBe('member')
+
+    // Made an admin by the owner, the peer is out of the colleague's reach.
+    await setMembership(PEER, organizationId, { roleId: null, role: 'admin' })
+    await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: plainRoleId })
+    expect(await membershipOf(PEER, organizationId)).toEqual({
+      id: peer.id,
+      role: 'admin',
+      roleId: null,
+    })
+  })
+
+  test('lets the owner do both', async ({ browser }) => {
+    const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    const page = await owner.newPage()
+    await page.goto('/settings/team')
+    const peer = await membershipOf(PEER, organizationId)
+
+    await callAssignRole(page, { memberId: peer.id, role: 'member', roleId: plainRoleId })
+    expect(await membershipOf(PEER, organizationId)).toEqual({
+      id: peer.id,
+      role: 'member',
+      roleId: plainRoleId,
+    })
+    await callAssignRole(page, { memberId: peer.id, role: 'admin', roleId: null })
+    expect((await membershipOf(PEER, organizationId)).role).toBe('admin')
+    await owner.close()
+  })
+})

+ 163 - 0
e2e/specs/security/whatsapp-webhook.spec.ts

@@ -0,0 +1,163 @@
+import { expect, test } from '@playwright/test'
+import { createHmac } from 'node:crypto'
+import {
+  deleteInboundWhatsapp,
+  forgetConnections,
+  inboundWhatsappCount,
+  insertConnection,
+  ownerOrganizationId,
+  userIdFor,
+} from '../../support/db'
+import { sealCredentials } from '../../support/webhooks'
+
+/**
+ * A WhatsApp message has to come from Meta.
+ *
+ * The webhook URL names only the workshop's id, which every share link and
+ * public logo carries, so the signature Meta puts on each delivery is the
+ * whole of the proof. The app secret that checks it used to be optional, and
+ * a workshop that left it blank had a webhook anyone could post to: a made-up
+ * message landed in the inbox as if a customer had written it. A delivery
+ * that cannot be checked is not read now.
+ *
+ * The route answers 200 whatever happens, so that Meta does not retry, and
+ * the outcome is read from the database.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const APP_SECRET = `e2e-meta-app-secret-${stamp}`
+const BODY = `E2E WhatsApp inbound ${stamp}`
+
+const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100'
+
+let organizationId = ''
+let webhook = ''
+
+/** What Meta posts for one text message. */
+function delivery(body: string): string {
+  return JSON.stringify({
+    entry: [
+      {
+        changes: [
+          {
+            value: {
+              metadata: { display_phone_number: '15550009999' },
+              contacts: [{ profile: { name: 'E2E Customer' } }],
+              messages: [
+                {
+                  id: `wamid.e2e.${stamp}.${body.length}`,
+                  from: '15551230000',
+                  timestamp: String(Math.floor(Date.now() / 1000)),
+                  type: 'text',
+                  text: { body },
+                },
+              ],
+            },
+          },
+        ],
+      },
+    ],
+  })
+}
+
+function signature(raw: string, secret: string): string {
+  return `sha256=${createHmac('sha256', secret).update(raw).digest('hex')}`
+}
+
+async function plantConnection(withSecret: boolean): Promise<void> {
+  await forgetConnections(['whatsapp-meta'])
+  await insertConnection({
+    organizationId,
+    connectorId: 'whatsapp-meta',
+    credentials: sealCredentials({
+      phoneNumberId: '123456789012345',
+      accessToken: 'e2e-access-token',
+      verifyToken: 'e2e-verify-token',
+      ...(withSecret ? { appSecret: APP_SECRET } : {}),
+    }),
+    settings: { enabled: true, phoneNumber: '+15550009999' },
+    createdById: await userIdFor('demo@torqvoice.com'),
+  })
+}
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  webhook = `${baseURL}/api/webhooks/whatsapp/meta/${organizationId}`
+})
+
+test.afterAll(async () => {
+  await forgetConnections(['whatsapp-meta'])
+  await deleteInboundWhatsapp(organizationId, BODY)
+})
+
+test.describe('a message posted to the Meta webhook', () => {
+  test('is dropped when the workshop has no app secret, even with a signature', async ({
+    request,
+  }) => {
+    await plantConnection(false)
+    const raw = delivery(`${BODY} unguarded`)
+
+    const bare = await request.post(webhook, {
+      data: raw,
+      headers: { 'content-type': 'application/json' },
+    })
+    expect(bare.status(), 'Meta is told not to retry').toBe(200)
+
+    // Whatever the poster signs it with: there is nothing to check it against.
+    const signed = await request.post(webhook, {
+      data: raw,
+      headers: {
+        'content-type': 'application/json',
+        'x-hub-signature-256': signature(raw, 'guess'),
+      },
+    })
+    expect(signed.status()).toBe(200)
+
+    expect(
+      await inboundWhatsappCount(organizationId, `${BODY} unguarded`),
+      'nothing was filed'
+    ).toBe(0)
+  })
+
+  test('is dropped without Meta’s signature once the secret is set', async ({ request }) => {
+    await plantConnection(true)
+    const raw = delivery(`${BODY} unsigned`)
+
+    await request.post(webhook, { data: raw, headers: { 'content-type': 'application/json' } })
+    await request.post(webhook, {
+      data: raw,
+      headers: {
+        'content-type': 'application/json',
+        'x-hub-signature-256': signature(raw, 'wrong'),
+      },
+    })
+    expect(await inboundWhatsappCount(organizationId, `${BODY} unsigned`)).toBe(0)
+  })
+
+  test('is filed once when signed with the app secret', async ({ request }) => {
+    const raw = delivery(`${BODY} genuine`)
+    const response = await request.post(webhook, {
+      data: raw,
+      headers: {
+        'content-type': 'application/json',
+        'x-hub-signature-256': signature(raw, APP_SECRET),
+      },
+    })
+    expect(response.status()).toBe(200)
+    expect(await inboundWhatsappCount(organizationId, `${BODY} genuine`)).toBe(1)
+  })
+
+  test('is dropped when the body was changed after signing', async ({ request }) => {
+    const signedRaw = delivery(`${BODY} original`)
+    await request.post(webhook, {
+      data: delivery(`${BODY} tampered`),
+      headers: {
+        'content-type': 'application/json',
+        'x-hub-signature-256': signature(signedRaw, APP_SECRET),
+      },
+    })
+    expect(await inboundWhatsappCount(organizationId, `${BODY} tampered`)).toBe(0)
+  })
+})

+ 12 - 9
e2e/specs/settings/marine.spec.ts

@@ -1,10 +1,10 @@
 import { expect, type Locator, type Page, test } from '@playwright/test'
-import { completeOnboarding, signUpWithPassword } from '../../support/cloud'
+import { signInWithPassword } from '../../support/cloud'
 import {
   connectRegistry,
   customerIdNamed,
   disconnectRegistry,
-  organizationIdFor,
+  plantWorkshop,
   userIdFor,
   workshopSetting,
 } from '../../support/db'
@@ -27,7 +27,7 @@ import { newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/wor
  * about vessels.
  */
 
-// Signing up and onboarding a workshop in a hook takes longer than a test.
+// Planting and signing into a workshop in a hook takes longer than a test.
 test.describe.configure({ mode: 'serial', timeout: 180_000 })
 
 const stamp = Date.now()
@@ -53,18 +53,21 @@ let vesselUrl = ''
 test.use({ storageState: STATE })
 
 test.beforeAll(async ({ browser }) => {
-  const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
-  const page = await context.newPage()
-  await signUpWithPassword(page, {
+  // A workshop of its own, planted: a self-hosted install opens one workshop
+  // and would send a sign-up to ask for an invitation instead.
+  const planted = await plantWorkshop({
     name: 'E2E Marine Owner',
     email: EMAIL,
     password: `E2e-pass-${stamp}`,
+    workshopName: `E2E Marina ${stamp}`,
   })
-  await completeOnboarding(page, `E2E Marina ${stamp}`, { sampleData: false })
+  organizationId = planted.organizationId
+
+  const context = await browser.newContext({ storageState: { cookies: [], origins: [] } })
+  const page = await context.newPage()
+  await signInWithPassword(page, { email: EMAIL, password: `E2e-pass-${stamp}` })
   await context.storageState({ path: STATE })
   await context.close()
-
-  organizationId = await organizationIdFor(EMAIL)
 })
 
 test.afterAll(async () => {

+ 78 - 0
e2e/specs/settings/mark-types.spec.ts

@@ -0,0 +1,78 @@
+import { expect, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+
+/**
+ * The workshop's kinds of mark, edited under Templates → Checklists →
+ * Condition map: the eight built-in kinds are there, one can be renamed, a
+ * kind of the workshop's own added, and both put back, so the seeded
+ * workshop draws with the app's own kinds again afterwards.
+ */
+
+const rows = (page: Page) => page.getByTestId('mark-type-row')
+const rowNamed = (page: Page, name: string) => rows(page).filter({ hasText: name })
+
+async function openCatalogue(page: Page) {
+  await page.goto('/settings/templates?tab=conditionMap')
+  await expect(page.getByRole('tab', { name: 'Condition map', exact: true })).toHaveAttribute(
+    'aria-selected',
+    'true'
+  )
+  await expect(rows(page).first()).toBeVisible({ timeout: 30_000 })
+  await settle(page)
+}
+
+async function saveDialog(page: Page) {
+  const dialog = page.getByRole('dialog')
+  await dialog.getByRole('button', { name: 'Save', exact: true }).click()
+  await expect(dialog).toBeHidden({ timeout: 15_000 })
+}
+
+test.describe.configure({ mode: 'serial' })
+
+test.describe('kinds of mark', () => {
+  test('ships the eight built-in kinds', async ({ page }) => {
+    await openCatalogue(page)
+    await expect(rows(page)).toHaveCount(8)
+    for (const name of ['Dent', 'Scratch', 'Stone chip', 'Crack', 'Rust', 'Paint damage']) {
+      await expect(rowNamed(page, name), `${name} is listed`).toHaveCount(1)
+    }
+  })
+
+  test('renames a built-in kind and restores it', async ({ page }) => {
+    await openCatalogue(page)
+    await rowNamed(page, 'Dent').getByRole('button', { name: 'Edit' }).click()
+    const dialog = page.getByRole('dialog', { name: 'Edit kind of mark' })
+    await dialog.getByLabel('Name').fill('Ding')
+    await saveDialog(page)
+    await expect(rowNamed(page, 'Ding')).toHaveCount(1)
+    await expect(rowNamed(page, 'Dent')).toHaveCount(0)
+
+    // The restore puts the app's own name back.
+    await rowNamed(page, 'Ding').getByRole('button', { name: 'Restore' }).click()
+    await page.getByRole('alertdialog').getByRole('button', { name: 'Restore' }).click()
+    await expect(rowNamed(page, 'Dent')).toHaveCount(1, { timeout: 15_000 })
+    await expect(rowNamed(page, 'Ding')).toHaveCount(0)
+  })
+
+  test('adds a kind of the workshop’s own and removes it again', async ({ page }) => {
+    await openCatalogue(page)
+    await page.getByRole('button', { name: 'Add kind' }).click()
+    const dialog = page.getByRole('dialog', { name: 'New kind of mark' })
+    await dialog.getByLabel('Name').fill('Curb rash')
+    await dialog.getByRole('button', { name: 'Hexagon' }).click()
+    await dialog.getByRole('button', { name: '#e11d48' }).click()
+    await saveDialog(page)
+    await expect(rows(page)).toHaveCount(9)
+    const own = rowNamed(page, 'Curb rash')
+    await expect(own).toHaveCount(1)
+    await expect(own).toContainText('Hexagon')
+
+    // Hidden kinds stay listed here, greyed, so they can be brought back.
+    await own.getByRole('button', { name: 'Hide from the picker' }).click()
+    await expect(own.getByText('Hidden')).toBeVisible({ timeout: 15_000 })
+
+    await own.getByRole('button', { name: 'Remove' }).click()
+    await page.getByRole('alertdialog').getByRole('button', { name: 'Remove' }).click()
+    await expect(rows(page)).toHaveCount(8, { timeout: 15_000 })
+  })
+})

+ 78 - 0
e2e/specs/status-reports/recorder.spec.ts

@@ -0,0 +1,78 @@
+import { expect, test } from '@playwright/test'
+import { addMark, openDropoff } from '../../support/condition-map'
+import { ownerOrganizationId, plantConditionJob, userIdFor } from '../../support/db'
+import { settle } from '../../support/hydration'
+
+/**
+ * Recording a video at the desk. A phone's "Record" opens its camera app; a
+ * desk computer has none to open, so the page records itself, and the take is
+ * used exactly as an uploaded file would be.
+ *
+ * Chromium is handed a fake camera and microphone, and the permission is
+ * granted up front, so the recorder runs as it would for a person who said yes.
+ */
+
+test.use({
+  launchOptions: {
+    args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
+  },
+  permissions: ['camera', 'microphone'],
+})
+
+const stamp = Date.now()
+
+test('Record on a desk computer opens the recorder, and the take becomes the report’s video', async ({
+  page,
+}) => {
+  const organizationId = await ownerOrganizationId()
+  const userId = await userIdFor('demo@torqvoice.com')
+  const job = await plantConditionJob(organizationId, userId, `E2E recorder ${stamp}`)
+  await page.goto(`/vehicles/${job.vehicleId}/service/${job.serviceRecordId}?tab=statusReports`)
+  await settle(page)
+
+  await expect(async () => {
+    await page.getByRole('button', { name: 'New Status Report' }).first().click()
+    await expect(page.getByRole('dialog', { name: 'Status Report' })).toBeVisible({
+      timeout: 2_000,
+    })
+  }).toPass({ timeout: 30_000 })
+  const report = page.getByRole('dialog', { name: 'Status Report' })
+
+  // No file picker: the page's own recorder.
+  let picked = false
+  page.on('filechooser', () => {
+    picked = true
+  })
+  await report.getByRole('button', { name: 'Record', exact: true }).click()
+  const recorder = page.getByTestId('video-recorder')
+  await expect(recorder).toBeVisible()
+  expect(picked, 'a desk computer is not sent to the file picker').toBe(false)
+
+  const start = recorder.getByRole('button', { name: 'Start recording' })
+  await expect(start).toBeEnabled({ timeout: 15_000 })
+  await start.click()
+  await expect(recorder).toContainText('Recording 0:02', { timeout: 10_000 })
+  await recorder.getByRole('button', { name: 'Stop' }).click()
+
+  // Watched back before it is used.
+  await expect(recorder.locator('video[controls]')).toBeVisible()
+  await recorder.getByRole('button', { name: 'Use this video' }).click()
+  await expect(recorder).toBeHidden()
+  await expect(report).toContainText(/recording-\d+\.webm/, { timeout: 30_000 })
+
+  await report.getByLabel('Title').fill(`E2E recorded ${stamp}`)
+  await report.getByRole('button', { name: 'Create', exact: true }).click()
+  await expect(page.getByText('Status report created')).toBeVisible()
+})
+
+test('Take photo is not offered on a desk computer, where it would only open the file picker', async ({
+  page,
+}) => {
+  const organizationId = await ownerOrganizationId()
+  const userId = await userIdFor('demo@torqvoice.com')
+  const job = await plantConditionJob(organizationId, userId, `E2E take photo ${stamp}`)
+  const map = await openDropoff(page, `/vehicles/${job.vehicleId}/service/${job.serviceRecordId}`)
+  const editor = await addMark(page, map, 'left', 'left_front_door')
+  await expect(editor.getByRole('button', { name: 'Add photo' })).toBeVisible()
+  await expect(editor.getByRole('button', { name: 'Take photo' })).toHaveCount(0)
+})

+ 60 - 35
e2e/specs/tech/api.spec.ts

@@ -1,6 +1,14 @@
 import { expect, type APIRequestContext, type Page, test } from '@playwright/test'
-import { foreignServiceRecordId, organizationIdFor, seededTenantFixtures } from '../../support/db'
+import {
+  foreignServiceRecordId,
+  jobAssignment,
+  organizationIdFor,
+  plantJob,
+  plantWorkshop,
+  seededTenantFixtures,
+} from '../../support/db'
 import { settle } from '../../support/hydration'
+import { laborRows, saveWorkOrder, setTitle } from '../../support/work-order'
 
 /**
  * The contract the technician app is built against.
@@ -89,23 +97,16 @@ test.beforeAll(async ({ browser, playwright, baseURL }) => {
   // A job in this workshop that will not be assigned to the new technician.
   someoneElsesJob = seeded.serviceRecordId
 
-  // A second workshop, for the cross-workshop refusals. Signing up gives it a
-  // few work orders of its own, which is what makes it a useful target.
-  const outsider = await browser.newContext({ storageState: { cookies: [], origins: [] } })
-  const outsiderPage = await outsider.newPage()
-  await outsiderPage.goto('/auth/sign-up')
-  await outsiderPage.locator('#name').fill('E2E Tech Outsider')
-  await outsiderPage.locator('#email').fill(OUTSIDER)
-  await outsiderPage.locator('#password').fill(OUTSIDER_PASSWORD)
-  await outsiderPage.locator('#terms').click()
-  await outsiderPage.getByRole('button', { name: /create account/i }).click()
-  await outsiderPage.waitForURL(/\/onboarding/, { timeout: 30_000 })
-  await outsiderPage.locator('#workshopName').fill(`E2E Tech Outsider Garage ${stamp}`)
-  await outsiderPage.locator('form button[type="submit"]').click()
-  await outsiderPage.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), {
-    timeout: 30_000,
+  // A second workshop, for the cross-workshop refusals, planted with a job
+  // of its own: a self-hosted install opens one workshop, so a sign-up would
+  // be told to ask for an invitation instead of opening this one.
+  const outsider = await plantWorkshop({
+    name: 'E2E Tech Outsider',
+    email: OUTSIDER,
+    password: OUTSIDER_PASSWORD,
+    workshopName: `E2E Tech Outsider Garage ${stamp}`,
   })
-  await outsider.close()
+  await plantJob(outsider.organizationId, outsider.userId, `E2E Tech Outsider Job ${stamp}`)
 
   foreignJob = await foreignServiceRecordId(await organizationIdFor(OUTSIDER))
   expect(foreignJob).not.toBe(someoneElsesJob)
@@ -250,26 +251,19 @@ test.describe('the technician app', () => {
       { timeout: 30_000 }
     )
     jobId = page.url().split('/').pop() as string
-    await page.locator('input[name="title"]').fill(`E2E tech job ${stamp}`)
-
+    await settle(page)
+    await setTitle(page, `E2E tech job ${stamp}`)
+    await saveWorkOrder(page)
+
+    // The technician list puts them on the job with one click.
+    const technician = page
+      .getByRole('radiogroup', { name: 'Technician' })
+      .getByRole('radio', { name: new RegExp(TECHNICIAN) })
     await expect(async () => {
-      await page
-        .getByRole('combobox')
-        .filter({ hasText: /select technician/i })
-        .first()
-        .click()
-      await expect(page.getByPlaceholder(/search or create technician/i)).toBeVisible({
-        timeout: 2_000,
-      })
+      await technician.click()
+      await expect(technician).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
     }).toPass({ timeout: 30_000 })
-    await page.getByPlaceholder(/search or create technician/i).fill(TECHNICIAN)
-    await page
-      .getByRole('option', { name: new RegExp(TECHNICIAN) })
-      .first()
-      .click()
-
-    await page.getByRole('button', { name: 'Save', exact: true }).click()
-    await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+    await expect.poll(async () => (await jobAssignment(jobId)).technicianId).toBeTruthy()
 
     const jobs = await phone(device).get('/api/v1/tech/jobs')
     const { data } = await jobs.json()
@@ -304,6 +298,37 @@ test.describe('the technician app', () => {
     expect(again.status()).toBe(409)
   })
 
+  /**
+   * The other half of clocking off: the time is billed onto the job, and the
+   * desk has that job open while it happens.
+   *
+   * A work order saves its labour by replacing every line, so a desk holding
+   * a list read before the technician's line existed deletes that line on its
+   * next save. The page therefore hears about it on the work board channel
+   * and reads the job again, and the line appears without a reload. This is
+   * the whole path: phone, endpoint, socket, browser.
+   */
+  test('bills time onto the job, and the desk sees it without reloading', async ({ page }) => {
+    const description = `E2E bay labour ${stamp}`
+    await page.goto(`/vehicles/${(await seededTenantFixtures()).vehicleId}/service/${jobId}`)
+    await settle(page)
+    // The socket is opened by the app shell after hydration; a line added
+    // before it is listening would only be found by reloading.
+    await expect(laborRows(page)).toHaveCount(0)
+
+    const added = await phone(device).post(`/api/v1/tech/jobs/${jobId}/labor`, {
+      description,
+      hours: 1.5,
+    })
+    expect(added.status()).toBe(201)
+    expect((await added.json()).data.labor.hours).toBe(1.5)
+
+    // No reload anywhere in this test: the page is told.
+    await expect(laborRows(page)).toHaveCount(1, { timeout: 30_000 })
+    await expect(laborRows(page).first()).toHaveValue(description)
+    expect(page.url(), 'the page never navigated').toContain(jobId)
+  })
+
   test('asks for a day rather than everything', async () => {
     // The phone owns the technician's timezone, so it sends the window; a
     // request without one is a client mistake and says which field is missing.

+ 296 - 0
e2e/specs/warranty/statement.spec.ts

@@ -0,0 +1,296 @@
+import { expect, type Page, test } from '@playwright/test'
+import { forgetWorkshopSetting, ownerOrganizationId, workshopSetting } from '../../support/db'
+import { settle } from '../../support/hydration'
+import { type PdfContent, pdfContent } from '../../support/pdf'
+import { addQuotePart, newQuote, quotePdfUrl, quoteShareLink, saveQuote } from '../../support/quote'
+import { setWarrantyDefaults } from '../../support/settings'
+import { chooseWarrantyStatement, expectWarrantyStatement } from '../../support/warranty'
+import { newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
+
+/**
+ * What a customer is told about the workshop's warranty, from the quote they
+ * accept to the invoice they pay.
+ *
+ * A workshop writes its standing answer once (Settings → Warranty). A new
+ * quote starts from it, the customer reads it on the public link and in the
+ * PDF before accepting, and the work order made from that quote carries the
+ * same words onto the invoice. "Not included" is a statement in its own right
+ * and travels the same way, with the line that the customer's statutory rights
+ * stand whenever the workshop wrote no sentence of its own.
+ */
+
+test.describe.configure({ mode: 'serial', timeout: 180_000 })
+
+const stamp = Date.now()
+const TERMS = `E2E terms ${stamp}: wear parts are not covered.`
+const NO_WARRANTY = `E2E statement ${stamp}: no workshop warranty on customer-supplied parts.`
+const PART = `E2E brake disc ${stamp}`
+
+const WARRANTY_KEYS = [
+  'warranty.defaultStatus',
+  'warranty.defaultMonths',
+  'warranty.defaultMileage',
+  'warranty.defaultTerms',
+  'warranty.notIncludedText',
+  'warranty.applyToQuotes',
+  'warranty.applyToWorkOrders',
+]
+
+let organizationId = ''
+/** 'km' or 'mi', whichever the workshop under test measures in. */
+let unit = ''
+let includedQuoteUrl = ''
+let notIncludedQuoteUrl = ''
+
+async function quotePdf(page: Page, quoteUrl: string): Promise<PdfContent> {
+  const id = quoteUrl.split('/').pop()
+  const response = await page.request.get(`/api/protected/quotes/${id}/pdf`, { timeout: 60_000 })
+  expect(response.status()).toBe(200)
+  return pdfContent(await response.body())
+}
+
+async function invoicePdf(page: Page, workOrderUrl: string): Promise<PdfContent> {
+  const id = workOrderUrl.split('/').pop()
+  const response = await page.request.get(`/api/protected/services/${id}/pdf`, {
+    timeout: 60_000,
+  })
+  expect(response.status()).toBe(200)
+  return pdfContent(await response.body())
+}
+
+/** "12 months / 20,000 km", whatever thousands separator the server prints. */
+function period(months: number, thousands: number): RegExp {
+  return new RegExp(`Duration: ${months} months / ${thousands}[,.\\s\\u00a0\\u202f]?000 ${unit}`)
+}
+
+/** Converts the open quote and lands on the work order made from it. */
+async function convertToWorkOrder(page: Page): Promise<string> {
+  const convert = page.getByRole('button', { name: 'Convert to Work Order', exact: true })
+  const dialog = page.getByRole('dialog', { name: 'Convert Quote to Work Order' })
+  await expect(async () => {
+    await convert.click()
+    await expect(dialog).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  const confirm = dialog.getByRole('button', { name: 'Convert', exact: true })
+  await expect(confirm).toBeEnabled()
+  await confirm.click()
+  await page.waitForURL(/\/vehicles\/[^/]+\/service\/[^/]+$/, { timeout: 60_000 })
+  await settle(page)
+  await expect(page.getByPlaceholder('Name *').first()).toHaveValue(PART)
+  return page.url()
+}
+
+test.beforeAll(async ({ browser }) => {
+  organizationId = await ownerOrganizationId()
+  unit = (await workshopSetting(organizationId, 'workshop.unitSystem')) === 'metric' ? 'km' : 'mi'
+
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await setWarrantyDefaults(page, {
+    newDocumentsSay: 'Warranty included',
+    months: 12,
+    distance: 20000,
+    terms: TERMS,
+    notIncludedText: NO_WARRANTY,
+  })
+  await page.close()
+})
+
+test.afterAll(async () => {
+  // Every other quote and invoice in the suite prints without a warranty panel.
+  for (const key of WARRANTY_KEYS) await forgetWorkshopSetting(organizationId, key)
+})
+
+test.describe('the workshop warranty, from quote to invoice', () => {
+  test('the settings page keeps what was saved', async ({ page }) => {
+    await page.goto('/settings/warranty')
+    await settle(page)
+    await expect(
+      page.getByRole('radio', { name: 'Warranty included', exact: true })
+    ).toHaveAttribute('aria-checked', 'true')
+    await expect(page.locator('#warrantyDefaultMonths')).toHaveValue('12')
+    await expect(page.locator('#warrantyDefaultMileage')).toHaveValue('20000')
+    await expect(page.locator('#warrantyDefaultTerms')).toHaveValue(TERMS)
+    await expect(page.locator('#warrantyNotIncludedText')).toHaveValue(NO_WARRANTY)
+    // The distance limit is labelled in the workshop's own unit.
+    await expect(page.getByText(`Distance limit (${unit})`, { exact: true })).toBeVisible()
+  })
+
+  test("a new quote starts from the workshop's warranty, and prints it", async ({ page }) => {
+    includedQuoteUrl = await newQuote(page, `E2E covered quote ${stamp}`)
+    await addQuotePart(page, { name: PART, quantity: 1, unitPrice: 500 })
+    await saveQuote(page)
+
+    const panel = await expectWarrantyStatement(page, 'Included')
+    await expect(panel.locator('#warrantyMonths')).toHaveValue('12')
+    await expect(panel.locator('#warrantyMileage')).toHaveValue('20000')
+    await expect(panel.locator('#warrantyNotes')).toHaveValue(TERMS)
+    // No expiry on a quote: the period starts with the work, which has no date yet.
+    await expect(panel.getByText('Expires', { exact: true })).toHaveCount(0)
+
+    // The titles in this file avoid the word, so this is the panel's heading.
+    const pdf = await quotePdf(page, includedQuoteUrl)
+    expect(pdf.flat).toMatch(/warranty/i)
+    expect(pdf.flat).toMatch(period(12, 20))
+    expect(pdf.flat).toContain(TERMS)
+    expect(pdf.flat).not.toContain('Expires:')
+  })
+
+  test('the customer reads the same warranty on the link and in its PDF', async ({ page }) => {
+    await page.goto(includedQuoteUrl)
+    const shareUrl = await quoteShareLink(page)
+
+    // Signed out, the way a customer opens a link.
+    const customer = await page.context().browser()?.newContext()
+    const customerPage = await (customer as NonNullable<typeof customer>).newPage()
+    await customerPage.goto(shareUrl)
+    await expect(
+      customerPage.getByText(period(12, 20)).filter({ visible: true }).first()
+    ).toBeVisible()
+    await expect(customerPage.getByText(TERMS).filter({ visible: true }).first()).toBeVisible()
+
+    // Absolute: a context made by hand has no base URL to resolve against.
+    const response = await customerPage.request.get(
+      new URL(quotePdfUrl(shareUrl), shareUrl).toString()
+    )
+    expect(response.status()).toBe(200)
+    const shared = await pdfContent(await response.body())
+    await customer?.close()
+
+    const downloaded = await quotePdf(page, includedQuoteUrl)
+    expect(shared.flat).toBe(downloaded.flat)
+  })
+
+  test('a period changed on the quote is the period on the work order and the invoice', async ({
+    page,
+  }) => {
+    await page.goto(includedQuoteUrl)
+    await settle(page)
+    const panel = await expectWarrantyStatement(page, 'Included')
+    await panel.getByRole('button', { name: '6 mo', exact: true }).click()
+    await expect(panel.locator('#warrantyMonths')).toHaveValue('6')
+    await saveQuote(page)
+
+    const workOrderUrl = await convertToWorkOrder(page)
+    const onJob = await expectWarrantyStatement(page, 'Included')
+    await expect(onJob.locator('#warrantyMonths')).toHaveValue('6')
+    await expect(onJob.locator('#warrantyMileage')).toHaveValue('20000')
+    await expect(onJob.locator('#warrantyNotes')).toHaveValue(TERMS)
+    // The job has a date, so the period now has an end.
+    await expect(onJob.getByText('Expires', { exact: true })).toBeVisible()
+
+    const invoice = await invoicePdf(page, workOrderUrl)
+    expect(invoice.flat).toMatch(period(6, 20))
+    expect(invoice.flat).toContain(TERMS)
+    expect(invoice.flat).toContain('Expires:')
+  })
+
+  test('"not included" swaps in the workshop\'s statement and drops the period', async ({
+    page,
+  }) => {
+    notIncludedQuoteUrl = await newQuote(page, `E2E uncovered quote ${stamp}`)
+    await addQuotePart(page, { name: PART, quantity: 1, unitPrice: 500 })
+
+    const panel = await chooseWarrantyStatement(page, 'Not included')
+    await expect(panel.locator('#warrantyMonths')).toHaveCount(0)
+    await expect(panel.locator('#warrantyMileage')).toHaveCount(0)
+    // The stock terms gave way to the stock statement; nobody had typed over them.
+    await expect(panel.locator('#warrantyNotes')).toHaveValue(NO_WARRANTY)
+    await saveQuote(page)
+
+    // Saved, not just on screen.
+    await page.reload()
+    await settle(page)
+    const reloaded = await expectWarrantyStatement(page, 'Not included')
+    await expect(reloaded.locator('#warrantyNotes')).toHaveValue(NO_WARRANTY)
+
+    const pdf = await quotePdf(page, notIncludedQuoteUrl)
+    expect(pdf.flat).toContain('No workshop warranty is included')
+    expect(pdf.flat).toContain(NO_WARRANTY)
+    expect(pdf.flat).not.toContain('Duration:')
+    expect(pdf.flat).not.toContain(TERMS)
+  })
+
+  test('with no sentence of its own, the document says statutory rights stand', async ({
+    page,
+  }) => {
+    await page.goto(notIncludedQuoteUrl)
+    await settle(page)
+    const panel = await expectWarrantyStatement(page, 'Not included')
+    await panel.locator('#warrantyNotes').fill('')
+    await saveQuote(page)
+
+    const pdf = await quotePdf(page, notIncludedQuoteUrl)
+    expect(pdf.flat).toContain('No workshop warranty is included')
+    expect(pdf.flat).toContain('Your statutory rights are not affected.')
+    expect(pdf.flat).not.toContain(NO_WARRANTY)
+  })
+
+  test('a quote that said "not included" makes a work order that says it too', async ({ page }) => {
+    await page.goto(notIncludedQuoteUrl)
+    await settle(page)
+    const workOrderUrl = await convertToWorkOrder(page)
+
+    // Not the workshop's default of twelve months: the customer accepted "none".
+    const onJob = await expectWarrantyStatement(page, 'Not included')
+    await expect(onJob.locator('#warrantyMonths')).toHaveCount(0)
+
+    const invoice = await invoicePdf(page, workOrderUrl)
+    expect(invoice.flat).toContain('No workshop warranty is included')
+    expect(invoice.flat).toContain('Your statutory rights are not affected.')
+    expect(invoice.flat).not.toContain('Duration:')
+    expect(invoice.flat).not.toContain('Expires:')
+  })
+
+  test('a new work order starts from the warranty too, and "not stated" prints nothing', async ({
+    page,
+  }) => {
+    const vehicleUrl = await seededVehicleUrl(page)
+    const workOrderUrl = await newWorkOrder(page, vehicleUrl, `E2E covered job ${stamp}`)
+    const panel = await expectWarrantyStatement(page, 'Included')
+    await expect(panel.locator('#warrantyMonths')).toHaveValue('12')
+    await expect(panel.locator('#warrantyNotes')).toHaveValue(TERMS)
+    await saveWorkOrder(page)
+    expect((await invoicePdf(page, workOrderUrl)).flat).toMatch(period(12, 20))
+
+    const cleared = await chooseWarrantyStatement(page, 'Not stated')
+    await expect(cleared.locator('#warrantyNotes')).toHaveCount(0)
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await settle(page)
+    // Folded shut again: a document that says nothing has nothing to show.
+    await expectWarrantyStatement(page, 'Not stated')
+    const silent = await invoicePdf(page, workOrderUrl)
+    expect(silent.flat).not.toMatch(/warranty/i)
+    expect(silent.flat).not.toContain(TERMS)
+  })
+
+  test('the defaults can be kept off new work orders and still reach new quotes', async ({
+    page,
+  }) => {
+    await setWarrantyDefaults(page, {
+      newDocumentsSay: 'Warranty included',
+      months: 12,
+      distance: 20000,
+      terms: TERMS,
+      notIncludedText: NO_WARRANTY,
+      onWorkOrders: false,
+    })
+
+    // `/service/new` hands back an untouched draft younger than five seconds.
+    await page.waitForTimeout(5_500)
+    const vehicleUrl = await seededVehicleUrl(page)
+    await newWorkOrder(page, vehicleUrl, `E2E bare job ${stamp}`)
+    const onJob = await expectWarrantyStatement(page, 'Not stated')
+    // The texts are still one click away.
+    const filled = await chooseWarrantyStatement(page, 'Included')
+    await expect(filled.locator('#warrantyMonths')).toHaveValue('12')
+    await expect(filled.locator('#warrantyNotes')).toHaveValue(TERMS)
+    await expect(onJob).toBeVisible()
+    // Saved, so the page is left clean rather than with an autosave pending.
+    await saveWorkOrder(page)
+
+    await newQuote(page, `E2E still-covered quote ${stamp}`)
+    await expectWarrantyStatement(page, 'Included')
+  })
+})

+ 0 - 175
e2e/specs/work-orders/layout.spec.ts

@@ -1,175 +0,0 @@
-import { expect, test } from '@playwright/test'
-import { settle } from '../../support/hydration'
-import { addPart, newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
-
-/**
- * The shape of the work order page, and the one rule underneath it: each field
- * exists once.
- *
- * Both columns used to be rendered twice, one layer per breakpoint with the
- * other hidden by CSS, which put two of every input in the document under the
- * same id and name. The form submitted the hidden copy's values, native
- * validation objected to controls the browser then refused to focus and
- * abandoned the submit in silence, and every editor row was mounted twice.
- * These tests are what keeps the page down to one copy.
- *
- * The rest is what the layout has to keep doing either way: the job and the
- * sidebar scrolling separately on a wide screen, one stack on a narrow one,
- * a sidebar that can be dragged, and a page that never grows taller than the
- * window whatever is piled into it.
- */
-
-test.describe.configure({ mode: 'serial' })
-
-const stamp = Date.now()
-
-let jobUrl = ''
-
-test.beforeAll(async ({ browser }) => {
-  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
-  const vehicleUrl = await seededVehicleUrl(page)
-  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E layout ${stamp}`)
-  await addPart(page, { name: `E2E cabin filter ${stamp}`, quantity: 1, unitPrice: 240 })
-  await saveWorkOrder(page)
-  await page.close()
-})
-
-test.describe('the work order page', () => {
-  test('holds one copy of each field, not one per breakpoint', async ({ page }) => {
-    await page.goto(jobUrl)
-    await expect(page.getByTestId('service-layout')).toBeVisible()
-    // Counted on a page that has finished arriving: across a navigation the
-    // old document and the new one can both answer for a moment, and a count
-    // taken then is a count of two pages.
-    await settle(page)
-
-    // Named fields, an id, and an editor row: one of each.
-    for (const selector of [
-      'input[name="title"]',
-      '#invoiceNumber',
-      'textarea[placeholder="Name *"]',
-    ]) {
-      await expect(page.locator(selector), `${selector} appears once`).toHaveCount(1)
-    }
-
-    // And nothing in the form shares a name with anything else in it.
-    const duplicates = await page.evaluate(() => {
-      const form = document.querySelector('form')
-      if (!form) return ['no form']
-      const seen = new Map<string, number>()
-      for (const el of form.querySelectorAll<HTMLInputElement>('input[name], textarea[name]')) {
-        seen.set(el.name, (seen.get(el.name) ?? 0) + 1)
-      }
-      return [...seen.entries()].filter(([, count]) => count > 1).map(([name]) => name)
-    })
-    expect(duplicates, 'field names used twice in the form').toEqual([])
-  })
-
-  test('scrolls the job and the sidebar separately on a wide screen', async ({ page }) => {
-    await page.setViewportSize({ width: 1440, height: 800 })
-    await page.goto(jobUrl)
-
-    const main = page.getByTestId('service-main')
-    const sidebar = page.getByTestId('service-sidebar')
-
-    // The two columns share one row of the grid, so they are the same height
-    // as the layer around them and each takes its own overflow.
-    const layerHeight = await page.getByTestId('service-layout').evaluate((el) => el.clientHeight)
-    for (const [name, column] of [
-      ['the job', main],
-      ['the sidebar', sidebar],
-    ] as const) {
-      const box = await column.evaluate((el) => ({
-        client: el.clientHeight,
-        scroll: el.scrollHeight,
-        overflowY: getComputedStyle(el).overflowY,
-      }))
-      expect(box.client, `${name} fills the row`).toBe(layerHeight)
-      expect(box.overflowY, `${name} scrolls itself`).toBe('auto')
-      expect(box.scroll, `${name} has more than fits`).toBeGreaterThan(box.client)
-    }
-
-    await main.evaluate((el) => el.scrollBy(0, 200))
-    expect(await main.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
-    expect(
-      await sidebar.evaluate((el) => el.scrollTop),
-      'scrolling the job leaves the sidebar where it was'
-    ).toBe(0)
-  })
-
-  test('never lets the page grow taller than the window', async ({ page }) => {
-    await page.setViewportSize({ width: 1440, height: 800 })
-    await page.goto(jobUrl)
-
-    // The whole reason the shell is built the way it is: content in either
-    // column must not push the document past the viewport.
-    const overflow = await page.evaluate(
-      () => (document.scrollingElement?.scrollHeight ?? 0) - window.innerHeight
-    )
-    expect(overflow, 'the document is no taller than the window').toBeLessThanOrEqual(1)
-  })
-
-  test('lets the sidebar be dragged wider', async ({ page }) => {
-    await page.setViewportSize({ width: 1440, height: 800 })
-    await page.goto(jobUrl)
-
-    const sidebar = page.getByTestId('service-sidebar')
-    const before = (await sidebar.boundingBox())?.width ?? 0
-    const handle = page.getByTestId('service-resize')
-    const grip = await handle.boundingBox()
-    expect(grip, 'the drag handle is on screen').not.toBeNull()
-
-    await page.mouse.move(grip!.x + grip!.width / 2, grip!.y + grip!.height / 2)
-    await page.mouse.down()
-    await page.mouse.move(grip!.x - 160, grip!.y + grip!.height / 2, { steps: 8 })
-    await page.mouse.up()
-
-    const after = (await sidebar.boundingBox())?.width ?? 0
-    expect(after, 'dragging left widens the sidebar').toBeGreaterThan(before + 100)
-  })
-
-  test('stacks into one scroller on a narrow screen', async ({ page }) => {
-    await page.setViewportSize({ width: 390, height: 844 })
-    await page.goto(jobUrl)
-
-    // Still one of each field, and still exactly one of them.
-    await expect(page.locator('input[name="title"]')).toHaveCount(1)
-    await expect(page.locator('input[name="title"]')).toBeVisible()
-
-    // Nothing to drag when there is nothing beside anything.
-    await expect(page.getByTestId('service-resize')).toBeHidden()
-
-    // One scroller around the pair, rather than one each.
-    const layer = page.getByTestId('service-layout')
-    expect(await layer.evaluate((el) => el.scrollHeight > el.clientHeight + 1)).toBe(true)
-    for (const id of ['service-main', 'service-sidebar']) {
-      expect(
-        await page.getByTestId(id).evaluate((el) => el.scrollHeight > el.clientHeight + 1),
-        `${id} does not scroll on its own`
-      ).toBe(false)
-    }
-
-    await layer.evaluate((el) => el.scrollBy(0, 300))
-    expect(await layer.evaluate((el) => el.scrollTop)).toBeGreaterThan(0)
-  })
-
-  test('saves from a narrow screen, with the values that are on it', async ({ page }) => {
-    await page.setViewportSize({ width: 390, height: 844 })
-    await page.goto(jobUrl)
-
-    const title = page.locator('input[name="title"]')
-    const renamed = `E2E layout narrow ${stamp}`
-    await expect(async () => {
-      await title.fill(renamed)
-      await expect(title).toHaveValue(renamed, { timeout: 2_000 })
-    }).toPass({ timeout: 30_000 })
-    await saveWorkOrder(page)
-
-    // The old shell submitted whichever copy the layout had hidden, so a
-    // narrow screen could save the desktop copy's stale title.
-    await page.reload()
-    await settle(page)
-    await expect(page.locator('input[name="title"]')).toHaveCount(1)
-    await expect(page.locator('input[name="title"]')).toHaveValue(renamed)
-  })
-})

+ 12 - 14
e2e/specs/work-orders/lifecycle.spec.ts

@@ -1,4 +1,5 @@
 import { expect, type Locator, type Page, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
 import {
   addLabor,
   addPart,
@@ -36,22 +37,17 @@ const MULTILINE_LABOR = 'Replace timing belt\nand water pump\nrefill coolant'
 let vehicleUrl = ''
 let jobUrl = ''
 
-/** The status control in the invoice details panel. */
-function statusSelect(page: Page): Locator {
-  return page
-    .getByText('Status', { exact: true })
-    .locator('xpath=ancestor::div[1]')
-    .getByRole('combobox')
+/** The status stepper in the page header: one button per stage. */
+function stage(page: Page, name: string): Locator {
+  return page.getByTestId('status-stepper').getByRole('button', { name: new RegExp(name) })
 }
 
-async function setStatus(page: Page, option: string): Promise<void> {
+/** Moves the job to a stage. A click before hydration does nothing, so it is repeated. */
+async function setStatus(page: Page, name: string): Promise<void> {
   await expect(async () => {
-    await statusSelect(page).click()
-    await expect(page.getByRole('option', { name: option, exact: true })).toBeVisible({
-      timeout: 2_000,
-    })
+    await stage(page, name).click()
+    await expect(stage(page, name)).toHaveAttribute('aria-current', 'step', { timeout: 2_000 })
   }).toPass({ timeout: 30_000 })
-  await page.getByRole('option', { name: option, exact: true }).click()
 }
 
 test.beforeAll(async ({ browser }) => {
@@ -154,7 +150,8 @@ test.describe('a work order from intake to completion', () => {
     await setStatus(page, 'In Progress')
     await saveWorkOrder(page)
     await page.reload()
-    await expect(statusSelect(page)).toContainText('In Progress')
+    await settle(page)
+    await expect(stage(page, 'In Progress')).toHaveAttribute('aria-current', 'step')
     // The badge in the header used to print the stored value beside a select
     // that said it properly, so the same job read "in-progress" and
     // "In Progress" an inch apart.
@@ -163,7 +160,8 @@ test.describe('a work order from intake to completion', () => {
     await setStatus(page, 'Completed')
     await saveWorkOrder(page)
     await page.reload()
-    await expect(statusSelect(page)).toContainText('Completed')
+    await settle(page)
+    await expect(stage(page, 'Completed')).toHaveAttribute('aria-current', 'step')
   })
 
   test('the finished job is on the work orders list with its number', async ({ page }) => {

+ 169 - 0
e2e/specs/work-orders/modern-layout.spec.ts

@@ -0,0 +1,169 @@
+import { expect, test } from '@playwright/test'
+import { settle } from '../../support/hydration'
+import { addPart, newWorkOrder, saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
+
+/**
+ * The work order page every browser opens. It is one form, so what these
+ * tests hold it to is each field once, and a save that keeps what was on
+ * screen. The classic page is still offered in the menu but is not tested.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const partName = `E2E wiper blade ${stamp}`
+
+let jobUrl = ''
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  const vehicleUrl = await seededVehicleUrl(page)
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E modern layout ${stamp}`)
+  await addPart(page, { name: partName, quantity: 2, unitPrice: 150 })
+  await saveWorkOrder(page)
+  await page.close()
+})
+
+test.describe('the work order page', () => {
+  test('opens for a browser that has not chosen a layout', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+
+    await expect(page.getByTestId('service-layout-modern')).toBeVisible()
+    await expect(page.getByTestId('service-layout')).toHaveCount(0)
+    // The top of the page is the work order's number and its status, then
+    // what the job is called.
+    const hero = page.getByTestId('service-hero')
+    await expect(hero.getByTestId('service-number')).not.toBeEmpty()
+    await expect(hero.getByTestId('service-status')).toHaveText('Pending')
+    await expect(hero).toContainText(`E2E modern layout ${stamp}`)
+  })
+
+  test('holds one copy of each field and keeps photos on the job', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+    await expect(page.getByTestId('service-layout-modern')).toBeVisible()
+
+    for (const selector of [
+      'input[name="title"]',
+      '#invoiceNumber',
+      '#invoiceDate',
+      '#mileage',
+      'input[name="techName"]',
+      'input[name="serviceDate"]',
+      'textarea[placeholder="Name *"]',
+    ]) {
+      await expect(page.locator(selector), `${selector} appears once`).toHaveCount(1)
+    }
+
+    const duplicates = await page.evaluate(() => {
+      const form = document.querySelector('form')
+      if (!form) return ['no form']
+      const seen = new Map<string, number>()
+      for (const el of form.querySelectorAll<HTMLInputElement>('input[name], textarea[name]')) {
+        seen.set(el.name, (seen.get(el.name) ?? 0) + 1)
+      }
+      return [...seen.entries()].filter(([, count]) => count > 1).map(([name]) => name)
+    })
+    expect(duplicates, 'field names used twice in the form').toEqual([])
+
+    // No tab bar at the top of the page: photos, documents, diagnostics,
+    // video and status reports are tabs of one card on the job itself.
+    const files = page.getByTestId('files-media')
+    await expect(files).toBeVisible()
+    await expect(page.getByRole('button', { name: /^Images/ })).toHaveCount(0)
+    await expect(page.getByRole('button', { name: /^Status Reports/ })).toHaveCount(0)
+    for (const name of [/^Photos/, /^Documents/, /^Diagnostics/, /^Video/, /^Status Reports/]) {
+      await expect(files.getByRole('tab', { name })).toBeVisible()
+    }
+    await expect(files.getByTestId('media-add')).toContainText('Add photos')
+    await files.getByRole('tab', { name: /^Status Reports/ }).click()
+    await expect(files.getByTestId('status-reports-section')).toBeVisible()
+
+    // The money stays on screen along the bottom, with what is done about it.
+    const bar = page.getByTestId('money-bar')
+    await expect(bar).toBeInViewport()
+    await expect(bar.getByTestId('money-balance')).toContainText('300')
+    await expect(bar.getByRole('button', { name: 'Take payment' })).toBeVisible()
+    await expect(bar.getByRole('button', { name: /Preview/ })).toBeVisible()
+    await expect(bar.getByRole('button', { name: /Send/ })).toBeVisible()
+  })
+
+  test('saves what is on screen', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+    await expect(page.getByTestId('service-layout-modern')).toBeVisible()
+
+    // The part saved when the job was made is here, with its figures.
+    await expect(page.locator('textarea[placeholder="Name *"]')).toHaveValue(partName)
+
+    // The stepper is the status control: walk the job on and save.
+    const stepper = page.getByTestId('status-stepper')
+    await stepper.getByRole('button', { name: /In Progress/i }).click()
+    await expect(stepper.getByRole('button', { name: /In Progress/i })).toHaveAttribute(
+      'aria-current',
+      'step'
+    )
+    // The title is edited in the header, behind its pencil; the form carries
+    // it as a hidden field, so Save takes it with everything else.
+    const title = `E2E modern layout ${stamp} renamed`
+    await page.getByTestId('edit-title').click()
+    await page.getByTestId('title-input').fill(title)
+    await page.getByTestId('title-input').press('Enter')
+    await expect(page.getByTestId('service-title')).toHaveText(title)
+    await expect(page.locator('input[name="title"]')).toHaveValue(title)
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await settle(page)
+    await expect(page.getByTestId('service-title').first()).toHaveText(title)
+    await expect(
+      page.getByTestId('status-stepper').getByRole('button', { name: /In Progress/i })
+    ).toHaveAttribute('aria-current', 'step')
+    await expect(page.locator('textarea[placeholder="Name *"]')).toHaveValue(partName)
+  })
+
+  test('stacks into one column on a phone without overflowing sideways', async ({ page }) => {
+    await page.setViewportSize({ width: 390, height: 844 })
+    await page.goto(jobUrl)
+    await settle(page)
+    const layout = page.getByTestId('service-layout-modern')
+    await expect(layout).toBeVisible()
+
+    // The money bar is a desktop thing: the page already carries its totals,
+    // payment form, preview and send, and a phone has no height to spare.
+    await expect(page.getByTestId('money-bar')).toBeHidden()
+    // What it leaves behind is the running total, in the flow under the parts,
+    // so adding a line on a small screen still shows what it did to the bill.
+    await expect(page.getByTestId('money-line')).toBeVisible()
+    // The header's second line is which job this is; when it was opened is
+    // left for a wider screen.
+    await expect(page.getByTestId('service-opened')).toBeHidden()
+
+    // Each control of the status stepper stays in its own space: a label is
+    // cut short rather than running into the next. Three stages, and the
+    // arrow beside "In Progress" that opens the statuses filed under it
+    // (always there, for "Waiting Parts"): four buttons in all on a workshop
+    // that has defined no statuses of its own.
+    const stages = await page
+      .getByTestId('status-stepper')
+      .getByRole('button')
+      .evaluateAll((buttons) => buttons.map((b) => b.getBoundingClientRect().toJSON()))
+    expect(stages).toHaveLength(4)
+    for (let i = 1; i < stages.length; i++) {
+      expect(
+        stages[i - 1].right,
+        `stage ${i} ends before stage ${i + 1} starts`
+      ).toBeLessThanOrEqual(stages[i].left)
+    }
+
+    const overflow = await layout.evaluate((el) => el.scrollWidth - el.clientWidth)
+    expect(overflow, 'sideways overflow in pixels').toBeLessThanOrEqual(1)
+
+    const main = await page.getByTestId('service-main').boundingBox()
+    const sidebar = await page.getByTestId('service-sidebar').boundingBox()
+    expect(sidebar?.y ?? 0, 'the sidebar sits under the job').toBeGreaterThan(
+      (main?.y ?? 0) + (main?.height ?? 0) - 1
+    )
+  })
+})

+ 510 - 0
e2e/specs/work-orders/modern-page.spec.ts

@@ -0,0 +1,510 @@
+import { expect, type Locator, type Page, test } from '@playwright/test'
+import {
+  deleteTechnicians,
+  insertTechnician,
+  jobAssignment,
+  ownerOrganizationId,
+  paymentsFor,
+} from '../../support/db'
+import { fillSettled, settle } from '../../support/hydration'
+import { makePdf, TINY_PNG } from '../../support/pdf'
+import { setInvoiceLock } from '../../support/settings'
+import {
+  addPart,
+  jobIdOf,
+  newWorkOrder,
+  partRows,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+} from '../../support/work-order'
+
+/**
+ * What the work order page does, beyond laying the fields out.
+ * `modern-layout.spec.ts` covers the one-copy-of-each-field rule; this file
+ * drives the things that exist
+ * only here or behave differently here: the job facts and the header's type,
+ * the concern story (condition, cause, correction, confirm) and the question
+ * before completing a job with a concern nobody confirmed, the files card,
+ * the technician list, the notes, and a locked invoice that still takes a
+ * status, a payment and an internal note.
+ *
+ * The jobs are made first, each with a part: `/service/new` hands back an
+ * untouched draft younger than five seconds instead of making a new one.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+let vehicleUrl = ''
+let factsJob = ''
+let filesJob = ''
+let scheduleJob = ''
+
+test.beforeAll(async ({ browser }) => {
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  vehicleUrl = await seededVehicleUrl(page)
+  factsJob = await jobWithPart(page, `E2E modern facts ${stamp}`)
+  filesJob = await jobWithPart(page, `E2E modern files ${stamp}`)
+  scheduleJob = await jobWithPart(page, `E2E modern schedule ${stamp}`)
+  await page.close()
+})
+
+async function jobWithPart(page: Page, title: string): Promise<string> {
+  const url = await newWorkOrder(page, vehicleUrl, title)
+  await addPart(page, { name: `${title} part`, quantity: 1, unitPrice: 500 })
+  await saveWorkOrder(page)
+  return url
+}
+
+/** Opens a job on the overhauled page and waits for it to be usable. */
+async function openModern(page: Page, url: string): Promise<void> {
+  await page.goto(url)
+  await settle(page)
+  await expect(page.getByTestId('service-layout-modern')).toBeVisible()
+}
+
+/**
+ * Clicks low in a notes box, well below the first line, and types. The box is
+ * taller than what is written in it, and a click in that empty space has to
+ * put the cursor in the text the way a textarea does.
+ */
+async function typeInNotes(page: Page, card: Locator, text: string): Promise<void> {
+  const area = card.getByTestId('rich-text-area')
+  const editor = card.locator('.ProseMirror')
+  await expect(async () => {
+    const box = await area.boundingBox()
+    if (!box) throw new Error('notes box not on screen')
+    await area.click({ position: { x: box.width / 2, y: box.height - 12 } })
+    await expect(editor).toBeFocused({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.keyboard.type(text)
+  await expect(editor).toContainText(text)
+}
+
+function stepper(page: Page): Locator {
+  return page.getByTestId('status-stepper')
+}
+
+async function expectStatus(page: Page, name: RegExp): Promise<void> {
+  await expect(stepper(page).getByRole('button', { name })).toHaveAttribute('aria-current', 'step')
+}
+
+test.describe('the overhauled work order page', () => {
+  test('keeps the type, set in the header, and the mileage, set on the facts card', async ({
+    page,
+  }) => {
+    await openModern(page, factsJob)
+
+    const facts = page.getByTestId('job-facts')
+    await expect(facts).toContainText('Camry')
+    await expect(facts.getByRole('link', { name: 'Open customer' })).toBeVisible()
+
+    const type = page.getByTestId('service-type')
+    await expect(async () => {
+      await type.click()
+      await expect(page.getByRole('option', { name: 'Repair' })).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.getByRole('option', { name: 'Repair' }).click()
+    await fillSettled(page.locator('#mileage'), '123456')
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await settle(page)
+    await expect(page.getByTestId('service-type')).toContainText('Repair')
+    await expect(page.locator('#mileage')).toHaveValue('123456')
+  })
+
+  test('shows the whole customer and vehicle under "More info", with the VIN one click from the clipboard', async ({
+    page,
+    context,
+    baseURL,
+  }) => {
+    await context.grantPermissions(['clipboard-read', 'clipboard-write'], {
+      origin: baseURL ?? 'http://127.0.0.1:3100',
+    })
+    await openModern(page, factsJob)
+    const facts = page.getByTestId('job-facts')
+    const details = facts.getByTestId('vehicle-details')
+    await expect(details).toHaveCount(0)
+
+    await expect(async () => {
+      await facts.getByRole('button', { name: 'More info' }).click()
+      await expect(details).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await expect(details.getByTestId('vehicle-detail-vehicle')).toContainText('Camry')
+    // The same switch opens the customer's half: every customer has a "since".
+    await expect(facts.getByTestId('customer-details')).toContainText('Customer since')
+
+    // The reason for the list: the VIN, copied without leaving the job.
+    const vinRow = details.getByTestId('vehicle-detail-vin')
+    const vin = (await vinRow.locator('dd span').first().innerText()).trim()
+    expect(vin).toMatch(/^[A-HJ-NPR-Z0-9]{17}$/)
+    await vinRow.getByRole('button', { name: 'Copy VIN' }).click()
+    await expect(vinRow.getByRole('button', { name: 'Copied' })).toBeVisible()
+    expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(vin)
+
+    // The plate on the card copies too; the vehicle's name is the link.
+    const plate = facts.getByTestId('copy-plate')
+    const plateText = (await plate.innerText()).trim()
+    await plate.click()
+    await expect(plate).toContainText('Copied')
+    expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(plateText)
+    await expect(plate).toHaveText(plateText)
+
+    // Left open, it opens again next time; closed, it stays closed.
+    await page.reload()
+    await settle(page)
+    await expect(page.getByTestId('vehicle-details')).toBeVisible()
+    await page.getByTestId('job-facts').getByRole('button', { name: 'Less info' }).click()
+    await expect(page.getByTestId('vehicle-details')).toHaveCount(0)
+    await expect(page.getByTestId('customer-details')).toHaveCount(0)
+  })
+
+  test('writes in both notes from a click anywhere in the box, and saves them', async ({
+    page,
+  }) => {
+    await openModern(page, factsJob)
+
+    const internal = `Pads measured 2 mm on the left ${stamp}`
+    const customer = `Front pads replaced, discs within limits ${stamp}`
+    await typeInNotes(page, page.getByTestId('notes-internal'), internal)
+    await typeInNotes(page, page.getByTestId('notes-customer'), customer)
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await settle(page)
+    await expect(page.getByTestId('notes-internal').locator('.ProseMirror')).toContainText(internal)
+    await expect(page.getByTestId('notes-customer').locator('.ProseMirror')).toContainText(customer)
+  })
+
+  test('tells a concern as condition, cause, correction and confirmation', async ({ page }) => {
+    await openModern(page, factsJob)
+
+    const concerns = page.getByTestId('customer-concerns')
+    const story = concerns.getByTestId('concern-story').first()
+    const words = {
+      condition: `Squeal from the front left when braking ${stamp}`,
+      cause: 'Inner pad worn to the backing plate',
+      correction: 'Replaced front pads and cleaned the slides',
+      confirm: 'Test drive, no noise',
+    }
+
+    await fillSettled(
+      story.getByPlaceholder('What the customer reported, or what you observed'),
+      words.condition
+    )
+    await story.getByTestId('concern-step-cause').click()
+    await story.getByPlaceholder('What testing showed the root cause to be').fill(words.cause)
+    await story.getByTestId('concern-step-correction').click()
+    await story.getByPlaceholder('What was done to fix it').fill(words.correction)
+    await story.getByTestId('concern-step-confirm').click()
+    await story
+      .getByPlaceholder('How the fix was checked: test drive, re-scan, symptom gone')
+      .fill(words.confirm)
+    await story.getByTestId('concern-confirmed').click()
+    await expect(story).toContainText('Stamped with your name when you save')
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await settle(page)
+    const saved = page.getByTestId('customer-concerns').getByTestId('concern-story').first()
+    await expect(saved.getByTestId('concern-step-condition')).toContainText(words.condition)
+    await expect(saved.getByTestId('concern-step-cause')).toContainText(words.cause)
+    await expect(saved.getByTestId('concern-step-correction')).toContainText(words.correction)
+    await expect(saved.getByTestId('concern-step-confirm')).toContainText(words.confirm)
+    await saved.getByTestId('concern-step-confirm').click()
+    // The stamp is the server's: who ticked it, and when.
+    await expect(saved).toContainText('Confirmed by')
+    await expect(page.getByTestId('concerns-confirmed')).toHaveText('1 of 1 confirmed')
+  })
+
+  test('asks before completing a job with a concern nobody confirmed', async ({ page }) => {
+    await openModern(page, factsJob)
+
+    const concerns = page.getByTestId('customer-concerns')
+    const stories = concerns.getByTestId('concern-story')
+    const add = concerns.getByRole('button', { name: 'Add another' })
+    // A blank row may already be waiting; otherwise ask for one.
+    if (await add.isEnabled()) {
+      const before = await stories.count()
+      await expect(async () => {
+        await add.click()
+        await expect(stories).toHaveCount(before + 1, { timeout: 2_000 })
+      }).toPass({ timeout: 30_000 })
+    }
+    const open = `Aircon smells musty ${stamp}`
+    await stories
+      .last()
+      .getByPlaceholder('What the customer reported, or what you observed')
+      .fill(open)
+    await saveWorkOrder(page)
+
+    const question = page.getByRole('alertdialog', { name: 'Complete with unconfirmed concerns?' })
+    await stepper(page)
+      .getByRole('button', { name: /Completed/ })
+      .click()
+    await expect(question).toContainText(open)
+    await question.getByRole('button', { name: 'Cancel' }).click()
+    await expect(question).toBeHidden()
+    await expect(stepper(page).getByRole('button', { name: /Completed/ })).not.toHaveAttribute(
+      'aria-current',
+      'step'
+    )
+
+    await stepper(page)
+      .getByRole('button', { name: /Completed/ })
+      .click()
+    await question.getByRole('button', { name: 'Complete anyway' }).click()
+    await expectStatus(page, /Completed/)
+    await saveWorkOrder(page)
+
+    await page.reload()
+    await settle(page)
+    await expectStatus(page, /Completed/)
+  })
+
+  test('files photos and video on the job, shown to the customer until hidden', async ({
+    page,
+  }) => {
+    await openModern(page, filesJob)
+    const files = page.getByTestId('files-media')
+    const tiles = files.getByTestId('media-tile')
+
+    // A photo: shown to the customer from the moment it lands, then hidden.
+    await files.locator('input[type="file"]').setInputFiles({
+      name: `e2e-photo-${stamp}.png`,
+      mimeType: 'image/png',
+      buffer: TINY_PNG,
+    })
+    await expect(tiles).toHaveCount(1)
+    await expect(tiles.first()).toContainText('Customer can see')
+    await expect(async () => {
+      await tiles.first().getByRole('button', { name: 'Hide from the customer' }).click()
+      await expect(tiles.first()).not.toContainText('Customer can see', { timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    // Read back: the switch turns before the write lands.
+    await expect(async () => {
+      await page.reload()
+      await settle(page)
+      await expect(page.getByTestId('media-tile').first()).not.toContainText('Customer can see', {
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+
+    // A video goes to the customer too, as on the classic page: the shared
+    // link plays it. And it plays here, not in a new tab.
+    const clip = `e2e-clip-${stamp}.mp4`
+    await files.getByRole('tab', { name: /^Video/ }).click()
+    await files.locator('input[type="file"]').setInputFiles({
+      name: clip,
+      mimeType: 'video/mp4',
+      // The server keeps the bytes as they came when it cannot re-encode them.
+      buffer: Buffer.from(`not really a video ${stamp}`),
+    })
+    await expect(tiles).toHaveCount(1)
+    await expect(tiles.first()).toContainText('Customer can see')
+    await tiles.first().getByRole('button', { name: clip }).click()
+    const player = page.getByRole('dialog', { name: clip })
+    await expect(player.locator('video')).toHaveAttribute('src', /\/api\/protected\/files\//)
+    await page.keyboard.press('Escape')
+    await expect(player).toBeHidden()
+  })
+
+  test('lands a link to an old tab on that tab of the files card', async ({ page }) => {
+    await page.goto(`${filesJob}?tab=documents`)
+    await settle(page)
+    const files = page.getByTestId('files-media')
+    await expect(files.getByRole('tab', { name: /^Documents/ })).toHaveAttribute(
+      'aria-selected',
+      'true'
+    )
+    await expect(files).toBeInViewport()
+  })
+
+  test("takes photos on a phone that scanned the desk's code, and shows them at the desk as they land", async ({
+    page,
+    browser,
+  }) => {
+    await openModern(page, factsJob)
+    const files = page.getByTestId('files-media')
+    const before = await files.getByTestId('media-tile').count()
+
+    const dialog = page.getByTestId('photo-handoff-dialog')
+    await expect(async () => {
+      await files.getByTestId('photo-handoff-open').click()
+      await expect(dialog).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    const link = dialog.getByTestId('photo-handoff-link')
+    await expect(link).toHaveValue(/\/p\/ph1\./)
+    const url = await link.inputValue()
+
+    // The phone: another browser, signed in to nothing.
+    const phone = await browser.newContext({
+      storageState: { cookies: [], origins: [] },
+      viewport: { width: 390, height: 844 },
+    })
+    const phonePage = await phone.newPage()
+    await phonePage.goto(url)
+    await settle(phonePage)
+    await expect(phonePage.getByRole('heading', { name: 'Add photos and documents' })).toBeVisible()
+    await expect(phonePage.getByRole('button', { name: 'Take photo' })).toBeVisible()
+    // The workshop's own page, as far as the customer holding the phone can tell.
+    await expect(phonePage.getByTestId('photo-handoff-workshop')).not.toBeEmpty()
+    await phonePage.getByTestId('photo-handoff-library').setInputFiles({
+      name: `e2e-phone-${stamp}.png`,
+      mimeType: 'image/png',
+      buffer: TINY_PNG,
+    })
+    await expect(phonePage.getByTestId('photo-handoff-added')).toHaveText('1 file added')
+
+    // And a customer's paperwork: a PDF goes on the job as a document.
+    await phonePage.getByTestId('photo-handoff-document').setInputFiles({
+      name: `e2e-registration-${stamp}.pdf`,
+      mimeType: 'application/pdf',
+      buffer: await makePdf([`Registration ${stamp}`]),
+    })
+    await expect(phonePage.getByTestId('photo-handoff-added')).toHaveText('2 files added')
+
+    // A link somebody changed opens nothing.
+    await phonePage.goto(url.replace(/.$/, (last) => (last === 'A' ? 'B' : 'A')))
+    await expect(phonePage.getByTestId('photo-handoff-problem')).toContainText(
+      'This link does not work'
+    )
+    await phone.close()
+
+    // At the desk the photo arrives without anybody reloading.
+    await expect(dialog.getByTestId('photo-handoff-received')).toHaveText('2 files received', {
+      timeout: 15_000,
+    })
+    await dialog.getByRole('button', { name: 'Done' }).click()
+    await expect(files.getByTestId('media-tile')).toHaveCount(before + 1)
+    await files.getByRole('tab', { name: /^Documents/ }).click()
+    // A document's name is its caption until somebody writes one.
+    await expect(files.getByPlaceholder(`e2e-registration-${stamp}.pdf`)).toHaveCount(1)
+  })
+
+  test.describe('the technician list', () => {
+    let technicianId = ''
+    const technicianName = `E2E Mechanic ${stamp}`
+
+    test.beforeAll(async () => {
+      technicianId = await insertTechnician(await ownerOrganizationId(), technicianName)
+    })
+
+    test.afterAll(async () => {
+      await deleteTechnicians([technicianId])
+    })
+
+    test('puts a technician on the job with one click', async ({ page }) => {
+      await openModern(page, scheduleJob)
+
+      const list = page.getByRole('radiogroup', { name: 'Technician' })
+      const technician = list.getByRole('radio', { name: new RegExp(technicianName) })
+      await expect(async () => {
+        await technician.click()
+        await expect(technician).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
+      }).toPass({ timeout: 30_000 })
+      await expect
+        .poll(async () => (await jobAssignment(jobIdOf(scheduleJob))).technicianId)
+        .toBe(technicianId)
+
+      await page.reload()
+      await settle(page)
+      await expect(
+        page
+          .getByRole('radiogroup', { name: 'Technician' })
+          .getByRole('radio', { name: new RegExp(technicianName) })
+      ).toHaveAttribute('aria-checked', 'true')
+      // The way to the workshop's defaults is here, as on the classic picker.
+      await expect(page.getByRole('link', { name: 'Set Defaults' })).toHaveAttribute(
+        'href',
+        '/settings/workshop'
+      )
+    })
+  })
+
+  test.describe('on a locked invoice', () => {
+    let lockedJob = ''
+
+    test.beforeAll(async ({ browser }) => {
+      const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+      // On first, so the share below is a send the lock counts.
+      await setInvoiceLock(page, { enabled: true, trigger: 'sent' })
+      lockedJob = await jobWithPart(page, `E2E modern locked ${stamp}`)
+      await shareLink(page)
+      await page.close()
+    })
+
+    test.afterAll(async ({ browser }) => {
+      const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+      await setInvoiceLock(page, { enabled: false, trigger: 'sent' })
+      await page.close()
+    })
+
+    test('freezes what is owed and nothing else', async ({ page }) => {
+      await openModern(page, lockedJob)
+      await expect(
+        page.getByText('This invoice is locked because it has been sent', { exact: true })
+      ).toBeVisible()
+
+      // Frozen: the lines, the mileage, and the notes the invoice prints.
+      await expect(partRows(page).first()).toBeDisabled()
+      await expect(page.locator('#mileage')).toBeDisabled()
+      // Still readable: the vehicle's details open on a locked invoice too.
+      await page.getByTestId('job-facts').getByRole('button', { name: 'More info' }).click()
+      await expect(page.getByTestId('vehicle-details')).toBeVisible()
+      await expect(page.getByTestId('notes-customer').locator('.ProseMirror')).toHaveAttribute(
+        'contenteditable',
+        'false'
+      )
+
+      // Still moving: the status, saved on its own. "Waiting Parts" is filed
+      // under "In Progress", in the menu behind that stage's arrow, and the
+      // stage then reads "In Progress: Waiting Parts".
+      await expect(async () => {
+        await page.getByTestId('status-menu-in-progress').click()
+        await expect(page.getByRole('menuitem', { name: 'Waiting Parts' })).toBeVisible({
+          timeout: 2_000,
+        })
+      }).toPass({ timeout: 30_000 })
+      await page.getByRole('menuitem', { name: 'Waiting Parts' }).click()
+      await expectStatus(page, /Waiting Parts/)
+
+      // Still written: the internal notes, saved on their own.
+      const note = `Customer will collect on Friday ${stamp}`
+      await typeInNotes(page, page.getByTestId('notes-internal'), note)
+      await expect(page.getByText('Saved', { exact: true })).toBeVisible()
+
+      // Still paid: the bar's button opens a form that works, and Enter in
+      // the amount records the payment rather than saving the job.
+      const bar = page.getByTestId('money-bar')
+      await bar.getByRole('button', { name: 'Take payment' }).click()
+      const amount = page.locator('#paymentAmount')
+      await expect(amount).toBeEnabled()
+      await expect(amount).toBeFocused()
+      await amount.fill('100')
+      await amount.press('Enter')
+      await expect(page.getByText('Payment recorded', { exact: true })).toBeVisible()
+      // Recording a payment offers to tell the customer; not today.
+      const notify = page.getByRole('dialog', { name: /^Notify / })
+      await expect(notify).toBeVisible()
+      await page.keyboard.press('Escape')
+      await expect(notify).toBeHidden()
+      await expect(bar.getByTestId('money-paid')).toContainText('100')
+      await expect.poll(async () => (await paymentsFor(jobIdOf(lockedJob))).length).toBe(1)
+      expect((await paymentsFor(jobIdOf(lockedJob)))[0].amount).toBe(100)
+
+      await page.reload()
+      await settle(page)
+      await expectStatus(page, /Waiting Parts/)
+      await expect(page.getByTestId('notes-internal').locator('.ProseMirror')).toContainText(note)
+      const activity = page
+        .getByRole('heading', { name: 'Activity', exact: true })
+        .locator('xpath=ancestor::*[.//ol][1]')
+      await expect(activity).toContainText('Invoice link shared')
+      await expect(activity).toContainText('Payment of')
+    })
+  })
+})

+ 2 - 2
e2e/specs/work-orders/title-template.spec.ts

@@ -7,7 +7,7 @@ import {
   workshopSetting,
 } from '../../support/db'
 import { settle } from '../../support/hydration'
-import { saveWorkOrder, seededVehicleUrl } from '../../support/work-order'
+import { saveWorkOrder, seededVehicleUrl, setTitle } from '../../support/work-order'
 
 /**
  * A new work order opens with a title the workshop chose.
@@ -151,7 +151,7 @@ test.describe('a template the workshop wrote', () => {
   test('can still be typed over on the job', async ({ page }) => {
     await saveTemplate(page, '{order_number} - {license_plate}')
     const job = await openNewJob(page)
-    await job.field.fill('Brake pads, front')
+    await setTitle(page, 'Brake pads, front')
     await saveWorkOrder(page)
     expect((await serviceRecordNames(job.id)).title).toBe('Brake pads, front')
   })

+ 0 - 11
e2e/specs/work-orders/validation.spec.ts

@@ -123,17 +123,6 @@ test.describe('a work order that cannot be saved says why', () => {
     await expectRefused(page, /cannot be negative/i)
   })
 
-  test('a job with no title is refused', async ({ page }) => {
-    await page.goto(jobUrl)
-    const title = page.locator('input[name="title"]')
-    await expect(async () => {
-      await title.fill('')
-      await expect(title).toHaveValue('', { timeout: 2_000 })
-    }).toPass({ timeout: 30_000 })
-
-    await expectRefused(page, /needs a title/i)
-  })
-
   test('the job is left as it was found, saved and correct', async ({ page }) => {
     await page.goto(jobUrl)
     await expect(totalsRow(page, 'Parts')).toContainText('$1,100.00')

+ 20 - 15
e2e/support/attachments.ts

@@ -1,7 +1,7 @@
 import { expect, type Page } from '@playwright/test'
 
 /**
- * Putting a file on a work order, through the tab that takes it.
+ * Putting a file on a work order, through the files card's tab that takes it.
  *
  * Shared because two specs need it for different reasons: one asks what an
  * attachment does to the printed invoice, the other needs a file that
@@ -10,24 +10,29 @@ import { expect, type Page } from '@playwright/test'
  */
 export async function attach(
   page: Page,
-  tab: 'Images' | 'Documents',
+  tab: 'Photos' | 'Documents',
   file: { name: string; mimeType: string; buffer: Buffer }
 ): Promise<void> {
-  // The tab counts what it holds — "Documents (1)" once there is one — so it
-  // is found by what it starts with rather than by its whole name.
+  // The tab counts what it holds, "Documents 1" once there is one, so it is
+  // found by what it starts with rather than by its whole name. The first
+  // click can land before hydration, so it is repeated until the tab turns.
+  const files = page.getByTestId('files-media')
+  const trigger = files.getByRole('tab', { name: new RegExp(`^${tab}`) })
   await expect(async () => {
-    await page.getByRole('button', { name: new RegExp(`^${tab}`) }).click()
-    await expect(page.locator('input[type="file"]').first()).toBeAttached({ timeout: 2_000 })
+    await trigger.click()
+    await expect(trigger).toHaveAttribute('aria-selected', 'true', { timeout: 2_000 })
   }).toPass({ timeout: 30_000 })
 
-  const accept = tab === 'Images' ? '.jpg,.jpeg,.png,.webp' : '.pdf,.csv,.txt'
-  await page.locator(`input[type="file"][accept="${accept}"]`).setInputFiles(file)
+  await files.locator('input[type="file"]').setInputFiles(file)
 
-  // A document is listed by name; a photograph is a thumbnail that carries
-  // its name only as the alt text.
-  const arrived =
-    tab === 'Images'
-      ? page.getByRole('img', { name: file.name })
-      : page.getByText(file.name).first()
-  await expect(arrived, `${file.name} reached the job`).toBeVisible({ timeout: 30_000 })
+  // Each file lands as a tile named for it. A photo is re-encoded in the
+  // browser on the way up and may come back with another extension.
+  const stem = file.name.replace(/\.[^.]+$/, '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+  await expect(
+    files
+      .getByTestId('media-tile')
+      .filter({ has: page.getByLabel(new RegExp(stem)) })
+      .first(),
+    `${file.name} reached the job`
+  ).toBeVisible({ timeout: 30_000 })
 }

+ 13 - 0
e2e/support/cloud.ts

@@ -89,6 +89,19 @@ export async function signUpWithPassword(
   await page.waitForURL(/\/onboarding/, { timeout: 30_000 })
 }
 
+/** Signs in with a password, from the sign-in page, and waits to be inside the app. */
+export async function signInWithPassword(
+  page: Page,
+  person: { email: string; password: string }
+): Promise<void> {
+  await page.goto('/auth/sign-in')
+  await settle(page)
+  await page.locator('#email').fill(person.email)
+  await page.locator('#password').fill(person.password)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+  await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+}
+
 /** Names the workshop and finishes onboarding, with or without the sample data. */
 export async function completeOnboarding(
   page: Page,

+ 100 - 0
e2e/support/condition-map.ts

@@ -0,0 +1,100 @@
+import { expect, type Locator, type Page } from '@playwright/test'
+import { settle } from './hydration'
+import { type PdfContent, pdfContent } from './pdf'
+
+/**
+ * Driving the vehicle condition map: the drawing on a work order's Drop-off
+ * tab and on an inspection's condition map check, the mark editor, and the
+ * documents that print it.
+ *
+ * A mark is added by clicking a panel of the drawing. Every panel is an SVG
+ * path carrying its view and panel id, so a spec names the panel it means
+ * rather than aiming at pixels. The click creates the mark at once, with the
+ * first kind on offer, and opens the editor on it; the editor saves each
+ * change as it is made.
+ */
+
+/** The map card on the page (one per work order, one per map check). */
+export function conditionMap(page: Page): Locator {
+  return page.getByTestId('condition-map').first()
+}
+
+/** Opens the job's Drop-off tab and waits for its map. */
+export async function openDropoff(page: Page, jobUrl: string): Promise<Locator> {
+  await page.goto(jobUrl)
+  await settle(page)
+  const files = page.getByTestId('files-media')
+  const tab = files.getByRole('tab', { name: /^Drop-off/ })
+  await expect(async () => {
+    await tab.click()
+    await expect(tab).toHaveAttribute('aria-selected', 'true', { timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  const map = conditionMap(page)
+  await expect(map).toBeVisible()
+  return map
+}
+
+/** The count on the Drop-off tab: its photos and this visit's marks. */
+export function dropoffTab(page: Page): Locator {
+  return page.getByTestId('files-media').getByRole('tab', { name: /^Drop-off/ })
+}
+
+/** The mark editor, whichever mark it is open on. */
+export function markEditor(page: Page): Locator {
+  return page.getByRole('dialog').filter({ has: page.getByRole('group', { name: 'Severity' }) })
+}
+
+/**
+ * Clicks a panel of the drawing and returns the editor the new mark opens in.
+ * Repeated until the editor opens: a click before hydration does nothing.
+ */
+export async function addMark(
+  page: Page,
+  map: Locator,
+  view: 'top' | 'left' | 'right' | 'front' | 'rear',
+  panel: string
+): Promise<Locator> {
+  const target = map.locator(`path[data-view="${view}"][data-panel="${panel}"]`)
+  const editor = markEditor(page)
+  await expect(async () => {
+    await target.click()
+    await expect(editor).toBeVisible({ timeout: 3_000 })
+  }).toPass({ timeout: 30_000 })
+  return editor
+}
+
+/** Closes the editor through "Done", which also keeps the note. */
+export async function closeEditor(page: Page): Promise<void> {
+  const editor = markEditor(page)
+  await editor.getByRole('button', { name: 'Done', exact: true }).click()
+  await expect(editor).toBeHidden()
+}
+
+/** This visit's marks, as the legend lists them: "Mark 1: Dent on the Left front door". */
+export function ownMarks(map: Locator): Locator {
+  return map.getByRole('region', { name: 'Marks' }).getByRole('button', { name: /^Mark \d+:/ })
+}
+
+/** The marks from other visits, drawn grey. */
+export function earlierSection(map: Locator): Locator {
+  return map.getByRole('region', { name: 'Recorded on an earlier visit' })
+}
+
+/** A PDF the page's own session may fetch, read as text. */
+export async function pdfAt(page: Page, path: string): Promise<PdfContent> {
+  const response = await page.request.get(path)
+  expect(response.status(), `${path} answers`).toBe(200)
+  return pdfContent(await response.body())
+}
+
+export const workOrderPdf = (page: Page, jobId: string) =>
+  pdfAt(page, `/api/protected/services/${jobId}/work-order-pdf`)
+export const invoicePdf = (page: Page, jobId: string) =>
+  pdfAt(page, `/api/protected/services/${jobId}/pdf`)
+export const certificatePdf = (page: Page, inspectionId: string) =>
+  pdfAt(page, `/api/protected/inspections/${inspectionId}/pdf`)
+
+/** Whether a printed document carries the condition map section at all. */
+export function printsMap(pdf: PdfContent): boolean {
+  return /VEHICLE CONDITION/i.test(pdf.flat)
+}

+ 907 - 25
e2e/support/db.ts

@@ -1,3 +1,4 @@
+import { randomBytes, scryptSync } from 'node:crypto'
 import { Client } from 'pg'
 
 /**
@@ -125,16 +126,36 @@ export interface TenantFixtures {
   quoteNumber: string
 }
 
+/** A customer and a quote, each taken whole so its id and its words agree. */
+async function pairs(
+  db: Client,
+  organizationId: string
+): Promise<Pick<TenantFixtures, 'customerId' | 'customerName' | 'quoteId' | 'quoteNumber'>> {
+  const customer = await db.query<{ id: string; name: string }>(
+    `select id, name from customers where "organizationId" = $1 order by "createdAt", id limit 1`,
+    [organizationId]
+  )
+  if (!customer.rows[0]) throw new Error('the seeded workshop has no customer')
+
+  const quote = await db.query<{ id: string; quoteNumber: string }>(
+    `select id, "quoteNumber" from quotes
+      where "organizationId" = $1 and "quoteNumber" is not null and "quoteNumber" <> ''
+      order by "createdAt", id limit 1`,
+    [organizationId]
+  )
+  if (!quote.rows[0]) throw new Error('the seeded workshop has no numbered quote')
+
+  return {
+    customerId: customer.rows[0].id,
+    customerName: customer.rows[0].name,
+    quoteId: quote.rows[0].id,
+    quoteNumber: quote.rows[0].quoteNumber,
+  }
+}
+
 export async function seededTenantFixtures(): Promise<TenantFixtures> {
   const organizationId = await ownerOrganizationId()
   return withDb(async (db) => {
-    const one = async (sql: string): Promise<string> => {
-      const result = await db.query<{ id: string }>(sql, [organizationId])
-      const id = result.rows[0]?.id
-      if (!id) throw new Error(`the seeded workshop has nothing for: ${sql}`)
-      return id
-    }
-
     /**
      * A vehicle and one of its own jobs, from one row.
      *
@@ -169,20 +190,12 @@ export async function seededTenantFixtures(): Promise<TenantFixtures> {
       vehicleId: job.vehicleId,
       serviceRecordId: job.serviceRecordId,
       vehiclePlate: job.licensePlate,
-      customerId: await one(`select id from customers where "organizationId" = $1 limit 1`),
-      quoteId: await one(
-        `select id from quotes
-          where "organizationId" = $1 and "quoteNumber" is not null and "quoteNumber" <> ''
-          limit 1`
-      ),
-      customerName: await one(
-        `select name as id from customers where "organizationId" = $1 limit 1`
-      ),
-      quoteNumber: await one(
-        `select "quoteNumber" as id from quotes
-          where "organizationId" = $1 and "quoteNumber" is not null and "quoteNumber" <> ''
-          limit 1`
-      ),
+      // Id and words from one row each, for the same reason the vehicle and
+      // its job come from one row: `limit 1` without an order is not a
+      // promise, and two queries for "a customer" can answer with two
+      // different customers. That way round the id opens one record and the
+      // name that is searched for on it belongs to another.
+      ...(await pairs(db, organizationId)),
     }
   })
 }
@@ -578,7 +591,10 @@ export async function teamInvitations(organizationId: string): Promise<number> {
  * Puts a workshop on an active Pro subscription, as a paid checkout would.
  * Returns the plan's id so the spec can take it away again.
  */
-export async function giveProPlan(organizationId: string): Promise<string> {
+export async function giveProPlan(
+  organizationId: string,
+  stripe?: { subscriptionId: string; customerId: string }
+): Promise<string> {
   return withDb(async (db) => {
     const plan = await db.query<{ id: string }>(
       `insert into subscription_plans (id, name, price, "updatedAt")
@@ -586,15 +602,28 @@ export async function giveProPlan(organizationId: string): Promise<string> {
        returning id`
     )
     const planId = plan.rows[0].id
+    // With Stripe ids the row looks like a real purchase, which is what the
+    // manage-subscription card and its buttons are shown for.
     await db.query(
-      `insert into subscriptions (id, status, "organizationId", "planId", "currentPeriodEnd", "updatedAt")
-       values (md5(random()::text || clock_timestamp()::text), 'active', $1, $2, now() + interval '30 days', now())`,
-      [organizationId, planId]
+      `insert into subscriptions (id, status, "organizationId", "planId", "currentPeriodEnd", "updatedAt",
+                                  "stripeSubscriptionId", "stripeCustomerId")
+       values (md5(random()::text || clock_timestamp()::text), 'active', $1, $2, now() + interval '30 days', now(), $3, $4)`,
+      [organizationId, planId, stripe?.subscriptionId ?? null, stripe?.customerId ?? null]
     )
     return planId
   })
 }
 
+/** Flags a subscription as ending at the period end, as a cancel through torqvoice.com would. */
+export async function setCancelAtPeriodEnd(organizationId: string, value: boolean): Promise<void> {
+  await withDb((db) =>
+    db.query(`update subscriptions set "cancelAtPeriodEnd" = $2 where "organizationId" = $1`, [
+      organizationId,
+      value,
+    ])
+  )
+}
+
 /** Takes a subscription and its plan away again. */
 export async function removePlan(organizationId: string, planId: string): Promise<void> {
   await withDb(async (db) => {
@@ -939,3 +968,856 @@ export async function forgetWorkshopSetting(organizationId: string, key: string)
     ])
   )
 }
+
+/** Marks the address verified, as clicking the mail's link would. */
+export async function markEmailVerified(email: string): Promise<void> {
+  await withDb((db) =>
+    db.query(`update users set "emailVerified" = true where lower(email) = lower($1)`, [email])
+  )
+}
+
+export interface MembershipRecord {
+  id: string
+  role: string
+  roleId: string | null
+}
+
+/** A person's membership of a workshop, as stored. */
+export async function membershipOf(
+  email: string,
+  organizationId: string
+): Promise<MembershipRecord> {
+  return withDb(async (db) => {
+    const result = await db.query<MembershipRecord>(
+      `select m.id, m.role, m."roleId" from organization_members m
+         join users u on u.id = m."userId"
+        where lower(u.email) = lower($1) and m."organizationId" = $2`,
+      [email, organizationId]
+    )
+    if (!result.rows[0]) throw new Error(`${email} is not a member of ${organizationId}`)
+    return result.rows[0]
+  })
+}
+
+/** A role that carries the admin switch and nothing else. */
+export async function createAdminRole(organizationId: string, name: string): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into roles (id, name, "isAdmin", "organizationId", "createdAt", "updatedAt")
+       values (gen_random_uuid()::text, $1, true, $2, now(), now()) returning id`,
+      [name, organizationId]
+    )
+    return result.rows[0].id
+  })
+}
+
+export async function deleteRoles(ids: string[]): Promise<void> {
+  if (ids.length === 0) return
+  await withDb((db) => db.query(`delete from roles where id = any($1::text[])`, [ids]))
+}
+
+/** A technician on a workshop's board, made here so the spec owns it. */
+export async function insertTechnician(organizationId: string, name: string): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into technicians (id, name, "organizationId", "createdAt", "updatedAt")
+       values (gen_random_uuid()::text, $1, $2, now(), now()) returning id`,
+      [name, organizationId]
+    )
+    return result.rows[0].id
+  })
+}
+
+export async function insertWorkBay(organizationId: string, name: string): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into work_bays (id, name, "organizationId", "createdAt", "updatedAt")
+       values (gen_random_uuid()::text, $1, $2, now(), now()) returning id`,
+      [name, organizationId]
+    )
+    return result.rows[0].id
+  })
+}
+
+export async function deleteTechnicians(ids: string[]): Promise<void> {
+  if (ids.length === 0) return
+  await withDb((db) => db.query(`delete from technicians where id = any($1::text[])`, [ids]))
+}
+
+export async function deleteWorkBays(ids: string[]): Promise<void> {
+  if (ids.length === 0) return
+  await withDb((db) => db.query(`delete from work_bays where id = any($1::text[])`, [ids]))
+}
+
+export interface JobAssignment {
+  id: string
+  technicianId: string | null
+  workBayId: string | null
+}
+
+/** A job's technician and bay as stored, by its id. */
+export async function jobAssignment(serviceRecordId: string): Promise<JobAssignment> {
+  return withDb(async (db) => {
+    const result = await db.query<JobAssignment>(
+      `select id, "technicianId", "workBayId" from service_records where id = $1`,
+      [serviceRecordId]
+    )
+    if (!result.rows[0]) throw new Error(`no job ${serviceRecordId}`)
+    return result.rows[0]
+  })
+}
+
+/** How many jobs a vehicle has, before and after an attempt to add one. */
+export async function jobCount(vehicleId: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from service_records where "vehicleId" = $1`,
+      [vehicleId]
+    )
+    return Number(result.rows[0].n)
+  })
+}
+
+/** Inbound WhatsApp messages with exactly this body, for a workshop. */
+export async function inboundWhatsappCount(organizationId: string, body: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from whatsapp_messages
+        where "organizationId" = $1 and direction = 'inbound' and body = $2`,
+      [organizationId, body]
+    )
+    return Number(result.rows[0].n)
+  })
+}
+
+export async function deleteInboundWhatsapp(organizationId: string, body: string): Promise<void> {
+  await withDb((db) =>
+    db.query(
+      `delete from whatsapp_messages where "organizationId" = $1 and direction = 'inbound' and body like $2`,
+      [organizationId, `${body}%`]
+    )
+  )
+}
+
+/** Open sessions a person has, however many browsers and phones that is. */
+export async function sessionCountFor(email: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from sessions s join users u on u.id = s."userId"
+        where lower(u.email) = lower($1) and s."expiresAt" > now()`,
+      [email]
+    )
+    return Number(result.rows[0].n)
+  })
+}
+
+/** Device rows a person has whose user agent mentions `needle`. */
+export async function deviceCountFor(email: string, needle: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from user_devices d join users u on u.id = d."userId"
+        where lower(u.email) = lower($1) and d."userAgent" like $2`,
+      [email, `%${needle}%`]
+    )
+    return Number(result.rows[0].n)
+  })
+}
+
+/**
+ * Matches better-auth's scrypt parameters, the same way the seed does, so a
+ * password written here is accepted by the sign-in form.
+ */
+function hashPassword(password: string): string {
+  const N = 16384
+  const r = 16
+  const p = 1
+  const salt = randomBytes(16).toString('hex')
+  const key = scryptSync(password.normalize('NFKC'), salt, 64, { N, r, p, maxmem: 128 * N * r * 2 })
+  return `${salt}:${key.toString('hex')}`
+}
+
+export interface PlantedWorkshop {
+  userId: string
+  organizationId: string
+}
+
+/**
+ * A second tenant, put straight into the database.
+ *
+ * A self-hosted install opens one workshop; every later sign-up is told to
+ * ask for an invitation. A spec that needs a second, separate workshop to
+ * prove isolation therefore cannot sign one up and has to plant it: a
+ * verified person with a password, and a workshop they own.
+ */
+export async function plantWorkshop(input: {
+  name: string
+  email: string
+  password: string
+  workshopName: string
+}): Promise<PlantedWorkshop> {
+  return withDb(async (db) => {
+    const user = await db.query<{ id: string }>(
+      `insert into users (id, name, email, "emailVerified", "termsAcceptedAt", "createdAt", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, true, now(), now(), now())
+       returning id`,
+      [input.name, input.email.toLowerCase()]
+    )
+    const userId = user.rows[0].id
+    await db.query(
+      `insert into accounts (id, "accountId", "providerId", "userId", password, "createdAt", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, 'credential', $1, $2, now(), now())`,
+      [userId, hashPassword(input.password)]
+    )
+    const org = await db.query<{ id: string }>(
+      `insert into organizations (id, name, "createdAt", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, now(), now())
+       returning id`,
+      [input.workshopName]
+    )
+    const organizationId = org.rows[0].id
+    await db.query(
+      `insert into organization_members (id, role, "userId", "organizationId")
+       values (md5(random()::text || clock_timestamp()::text), 'owner', $1, $2)`,
+      [userId, organizationId]
+    )
+    return { userId, organizationId }
+  })
+}
+
+/** Removes a person and, through the cascade, their memberships and sessions. */
+export async function deletePersonWithEmail(email: string): Promise<void> {
+  await withDb((db) => db.query(`delete from users where lower(email) = lower($1)`, [email]))
+}
+
+/** How many workshops the install has. */
+export async function organizationCount(): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ count: string }>(
+      `select count(*)::text as count from organizations`
+    )
+    return Number(result.rows[0].count)
+  })
+}
+
+/**
+ * One customer, one vehicle and one work order in a workshop, for a spec
+ * that needs a job to point at. A planted workshop has none of the sample
+ * data onboarding would have given it.
+ */
+export async function plantJob(
+  organizationId: string,
+  userId: string,
+  title: string
+): Promise<{ serviceRecordId: string; vehicleId: string }> {
+  return withDb(async (db) => {
+    const customer = await db.query<{ id: string }>(
+      `insert into customers (id, name, "userId", "organizationId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
+       returning id`,
+      [`${title} customer`, userId, organizationId]
+    )
+    const vehicle = await db.query<{ id: string }>(
+      `insert into vehicles (id, make, model, year, "userId", "organizationId", "customerId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), 'E2E', $1, 2020, $2, $3, $4, now())
+       returning id`,
+      [title, userId, organizationId, customer.rows[0].id]
+    )
+    const vehicleId = vehicle.rows[0].id
+    const job = await db.query<{ id: string }>(
+      `insert into service_records (id, title, "vehicleId", "organizationId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, now())
+       returning id`,
+      [title, vehicleId, organizationId]
+    )
+    return { serviceRecordId: job.rows[0].id, vehicleId }
+  })
+}
+
+// ─── Notifications ───────────────────────────────────────────────────────────
+
+export interface PlantedNotification {
+  type: string
+  title: string
+  message: string
+  entityType: string
+  entityId: string
+  entityUrl: string
+}
+
+/**
+ * A notification written straight into the bell, with the address the code
+ * that raises it builds. Planting it rather than provoking it lets a spec
+ * follow links whose trigger needs a provider the harness cannot play (an
+ * inbound SMS, a Telegram webhook), and also links already stored in the old
+ * shape, which the pages still have to honour.
+ */
+export async function plantNotification(
+  organizationId: string,
+  n: PlantedNotification
+): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into notifications (id, type, title, message, "entityType", "entityId", "entityUrl", read, "organizationId", "createdAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5, $6, false, $7, now())
+       returning id`,
+      [n.type, n.title, n.message, n.entityType, n.entityId, n.entityUrl, organizationId]
+    )
+    return result.rows[0].id
+  })
+}
+
+export async function deleteNotifications(ids: string[]): Promise<void> {
+  if (ids.length === 0) return
+  await withDb((db) => db.query('delete from notifications where id = any($1)', [ids]))
+}
+
+/** An inbound message on a customer's thread, as the webhook would have stored it. */
+export async function plantInboundMessage(
+  channel: 'sms' | 'telegram',
+  organizationId: string,
+  customerId: string,
+  body: string
+): Promise<void> {
+  await withDb((db) =>
+    channel === 'sms'
+      ? db.query(
+          `insert into sms_messages (id, direction, "fromNumber", "toNumber", body, status, "organizationId", "customerId", "createdAt", "updatedAt")
+           values (md5(random()::text || clock_timestamp()::text), 'inbound', '+4790000000', '+4790000001', $1, 'received', $2, $3, now(), now())`,
+          [body, organizationId, customerId]
+        )
+      : db.query(
+          `insert into telegram_messages (id, direction, "chatId", body, status, "organizationId", "customerId", "createdAt", "updatedAt")
+           values (md5(random()::text || clock_timestamp()::text), 'inbound', '777000', $1, 'received', $2, $3, now(), now())`,
+          [body, organizationId, customerId]
+        )
+  )
+}
+
+/**
+ * Links a customer to a Telegram chat and hands back what was there before.
+ * A real inbound Telegram message only ever comes from a linked chat, and the
+ * conversation shows nothing but "not connected yet" without one.
+ */
+export async function linkTelegramChat(
+  customerId: string,
+  chatId: string | null
+): Promise<string | null> {
+  return withDb(async (db) => {
+    const before = await db.query<{ telegramChatId: string | null }>(
+      'select "telegramChatId" from customers where id = $1',
+      [customerId]
+    )
+    await db.query('update customers set "telegramChatId" = $1 where id = $2', [chatId, customerId])
+    return before.rows[0]?.telegramChatId ?? null
+  })
+}
+
+export async function deleteMessagesWithBody(body: string): Promise<void> {
+  await withDb(async (db) => {
+    await db.query('delete from sms_messages where body = $1', [body])
+    await db.query('delete from telegram_messages where body = $1', [body])
+  })
+}
+
+/** A vehicle job with the customer it belongs to, taken from one row so the ids agree. */
+export async function jobWithCustomer(organizationId: string): Promise<{
+  vehicleId: string
+  serviceRecordId: string
+  customerId: string
+  customerName: string
+}> {
+  return withDb(async (db) => {
+    const result = await db.query<{
+      vehicleId: string
+      serviceRecordId: string
+      customerId: string
+      customerName: string
+    }>(
+      `select v.id as "vehicleId", s.id as "serviceRecordId", c.id as "customerId", c.name as "customerName"
+         from service_records s
+         join vehicles v on v.id = s."vehicleId"
+         join customers c on c.id = v."customerId"
+        where s."organizationId" = $1
+        order by s."createdAt" asc
+        limit 1`,
+      [organizationId]
+    )
+    const row = result.rows[0]
+    if (!row) throw new Error('the seeded workshop has no vehicle job with a customer')
+    return row
+  })
+}
+
+export interface PlantedVehicleFiles {
+  vehicleImage: string
+  jobPhoto: string
+  /** A tire set's photo, also on the job as a tire hotel copy. */
+  tireSetPhoto: string
+  statusVideo: string
+  inspectionPhoto?: string
+  quoteDocument: string
+  /** A URL naming another workshop, as a row restored from its backup can. */
+  foreignPhoto: string
+}
+
+export interface PlantedVehicle {
+  vehicleId: string
+  serviceRecordId: string
+  tireSetId: string
+  quoteId: string
+  inspected: boolean
+}
+
+/**
+ * A vehicle with every kind of file that can go with it, written straight
+ * into the database so a spec knows exactly which rows point at which file:
+ * its image; a job with a photo, a status report video and the copy of a tire
+ * set's photo; an inspection with a photo on one item (when the workshop has
+ * a template to hang it on); and, pointing at the same vehicle but not
+ * deleted with it, a stored tire set and a quote with a document.
+ */
+export async function plantVehicleWithFiles(
+  organizationId: string,
+  userId: string,
+  files: PlantedVehicleFiles,
+  label: string
+): Promise<PlantedVehicle> {
+  return withDb(async (db) => {
+    const id = () => randomBytes(12).toString('hex')
+    const vehicleId = id()
+    const serviceRecordId = id()
+    const tireSetId = id()
+    const quoteId = id()
+    await db.query(
+      `insert into vehicles (id, make, model, year, "userId", "organizationId", "imageUrl", "updatedAt")
+       values ($1, 'E2E', $2, 2020, $3, $4, $5, now())`,
+      [vehicleId, label, userId, organizationId, files.vehicleImage]
+    )
+    await db.query(
+      `insert into service_records (id, title, "vehicleId", "organizationId", "updatedAt")
+       values ($1, $2, $3, $4, now())`,
+      [serviceRecordId, `${label} job`, vehicleId, organizationId]
+    )
+    await db.query(
+      `insert into tire_sets (id, "organizationId", "userId", "vehicleId", "updatedAt")
+       values ($1, $2, $3, $4, now())`,
+      [tireSetId, organizationId, userId, vehicleId]
+    )
+    await db.query(
+      `insert into tire_set_attachments (id, "organizationId", "tireSetId", "fileName", "fileUrl", "fileType", "fileSize")
+       values ($1, $2, $3, 'rim.jpg', $4, 'image/jpeg', 10)`,
+      [id(), organizationId, tireSetId, files.tireSetPhoto]
+    )
+    for (const [fileUrl, category] of [
+      [files.jobPhoto, 'image'],
+      [files.tireSetPhoto, 'tire_hotel'],
+      [files.foreignPhoto, 'image'],
+    ]) {
+      await db.query(
+        `insert into service_attachments (id, "serviceRecordId", "fileName", "fileUrl", "fileType", "fileSize", category)
+         values ($1, $2, 'photo.jpg', $3, 'image/jpeg', 10, $4)`,
+        [id(), serviceRecordId, fileUrl, category]
+      )
+    }
+    await db.query(
+      `insert into status_reports (id, "publicToken", "organizationId", "serviceRecordId", "videoUrl", "updatedAt")
+       values ($1, $2, $3, $4, $5, now())`,
+      [id(), id(), organizationId, serviceRecordId, files.statusVideo]
+    )
+    await db.query(
+      `insert into quotes (id, title, "userId", "organizationId", "vehicleId", "updatedAt")
+       values ($1, $2, $3, $4, $5, now())`,
+      [quoteId, `${label} quote`, userId, organizationId, vehicleId]
+    )
+    await db.query(
+      `insert into quote_attachments (id, "quoteId", "fileName", "fileUrl", "fileType", "fileSize")
+       values ($1, $2, 'estimate.pdf', $3, 'application/pdf', 10)`,
+      [id(), quoteId, files.quoteDocument]
+    )
+
+    let inspected = false
+    const template = await db.query<{ id: string }>(
+      `select id from inspection_templates where "organizationId" = $1 limit 1`,
+      [organizationId]
+    )
+    if (files.inspectionPhoto && template.rows[0]) {
+      const inspectionId = id()
+      await db.query(
+        `insert into inspections (id, "vehicleId", "organizationId", "templateId", "updatedAt")
+         values ($1, $2, $3, $4, now())`,
+        [inspectionId, vehicleId, organizationId, template.rows[0].id]
+      )
+      await db.query(
+        `insert into inspection_items (id, "inspectionId", name, section, "imageUrls")
+         values ($1, $2, 'Brakes', 'Checks', $3)`,
+        [id(), inspectionId, [files.inspectionPhoto]]
+      )
+      inspected = true
+    }
+    return { vehicleId, serviceRecordId, tireSetId, quoteId, inspected }
+  })
+}
+
+/** Removes what `plantVehicleWithFiles` made that its spec did not delete. */
+export async function removePlantedVehicle(planted: PlantedVehicle): Promise<void> {
+  await withDb(async (db) => {
+    await db.query('delete from quotes where id = $1', [planted.quoteId])
+    await db.query('delete from tire_sets where id = $1', [planted.tireSetId])
+    await db.query('delete from inspections where "vehicleId" = $1', [planted.vehicleId])
+    await db.query('delete from vehicles where id = $1', [planted.vehicleId])
+  })
+}
+
+/**
+ * Every permission refusal logged for one person since a moment in time.
+ *
+ * `withAuth` writes an `auth.permissionDenied` row whenever a role is short of
+ * what an action asked for, which makes the audit log the one place that says
+ * what a page quietly wanted and did not get. A refusal on a page the role is
+ * meant to reach is, by definition, a bug: the page renders anyway, falls back
+ * to a built-in default, and says nothing about it.
+ *
+ * The write is fire-and-forget, so give it a moment to land before counting.
+ */
+export async function permissionDenialsFor(email: string, since: Date): Promise<string[]> {
+  return withDb(async (db) => {
+    const result = await db.query<{ message: string }>(
+      `select coalesce(a.message, a.action) as message
+         from audit_logs a
+         join users u on u.id = a."userId"
+        where lower(u.email) = lower($1)
+          and a.action = 'auth.permissionDenied'
+          and a.timestamp >= $2
+        order by a.timestamp`,
+      [email, since]
+    )
+    return result.rows.map((row) => row.message)
+  })
+}
+
+/**
+ * Sets one of a workshop's settings directly, returning what was there before
+ * (null when the key had never been saved), so a spec can put it back.
+ *
+ * The row needs an owner: `app_settings.userId` is not nullable, so a key the
+ * workshop has never saved is attributed to whoever owns the workshop.
+ */
+export async function setWorkshopSetting(
+  organizationId: string,
+  key: string,
+  value: string
+): Promise<string | null> {
+  return withDb(async (db) => {
+    const before = await db.query<{ value: string }>(
+      `select value from app_settings where "organizationId" = $1 and key = $2`,
+      [organizationId, key]
+    )
+    await db.query(
+      `insert into app_settings (id, key, value, "userId", "organizationId")
+       values (gen_random_uuid()::text, $2, $3,
+               (select "userId" from organization_members
+                 where "organizationId" = $1 and role = 'owner' limit 1),
+               $1)
+       on conflict ("organizationId", key) do update set value = excluded.value`,
+      [organizationId, key, value]
+    )
+    return before.rows[0]?.value ?? null
+  })
+}
+
+/**
+ * A custom field on one kind of record, made here so the spec owns it.
+ *
+ * `name` is the key the app stores values under and `label` is what a person
+ * reads, so both are stamped: the point of the field is that its label shows
+ * up on the record, and a name left over from an earlier run would collide on
+ * `(organizationId, name, entityType)`.
+ */
+export async function plantCustomField(
+  organizationId: string,
+  entityType: 'service_record' | 'quote',
+  name: string
+): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into custom_field_definitions
+         (id, name, label, "fieldType", "entityType", "sortOrder", "isActive",
+          "createdAt", "updatedAt", "userId", "organizationId")
+       values (gen_random_uuid()::text, $2, $2, 'text', $3, 0, true, now(), now(),
+               (select "userId" from organization_members
+                 where "organizationId" = $1 and role = 'owner' limit 1),
+               $1)
+       returning id`,
+      [organizationId, name, entityType]
+    )
+    return result.rows[0].id
+  })
+}
+
+/** Removes planted field definitions, and the values written into them. */
+export async function deleteCustomFields(ids: string[]): Promise<void> {
+  if (ids.length === 0) return
+  await withDb(async (db) => {
+    await db.query(`delete from custom_field_values where "fieldId" = any($1::text[])`, [ids])
+    await db.query(`delete from custom_field_definitions where id = any($1::text[])`, [ids])
+  })
+}
+
+/**
+ * The value stored in one custom field for one record, or null.
+ *
+ * Read back to prove a save from the browser reached the database rather than
+ * only the input it was typed into.
+ */
+export async function customFieldValue(fieldId: string, entityId: string): Promise<string | null> {
+  return withDb(async (db) => {
+    const result = await db.query<{ value: string }>(
+      `select value from custom_field_values where "fieldId" = $1 and "entityId" = $2`,
+      [fieldId, entityId]
+    )
+    return result.rows[0]?.value ?? null
+  })
+}
+
+/**
+ * A workshop's own role by name, as `createDefaultRoles` made it.
+ *
+ * The built-in Member role is what the product really hands somebody at the
+ * desk, so a spec about that role has to use that row rather than build an
+ * equivalent permission list by hand: a list assembled in the test would keep
+ * passing after the real role changed underneath it.
+ */
+export async function roleIdNamed(organizationId: string, name: string): Promise<string | null> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `select id from roles where "organizationId" = $1 and name = $2 limit 1`,
+      [organizationId, name]
+    )
+    return result.rows[0]?.id ?? null
+  })
+}
+
+// ─── Condition map ───────────────────────────────────────────────────────────
+
+/** Where a planted mark was drawn: a job's drop-off, or one inspection check. */
+export type MarkSheet =
+  | { serviceRecordId: string }
+  | { inspectionId: string; inspectionItemId: string }
+
+export interface PlantedMark {
+  view?: 'top' | 'left' | 'right' | 'front' | 'rear'
+  panel?: string
+  kind?: string
+  severity?: 'minor' | 'major'
+  note?: string
+  /** When it was recorded; an earlier visit's mark is planted in the past. */
+  recordedAt?: Date
+  bodyType?: string
+}
+
+/**
+ * A job on a vehicle of its own, drawn as a sedan. Each spec plants its own
+ * car, so marks from another spec or an earlier run are never on it.
+ */
+export async function plantConditionJob(
+  organizationId: string,
+  userId: string,
+  title: string,
+  openedAt?: Date
+): Promise<{ serviceRecordId: string; vehicleId: string }> {
+  const job = await plantJob(organizationId, userId, title)
+  await withDb(async (db) => {
+    await db.query(`update vehicles set "bodyType" = 'sedan' where id = $1`, [job.vehicleId])
+    if (openedAt) {
+      await db.query(`update service_records set "createdAt" = $2 where id = $1`, [
+        job.serviceRecordId,
+        openedAt,
+      ])
+    }
+  })
+  return job
+}
+
+/**
+ * An inspection on the vehicle with one condition map check, from a template
+ * of its own (the seed makes none). With `serviceRecordId` the job is linked to
+ * it, the way "Start inspection" links them.
+ */
+export async function plantConditionInspection(
+  organizationId: string,
+  vehicleId: string,
+  label: string,
+  options: {
+    serviceRecordId?: string
+    completed?: boolean
+    startedAt?: Date
+    /** A second condition map check after the first: a hand-back beside the check-in. */
+    secondCheck?: boolean
+  } = {}
+): Promise<{ inspectionId: string; inspectionItemId: string; secondItemId: string | null }> {
+  return withDb(async (db) => {
+    const id = () => randomBytes(12).toString('hex')
+    const templateId = id()
+    const sectionId = id()
+    const inspectionId = id()
+    const inspectionItemId = id()
+    await db.query(
+      `insert into inspection_templates (id, name, "organizationId", "updatedAt")
+       values ($1, $2, $3, now())`,
+      [templateId, `${label} checklist`, organizationId]
+    )
+    await db.query(
+      `insert into inspection_template_sections (id, name, "templateId") values ($1, 'Body', $2)`,
+      [sectionId, templateId]
+    )
+    await db.query(
+      `insert into inspection_template_items (id, name, "inputType", "sectionId")
+       values ($1, 'Condition map', 'condition_map', $2)`,
+      [id(), sectionId]
+    )
+    await db.query(
+      `insert into inspections (id, "vehicleId", "organizationId", "templateId", status, "completedAt", "createdAt", "updatedAt")
+       values ($1, $2, $3, $4, $5, $6, coalesce($7, now()), now())`,
+      [
+        inspectionId,
+        vehicleId,
+        organizationId,
+        templateId,
+        options.completed ? 'completed' : 'in_progress',
+        options.completed ? new Date() : null,
+        options.startedAt ?? null,
+      ]
+    )
+    await db.query(
+      `insert into inspection_items (id, "inspectionId", name, section, "inputType", condition, "sortOrder")
+       values ($1, $2, 'Condition map', 'Body', 'condition_map', 'ok', 0)`,
+      [inspectionItemId, inspectionId]
+    )
+    const secondItemId = options.secondCheck ? id() : null
+    if (secondItemId) {
+      await db.query(
+        `insert into inspection_items (id, "inspectionId", name, section, "inputType", condition, "sortOrder")
+         values ($1, $2, 'Hand-back map', 'Body', 'condition_map', 'ok', 1)`,
+        [secondItemId, inspectionId]
+      )
+    }
+    if (options.serviceRecordId) {
+      await db.query(`update service_records set "inspectionId" = $2 where id = $1`, [
+        options.serviceRecordId,
+        inspectionId,
+      ])
+    }
+    return { inspectionId, inspectionItemId, secondItemId }
+  })
+}
+
+/** One mark written straight into the table, as if drawn on that sheet. */
+export async function plantConditionMark(
+  organizationId: string,
+  vehicleId: string,
+  sheet: MarkSheet,
+  mark: PlantedMark = {}
+): Promise<string> {
+  return withDb(async (db) => {
+    const markId = randomBytes(12).toString('hex')
+    const onJob = 'serviceRecordId' in sheet
+    await db.query(
+      `insert into condition_marks
+         (id, "organizationId", "vehicleId", "serviceRecordId", "inspectionId", "inspectionItemId",
+          "bodyType", view, panel, x, y, kind, severity, note, "recordedAt", "updatedAt")
+       values ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0.5, 0.5, $10, $11, $12, coalesce($13, now()), now())`,
+      [
+        markId,
+        organizationId,
+        vehicleId,
+        onJob ? sheet.serviceRecordId : null,
+        onJob ? null : sheet.inspectionId,
+        onJob ? null : sheet.inspectionItemId,
+        mark.bodyType ?? 'sedan',
+        mark.view ?? 'left',
+        mark.panel ?? 'left_front_door',
+        mark.kind ?? 'dent',
+        mark.severity ?? 'minor',
+        mark.note ?? null,
+        mark.recordedAt ?? null,
+      ]
+    )
+    return markId
+  })
+}
+
+export interface ConditionMarkRow {
+  id: string
+  serviceRecordId: string | null
+  inspectionId: string | null
+  view: string
+  panel: string
+  kind: string
+  severity: string
+  note: string | null
+  imageUrls: string[]
+  resolvedAt: Date | null
+}
+
+/** Every mark on the vehicle, oldest first, resolved ones included. */
+export async function conditionMarksOf(vehicleId: string): Promise<ConditionMarkRow[]> {
+  return withDb(async (db) => {
+    const result = await db.query<ConditionMarkRow>(
+      `select id, "serviceRecordId", "inspectionId", view, panel, kind, severity, note,
+              "imageUrls", "resolvedAt"
+         from condition_marks where "vehicleId" = $1 order by "recordedAt", "createdAt"`,
+      [vehicleId]
+    )
+    return result.rows
+  })
+}
+
+/** Whether the job's invoice was told to carry the map: true, false, or null to follow the design. */
+export async function conditionMapOnInvoice(serviceRecordId: string): Promise<boolean | null> {
+  return withDb(async (db) => {
+    const result = await db.query<{ on: boolean | null }>(
+      `select "conditionMapOnInvoice" as on from service_records where id = $1`,
+      [serviceRecordId]
+    )
+    return result.rows[0]?.on ?? null
+  })
+}
+
+/** Another job on a car that already has one: the next visit. */
+export async function plantJobOnVehicle(
+  organizationId: string,
+  vehicleId: string,
+  title: string,
+  openedAt?: Date
+): Promise<{ serviceRecordId: string; vehicleId: string }> {
+  return withDb(async (db) => {
+    const job = await db.query<{ id: string }>(
+      `insert into service_records (id, title, "vehicleId", "organizationId", "createdAt", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, coalesce($4, now()), now())
+       returning id`,
+      [title, vehicleId, organizationId, openedAt ?? null]
+    )
+    return { serviceRecordId: job.rows[0].id, vehicleId }
+  })
+}
+
+/** A kind of mark of the workshop's own, keyed the way the settings page keys one. */
+export async function plantOwnMarkKind(
+  organizationId: string,
+  name: string
+): Promise<{ id: string; key: string; name: string }> {
+  return withDb(async (db) => {
+    const id = randomBytes(12).toString('hex')
+    const key = `own_${id}`
+    await db.query(
+      `insert into condition_mark_types (id, "organizationId", key, name, shape, color, "sortOrder", hidden, "updatedAt")
+       values ($1, $2, $3, $4, 'circle', '#7c3aed', 100, false, now())`,
+      [id, organizationId, key, name]
+    )
+    return { id, key, name }
+  })
+}
+
+export async function removeOwnMarkKind(id: string): Promise<void> {
+  await withDb((db) => db.query(`delete from condition_mark_types where id = $1`, [id]))
+}

+ 49 - 0
e2e/support/files.ts

@@ -0,0 +1,49 @@
+import { randomUUID } from 'node:crypto'
+import { existsSync } from 'node:fs'
+import { mkdir, rm, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+
+/**
+ * Real files in the app server's upload folder, for the specs that check
+ * what deleting something does to the disk. The server and the suite share
+ * a disk locally and on CI (the server runs from the repository, and so
+ * `data/uploads` is the same folder for both); a spec skips when they do not.
+ */
+
+/** Where the app server keeps a workshop's uploads, as the suite sees it. */
+export function uploadPath(organizationId: string, ...segments: string[]): string {
+  return path.join('data', 'uploads', organizationId, ...segments)
+}
+
+/** Whether this run can see the server's uploads at all. */
+export function sharesDiskWithServer(): boolean {
+  return existsSync(path.join('data', 'uploads'))
+}
+
+export interface PlantedFile {
+  /** What a row stores. */
+  url: string
+  /** Where it is on disk. */
+  path: string
+}
+
+/** A file with a name like the upload routes give, and the URL a row would hold. */
+export async function plantFile(
+  organizationId: string,
+  folder: string,
+  ext = 'jpg'
+): Promise<PlantedFile> {
+  const name = `${randomUUID()}.${ext}`
+  const file = uploadPath(organizationId, folder, name)
+  await mkdir(path.dirname(file), { recursive: true })
+  await writeFile(file, `e2e ${folder} ${name}`)
+  return { url: `/api/protected/files/${organizationId}/${folder}/${name}`, path: file }
+}
+
+export function onDisk(file: PlantedFile): boolean {
+  return existsSync(file.path)
+}
+
+export async function removePlanted(files: PlantedFile[]): Promise<void> {
+  await Promise.all(files.map((file) => rm(file.path, { force: true })))
+}

+ 111 - 0
e2e/support/settings.ts

@@ -93,6 +93,78 @@ export async function setInvoiceNumbering(
   await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
 }
 
+export interface QuoteLockSetup {
+  enabled: boolean
+  /** When a quote stops being editable: once it goes to the customer, or once they accept. */
+  trigger: 'sent' | 'accepted'
+}
+
+/**
+ * Settings → Invoice, the quote half of "Locking finished documents", saved.
+ * Off by default, so a spec that turns it on turns it off again.
+ */
+export async function setQuoteLock(
+  page: Page,
+  { enabled, trigger }: QuoteLockSetup
+): Promise<void> {
+  await page.goto('/settings/invoice')
+  await settle(page)
+
+  // The trigger select is disabled while the switch is off, so the switch goes
+  // on first and is set to what was asked for after the trigger is chosen.
+  const toggle = page.locator('#quoteLockEnabled')
+  await setSwitch(toggle, true)
+
+  const wanted = trigger === 'sent' ? 'Once the quote is sent' : 'Once the quote is accepted'
+  const select = page.locator('#quoteLockTrigger')
+  await expect(async () => {
+    await select.click()
+    await expect(page.getByRole('option', { name: wanted })).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.getByRole('option', { name: wanted }).click()
+  await expect(select).toContainText(wanted)
+
+  await setSwitch(toggle, enabled)
+  await page.getByRole('button', { name: 'Save Invoice Settings', exact: true }).click()
+  await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
+}
+
+export interface InvoiceLockSetup {
+  enabled: boolean
+  trigger: 'sent' | 'paid'
+}
+
+/**
+ * Settings → Invoice, the invoice half of "Locking finished documents", saved.
+ * Off by default, so a spec that turns it on turns it off again. Invoices sent
+ * before it was turned on stay editable, so a spec sends its job afterwards.
+ */
+export async function setInvoiceLock(
+  page: Page,
+  { enabled, trigger }: InvoiceLockSetup
+): Promise<void> {
+  await page.goto('/settings/invoice')
+  await settle(page)
+
+  // As with the quote lock: the trigger is disabled while the switch is off.
+  const toggle = page.locator('#invoiceLockEnabled')
+  await setSwitch(toggle, true)
+
+  const wanted =
+    trigger === 'sent' ? 'Once the invoice is sent' : 'Once the invoice is paid in full'
+  const select = page.locator('#invoiceLockTrigger')
+  await expect(async () => {
+    await select.click()
+    await expect(page.getByRole('option', { name: wanted })).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.getByRole('option', { name: wanted }).click()
+  await expect(select).toContainText(wanted)
+
+  await setSwitch(toggle, enabled)
+  await page.getByRole('button', { name: 'Save Invoice Settings', exact: true }).click()
+  await expect(page.getByText('Invoice settings saved', { exact: true })).toBeVisible()
+}
+
 /** What the settings page currently offers as the next invoice number. */
 export async function invoiceStartNumber(page: Page): Promise<string> {
   await page.goto('/settings/invoice')
@@ -177,3 +249,42 @@ export async function bankAccount(page: Page): Promise<string> {
   await expect(field).toBeVisible()
   return field.inputValue()
 }
+
+export interface WarrantySetup {
+  /** What a new quote or work order says before anybody touches it. */
+  newDocumentsSay: 'Nothing' | 'Warranty included' | 'No warranty'
+  months?: number
+  distance?: number
+  terms?: string
+  notIncludedText?: string
+  /** Whether the defaults reach new quotes, and new work orders. On unless said otherwise. */
+  onQuotes?: boolean
+  onWorkOrders?: boolean
+}
+
+/**
+ * Settings → Warranty, saved. Nothing is set on a seeded workshop, so a spec
+ * that writes these removes them afterwards (`forgetWorkshopSetting`): every
+ * other quote and invoice in the suite is printed without a warranty panel.
+ */
+export async function setWarrantyDefaults(page: Page, warranty: WarrantySetup): Promise<void> {
+  await page.goto('/settings/warranty')
+  await settle(page)
+
+  const choice = page.getByRole('radio', { name: warranty.newDocumentsSay, exact: true })
+  await expect(async () => {
+    await choice.click()
+    await expect(choice).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  const text = (value: number | string | undefined) => (value === undefined ? '' : String(value))
+  await fillSettled(page.locator('#warrantyDefaultMonths'), text(warranty.months))
+  await fillSettled(page.locator('#warrantyDefaultMileage'), text(warranty.distance))
+  await fillSettled(page.locator('#warrantyDefaultTerms'), text(warranty.terms))
+  await fillSettled(page.locator('#warrantyNotIncludedText'), text(warranty.notIncludedText))
+  await setSwitch(page.locator('#warrantyApplyToQuotes'), warranty.onQuotes ?? true)
+  await setSwitch(page.locator('#warrantyApplyToWorkOrders'), warranty.onWorkOrders ?? true)
+
+  await page.getByRole('button', { name: 'Save warranty settings', exact: true }).click()
+  await expect(page.getByText('Warranty settings saved', { exact: true })).toBeVisible()
+}

+ 44 - 0
e2e/support/torqvoice-com.ts

@@ -0,0 +1,44 @@
+/**
+ * The torqvoice.com stand-in, from a spec's side: what the app asked it and
+ * whether it is currently accepting the app at all.
+ */
+
+const STANDIN_URL = `http://127.0.0.1:${process.env.E2E_TORQVOICE_COM_PORT ?? '8028'}`
+
+export interface StandinCall {
+  path: string
+  body: Record<string, unknown>
+  authorized: boolean
+  at: number
+}
+
+export interface StandinState {
+  linked: boolean
+  calls: StandinCall[]
+}
+
+export async function torqvoiceComState(): Promise<StandinState> {
+  const res = await fetch(`${STANDIN_URL}/state`)
+  return (await res.json()) as StandinState
+}
+
+/** Accept or refuse the app, as the site does with a right or wrong secret. */
+export async function setTorqvoiceComLinked(linked: boolean): Promise<void> {
+  await fetch(`${STANDIN_URL}/state`, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({ linked }),
+  })
+}
+
+export async function clearTorqvoiceComCalls(): Promise<void> {
+  await fetch(`${STANDIN_URL}/state`, {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({ reset: true }),
+  })
+}
+
+export function torqvoiceComUrl(): string {
+  return STANDIN_URL
+}

+ 52 - 0
e2e/support/warranty.ts

@@ -0,0 +1,52 @@
+import { expect, type Locator, type Page } from '@playwright/test'
+
+/**
+ * The warranty panel, which is one component on the quote editor and on the
+ * work order editor. It is folded shut until a document says something about
+ * warranty, so everything here opens it first.
+ */
+
+export type WarrantyStatement = 'Not stated' | 'Included' | 'Not included'
+
+/** The panel, opened. */
+export async function warrantyPanel(page: Page): Promise<Locator> {
+  const panel = page.getByTestId('warranty-section')
+  await expect(panel).toBeVisible()
+  // Folded on the quote editor; always open on the work order page, which
+  // has no header button at all.
+  const header = panel.getByRole('button', { name: /^Warranty/ })
+  await expect(async () => {
+    if ((await header.count()) > 0 && (await header.getAttribute('aria-expanded')) !== 'true') {
+      await header.click()
+    }
+    await expect(panel.getByRole('radiogroup')).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  return panel
+}
+
+/** Which of the three statements the open document currently makes. */
+export async function expectWarrantyStatement(
+  page: Page,
+  statement: WarrantyStatement
+): Promise<Locator> {
+  const panel = await warrantyPanel(page)
+  await expect(panel.getByRole('radio', { name: statement, exact: true })).toHaveAttribute(
+    'aria-checked',
+    'true'
+  )
+  return panel
+}
+
+/** Picks a statement, retried because a click before hydration does nothing. */
+export async function chooseWarrantyStatement(
+  page: Page,
+  statement: WarrantyStatement
+): Promise<Locator> {
+  const panel = await warrantyPanel(page)
+  const choice = panel.getByRole('radio', { name: statement, exact: true })
+  await expect(async () => {
+    await choice.click()
+    await expect(choice).toHaveAttribute('aria-checked', 'true', { timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  return panel
+}

+ 25 - 7
e2e/support/work-order.ts

@@ -21,6 +21,28 @@ export async function seededVehicleUrl(page: Page, search = 'Camry'): Promise<st
   return page.url()
 }
 
+/** The work order's id, from an editor address. */
+export function jobIdOf(jobUrl: string): string {
+  const id = new URL(jobUrl).pathname.split('/').pop()
+  if (!id) throw new Error(`no job id in ${jobUrl}`)
+  return id
+}
+
+/**
+ * Retitles the job open in the editor: the title reads as text until its
+ * pencil is pressed. Kept, not saved; it goes with the rest on Save.
+ */
+export async function setTitle(page: Page, title: string): Promise<void> {
+  const field = page.getByTestId('title-input')
+  await expect(async () => {
+    await page.getByTestId('edit-title').click()
+    await expect(field).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await field.fill(title)
+  await field.press('Enter')
+  await expect(page.getByTestId('service-title').first()).toContainText(title)
+}
+
 /** A fresh draft work order on the vehicle, titled, open in the editor. */
 export async function newWorkOrder(page: Page, vehicleUrl: string, title: string): Promise<string> {
   await page.goto(`${vehicleUrl}/service/new`)
@@ -29,9 +51,7 @@ export async function newWorkOrder(page: Page, vehicleUrl: string, title: string
   // page being left and the page arriving are both in the document: two title
   // fields, and a strict-mode error instead of a retry. Settled, there is one.
   await settle(page)
-  const titleField = page.locator('input[name="title"]')
-  await expect(titleField).toBeVisible()
-  await titleField.fill(title)
+  await setTitle(page, title)
   return page.url()
 }
 
@@ -145,10 +165,8 @@ export async function saveWorkOrder(page: Page): Promise<void> {
  * parent and the figure is read from it.
  */
 export function totalsRow(page: Page, label: string): Locator {
-  // Scoped to the Totals panel: "Parts" and "Labor" are also section headings.
-  const panel = page
-    .getByRole('heading', { name: 'Totals', exact: true })
-    .locator('xpath=ancestor::div[1]')
+  // Scoped to the totals rows: "Parts" and "Labor" are also section headings.
+  const panel = page.getByTestId('totals')
   // The row is the nearest box that spreads label and figure apart; the tax
   // label sits one level deeper, beside its percentage input.
   return panel

+ 182 - 0
e2e/torqvoice-com-standin.ts

@@ -0,0 +1,182 @@
+import { createHmac, timingSafeEqual } from 'node:crypto'
+import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
+
+/**
+ * torqvoice.com as far as the app can tell.
+ *
+ * Plans are sold on the real site, which cannot be part of a test run: it
+ * has its own database, Stripe keys and deploy. This stands in for the
+ * three things the app asks of it, with the same shapes the site answers
+ * with: the bearer-secret API under /api/app/subscription/*, the checkout
+ * page a handoff lands on, and the account link that signs a person in.
+ * The app is pointed here by NEXT_PUBLIC_TORQVOICE_COM_URL and shares
+ * E2E_SERVICE_SECRET with it as TORQVOICE_SERVICE_SECRET.
+ *
+ * Tokens are verified exactly as the site verifies them, so a spec that
+ * lands on the checkout page has proved the signature, the prefix and the
+ * expiry, not just that a redirect happened. Everything the app sent is
+ * kept and handed back over /state; POST /state with {linked:false} makes
+ * the stand-in refuse the app, the way the site refuses a wrong secret.
+ * Run on its own with `npx tsx e2e/torqvoice-com-standin.ts`.
+ */
+
+const PORT = Number(process.env.E2E_TORQVOICE_COM_PORT ?? 8028)
+const SECRET = process.env.E2E_SERVICE_SECRET ?? 'e2e-service-secret-0123456789abcdef'
+
+interface Call {
+  path: string
+  body: Record<string, unknown>
+  authorized: boolean
+  /** ms since the epoch, for reading a spec's timeline afterwards */
+  at: number
+}
+
+const state = {
+  linked: true,
+  calls: [] as Call[],
+}
+
+function json(res: ServerResponse, status: number, body: unknown): void {
+  res.writeHead(status, { 'content-type': 'application/json' })
+  res.end(JSON.stringify(body))
+}
+
+function html(
+  res: ServerResponse,
+  status: number,
+  title: string,
+  rows: Record<string, string>
+): void {
+  const items = Object.entries(rows)
+    .map(
+      ([key, value]) => `<li>${key}: <code data-testid="${key}">${escapeHtml(value)}</code></li>`
+    )
+    .join('')
+  res.writeHead(status, { 'content-type': 'text/html; charset=utf-8' })
+  res.end(
+    `<!doctype html><title>${escapeHtml(title)}</title><h1>${escapeHtml(title)}</h1><ul>${items}</ul>`
+  )
+}
+
+function escapeHtml(value: string): string {
+  return value.replace(
+    /[&<>"]/g,
+    (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c
+  )
+}
+
+async function readBody(req: IncomingMessage): Promise<string> {
+  const chunks: Buffer[] = []
+  for await (const chunk of req) chunks.push(chunk as Buffer)
+  return Buffer.concat(chunks).toString('utf8')
+}
+
+/** The site's verification, byte for byte: HMAC-SHA256 over `${prefix}.${payload}`. */
+function verify(prefix: string, token: string): Record<string, unknown> | { error: string } {
+  const parts = token.split('.')
+  if (parts.length !== 3 || parts[0] !== prefix) return { error: 'malformed' }
+  const expected = createHmac('sha256', SECRET).update(`${prefix}.${parts[1]}`).digest('base64url')
+  const a = Buffer.from(expected)
+  const b = Buffer.from(parts[2])
+  if (a.length !== b.length || !timingSafeEqual(a, b)) return { error: 'signature' }
+  let payload: Record<string, unknown>
+  try {
+    payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))
+  } catch {
+    return { error: 'payload' }
+  }
+  const now = Math.floor(Date.now() / 1000)
+  if (typeof payload.exp !== 'number' || payload.exp <= now) return { error: 'expired' }
+  return payload
+}
+
+const server = createServer(async (req, res) => {
+  const url = new URL(req.url ?? '/', `http://127.0.0.1:${PORT}`)
+
+  if (url.pathname === '/health') return json(res, 200, { ok: true })
+
+  if (url.pathname === '/state') {
+    if (req.method === 'POST') {
+      const body = JSON.parse((await readBody(req)) || '{}')
+      if (typeof body.linked === 'boolean') {
+        state.linked = body.linked
+        console.log(`[torqvoice-com-standin] ${Date.now()} linked=${state.linked}`)
+      }
+      if (body.reset) state.calls = []
+      return json(res, 200, state)
+    }
+    return json(res, 200, state)
+  }
+
+  // The checkout page a handoff lands on.
+  if (req.method === 'GET' && url.pathname === '/checkout') {
+    const result = verify('tvh1', url.searchParams.get('token') ?? '')
+    if ('error' in result)
+      return html(res, 400, 'Stand-in checkout refused', { reason: String(result.error) })
+    return html(res, 200, 'Stand-in checkout', {
+      org: String(result.org),
+      plan: String(result.plan),
+      email: String(result.email),
+      appUrl: String(result.appUrl),
+    })
+  }
+
+  // The account link that signs a person in on the site.
+  if (req.method === 'GET' && url.pathname === '/api/auth/sso/app-link') {
+    const result = verify('tva1', url.searchParams.get('token') ?? '')
+    if ('error' in result)
+      return html(res, 400, 'Stand-in account refused', { reason: String(result.error) })
+    return html(res, 200, 'Stand-in account', {
+      sub: String(result.sub),
+      email: String(result.email),
+      emailVerified: String(result.emailVerified),
+    })
+  }
+
+  // Where "Manage billing" lands.
+  if (req.method === 'GET' && url.pathname.startsWith('/portal/')) {
+    return html(res, 200, 'Stand-in billing portal', { org: url.pathname.slice('/portal/'.length) })
+  }
+
+  // The app-facing API.
+  const api = url.pathname.match(/^\/api\/app\/subscription\/([a-z-]+)$/)
+  if (req.method === 'POST' && api) {
+    const path = api[1]
+    const body = JSON.parse((await readBody(req)) || '{}') as Record<string, unknown>
+    const authorized = req.headers.authorization === `Bearer ${SECRET}` && state.linked
+    state.calls.push({ path, body, authorized, at: Date.now() })
+    if (!authorized) return json(res, 401, { error: 'Unauthorized' })
+    if (typeof body.appUrl !== 'string') return json(res, 400, { error: 'Invalid request body' })
+
+    switch (path) {
+      case 'ping':
+        return json(res, 200, { linked: true, source: 'app' })
+      case 'portal':
+        return json(res, 200, { url: `http://127.0.0.1:${PORT}/portal/${body.organizationId}` })
+      case 'cancel':
+        return json(res, 200, { cancelAtPeriodEnd: true })
+      case 'resume':
+        return json(res, 200, { cancelAtPeriodEnd: false })
+      case 'end':
+        return json(res, 200, { ended: true })
+      case 'upgrade-preview':
+        return json(res, 200, {
+          amountDue: 41,
+          currency: 'usd',
+          prorationDate: Math.floor(Date.now() / 1000),
+        })
+      case 'upgrade':
+        return json(res, 200, { success: true })
+      case 'sync':
+        return json(res, 200, { checked: 1, synced: 0, errors: 0 })
+      default:
+        return json(res, 404, { error: 'Not found' })
+    }
+  }
+
+  json(res, 404, { error: 'Not found' })
+})
+
+server.listen(PORT, '127.0.0.1', () => {
+  console.log(`[torqvoice-com-standin] listening on http://127.0.0.1:${PORT}`)
+})

+ 3 - 4
messages/de/admin.json

@@ -24,15 +24,14 @@
   "overview": {
     "totalUsers": "Benutzer gesamt",
     "organizations": "Organisationen",
-    "activeSubscriptions": "Aktive Abonnements",
-    "monthlyRevenue": "Monatlicher Umsatz"
+    "activeSubscriptions": "Aktive Abonnements"
   },
   "users": {
     "searchPlaceholder": "Nach Name oder E-Mail suchen...",
     "name": "Name",
     "email": "E-Mail",
     "role": "Rolle",
-    "orgs": "Orgs",
+    "orgs": "Organisationen",
     "created": "Erstellt",
     "lastSeen": "Zuletzt gesehen",
     "noResults": "Keine Benutzer entsprechen Ihrer Suche.",
@@ -118,7 +117,7 @@
     "sendgridApiKeyHint": "Holen Sie Ihren API-Schlüssel von app.sendgrid.com"
   },
   "common": {
-    "openMenu": "Open menu"
+    "openMenu": "Menü öffnen"
   },
   "userDetails": {
     "back": "Zurück zu Benutzern",

+ 26 - 12
messages/de/aiChat.json

@@ -1,19 +1,33 @@
 {
-  "title": "KI-Assistent",
-  "description": "Stellen Sie Fragen zu Ihren Werkstattdaten — Fahrzeuge, Services, Kunden, Umsätze und mehr.",
-  "placeholder": "Fragen Sie zu Ihrer Werkstatt...",
-  "clear": "Gespräch löschen",
+  "askAi": "KI fragen",
+  "title": "KI fragen",
+  "aboutVehicle": "Fragen zu {name}, beantwortet nur aus den Daten dieses Fahrzeugs.",
+  "aboutCustomer": "Fragen zu {name}, beantwortet nur aus den Daten dieses Kunden.",
+  "placeholder": "Frage stellen...",
+  "send": "Senden",
+  "stop": "Stopp",
+  "stopped": "Abgebrochen.",
+  "newChat": "Neuer Chat",
+  "history": "Frühere Chats",
+  "noChats": "Keine früheren Chats zu diesem Datensatz",
+  "chatsPrivate": "Ihre Chats sind privat und nur für Sie sichtbar.",
+  "deleteChat": "Chat löschen",
   "error": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
+  "disclaimer": "Die Antworten werden aus den in der App gezeigten Daten erzeugt und können falsch sein. Prüfen Sie sie, bevor Sie danach handeln.",
+  "vehicleSuggestion1": "Servicehistorie zusammenfassen",
+  "vehicleSuggestion2": "Was ist fällig oder überfällig?",
+  "vehicleSuggestion3": "Gibt es wiederkehrende Probleme?",
+  "vehicleSuggestion4": "Was wurde beim letzten Besuch gemacht?",
+  "customerSuggestion1": "Welche Fahrzeuge hat dieser Kunde?",
+  "customerSuggestion2": "Gibt es unbezahlte Rechnungen?",
+  "customerSuggestion3": "Wann war der letzte Besuch und was wurde gemacht?",
+  "customerSuggestion4": "Unsere letzten Nachrichten zusammenfassen",
+  "assistantTitle": "KI-Assistent",
+  "assistantDescription": "Stellen Sie Fragen zu Ihren Werkstattdaten — Fahrzeuge, Services, Kunden, Umsätze und mehr.",
+  "assistantPlaceholder": "Fragen Sie zu Ihrer Werkstatt...",
   "notEnabled": "KI ist nicht verbunden. Verbinden Sie OpenAI oder Anthropic unter Einstellungen → Integrationen.",
   "suggestion1": "Welche Fahrzeuge sind bald fällig für den Service?",
   "suggestion2": "Zeige mir unbezahlte Rechnungen",
   "suggestion3": "Wie ist der Umsatz diesen Monat?",
-  "suggestion4": "Gib mir eine Werkstattübersicht",
-  "newChat": "Neuer Chat",
-  "noChats": "Noch keine Unterhaltungen",
-  "chatsPrivate": "Ihre Chats sind privat und nur für Sie sichtbar.",
-  "deleteChat": "Chat löschen",
-  "hideSidebar": "Seitenleiste ausblenden",
-  "showSidebar": "Seitenleiste einblenden",
-  "sendMessage": "Send message"
+  "suggestion4": "Gib mir eine Werkstattübersicht"
 }

+ 40 - 12
messages/de/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Notiz gelöscht",
     "reminder_create": "Erinnerung erstellt",
     "reminder_delete": "Erinnerung gelöscht",
+    "reminder_import": "Erinnerungen importiert",
     "organization_create": "Organisation erstellt",
     "subscription_cancel": "Abonnement gekündigt",
     "subscription_resume": "Abonnement fortgesetzt",
@@ -77,14 +78,16 @@
     "settings_updateInvoiceLayout": "Rechnungslayout aktualisiert",
     "settings_updateQuoteLayout": "Angebotslayout aktualisiert",
     "settings_updatePortalSlug": "Portal-URL aktualisiert",
-    "inspectionTemplate_create": "Inspektionsvorlage erstellt",
-    "inspectionTemplate_update": "Inspektionsvorlage aktualisiert",
-    "inspectionTemplate_delete": "Inspektionsvorlage gelöscht",
+    "inspectionTemplate_create": "Inspektions-Checkliste erstellt",
+    "inspectionTemplate_update": "Inspektions-Checkliste aktualisiert",
+    "inspectionTemplate_delete": "Inspektions-Checkliste gelöscht",
     "customField_create": "Benutzerdefiniertes Feld erstellt",
     "customField_update": "Benutzerdefiniertes Feld aktualisiert",
     "customField_delete": "Benutzerdefiniertes Feld gelöscht",
     "quoteRequest_update": "Angebotsanfrage aktualisiert",
     "auth_login": "Benutzer angemeldet",
+    "auth_sessionRevoked": "Gerät abgemeldet",
+    "auth_sessionsRevoked": "Andere Geräte abgemeldet",
     "auth_loginFailed": "Fehlgeschlagener Anmeldeversuch",
     "auth_register": "Benutzer registriert",
     "auth_passwordReset": "Passwort-Zurücksetzung angefordert",
@@ -101,7 +104,8 @@
     "webhook_delete": "Webhook gelöscht",
     "webhook_rotateSecret": "Webhook-Schlüssel rotiert",
     "inspection_reopen": "Inspektion wieder geöffnet",
-    "inspectionTemplate_import": "Inspektionsvorlage importiert",
+    "inspectionTemplate_import": "Inspektions-Checkliste importiert",
+    "inspectionTemplate_translate": "Inspektionschecklisten übersetzt",
     "scheduled_message_create": "Geplante Nachricht erstellt",
     "scheduled_message_update": "Geplante Nachricht aktualisiert",
     "scheduled_message_delete": "Geplante Nachricht gelöscht",
@@ -166,12 +170,29 @@
     "settings_deleteEmailTemplate": "E-Mail-Vorlage gelöscht",
     "settings_applyEmailTemplate": "Standard-E-Mail-Vorlage festgelegt",
     "settings_freezeInvoices": "Ältere Rechnungen gesperrt",
+    "settings_reapplyInvoiceDesign": "Rechnungsdesign erneut angewendet",
+    "invoice_reapplyDesign": "Rechnungsdesign erneut angewendet",
+    "invoice_reissue": "Gesendete Rechnung auf aktuelle Angaben aktualisiert",
     "inspection_reminders_sent": "HU-Erinnerungen gesendet",
     "inspection_reminders_send": "HU-Erinnerungen gesendet",
     "service_videoCall_send": "Link zum Videoanruf gesendet",
     "timeEntry_create": "Zeiteintrag hinzugefügt",
     "timeEntry_update": "Zeiteintrag korrigiert",
-    "timeEntry_delete": "Zeiteintrag gelöscht"
+    "timeEntry_delete": "Zeiteintrag gelöscht",
+    "settings_workOrderStatus_create": "Auftragsstatus hinzugefügt",
+    "settings_workOrderStatus_update": "Auftragsstatus geändert",
+    "settings_workOrderStatus_archive": "Auftragsstatus entfernt",
+    "inspection_photo_added": "Prüfungsfoto hinzugefügt",
+    "inspection_file_added": "Prüfungsdatei hinzugefügt",
+    "signature_save": "Unterschrift gespeichert",
+    "signature_remove": "Unterschrift entfernt",
+    "conditionMark_add": "Zustandsmarkierung hinzugefügt",
+    "conditionMark_remove": "Zustandsmarkierung entfernt",
+    "conditionMark_onInvoice": "Zustandskarte auf Rechnung",
+    "settings_markType_create": "Markierungsart hinzugefügt",
+    "settings_markType_update": "Markierungsart geändert",
+    "settings_markType_remove": "Markierungsart entfernt",
+    "settings_markType_reorder": "Markierungsarten neu geordnet"
   },
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
@@ -188,12 +209,12 @@
     "finding_delete": "Befund {id} gelöscht",
     "finding_resolve": "Befund „{description}“ erledigt",
     "finding_update": "Befund „{description}“ aktualisiert",
-    "inspectionTemplate_create": "Prüfvorlage „{name}“ erstellt",
-    "inspectionTemplate_createFromPreset": "Prüfvorlage „{name}“ aus einer Voreinstellung erstellt",
-    "inspectionTemplate_delete": "Prüfvorlage „{name}“ gelöscht",
-    "inspectionTemplate_duplicate": "Prüfvorlage nach „{name}“ dupliziert",
-    "inspectionTemplate_import": "{count, plural, one {# Prüfvorlage} other {# Prüfvorlagen}} aus einem Paket importiert",
-    "inspectionTemplate_update": "Prüfvorlage „{name}“ aktualisiert",
+    "inspectionTemplate_create": "Inspektions-Checkliste „{name}“ erstellt",
+    "inspectionTemplate_createFromPreset": "Inspektions-Checkliste „{name}“ aus einer Vorlage erstellt",
+    "inspectionTemplate_delete": "Inspektions-Checkliste „{name}“ gelöscht",
+    "inspectionTemplate_duplicate": "Inspektions-Checkliste in „{name}“ dupliziert",
+    "inspectionTemplate_import": "{count, plural, one {# Inspektions-Checkliste} other {# Inspektions-Checklisten}} aus einem Paket importiert",
+    "inspectionTemplate_update": "Inspektions-Checkliste „{name}“ aktualisiert",
     "inspection_complete": "Prüfung {id} abgeschlossen",
     "inspection_create": "Prüfung {id} angelegt",
     "inspection_delete": "Prüfung {id} gelöscht",
@@ -222,6 +243,7 @@
     "recurringInvoice_update": "Wiederkehrende Rechnung „{title}“ aktualisiert",
     "reminder_create": "Erinnerung „{title}“ erstellt",
     "reminder_delete": "Erinnerung {id} gelöscht",
+    "reminder_import": "{count, plural, one {# Erinnerung} other {# Erinnerungen}} aus {fileName} importiert",
     "role_create": "Rolle „{name}“ erstellt",
     "role_delete": "Rolle „{name}“ gelöscht",
     "role_update": "Rolle „{name}“ aktualisiert",
@@ -310,11 +332,17 @@
     "settings_deleteEmailTemplate": "Hat die E-Mail-Vorlage {name} gelöscht",
     "settings_applyEmailTemplate": "Hat {name} als Vorlage für {kind}-E-Mails festgelegt",
     "settings_freezeInvoices": "Hat {count, plural, one {# Rechnung} other {# Rechnungen}} von vor der Sperre mit dem aktuellen Design gesperrt",
+    "settings_reapplyInvoiceDesign": "Hat das aktuelle Design erneut auf {count, plural, one {# bereits versendete Rechnung} other {# bereits versendete Rechnungen}} angewendet",
     "inspection_reminders_sent": "{count} HU-Erinnerungen gesendet",
     "service_videoCall_send": "Link zum Videoanruf per {channels} gesendet",
     "timeEntry_create": "{minutes} Minuten für {technician} auf \"{job}\" hinzugefügt",
     "timeEntry_update": "Zeiteintrag für {technician} auf \"{job}\" auf {minutes} Minuten korrigiert",
     "timeEntry_stop": "Uhr für {technician} auf \"{job}\" bei {minutes} Minuten gestoppt",
-    "timeEntry_delete": "Zeiteintrag {id} gelöscht"
+    "timeEntry_delete": "Zeiteintrag {id} gelöscht",
+    "inspection_phoneUpload": "Datei vom Handy zur Prüfung {ref} hinzugefügt",
+    "condition_mark_add": "Markierung zur Zustandskarte hinzugefügt",
+    "condition_mark_remove": "Markierung von der Zustandskarte entfernt",
+    "condition_map_on_invoice_on": "Zustandskarte auf die Rechnung gesetzt",
+    "condition_map_on_invoice_off": "Zustandskarte von der Rechnung genommen"
   }
 }

+ 2 - 1
messages/de/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Mit Google fortfahren",
     "orEmail": "oder mit E-Mail fortfahren",
-    "failed": "Die Anmeldung mit Google wurde nicht abgeschlossen. Versuchen Sie es erneut oder nutzen Sie E-Mail und Passwort."
+    "failed": "Die Anmeldung mit Google wurde nicht abgeschlossen. Versuchen Sie es erneut oder nutzen Sie E-Mail und Passwort.",
+    "notLinked": "Es gibt bereits ein Konto mit dieser E-Mail-Adresse, aber die Adresse wurde noch nicht bestätigt, daher konnte Google nicht damit verknüpft werden. Melde dich mit deinem Passwort an; sobald die Adresse bestätigt ist, funktioniert auch die Anmeldung mit Google."
   },
   "forgotPassword": {
     "title": "Passwort zurücksetzen",

+ 4 - 2
messages/de/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Geöffnet",
     "deliveryViewedOn": "Zuletzt geöffnet {date}",
     "filterUnviewed": "Nicht geöffnet",
-    "filterUnviewedHint": "Rechnungen, die gesendet, aber nie geöffnet wurden"
+    "filterUnviewedHint": "Rechnungen, die gesendet, aber nie geöffnet wurden",
+    "filterCompleted": "Nur abgeschlossene",
+    "filterCompletedHint": "Nur als abgeschlossen markierte Aufträge anzeigen. Wird für das nächste Mal gespeichert."
   },
   "recurring": {
     "title": "Wiederkehrend",
@@ -85,7 +87,7 @@
     "failedDelete": "Löschen fehlgeschlagen",
     "processed": "{count} Rechnung(en) verarbeitet",
     "failedProcess": "Verarbeitung fehlgeschlagen",
-    "pause": "Pause",
+    "pause": "Pausieren",
     "resume": "Fortsetzen",
     "delete": "Löschen",
     "removeRow": "Zeile entfernen"

+ 5 - 5
messages/de/calendar.json

@@ -80,10 +80,10 @@
   },
   "selectVehicle": "Fahrzeug für Arbeitsauftrag auswählen",
   "dateChoice": {
-    "title": "Work Order Date",
-    "description": "You have {selectedDate} selected. Which date should this work order be for?",
+    "title": "Auftragsdatum",
+    "description": "Sie haben {selectedDate} ausgewählt. Für welches Datum soll dieser Arbeitsauftrag gelten?",
     "useSelected": "Use {date}",
-    "useToday": "Use today"
+    "useToday": "Heute verwenden"
   },
   "more": "+ {count} weitere",
   "monthTotal": "{count} Termine",
@@ -144,6 +144,6 @@
       "readOnly": "Belegte Zeit aus einem verbundenen Kalender. Änderungen dort erscheinen hier bei der nächsten Synchronisierung."
     }
   },
-  "previousMonth": "Previous month",
-  "nextMonth": "Next month"
+  "previousMonth": "Vorheriger Monat",
+  "nextMonth": "Nächster Monat"
 }

+ 22 - 1
messages/de/common.json

@@ -110,5 +110,26 @@
     "failed": "Die PDF-Vorschau konnte nicht erstellt werden."
   },
   "attachPdf": "PDF anhängen",
-  "attachPdfHint": "Ausschalten, um stattdessen einen Link zu senden, damit Sie sehen, wann der Kunde ihn öffnet."
+  "attachPdfHint": "Ausschalten, um stattdessen einen Link zu senden, damit Sie sehen, wann der Kunde ihn öffnet.",
+  "sentCopy": {
+    "note": "Gesendet am {date}. Änderungen hier kommen auf die Rechnung, wenn sie erneut gesendet wird.",
+    "confirmTitle": "Diese Rechnung wurde bereits gesendet",
+    "confirmBody": "Die Kopie des Kunden bleibt so, wie sie gesendet wurde. Ihre Änderung wird am Auftrag gespeichert und kommt auf die Rechnung, wenn sie wieder geöffnet und erneut gesendet wird.",
+    "confirm": "Für den nächsten Versand speichern",
+    "cancel": "Abbrechen"
+  },
+  "videoRecorder": {
+    "title": "Video aufnehmen",
+    "starting": "Kamera wird gestartet...",
+    "denied": "Der Browser durfte Kamera oder Mikrofon nicht verwenden. Erlauben Sie beides für diese Seite über die Adressleiste des Browsers und versuchen Sie es erneut.",
+    "noCamera": "Auf diesem Computer wurde keine Kamera gefunden.",
+    "unsupported": "Dieser Browser kann kein Video aufnehmen. Laden Sie stattdessen eine Datei hoch.",
+    "camera": "Kamera",
+    "start": "Aufnahme starten",
+    "stop": "Stopp",
+    "recording": "Aufnahme läuft",
+    "retake": "Neu aufnehmen",
+    "use": "Dieses Video verwenden",
+    "limit": "Die Aufnahme endet automatisch nach {minutes} Minuten."
+  }
 }

+ 154 - 0
messages/de/conditionMap.json

@@ -0,0 +1,154 @@
+{
+  "title": "Fahrzeugzustand",
+  "description": "Tippen Sie auf die Zeichnung, wo der Schaden ist. Jede Markierung bekommt Art, Schwere, Notiz und Foto.",
+  "addHint": "Tippen Sie auf ein Teil, um eine Markierung zu setzen",
+  "bodyType": "Karosserie",
+  "legend": "Markierungen",
+  "noMarks": "Noch keine Markierungen. Tippen Sie auf die Zeichnung, wo der Schaden ist.",
+  "previousHeading": "Bei einem früheren Besuch erfasst",
+  "previousHint": "Noch da seit dem letzten Mal, grau gezeichnet. Löschen Sie, was nicht mehr da ist.",
+  "newSinceLastVisit": "Neu seit dem letzten Besuch",
+  "confirmed": "Noch da",
+  "clearRepaired": "Als repariert löschen",
+  "restore": "Wiederherstellen",
+  "edit": "Markierung bearbeiten",
+  "remove": "Markierung entfernen",
+  "removeTitle": "Diese Markierung entfernen?",
+  "removeBody": "Markierung {n} an {area} und ihre Notiz werden entfernt.",
+  "kind": "Art",
+  "severity": "Schwere",
+  "note": "Notiz",
+  "notePlaceholder": "Wo genau, wie groß, was der Kunde gesagt hat",
+  "photo": "Foto",
+  "addPhoto": "Foto hinzufügen",
+  "takePhoto": "Foto aufnehmen",
+  "removePhoto": "Foto entfernen",
+  "save": "Markierung speichern",
+  "done": "Fertig",
+  "cancel": "Abbrechen",
+  "moveHint": "Ziehen Sie eine Markierung, um sie zu verschieben",
+  "enlarge": "{view} vergrößern",
+  "allViews": "Alle Ansichten",
+  "markLabel": "Markierung {n}: {kind} an {area}",
+  "markOn": "{kind} an {area}",
+  "photosOf": "Fotos von Markierung {n}",
+  "saved": "Gespeichert",
+  "saveFailed": "Markierung konnte nicht gespeichert werden",
+  "uploadFailed": "Foto konnte nicht hochgeladen werden",
+  "readOnly": "Zum Ändern der Zustandskarte wieder öffnen.",
+  "readOnlyLinked": "Auf der verknüpften Inspektion eingezeichnet. Dort ändern.",
+  "readOnlyEarlier": "Bei einem früheren Besuch erfasst. Als repariert entfernen, sobald der Schaden behoben ist.",
+  "readOnlyOtherCheck": "Auf einem anderen Prüfpunkt dieser Inspektion eingezeichnet. Dort ändern.",
+  "count": "{count, plural, =0 {Keine Markierungen} one {# Markierung} other {# Markierungen}}",
+  "previousCount": "{count, plural, one {# von einem früheren Besuch} other {# von früheren Besuchen}}",
+  "fromInspection": "Aus der verknüpften Inspektion, dort änderbar",
+  "fromOtherCheck": "Von einem anderen Prüfpunkt dieser Inspektion",
+  "elsewhere": "{count, plural, one {# Markierung wurde auf einer anderen Karosserie gesetzt und erscheint nur im Ausdruck} other {# Markierungen wurden auf einer anderen Karosserie gesetzt und erscheinen nur im Ausdruck}}",
+  "photoCount": "{count, plural, one {# Foto} other {# Fotos}}",
+  "clearBody": "Markierung {n} an {area} wird als repariert vermerkt. Sie bleibt in der Historie und wird nicht mehr gedruckt.",
+  "views": {
+    "top": "Von oben",
+    "left": "Linke Seite",
+    "right": "Rechte Seite",
+    "front": "Vorn",
+    "rear": "Hinten"
+  },
+  "bodies": {
+    "sedan": "Limousine",
+    "hatchback": "Schrägheck",
+    "estate": "Kombi",
+    "suv": "SUV",
+    "van": "Transporter",
+    "pickup": "Pickup",
+    "motorcycle": "Motorrad",
+    "boat": "Boot"
+  },
+  "kinds": {
+    "dent": "Delle",
+    "scratch": "Kratzer",
+    "chip": "Steinschlag",
+    "crack": "Riss",
+    "rust": "Rost",
+    "paint": "Lackschaden",
+    "previous_repair": "Frühere Reparatur",
+    "missing": "Fehlendes Teil"
+  },
+  "severities": {
+    "minor": "Leicht",
+    "major": "Schwer"
+  },
+  "panels": {
+    "hood": "Motorhaube",
+    "roof": "Dach",
+    "trunk": "Kofferraumdeckel",
+    "tailgate": "Heckklappe",
+    "front_bumper": "Stoßstange vorn",
+    "rear_bumper": "Stoßstange hinten",
+    "grille": "Kühlergrill",
+    "windshield": "Windschutzscheibe",
+    "rear_window": "Heckscheibe",
+    "left_headlight": "Scheinwerfer links",
+    "right_headlight": "Scheinwerfer rechts",
+    "left_taillight": "Rücklicht links",
+    "right_taillight": "Rücklicht rechts",
+    "left_front_fender": "Kotflügel vorn links",
+    "right_front_fender": "Kotflügel vorn rechts",
+    "left_front_door": "Tür vorn links",
+    "right_front_door": "Tür vorn rechts",
+    "left_rear_door": "Tür hinten links",
+    "right_rear_door": "Tür hinten rechts",
+    "left_rear_quarter": "Seitenteil hinten links",
+    "right_rear_quarter": "Seitenteil hinten rechts",
+    "left_sill": "Schweller links",
+    "right_sill": "Schweller rechts",
+    "left_mirror": "Spiegel links",
+    "right_mirror": "Spiegel rechts",
+    "left_front_wheel": "Rad vorn links",
+    "right_front_wheel": "Rad vorn rechts",
+    "left_rear_wheel": "Rad hinten links",
+    "right_rear_wheel": "Rad hinten rechts",
+    "left_a_pillar": "A-Säule links",
+    "right_a_pillar": "A-Säule rechts",
+    "left_windows": "Seitenscheiben links",
+    "right_windows": "Seitenscheiben rechts",
+    "left_sliding_door": "Schiebetür links",
+    "right_sliding_door": "Schiebetür rechts",
+    "left_side_panel": "Seitenwand links",
+    "right_side_panel": "Seitenwand rechts",
+    "rear_doors": "Hecktüren",
+    "bed": "Ladefläche",
+    "bed_side_left": "Ladeflächenwand links",
+    "bed_side_right": "Ladeflächenwand rechts",
+    "tank": "Tank",
+    "seat": "Sitz",
+    "front_fairing": "Frontverkleidung",
+    "left_fairing": "Verkleidung links",
+    "right_fairing": "Verkleidung rechts",
+    "front_fender": "Schutzblech vorn",
+    "rear_fender": "Schutzblech hinten",
+    "front_wheel": "Vorderrad",
+    "rear_wheel": "Hinterrad",
+    "exhaust": "Auspuff",
+    "handlebars": "Lenker",
+    "headlamp": "Scheinwerfer",
+    "tail": "Heck",
+    "bow": "Bug",
+    "stern": "Heck",
+    "transom": "Spiegel (Heck)",
+    "port_hull": "Rumpf Backbord",
+    "starboard_hull": "Rumpf Steuerbord",
+    "deck": "Deck",
+    "cabin": "Kabine",
+    "windscreen": "Windschutzscheibe",
+    "gunwale_port": "Dollbord Backbord",
+    "gunwale_starboard": "Dollbord Steuerbord",
+    "keel": "Kiel",
+    "outboard": "Außenborder"
+  },
+  "print": {
+    "previous": "früher erfasst"
+  },
+  "manageKinds": "Arten verwalten",
+  "photosHeading": "Fotos",
+  "previousShort": "früher"
+}

+ 2 - 2
messages/de/customers.json

@@ -27,7 +27,7 @@
     "batchDeleteTitle": "Kunden löschen",
     "batchDeleteDescription": "{count} Kunde(n) löschen? Die zugehörigen Fahrzeuge werden dabei entknüpft.",
     "batchDeleteSuccess": "{count} Kunde(n) gelöscht",
-    "openMenu": "Open menu"
+    "openMenu": "Menü öffnen"
   },
   "detail": {
     "backToCustomers": "Zurück zur Kundenliste",
@@ -95,7 +95,7 @@
     "customerCreated": "Kunde angelegt",
     "saveError": "Kunde konnte nicht gespeichert werden",
     "customerNumber": "Kundennr.",
-    "customerNumberAuto": "Auto",
+    "customerNumberAuto": "Automatisch",
     "sectionContact": "Kontakt",
     "sectionBilling": "Rechnung",
     "addScannedVehicle": "Fahrzeug {vehicle} anlegen",

+ 6 - 6
messages/de/dashboard.json

@@ -74,8 +74,8 @@
     "noDismissed": "Keine ausgeblendeten Fahrzeuge.",
     "dismissedOn": "Verworfen am {date}",
     "restore": "Wiederherstellen",
-    "settingsAriaLabel": "Maintenance settings",
-    "dismissAriaLabel": "Dismiss"
+    "settingsAriaLabel": "Wartungseinstellungen",
+    "dismissAriaLabel": "Ausblenden"
   },
   "reminders": {
     "title": "Anstehende Erinnerungen",
@@ -112,7 +112,7 @@
     "createQuote": "Angebot erstellen",
     "itemsRequested": "{count, plural, one {# Position angefragt} other {# Positionen angefragt}}",
     "noData": "Keine ausstehenden Angebotsanfragen.",
-    "dismissAriaLabel": "Dismiss quote request"
+    "dismissAriaLabel": "Angebotsanfrage ausblenden"
   },
   "quoteResponses": {
     "title": "Angebotsantworten der Kunden",
@@ -120,11 +120,11 @@
     "accepted": "Angenommen",
     "changesRequested": "Änderungen gewünscht",
     "convertToWorkOrder": "In Arbeitsauftrag umwandeln",
-    "convert": "Convert",
+    "convert": "Umwandeln",
     "viewQuote": "Angebot anzeigen",
-    "view": "View",
+    "view": "Anzeigen",
     "noData": "Keine Kundenantworten.",
-    "dismissAriaLabel": "Dismiss response"
+    "dismissAriaLabel": "Antwort ausblenden"
   },
   "recentCompleted": {
     "title": "Kürzlich abgeschlossen",

+ 3 - 0
messages/de/dataImport.json

@@ -180,6 +180,8 @@
       "transmission": "Getriebe",
       "engineSize": "Hubraum",
       "engineCode": "Motorcode",
+      "hsn": "HSN (Herstellerschlüsselnummer)",
+      "tsn": "TSN (Typschlüsselnummer)",
       "purchaseDate": "Kaufdatum",
       "purchasePrice": "Kaufpreis"
     },
@@ -198,6 +200,7 @@
     "customer_name_required": "Kundenname fehlt",
     "invalid_email": "E-Mail \"{value}\" ist ungültig und wurde weggelassen",
     "invalid_vin": "FIN \"{value}\" hat nicht 17 Zeichen; wie eingegeben übernommen",
+    "invalid_type_key": "{field} \"{value}\" ist keine gültige HSN oder TSN und wurde ausgelassen",
     "vehicle_make_model_required": "Marke und Modell fehlen",
     "vehicle_year_required": "Baujahr fehlt",
     "vehicle_year_invalid": "Baujahr \"{value}\" ist kein gültiges Jahr",

+ 17 - 17
messages/de/documentLock.json

@@ -1,27 +1,27 @@
 {
   "lockedTitle": {
     "invoice": {
-      "sent": "This invoice is locked because it has been sent",
-      "paid": "This invoice is locked because it has been paid"
+      "sent": "Diese Rechnung ist gesperrt, weil sie gesendet wurde",
+      "paid": "Diese Rechnung ist gesperrt, weil sie bezahlt wurde"
     },
     "quote": {
-      "sent": "This quote is locked because it has been sent",
-      "accepted": "This quote is locked because it has been accepted"
+      "sent": "Dieses Angebot ist gesperrt, weil es gesendet wurde",
+      "accepted": "Dieses Angebot ist gesperrt, weil es angenommen wurde"
     }
   },
-  "lockedBody": "Its parts, labour, prices, discount, tax, number and date can no longer be changed. Payments, status changes, sending and attachments all still work.",
-  "lockedAdminHint": "As an owner or admin you can unlock it if something genuinely needs correcting. The unlock is recorded in the audit log.",
-  "lockedMemberHint": "Ask an owner or admin to unlock it if something needs correcting.",
-  "unlock": "Unlock for editing",
-  "relock": "Lock again",
-  "unlockConfirmTitle": "Unlock this document?",
+  "lockedBody": "Teile, Arbeitsleistung, Preise, Rabatt, Steuer, Nummer und Datum können nicht mehr geändert werden. Zahlungen, Statusänderungen, Versand und Anhänge funktionieren weiterhin.",
+  "lockedAdminHint": "Als Inhaber oder Admin können Sie die Sperre aufheben, wenn wirklich etwas korrigiert werden muss. Das Entsperren wird im Prüfprotokoll festgehalten.",
+  "lockedMemberHint": "Bitten Sie einen Inhaber oder Admin, die Sperre aufzuheben, wenn etwas korrigiert werden muss.",
+  "unlock": "Zum Bearbeiten entsperren",
+  "relock": "Wieder sperren",
+  "unlockConfirmTitle": "Dieses Dokument entsperren?",
   "unlockConfirmDescription": {
-    "invoice": "It will become editable again, and the change will be recorded in the audit log with your name. Prefer a credit note where your local rules require one.",
-    "quote": "It will become editable again, and the change will be recorded in the audit log with your name."
+    "invoice": "Es wird wieder bearbeitbar, und die Änderung wird mit Ihrem Namen im Prüfprotokoll festgehalten. Erstellen Sie lieber eine Gutschrift, wo die Vorschriften in Ihrem Land das verlangen.",
+    "quote": "Es wird wieder bearbeitbar, und die Änderung wird mit Ihrem Namen im Prüfprotokoll festgehalten."
   },
-  "unlocked": "Unlocked for editing",
-  "relocked": "Locked again",
-  "reopenedTitle": "This document has been unlocked for editing",
-  "reopenedBody": "It stays editable until it is locked again.",
-  "failed": "Could not change the lock"
+  "unlocked": "Zum Bearbeiten entsperrt",
+  "relocked": "Wieder gesperrt",
+  "reopenedTitle": "Dieses Dokument wurde zum Bearbeiten entsperrt",
+  "reopenedBody": "Es bleibt bearbeitbar, bis es wieder gesperrt wird.",
+  "failed": "Die Sperre konnte nicht geändert werden"
 }

+ 1870 - 0
messages/de/inspectionLibrary.json

@@ -0,0 +1,1870 @@
+{
+  "presets": {
+    "eu-roadworthiness": {
+      "name": "EU-Hauptuntersuchung (regelmäßige technische Überwachung)",
+      "description": "Die vollständige Prüfliste aus Anhang I der Richtlinie 2014/45/EU, bewertet nach geringfügigen, erheblichen und gefährlichen Mängeln. Die Grenzwerte sind mit den üblichen EU-Werten vorbelegt; passen Sie sie an Ihre nationale Prüfung an.",
+      "standardLabel": "Richtlinie 2014/45/EU"
+    },
+    "eu-roadworthiness-motorcycle": {
+      "name": "EU-Prüfung für Krafträder (Klasse L)",
+      "description": "Anhang I, reduziert auf die Prüfpunkte für zwei- und dreirädrige Fahrzeuge, mit Profiltiefen-Grenzwerten für Fahrzeuge der Klasse L.",
+      "standardLabel": "Richtlinie 2014/45/EU, Klasse L"
+    },
+    "no-eu-kontroll": {
+      "name": "Norwegen: EU-kontroll",
+      "description": "Kontrollinstruks 4.1 der Statens vegvesen für Personenkraftwagen und Lieferwagen bis einschließlich 3500 kg, mit der eigenen Nummerierung, den Mängelgründen, Bewertungen und Grenzwerten der Instruks. Prüfen Sie, ob es eine neuere Version der Instruks gibt, bevor Sie eine kontrollseddel (Prüfbescheinigung) ausstellen.",
+      "standardLabel": "EU-kontroll, kontrollinstruks 4.1"
+    },
+    "de-hauptuntersuchung": {
+      "name": "Deutschland: Hauptuntersuchung",
+      "description": "Anhang I, aufbereitet für die deutsche Hauptuntersuchung nach §29 StVZO. Prüfen Sie die aktuellen Grenzwerte der HU-Richtlinie, bevor Sie Bescheinigungen ausstellen.",
+      "standardLabel": "Hauptuntersuchung (§29 StVZO)"
+    },
+    "nl-apk": {
+      "name": "Niederlande: APK",
+      "description": "Anhang I, aufbereitet für die niederländische APK. Prüfen Sie die aktuellen Anforderungen der RDW, bevor Sie Bescheinigungen ausstellen.",
+      "standardLabel": "APK"
+    },
+    "standard-multipoint": {
+      "name": "Standard-Fahrzeugcheck",
+      "description": "Ein allgemeiner Werkstatt-Check der wichtigsten Baugruppen. Bewertet mit in Ordnung / beobachten / nicht in Ordnung statt nach der EU-Mängelskala.",
+      "standardLabel": "Werkstatt-Checkliste"
+    },
+    "pre-purchase": {
+      "name": "Gebrauchtwagen-Check vor dem Kauf",
+      "description": "Bericht für Kaufinteressenten zu Unterlagen, Karosserie, Technik und Verschleißteilen, mit Platz für Messwerte und Fotos.",
+      "standardLabel": "Werkstatt-Checkliste"
+    },
+    "ev-hybrid": {
+      "name": "Check für Elektro- und Hybridfahrzeuge",
+      "description": "Hochvoltsicherheit, Zustand der Traktionsbatterie und Laden, dazu die konventionellen Bauteile, die sich bei einem elektrifizierten Antrieb anders verhalten.",
+      "standardLabel": "Fach-Checkliste"
+    },
+    "marine": {
+      "name": "Bootsinspektion",
+      "description": "Rumpf, Antrieb, Elektrik und Sicherheitsausrüstung eines Boots, mit Motorbetriebsstunden anstelle des Kilometerstands.",
+      "standardLabel": "Fach-Checkliste"
+    },
+    "blank": {
+      "name": "Leere Vorlage",
+      "description": "Beginnen Sie bei null und erstellen Sie die Checkliste, die Ihre Werkstatt tatsächlich verwendet.",
+      "standardLabel": "Leer"
+    },
+    "vehicle-intake": {
+      "name": "Fahrzeugannahme",
+      "description": "Der Rundgang bei der Abgabe, mit dem Kunden daneben: wie das Fahrzeug ankam, was schon an der Karosserie war, was innen liegen blieb. Wird auf dem Arbeitsauftrag gedruckt, den der Kunde unterschreibt.",
+      "standardLabel": "Werkstatt"
+    }
+  },
+  "sections": {
+    "identificationVehicle": "Identifizierung des Fahrzeugs",
+    "brakingEquipment": "Bremsanlage",
+    "steering": "Lenkanlage",
+    "visibility": "Sichtverhältnisse",
+    "lampsReflectorsElectricalEquipment": "Leuchten, Rückstrahler und elektrische Ausrüstung",
+    "axlesWheelsTyresSuspension": "Achsen, Räder, Reifen und Aufhängung",
+    "chassisChassisAttachments": "Fahrgestell und daran befestigte Teile",
+    "otherEquipment": "Sonstige Ausstattung",
+    "nuisance": "Umweltbelastung",
+    "exterior": "Außen",
+    "underHood": "Motorraum",
+    "underVehicle": "Unterboden",
+    "brakes": "Bremsen",
+    "tyres": "Reifen",
+    "interior": "Innenraum",
+    "documentationHistory": "Unterlagen und Historie",
+    "bodyPaint": "Karosserie und Lack",
+    "mechanical": "Technik",
+    "wearItems": "Verschleißteile",
+    "highVoltageSafety": "Hochvoltsicherheit",
+    "tractionBattery": "Traktionsbatterie",
+    "charging": "Laden",
+    "drivetrainConventionalItems": "Antrieb und konventionelle Bauteile",
+    "hullStructure": "Rumpf und Struktur",
+    "propulsion": "Antrieb",
+    "electrical": "Elektrik",
+    "safetyEquipment": "Sicherheitsausrüstung",
+    "newSection": "Neuer Abschnitt",
+    "no_s0": "Identifizierung des Fahrzeugs",
+    "no_s1": "Bremsanlage",
+    "no_s2": "Lenkung",
+    "no_s3": "Sichtverhältnisse",
+    "no_s4": "Beleuchtungseinrichtungen und Teile der elektrischen Anlage",
+    "no_s5": "Achsen, Räder, Reifen und Aufhängung",
+    "no_s6": "Fahrgestell und daran befestigte Teile",
+    "no_s7": "Sonstige Ausrüstung",
+    "no_s8": "Umweltbelastung",
+    "no_s10": "Vorführung bei einer trafikkstasjon (Verkehrsstation)",
+    "arrival": "Ankunft",
+    "exteriorWalkround": "Rundgang außen"
+  },
+  "checks": {
+    "registrationPlates": "Kennzeichen",
+    "vehicleIdentificationChassisNumber": "Fahrzeug-Identifizierungsnummer / Fahrgestellnummer",
+    "serviceBrakePedalHandLeverPivot": "Lagerung von Betriebsbremspedal / Handbremshebel",
+    "pedalLeverConditionTravel": "Zustand und Weg von Pedal / Hebel",
+    "vacuumPumpCompressorReservoirs": "Unterdruckpumpe oder Kompressor und Behälter",
+    "lowPressureWarningGaugeIndicator": "Warneinrichtung oder Manometer für Niederdruck",
+    "parkingBrakeActuatorLeverRatchet": "Feststellbremsbetätigung, Hebel und Sperrklinke",
+    "brakeValves": "Bremsventile",
+    "brakeServoUnitMasterCylinder": "Bremskraftverstärker und Hauptbremszylinder",
+    "rigidBrakePipes": "Starre Bremsleitungen",
+    "flexibleBrakeHoses": "Bremsschläuche",
+    "brakeLiningsPads": "Bremsbeläge",
+    "brakeDrumsDiscs": "Bremstrommeln und Bremsscheiben",
+    "brakeCablesRodsLeversLinkages": "Bremsseile, Gestänge, Hebel und Verbindungen",
+    "brakeActuators": "Bremszylinder",
+    "loadSensingValve": "Automatisch lastabhängiger Bremskraftregler",
+    "completeBrakingSystem": "Bremsanlage insgesamt",
+    "serviceBrakeEfficiency": "Wirkung der Betriebsbremse",
+    "serviceBrakeImbalanceAcrossAxle": "Ungleichmäßigkeit der Betriebsbremse an einer Achse",
+    "parkingBrakeEfficiency": "Wirkung der Feststellbremse",
+    "antiLockBrakingSystemAbs": "Antiblockiersystem (ABS)",
+    "electronicBrakingSystemEbs": "Elektronisches Bremssystem (EBS)",
+    "brakeFluid": "Bremsflüssigkeit",
+    "mechanicalConditionSteering": "Mechanischer Zustand der Lenkung",
+    "steeringWheelColumnHandlebars": "Lenkrad, Lenksäule und Lenker",
+    "steeringPlay": "Lenkungsspiel",
+    "wheelAlignment": "Achsvermessung",
+    "powerSteering": "Servolenkung",
+    "fieldVision": "Sichtfeld",
+    "conditionGlass": "Zustand der Verglasung",
+    "rearViewMirrorsDevices": "Rückspiegel oder Einrichtungen für indirekte Sicht",
+    "windscreenWipers": "Scheibenwischer",
+    "windscreenWashers": "Scheibenwaschanlage",
+    "demistingDefrostingSystem": "Scheibenentfeuchtungs- und Entfrostungsanlage",
+    "headlampsConditionOperation": "Scheinwerfer: Zustand und Funktion",
+    "headlampAim": "Scheinwerfereinstellung",
+    "positionSideMarkerDaytimeRunningLamps": "Begrenzungs-, Seitenmarkierungs- und Tagfahrleuchten",
+    "stopLamps": "Bremsleuchten",
+    "directionIndicatorHazardWarningLamps": "Fahrtrichtungsanzeiger und Warnblinkanlage",
+    "frontRearFogLamps": "Nebelscheinwerfer und Nebelschlussleuchten",
+    "reversingLamps": "Rückfahrscheinwerfer",
+    "rearRegistrationPlateLamp": "Kennzeichenbeleuchtung hinten",
+    "retroReflectorsConspicuityMarkings": "Rückstrahler und Konturmarkierungen",
+    "tellTales": "Kontrollleuchten",
+    "electricalConnectionsTrailer": "Elektrische Verbindungen zum Anhänger",
+    "electricalWiring": "Elektrische Leitungen",
+    "battery": "Batterie",
+    "axles": "Achsen",
+    "stubAxlesWheelBearings": "Achsschenkel und Radlager",
+    "wheels": "Räder",
+    "tyreConditionSidewallsLoadSpeedRating": "Reifenzustand: Flanken, Tragfähigkeit und Geschwindigkeitsindex",
+    "treadDepthFrontLeft": "Profiltiefe vorne links",
+    "treadDepthFrontRight": "Profiltiefe vorne rechts",
+    "treadDepthRearLeft": "Profiltiefe hinten links",
+    "treadDepthRearRight": "Profiltiefe hinten rechts",
+    "springsStabiliser": "Federn und Stabilisator",
+    "shockAbsorbers": "Stoßdämpfer",
+    "suspensionArmsRodsJoints": "Lenker, Streben und Gelenke der Radaufhängung",
+    "chassisFrameAttachments": "Fahrgestell oder Rahmen und Anbauteile",
+    "exhaustPipesSilencer": "Auspuffrohre und Schalldämpfer",
+    "fuelTankPipes": "Kraftstoffbehälter und Kraftstoffleitungen",
+    "spareWheelCarrier": "Reserveradhalterung",
+    "couplingDeviceTowingEquipment": "Verbindungseinrichtung und Anhängevorrichtung",
+    "cabBodyworkCondition": "Zustand von Fahrerhaus und Aufbau",
+    "doorsDoorCatches": "Türen und Türverschlüsse",
+    "floor": "Boden",
+    "driverSeatSeats": "Fahrersitz und Sitze",
+    "steps": "Trittstufen",
+    "mudguardsSpraySuppression": "Kotflügel und Spritzschutz",
+    "safetyBeltsBucklesRestraintSystems": "Sicherheitsgurte, Gurtschlösser und Rückhaltesysteme",
+    "fireExtinguisher": "Feuerlöscher",
+    "locksAntiTheftDevice": "Schlösser und Diebstahlsicherung",
+    "warningTriangle": "Warndreieck",
+    "firstAidKit": "Verbandkasten",
+    "audibleWarningDevice": "Einrichtung für Schallzeichen (Hupe)",
+    "speedometer": "Tachometer",
+    "tachograph": "Fahrtschreiber",
+    "speedLimitationDevice": "Geschwindigkeitsbegrenzer",
+    "odometerReading": "Kilometerstand",
+    "electronicStabilityControlEsc": "Elektronische Stabilitätskontrolle (ESC)",
+    "noiseSuppressionSystem": "Geräuschdämpfungsanlage",
+    "petrolCoIdle": "Benziner: CO im Leerlauf",
+    "petrolLambdaHighIdle": "Benziner: Lambda bei erhöhter Leerlaufdrehzahl",
+    "dieselSmokeOpacity": "Diesel: Rauchtrübung",
+    "exhaustAfterTreatmentSystem": "Abgasnachbehandlungssystem",
+    "fluidLeaks": "Flüssigkeitsverluste",
+    "treadDepthFrontTyre": "Profiltiefe Vorderreifen",
+    "treadDepthRearTyre": "Profiltiefe Hinterreifen",
+    "bodyCondition": "Zustand der Karosserie",
+    "paint": "Lack",
+    "lights": "Beleuchtung",
+    "windscreen": "Windschutzscheibe",
+    "wipers": "Wischer",
+    "mirrors": "Spiegel",
+    "engineOilLevelCondition": "Motorölstand und Zustand",
+    "coolant": "Kühlmittel",
+    "powerSteeringFluid": "Servolenkungsöl",
+    "batteryVoltage": "Batteriespannung",
+    "driveBelts": "Antriebsriemen",
+    "hoses": "Schläuche",
+    "airFilter": "Luftfilter",
+    "cabinFilter": "Innenraumfilter",
+    "exhaustSystem": "Abgasanlage",
+    "suspension": "Fahrwerk",
+    "cvJointsBoots": "Antriebsgelenke und Manschetten",
+    "brakeLines": "Bremsleitungen",
+    "frontPads": "Bremsbeläge vorne",
+    "rearPads": "Bremsbeläge hinten",
+    "discs": "Bremsscheiben",
+    "parkingBrake": "Feststellbremse",
+    "tyrePressure": "Reifendruck",
+    "spareTyreRepairKit": "Reserverad oder Pannenset",
+    "warningLights": "Warnleuchten",
+    "horn": "Hupe",
+    "airConditioning": "Klimaanlage",
+    "heater": "Heizung",
+    "seatBelts": "Sicherheitsgurte",
+    "registrationDocumentMatchesVehicle": "Zulassungsbescheinigung stimmt mit dem Fahrzeug überein",
+    "vin": "FIN",
+    "serviceHistory": "Wartungshistorie",
+    "outstandingRecalls": "Offene Rückrufe",
+    "previousAccidentDamage": "Frühere Unfallschäden",
+    "panelGaps": "Spaltmaße",
+    "paintThicknessWorstPanel": "Lackschichtdicke, schlechtestes Karosserieteil",
+    "corrosion": "Korrosion",
+    "glassLights": "Verglasung und Beleuchtung",
+    "coldStart": "Kaltstart",
+    "engineNoise": "Motorgeräusche",
+    "compressionLowestCylinder": "Kompression, niedrigster Zylinder",
+    "gearboxClutch": "Getriebe und Kupplung",
+    "faultCodesDiagnosticPort": "Fehlercodes an der Diagnoseschnittstelle",
+    "roadTest": "Probefahrt",
+    "brakePadsDiscs": "Bremsbeläge und Bremsscheiben",
+    "tyresIncludingDateCodes": "Reifen einschließlich DOT-Nummer",
+    "suspensionBushes": "Fahrwerkslager",
+    "exhaust": "Auspuff",
+    "serviceDisconnectIsolationProcedureFollowed": "Freischaltung und Sicherung gegen Wiedereinschalten durchgeführt",
+    "orangeHighVoltageCablingChafingDamage": "Orangefarbene Hochvoltleitungen: Scheuerstellen oder Schäden",
+    "highVoltageConnectorLocksInterlocks": "Verriegelungen und Pilotlinien der Hochvoltstecker",
+    "insulationResistance": "Isolationswiderstand",
+    "warningLabelsPresentLegible": "Warnhinweise vorhanden und lesbar",
+    "stateHealth": "Gesundheitszustand (SoH)",
+    "cellVoltageSpread": "Zellspannungsabweichung",
+    "batteryEnclosureUnderbodyProtection": "Batteriegehäuse und Unterbodenschutz",
+    "coolantLevelCondition": "Kühlmittelstand und Zustand",
+    "storedBatteryFaultCodes": "Gespeicherte Batterie-Fehlercodes",
+    "chargePortFlap": "Ladeanschluss und Ladeklappe",
+    "acChargeSession": "AC-Ladevorgang",
+    "dcChargeSession": "DC-Ladevorgang",
+    "chargingCablePlugCondition": "Zustand von Ladekabel und Stecker",
+    "residualCurrentDeviceOperation": "Funktion der Fehlerstrom-Schutzeinrichtung",
+    "driveMotorNoise": "Geräusche des Antriebsmotors",
+    "reductionGearOil": "Untersetzungsgetriebeöl",
+    "regenerativeBrakingOperation": "Funktion der Rekuperation",
+    "frictionBrakesCorrosionFromLowUse": "Reibungsbremsen: Korrosion durch geringe Nutzung",
+    "n12VAuxiliaryBattery": "12-V-Bordnetzbatterie",
+    "acousticVehicleAlertingSystemAvas": "Akustisches Fahrzeug-Warnsystem (AVAS)",
+    "hullBelowWaterline": "Rumpf unterhalb der Wasserlinie",
+    "keelSkeg": "Kiel und Skeg",
+    "throughHullFittingsSeacocks": "Borddurchlässe und Seeventile",
+    "anodes": "Anoden",
+    "rudderBearings": "Ruder und Lager",
+    "deckFittings": "Deck und Beschläge",
+    "engineHours": "Motorbetriebsstunden",
+    "engineOil": "Motoröl",
+    "gearboxOil": "Getriebeöl",
+    "coolingSystemImpeller": "Kühlsystem und Impeller",
+    "fuelSystemFilters": "Kraftstoffsystem und Filter",
+    "propellerShaft": "Propeller und Welle",
+    "sternGland": "Stopfbuchse",
+    "exhaustWaterLock": "Abgasanlage und Wassersammler",
+    "starterBattery": "Starterbatterie",
+    "serviceBatteryBank": "Verbraucherbatterien",
+    "shorePowerGalvanicIsolator": "Landstrom und galvanischer Trenner",
+    "navigationLights": "Navigationslichter",
+    "bilgePumpsFloatSwitches": "Bilgepumpen und Schwimmerschalter",
+    "lifeJacketsQuantityServiceDate": "Rettungswesten: Anzahl und Wartungsdatum",
+    "lifeRaftServiceDate": "Wartungsdatum der Rettungsinsel",
+    "fireExtinguishersServiceDate": "Feuerlöscher: Wartungsdatum",
+    "flaresDate": "Seenotsignale innerhalb des Ablaufdatums",
+    "vhfRadioDsc": "UKW-Funk und DSC",
+    "epirbRegistrationBatteryDate": "EPIRB: Registrierung und Batteriedatum",
+    "gasSystemLocker": "Gasanlage und Gaskasten",
+    "newCheck": "Neuer Prüfpunkt",
+    "no_0_1": "Kennzeichenschilder",
+    "no_0_2": "Fahrzeug-Identifizierungsnummer (Fahrgestell-/Seriennummer)",
+    "no_1_1_1": "Lagerung des Betriebsbremspedals",
+    "no_1_1_2": "Zustand und Pedalweg des Bremspedals",
+    "no_1_1_3": "Vakuumpumpe oder Kompressor und Vorratsbehälter",
+    "no_1_1_4": "Niederdruckwarnanzeige oder Manometer",
+    "no_1_1_6": "Betätigung der Feststellbremse, Hebel, Sperrklinke, elektronische Feststellbremse",
+    "no_1_1_9": "Druckspeicher, Druckluftbehälter",
+    "no_1_1_10": "Bremskraftverstärker, Hauptbremszylinder (hydraulische Anlagen)",
+    "no_1_1_11": "Bremsleitungen",
+    "no_1_1_12": "Bremsschläuche",
+    "no_1_1_13": "Bremsbeläge",
+    "no_1_1_14": "Bremstrommeln, Bremsscheiben",
+    "no_1_1_15": "Bremsseile, Gestänge, Hebel, Verbindungen",
+    "no_1_1_16": "Bremszylinder",
+    "no_1_1_17": "Automatisch lastabhängiger Bremskraftregler",
+    "no_1_1_18": "Gestängesteller",
+    "no_1_1_21": "Bauteile der Bremsanlage",
+    "no_1_1_23": "Kreisschutzventil / Druckschutzventil / Warnung bei Ausfall eines Bremskreises",
+    "no_1_2_1": "Leistung der Betriebsbremse",
+    "no_m_imbalance_front": "Bremskraftdifferenz der Betriebsbremse, Vorderachse",
+    "no_m_imbalance_rear": "Bremskraftdifferenz der Betriebsbremse, Hinterachse",
+    "no_1_2_2": "Wirkung der Betriebsbremse",
+    "no_m_efficiency_m1_2012": "Abbremsung, Personenkraftwagen (M1) mit Erstzulassung ab 1. Jan. 2012",
+    "no_m_efficiency_other": "Abbremsung, sonstige leichte Fahrzeuge",
+    "no_1_4_1": "Leistung der Feststellbremse",
+    "no_1_4_2": "Wirkung der Feststellbremse",
+    "no_m_parking_brake": "Feststellbremse, Abbremsung",
+    "no_1_6": "Antiblockiersystem (ABS)",
+    "no_1_8": "Bremsflüssigkeit",
+    "no_2_1_1": "Zustand des Lenkgetriebes",
+    "no_2_1_2": "Befestigung des Lenkgetriebegehäuses",
+    "no_2_1_3": "Zustand des Lenkgestänges",
+    "no_2_1_4": "Funktion des Lenkgestänges",
+    "no_2_1_5": "Servolenkung",
+    "no_2_2_1": "Zustand des Lenkrads",
+    "no_2_2_2": "Lenksäule, Lenksäulengelenke und Lenkungsdämpfer",
+    "no_2_3": "Lenkungsspiel",
+    "no_2_4": "Radstellung (Spur)",
+    "no_2_6": "Elektronische Servolenkung (EPS)",
+    "no_2_7": "Geradeauslauf und Selbstrückstellung",
+    "no_3_1": "Sichtfeld",
+    "no_3_2": "Zustand der Verglasung",
+    "no_3_3": "Spiegel und Einrichtungen für indirekte Sicht",
+    "no_3_4": "Scheibenwischer",
+    "no_3_5": "Scheibenwaschanlage",
+    "no_3_6": "Entfeuchtungsanlage (Defroster)",
+    "no_4_1_1": "Scheinwerfer, Zustand und Funktion",
+    "no_4_1_2": "Scheinwerfer, Einstellung",
+    "no_m_headlamp_aim": "Scheinwerfer, gemessene Neigung",
+    "no_4_1_3": "Scheinwerfer, Schaltung",
+    "no_4_1_4": "Scheinwerfer, Übereinstimmung mit den Anforderungen",
+    "no_4_1_5": "Scheinwerfer, automatische Leuchtweitenregelung (sofern vorgeschrieben)",
+    "no_4_1_6": "Scheinwerfer, Scheinwerferreinigungsanlage (sofern vorgeschrieben)",
+    "no_4_2_1": "Begrenzungs-/Schluss-/Tagfahr-/Umrissleuchten, Zustand und Funktion",
+    "no_4_2_2": "Begrenzungs-/Schluss-/Tagfahr-/Umrissleuchten, Schaltung",
+    "no_4_2_3": "Begrenzungs-/Schluss-/Tagfahr-/Umrissleuchten, Übereinstimmung mit den Anforderungen",
+    "no_4_3_1": "Bremsleuchten, Zustand und Funktion",
+    "no_4_3_2": "Bremsleuchten, Schaltung",
+    "no_4_3_3": "Bremsleuchten, Übereinstimmung mit den Anforderungen",
+    "no_4_4_1": "Fahrtrichtungsanzeiger und Warnblinkanlage, Zustand und Funktion",
+    "no_4_4_2": "Fahrtrichtungsanzeiger und Warnblinkanlage, Schaltung",
+    "no_4_4_3": "Fahrtrichtungsanzeiger und Warnblinkanlage, Übereinstimmung mit den Anforderungen",
+    "no_4_4_4": "Fahrtrichtungsanzeiger und Warnblinkanlage, Blinkfrequenz",
+    "no_4_5_1": "Nebelscheinwerfer und Nebelschlussleuchten, Zustand und Funktion",
+    "no_4_5_2": "Nebelscheinwerfer und Nebelschlussleuchten, Einstellung",
+    "no_4_5_3": "Nebelscheinwerfer und Nebelschlussleuchten, Schaltung",
+    "no_4_5_4": "Nebelscheinwerfer und Nebelschlussleuchten, Übereinstimmung mit den Anforderungen",
+    "no_4_6_1": "Rückfahrscheinwerfer, Zustand und Funktion",
+    "no_4_6_2": "Rückfahrscheinwerfer, Übereinstimmung mit den Anforderungen",
+    "no_4_6_3": "Rückfahrscheinwerfer, Schaltung",
+    "no_4_7_1": "Beleuchtung des hinteren Kennzeichens, Zustand und Funktion",
+    "no_4_7_2": "Beleuchtung des hinteren Kennzeichens, Übereinstimmung mit den Anforderungen",
+    "no_4_8_1": "Rückstrahler, Zustand",
+    "no_4_8_2": "Rückstrahler, Übereinstimmung mit den Anforderungen",
+    "no_4_9_1": "Kontrollleuchten der Beleuchtungseinrichtungen, Zustand und Funktion",
+    "no_4_9_2": "Kontrollleuchten der Beleuchtungseinrichtungen, Übereinstimmung mit den Anforderungen",
+    "no_4_10": "Elektrische Verbindung zwischen Zugfahrzeug und Anhänger",
+    "no_4_11": "Elektrische Leitungen",
+    "no_4_12": "Nicht vorgeschriebene Leuchten und Rückstrahler",
+    "no_4_13": "Batterie(n)",
+    "no_4_14_1": "Traktionsbatterie",
+    "no_4_14_2": "Kabel des Antriebssystems",
+    "no_4_14_3": "Ladekabel, extern (zwischen Fahrzeug und Elektroinstallation)",
+    "no_4_14_4": "Ladeanschluss",
+    "no_5_1_1": "Achsen",
+    "no_5_1_2": "Achsschenkel",
+    "no_5_1_3": "Radlager",
+    "no_5_2_1": "Radnabe",
+    "no_5_2_2": "Felge",
+    "no_5_2_3": "Reifen",
+    "no_m_tread_summer": "Profiltiefe, Sommerzeitraum",
+    "no_m_tread_winter": "Profiltiefe, Winterzeitraum",
+    "no_5_3_1": "Federn und Stabilisator",
+    "no_5_3_2": "Stoßdämpfer",
+    "no_5_3_3": "Lenker und Gelenkverbindungen der Radaufhängung",
+    "no_5_3_4": "Gelenke und Buchsen der Radaufhängung",
+    "no_5_3_5": "Luftfederung",
+    "no_6_1_1": "Zustand von Fahrgestell, Rahmen und Befestigungen",
+    "no_6_1_2": "Auspuffrohre und Schalldämpfer",
+    "no_6_1_3": "Kraftstoffbehälter und -leitungen/-schläuche",
+    "no_6_1_4": "Stoßfänger, seitliche Schutzvorrichtungen und hinterer Unterfahrschutz",
+    "no_6_1_5": "Reserveradhalterung",
+    "no_6_1_6": "Kupplungs- und Zugeinrichtungen (inkl. Anhängerkupplung)",
+    "no_6_1_7": "Antriebsstrang",
+    "no_6_1_8": "Motorlager",
+    "no_6_1_9": "Motorleistung",
+    "no_6_2_1": "Zustand von Fahrerhaus und Aufbau",
+    "no_6_2_2": "Befestigung von Fahrerhaus und Aufbau",
+    "no_6_2_3": "Türen und Türverschlüsse",
+    "no_6_2_4": "Boden",
+    "no_6_2_5": "Fahrersitz",
+    "no_6_2_6": "Andere Sitze",
+    "no_6_2_7": "Fahrbedienelemente",
+    "no_6_2_8": "Trittstufen zum Fahrerhaus",
+    "no_6_2_9": "Sonstige Innen- und Außenausstattung und Ausrüstung",
+    "no_6_2_10": "Kotflügel und Spritzschutzeinrichtungen",
+    "no_7_1_1": "Sichere Befestigung der Sicherheitsgurte/Gurtschlösser",
+    "no_7_1_2": "Zustand der Sicherheitsgurte/Gurtschlösser",
+    "no_7_1_3": "Gurtkraftbegrenzer",
+    "no_7_1_4": "Gurtstraffer",
+    "no_7_1_5": "Airbag",
+    "no_7_1_6": "SRS-Systeme",
+    "no_7_2": "Feuerlöscher",
+    "no_7_3": "Schlösser und Diebstahlsicherung",
+    "no_7_4": "Warndreieck",
+    "no_7_5": "Warnweste",
+    "no_7_7": "Einrichtung für Schallzeichen (Hupe)",
+    "no_7_8": "Geschwindigkeitsmesser (Tachometer)",
+    "no_7_12": "Elektronische Stabilitätskontrolle (ESC)",
+    "no_7_14_1": "eCall, Vorhandensein der Bauteile",
+    "no_7_14_2": "eCall, Zustand",
+    "no_7_14_3": "eCall, Leistung/Funktion",
+    "no_8_1_1": "Geräuschdämpfungssystem",
+    "no_8_2_1_1": "Abgasreinigungsanlage, Benzin/Gas",
+    "no_8_2_1_2": "Abgasemissionen und Lambdawert",
+    "no_m_co_idle_euro3": "Benzin: CO im Leerlauf, Euro 3 oder neuer",
+    "no_m_co_high_idle_euro3": "Benzin: CO bei erhöhter Leerlaufdrehzahl, Euro 3 oder neuer",
+    "no_m_co_idle_pre_euro3": "Benzin: CO im Leerlauf, Katalysator älter als Euro 3",
+    "no_m_co_high_idle_pre_euro3": "Benzin: CO bei erhöhter Leerlaufdrehzahl, Katalysator älter als Euro 3",
+    "no_m_co_idle_no_catalyst": "Benzin: CO im Leerlauf, ohne Katalysator",
+    "no_m_lambda": "Benzin: Lambda bei erhöhter Leerlaufdrehzahl",
+    "no_8_2_2_1": "Abgasreinigungsanlage, Diesel",
+    "no_8_2_2_2": "Abgastrübung",
+    "no_m_opacity_euro6": "Diesel: Abgastrübung, Euro 6 oder neuer",
+    "no_m_opacity_euro4_5": "Diesel: Abgastrübung, Euro 4, Euro 5 oder EEV",
+    "no_m_opacity_older_na": "Diesel: Abgastrübung, älter als Euro 4, Saugmotor",
+    "no_m_opacity_older_turbo": "Diesel: Abgastrübung, älter als Euro 4, mit Turbolader",
+    "no_8_2_3_1": "Abgas allgemein und Motorlauf",
+    "no_8_3_1": "Flüssigkeitsverluste",
+    "no_10_1": "Vorführung bei einer trafikkstasjon",
+    "fuelLevel": "Tankfüllstand",
+    "keysHandedOver": "Übergebene Schlüssel",
+    "warningLightsOnArrival": "Warnleuchten bei Ankunft",
+    "personalBelongings": "Persönliche Gegenstände im Fahrzeug",
+    "glassCondition": "Zustand der Scheiben",
+    "wheelsAndTyresVisual": "Räder und Reifen, Sichtprüfung",
+    "seatsAndTrim": "Sitze und Verkleidung",
+    "floorAndMats": "Boden und Matten",
+    "infotainmentControls": "Infotainment und Bedienelemente"
+  },
+  "descriptions": {
+    "annexIItem0ConfirmsVehicle": "Anhang I, Nummer 0. Bestätigt, dass das vorgeführte Fahrzeug mit seinen Papieren übereinstimmt.",
+    "vinPresentLegibleMatchingRegistrationDocument": "FIN vorhanden, lesbar und übereinstimmend mit der Zulassungsbescheinigung.",
+    "annexIItem1MechanicalCondition": "Anhang I, Nummer 1. Zuerst der mechanische Zustand, dann die gemessene Wirkung.",
+    "remainingFrictionMaterialThinnestPoint": "Verbleibender Reibbelag an der dünnsten Stelle.",
+    "brakingRateAsPercentageMaximumAuthorised": "Abbremsung in Prozent der zulässigen Gesamtmasse. Tragen Sie den Wert Ihrer nationalen Prüfung ein.",
+    "differenceBetweenBrakingForcesTwoWheels": "Unterschied der Bremskräfte zwischen den beiden Rädern einer Achse.",
+    "contaminationBoilingPoint": "Verunreinigung und Siedepunkt.",
+    "annexIItem2": "Anhang I, Nummer 2.",
+    "freeMovementRimBeforeRoadWheels": "Freies Spiel am Lenkradkranz, bevor die Räder reagieren.",
+    "annexIItem3": "Anhang I, Nummer 3.",
+    "chipsCracksSweptAreaWindscreen": "Steinschläge und Risse im Wischfeld der Windschutzscheibe.",
+    "annexIItem4": "Anhang I, Nummer 4.",
+    "downwardInclinationDippedBeam": "Neigung des Abblendlichts nach unten.",
+    "annexIItem5TreadDepth": "Anhang I, Nummer 5. Die Profiltiefe wird je Rad erfasst.",
+    "annexIItem6": "Anhang I, Nummer 6.",
+    "annexIItem7SomeChecks": "Anhang I, Nummer 7. Einige Prüfpunkte gelten nur für bestimmte Fahrzeugklassen.",
+    "readingTimeTestAnnexIvRequires": "Stand zum Zeitpunkt der Prüfung. Nach Anhang IV muss er in der Bescheinigung stehen.",
+    "annexIItem8EmissionsNoise": "Anhang I, Nummer 8. Abgase und Geräusche. Füllen Sie die Zeile aus, die zur Kraftstoffart passt.",
+    "absorptionCoefficientFromFreeAccelerationTest": "Absorptionskoeffizient aus der Messung bei freier Beschleunigung.",
+    "dpfEgrScrPresentUnmodified": "DPF, AGR und SCR vorhanden und unverändert.",
+    "carryOutHighVoltageSystemIsolated": "Nur bei freigeschaltetem Hochvoltsystem und gesichertem Fahrzeug durchführen.",
+    "regenerativeBrakingLeavesDiscsLightlyUsed": "Durch die Rekuperation werden die Bremsscheiben kaum genutzt; auf Riefen und Rost prüfen.",
+    "mandatoryEuApprovedElectricVehicles": "Vorgeschrieben für EU-typgenehmigte Elektrofahrzeuge.",
+    "requiredEquipmentVariesFlagStateArea": "Die vorgeschriebene Ausrüstung hängt vom Flaggenstaat und vom Fahrtgebiet ab.",
+    "no_0_1": "Die Kennzeichen werden mit dem vognkort (norwegische Zulassungsbescheinigung) oder den elektronischen Daten der Statens vegvesen abgeglichen und auf Beschädigung, Sichtbarkeit und Anbringung geprüft. Abgemeldete Fahrzeuge erhalten keinen Mangel für fehlende Kennzeichen.",
+    "no_0_2": "Die eingeschlagene FIN wird mit dem vognkort abgeglichen. Bei Euro 5 und neuer sowie bei Elektrofahrzeugen mit Erstzulassung am oder nach dem 1. Januar 2011 wird die FIN zusätzlich mit einem Diagnosegerät über OBD ausgelesen. Die Mängel d) und e) werden nicht vergeben, wenn der einzige Unterschied I statt 1 oder O statt 0 ist; werden sie vergeben, ist auch Punkt 10.1 anzukreuzen.",
+    "no_1_1_1": "Fahrzeuge mit Bremskraftverstärker werden bei abgestelltem Motor geprüft.",
+    "no_1_1_2": "Fahrzeuge mit Bremskraftverstärker werden bei abgestelltem Motor geprüft.",
+    "no_1_1_3": "Prüfen Sie die Zeit bis zum Erreichen eines sicheren Arbeitsdrucks und wie oft die Bremse nach dem Abstellen des Motors betätigt werden kann. Die Mängel a) und f) sind Bewertung 2, wenn die Bremse nicht mindestens 4-mal betätigt werden kann (bei hydraulischen Bremsen mit abgestelltem Motor), Bewertung 3 bei weniger als 2-mal.",
+    "no_1_1_10": "Motor abstellen und das Pedal mehrmals treten, das Pedal sehr langsam durchtreten, um innere Undichtigkeit und Absinken zu erkennen, dann den Motor starten und prüfen, ob das Pedal nachgibt. Bremsflüssigkeit unter Minimum ist Bewertung 1, deutlich unter Minimum Bewertung 2 und keine sichtbare Flüssigkeit Bewertung 3.",
+    "no_1_1_12": "Den Schlauch biegen, um Risse zu beurteilen. Sichtbares Gewebe ist ein Mangel.",
+    "no_1_1_13": "Sichtprüfung, bei vorhandenen Schauöffnungen im Ankerblech durch diese.",
+    "no_1_1_14": "Rost wird auf der Reibfläche der Scheibe beurteilt, radial gemessen. Mangel e) wird nicht vergeben, wenn kein Ankerblech vorhanden ist. Leichte Fahrzeuge (zulässige Gesamtmasse bis einschließlich 3500 kg): Rostmangel, wenn mehr als 25 % einer Seite einer Bremsscheibe an der Vorderachse verrostet sind (f), und mehr als 50 % einer Seite einer Bremsscheibe an anderen Achsen (g).",
+    "no_1_1_16": "Umfasst hydraulische Zylinder einschließlich Radbremszylinder und Bremssättel, geprüft bei betätigter Bremse.",
+    "no_1_1_17": "Ein mechanisches Ventil wird anhand der Werte auf dem Typschild des Bremskraftreglers geprüft; bei elektronischer Lastregelung ist die Warnfunktion des Fahrzeugs zu nutzen.",
+    "no_1_1_23": "Hydraulische Anlage: Prüfen, ob die Warnleuchte für Bremskreisausfall funktioniert und nicht ständig leuchtet.",
+    "no_1_2_1": "Die Bremsen auf dem Rollenbremsprüfstand stufenweise bis zur maximalen Kraft betätigen, oder bei einer Probefahrt, wenn dies nicht möglich ist. Mangel g) wird vergeben, wenn die Prüfung aus klimatischen Gründen nicht durchgeführt werden kann. Bremskraftdifferenz zwischen den Rädern einer Achse: mehr als 30 % des höchsten Werts ist Bewertung 2, mehr als 50 % an der Vorderachse eines Kraftfahrzeugs ist Bewertung 3. Schwankung max. 30 daN bei zulässiger Gesamtmasse bis einschließlich 3500 kg, unabhängig von der gewählten Bremskraft. Die Bremskraft sollte während der Prüfung 1800 daN je Rad nicht überschreiten.",
+    "no_m_imbalance_front": "Bremskraftdifferenz zwischen den Rädern in % des höheren Werts. Über 30 % ist Mangel b) mit Bewertung 2, über 50 % an der Vorderachse ist Bewertung 3.",
+    "no_m_imbalance_rear": "Bremskraftdifferenz zwischen den Rädern in % des höheren Werts. Über 30 % ist Mangel b) mit Bewertung 2.",
+    "no_1_2_2": "Jede Achse auf dem Rollenbremsprüfstand mit zunehmender Kraft abbremsen und die Gesamtwirkung bei einer Probefahrt beurteilen, im Zweifel mit einem Verzögerungsmessgerät. Kann das Fahrzeug nicht auf dem Rollenprüfstand geprüft werden, wird auf der Straße ein Verzögerungsmessgerät aus mindestens 30 km/h verwendet; Mangel d) wird vergeben, wenn die Prüfung nicht durchgeführt werden kann, zum Beispiel aus klimatischen Gründen. Mindestabbremsung. Zugelassen am oder nach dem 1. Januar 2012: Personenkraftwagen (M1) 58 %, Lieferwagen/Kombifahrzeug mit zulässiger Gesamtmasse bis 3500 kg (N1) 50 %. Zugelassen vor dem 1. Januar 2012: Personenkraftwagen (M1) 50 %, N1 zugelassen am 1.10.92 oder später 50 %, N1 zugelassen vor dem 1.10.92 45 %. Probefahrt mit Verzögerungsmessgerät: m/s² mal 10 ergibt die Verzögerung in %, 35 % oder weniger ist Mangel e). Die Bremskraftverteilung vorne/hinten liegt normalerweise bei 60/40.",
+    "no_m_efficiency_m1_2012": "Anforderung 58 %. Die für das Fahrzeug zutreffende Zeile ausfüllen.",
+    "no_m_efficiency_other": "Personenkraftwagen (M1) zugelassen vor dem 1. Jan. 2012 und Lieferwagen (N1): 50 %. Lieferwagen (N1) zugelassen vor dem 1.10.92: 45 %. Die für das Fahrzeug zutreffende Zeile ausfüllen.",
+    "no_1_4_1": "Prüfung auf dem Rollenbremsprüfstand, oder auf der Straße, wenn dies nicht möglich ist; bei einer Probefahrt wird Mangel a) vergeben, wenn das Fahrzeug stark von der Geradeausfahrt abweicht. Die Prüfmethode des Herstellers darf ebenfalls angewendet werden.",
+    "no_1_4_2": "Die Feststellbremse muss das Fahrzeug an einer vorgegebenen Steigung halten. Bei hydraulischer Betriebsbremse genügt die Beurteilung an einem Gefälle mit bekannter Neigung oder mit einer anderen geeigneten Methode. Zugelassen vor dem 1.10.92: 16 % Steigung (9,1°, z = 0,158). Zugelassen nach dem 1.10.92: 18 % Steigung (10,2°, z = 0,177), und das Kraftfahrzeug muss außerdem die Kombination mit zulässiger Zuggesamtmasse an 12 % (6,8°) halten.",
+    "no_m_parking_brake": "Muss das Fahrzeug an 18 % Steigung halten (z = 0,177). Zugelassen vor dem 1.10.92: 16 % Steigung (z = 0,158).",
+    "no_1_6": "Prüfen Sie sowohl, dass die Warnleuchte keinen Fehler anzeigt, als auch, dass die Eigendiagnose funktioniert, zum Beispiel leuchtet die Leuchte beim Start auf und erlischt danach oder während der Probefahrt.",
+    "no_1_8": "Sichtprüfung und Messung des Siedepunkts. Mangel b), wenn der Siedepunkt unter 150 Grad Celsius liegt.",
+    "no_2_1_1": "Das Lenkrad bei angehobenen Rädern oder auf Drehtellern von Anschlag zu Anschlag drehen und die Funktion des Lenkgetriebes durch Sichtprüfung kontrollieren. Undichtigkeit unter f) gilt für Fahrzeuge ohne Servolenkung; bei Servolenkung wird Undichtigkeit unter 2.1.5 beurteilt.",
+    "no_2_1_2": "Das Lenkrad bei auf dem Boden stehenden Rädern in beide Richtungen drehen oder einen Spieldetektor verwenden und die Befestigung von Lenkgetriebe/Lenkgehäuse durch Sichtprüfung kontrollieren.",
+    "no_2_1_3": "Das Lenkrad bei auf dem Boden stehenden Rädern in beide Richtungen drehen oder einen Spieldetektor verwenden und die Bauteile auf Verschleiß, Brüche und sichere Befestigung prüfen.",
+    "no_2_1_4": "Das Lenkrad bei belasteten Rädern und bei Servolenkung laufendem Motor von Anschlag zu Anschlag drehen und die beweglichen Bauteile prüfen.",
+    "no_2_1_5": "Auf Undichtigkeit und Flüssigkeitsstand (sofern sichtbar) prüfen und kontrollieren, ob die Servolenkung bei auf dem Boden stehenden Rädern und laufendem Motor funktioniert. Zu wenig Flüssigkeit ist Bewertung 1, wenn der Stand unter Minimum liegt, aber noch Flüssigkeit im Behälter ist.",
+    "no_2_2_1": "Das Lenkrad in Richtung der Lenksäule und rechtwinklig dazu ziehen und drücken, während das Fahrzeuggewicht auf den Rädern lastet.",
+    "no_2_2_2": "Das Lenkrad bei belasteten Rädern drehen, ziehen und drücken und Spiel sowie Zustand von elastischen Kupplungen und Kreuzgelenken prüfen.",
+    "no_2_3": "Das Lenkrad vorsichtig so weit wie möglich in beide Richtungen drehen, ohne dass sich die Räder bewegen, bei belasteten Rädern, Räder in Geradeausstellung und bei Servolenkung laufendem Motor.",
+    "no_2_4": "Sichtprüfung, ob ein oder mehrere Räder offensichtlich stark falsch eingestellt sind, ohne Messgeräte. Ungleichmäßig abgefahrene Reifen allein sind kein Mangel (Reifen werden unter 5.2.3 beurteilt).",
+    "no_2_6": "Sichtprüfung und Prüfung, ob Lenkradwinkel und Radwinkel beim Starten und Abstellen des Motors übereinstimmen.",
+    "no_2_7": "Wird durch eine Probefahrt geprüft.",
+    "no_3_1": "Sichtprüfung vom Fahrersitz aus.",
+    "no_3_2": "Frontscheibe und vordere Seitenscheiben nur hinsichtlich der Sicht prüfen, einschließlich Verschleiß mit Blendwirkung sowie reflektierender oder getönter Folie (wird unter i) erfasst). k) wird erfasst, wenn die Seitenscheibe heruntergelassen ist und nicht geprüft werden kann, und wird bei der Nachprüfung nur aufgehoben, wenn die Scheibe die Anforderungen erfüllt. Im Sichtfeld wird kein Schaden über 10 mm Durchmesser akzeptiert, einschließlich des matten Flecks nach einer Reparatur. Sichtfeld bei Kraftfahrzeugen bis einschließlich 3500 kg zulässiger Gesamtmasse: 30 cm breit, mittig vor dem Lenkrad und innerhalb des Wischfelds.",
+    "no_3_3": "Fehler an der Spiegelverstellung werden nicht erfasst, wenn der Spiegel von Hand direkt am Spiegel eingestellt werden kann.",
+    "no_3_6": "Ein Mangel wird erfasst, wenn das Gebläse des Defrosters defekt ist und/oder keine Luft an die Frontscheibe gelangt.",
+    "no_4_1_2": "Abblend- und Fernlicht jedes Scheinwerfers mit einem Scheinwerfereinstellprüfgerät prüfen. c) wird bei der Nachprüfung nur aufgehoben, wenn die Scheinwerfer richtig eingestellt sind. Die Neigung des Herstellers verwenden, sonst 1 %. Ein Mangel wird erfasst, wenn die Hell-Dunkel-Grenze auch dann noch abweicht, wenn das Prüfgerät auf die Neigung des Fahrzeugs +-1 Prozentpunkt eingestellt ist (z. B. ergibt 1,5 % den Bereich 0,5 % bis 2,5 %); dieselbe Toleranz gilt seitlich. Die Neigung muss in jedem Fall mindestens 0,5 % betragen (bei angegebenen 1 % ergibt sich ein zulässiger Bereich von 0,5 % bis 2 %).",
+    "no_m_headlamp_aim": "Die Neigung des Herstellers ±1 Prozentpunkt und nie unter 0,5 %. Ohne angegebenen Wert gilt 1 %, also 0,5 bis 2,0 % nach unten. Die Grenzwerte anpassen, wenn der Hersteller einen anderen Wert angibt.",
+    "no_4_1_4": "Beurteilen oder mit einem Luxmeter messen, ob das Licht stark genug ist.",
+    "no_4_1_5": "Umfasst nur die aufgrund der Lichtquellenart vorgeschriebene Leuchtweitenregelung, nicht die manuelle Leuchtweitenregelung (siehe 4.1.2).",
+    "no_4_1_6": "Defekte Scheinwerferreinigungsanlagen bei Glühlampen- oder Halogenscheinwerfern sind kein Mangel.",
+    "no_4_4_4": "Prüfen, ob die Blinkfrequenz offensichtlich von den Anforderungen abweicht.",
+    "no_4_5_2": "Wird mit einem Scheinwerfereinstellprüfgerät geprüft. b) wird bei der Nachprüfung nur aufgehoben, wenn die Leuchten richtig eingestellt sind. Die Neigung des Herstellers verwenden, sonst 1,5 %. Ein Mangel wird erfasst, wenn die Hell-Dunkel-Grenze auch dann noch abweicht, wenn das Prüfgerät auf die Neigung des Fahrzeugs +-1 Prozentpunkt eingestellt ist (z. B. ergibt 1,5 % den Bereich 0,5 % bis 2,5 %); dieselbe Toleranz gilt seitlich. Die Neigung muss in jedem Fall mindestens 0,5 % betragen (bei angegebenen 1 % ergibt sich ein zulässiger Bereich von 0,5 % bis 2 %).",
+    "no_4_10": "Die Anhängersteckdose mit geeigneter Ausrüstung prüfen; sie muss nach ISO-Norm belegt sein. Bei der Beleuchtung werden nur Schluss-, Brems- und Fahrtrichtungsanzeigeleuchten geprüft.",
+    "no_4_11": "Sichtprüfung unter dem Fahrzeug und gegebenenfalls im Motorraum.",
+    "no_4_12": "Umfasst unter anderem nachgerüstete Zusatzleuchten (Zusatzfernscheinwerfer, Nebelscheinwerfer, Unterbodenbeleuchtung, Rundumkennleuchten, Lichtbalken, Arbeitsscheinwerfer) und Taxi-Dachschilder. i) wird bei der Nachprüfung nur aufgehoben, wenn die Leuchten richtig eingestellt sind.",
+    "no_4_13": "Auch Hauptschalter, Sicherungen und Belüftung prüfen, sofern vorgeschrieben. a) wird erfasst, wenn mangelhafte oder fehlende Befestigung Kurzschlussgefahr verursacht.",
+    "no_4_14_1": "Sichtprüfung. Gilt für Elektro- und Hybridfahrzeuge.",
+    "no_4_14_2": "Unterbodenschutz oder Ähnliches auf dem Kabel ist kein Mangel, solange das Kabel noch leicht als Antriebskabel erkennbar ist.",
+    "no_4_14_3": "d) wird nicht erfasst, wenn der Halter nur eine fest installierte Ladestation hat.",
+    "no_5_1_1": "Prüfung über einer Grube oder auf einer Auffahrbühne. Spieldetektoren werden empfohlen (vorgeschrieben nur über 3500 kg).",
+    "no_5_1_2": "Eine horizontale oder vertikale Kraft auf jedes Rad ausüben und auf Bewegung zwischen Achsschenkel und Achse achten.",
+    "no_5_1_3": "Eine horizontale Kraft auf jedes Rad ausüben und prüfen, wie weit es sich gegenüber dem Achsschenkel bewegt. Auf Geräusche achten.",
+    "no_5_2_1": "Sichtprüfung. Fehlen an einem Rad mehr als 25 % der Radmuttern/Radbolzen, ist das Bewertung 3.",
+    "no_5_2_2": "Beide Seiten jeder Felge prüfen. Abweichungen von Felgengröße und Einpresstiefe gegenüber dem vognkort werden mit Herstellerdokumentation, dem Original-Aufkleber oder der Betriebsanleitung akzeptiert. Eine unzulässig montierte Spurverbreiterung ist Grund f).",
+    "no_5_2_3": "Größe, Tragfähigkeitsindex, Geschwindigkeitsindex und Genehmigungszeichen mit dem vognkort abgleichen; Tragfähigkeits- und Geschwindigkeitsindex müssen die höchstzulässige Achslast und die Höchstgeschwindigkeit abdecken. f) betrifft unterschiedliche Bauart (Radial/Diagonal, Sommer/Winter), nicht unterschiedliche Marke oder Profil. Spikelose Winterreifen auf einer Achse und Sommerreifen auf der anderen sind kein Mangel, ein leichtes Fahrzeug mit Spikereifen muss sie jedoch an allen Rädern haben. Die Profiltiefe wird in den Hauptrillen über die mittleren drei Viertel der Laufflächenbreite gemessen und nach den geltenden technischen und Nutzungsvorschriften beurteilt. Die Instruks nennt keinen Zahlenwert.",
+    "no_m_tread_summer": "Mindestens 1,6 mm, gemessen in den Hauptrillen über die mittleren ¾ der Lauffläche. Der niedrigste Messwert zählt. Bruksforskriften § 1-4.",
+    "no_m_tread_winter": "Mindestens 3 mm bei Fahrzeugen mit zulässiger Gesamtmasse bis 3500 kg. Der Winterzeitraum geht vom 1. November bis zum ersten Sonntag nach Ostermontag, in Nordland, Troms und Finnmark vom 16. Oktober bis 30. April. Bruksforskriften § 1-4.",
+    "no_5_3_2": "Einschließlich Federbeinen. b) betrifft starke Undichtigkeit oder Funktionsverlust; leichtes Schwitzen allein ist kein Mangel.",
+    "no_5_3_3": "Umfasst auch die Bauteile der Lenker sowie Schubrohre/Kardanrohre.",
+    "no_5_3_5": "Luftbälge, Niveauregelventile, Leitungen und Behälter auf Beschädigung prüfen und auf Undichtigkeiten horchen.",
+    "no_6_1_3": "Umfasst auch Behälter und Leitungen/Schläuche von Standheizungen. Bei LPG/CNG/LNG-Anlagen ein Lecksuchgerät verwenden.",
+    "no_6_1_4": "Auf lose Teile oder Beschädigungen achten, die bei Berührung Verletzungen verursachen können.",
+    "no_6_1_5": "Gilt für außen angebrachte Reserveradhalterungen.",
+    "no_6_1_6": "Auf gefährliche Korrosion (im Zweifel mit einem Rostpickel prüfen), Beschädigung und unsachgemäße Reparatur achten. Die Funktionsprüfung eines abnehmbaren Kugelkopfs ist nicht Teil der Prüfung. y) gilt, wenn die Kupplung für die Gesamt-/zulässige Anhängelast offensichtlich zu schwach ist.",
+    "no_6_1_7": "k), l) und m) betreffen die Staubmanschetten vorne und hinten.",
+    "no_6_1_8": "Bewertung 2, wenn die Lagerung stark geschwächt oder offensichtlich schwer beschädigt ist.",
+    "no_6_1_9": "Sichtprüfung auf offensichtliche Änderung oder Manipulation.",
+    "no_6_2_2": "Prüfung über einer Grube, auf einer Hebebühne mit Achsheber oder auf einer Auffahrbühne.",
+    "no_6_2_7": "Sicht- und Funktionsprüfung der Bedienelemente, die für das sichere Führen des Fahrzeugs erforderlich sind.",
+    "no_6_2_9": "Umfasst auch Hydraulikschläuche für Ausstattung und Ausrüstung.",
+    "no_7_1_1": "Sichtprüfung.",
+    "no_7_1_2": "Sichtprüfung und Funktionsprüfung.",
+    "no_7_1_3": "Sichtprüfung auf offensichtliche Fehler oder fehlende Teile.",
+    "no_7_1_4": "Sichtprüfung auf offensichtliche Fehler oder fehlende Teile.",
+    "no_7_1_5": "Sichtprüfung auf offensichtliche Fehler oder fehlende Teile.",
+    "no_7_1_6": "Prüfen Sie sowohl, ob die Fehlfunktionsanzeige einen Fehler anzeigt, als auch, ob die Eigendiagnose richtig funktioniert (bei einigen Modellen leuchtet die Leuchte nach dem Start eine Weile und erlischt dann).",
+    "no_7_2": "Wo ein Feuerlöscher vorgeschrieben ist, muss ein zugelassener Feuerlöscher der richtigen Größe angebracht sein. Prüfen, ob das Manometer richtig anzeigt und das Gerät mit dem Datum der letzten Prüfung gekennzeichnet ist.",
+    "no_7_3": "Prüfen, ob Zündschloss, Lenkradschloss oder Getriebesperre in Ordnung sind. Nur eine dieser Einrichtungen ist vorgeschrieben.",
+    "no_7_7": "Sichtprüfung und Funktionsprüfung.",
+    "no_7_8": "Sichtprüfung oder Funktionsprüfung durch Probefahrt oder elektronisch. Die Beleuchtung prüfen.",
+    "no_7_12": "Prüfen Sie sowohl, ob die Fehlfunktionsanzeige einen Fehler anzeigt, als auch, ob die Eigendiagnose richtig funktioniert (bei einigen Modellen leuchtet die Leuchte nach dem Start eine Weile und erlischt dann).",
+    "no_7_14_1": "Gilt nur für Fahrzeuge der Klassen M1 und N1, die nach ihrer Typgenehmigung eCall haben müssen.",
+    "no_7_14_2": "Gilt nur für Fahrzeuge der Klassen M1 und N1, die nach ihrer Typgenehmigung eCall haben müssen.",
+    "no_7_14_3": "Sichtprüfung der Audiokomponenten (Lautsprecher/Mikrofon). Gilt nur für Fahrzeuge der Klassen M1 und N1, die nach ihrer Typgenehmigung eCall haben müssen.",
+    "no_8_1_1": "Zugelassen vor dem 1. Okt. 1992: den Geräuschpegel beobachten. Zugelassen nach dem 1. Okt. 1992: Ist die Anlage original oder gleichwertig und ohne hörbare Undichtigkeiten, das Geräusch bei einem Gasstoß aus dem Leerlauf beurteilen; im Zweifel messen (im Stand, Motor betriebswarm, Drehzahl laut vognkort oder 3/4 der Nennleistungsdrehzahl, mindestens 3 Messungen, der höchste Wert zählt). Mangel, wenn der gemessene Pegel den angegebenen Wert um mehr als 5 dB(A) überschreitet, sofern vom Hersteller angegeben (F), oder um mehr als 2 dB(A), sofern von einem Labor gemessen (L). Eine Messung ist gültig, wenn 3 aufeinanderfolgende Messwerte um nicht mehr als 2 dB(A) voneinander abweichen; eine Kalibrierabweichung über 1 dB macht die Prüfung ungültig. Hintergrundgeräusch mindestens 10 dB(A) unter dem gemessenen Pegel. Mikrofon 0,5 m von der Auspufföffnung in 45 Grad zur Seite.",
+    "no_8_2_1_2": "Euro 4 oder niedriger: mit einem Abgasanalysator messen; Grenzwerte des Herstellers gehen den allgemeinen vor. Euro 5 und neuer: OBD mit Diagnosegerät auslesen; keine Endrohrmessung, wenn das Steuergerät antwortet, der Readiness-Test in Ordnung ist und keine aktiven Motorfehlercodes (P0 bis P6) vorliegen. Andernfalls wie bei Euro 4 messen. Eine Undichtigkeit oder bewusste Manipulation, die eine Messung verhindert, ergibt d). Ausgenommen: Fahrzeuge mit Zulassung vor 1974 und Zweitaktmotoren. Ohne Dreiwegekatalysator und Lambdasonde (CO im Leerlauf, Motor warm): 4,5 % bei Fahrzeugen mit Zulassung vom 1. Jan. 1974 bis 1. Okt. 1986; 3,5 % bei Zulassung nach dem 1. Okt. 1986. Mit Dreiwegekatalysator und Lambdasonde, älter als Euro 3: Leerlauf 0,5 %, erhöhter Leerlauf 0,3 %. Euro 3 oder neuer: Leerlauf 0,3 %, erhöhter Leerlauf 0,2 %. Erhöhter Leerlauf mindestens 2000 min⁻¹; Lambda 1 ± 0,03. Sonde mindestens 30 cm in das Endrohr einführen; bei mehreren Rohren den Mittelwert verwenden.",
+    "no_m_co_idle_euro3": "Mit Dreiwegekatalysator und Lambdasonde: max. 0,3 %. Der Grenzwert des Herstellers hat Vorrang. Euro 5 und neuer wird nur gemessen, wenn die OBD-Prüfung nicht in Ordnung ist.",
+    "no_m_co_high_idle_euro3": "Mindestens 2000 min⁻¹: max. 0,2 %. Der Grenzwert des Herstellers hat Vorrang.",
+    "no_m_co_idle_pre_euro3": "Mit Dreiwegekatalysator und Lambdasonde, älter als Euro 3: max. 0,5 %. Der Grenzwert des Herstellers hat Vorrang.",
+    "no_m_co_high_idle_pre_euro3": "Mindestens 2000 min⁻¹: max. 0,3 %. Der Grenzwert des Herstellers hat Vorrang.",
+    "no_m_co_idle_no_catalyst": "Ohne Dreiwegekatalysator und Lambdasonde: max. 3,5 %, oder 4,5 % bei Fahrzeugen mit Zulassung vom 1.1.74 bis 1.10.86. Fahrzeuge mit Zulassung vor 1974 und Zweitaktmotoren sind ausgenommen.",
+    "no_m_lambda": "Mit Dreiwegekatalysator und Lambdasonde: 1 ± 0,03 oder nach Herstellerangabe.",
+    "no_8_2_2_2": "Euro 4 oder niedriger: Trübung durch freie Beschleunigung im Leerlauf (Getriebe in Neutralstellung, Kupplung eingekuppelt) messen; Grenzwerte des Herstellers gehen den allgemeinen vor. Euro 5 und neuer: OBD mit Diagnosegerät auslesen; keine Messung, wenn das Steuergerät antwortet, der Readiness-Test in Ordnung ist und keine aktiven Motorfehlercodes (P0 bis P6) vorliegen. Eine Undichtigkeit, eine abgebrochene Prüfung (unrunder Motorlauf, zu hohe Leerlaufdrehzahl, unsichere Abregeldrehzahl) oder eine Änderung, die die Messung verhindert, ergibt c). Ausgenommen: Fahrzeuge mit Zulassung oder erster Inbetriebnahme vor dem 1. Jan. 1980. Allgemeine Grenzwerte: älter als Euro 4, Saugmotor 2,5 m-1, mit Turbolader 3,0 m-1; Euro 4/5/EEV 1,5 m-1; Euro 6 oder neuer 0,7 m-1. Bestanden nach der ersten Beschleunigung, wenn die Trübung den Grenzwert x 0,7 nicht überschreitet; andernfalls drei weitere Beschleunigungen, deren Mittelwert das Ergebnis ist. Sonde mindestens 3 x Rohrdurchmesser tief, vorzugsweise mindestens 20 cm.",
+    "no_m_opacity_euro6": "Max. 0,7 m⁻¹. Der Grenzwert des Herstellers hat Vorrang. Wird nur gemessen, wenn die OBD-Prüfung nicht in Ordnung ist.",
+    "no_m_opacity_euro4_5": "Max. 1,5 m⁻¹. Der Grenzwert des Herstellers hat Vorrang. Euro 5 wird nur gemessen, wenn die OBD-Prüfung nicht in Ordnung ist.",
+    "no_m_opacity_older_na": "Max. 2,5 m⁻¹. Der Grenzwert des Herstellers hat Vorrang.",
+    "no_m_opacity_older_turbo": "Max. 3,0 m⁻¹. Der Grenzwert des Herstellers hat Vorrang.",
+    "no_8_2_3_1": "Bei laufendem Motor: prüfen, ob die OBD-Fehlfunktionsanzeige einen Fehler anzeigt und ob die Eigendiagnose richtig funktioniert; jede Abweichung ergibt e). Bei der Probefahrt und bei einem Gasstoß aus dem Leerlauf auf sichtbaren blauen, dunkelgrauen oder schwarzen Rauch achten und den Motorlauf prüfen.",
+    "no_8_3_1": "Bewertung 2 bei jedem großen Flüssigkeitsverlust (außer Wasser), der die Umwelt schädigen kann oder ein Sicherheitsrisiko für andere Verkehrsteilnehmer darstellt, sofern während der Prüfung Flüssigkeit auf den Boden tropft.",
+    "no_10_1": "Wird vergeben, wenn Umstände erfordern, dass das Fahrzeug zur Beurteilung, Neugenehmigung oder Eintragung im vognkort zu einer trafikkstasjon muss, z. B. unzulässig geänderter Motor oder unzulässig geändertes Steuergerät, tiefer-/höhergelegt, unzulässiger Frontschutz (Frontschutzbügel), mehr Sitzplätze als im vognkort, Umbau auf einen anderen Kraftstoff oder eine genehmigungspflichtige Änderung. Wird zusätzlich zum zutreffenden Mangel in derselben Sache vergeben; bei der Nachprüfung kann nur die Statens vegvesen ihn aufheben.",
+    "bodyConditionMap": "Tippen Sie auf die Zeichnung, wo jede Delle, jeder Kratzer oder Steinschlag ist, und ergänzen Sie Notiz und Foto. Markierungen früherer Besuche erscheinen grau."
+  },
+  "choices": {
+    "full": "Vollständig",
+    "partial": "Teilweise",
+    "none": "Keine",
+    "fuelEmpty": "Leer",
+    "fuelQuarter": "1/4",
+    "fuelHalf": "1/2",
+    "fuelThreeQuarters": "3/4",
+    "fuelFull": "Voll"
+  },
+  "defects": {
+    "registrationPlateMissing": "Kennzeichen fehlt",
+    "registrationPlateInsecureLikelyFallOff": "Kennzeichen nicht sicher befestigt, könnte abfallen",
+    "inscriptionMissingIllegibleDoesNotMatch": "Aufschrift fehlt, ist unleserlich oder stimmt nicht mit den Fahrzeugpapieren überein",
+    "vinMissingNotFound": "FIN fehlt oder ist nicht auffindbar",
+    "vinIncompleteIllegibleObviouslyFalsifiedDoes": "FIN unvollständig, unleserlich, offensichtlich gefälscht oder nicht mit den Fahrzeugpapieren übereinstimmend",
+    "vehicleDocumentsIllegibleContainFactualInaccuracy": "Fahrzeugpapiere unleserlich oder sachlich fehlerhaft",
+    "excessiveTravelInsufficientReserveTravelBrake": "Übermäßiger Weg oder unzureichende Wegreserve der Bremsbetätigung",
+    "brakeControlNotReleasingCorrectly": "Bremsbetätigung löst nicht ordnungsgemäß",
+    "antiSlipProvisionBrakePedalMissing": "Rutschfester Belag des Bremspedals fehlt, ist lose oder abgenutzt",
+    "pressureBuildUpTimeInsufficientEffective": "Zeit für den Druckaufbau zu lang für eine wirksame Bremsung",
+    "insufficientPressureAssistBrakingLeastTwice": "Druck reicht nach Ansprechen der Warneinrichtung nicht für mindestens zwei unterstützte Bremsungen",
+    "airAntifreezeLeak": "Luft- oder Frostschutzmittelverlust",
+    "externalDamageLikelyAffectFunctionBraking": "Äußere Beschädigung, die die Funktion der Bremsanlage beeinträchtigen könnte",
+    "gaugeIndicatorMalfunctioningDefective": "Manometer oder Anzeige funktionsgestört oder defekt",
+    "lowPressureNotIdentifiable": "Niederdruck nicht erkennbar",
+    "parkingBrakeControlNotHoldingRatchet": "Feststellbremsbetätigung hält nicht, Sperrklinke rastet nicht richtig ein",
+    "excessiveWearControlPivotRatchetMechanism": "Übermäßiger Verschleiß an der Lagerung der Betätigung oder an der Sperrklinke",
+    "excessiveMovementControlIndicatingIncorrectAdjustment": "Übermäßiger Betätigungsweg, der auf falsche Einstellung hinweist",
+    "actuatorMissingDamagedInoperative": "Betätigungseinrichtung fehlt, ist beschädigt oder funktionsunfähig",
+    "servoUnitDefectiveIneffective": "Bremskraftverstärker defekt oder unwirksam",
+    "servoUnitInoperative": "Bremskraftverstärker funktionsunfähig",
+    "masterCylinderDefectiveButBrakeStill": "Hauptbremszylinder defekt, Bremse funktioniert aber noch",
+    "masterCylinderLeaking": "Hauptbremszylinder undicht",
+    "brakeFluidReservoirLevelBelowMinimum": "Bremsflüssigkeitsstand unter der Minimum-Markierung",
+    "brakeFluidReservoirCapMissing": "Deckel des Bremsflüssigkeitsbehälters fehlt",
+    "imminentRiskFailureFractureRigidPipe": "Unmittelbare Gefahr des Versagens oder Bruchs einer Bremsleitung",
+    "pipesJointsLeaking": "Leitungen oder Anschlüsse undicht",
+    "pipesDamagedExcessivelyCorroded": "Leitungen beschädigt oder übermäßig korrodiert",
+    "pipeMisplacedRiskDamage": "Leitung falsch verlegt, Gefahr der Beschädigung",
+    "imminentRiskFailureFractureFlexibleHose": "Unmittelbare Gefahr des Versagens oder Bruchs eines Bremsschlauchs",
+    "hosesDamagedChafingTwistedTooShort": "Schläuche beschädigt, scheuern, verdreht oder zu kurz",
+    "hosesCouplingsLeaking": "Schläuche oder Anschlüsse undicht",
+    "hosesBulgingUnderPressure": "Schläuche wölben sich unter Druck aus",
+    "hosesPorousCordDamaged": "Schläuche porös oder Gewebe beschädigt",
+    "liningsPadsWornDownWearIndicator": "Beläge bis zur Verschleißmarke abgenutzt",
+    "liningsPadsWornBeyondWearIndicator": "Beläge über die Verschleißmarke hinaus abgenutzt, Metall auf Metall",
+    "liningsPadsContaminatedOilGreaseOther": "Beläge mit Öl, Fett oder anderen Stoffen verunreinigt",
+    "liningsPadsContaminatedBrakingEffectSeriously": "Beläge verunreinigt, Bremswirkung erheblich vermindert",
+    "liningPadMissingIncorrectlyMounted": "Belag fehlt oder ist falsch montiert",
+    "drumDiscExcessivelyWornScoredCracked": "Trommel oder Scheibe übermäßig abgenutzt, riefig, rissig, lose oder gebrochen",
+    "drumDiscContaminatedOilGreaseOther": "Trommel oder Scheibe mit Öl, Fett oder anderen Stoffen verunreinigt",
+    "drumDiscContaminatedBrakingEffectSeriously": "Trommel oder Scheibe verunreinigt, Bremswirkung erheblich vermindert",
+    "drumDiscMissing": "Trommel oder Scheibe fehlt",
+    "backPlateInsecure": "Ankerblech nicht sicher befestigt",
+    "cableDamagedKnotted": "Seil beschädigt oder geknickt",
+    "componentExcessivelyWornCorroded": "Bauteil übermäßig abgenutzt oder korrodiert",
+    "cableRodJointInsecure": "Seil, Gestänge oder Gelenk nicht sicher befestigt",
+    "cableGuideDefective": "Seilführung defekt",
+    "restrictionFreeMovementBrakingSystem": "Freigängigkeit der Bremsanlage beeinträchtigt",
+    "actuatorCrackedDamaged": "Bremszylinder gerissen oder beschädigt",
+    "actuatorLeaking": "Bremszylinder undicht",
+    "actuatorInsecureInadequatelyMounted": "Bremszylinder nicht sicher oder unzureichend befestigt",
+    "actuatorSeverelyCorroded": "Bremszylinder stark korrodiert",
+    "linkageDefective": "Gestänge defekt",
+    "linkageIncorrectlyAdjusted": "Gestänge falsch eingestellt",
+    "valveSeizedInoperative": "Ventil festgefressen oder funktionsunfähig",
+    "valveMissingWhereRequired": "Vorgeschriebenes Ventil fehlt",
+    "otherSystemDeviceDamagedBrakingSystem": "Einrichtung eines anderen Systems beschädigt, Bremsanlage beeinträchtigt",
+    "airLeakageCausingNoticeableDropPressure": "Luftverlust mit spürbarem Druckabfall",
+    "anyComponentInsecureInadequatelyMounted": "Ein Bauteil nicht sicher oder unzureichend befestigt",
+    "unsafeModificationComponent": "Unsachgemäße Veränderung an einem Bauteil",
+    "inadequateBrakingForceOneMoreWheels": "Unzureichende Bremskraft an einem oder mehreren Rädern",
+    "noBrakingForceOneMoreWheels": "Keine Bremskraft an einem oder mehreren Rädern",
+    "brakingForceAnyWheelLessThan": "Bremskraft an einem Rad unter 70 % der höchsten Bremskraft an derselben Achse",
+    "excessiveFluctuationBrakingForceThroughAny": "Übermäßige Schwankung der Bremskraft während einer vollen Radumdrehung",
+    "abnormallyLongDelayOperationBrakesAny": "Ungewöhnlich lange Ansprechzeit der Bremse an einem Rad",
+    "brakingRateBelowMinimumRequired": "Abbremsung unter dem vorgeschriebenen Mindestwert",
+    "brakingRateLessThan50Required": "Abbremsung unter 50 % des vorgeschriebenen Werts",
+    "parkingBrakeRateBelowMinimumRequired": "Abbremsung der Feststellbremse unter dem vorgeschriebenen Mindestwert",
+    "parkingBrakeRateLessThan50": "Abbremsung der Feststellbremse unter 50 % des vorgeschriebenen Werts",
+    "parkingBrakeDoesNotHoldVehicle": "Feststellbremse hält das Fahrzeug an einer Steigung nicht",
+    "absWarningDeviceIndicatesMalfunction": "ABS-Warneinrichtung zeigt eine Funktionsstörung an",
+    "absWarningDeviceInoperative": "ABS-Warneinrichtung funktionsunfähig",
+    "absWheelSpeedSensorMissingDamaged": "ABS-Raddrehzahlsensor fehlt oder ist beschädigt",
+    "absWiringDamaged": "ABS-Verkabelung beschädigt",
+    "absSystemInoperative": "ABS funktionsunfähig",
+    "ebsWarningDeviceIndicatesMalfunction": "EBS-Warneinrichtung zeigt eine Funktionsstörung an",
+    "ebsWarningDeviceInoperative": "EBS-Warneinrichtung funktionsunfähig",
+    "brakeFluidContaminated": "Bremsflüssigkeit verunreinigt",
+    "brakeFluidBoilingPointTooLow": "Siedepunkt der Bremsflüssigkeit zu niedrig oder Wassergehalt zu hoch",
+    "brakeFluidBelowMinimumLevel": "Bremsflüssigkeit auf oder unter dem Mindeststand",
+    "steeringBoxShaftTurningStiffly": "Lenkgetriebewelle schwergängig",
+    "steeringBoxShaftExcessivelyWorn": "Lenkgetriebewelle übermäßig abgenutzt",
+    "excessiveMovementSteeringBox": "Übermäßige Bewegung des Lenkgetriebes",
+    "steeringBoxLeaking": "Lenkgetriebe undicht",
+    "steeringBoxDrippingOil": "Lenkgetriebe tropft Öl",
+    "steeringComponentInsecureCrackedExcessivelyWorn": "Lenkungsteil lose, gerissen oder übermäßig abgenutzt",
+    "steeringComponentInsecurePointAffectingSafety": "Lenkungsteil so lose, dass die Sicherheit beeinträchtigt ist",
+    "relativeMovementBetweenSteeringWheelColumn": "Relativbewegung zwischen Lenkrad und Lenksäule, die auf Lockerung hinweist",
+    "steeringWheelRetainingDeviceMissing": "Sicherungseinrichtung der Lenkradnabe fehlt",
+    "columnBearingsCouplingsExcessivelyWorn": "Lenksäulenlager oder Kupplungen übermäßig abgenutzt",
+    "excessiveVerticalRadialMovementSteeringWheel": "Übermäßige axiale oder radiale Bewegung der Lenkradnabe",
+    "handlebarsCrackedInsecureForkStem": "Lenker gerissen oder am Gabelschaft nicht sicher befestigt",
+    "excessiveFreePlaySteeringWheelHandlebars": "Übermäßiges Spiel am Lenkrad oder Lenker",
+    "freePlaySoExcessiveThatSafe": "Spiel so groß, dass sicheres Lenken beeinträchtigt ist",
+    "wheelAlignmentOutsideManufacturerSpecification": "Achsvermessung außerhalb der Herstellerangaben",
+    "alignmentCausingAbnormalUnevenTyreWear": "Fahrwerkseinstellung verursacht ungewöhnlichen oder ungleichmäßigen Reifenverschleiß",
+    "powerSteeringSystemLeaking": "Servolenkung undicht",
+    "insufficientPowerSteeringFluidBelowMinimum": "Zu wenig Servolenkungsöl, unter der Minimum-Markierung",
+    "powerSteeringMechanismNotFunctioning": "Servolenkung funktioniert nicht",
+    "powerSteeringMechanismCrackedInsecure": "Servolenkung gerissen oder nicht sicher befestigt",
+    "steeringWheelHandlebarsMisalignedIncompatibleRoad": "Lenkrad oder Lenker schief oder nicht passend zur Radstellung",
+    "obstructionWithinDriverFieldVisionThat": "Behinderung im Sichtfeld des Fahrers, die die Sicht nach vorn oder zur Seite wesentlich beeinträchtigt",
+    "obstructionThatSeriouslyAffectsViewThrough": "Behinderung, die die Sicht durch die Windschutzscheibe erheblich beeinträchtigt",
+    "glassCrackedDiscoloured": "Scheibe gesprungen oder verfärbt",
+    "crackedDiscolouredGlassWithinSweptArea": "Gesprungene oder verfärbte Scheibe im Wischfeld der Windschutzscheibe",
+    "glassNotConformingApplicableRequirements": "Verglasung entspricht nicht den geltenden Anforderungen",
+    "conditionGlassSeriouslyImpairingVisibility": "Zustand der Verglasung beeinträchtigt die Sicht erheblich",
+    "chipWithinDriverCriticalVisionArea": "Steinschlag im zentralen Sichtfeld des Fahrers",
+    "mirrorDeviceMissingNotFittedAs": "Spiegel oder Einrichtung fehlt oder nicht vorschriftsmäßig angebracht",
+    "mirrorDeviceDefectiveLooseInsecure": "Spiegel oder Einrichtung defekt, lose oder nicht sicher befestigt",
+    "mirrorDeviceInsufficientFieldVision": "Spiegel oder Einrichtung mit unzureichendem Sichtfeld",
+    "wipersNotOperatingMissing": "Scheibenwischer funktioniert nicht oder fehlt",
+    "wiperBladeDefective": "Wischblatt defekt",
+    "wiperBladeMissingObviouslyIneffective": "Wischblatt fehlt oder ist offensichtlich wirkungslos",
+    "wiperSmearingJudderingAcrossSweptArea": "Wischer schmiert oder rattert über das Wischfeld",
+    "washersNotOperatingAdequately": "Scheibenwaschanlage funktioniert nicht ausreichend",
+    "washersNotOperatingAll": "Scheibenwaschanlage funktioniert überhaupt nicht",
+    "washerFluidReservoirEmpty": "Behälter für Scheibenwaschflüssigkeit leer",
+    "demistingDefrostingSystemInoperativeClearlyNot": "Entfeuchtungs- oder Entfrostungsanlage funktionsunfähig oder offensichtlich gestört",
+    "lightSourceLampDefectiveMissing": "Lichtquelle oder Leuchte defekt oder fehlt",
+    "twoLightSourcesLampsDefectiveMissing": "Zwei Lichtquellen oder Leuchten defekt oder fehlen",
+    "projectionSystemSlightlyDefective": "Optisches System leicht beschädigt",
+    "projectionSystemSeverelyDefectiveMissing": "Optisches System stark beschädigt oder fehlt",
+    "lampInsecurelyAttached": "Leuchte nicht sicher befestigt",
+    "lensHeavilyCloudedReducingLightOutput": "Abschlussscheibe stark getrübt, Lichtleistung vermindert",
+    "colourEmittedLightNotCompliant": "Lichtfarbe nicht vorschriftsmäßig",
+    "headlampAimOutsideRequiredLimits": "Scheinwerfereinstellung außerhalb der vorgeschriebenen Grenzen",
+    "lightSourceLampDefective": "Lichtquelle oder Leuchte defekt",
+    "lensDefective": "Abschlussscheibe defekt",
+    "lampInsecurelyAttachedSeriousRiskFalling": "Leuchte nicht sicher befestigt, große Gefahr des Herabfallens",
+    "stopLampNotOperating": "Bremsleuchte funktioniert nicht",
+    "oneStopLampPairNotOperating": "Eine von zwei Bremsleuchten funktioniert nicht",
+    "noStopLampOperatingAll": "Keine Bremsleuchte funktioniert",
+    "stopLampOperatingPermanently": "Bremsleuchte leuchtet ständig",
+    "indicatorHazardLampNotOperating": "Fahrtrichtungsanzeiger oder Warnblinkleuchte funktioniert nicht",
+    "flashingRateOutsideRequiredRange": "Blinkfrequenz außerhalb des vorgeschriebenen Bereichs",
+    "indicatorTellTaleInoperative": "Kontrollleuchte des Fahrtrichtungsanzeigers funktionsunfähig",
+    "fogLampNotOperating": "Nebelleuchte funktioniert nicht",
+    "fogLampIncorrectlyAimed": "Nebelscheinwerfer falsch eingestellt",
+    "rearFogLampOperatingPermanently": "Nebelschlussleuchte leuchtet ständig",
+    "reversingLampNotOperating": "Rückfahrscheinwerfer funktioniert nicht",
+    "reversingLampOperatingPermanentlyShowingRear": "Rückfahrscheinwerfer leuchtet ständig oder ohne eingelegten Rückwärtsgang",
+    "registrationPlateLampNotOperatingMissing": "Kennzeichenbeleuchtung funktioniert nicht oder fehlt",
+    "reflectorDefectiveDamagedMissing": "Rückstrahler defekt, beschädigt oder fehlt",
+    "conspicuityMarkingDamagedDirtyPartlyMissing": "Konturmarkierung beschädigt, verschmutzt oder teilweise fehlend",
+    "reflectorColourNotCompliant": "Farbe des Rückstrahlers nicht vorschriftsmäßig",
+    "tellTaleNotOperating": "Kontrollleuchte funktioniert nicht",
+    "tellTaleIndicatingMalfunctionSafetyRelated": "Kontrollleuchte zeigt eine Störung eines sicherheitsrelevanten Systems an",
+    "fixedComponentsInsecureDamaged": "Fest angebrachte Bauteile nicht sicher befestigt oder beschädigt",
+    "insulationDamagedDeteriorated": "Isolierung beschädigt oder schadhaft",
+    "trailerElectricalConnectionNotFunctioning": "Elektrische Anhängerverbindung funktioniert nicht",
+    "wiringInsecureInadequatelySecured": "Leitungen nicht sicher oder unzureichend befestigt",
+    "wiringInsulationDamagedDeteriorated": "Isolierung der Leitungen beschädigt oder schadhaft",
+    "wiringDamagedRiskFireSparking": "Leitungen beschädigt, Brand- oder Funkengefahr",
+    "excessivelyDeterioratedWiringEngineBayNear": "Leitungen im Motorraum oder nahe heißen Bauteilen stark schadhaft",
+    "batteryInsecure": "Batterie nicht sicher befestigt",
+    "batteryLeaking": "Batterie undicht",
+    "batteryLeakingCorrosiveElectrolyte": "Batterie verliert ätzenden Elektrolyt",
+    "batterySwitchDefective": "Batterieschalter defekt",
+    "batteryTerminalsCorrodedLoose": "Batteriepole korrodiert oder lose",
+    "batteryStateChargeBelowServiceableThreshold": "Ladezustand der Batterie unter dem Mindestwert",
+    "axleCrackedDeformed": "Achse gerissen oder verformt",
+    "axleInsecurelyAttachedVehicle": "Achse nicht sicher am Fahrzeug befestigt",
+    "unsafeModificationAxle": "Unsachgemäße Veränderung an einer Achse",
+    "wheelBearingExcessivePlay": "Radlager mit übermäßigem Spiel",
+    "wheelBearingTooTightSeizing": "Radlager zu stramm oder schwergängig",
+    "wheelBearingNoisyUnderLoad": "Radlager unter Last laut",
+    "wheelFractureDefectiveWelding": "Rad gebrochen oder Schweißnaht mangelhaft",
+    "wheelRetainingRingsNotCorrectlyFitted": "Sicherungsringe des Rads nicht richtig montiert",
+    "wheelBadlyDistortedWorn": "Rad stark verformt oder abgenutzt",
+    "wheelNutsStudsMissingLoose": "Radmuttern oder Radbolzen fehlen oder sind lose",
+    "wheelSizeTypeNotCompatibleAffecting": "Radgröße oder Radtyp nicht zulässig, Verkehrssicherheit beeinträchtigt",
+    "treadDepthBelowLegalMinimum": "Profiltiefe unter dem gesetzlichen Mindestwert",
+    "treadWearIndicatorVisible": "Verschleißanzeiger des Profils sichtbar",
+    "tyreSeverelyDamagedCut": "Reifen stark beschädigt oder eingeschnitten",
+    "tyreCordsVisibleDamaged": "Karkasse sichtbar oder beschädigt",
+    "tyresDifferentSizeConstructionSameAxle": "Reifen unterschiedlicher Größe oder Bauart auf derselben Achse",
+    "tyreLoadIndexSpeedRatingNot": "Tragfähigkeitskennzahl oder Geschwindigkeitsindex nicht zulässig für das Fahrzeug",
+    "tyreRubbingAgainstAnotherComponent": "Reifen schleift an einem anderen Bauteil",
+    "tyreSeriouslyRubbingAgainstAnotherComponent": "Reifen schleift stark an einem anderen Bauteil",
+    "unevenWearAcrossTreadIndicatingAlignment": "Ungleichmäßiger Profilverschleiß, weist auf Fehler bei Spur oder Fahrwerk hin",
+    "tyrePressureOutsideManufacturerSpecification": "Reifendruck außerhalb der Herstellerangaben",
+    "tyrePerishedShowingSidewallCrackingAge": "Reifen überaltert oder mit Alterungsrissen in der Flanke",
+    "springsInsecurelyAttachedChassisAxle": "Federn nicht sicher an Fahrgestell oder Achse befestigt",
+    "springComponentDamagedCracked": "Federteil beschädigt oder gerissen",
+    "springMissingBroken": "Feder fehlt oder ist gebrochen",
+    "stabiliserBarLinkWornInsecure": "Stabilisator oder Koppelstange abgenutzt oder lose",
+    "shockAbsorbersInsecure": "Stoßdämpfer nicht sicher befestigt",
+    "shockAbsorbersInsecureRiskDetachment": "Stoßdämpfer nicht sicher befestigt, Gefahr des Ablösens",
+    "shockAbsorberDamagedShowingSignsSevere": "Stoßdämpfer beschädigt, mit Anzeichen starker Undichtigkeit oder Funktionsstörung",
+    "shockAbsorberMissing": "Stoßdämpfer fehlt",
+    "suspensionArmRodJointExcessivelyWorn": "Lenker, Strebe oder Gelenk übermäßig abgenutzt",
+    "jointExcessivePlaySeriouslyAffectingStability": "Gelenk mit übermäßigem Spiel, Stabilität erheblich beeinträchtigt",
+    "rubberBushPerishedSplitMissing": "Gummilager porös, eingerissen oder fehlt",
+    "dustCoverSplitMissing": "Staubmanschette eingerissen oder fehlt",
+    "componentInsecureBadlyCorroded": "Bauteil nicht sicher befestigt oder stark korrodiert",
+    "slightDamageMemberCrossmember": "Leichte Beschädigung eines Längs- oder Querträgers",
+    "seriousDamageMemberCrossmember": "Schwere Beschädigung eines Längs- oder Querträgers",
+    "insecurityMemberCrossmember": "Längs- oder Querträger nicht sicher befestigt",
+    "excessiveCorrosionAffectingRigidityAssembly": "Übermäßige Korrosion, die die Steifigkeit der Baugruppe beeinträchtigt",
+    "corrosionPerforatingStructuralLoadBearingMember": "Durchrostung eines tragenden Bauteils",
+    "unsafeRepairModificationStructure": "Unsachgemäße Reparatur oder Veränderung der Struktur",
+    "exhaustSystemInsecureLeaking": "Auspuffanlage nicht sicher befestigt oder undicht",
+    "fumesEnteringCabPassengerCompartment": "Abgase dringen in Fahrerhaus oder Fahrgastraum ein",
+    "fumesEnteringCabQuantityDangerousHealth": "Abgase dringen in einer für die Insassen gesundheitsgefährdenden Menge ein",
+    "exhaustSilencerDefectiveMissingBypassed": "Schalldämpfer defekt, fehlt oder überbrückt",
+    "fuelTankPipesInsecure": "Kraftstoffbehälter oder Leitungen nicht sicher befestigt",
+    "fuelLeakingFillerCapMissingIneffective": "Kraftstoffverlust oder Tankdeckel fehlt oder schließt nicht",
+    "fuelLeakingRiskFire": "Kraftstoffverlust mit Brandgefahr",
+    "fuelPipesChafingDamaged": "Kraftstoffleitungen scheuern oder sind beschädigt",
+    "heatShieldMissingIneffective": "Hitzeschutzblech fehlt oder ist unwirksam",
+    "spareWheelCarrierInsecureUnsatisfactoryCondition": "Reserveradhalterung nicht sicher befestigt oder in schlechtem Zustand",
+    "couplingDeviceDamagedDefectiveCracked": "Verbindungseinrichtung beschädigt, defekt oder gerissen",
+    "couplingDeviceExcessivelyWorn": "Verbindungseinrichtung übermäßig abgenutzt",
+    "couplingDeviceDefectivePointRiskingDetachment": "Verbindungseinrichtung so defekt, dass sie sich lösen könnte",
+    "safetyDeviceMissingDamagedNotFunctioning": "Sicherungseinrichtung fehlt, ist beschädigt oder funktioniert nicht",
+    "panelComponentLooseDamagedLikelyCause": "Verkleidungsteil oder Bauteil lose oder beschädigt, Verletzungsgefahr",
+    "bodyPillarInsecure": "Karosseriesäule nicht sicher befestigt",
+    "ingressEngineExhaustFumes": "Eindringen von Motor- oder Abgasen",
+    "corrosionAreaThatCouldInjureOccupant": "Korrosion an einer Stelle, an der Insassen oder andere Verkehrsteilnehmer verletzt werden könnten",
+    "sharpEdgeProtrusionLikelyCauseInjury": "Scharfe Kante oder vorstehendes Teil, Verletzungsgefahr",
+    "doorHatchDoesNotOpenClose": "Tür oder Klappe öffnet oder schließt nicht ordnungsgemäß",
+    "doorHatchLiableOpenUnintentionallyFails": "Tür oder Klappe kann sich unbeabsichtigt öffnen oder bleibt nicht geschlossen",
+    "doorHingeCatchPillarDeteriorated": "Tür, Scharnier, Schloss oder Säule schadhaft",
+    "floorInsecureBadlyDeteriorated": "Boden nicht sicher befestigt oder stark schadhaft",
+    "floorInsufficientlySecureSafeStand": "Boden nicht tragfähig genug, um sicher darauf zu stehen",
+    "seatDefectiveStructure": "Sitz mit schadhafter Struktur",
+    "seatInsecurelyMounted": "Sitz nicht sicher befestigt",
+    "seatAdjustmentMechanismNotFunctioningCorrectly": "Sitzverstellung funktioniert nicht ordnungsgemäß",
+    "stepFootRestInsecure": "Trittstufe oder Fußraste nicht sicher befestigt",
+    "stepInsecurePointRiskingInjury": "Trittstufe so lose, dass Verletzungsgefahr besteht",
+    "mudguardMissingLooseBadlyCorroded": "Kotflügel fehlt, ist lose oder stark korrodiert",
+    "insufficientClearanceWheelSpraySuppressionIneffective": "Zu geringer Abstand zum Rad oder Spritzschutz unwirksam",
+    "beltAnchorageBadlyDeteriorated": "Gurtverankerung stark schadhaft",
+    "anchorageDeterioratedPointAffectingStability": "Verankerung so schadhaft, dass die Stabilität beeinträchtigt ist",
+    "beltDamagedCutSignOverstretching": "Gurt beschädigt, eingeschnitten oder überdehnt",
+    "beltFrayedDirtyButServiceable": "Gurt ausgefranst oder verschmutzt, aber funktionsfähig",
+    "beltRetractorBuckleNotOperatingCorrectly": "Gurtaufroller oder Gurtschloss funktioniert nicht ordnungsgemäß",
+    "beltMissingWhereOneRequired": "Vorgeschriebener Gurt fehlt",
+    "airbagSrsWarningLampIndicatesSystem": "Airbag- oder SRS-Warnleuchte zeigt einen Systemausfall an",
+    "fireExtinguisherMissingWhereRequired": "Vorgeschriebener Feuerlöscher fehlt",
+    "fireExtinguisherOutServiceDateDischarged": "Feuerlöscher nicht mehr geprüft oder entleert",
+    "fireExtinguisherNotSecurelyMounted": "Feuerlöscher nicht sicher befestigt",
+    "deviceNotFunctioningPreventVehicleBeing": "Einrichtung zur Verhinderung der unbefugten Benutzung funktioniert nicht",
+    "deviceDefectiveLockingUnintentionally": "Einrichtung defekt oder verriegelt unbeabsichtigt",
+    "warningTriangleMissingIncomplete": "Warndreieck fehlt oder ist unvollständig",
+    "firstAidKitMissingIncompleteOut": "Verbandkasten fehlt, ist unvollständig oder abgelaufen",
+    "audibleWarningDeviceNotWorkingAll": "Einrichtung für Schallzeichen funktioniert überhaupt nicht",
+    "controlInsecureDeviceSoundingIntermittently": "Betätigung nicht sicher befestigt oder Schallzeichen setzt aus",
+    "toneNotCompliantClearlyInadequate": "Klang nicht vorschriftsmäßig oder offensichtlich unzureichend",
+    "speedometerNotFittedWhereRequired": "Vorgeschriebener Tachometer nicht vorhanden",
+    "speedometerNotFunctioning": "Tachometer funktioniert nicht",
+    "speedometerNotIlluminated": "Tachometer nicht beleuchtet",
+    "tachographMissingWhereRequired": "Vorgeschriebener Fahrtschreiber fehlt",
+    "tachographNotFunctioningSealsBroken": "Fahrtschreiber funktioniert nicht oder Plomben beschädigt",
+    "tachographCalibrationPlateMissingIllegibleOut": "Einbauschild des Fahrtschreibers fehlt, ist unleserlich oder abgelaufen",
+    "obviousEvidenceTamperingManipulation": "Offensichtliche Anzeichen von Manipulation",
+    "speedLimitationDeviceMissingWhereRequired": "Vorgeschriebener Geschwindigkeitsbegrenzer fehlt",
+    "speedLimitationDeviceEvidentlyNotFunctioning": "Geschwindigkeitsbegrenzer funktioniert offensichtlich nicht",
+    "speedLimitationDeviceSetWrongSpeed": "Geschwindigkeitsbegrenzer falsch eingestellt oder Plomben fehlen",
+    "odometerObviouslyManipulatedMisrepresentMileage": "Kilometerzähler offensichtlich manipuliert, um den Kilometerstand zu verfälschen",
+    "odometerObviouslyNotFunctioning": "Kilometerzähler funktioniert offensichtlich nicht",
+    "escWheelSpeedSensorMissingDamaged": "ESC-Raddrehzahlsensor fehlt oder ist beschädigt",
+    "escWiringDamaged": "ESC-Verkabelung beschädigt",
+    "escWarningDeviceIndicatesMalfunction": "ESC-Warneinrichtung zeigt eine Funktionsstörung an",
+    "escSystemInoperative": "ESC funktionsunfähig",
+    "noiseLevelExceedingPermittedLevel": "Geräuschpegel über dem zulässigen Wert",
+    "partNoiseSuppressionSystemLooseDamaged": "Teil der Geräuschdämpfungsanlage lose, beschädigt, fehlend oder offensichtlich verändert",
+    "partNoiseSuppressionSystemLikelyIncrease": "Teil der Geräuschdämpfungsanlage, das den Geräuschpegel erhöhen könnte",
+    "coContentExceedingPermittedLevel": "CO-Gehalt über dem zulässigen Wert",
+    "lambdaOutsideRange1003": "Lambda außerhalb des Bereichs 1 ± 0,03 oder nicht nach Herstellerangaben",
+    "emissionControlEquipmentMissingModifiedObviously": "Abgasreinigungsanlage fehlt, ist verändert oder offensichtlich defekt",
+    "engineManagementIndicatingSeriousMalfunction": "Motorsteuerung zeigt eine schwere Funktionsstörung an",
+    "emissionControlEquipmentMissingObviouslyDefective": "Abgasreinigungsanlage fehlt oder ist offensichtlich defekt",
+    "dieselParticulateFilterObviouslyModifiedRemoved": "Dieselpartikelfilter offensichtlich verändert oder entfernt",
+    "egrScrSystemObviouslyDefectiveBypassed": "AGR- oder SCR-System offensichtlich defekt oder überbrückt",
+    "smokeOpacityExceedingLevelRecordedManufacturer": "Rauchtrübung über dem auf dem Herstellerschild angegebenen Wert",
+    "excessiveSmokeObscuringViewOtherRoad": "Übermäßiger Rauch behindert die Sicht anderer Verkehrsteilnehmer",
+    "smokeOpacityExceedingApplicableLimit": "Rauchtrübung über dem geltenden Grenzwert",
+    "excessiveFluidLeakLikelyHarmEnvironment": "Übermäßiger Flüssigkeitsverlust, der die Umwelt schädigen oder andere Verkehrsteilnehmer gefährden könnte",
+    "constantFormationDropsPresentingVerySerious": "Ständige Tropfenbildung, die eine sehr ernste Gefahr darstellt",
+    "seepageWeepingWithoutDripping": "Feucht oder schwitzend, ohne zu tropfen",
+    "componentExcessivelyWorn": "Bauteil übermäßig abgenutzt",
+    "componentInsecureInadequatelyMounted": "Bauteil nicht sicher oder unzureichend befestigt",
+    "leakFromBrakingSystem": "Undichtigkeit an der Bremsanlage",
+    "brakingPerformanceReduced": "Bremswirkung vermindert",
+    "excessivePlaySteering": "Übermäßiges Spiel in der Lenkung",
+    "componentWornInsecureDamaged": "Bauteil abgenutzt, lose oder beschädigt",
+    "leakFromSteeringSystem": "Undichtigkeit an der Lenkanlage",
+    "visibilityImpaired": "Sicht beeinträchtigt",
+    "componentDefectiveMissingIneffective": "Bauteil defekt, fehlt oder unwirksam",
+    "lampNotOperating": "Leuchte funktioniert nicht",
+    "lampDefectiveDamagedInsecure": "Leuchte defekt, beschädigt oder nicht sicher befestigt",
+    "componentInsecureDamaged": "Bauteil nicht sicher befestigt oder beschädigt",
+    "excessivePlay": "Übermäßiges Spiel",
+    "corrosionAffectingStructure": "Korrosion beeinträchtigt die Struktur",
+    "componentInsecureLooseDamaged": "Bauteil nicht sicher befestigt, lose oder beschädigt",
+    "leakFromSystem": "Undichtigkeit am System",
+    "equipmentMissingWhereRequired": "Vorgeschriebene Ausrüstung fehlt",
+    "equipmentDefectiveNotFunctioning": "Ausrüstung defekt oder funktioniert nicht",
+    "equipmentOutServiceDate": "Prüfdatum der Ausrüstung abgelaufen",
+    "emissionNoiseLevelExceedingPermittedLimit": "Abgas- oder Geräuschwert über dem zulässigen Grenzwert",
+    "controlEquipmentMissingModifiedDefective": "Reinigungsanlage fehlt, ist verändert oder defekt",
+    "fluidLeak": "Flüssigkeitsverlust",
+    "wornCloseMinimumThicknessReplacementDue": "Nahe an der Mindeststärke abgenutzt, Erneuerung bald fällig",
+    "wornBelowMinimumThickness": "Unter die Mindeststärke abgenutzt",
+    "scoredCrackedCorroded": "Riefig, rissig oder korrodiert",
+    "contaminatedOilGrease": "Mit Öl oder Fett verunreinigt",
+    "seizedBinding": "Festgefressen oder schwergängig",
+    "treadApproachingLegalMinimum": "Profil nähert sich dem gesetzlichen Mindestwert",
+    "treadBelowLegalMinimum": "Profil unter dem gesetzlichen Mindestwert",
+    "unevenEdgeWear": "Ungleichmäßiger Verschleiß oder Flankenverschleiß",
+    "sidewallDamageBulgeCut": "Flankenschaden, Beule oder Schnitt",
+    "perishedAgeCracking": "Überaltert oder rissig",
+    "pressureIncorrect": "Druck nicht korrekt",
+    "levelLowToppedUp": "Stand zu niedrig, aufgefüllt",
+    "seepageNoDripping": "Feucht, tropft nicht",
+    "activeLeakDripping": "Undicht, tropft",
+    "fluidContaminatedOverdueReplacement": "Flüssigkeit verunreinigt oder Wechsel überfällig",
+    "bulbBlown": "Glühlampe defekt",
+    "lensCrackedCloudedDiscoloured": "Abschlussscheibe gesprungen, getrübt oder verfärbt",
+    "aimIncorrect": "Einstellung nicht korrekt",
+    "insecureWaterIngress": "Lose oder Wassereintritt",
+    "perishedCrackedGlazed": "Porös, rissig oder verglast",
+    "chafingAgainstAdjacentComponent": "Scheuert an einem benachbarten Bauteil",
+    "splitLeaking": "Eingerissen oder undicht",
+    "tensionIncorrect": "Spannung nicht korrekt",
+    "stateChargeLow": "Ladezustand niedrig",
+    "terminalsCorrodedLoose": "Pole korrodiert oder lose",
+    "failsLoadTestReplacementDue": "Belastungstest nicht bestanden, Erneuerung fällig",
+    "casingDamagedLeaking": "Gehäuse beschädigt oder undicht",
+    "bushPerishedSplit": "Lager porös oder eingerissen",
+    "leakingIneffective": "Undicht oder wirkungslos",
+    "corrodedInsecure": "Korrodiert oder lose",
+    "noisyUnderLoad": "Unter Last laut",
+    "blowingJoint": "Undicht an einer Verbindung",
+    "corrodedRepairDueSoon": "Korrodiert, Reparatur bald fällig",
+    "mountingPerishedInsecure": "Aufhängung porös oder lose",
+    "emissionsAboveLimit": "Abgaswerte über dem Grenzwert",
+    "bladeSmearingJuddering": "Wischblatt schmiert oder rattert",
+    "chipCrackOutsideSweptArea": "Steinschlag oder Riss außerhalb des Wischfelds",
+    "chipCrackDriverVisionArea": "Steinschlag oder Riss im Sichtfeld des Fahrers",
+    "washerJetBlockedMisaligned": "Waschdüse verstopft oder falsch eingestellt",
+    "dirtyReplacementRecommended": "Verschmutzt, Wechsel empfohlen",
+    "heavilyContaminatedReplacementDue": "Stark verschmutzt, Wechsel fällig",
+    "wornWithinLimitsMonitorNextService": "Abgenutzt, noch innerhalb der Grenzen, bei nächster Wartung prüfen",
+    "wornBeyondServiceableLimit": "Über die Verschleißgrenze abgenutzt",
+    "damaged": "Beschädigt",
+    "corroded": "Korrodiert",
+    "insecureLoose": "Nicht sicher befestigt oder lose",
+    "leaking": "Undicht",
+    "missing": "Fehlt",
+    "notWorking": "Funktioniert nicht",
+    "advisoryOnlyNoActionNeededYet": "Nur Hinweis, noch kein Handlungsbedarf",
+    "no_0_1_a": "a) Kennzeichen fehlt",
+    "no_0_1_b": "b) Kennzeichen so unsicher befestigt, dass es abfallen kann",
+    "no_0_1_c": "c) Beschriftung fehlt oder unleserlich",
+    "no_0_1_d": "d) Nicht in Übereinstimmung mit den Fahrzeugpapieren oder Registern",
+    "no_0_1_e": "e) Nicht vorschriftsmäßig angebracht",
+    "no_0_1_f": "f) Kennzeichen beschädigt oder verformt",
+    "no_0_1_g": "g) Kennzeichen entspricht nicht den Anforderungen",
+    "no_0_2_a": "a) Fehlt oder nicht auffindbar",
+    "no_0_2_b": "b) Unvollständig",
+    "no_0_2_c": "c) Unleserlich",
+    "no_0_2_d": "d) Eingeschlagene FIN nicht in Übereinstimmung mit dem vognkort oder den Registern",
+    "no_0_2_e": "e) Elektronisch gespeicherte FIN nicht in Übereinstimmung mit dem vognkort oder den Registern",
+    "no_0_2_f": "f) vognkort (Zulassungsbescheinigung) des Fahrzeugs fehlt oder ist unleserlich",
+    "no_1_1_1_a": "a) Bremspedal zu schwergängig",
+    "no_1_1_1_b": "b) Übermäßiger Verschleiß oder übermäßiges Spiel",
+    "no_1_1_1_c": "c) Entspricht nicht den Anforderungen",
+    "no_1_1_2_a": "a) Zu großer Pedalweg oder unzureichende Wegreserve",
+    "no_1_1_2_b": "b) Pedal kehrt nicht richtig zurück",
+    "no_1_1_2_b_2": "b) Pedal kehrt nicht richtig zurück, die Pedalfunktion ist beeinträchtigt",
+    "no_1_1_2_c": "c) Rutschhemmender Belag fehlt",
+    "no_1_1_2_d": "d) Rutschhemmender Belag lose",
+    "no_1_1_2_e": "e) Rutschhemmender Belag glatt abgenutzt",
+    "no_1_1_2_f": "f) Entspricht nicht den Anforderungen",
+    "no_1_1_3_a": "a) Unzureichender Druck/Unterdruck, um die Bremse nach Ansprechen der Warneinrichtung ausreichend oft zu betätigen (oder das Manometer zeigt einen gefährlichen Wert an)",
+    "no_1_1_3_a_3": "a) Unzureichender Druck/Unterdruck, um die Bremse nach Ansprechen der Warneinrichtung ausreichend oft zu betätigen (oder das Manometer zeigt einen gefährlichen Wert an), die Bremse kann nicht mindestens 2-mal betätigt werden",
+    "no_1_1_3_b": "b) Zeit für den Aufbau von Druck/Unterdruck auf einen sicheren Arbeitswert entspricht nicht den Anforderungen",
+    "no_1_1_3_e": "e) Äußere Beschädigung, die die Funktion der Bremsanlage beeinträchtigen kann",
+    "no_1_1_3_e_3": "e) Äußere Beschädigung, die die Funktion der Bremsanlage beeinträchtigen kann, der Mangel ist verkehrsgefährdend",
+    "no_1_1_3_f": "f) Unzureichender Druck/Unterdruck, um die Bremse ausreichend oft zu betätigen",
+    "no_1_1_3_f_3": "f) Unzureichender Druck/Unterdruck, um die Bremse ausreichend oft zu betätigen, die Bremse kann nicht mindestens 2-mal betätigt werden",
+    "no_1_1_4_a": "a) Warneinrichtung oder Manometer funktioniert nicht richtig oder ist defekt",
+    "no_1_1_4_a_2": "a) Warneinrichtung oder Manometer funktioniert nicht richtig oder ist defekt, niedriger Druck wird nicht gewarnt oder angezeigt",
+    "no_1_1_6_a": "a) Sperrklinke hält nicht richtig",
+    "no_1_1_6_b": "b) Übermäßiger Verschleiß am Hebellager",
+    "no_1_1_6_b_2": "b) Übermäßiger Verschleiß am Hebellager, die Verriegelungsfunktion ist beeinträchtigt",
+    "no_1_1_6_c": "c) Übermäßiger Verschleiß im Sperrklinkenmechanismus",
+    "no_1_1_6_c_2": "c) Übermäßiger Verschleiß im Sperrklinkenmechanismus, die Verriegelungsfunktion ist beeinträchtigt",
+    "no_1_1_6_d": "d) Zu großer Hebelweg",
+    "no_1_1_6_e": "e) Betätigungseinrichtung fehlt",
+    "no_1_1_6_f": "f) Betätigungseinrichtung beschädigt",
+    "no_1_1_6_g": "g) Betätigungseinrichtung defekt",
+    "no_1_1_6_h": "h) Funktioniert nicht richtig",
+    "no_1_1_6_i": "i) Warnanzeige zeigt Fehlfunktion an",
+    "no_1_1_9_a": "a) Behälter beschädigt",
+    "no_1_1_9_a_3": "a) Behälter beschädigt, der Mangel ist verkehrsgefährdend",
+    "no_1_1_9_b": "b) Behälter übermäßig korrodiert",
+    "no_1_1_9_b_3": "b) Behälter übermäßig korrodiert, der Mangel ist verkehrsgefährdend",
+    "no_1_1_9_c": "c) Behälter undicht",
+    "no_1_1_9_c_3": "c) Behälter undicht, der Mangel ist verkehrsgefährdend",
+    "no_1_1_9_e": "e) Behälter unsicher befestigt",
+    "no_1_1_9_f": "f) Behälter falsch angebracht",
+    "no_1_1_10_a": "a) Bremskraftverstärker defekt oder unwirksam",
+    "no_1_1_10_a_3": "a) Bremskraftverstärker defekt oder unwirksam, keine Servounterstützung",
+    "no_1_1_10_b": "b) Hauptbremszylinder defekt",
+    "no_1_1_10_b_3": "b) Hauptbremszylinder defekt, Bremsfunktion stark beeinträchtigt oder schwere Mängel",
+    "no_1_1_10_c": "c) Hauptbremszylinder unsicher befestigt",
+    "no_1_1_10_c_3": "c) Hauptbremszylinder unsicher befestigt, schwere Befestigungsmängel und/oder Bremsfunktion stark beeinträchtigt",
+    "no_1_1_10_d": "d) Zu wenig Bremsflüssigkeit",
+    "no_1_1_10_d_2": "d) Zu wenig Bremsflüssigkeit, Stand deutlich unter der Minimum-Markierung",
+    "no_1_1_10_d_3": "d) Zu wenig Bremsflüssigkeit, keine Bremsflüssigkeit im Behälter sichtbar",
+    "no_1_1_10_e": "e) Deckel des Bremsflüssigkeitsbehälters fehlt",
+    "no_1_1_10_f": "f) Warnleuchte leuchtet oder ist defekt",
+    "no_1_1_10_g": "g) Warneinrichtung für den Bremsflüssigkeitsstand funktioniert nicht richtig",
+    "no_1_1_10_h": "h) Ausfall der Bremsanlage",
+    "no_1_1_10_i": "i) Schwammiges Bremspedal",
+    "no_1_1_10_j": "j) Undichtigkeit",
+    "no_1_1_10_j_3": "j) Undichtigkeit, der Mangel ist verkehrsgefährdend",
+    "no_1_1_11_a": "a) Unmittelbare Gefahr des Versagens oder Bruchs",
+    "no_1_1_11_b": "b) Leitung oder Verbindung undicht",
+    "no_1_1_11_b_3": "b) Leitung oder Verbindung undicht, der Mangel ist verkehrsgefährdend",
+    "no_1_1_11_c": "c) Leitung beschädigt",
+    "no_1_1_11_c_3": "c) Leitung beschädigt, Bremsen durch eine verstopfte Leitung beeinträchtigt oder unmittelbare Gefahr einer Undichtigkeit",
+    "no_1_1_11_d": "d) Leitung übermäßig korrodiert",
+    "no_1_1_11_d_3": "d) Leitung übermäßig korrodiert, der Mangel ist verkehrsgefährdend",
+    "no_1_1_11_e": "e) Leitung falsch verlegt",
+    "no_1_1_11_f": "f) Unsachgemäße Reparatur oder Änderung",
+    "no_1_1_11_f_3": "f) Unsachgemäße Reparatur oder Änderung, der Mangel ist verkehrsgefährdend",
+    "no_1_1_12_a": "a) Unmittelbare Gefahr des Versagens oder Bruchs",
+    "no_1_1_12_b": "b) Schlauch beschädigt",
+    "no_1_1_12_c": "c) Schlauch falsch verlegt",
+    "no_1_1_12_d": "d) Schlauch zu kurz",
+    "no_1_1_12_e": "e) Schlauch oder Verbindung undicht",
+    "no_1_1_12_e_3": "e) Schlauch oder Verbindung undicht, der Mangel ist verkehrsgefährdend",
+    "no_1_1_12_f": "f) Schlauch wölbt sich unter Druck aus",
+    "no_1_1_12_f_3": "f) Schlauch wölbt sich unter Druck aus, der Mangel ist verkehrsgefährdend",
+    "no_1_1_12_g": "g) Schlauch porös/rissig",
+    "no_1_1_12_g_3": "g) Schlauch porös/rissig, der Mangel ist verkehrsgefährdend",
+    "no_1_1_12_h": "h) Unsachgemäße Reparatur oder Änderung",
+    "no_1_1_12_h_3": "h) Unsachgemäße Reparatur oder Änderung, der Mangel ist verkehrsgefährdend",
+    "no_1_1_13_a": "a) Übermäßiger Verschleiß des Bremsbelags",
+    "no_1_1_13_a_3": "a) Übermäßiger Verschleiß des Bremsbelags, der Belag ist bis auf das Metall abgenutzt",
+    "no_1_1_13_b": "b) Belag verunreinigt (Öl, Fett usw.)",
+    "no_1_1_13_b_3": "b) Belag verunreinigt (Öl, Fett usw.), die Bremswirkung ist stark beeinträchtigt",
+    "no_1_1_13_c": "c) Bremsbeläge oder Bremsklötze fehlen",
+    "no_1_1_13_d": "d) Bremsbeläge oder Bremsklötze falsch eingebaut",
+    "no_1_1_13_d_3": "d) Bremsbeläge oder Bremsklötze falsch eingebaut, der Mangel ist verkehrsgefährdend",
+    "no_1_1_13_e": "e) Belag lose",
+    "no_1_1_13_e_3": "e) Belag lose, der Mangel ist verkehrsgefährdend",
+    "no_1_1_14_a": "a) Übermäßiger Verschleiß",
+    "no_1_1_14_a_3": "a) Übermäßiger Verschleiß, der Mangel ist verkehrsgefährdend",
+    "no_1_1_14_b": "b) Mängel",
+    "no_1_1_14_b_3": "b) Mängel, der Mangel ist verkehrsgefährdend",
+    "no_1_1_14_c": "c) Trommel oder Scheibe verunreinigt (Öl, Fett usw.)",
+    "no_1_1_14_c_3": "c) Trommel oder Scheibe verunreinigt (Öl, Fett usw.), der Mangel ist verkehrsgefährdend",
+    "no_1_1_14_d": "d) Trommel oder Scheibe fehlt",
+    "no_1_1_14_e": "e) Ankerblech unsicher befestigt",
+    "no_1_1_14_f": "f) Mehr als 25 % Rost auf einer Seite einer Bremsscheibe",
+    "no_1_1_14_g": "g) Mehr als 50 % Rost auf einer Seite einer Bremsscheibe",
+    "no_1_1_14_h": "h) Übermäßiger Riss",
+    "no_1_1_14_h_3": "h) Übermäßiger Riss, der Mangel ist verkehrsgefährdend",
+    "no_1_1_14_i": "i) Übermäßige Riefenbildung",
+    "no_1_1_14_i_3": "i) Übermäßige Riefenbildung, der Mangel ist verkehrsgefährdend",
+    "no_1_1_14_j": "j) Übermäßige Beschädigung",
+    "no_1_1_14_j_3": "j) Übermäßige Beschädigung, der Mangel ist verkehrsgefährdend",
+    "no_1_1_14_k": "k) Unsicher befestigt",
+    "no_1_1_14_k_3": "k) Unsicher befestigt, der Mangel ist verkehrsgefährdend",
+    "no_1_1_14_l": "l) Gebrochen",
+    "no_1_1_14_l_3": "l) Gebrochen, der Mangel ist verkehrsgefährdend",
+    "no_1_1_15_a": "a) Defekt",
+    "no_1_1_15_a_3": "a) Defekt, der Mangel ist verkehrsgefährdend",
+    "no_1_1_15_b": "b) Beschädigt",
+    "no_1_1_15_b_3": "b) Beschädigt, der Mangel ist verkehrsgefährdend",
+    "no_1_1_15_c": "c) Verformt",
+    "no_1_1_15_c_3": "c) Verformt, der Mangel ist verkehrsgefährdend",
+    "no_1_1_15_d": "d) Übermäßiger Verschleiß oder übermäßige Korrosion",
+    "no_1_1_15_d_3": "d) Übermäßiger Verschleiß oder übermäßige Korrosion, der Mangel ist verkehrsgefährdend",
+    "no_1_1_15_e": "e) Schwachstellen",
+    "no_1_1_15_f": "f) Seilführung defekt",
+    "no_1_1_15_g": "g) Freigängigkeit der Bremsanlage eingeschränkt",
+    "no_1_1_15_h": "h) Anormale Bewegung von Hebeln/Gestänge durch Fehleinstellung oder übermäßigen Verschleiß",
+    "no_1_1_16_a": "a) Gerissen",
+    "no_1_1_16_a_3": "a) Gerissen, der Mangel ist verkehrsgefährdend",
+    "no_1_1_16_b": "b) Beschädigt",
+    "no_1_1_16_b_3": "b) Beschädigt, der Mangel ist verkehrsgefährdend",
+    "no_1_1_16_c": "c) Undicht",
+    "no_1_1_16_c_3": "c) Undicht, der Mangel ist verkehrsgefährdend",
+    "no_1_1_16_d": "d) Fehlt",
+    "no_1_1_16_d_3": "d) Fehlt, der Mangel ist verkehrsgefährdend",
+    "no_1_1_16_e": "e) Unsicher befestigt",
+    "no_1_1_16_e_3": "e) Unsicher befestigt, der Mangel ist verkehrsgefährdend",
+    "no_1_1_16_f": "f) Falsch eingebaut",
+    "no_1_1_16_f_3": "f) Falsch eingebaut, der Mangel ist verkehrsgefährdend",
+    "no_1_1_16_g": "g) Übermäßig korrodiert",
+    "no_1_1_16_g_3": "g) Übermäßig korrodiert, der Mangel ist verkehrsgefährdend",
+    "no_1_1_16_i": "i) Staubmanschette stark geschwächt",
+    "no_1_1_16_j": "j) Staubmanschette fehlt",
+    "no_1_1_16_k": "k) Staubmanschette gerissen",
+    "no_1_1_16_l": "l) Defekt",
+    "no_1_1_16_l_3": "l) Defekt, der Mangel ist verkehrsgefährdend",
+    "no_1_1_17_a": "a) Gestänge defekt",
+    "no_1_1_17_b": "b) Gestänge/Ventil falsch eingestellt",
+    "no_1_1_17_c": "c) Ventil festgefressen",
+    "no_1_1_17_d": "d) Ventil funktionslos",
+    "no_1_1_17_e": "e) Ventil fehlt",
+    "no_1_1_17_f": "f) Typschild des Ventils fehlt",
+    "no_1_1_17_g": "g) Angaben unleserlich oder nicht in Übereinstimmung mit den Anforderungen",
+    "no_1_1_17_h": "h) Undichtigkeit",
+    "no_1_1_17_h_3": "h) Undichtigkeit, der Mangel ist verkehrsgefährdend",
+    "no_1_1_18_a": "a) Beschädigt",
+    "no_1_1_18_b": "b) Festgefressen",
+    "no_1_1_18_c": "c) Anormale Bewegung",
+    "no_1_1_18_d": "d) Übermäßiges Spiel",
+    "no_1_1_18_e": "e) Falsch eingestellt",
+    "no_1_1_18_f": "f) Defekt",
+    "no_1_1_18_g": "g) Falsch eingebaut",
+    "no_1_1_21_c": "c) Bauteil unsicher befestigt",
+    "no_1_1_21_d": "d) Bauteil falsch eingebaut",
+    "no_1_1_21_e": "e) Unsachgemäße Reparatur oder Änderung eines Bauteils",
+    "no_1_1_21_e_3": "e) Unsachgemäße Reparatur oder Änderung eines Bauteils, die Bremswirkung ist stark beeinträchtigt",
+    "no_1_1_21_f": "f) Undichtigkeit",
+    "no_1_1_21_f_3": "f) Undichtigkeit, der Mangel ist verkehrsgefährdend",
+    "no_1_1_23_d": "d) Warnleuchte funktioniert nicht",
+    "no_1_1_23_e": "e) Warnleuchte zeigt einen Systemfehler an",
+    "no_1_2_1_a": "a) Unzureichende Bremskraft an einem oder mehreren Rädern",
+    "no_1_2_1_a_3": "a) Unzureichende Bremskraft an einem oder mehreren Rädern, ein oder mehrere Räder haben keine Bremswirkung, was die Gesamtbremswirkung stark beeinträchtigt",
+    "no_1_2_1_b": "b) Zu große Bremskraftdifferenz zwischen den Rädern derselben Achse",
+    "no_1_2_1_b_3": "b) Zu große Bremskraftdifferenz zwischen den Rädern derselben Achse, Differenz größer als 50 % des höchsten Werts an der Vorderachse eines Kraftfahrzeugs oder gefährliche Abweichung bei der Probefahrt",
+    "no_1_2_1_c": "c) Bremskraft nicht stufenlos (ruckartig)",
+    "no_1_2_1_d": "d) Anormale Ansprechverzögerung der Bremse an einem oder mehreren Rädern",
+    "no_1_2_1_e": "e) Zu starke Bremskraftschwankung",
+    "no_1_2_1_f": "f) Bremsen schleifen",
+    "no_1_2_1_g": "g) Bremsprüfung nicht durchgeführt. Bei der Nachprüfung muss eine Bremsprüfung durchgeführt und bestanden werden",
+    "no_1_2_2_a": "a) Unzureichende Abbremsung",
+    "no_1_2_2_d": "d) Bremsprüfung nicht durchgeführt. Bei der Nachprüfung muss eine Bremsprüfung durchgeführt und bestanden werden",
+    "no_1_2_2_e": "e) Gefährlich unzureichende Abbremsung",
+    "no_1_4_1_a": "a) Keine Bremswirkung auf einer Seite",
+    "no_1_4_1_b": "b) Feststellbremse schleift",
+    "no_1_4_2_a": "a) Unzureichende Abbremsung",
+    "no_1_4_2_a_3": "a) Unzureichende Abbremsung, gefährliche oder keine Bremswirkung (hydraulische Betriebsbremse)",
+    "no_1_6_a": "a) Warneinrichtung funktioniert nicht richtig",
+    "no_1_6_b": "b) Warneinrichtung zeigt Systemfehler an",
+    "no_1_6_c": "c) Raddrehzahlsensor fehlt",
+    "no_1_6_d": "d) Raddrehzahlsensor beschädigt",
+    "no_1_6_e": "e) Verkabelung beschädigt",
+    "no_1_6_f": "f) Bauteil fehlt",
+    "no_1_6_g": "g) Bauteil beschädigt",
+    "no_1_6_h": "h) ABS-Ventil funktioniert nicht",
+    "no_1_6_j": "j) Fehlerhafte/unbeabsichtigte Funktion",
+    "no_1_6_k": "k) Funktioniert nicht",
+    "no_1_8_a": "a) Bremsflüssigkeit stark verunreinigt oder mit starker Ablagerung",
+    "no_1_8_a_3": "a) Bremsflüssigkeit stark verunreinigt oder mit starker Ablagerung, unmittelbare Gefahr eines Ausfalls der Bremsanlage",
+    "no_1_8_b": "b) Siedepunkt zu niedrig",
+    "no_2_1_1_a": "a) Lenkgetriebe schwergängig",
+    "no_2_1_1_a_3": "a) Lenkgetriebe schwergängig, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_1_b": "b) Lenkwelle verdreht oder Verzahnung verschlissen",
+    "no_2_1_1_b_3": "b) Lenkwelle verdreht oder Verzahnung verschlissen, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_1_c": "c) Übermäßiger Verschleiß der Zahnstange",
+    "no_2_1_1_c_3": "c) Übermäßiger Verschleiß der Zahnstange, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_1_d": "d) Übermäßiger Verschleiß der Lenkschnecke",
+    "no_2_1_1_d_3": "d) Übermäßiger Verschleiß der Lenkschnecke, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_1_e": "e) Übermäßige Bewegung der Lenkwelle",
+    "no_2_1_1_e_3": "e) Übermäßige Bewegung der Lenkwelle, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_1_f": "f) Undichtigkeit",
+    "no_2_1_1_f_2": "f) Undichtigkeit, starke Undichtigkeit",
+    "no_2_1_2_a": "a) Nicht ausreichend befestigt",
+    "no_2_1_2_a_3": "a) Nicht ausreichend befestigt, die Befestigung ist gefährlich oder es besteht starke Bewegung zwischen Lenkgetriebe und Aufbau/Rahmen",
+    "no_2_1_2_b": "b) Ausgeweitete Befestigungslöcher im Fahrgestell",
+    "no_2_1_2_b_3": "b) Ausgeweitete Befestigungslöcher im Fahrgestell, die Befestigung ist stark geschwächt",
+    "no_2_1_2_c": "c) Befestigungsschrauben fehlen oder sind gebrochen",
+    "no_2_1_2_c_3": "c) Befestigungsschrauben fehlen oder sind gebrochen, die Befestigung ist stark geschwächt",
+    "no_2_1_2_d": "d) Lenkgetriebe-/Lenkgehäuse gebrochen",
+    "no_2_1_2_d_3": "d) Lenkgetriebe-/Lenkgehäuse gebrochen, der Bruch beeinträchtigt die Stabilität oder Befestigung des Lenkgetriebe-/Lenkgehäuses",
+    "no_2_1_3_a": "a) Bewegung zwischen Bauteilen, die fest verbunden sein sollten",
+    "no_2_1_3_a_3": "a) Bewegung zwischen Bauteilen, die fest verbunden sein sollten, stark übermäßige Bewegung zwischen Bauteilen oder Gefahr, dass sich ein Bauteil löst",
+    "no_2_1_3_b": "b) Übermäßiger Verschleiß im Kugelgelenk",
+    "no_2_1_3_b_3": "b) Übermäßiger Verschleiß im Kugelgelenk, Gefahr, dass das Bauteil versagt oder sich löst",
+    "no_2_1_3_c": "c) Übermäßiger Verschleiß im Lenkgelenk",
+    "no_2_1_3_c_3": "c) Übermäßiger Verschleiß im Lenkgelenk, Gefahr, dass das Bauteil versagt oder sich löst",
+    "no_2_1_3_d": "d) Übermäßiger Verschleiß im Lenkzwischenhebel/Hilfslenkgetriebe",
+    "no_2_1_3_d_3": "d) Übermäßiger Verschleiß im Lenkzwischenhebel/Hilfslenkgetriebe, Gefahr, dass das Bauteil versagt oder sich löst",
+    "no_2_1_3_e": "e) Bauteil beschädigt",
+    "no_2_1_3_e_3": "e) Bauteil beschädigt, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_3_f": "f) Bauteil verformt",
+    "no_2_1_3_f_3": "f) Bauteil verformt, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_3_g": "g) Mängel",
+    "no_2_1_3_g_3": "g) Mängel, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_3_h": "h) Bauteil gebrochen",
+    "no_2_1_3_h_3": "h) Bauteil gebrochen, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_3_i": "i) Sicherungseinrichtungen fehlen",
+    "no_2_1_3_j": "j) Bauteil fluchtet nicht oder ist falsch eingestellt",
+    "no_2_1_3_k": "k) Unsachgemäße Reparatur oder Änderung",
+    "no_2_1_3_k_3": "k) Unsachgemäße Reparatur oder Änderung, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_3_l": "l) Staubmanschette stark geschwächt",
+    "no_2_1_3_m": "m) Staubmanschette fehlt",
+    "no_2_1_3_n": "n) Staubmanschette gerissen",
+    "no_2_1_4_a": "a) Bewegliche Lenkungsteile schleifen an einem festen Teil des Fahrgestells",
+    "no_2_1_4_b": "b) Lenkanschläge funktionieren nicht",
+    "no_2_1_4_c": "c) Lenkanschläge fehlen",
+    "no_2_1_5_a": "a) Undichtigkeit",
+    "no_2_1_5_b": "b) Zu wenig Servolenkungsflüssigkeit",
+    "no_2_1_5_b_2": "b) Zu wenig Servolenkungsflüssigkeit, keine Flüssigkeit im Behälter",
+    "no_2_1_5_c": "c) Fehlfunktion",
+    "no_2_1_5_d": "d) Unzureichende Lenkunterstützung",
+    "no_2_1_5_e": "e) Keine Lenkunterstützung",
+    "no_2_1_5_e_3": "e) Keine Lenkunterstützung, aufgrund von Bauart/Größe des Fahrzeugs ist das Fahren ohne Lenkunterstützung gefährlich",
+    "no_2_1_5_f": "f) Bauteil gebrochen",
+    "no_2_1_5_f_3": "f) Bauteil gebrochen, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_5_g": "g) Bauteil nicht ausreichend befestigt",
+    "no_2_1_5_g_3": "g) Bauteil nicht ausreichend befestigt, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_5_h": "h) Bauteil fluchtet nicht",
+    "no_2_1_5_h_3": "h) Bauteil fluchtet nicht, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_5_i": "i) Bauteile schleifen aneinander",
+    "no_2_1_5_i_3": "i) Bauteile schleifen aneinander, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_5_j": "j) Unsachgemäße Reparatur oder Änderung",
+    "no_2_1_5_j_3": "j) Unsachgemäße Reparatur oder Änderung, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_5_k": "k) Leitung/Schlauch beschädigt",
+    "no_2_1_5_k_3": "k) Leitung/Schlauch beschädigt, die Lenkfunktion ist stark beeinträchtigt",
+    "no_2_1_5_l": "l) Leitung/Schlauch übermäßig korrodiert",
+    "no_2_2_1_a": "a) Relativbewegung zwischen Lenkrad und Lenksäule, die auf Lockerung hindeutet",
+    "no_2_2_1_a_3": "a) Relativbewegung zwischen Lenkrad und Lenksäule, die auf Lockerung hindeutet, Gefahr, dass das Bauteil versagt oder sich löst",
+    "no_2_2_1_b": "b) Sicherung an der Lenkradnabe fehlt",
+    "no_2_2_1_b_3": "b) Sicherung an der Lenkradnabe fehlt, Gefahr, dass das Bauteil versagt oder sich löst",
+    "no_2_2_1_c": "c) Lenkradnabe, -kranz oder -speichen beschädigt",
+    "no_2_2_1_c_3": "c) Lenkradnabe, -kranz oder -speichen beschädigt, Gefahr, dass das Bauteil versagt oder sich löst",
+    "no_2_2_1_d": "d) Lenkradnabe, -kranz oder -speichen defekt",
+    "no_2_2_1_d_3": "d) Lenkradnabe, -kranz oder -speichen defekt, Gefahr, dass das Bauteil versagt oder sich löst",
+    "no_2_2_1_e": "e) Lenkradnabe, -kranz oder -speichen gebrochen",
+    "no_2_2_1_e_3": "e) Lenkradnabe, -kranz oder -speichen gebrochen, Gefahr, dass das Bauteil versagt oder sich löst",
+    "no_2_2_1_f": "f) Lenkradnabe, -kranz oder -speichen lose",
+    "no_2_2_1_f_3": "f) Lenkradnabe, -kranz oder -speichen lose, Gefahr, dass das Bauteil versagt oder sich löst",
+    "no_2_2_1_g": "g) Lenkrad nicht genehmigt oder verändert",
+    "no_2_2_2_a": "a) Übermäßige Auf- oder Abbewegung in der Lenkradmitte",
+    "no_2_2_2_b": "b) Übermäßige radiale Bewegung am oberen Ende der Lenksäule",
+    "no_2_2_2_c": "c) Übermäßiger Verschleiß im Gelenk",
+    "no_2_2_2_c_3": "c) Übermäßiger Verschleiß im Gelenk, der Mangel ist verkehrsgefährdend",
+    "no_2_2_2_d": "d) Übermäßiger Verschleiß in der Kupplung",
+    "no_2_2_2_d_3": "d) Übermäßiger Verschleiß in der Kupplung, der Mangel ist verkehrsgefährdend",
+    "no_2_2_2_e": "e) Nicht ausreichend befestigt",
+    "no_2_2_2_e_3": "e) Nicht ausreichend befestigt, der Mangel ist verkehrsgefährdend",
+    "no_2_2_2_f": "f) Unsachgemäß repariert oder verändert",
+    "no_2_2_2_f_3": "f) Unsachgemäß repariert oder verändert, der Mangel ist verkehrsgefährdend",
+    "no_2_3_a": "a) Zu großes Lenkungsspiel",
+    "no_2_3_a_3": "a) Zu großes Lenkungsspiel, die Lenkung kann nicht sicher bedient werden",
+    "no_2_4_a": "a) Radstellung stark fehlerhaft",
+    "no_2_6_a": "a) EPS-Fehlfunktionsanzeige zeigt einen Fehler oder Ausfall des Systems an",
+    "no_2_6_b": "b) Lenkradwinkel stimmt nicht mit dem Radwinkel überein",
+    "no_2_6_b_3": "b) Lenkradwinkel stimmt nicht mit dem Radwinkel überein, die Lenkung ist stark beeinträchtigt",
+    "no_2_6_c": "c) Keine Lenkunterstützung",
+    "no_2_6_c_3": "c) Keine Lenkunterstützung, aufgrund von Bauart/Größe des Fahrzeugs ist das Fahren ohne Lenkunterstützung gefährlich",
+    "no_2_7_a": "a) Schlechte Selbstrückstellung",
+    "no_2_7_b": "b) Schlechter Geradeauslauf",
+    "no_3_1_a": "a) Sichtbehinderung, die die Sicht nach vorne oder zur Seite erheblich beeinträchtigt",
+    "no_3_2_a": "a) Übermäßige Beschädigung der Frontscheibe im Sichtfeld",
+    "no_3_2_b": "b) Übermäßiger Riss in der Frontscheibe im Sichtfeld",
+    "no_3_2_c": "c) Verfärbte Frontscheibe im Sichtfeld",
+    "no_3_2_d": "d) Übermäßige Beschädigung der Frontscheibe außerhalb des Sichtfelds, die Sicht ist erheblich beeinträchtigt",
+    "no_3_2_e": "e) Übermäßiger Riss in der Frontscheibe außerhalb des Sichtfelds, die Sicht ist erheblich beeinträchtigt",
+    "no_3_2_f": "f) Verfärbte Frontscheibe, die Sicht ist erheblich beeinträchtigt",
+    "no_3_2_g": "g) Frontscheibe übermäßig abgenutzt, die Sicht ist erheblich beeinträchtigt",
+    "no_3_2_h": "h) Seitenscheibe beschädigt, gerissen, abgenutzt oder verfärbt, die Sicht ist erheblich beeinträchtigt",
+    "no_3_2_i": "i) Scheibe entspricht nicht den Anforderungen (getönte Folie usw.)",
+    "no_3_2_j": "j) Verkehrsgefährdender Mangel an der Scheibe",
+    "no_3_2_k": "k) Vordere Seitenscheibe wegen ihrer Stellung nicht geprüft",
+    "no_3_3_a": "a) Fehlt",
+    "no_3_3_b": "b) Entspricht nicht den Anforderungen",
+    "no_3_3_c": "c) Funktioniert nicht",
+    "no_3_3_d": "d) Beschädigt",
+    "no_3_3_e": "e) Lose oder nicht ausreichend befestigt",
+    "no_3_4_a": "a) Funktioniert nicht",
+    "no_3_4_b": "b) Wischerarm fehlt",
+    "no_3_4_c": "c) Wischerarm beschädigt oder defekt",
+    "no_3_4_d": "d) Entspricht nicht den Anforderungen",
+    "no_3_4_e": "e) Wischerblatt verschlissen",
+    "no_3_4_f": "f) Wischerblatt defekt",
+    "no_3_4_g": "g) Wischerblatt fehlt",
+    "no_3_4_h": "h) Endabschaltung defekt und die Wischer bleiben automatisch in einer Stellung stehen, die die Sicht erheblich beeinträchtigt",
+    "no_3_5_a": "a) Funktioniert nicht",
+    "no_3_5_b": "b) Waschdüse falsch ausgerichtet",
+    "no_3_5_c": "c) Waschanlage funktioniert nicht zufriedenstellend",
+    "no_3_6_a": "a) Anlage funktioniert nicht, keine Entfeuchtungsfunktion",
+    "no_4_1_1_a": "a) Funktioniert nicht",
+    "no_4_1_1_b": "b) Reflektor oder Streuscheibe matt",
+    "no_4_1_1_c": "c) Reflektor oder Streuscheibe beschädigt",
+    "no_4_1_1_d": "d) Reflektor oder Streuscheibe defekt",
+    "no_4_1_1_e": "e) Reflektor oder Streuscheibe fehlt",
+    "no_4_1_1_f": "f) Leuchte nicht ausreichend befestigt",
+    "no_4_1_2_a": "a) Falsche Einstellung",
+    "no_4_1_2_b": "b) Falsches Lichtbild",
+    "no_4_1_2_c": "c) Einstellung wegen defekter Leuchte/Lichtquelle nicht geprüft",
+    "no_4_1_3_a": "a) Schalter funktioniert nicht gemäß den Anforderungen",
+    "no_4_1_3_b": "b) Kontrolleinrichtung funktioniert nicht zufriedenstellend",
+    "no_4_1_4_a": "a) Leuchte entspricht nicht den Anforderungen",
+    "no_4_1_4_b": "b) Anbringung entspricht nicht den Anforderungen",
+    "no_4_1_4_c": "c) Kennzeichnung entspricht nicht den Anforderungen",
+    "no_4_1_4_d": "d) Lichtstärke entspricht nicht den Anforderungen",
+    "no_4_1_4_e": "e) Lichtfarbe entspricht nicht den Anforderungen",
+    "no_4_1_4_f": "f) Mittel auf Streuscheibe oder Lichtquelle, das die Lichtstärke offensichtlich verringert oder die Lichtfarbe verändert",
+    "no_4_1_4_g": "g) Lichtquelle und Leuchte nicht kompatibel",
+    "no_4_1_5_a": "a) Funktioniert nicht",
+    "no_4_1_6_a": "a) Funktioniert nicht",
+    "no_4_2_1_a": "a) Funktioniert nicht",
+    "no_4_2_1_b": "b) Beschädigt",
+    "no_4_2_1_c": "c) Fehlt",
+    "no_4_2_1_d": "d) Defekt",
+    "no_4_2_1_e": "e) Leuchte nicht ausreichend befestigt",
+    "no_4_2_1_e_2": "e) Leuchte nicht ausreichend befestigt, es besteht große Gefahr, dass die Leuchte abfällt",
+    "no_4_2_2_a": "a) Schaltung entspricht nicht den Anforderungen",
+    "no_4_2_2_b": "b) Kontrolleinrichtung funktioniert nicht zufriedenstellend",
+    "no_4_2_3_a": "a) Leuchte entspricht nicht den Anforderungen",
+    "no_4_2_3_b": "b) Lichtfarbe entspricht nicht den Anforderungen",
+    "no_4_2_3_c": "c) Anbringung entspricht nicht den Anforderungen",
+    "no_4_2_3_d": "d) Anzahl entspricht nicht den Anforderungen",
+    "no_4_2_3_e": "e) Kennzeichnung entspricht nicht den Anforderungen",
+    "no_4_2_3_f": "f) Lichtstärke entspricht nicht den Anforderungen",
+    "no_4_2_3_g": "g) Mittel auf Streuscheibe oder Lichtquelle, das die Lichtstärke offensichtlich verringert oder die Lichtfarbe verändert",
+    "no_4_3_1_a": "a) Funktioniert nicht",
+    "no_4_3_1_a_3": "a) Funktioniert nicht, keine Bremsleuchte funktioniert",
+    "no_4_3_1_b": "b) Beschädigt",
+    "no_4_3_1_c": "c) Fehlt",
+    "no_4_3_1_d": "d) Defekt",
+    "no_4_3_1_e": "e) Leuchte nicht ausreichend befestigt",
+    "no_4_3_1_e_2": "e) Leuchte nicht ausreichend befestigt, es besteht große Gefahr, dass die Leuchte abfällt",
+    "no_4_3_2_a": "a) Schalter funktioniert nicht gemäß den Anforderungen",
+    "no_4_3_2_a_3": "a) Schalter funktioniert nicht gemäß den Anforderungen, der Schalter funktioniert überhaupt nicht",
+    "no_4_3_2_b": "b) Kontrolleinrichtung funktioniert nicht zufriedenstellend",
+    "no_4_3_2_c": "c) Notbremssignal funktioniert nicht oder nicht richtig",
+    "no_4_3_3_a": "a) Leuchte entspricht nicht den Anforderungen",
+    "no_4_3_3_b": "b) Lichtfarbe entspricht nicht den Anforderungen",
+    "no_4_3_3_c": "c) Anbringung entspricht nicht den Anforderungen",
+    "no_4_3_3_d": "d) Anzahl entspricht nicht den Anforderungen",
+    "no_4_3_3_e": "e) Kennzeichnung entspricht nicht den Anforderungen",
+    "no_4_3_3_f": "f) Lichtstärke entspricht nicht den Anforderungen",
+    "no_4_4_1_a": "a) Funktioniert nicht",
+    "no_4_4_1_b": "b) Beschädigt",
+    "no_4_4_1_c": "c) Fehlt",
+    "no_4_4_1_d": "d) Defekt",
+    "no_4_4_1_e": "e) Leuchte nicht ausreichend befestigt",
+    "no_4_4_1_e_2": "e) Leuchte nicht ausreichend befestigt, es besteht große Gefahr, dass die Leuchte abfällt",
+    "no_4_4_2_a": "a) Schaltung entspricht nicht den Anforderungen",
+    "no_4_4_2_a_2": "a) Schaltung entspricht nicht den Anforderungen, der Schalter funktioniert nicht (kann nicht ein- oder ausgeschaltet werden)",
+    "no_4_4_3_a": "a) Leuchte entspricht nicht den Anforderungen",
+    "no_4_4_3_b": "b) Lichtfarbe entspricht nicht den Anforderungen",
+    "no_4_4_3_c": "c) Anbringung entspricht nicht den Anforderungen",
+    "no_4_4_3_d": "d) Anzahl entspricht nicht den Anforderungen",
+    "no_4_4_3_e": "e) Kennzeichnung entspricht nicht den Anforderungen",
+    "no_4_4_3_f": "f) Lichtstärke entspricht nicht den Anforderungen",
+    "no_4_4_4_a": "a) Blinkfrequenz entspricht nicht den Anforderungen",
+    "no_4_4_4_a_2": "a) Blinkfrequenz entspricht nicht den Anforderungen, große Abweichung",
+    "no_4_5_1_a": "a) Funktioniert nicht",
+    "no_4_5_1_b": "b) Beschädigt",
+    "no_4_5_1_c": "c) Fehlt",
+    "no_4_5_1_d": "d) Defekt",
+    "no_4_5_1_e": "e) Leuchte nicht ausreichend befestigt",
+    "no_4_5_1_e_2": "e) Leuchte nicht ausreichend befestigt, es besteht große Gefahr, dass die Leuchte abfällt, oder sie blendet den Gegenverkehr",
+    "no_4_5_2_a": "a) Falsche Einstellung",
+    "no_4_5_2_b": "b) Einstellung wegen defekter Leuchte/Lichtquelle nicht geprüft",
+    "no_4_5_3_a": "a) Schaltung entspricht nicht den Anforderungen",
+    "no_4_5_4_a": "a) Leuchte entspricht nicht den Anforderungen",
+    "no_4_5_4_b": "b) Lichtfarbe entspricht nicht den Anforderungen",
+    "no_4_5_4_c": "c) Anbringung entspricht nicht den Anforderungen",
+    "no_4_5_4_d": "d) Anzahl entspricht nicht den Anforderungen",
+    "no_4_5_4_e": "e) Kennzeichnung entspricht nicht den Anforderungen",
+    "no_4_5_4_f": "f) Lichtstärke entspricht nicht den Anforderungen",
+    "no_4_5_4_g": "g) Funktion der Anlage entspricht nicht den Anforderungen",
+    "no_4_5_4_h": "h) Lichtquelle und Leuchte nicht kompatibel",
+    "no_4_6_1_a": "a) Funktioniert nicht",
+    "no_4_6_1_b": "b) Beschädigt",
+    "no_4_6_1_c": "c) Fehlt",
+    "no_4_6_1_d": "d) Defekt",
+    "no_4_6_1_e": "e) Leuchte nicht ausreichend befestigt",
+    "no_4_6_1_e_2": "e) Leuchte nicht ausreichend befestigt, es besteht große Gefahr, dass die Leuchte abfällt",
+    "no_4_6_2_a": "a) Leuchte entspricht nicht den Anforderungen",
+    "no_4_6_2_b": "b) Lichtfarbe entspricht nicht den Anforderungen",
+    "no_4_6_2_c": "c) Anbringung entspricht nicht den Anforderungen",
+    "no_4_6_2_d": "d) Anzahl entspricht nicht den Anforderungen",
+    "no_4_6_2_e": "e) Kennzeichnung entspricht nicht den Anforderungen",
+    "no_4_6_2_f": "f) Lichtstärke entspricht nicht den Anforderungen",
+    "no_4_6_2_g": "g) Funktion der Anlage entspricht nicht den Anforderungen",
+    "no_4_6_3_a": "a) Schaltung entspricht nicht den Anforderungen",
+    "no_4_7_1_a": "a) Funktioniert nicht",
+    "no_4_7_1_a_2": "a) Funktioniert nicht, betrifft alle Kennzeichenleuchten des Fahrzeugs",
+    "no_4_7_1_b": "b) Beschädigt",
+    "no_4_7_1_c": "c) Fehlt",
+    "no_4_7_1_c_2": "c) Fehlt, betrifft alle Kennzeichenleuchten des Fahrzeugs",
+    "no_4_7_1_d": "d) Defekt",
+    "no_4_7_1_d_2": "d) Defekt, betrifft alle Kennzeichenleuchten des Fahrzeugs",
+    "no_4_7_1_e": "e) Leuchte nicht ausreichend befestigt",
+    "no_4_7_1_e_2": "e) Leuchte nicht ausreichend befestigt, es besteht große Gefahr, dass die Leuchte abfällt",
+    "no_4_7_1_f": "f) Strahlt Licht direkt nach hinten ab",
+    "no_4_7_2_a": "a) Funktion der Anlage entspricht nicht den Anforderungen",
+    "no_4_8_1_a": "a) Defekt",
+    "no_4_8_1_a_2": "a) Defekt, die Rückstrahlwirkung ist stark beeinträchtigt",
+    "no_4_8_1_b": "b) Beschädigt",
+    "no_4_8_1_b_2": "b) Beschädigt, die Rückstrahlwirkung ist stark beeinträchtigt",
+    "no_4_8_1_c": "c) Nicht ausreichend befestigt",
+    "no_4_8_1_c_2": "c) Nicht ausreichend befestigt, der Rückstrahler kann abfallen",
+    "no_4_8_2_a": "a) Einrichtung entspricht nicht den Anforderungen",
+    "no_4_8_2_b": "b) Farbe des reflektierten Lichts entspricht nicht den Anforderungen",
+    "no_4_8_2_b_2": "b) Farbe des reflektierten Lichts entspricht nicht den Anforderungen, Rot wird nach vorne oder Weiß nach hinten reflektiert",
+    "no_4_8_2_c": "c) Anbringung entspricht nicht den Anforderungen",
+    "no_4_9_1_a": "a) Funktioniert nicht",
+    "no_4_9_1_a_2": "a) Funktioniert nicht, die Kontrollleuchte für Fernlicht oder Nebelschlussleuchte funktioniert nicht",
+    "no_4_9_2_a": "a) Entspricht nicht den Anforderungen",
+    "no_4_10_a": "a) Fest angebrachte Teile nicht sicher befestigt",
+    "no_4_10_a_2": "a) Fest angebrachte Teile nicht sicher befestigt, die Anhängersteckdose ist lose",
+    "no_4_10_b": "b) Isolierung beschädigt oder erheblich verschlechtert",
+    "no_4_10_b_2": "b) Isolierung beschädigt oder erheblich verschlechtert, es besteht Kurzschlussgefahr",
+    "no_4_10_c": "c) Elektrische Verbindung funktioniert nicht richtig",
+    "no_4_10_c_3": "c) Elektrische Verbindung funktioniert nicht richtig, der Anhänger hat bei Vorführung zusammen mit dem Zugfahrzeug keine Bremsleuchten",
+    "no_4_11_a": "a) Unzureichend oder unsicher befestigt",
+    "no_4_11_a_2": "a) Unzureichend oder unsicher befestigt, Verlegung/Befestigung ist lose, Leitungen berühren scharfe Kanten, Verbindungen können sich lösen oder Leitungen (auch nachgerüstete) sind nicht ausreichend befestigt",
+    "no_4_11_a_3": "a) Unzureichend oder unsicher befestigt, Gefahr, dass Leitungen heiße oder rotierende Teile oder die Fahrbahn berühren, oder Verbindungen für Bremse oder Lenkung sind getrennt",
+    "no_4_11_b": "b) Geschwächt",
+    "no_4_11_b_2": "b) Geschwächt, Leitungen stark geschwächt",
+    "no_4_11_b_3": "b) Geschwächt, sehr stark geschwächte Leitungen für Bremse und Lenkung",
+    "no_4_11_c": "c) Isolierung beschädigt oder erheblich verschlechtert",
+    "no_4_11_c_2": "c) Isolierung beschädigt oder erheblich verschlechtert, es besteht Kurzschlussgefahr",
+    "no_4_11_c_3": "c) Isolierung beschädigt oder erheblich verschlechtert, unmittelbare Brandgefahr oder Funkenbildung",
+    "no_4_12_a": "a) Leuchte entgegen den Anforderungen angebracht",
+    "no_4_12_b": "b) Rückstrahler entgegen den Anforderungen angebracht",
+    "no_4_12_c": "c) Funktion der Leuchte entspricht nicht den Anforderungen",
+    "no_4_12_d": "d) Nicht ausreichend befestigt",
+    "no_4_12_d_2": "d) Nicht ausreichend befestigt, es besteht große Gefahr, dass die Leuchte oder der Rückstrahler abfällt",
+    "no_4_12_e": "e) Funktioniert nicht",
+    "no_4_12_f": "f) Leuchte beschädigt",
+    "no_4_12_g": "g) Leuchte defekt",
+    "no_4_12_h": "h) Falsche Einstellung einer nicht vorgeschriebenen Leuchte",
+    "no_4_12_i": "i) Einstellung wegen defekter Leuchte/Lichtquelle nicht geprüft",
+    "no_4_12_j": "j) Leuchte entspricht nicht den Anforderungen",
+    "no_4_13_a": "a) Nicht ausreichend befestigt",
+    "no_4_13_b": "b) Undichtigkeit",
+    "no_4_13_b_2": "b) Undichtigkeit, erheblicher Austritt gefährlicher Stoffe",
+    "no_4_13_c": "c) Schalter defekt",
+    "no_4_13_d": "d) Sicherung defekt",
+    "no_4_13_e": "e) Sicherung fehlt",
+    "no_4_13_f": "f) Unzureichende Belüftung",
+    "no_4_14_1_a": "a) Sichtbare mechanische Beschädigung am Batteriegehäuse / an der Batterie",
+    "no_4_14_1_a_3": "a) Sichtbare mechanische Beschädigung am Batteriegehäuse / an der Batterie, gefährlicher Mangel",
+    "no_4_14_1_b": "b) Abdeckung zum Schutz von Batterie, Kabeln und Antriebssystem fehlt, ist nicht richtig befestigt oder beschädigt",
+    "no_4_14_1_c": "c) Undichtigkeit",
+    "no_4_14_1_c_3": "c) Undichtigkeit, gefährlicher Mangel",
+    "no_4_14_1_d": "d) Sichtbare Undichtigkeit am Batteriekühlsystem",
+    "no_4_14_1_d_3": "d) Sichtbare Undichtigkeit am Batteriekühlsystem, gefährlicher Mangel",
+    "no_4_14_1_e": "e) Sichtbare Schmelz-/Überhitzungsschäden durch Batterieheizung/Heizelement",
+    "no_4_14_1_e_3": "e) Sichtbare Schmelz-/Überhitzungsschäden durch Batterieheizung/Heizelement, gefährlicher Mangel",
+    "no_4_14_1_f": "f) Warneinrichtung zeigt Batteriefehler an",
+    "no_4_14_1_g": "g) Befestigung der Traktionsbatterie stark geschwächt",
+    "no_4_14_1_g_3": "g) Befestigung der Traktionsbatterie stark geschwächt, gefährlicher Mangel",
+    "no_4_14_2_a": "a) Beschädigung der Kabelisolierung",
+    "no_4_14_2_a_3": "a) Beschädigung der Kabelisolierung, gefährlicher Mangel",
+    "no_4_14_2_b": "b) Beschädigung des Kabelschutzrohrs",
+    "no_4_14_2_b_3": "b) Beschädigung des Kabelschutzrohrs, gefährlicher Mangel",
+    "no_4_14_2_c": "c) Kabel nicht sicher befestigt und geschützt",
+    "no_4_14_3_a": "a) Sichtbarer Riss, Quetschschaden oder Verformung am Kabel",
+    "no_4_14_3_b": "b) Sichtbarer Riss, Quetschschaden oder Verformung am Stecker",
+    "no_4_14_3_c": "c) Sichtbarer Riss, Quetschschaden oder Verformung an der In-Kabel-Kontrollbox",
+    "no_4_14_3_d": "d) Ladekabel nicht auffindbar, nicht mitgebracht",
+    "no_4_14_4_a": "a) Sichtbare Korrosion",
+    "no_4_14_4_a_3": "a) Sichtbare Korrosion, gefährlicher Mangel",
+    "no_4_14_4_b": "b) Gerissen",
+    "no_4_14_4_b_3": "b) Gerissen, gefährlicher Mangel",
+    "no_4_14_4_c": "c) Verformt",
+    "no_4_14_4_c_3": "c) Verformt, gefährlicher Mangel",
+    "no_4_14_4_d": "d) Anzeichen von Überhitzung, Ruß oder Verfärbung",
+    "no_4_14_4_d_3": "d) Anzeichen von Überhitzung, Ruß oder Verfärbung, gefährlicher Mangel",
+    "no_4_14_4_e": "e) Abdeckung des Ladeanschlusses fehlt, ist beschädigt oder lässt sich nicht schließen",
+    "no_5_1_1_a": "a) Achse gebrochen",
+    "no_5_1_1_b": "b) Achse verformt",
+    "no_5_1_1_c": "c) Unsicher am Fahrzeug befestigt",
+    "no_5_1_1_c_3": "c) Unsicher am Fahrzeug befestigt, die Stabilität ist stark beeinträchtigt, die Funktion ist stark beeinträchtigt oder es besteht übermäßige Bewegung zwischen Bauteilen, die fest verbunden sein sollten",
+    "no_5_1_1_d": "d) Unsachgemäße Reparatur oder Änderung",
+    "no_5_1_1_d_3": "d) Unsachgemäße Reparatur oder Änderung, die Stabilität ist stark beeinträchtigt oder der Freiraum zu anderen Bauteilen oder zur Fahrbahn ist unzureichend",
+    "no_5_1_1_e": "e) Achse fehlt, ist beschädigt oder defekt",
+    "no_5_1_2_a": "a) Achsschenkel gebrochen",
+    "no_5_1_2_b": "b) Übermäßiger Verschleiß an Achsschenkelbolzen und/oder Buchsen",
+    "no_5_1_2_b_3": "b) Übermäßiger Verschleiß an Achsschenkelbolzen und/oder Buchsen, es besteht Gefahr, dass sich das Bauteil löst, oder die Fahrstabilität ist stark beeinträchtigt",
+    "no_5_1_2_c": "c) Übermäßige Bewegung zwischen Achsschenkel und Achskörper",
+    "no_5_1_2_c_3": "c) Übermäßige Bewegung zwischen Achsschenkel und Achskörper, es besteht Gefahr, dass sich das Bauteil löst, oder die Fahrstabilität ist stark beeinträchtigt",
+    "no_5_1_2_d": "d) Achsschenkelbolzen/Sicherungsbolzen in der Achse lose",
+    "no_5_1_2_d_3": "d) Achsschenkelbolzen/Sicherungsbolzen in der Achse lose, es besteht Gefahr, dass sich das Bauteil löst, oder die Fahrstabilität ist stark beeinträchtigt",
+    "no_5_1_3_a": "a) Übermäßiges Spiel im Radlager",
+    "no_5_1_3_a_3": "a) Übermäßiges Spiel im Radlager, die Fahrstabilität ist stark beeinträchtigt oder es besteht Gefahr eines Versagens",
+    "no_5_1_3_b": "b) Radlager zu fest oder blockiert",
+    "no_5_1_3_b_3": "b) Radlager zu fest oder blockiert, es besteht Gefahr einer Überhitzung oder eines Versagens",
+    "no_5_1_3_c": "c) Starkes Geräusch vom Radlager",
+    "no_5_1_3_c_3": "c) Starkes Geräusch vom Radlager, es besteht Gefahr eines Versagens",
+    "no_5_2_1_a": "a) Radmutter oder Radbolzen fehlt",
+    "no_5_2_1_a_3": "a) Radmutter oder Radbolzen fehlt, mehr als 25 % der Radmuttern/Radbolzen an einem Rad sind betroffen",
+    "no_5_2_1_b": "b) Radmutter oder Radbolzen lose",
+    "no_5_2_1_b_3": "b) Radmutter oder Radbolzen lose, gefährlicher Mangel",
+    "no_5_2_1_c": "c) Falsche Radmutter oder falscher Radbolzen",
+    "no_5_2_1_c_3": "c) Falsche Radmutter oder falscher Radbolzen, gefährlicher Mangel",
+    "no_5_2_1_d": "d) Radmutter oder Radbolzen beschädigt",
+    "no_5_2_1_d_3": "d) Radmutter oder Radbolzen beschädigt, gefährlicher Mangel",
+    "no_5_2_1_e": "e) Radmutter oder Radbolzen falsch montiert",
+    "no_5_2_1_e_3": "e) Radmutter oder Radbolzen falsch montiert, gefährlicher Mangel",
+    "no_5_2_1_f": "f) Nabe verschlissen",
+    "no_5_2_1_f_3": "f) Nabe verschlissen, gefährlicher Mangel",
+    "no_5_2_1_g": "g) Nabe beschädigt",
+    "no_5_2_1_g_3": "g) Nabe beschädigt, gefährlicher Mangel",
+    "no_5_2_2_a": "a) Falsche Einpresstiefe",
+    "no_5_2_2_b": "b) Bruch oder Schweißfehler",
+    "no_5_2_2_c": "c) Reifensicherungsring falsch montiert",
+    "no_5_2_2_c_3": "c) Reifensicherungsring falsch montiert, es besteht Gefahr, dass sich der Sicherungsring löst",
+    "no_5_2_2_d": "d) Felge stark beschädigt oder verformt",
+    "no_5_2_2_d_3": "d) Felge stark beschädigt oder verformt, die sichere Verbindung mit Nabe oder Reifen ist beeinträchtigt",
+    "no_5_2_2_e": "e) Felge entspricht nicht den Anforderungen",
+    "no_5_2_2_f": "f) Unsachgemäße Reparatur oder Änderung",
+    "no_5_2_3_a": "a) Falsche Größe",
+    "no_5_2_3_a_3": "a) Falsche Größe, gefährliche Abweichung",
+    "no_5_2_3_b": "b) Falscher Tragfähigkeitsindex",
+    "no_5_2_3_b_3": "b) Falscher Tragfähigkeitsindex, gefährliche Abweichung",
+    "no_5_2_3_c": "c) Falsches Geschwindigkeitssymbol",
+    "no_5_2_3_c_3": "c) Falsches Geschwindigkeitssymbol, gefährliche Abweichung",
+    "no_5_2_3_d": "d) Reifen entspricht nicht den Anforderungen",
+    "no_5_2_3_d_3": "d) Reifen entspricht nicht den Anforderungen, gefährliche Abweichung",
+    "no_5_2_3_e": "e) Unterschiedliche Reifengrößen auf derselben Achse oder bei Zwillingsbereifung",
+    "no_5_2_3_f": "f) Unterschiedliche Reifenbauarten",
+    "no_5_2_3_g": "g) Schwere Beschädigung des Reifens",
+    "no_5_2_3_g_3": "g) Schwere Beschädigung des Reifens, gefährliche Beschädigung oder die Karkasse ist sichtbar oder beschädigt",
+    "no_5_2_3_h": "h) Unzureichende Profiltiefe",
+    "no_5_2_3_h_3": "h) Unzureichende Profiltiefe, gefährlich geringe Profiltiefe",
+    "no_5_2_3_i": "i) Reifen schleift an anderen Bauteilen",
+    "no_5_2_3_i_3": "i) Reifen schleift an anderen Bauteilen, der Reifen berührt feste Bauteile, sodass das sichere Fahren beeinträchtigt ist",
+    "no_5_2_3_j": "j) Runderneuerte Reifen entsprechen nicht den Anforderungen",
+    "no_5_2_3_j_3": "j) Runderneuerte Reifen entsprechen nicht den Anforderungen, der Schutz der Karkasse ist beeinträchtigt",
+    "no_5_2_3_k": "k) Reifendruckkontrollsystem zeigt einen Fehler an oder ist offensichtlich defekt",
+    "no_5_2_3_k_2": "k) Reifendruckkontrollsystem zeigt einen Fehler an oder ist offensichtlich defekt, das Reifendruckkontrollsystem ist offensichtlich defekt",
+    "no_5_2_3_l": "l) Reifen hat falsche Laufrichtung",
+    "no_5_2_3_m": "m) Offensichtlich zu niedriger Reifendruck",
+    "no_5_3_1_a": "a) Feder gebrochen",
+    "no_5_3_1_a_3": "a) Feder gebrochen, gefährlicher Mangel",
+    "no_5_3_1_b": "b) Feder unsicher an Fahrgestell oder Achse befestigt",
+    "no_5_3_1_b_3": "b) Feder unsicher an Fahrgestell oder Achse befestigt, gefährlicher Mangel",
+    "no_5_3_1_c": "c) Feder beschädigt",
+    "no_5_3_1_c_3": "c) Feder beschädigt, gefährlicher Mangel",
+    "no_5_3_1_d": "d) Federbauteil beschädigt oder gebrochen",
+    "no_5_3_1_d_3": "d) Federbauteil beschädigt oder gebrochen, gefährlicher Mangel",
+    "no_5_3_1_e": "e) Feder fehlt",
+    "no_5_3_1_e_3": "e) Feder fehlt, gefährlicher Mangel",
+    "no_5_3_1_f": "f) Unsachgemäße Reparatur oder Änderung",
+    "no_5_3_1_f_3": "f) Unsachgemäße Reparatur oder Änderung, der Freiraum zu anderen Bauteilen ist unzureichend oder die Federung funktioniert nicht",
+    "no_5_3_1_g": "g) Stabilisator stark beschädigt oder defekt",
+    "no_5_3_1_h": "h) Stabilisator, übermäßiges Spiel in der Buchse",
+    "no_5_3_1_i": "i) Stabilisator, übermäßiges Spiel im Kugelgelenk",
+    "no_5_3_2_a": "a) Stoßdämpfer unsicher an Fahrgestell oder Achse befestigt",
+    "no_5_3_2_a_2": "a) Stoßdämpfer unsicher an Fahrgestell oder Achse befestigt, der Stoßdämpfer ist lose",
+    "no_5_3_2_b": "b) Stoßdämpfer defekt",
+    "no_5_3_2_c": "c) Federbeinstützlager defekt",
+    "no_5_3_2_c_3": "c) Federbeinstützlager defekt, gefährlicher Mangel",
+    "no_5_3_2_d": "d) Übermäßig korrodiert",
+    "no_5_3_2_d_3": "d) Übermäßig korrodiert, gefährlicher Mangel",
+    "no_5_3_2_e": "e) Federbein unsicher an Fahrgestell oder Achse befestigt",
+    "no_5_3_2_e_3": "e) Federbein unsicher an Fahrgestell oder Achse befestigt, gefährlicher Mangel",
+    "no_5_3_2_f": "f) Federbein defekt",
+    "no_5_3_2_f_3": "f) Federbein defekt, gefährlicher Mangel",
+    "no_5_3_3_a": "a) Unsicher an Fahrgestell oder Achse befestigt",
+    "no_5_3_3_a_3": "a) Unsicher an Fahrgestell oder Achse befestigt, gefährlicher Mangel",
+    "no_5_3_3_b": "b) Beschädigt",
+    "no_5_3_3_b_3": "b) Beschädigt, gefährlicher Mangel",
+    "no_5_3_3_c": "c) Defekt",
+    "no_5_3_3_c_3": "c) Defekt, gefährlicher Mangel",
+    "no_5_3_3_d": "d) Übermäßig korrodiert",
+    "no_5_3_3_d_3": "d) Übermäßig korrodiert, gefährlicher Mangel",
+    "no_5_3_3_e": "e) Unsachgemäße Reparatur oder Änderung",
+    "no_5_3_3_e_3": "e) Unsachgemäße Reparatur oder Änderung, der Freiraum zu anderen Bauteilen ist unzureichend oder der Mangel bzw. die Änderung ist gefährlich",
+    "no_5_3_4_a": "a) Buchse defekt",
+    "no_5_3_4_a_3": "a) Buchse defekt, Gefahr, dass das Bauteil versagt oder sich löst",
+    "no_5_3_4_b": "b) Übermäßiger Verschleiß im Traggelenk der Radaufhängung",
+    "no_5_3_4_b_3": "b) Übermäßiger Verschleiß im Traggelenk der Radaufhängung, Gefahr, dass das Bauteil versagt oder sich löst",
+    "no_5_3_4_c": "c) Staubmanschette stark geschwächt",
+    "no_5_3_4_d": "d) Staubmanschette fehlt",
+    "no_5_3_4_e": "e) Staubmanschette gerissen",
+    "no_5_3_5_a": "a) Anlage funktionsunfähig",
+    "no_5_3_5_b": "b) Bauteil beschädigt, verändert oder übermäßig verschlissen",
+    "no_5_3_5_b_3": "b) Bauteil beschädigt, verändert oder übermäßig verschlissen, die Funktion der Anlage ist stark beeinträchtigt",
+    "no_5_3_5_c": "c) Hörbare Undichtigkeit in der Anlage",
+    "no_6_1_1_a": "a) Längsträger gebrochen",
+    "no_6_1_1_a_3": "a) Längsträger gebrochen, gefährlicher Mangel",
+    "no_6_1_1_b": "b) Längsträger stark verformt",
+    "no_6_1_1_b_3": "b) Längsträger stark verformt, gefährlicher Mangel",
+    "no_6_1_1_c": "c) Querträger gebrochen",
+    "no_6_1_1_c_3": "c) Querträger gebrochen, gefährlicher Mangel",
+    "no_6_1_1_d": "d) Querträger stark verformt",
+    "no_6_1_1_d_3": "d) Querträger stark verformt, gefährlicher Mangel",
+    "no_6_1_1_e": "e) Verstärkungsblech unsicher befestigt",
+    "no_6_1_1_e_3": "e) Verstärkungsblech unsicher befestigt, gefährlicher Mangel",
+    "no_6_1_1_f": "f) Befestigungseinrichtung nicht ausreichend befestigt",
+    "no_6_1_1_f_3": "f) Befestigungseinrichtung nicht ausreichend befestigt, gefährlicher Mangel",
+    "no_6_1_1_g": "g) Übermäßige Korrosion am Längsträger",
+    "no_6_1_1_g_3": "g) Übermäßige Korrosion am Längsträger, gefährlicher Mangel",
+    "no_6_1_1_h": "h) Übermäßige Korrosion am Querträger",
+    "no_6_1_1_h_3": "h) Übermäßige Korrosion am Querträger, gefährlicher Mangel",
+    "no_6_1_1_i": "i) Übermäßige Korrosion an einem tragenden Teil",
+    "no_6_1_1_i_3": "i) Übermäßige Korrosion an einem tragenden Teil, gefährlicher Mangel",
+    "no_6_1_1_j": "j) Übermäßige Korrosion am Schweller/Kanal",
+    "no_6_1_1_j_3": "j) Übermäßige Korrosion am Schweller/Kanal, gefährlicher Mangel",
+    "no_6_1_1_k": "k) Unsachgemäße Reparatur oder Änderung",
+    "no_6_1_1_k_3": "k) Unsachgemäße Reparatur oder Änderung, gefährlicher Mangel",
+    "no_6_1_2_a": "a) Auspuffanlage unsicher befestigt",
+    "no_6_1_2_a_3": "a) Auspuffanlage unsicher befestigt, es besteht große Gefahr, dass ein Bauteil abfällt",
+    "no_6_1_2_b": "b) Auspuffrohr übermäßig undicht",
+    "no_6_1_2_c": "c) Schalldämpfer übermäßig undicht",
+    "no_6_1_2_d": "d) Mängel an der Auspuffanlage",
+    "no_6_1_2_e": "e) Falsch eingebaut",
+    "no_6_1_2_f": "f) Bauteil fehlt",
+    "no_6_1_2_g": "g) Abgase dringen in Fahrerhaus oder Fahrgastraum ein",
+    "no_6_1_2_g_3": "g) Abgase dringen in Fahrerhaus oder Fahrgastraum ein, es besteht ein Gesundheitsrisiko für Fahrer oder Insassen",
+    "no_6_1_3_a": "a) Kraftstoffbehälter unsicher befestigt",
+    "no_6_1_3_a_3": "a) Kraftstoffbehälter unsicher befestigt, es besteht Brandgefahr",
+    "no_6_1_3_b": "b) Kraftstoffbehälter übermäßig beschädigt",
+    "no_6_1_3_c": "c) Kraftstoffbehälter übermäßig korrodiert",
+    "no_6_1_3_d": "d) Leitung unsicher befestigt",
+    "no_6_1_3_d_3": "d) Leitung unsicher befestigt, es besteht Brandgefahr",
+    "no_6_1_3_e": "e) Leitung beschädigt",
+    "no_6_1_3_f": "f) Leitung übermäßig korrodiert",
+    "no_6_1_3_f_2": "f) Leitung übermäßig korrodiert, schwerer Mangel",
+    "no_6_1_3_g": "g) Schlauch unsicher befestigt",
+    "no_6_1_3_g_3": "g) Schlauch unsicher befestigt, es besteht Brandgefahr",
+    "no_6_1_3_h": "h) Schlauch beschädigt",
+    "no_6_1_3_i": "i) Schlauch übermäßig durchgescheuert/brüchig",
+    "no_6_1_3_i_2": "i) Schlauch übermäßig durchgescheuert/brüchig, schwerer Mangel",
+    "no_6_1_3_j": "j) Kraftstoffundichtigkeit",
+    "no_6_1_3_j_3": "j) Kraftstoffundichtigkeit, es besteht Brandgefahr oder die Undichtigkeit ist erheblich",
+    "no_6_1_3_k": "k) Tankdeckel fehlt oder ist defekt",
+    "no_6_1_3_l": "l) Kraftstoffabsperrventil funktioniert nicht richtig",
+    "no_6_1_3_m": "m) Brandgefahr durch unzureichend abgeschirmten Kraftstoffbehälter",
+    "no_6_1_3_n": "n) Brandgefahr durch unzureichend abgeschirmte oder defekte Auspuffanlage",
+    "no_6_1_3_o": "o) Brandgefahr durch den Zustand des Motorraums",
+    "no_6_1_3_p": "p) LPG/CNG/LNG- oder Wasserstoffanlage entspricht nicht den Anforderungen",
+    "no_6_1_3_p_3": "p) LPG/CNG/LNG- oder Wasserstoffanlage entspricht nicht den Anforderungen, Brand- oder Explosionsgefahr",
+    "no_6_1_3_q": "q) Bauteil der LPG/CNG/LNG- oder Wasserstoffanlage defekt",
+    "no_6_1_3_q_3": "q) Bauteil der LPG/CNG/LNG- oder Wasserstoffanlage defekt, Brand- oder Explosionsgefahr",
+    "no_6_1_4_a": "a) Stoßfänger lose",
+    "no_6_1_4_a_3": "a) Stoßfänger lose, gefährlicher Zustand oder Gefahr, dass das Bauteil abfällt",
+    "no_6_1_4_b": "b) Stoßfänger beschädigt",
+    "no_6_1_4_b_3": "b) Stoßfänger beschädigt, gefährlicher Zustand oder Gefahr, dass das Bauteil abfällt",
+    "no_6_1_4_c": "c) Stoßfänger geschwächt",
+    "no_6_1_4_c_3": "c) Stoßfänger geschwächt, gefährlicher Zustand oder Gefahr, dass das Bauteil abfällt",
+    "no_6_1_4_d": "d) Stoßfänger fehlt",
+    "no_6_1_4_d_3": "d) Stoßfänger fehlt, gefährlicher Zustand oder Gefahr, dass das Bauteil abfällt",
+    "no_6_1_4_i": "i) Hinterer Unterfahrschutz lose",
+    "no_6_1_4_i_3": "i) Hinterer Unterfahrschutz lose, gefährlicher Zustand oder Gefahr, dass das Bauteil abfällt",
+    "no_6_1_4_j": "j) Hinterer Unterfahrschutz beschädigt",
+    "no_6_1_4_j_3": "j) Hinterer Unterfahrschutz beschädigt, gefährlicher Zustand oder Gefahr, dass das Bauteil abfällt",
+    "no_6_1_4_k": "k) Hinterer Unterfahrschutz geschwächt",
+    "no_6_1_4_k_3": "k) Hinterer Unterfahrschutz geschwächt, gefährlicher Zustand oder Gefahr, dass das Bauteil abfällt",
+    "no_6_1_4_l": "l) Hinterer Unterfahrschutz fehlt",
+    "no_6_1_4_l_3": "l) Hinterer Unterfahrschutz fehlt, gefährlicher Zustand oder Gefahr, dass das Bauteil abfällt",
+    "no_6_1_4_m": "m) Stoßfänger entspricht offensichtlich nicht den Anforderungen",
+    "no_6_1_4_o": "o) Hinterer Unterfahrschutz entspricht offensichtlich nicht den Anforderungen",
+    "no_6_1_5_a": "a) Reserveradhalterung nicht in zufriedenstellendem Zustand",
+    "no_6_1_5_b": "b) Reserveradhalterung gebrochen",
+    "no_6_1_5_c": "c) Reserveradhalterung unsicher befestigt",
+    "no_6_1_5_d": "d) Reserverad unsicher an der Halterung befestigt",
+    "no_6_1_5_d_3": "d) Reserverad unsicher an der Halterung befestigt, es besteht große Gefahr, dass das Reserverad abfällt",
+    "no_6_1_6_a": "a) Anhängerkupplung beschädigt, defekt oder gerissen",
+    "no_6_1_6_a_3": "a) Anhängerkupplung beschädigt, defekt oder gerissen, gefährlicher Mangel, wenn das Fahrzeug zusammen mit seinem Anhänger vorgeführt wird",
+    "no_6_1_6_b": "b) Anhängerkupplung übermäßig korrodiert",
+    "no_6_1_6_b_3": "b) Anhängerkupplung übermäßig korrodiert, gefährlicher Mangel, wenn das Fahrzeug zusammen mit seinem Anhänger vorgeführt wird",
+    "no_6_1_6_c": "c) Anhängerkupplung unsicher befestigt",
+    "no_6_1_6_c_3": "c) Anhängerkupplung unsicher befestigt, gefährlicher Mangel, wenn das Fahrzeug zusammen mit seinem Anhänger vorgeführt wird",
+    "no_6_1_6_q": "q) Bauteil beschädigt, defekt, gerissen oder übermäßig korrodiert",
+    "no_6_1_6_q_3": "q) Bauteil beschädigt, defekt, gerissen oder übermäßig korrodiert, gefährlicher Mangel, wenn das Fahrzeug zusammen mit seinem Anhänger vorgeführt wird",
+    "no_6_1_6_r": "r) Übermäßiger Verschleiß eines Bauteils",
+    "no_6_1_6_r_3": "r) Übermäßiger Verschleiß eines Bauteils, gefährlicher Mangel, wenn das Fahrzeug zusammen mit seinem Anhänger vorgeführt wird",
+    "no_6_1_6_s": "s) Unsicher befestigt",
+    "no_6_1_6_s_3": "s) Unsicher befestigt, gefährlicher Mangel, wenn das Fahrzeug zusammen mit seinem Anhänger vorgeführt wird",
+    "no_6_1_6_t": "t) Sicherungseinrichtung fehlt oder funktioniert nicht richtig",
+    "no_6_1_6_u": "u) Anzeige funktioniert nicht oder zeigt einen Fehler an",
+    "no_6_1_6_v": "v) Verdeckt Leuchten",
+    "no_6_1_6_w": "w) Verdeckt das Kennzeichen",
+    "no_6_1_6_x": "x) Unsachgemäße Reparatur oder Änderung",
+    "no_6_1_6_x_3": "x) Unsachgemäße Reparatur oder Änderung, gefährlicher Mangel, wenn das Fahrzeug zusammen mit seinem Anhänger vorgeführt wird",
+    "no_6_1_6_y": "y) Kupplung zu schwach",
+    "no_6_1_7_a": "a) Befestigung/Schrauben fehlen",
+    "no_6_1_7_a_3": "a) Befestigung/Schrauben fehlen, so viele Befestigungsschrauben/-punkte fehlen, dass ein ernstes Risiko für die Verkehrssicherheit besteht",
+    "no_6_1_7_b": "b) Befestigung/Schrauben lose",
+    "no_6_1_7_b_3": "b) Befestigung/Schrauben lose, so viele Befestigungsschrauben/-punkte sind lose, dass ein ernstes Risiko für die Verkehrssicherheit besteht",
+    "no_6_1_7_c": "c) Übermäßiger Verschleiß der Antriebswellenlager",
+    "no_6_1_7_c_3": "c) Übermäßiger Verschleiß der Antriebswellenlager, es besteht große Gefahr, dass sich das Bauteil löst oder reißt",
+    "no_6_1_7_d": "d) Übermäßiger Verschleiß der Antriebswelle/Kreuzgelenke",
+    "no_6_1_7_d_3": "d) Übermäßiger Verschleiß der Antriebswelle/Kreuzgelenke, es besteht große Gefahr, dass sich das Bauteil löst oder reißt",
+    "no_6_1_7_e": "e) Übermäßiger Verschleiß oder Alterung der elastischen Kupplung (Hardyscheibe)",
+    "no_6_1_7_e_3": "e) Übermäßiger Verschleiß oder Alterung der elastischen Kupplung (Hardyscheibe), es besteht große Gefahr, dass sich das Bauteil löst oder reißt",
+    "no_6_1_7_f": "f) Welle beschädigt",
+    "no_6_1_7_g": "g) Welle verbogen",
+    "no_6_1_7_h": "h) Lagergehäuse stark beschädigt",
+    "no_6_1_7_h_3": "h) Lagergehäuse stark beschädigt, es besteht große Gefahr, dass sich das Bauteil löst oder reißt",
+    "no_6_1_7_i": "i) Lagergehäuse unsicher befestigt",
+    "no_6_1_7_i_3": "i) Lagergehäuse unsicher befestigt, es besteht große Gefahr, dass sich das Bauteil löst oder reißt",
+    "no_6_1_7_j": "j) Unzulässige Änderung des Antriebsstrangs",
+    "no_6_1_7_k": "k) Staubmanschette stark geschwächt",
+    "no_6_1_7_l": "l) Staubmanschette fehlt",
+    "no_6_1_7_m": "m) Staubmanschette defekt",
+    "no_6_1_8_a": "a) Motorlager defekt",
+    "no_6_1_8_a_3": "a) Motorlager defekt, das Lager ist lose oder gebrochen und stellt ein ernstes Risiko für die Verkehrssicherheit dar",
+    "no_6_1_9_a": "a) Unzulässige Änderung des Steuergeräts",
+    "no_6_1_9_b": "b) Unzulässige Änderung des Motors",
+    "no_6_1_9_b_3": "b) Unzulässige Änderung des Motors, die Änderung stellt ein ernstes Risiko für die Verkehrssicherheit dar",
+    "no_6_2_1_a": "a) Loses/beschädigtes Bauteil, das Verletzungen verursachen kann",
+    "no_6_2_1_a_3": "a) Loses/beschädigtes Bauteil, das Verletzungen verursachen kann, es besteht Gefahr, dass das Bauteil abfällt, und es stellt ein ernstes Risiko für die Verkehrssicherheit dar",
+    "no_6_2_1_b": "b) Karosseriesäule unsicher befestigt",
+    "no_6_2_1_b_3": "b) Karosseriesäule unsicher befestigt, die Festigkeit ist stark beeinträchtigt",
+    "no_6_2_1_c": "c) Motor- oder Abgase können eindringen",
+    "no_6_2_1_c_3": "c) Motor- oder Abgase können eindringen, es besteht ein Gesundheitsrisiko für Fahrer oder Insassen",
+    "no_6_2_1_d": "d) Unsachgemäße Reparatur oder Änderung",
+    "no_6_2_1_d_3": "d) Unsachgemäße Reparatur oder Änderung, der Freiraum für rotierende oder bewegliche Teile oder zur Fahrbahn ist unzureichend und es stellt ein ernstes Risiko für die Verkehrssicherheit dar",
+    "no_6_2_1_e": "e) Beschädigung tragender Teile",
+    "no_6_2_1_e_3": "e) Beschädigung tragender Teile, gefährlicher Zustand",
+    "no_6_2_1_f": "f) Mängel an tragenden Teilen",
+    "no_6_2_1_f_3": "f) Mängel an tragenden Teilen, gefährlicher Zustand",
+    "no_6_2_1_g": "g) Übermäßige Korrosion tragender Teile",
+    "no_6_2_1_g_3": "g) Übermäßige Korrosion tragender Teile, gefährlicher Zustand",
+    "no_6_2_2_a": "a) Unsicher befestigt",
+    "no_6_2_2_a_3": "a) Unsicher befestigt, der Mangel ist ein ernstes Risiko für die Verkehrssicherheit",
+    "no_6_2_2_b": "b) Offensichtlich nicht richtig auf dem Fahrgestell positioniert",
+    "no_6_2_2_c": "c) Unsicher an Fahrgestell oder Querträgern befestigt",
+    "no_6_2_2_c_3": "c) Unsicher an Fahrgestell oder Querträgern befestigt, so viele unsichere, lose oder fehlende Befestigungen, dass ein ernstes Risiko für die Verkehrssicherheit besteht",
+    "no_6_2_2_d": "d) Übermäßige Korrosion an Befestigungspunkten bei selbsttragenden Karosserien",
+    "no_6_2_2_d_3": "d) Übermäßige Korrosion an Befestigungspunkten bei selbsttragenden Karosserien, der Mangel ist ein ernstes Risiko für die Verkehrssicherheit",
+    "no_6_2_2_e": "e) Unsachgemäße Reparatur oder Änderung",
+    "no_6_2_2_e_3": "e) Unsachgemäße Reparatur oder Änderung, gefährlicher Mangel",
+    "no_6_2_3_a": "a) Tür öffnet nicht richtig",
+    "no_6_2_3_b": "b) Tür schließt nicht richtig",
+    "no_6_2_3_c": "c) Tür kann sich unbeabsichtigt öffnen",
+    "no_6_2_3_c_3": "c) Tür kann sich unbeabsichtigt öffnen, Flügeltür (Schiebetür bleibt 2)",
+    "no_6_2_3_d": "d) Tür bleibt nicht geschlossen",
+    "no_6_2_3_d_3": "d) Tür bleibt nicht geschlossen, Flügeltür (Schiebetür bleibt 2)",
+    "no_6_2_3_e": "e) Tür fehlt, lose oder geschwächt",
+    "no_6_2_3_f": "f) Scharnier fehlt, lose oder geschwächt",
+    "no_6_2_3_g": "g) Schloss fehlt, lose oder geschwächt",
+    "no_6_2_3_h": "h) Säule fehlt, lose oder geschwächt",
+    "no_6_2_4_a": "a) Boden unsicher befestigt",
+    "no_6_2_4_a_3": "a) Boden unsicher befestigt, gefährlicher Mangel",
+    "no_6_2_4_b": "b) Boden stark geschwächt",
+    "no_6_2_4_b_3": "b) Boden stark geschwächt, gefährlicher Mangel",
+    "no_6_2_4_c": "c) Boden übermäßig korrodiert",
+    "no_6_2_4_c_3": "c) Boden übermäßig korrodiert, gefährlicher Mangel",
+    "no_6_2_5_a": "a) Sitz lose",
+    "no_6_2_5_a_3": "a) Sitz lose, gefährlicher Mangel",
+    "no_6_2_5_b": "b) Sitzgestell defekt",
+    "no_6_2_5_b_3": "b) Sitzgestell defekt, gefährlicher Mangel",
+    "no_6_2_5_c": "c) Verstellmechanismus funktioniert nicht richtig",
+    "no_6_2_5_c_3": "c) Verstellmechanismus funktioniert nicht richtig, gefährlicher Mangel",
+    "no_6_2_5_d": "d) Unsachgemäße Reparatur oder Änderung",
+    "no_6_2_5_d_3": "d) Unsachgemäße Reparatur oder Änderung, gefährlicher Mangel",
+    "no_6_2_6_a": "a) Sitz defekt",
+    "no_6_2_6_b": "b) Sitz unsicher befestigt",
+    "no_6_2_6_c": "c) Sitz nicht gemäß den Anforderungen eingebaut",
+    "no_6_2_6_d": "d) Unsachgemäße Reparatur oder Änderung",
+    "no_6_2_7_a": "a) Bedienelement funktioniert nicht richtig",
+    "no_6_2_7_a_3": "a) Bedienelement funktioniert nicht richtig, der Mangel ist ein ernstes Risiko für die Verkehrssicherheit",
+    "no_6_2_8_a": "a) Trittstufe oder Trittring unsicher befestigt",
+    "no_6_2_8_a_2": "a) Trittstufe oder Trittring unsicher befestigt, starke Schwächung",
+    "no_6_2_8_b": "b) Trittstufe oder Ring in einem Zustand, der Verletzungen verursachen kann",
+    "no_6_2_9_a": "a) Befestigung von Ausstattung oder Ausrüstung defekt",
+    "no_6_2_9_b": "b) Ausstattung oder Ausrüstung entspricht nicht den Anforderungen",
+    "no_6_2_9_b_2": "b) Ausstattung oder Ausrüstung entspricht nicht den Anforderungen, der Mangel kann Verletzungen verursachen oder beeinträchtigt den sicheren Betrieb",
+    "no_6_2_9_c": "c) Vorgeschriebene Ausstattung oder Ausrüstung fehlt",
+    "no_6_2_9_d": "d) Unmittelbare Gefahr des Versagens oder Bruchs",
+    "no_6_2_9_d_3": "d) Unmittelbare Gefahr des Versagens oder Bruchs, gefährlicher Mangel",
+    "no_6_2_9_e": "e) Schlauch beschädigt",
+    "no_6_2_9_f": "f) Schlauch falsch verlegt",
+    "no_6_2_9_g": "g) Schlauch zu kurz",
+    "no_6_2_9_h": "h) Undichtigkeit an Schlauch oder Verbindung",
+    "no_6_2_9_i": "i) Schlauch wölbt sich unter Druck aus",
+    "no_6_2_9_j": "j) Schlauch porös/rissig",
+    "no_6_2_9_k": "k) Unsachgemäße Reparatur oder Änderung",
+    "no_6_2_9_l": "l) Übermäßiger Verschleiß",
+    "no_6_2_9_m": "m) Mängel",
+    "no_6_2_10_a": "a) Beschädigt",
+    "no_6_2_10_b": "b) Lose",
+    "no_6_2_10_c": "c) Übermäßig korrodiert/durchgerostet",
+    "no_6_2_10_d": "d) Unzureichender Freiraum zum Rad",
+    "no_6_2_10_e": "e) Fehlt",
+    "no_6_2_10_f": "f) Entspricht nicht den Anforderungen",
+    "no_7_1_1_a": "a) Verankerung geschwächt",
+    "no_7_1_1_a_3": "a) Verankerung geschwächt, der Mangel ist verkehrsgefährdend",
+    "no_7_1_1_b": "b) Verankerung lose",
+    "no_7_1_2_a": "a) Vorgeschriebener Sicherheitsgurt fehlt",
+    "no_7_1_2_b": "b) Sicherheitsgurt beschädigt",
+    "no_7_1_2_c": "c) Sicherheitsgurt entspricht nicht den Anforderungen",
+    "no_7_1_2_d": "d) Gurtschloss beschädigt",
+    "no_7_1_2_e": "e) Gurtschloss funktioniert nicht richtig",
+    "no_7_1_2_f": "f) Gurtaufroller beschädigt",
+    "no_7_1_2_g": "g) Gurtaufroller funktioniert nicht richtig",
+    "no_7_1_3_a": "a) Gurtkraftbegrenzer fehlt",
+    "no_7_1_3_b": "b) Gurtkraftbegrenzer nicht für das Fahrzeug geeignet",
+    "no_7_1_4_a": "a) Gurtstraffer fehlt",
+    "no_7_1_4_b": "b) Gurtstraffer nicht für das Fahrzeug geeignet",
+    "no_7_1_5_a": "a) Airbag fehlt",
+    "no_7_1_5_b": "b) Airbag nicht für das Fahrzeug geeignet",
+    "no_7_1_5_c": "c) Airbag nicht funktionsfähig",
+    "no_7_1_5_d": "d) Unsachgemäße Reparatur oder Änderung",
+    "no_7_1_6_a": "a) SRS-Fehlfunktionsanzeige zeigt einen Fehler oder Ausfall des Systems an",
+    "no_7_2_a": "a) Fehlt",
+    "no_7_2_b": "b) Entspricht nicht den Anforderungen",
+    "no_7_2_c": "c) Druck fehlerhaft",
+    "no_7_2_d": "d) Manometer fehlerhaft",
+    "no_7_2_e": "e) Feuerlöscher: Prüfdatum älter als 12 Monate",
+    "no_7_3_a": "a) Einrichtung verhindert nicht, dass das Fahrzeug gefahren wird",
+    "no_7_3_b": "b) Defekt",
+    "no_7_3_b_3": "b) Defekt, das Lenkradschloss verriegelt/rastet unbeabsichtigt ein",
+    "no_7_4_a": "a) Fehlt",
+    "no_7_4_b": "b) Defekt",
+    "no_7_4_c": "c) Entspricht nicht den Anforderungen",
+    "no_7_5_a": "a) Fehlt",
+    "no_7_5_b": "b) Offensichtlich unbrauchbar",
+    "no_7_7_a": "a) Unzureichende Wirkung",
+    "no_7_7_b": "b) Funktioniert nicht",
+    "no_7_7_c": "c) Bedienelement nicht ausreichend befestigt",
+    "no_7_7_d": "d) Entspricht nicht den Anforderungen",
+    "no_7_8_a": "a) Entspricht nicht den Anforderungen",
+    "no_7_8_a_2": "a) Entspricht nicht den Anforderungen, der Geschwindigkeitsmesser fehlt",
+    "no_7_8_b": "b) Zu große Anzeigeabweichung",
+    "no_7_8_c": "c) Funktioniert nicht",
+    "no_7_8_d": "d) Unzureichende Beleuchtung",
+    "no_7_8_d_2": "d) Unzureichende Beleuchtung, die Beleuchtung funktioniert nicht",
+    "no_7_12_a": "a) Raddrehzahlsensor fehlt",
+    "no_7_12_b": "b) Raddrehzahlsensor beschädigt",
+    "no_7_12_c": "c) Verkabelung beschädigt",
+    "no_7_12_d": "d) Bauteil fehlt",
+    "no_7_12_e": "e) Bauteil beschädigt",
+    "no_7_12_f": "f) Schalter defekt",
+    "no_7_12_g": "g) Schalter funktioniert nicht richtig",
+    "no_7_12_h": "h) Fehlfunktionsanzeige zeigt einen Fehler oder Ausfall des Systems an",
+    "no_7_14_1_a": "a) System oder Systembauteil fehlt",
+    "no_7_14_2_a": "a) System oder Systembauteil defekt",
+    "no_7_14_2_b": "b) eCall-Fehlfunktionsanzeige zeigt einen Fehler oder Ausfall des Systems an",
+    "no_7_14_3_a": "a) Audiokomponenten funktionieren offensichtlich nicht",
+    "no_8_1_1_a": "a) Geräuschpegel überschreitet den nach den Anforderungen zulässigen Wert",
+    "no_8_1_1_b": "b) Offensichtlich so verändert, dass sich der Geräuschpegel verschlechtert",
+    "no_8_2_1_1_a": "a) Vom Hersteller eingebaute Ausrüstung fehlt",
+    "no_8_2_1_1_b": "b) Vom Hersteller eingebaute Ausrüstung verändert",
+    "no_8_2_1_1_c": "c) Vom Hersteller eingebaute Ausrüstung offensichtlich defekt",
+    "no_8_2_1_1_d": "d) Undichtigkeit, die die Ergebnisse der Abgasmessung beeinflussen kann",
+    "no_8_2_1_2_a": "a) CO-Emissionen überschreiten den vom Hersteller angegebenen Wert",
+    "no_8_2_1_2_b": "b) CO-Emissionen überschreiten die allgemeinen Grenzwerte",
+    "no_8_2_1_2_c": "c) Lambda außerhalb des Bereichs 1 ± 0,03 oder nicht gemäß Herstellerangabe",
+    "no_8_2_1_2_d": "d) Abgasmessung nicht durchgeführt. Bei der Nachprüfung muss eine Abgasmessung durchgeführt und bestanden werden",
+    "no_8_2_2_1_a": "a) Vom Hersteller eingebaute Ausrüstung fehlt",
+    "no_8_2_2_1_b": "b) Vom Hersteller eingebaute Ausrüstung verändert",
+    "no_8_2_2_1_c": "c) Vom Hersteller eingebaute Ausrüstung offensichtlich defekt",
+    "no_8_2_2_1_d": "d) Undichtigkeit, die die Ergebnisse der Trübungsmessung beeinflussen kann",
+    "no_8_2_2_2_a": "a) Abgastrübung überschreitet den vom Hersteller angegebenen Wert",
+    "no_8_2_2_2_b": "b) Abgastrübung überschreitet die allgemeinen Grenzwerte",
+    "no_8_2_2_2_c": "c) Messung nicht durchgeführt. Bei der Nachprüfung muss eine Trübungsmessung durchgeführt und bestanden werden",
+    "no_8_2_3_1_a": "a) Deutlich sichtbarer, aber nicht ständiger Rauch",
+    "no_8_2_3_1_b": "b) Deutlich sichtbarer und ständiger Rauch",
+    "no_8_2_3_1_c": "c) Verkehrsgefährdende Rauchmenge",
+    "no_8_2_3_1_d": "d) Deutlich unrunder Motorlauf mit Zündaussetzern",
+    "no_8_2_3_1_e": "e) OBD-Fehlfunktionsanzeige zeigt einen Fehler oder Ausfall des Systems an",
+    "no_8_3_1_a": "a) Undichtigkeit Motoröl",
+    "no_8_3_1_a_3": "a) Undichtigkeit Motoröl, Flüssigkeit läuft ständig aus oder es besteht Brandgefahr",
+    "no_8_3_1_b": "b) Undichtigkeit Getriebe-/Antriebsöl",
+    "no_8_3_1_b_3": "b) Undichtigkeit Getriebe-/Antriebsöl, Flüssigkeit läuft ständig aus oder es besteht Brandgefahr",
+    "no_8_3_1_c": "c) Undichtigkeit Kühlmittel",
+    "no_8_3_1_c_3": "c) Undichtigkeit Kühlmittel, Flüssigkeit läuft ständig aus oder es besteht Brandgefahr",
+    "no_8_3_1_d": "d) Undichtigkeit Hydrauliköl",
+    "no_8_3_1_d_3": "d) Undichtigkeit Hydrauliköl, Flüssigkeit läuft ständig aus oder es besteht Brandgefahr",
+    "no_8_3_1_e": "e) Flüssigkeitsverlust",
+    "no_8_3_1_e_3": "e) Flüssigkeitsverlust, Flüssigkeit läuft ständig aus oder es besteht Brandgefahr",
+    "no_10_1_a": "a) Vorführung bei einer trafikkstasjon"
+  },
+  "defectKeywords": {
+    "brakes": "brems, belag, beläge, trommel, klotz, klötze",
+    "tyres": "reifen, profil, felge, räder, luftdruck",
+    "fluids": "motoröl, getriebeöl, ölstand, kühlmittel, kühlwasser, flüssigkeit, undicht, leck, fett, schmier",
+    "lights": "leuchte, lampe, scheinwerfer, blinker, fahrtrichtungsanzeiger, fernlicht, abblendlicht, standlicht, tagfahrlicht, rücklicht, nebel, beleuchtung, glühbirne",
+    "beltsHoses": "riemen, schlauch, schläuche, leitung, rohr",
+    "battery": "batterie, akku, ladezustand, laden, ladung, lichtmaschine, generator, bordnetz",
+    "suspension": "fahrwerk, federung, feder, aufhängung, stoßdämpfer, dämpfer, lager, gelenk, stabilisator",
+    "exhaust": "auspuff, abgas, emission, trübung, schalldämpfer, dpf, partikelfilter, katalysator",
+    "glass": "wischer, wasch, windschutz, frontscheibe, heckscheibe, seitenscheibe, verglasung, glas, spiegel",
+    "filters": "filter, pollenfilter, innenraumfilter"
+  }
+}

+ 147 - 46
messages/de/inspections.json

@@ -5,28 +5,32 @@
       "attention": "Geringer Mangel",
       "fail": "Erheblicher Mangel",
       "dangerous": "Gefährlicher Mangel",
-      "not_inspected": "Nicht geprüft"
+      "not_inspected": "Nicht geprüft",
+      "not_applicable": "Nicht zutreffend"
     },
     "basic": {
       "pass": "Bestanden",
       "attention": "Achtung",
       "fail": "Durchgefallen",
       "dangerous": "Gefährlich",
-      "not_inspected": "Nicht geprüft"
+      "not_inspected": "Nicht geprüft",
+      "not_applicable": "Nicht zutreffend"
     },
     "short": {
       "pass": "OK",
       "attention": "Gering",
       "fail": "Erheblich",
       "dangerous": "Gefährlich",
-      "not_inspected": "–"
+      "not_inspected": "–",
+      "not_applicable": "n. z."
     },
     "hint": {
       "pass": "Erfüllt die Anforderungen. Nichts zu melden.",
       "attention": "Ohne wesentliche Auswirkung auf die Sicherheit. Fahrzeug besteht; bei Gelegenheit beheben.",
       "fail": "Kann die Sicherheit beeinträchtigen oder die Umwelt belasten. Fahrzeug besteht nicht; beheben und nachprüfen lassen.",
       "dangerous": "Unmittelbare Gefahr für die Verkehrssicherheit. Das Fahrzeug darf nicht gefahren werden.",
-      "not_inspected": "Dieser Prüfpunkt wurde nicht durchgeführt."
+      "not_inspected": "Dieser Prüfpunkt wurde nicht durchgeführt.",
+      "not_applicable": "Das Fahrzeug hat kein solches Teil, es gab nichts zu prüfen."
     }
   },
   "results": {
@@ -58,13 +62,12 @@
     "allSaved": "Alle Änderungen sind gespeichert",
     "moreActions": "Weitere Aktionen",
     "downloadCertificate": "Prüfbericht herunterladen",
-    "manageTemplates": "Vorlagen verwalten",
+    "manageTemplates": "Checklisten verwalten",
     "delete": "Löschen",
     "createWorkOrder": "Auftrag anlegen",
     "viewWorkOrder": "Auftrag anzeigen",
     "createQuote": "Angebot anlegen",
     "viewQuote": "Angebot anzeigen",
-    "needsDefect": "Verfügbar, sobald ein Mangel erfasst ist",
     "workOrderRaised": "Auftrag {number} am {date} aus dieser Inspektion angelegt",
     "open": "Öffnen",
     "quoteCreated": "Angebot von {name} am {date} angelegt",
@@ -80,17 +83,17 @@
     "customer": "Kunde",
     "plate": "Kennzeichen",
     "registration": "Registrierung",
-    "template": "Vorlage",
+    "template": "Checkliste",
     "scaleEu": "Bewertet nach der EU-Mängelskala aus Richtlinie 2014/45/EU.",
     "scaleBasic": "Bewertet bestanden / Achtung / durchgefallen.",
     "country": "Land: {code}.",
-    "manageInspectionTemplates": "Inspektionsvorlagen verwalten",
+    "manageInspectionTemplates": "Checklisten verwalten",
     "readMore": "Mehr erfahren",
     "odometer": "Kilometerstand",
     "engineHours": "Betriebsstunden",
     "completeTitle": "Diese Inspektion abschließen?",
     "completeBody": "Die Prüfpunkte werden mit dem Abschluss gesperrt, und das Ergebnis lautet dann „{result}“.",
-    "completeBlocked": "{count} von der Vorlage als Pflicht markierte Prüfpunkte sind offen.",
+    "completeBlocked": "{count} Prüfung(en), die die Checkliste als Pflicht markiert, sind noch offen.",
     "completeUngraded": "{count} Prüfpunkt(e) wurden nicht bewertet.",
     "completeDangerous": "{count} gefährliche(r) Mangel erfasst. Das Fahrzeug darf nicht am öffentlichen Verkehr teilnehmen.",
     "reopenTitle": "Diese Inspektion wieder öffnen?",
@@ -106,7 +109,39 @@
     "reopenFailed": "Inspektion konnte nicht wieder geöffnet werden",
     "deleteFailed": "Inspektion konnte nicht gelöscht werden",
     "quoteFailed": "Angebot konnte nicht angelegt werden",
-    "workOrderFailed": "Auftrag konnte nicht angelegt werden"
+    "workOrderFailed": "Auftrag konnte nicht angelegt werden",
+    "workOrderDefectsTitle": "{count, plural, one {Den Punkt mit Handlungsbedarf hinzufügen?} other {Die # Punkte mit Handlungsbedarf hinzufügen?}}",
+    "workOrderDefectsBody": "Jeder wird eine Zeile im Arbeitsauftrag, mit der Notiz des Technikers, nach der Zeile für die Prüfung.",
+    "workOrderDefectsMore": "und {count} weitere",
+    "workOrderInspectionOnly": "Nur die Prüfung",
+    "workOrderAddDefects": "{count, plural, one {# Punkt hinzufügen} other {# Punkte hinzufügen}}",
+    "raisedFromInspection": "Erstellt aus der Inspektion vom {date}.",
+    "quoteTitle": "{vehicle} - Angebot nach Inspektion",
+    "preview": "Zertifikat-Vorschau",
+    "nextStep": "Nächster Schritt",
+    "nextStepWorkOrderHint": "Die Reparaturen, oder nur die Inspektion, als Auftrag auf die Tafel.",
+    "nextStepQuoteHint": "Die Mängel zuerst für die Freigabe des Kunden bepreisen.",
+    "linkWorkOrderTitle": "Auftrag für diese Inspektion",
+    "linkWorkOrderBody": "Neuen Auftrag anlegen oder die Inspektion mit einem bereits gebuchten Auftrag für dieses Fahrzeug verknüpfen.",
+    "createNewWorkOrder": "Neu anlegen",
+    "createNewWorkOrderHint": "Ein neuer Auftrag für dieses Fahrzeug, nummeriert und auf der Tafel.",
+    "linkExistingWorkOrder": "Mit bestehendem verknüpfen",
+    "loadingWorkOrders": "Offene Aufträge werden gesucht…",
+    "noOpenWorkOrders": "Keine offenen Aufträge für dieses Fahrzeug.",
+    "workOrderLinked": "Mit Auftrag {number} verknüpft",
+    "next": "Weiter",
+    "previousStep": "Zurück",
+    "linkWorkOrder": "Mit Auftrag verknüpfen",
+    "notes": "Anmerkungen zur Inspektion",
+    "notesHint": "Wird auf dem Zertifikat gedruckt und dem Kunden angezeigt.",
+    "notesPlaceholder": "Alles, was der Kunde über die bewerteten Prüfungen hinaus wissen sollte…",
+    "quoteRequestHeading": "Angebotsanfrage",
+    "quoteRequestedBy": "Angefragt von {name} am {date}",
+    "quoteRequestedOn": "Angefragt am {date}",
+    "quoteRequestMissingChecks": "{count} angefragte Prüfpunkt(e) stehen nicht mehr auf der Checkliste.",
+    "createQuoteFromRequest": "Angebot aus Anfrage anlegen",
+    "quoteFromRequestNote": "Erstellt aus der Angebotsanfrage des Kunden über den Prüfbericht-Link.",
+    "quoteCustomerSaid": "Nachricht des Kunden: {message}"
   },
   "item": {
     "required": "(Pflicht)",
@@ -122,6 +157,8 @@
     "reading": "Messwert",
     "readingUnit": "Messwert ({unit})",
     "limit": "Grenzwert: {range}",
+    "rangeMin": "mind.",
+    "rangeMax": "max.",
     "recordedValue": "Erfasster Wert",
     "answer": "Antwort",
     "selectAnswer": "Antwort wählen",
@@ -134,7 +171,10 @@
     "cancel": "Abbrechen",
     "saveFailed": "Dieser Prüfpunkt konnte nicht gespeichert werden",
     "uploadFailed": "Upload fehlgeschlagen",
-    "uploadFailedFor": "{name} konnte nicht hochgeladen werden"
+    "uploadFailedFor": "{name} konnte nicht hochgeladen werden",
+    "reopenToChange": "Inspektion wieder öffnen, um Fotos hinzuzufügen",
+    "tapToClear": "Erneut tippen, um die Bewertung zu löschen",
+    "quoteRequested": "Angebot angefragt"
   },
   "suggestions": {
     "title": "Häufige Mängel",
@@ -163,6 +203,23 @@
     "addTechnician": "Mechaniker hinzufügen…",
     "recordedAs": "Erfasst als {name}",
     "vehicleCategory": "Fahrzeugklasse",
+    "categories": {
+      "M1": "Pkw, bis 8 Sitzplätze",
+      "M2": "Bus, bis 5 t",
+      "M3": "Bus, über 5 t",
+      "N1": "Nutzfahrzeug, bis 3,5 t",
+      "N2": "Nutzfahrzeug, 3,5 t bis 12 t",
+      "N3": "Nutzfahrzeug, über 12 t",
+      "O1": "Anhänger, bis 0,75 t",
+      "O2": "Anhänger, 0,75 t bis 3,5 t",
+      "O3": "Anhänger, 3,5 t bis 10 t",
+      "O4": "Anhänger, über 10 t",
+      "L3e": "Motorrad",
+      "L4e": "Motorrad mit Beiwagen",
+      "L5e": "Dreirädriges Kraftfahrzeug",
+      "L7e": "Schweres Vierradfahrzeug",
+      "T": "Zugmaschine auf Rädern"
+    },
     "mileageAtTest": "{label} zum Zeitpunkt der Prüfung",
     "reportReference": "Berichtsreferenz",
     "reportReferenceHelp": "Ihre eigene Referenz für diesen Bericht. Wenn Sie als anerkannte Prüfstelle einen amtlichen Prüfbericht ausstellen, tragen Sie hier dessen Nummer ein, damit beide übereinstimmen. Andernfalls lassen Sie das Feld leer.",
@@ -195,20 +252,27 @@
     "addAndSelect": "Hinzufügen und auswählen",
     "technicianAdded": "{name} hinzugefügt und ausgewählt",
     "technicianFailed": "Der Mechaniker konnte nicht hinzugefügt werden",
-    "cancel": "Abbrechen"
+    "cancel": "Abbrechen",
+    "saving": "Wird gespeichert…",
+    "savedAt": "Gespeichert {time}",
+    "savesAsYouGo": "Angaben werden beim Ausfüllen gespeichert"
   },
   "templates": {
-    "title": "Inspektionsvorlagen",
+    "title": "Inspektions-Checklisten",
     "description": "Die Prüflisten, die Ihre Mechaniker durchgehen. Legen Sie eine je Land, je Fahrzeugtyp oder je angebotener Leistung an.",
     "import": "Importieren",
     "browse": "Prüflisten durchsuchen",
-    "new": "Neue Vorlage",
+    "new": "Neue Checkliste",
     "restoreBanner": "{count} fertige Prüflisten wurden aus Ihrer Liste entfernt. Sie können sie zurückholen.",
     "restore": "Zurückholen",
+    "translateBanner": "Ihre vorgefertigten Checklisten sind auf {from}. Auf {to} übersetzen? Umbenannte Prüfpunkte bleiben unverändert.",
+    "translate": "Übersetzen",
+    "translated": "Checklisten auf {language} übersetzt",
+    "translateFailed": "Die Checklisten konnten nicht übersetzt werden",
     "restored": "{count} Prüfliste(n) zurückgeholt",
     "allPresent": "Alle Prüflisten sind bereits vorhanden",
     "restoreFailed": "Die Prüflisten konnten nicht hinzugefügt werden",
-    "emptyTitle": "Noch keine Vorlagen",
+    "emptyTitle": "Noch keine Checklisten",
     "emptyBody": "Die fertigen Prüflisten, einschließlich der periodischen EU-Fahrzeugprüfung, wurden alle entfernt. Holen Sie sie zurück oder bauen Sie Ihre eigene von Grund auf.",
     "restoreChecklists": "Prüflisten zurückholen",
     "buildMyOwn": "Eigene bauen",
@@ -224,29 +288,29 @@
     "euScale": "EU-Mängelskala",
     "basicScale": "Bestanden / Achtung / durchgefallen",
     "deleteTitle": "„{name}“ löschen?",
-    "deleteBody": "Damit wird die Vorlage dauerhaft gelöscht. Bereits durchgeführte Inspektionen behalten ihre eigene Kopie der Prüfliste und sind nicht betroffen.",
-    "deleted": "Vorlage gelöscht",
-    "deleteFailed": "Vorlage konnte nicht gelöscht werden",
-    "duplicated": "Vorlage dupliziert",
-    "duplicateFailed": "Vorlage konnte nicht dupliziert werden",
+    "deleteBody": "Die Checkliste wird endgültig gelöscht. Bereits durchgeführte Inspektionen behalten ihre eigene Kopie und sind nicht betroffen.",
+    "deleted": "Checkliste gelöscht",
+    "deleteFailed": "Checkliste konnte nicht gelöscht werden",
+    "duplicated": "Checkliste dupliziert",
+    "duplicateFailed": "Checkliste konnte nicht dupliziert werden",
     "cancel": "Abbrechen",
     "srSections": "Gruppen",
     "srChecks": "Prüfpunkte",
     "srMeasurements": "Messwerte"
   },
   "presets": {
-    "title": "Mit einer Vorlage beginnen",
-    "description": "Jede Vorlage wird als normale Vorlage in Ihre Werkstatt kopiert. Benennen Sie Gruppen um, verschieben Sie Prüfpunkte und ändern Sie jeden Grenzwert passend zu Ihrem Land und Ihrer Ausrüstung.",
+    "title": "Mit einer fertigen Checkliste beginnen",
+    "description": "Jede Vorlage wird als normale Checkliste in Ihre Werkstatt kopiert. Benennen Sie Abschnitte um, verschieben Sie Prüfungen und passen Sie Grenzwerte an Ihr Land und Ihre Ausrüstung an.",
     "search": "Nach Name, Land oder Standard suchen",
-    "searchLabel": "Vorlagen suchen",
+    "searchLabel": "Checklisten durchsuchen",
     "inYourList": "In Ihrer Liste",
     "counts": "{sections} Gruppen · {checks} Prüfpunkte",
-    "noMatch": "Keine Vorlage passt zu „{query}“.",
+    "noMatch": "Keine Checkliste passt zu „{query}“.",
     "warning": "Die gesetzlichen Vorlagen enthalten die üblichen EU-Grenzwerte. Prüfen Sie sie gegen Ihre nationalen Vorschriften, bevor Sie einen Prüfbericht ausstellen.",
     "cancel": "Abbrechen",
-    "add": "Vorlage hinzufügen",
-    "added": "Vorlage hinzugefügt. Passen Sie sie an Ihre Werkstatt an.",
-    "addFailed": "Vorlage konnte nicht hinzugefügt werden",
+    "add": "Checkliste hinzufügen",
+    "added": "Checkliste hinzugefügt. Passen Sie sie an Ihre Werkstatt an.",
+    "addFailed": "Checkliste konnte nicht hinzugefügt werden",
     "group": {
       "regulatory": "Gesetzlich",
       "workshop": "Werkstatt",
@@ -261,8 +325,8 @@
     "basicScale": "Bestanden / Achtung / durchgefallen"
   },
   "builder": {
-    "newTitle": "Neue Vorlage",
-    "editTitle": "Vorlage bearbeiten",
+    "newTitle": "Neue Checkliste",
+    "editTitle": "Checkliste bearbeiten",
     "description": "Bauen Sie die Prüfliste, die Ihre Werkstatt tatsächlich verwendet. Ziehen Sie zum Umsortieren und richten Sie jeden Prüfpunkt so ein, dass er eine Bewertung, einen Messwert, eine geschriebene Notiz oder eine feste Antwort erfasst.",
     "name": "Name",
     "namePlaceholder": "z. B. Periodische Fahrzeugprüfung",
@@ -276,7 +340,7 @@
     "scaleBasic": "Bestanden / Achtung / durchgefallen",
     "scaleEuHelp": "Die Kategorien aus Artikel 7 Absatz 2 der Richtlinie 2014/45/EU. Ein Fahrzeug mit ausschließlich geringen Mängeln besteht weiterhin.",
     "scaleBasicHelp": "Eine einfache dreistufige Skala für Serviceprüflisten, die keine gesetzliche Untersuchung sind.",
-    "defaultTemplate": "Standardvorlage",
+    "defaultTemplate": "Standard-Checkliste",
     "defaultHelp": "Vorausgewählt, wenn ein Mechaniker eine neue Inspektion beginnt.",
     "summary": "{sections} Gruppe(n) · {checks} Prüfpunkt(e)",
     "renumber": "Referenzen neu nummerieren",
@@ -314,24 +378,27 @@
       "condition": "Mängelbewertung",
       "measurement": "Messwert",
       "text": "Freitext",
-      "choice": "Auswahl"
+      "choice": "Auswahl",
+      "condition_map": "Zustandskarte"
     },
     "inputHint": {
       "condition": "Der Mechaniker bewertet den Prüfpunkt nach der Mängelskala.",
       "measurement": "Erfasst eine Zahl mit Einheit, automatisch gegen einen Bereich bewertet.",
       "text": "Erfasst eine schriftliche Beobachtung, etwa eine Reifenmarke oder einen Fehlercode.",
-      "choice": "Der Mechaniker wählt eine Antwort aus einer festen Liste."
+      "choice": "Der Mechaniker wählt eine Antwort aus einer festen Liste.",
+      "condition_map": "Eine Zeichnung des Fahrzeugs, auf der Dellen, Kratzer und Steinschläge markiert werden, jeweils mit Notiz und Foto."
     },
     "cancel": "Abbrechen",
-    "create": "Vorlage anlegen",
+    "create": "Checkliste erstellen",
     "saveChanges": "Änderungen speichern",
-    "created": "Vorlage angelegt",
-    "updated": "Vorlage aktualisiert",
-    "saveFailed": "Vorlage konnte nicht gespeichert werden",
+    "created": "Checkliste erstellt",
+    "updated": "Checkliste aktualisiert",
+    "saveFailed": "Checkliste konnte nicht gespeichert werden",
     "severityAttention": "Geringer Mangel",
     "severityFail": "Erheblicher Mangel",
     "severityDangerous": "Gefährlicher Mangel",
-    "answersPlaceholder": "Vollständig, Teilweise, Keine"
+    "answersPlaceholder": "Vollständig, Teilweise, Keine",
+    "allowNotApplicable": "Darf als „nicht zutreffend“ bewertet werden"
   },
   "packages": {
     "exportTitle": "„{name}“ exportieren",
@@ -342,17 +409,17 @@
     "attributePlaceholder": "z. B. Egeland Auto",
     "attributeHelp": "Wird in die Datei geschrieben, damit der Installierende sieht, woher sie stammt. Aus Ihrem Konto wird nichts übernommen.",
     "download": "Herunterladen",
-    "exported": "Vorlage exportiert",
-    "exportFailed": "Diese Vorlage konnte nicht exportiert werden",
+    "exported": "Checkliste exportiert",
+    "exportFailed": "Diese Checkliste konnte nicht exportiert werden",
     "importTitle": "Prüfliste importieren",
-    "importDescription": "Fügt eine Prüfliste aus einer Torqvoice-Paketdatei hinzu. Sie kommt als normale, bearbeitbare Vorlage an und ersetzt niemals eine vorhandene.",
+    "importDescription": "Fügt eine Checkliste aus einer Torqvoice-Paketdatei hinzu. Sie kommt als normale, bearbeitbare Checkliste an und ersetzt nie eine vorhandene.",
     "chooseFile": "Paketdatei wählen",
     "chooseAnother": "Andere Datei wählen",
-    "unreadable": "Die Datei konnte nicht gelesen werden. Ein Torqvoice-Paket ist eine aus einer Vorlage exportierte .json-Datei.",
+    "unreadable": "Die Datei konnte nicht gelesen werden. Ein Torqvoice-Paket ist eine .json-Datei, die aus einer Checkliste exportiert wurde.",
     "from": "Von {author}",
     "limitsWarning": "Die Grenzwerte einer geteilten Prüfliste sind die, die ihr Autor gesetzt hat. Prüfen Sie sie gegen Ihre nationalen Vorschriften, bevor Sie etwas daraus ausstellen.",
     "import": "Importieren",
-    "imported": "{count} Vorlage(n) importiert",
+    "imported": "{count} Checkliste(n) importiert",
     "importFailed": "Dieses Paket konnte nicht importiert werden",
     "cancel": "Abbrechen"
   },
@@ -364,7 +431,7 @@
     "new": "Neue Inspektion",
     "empty": "Keine Inspektionen gefunden.",
     "vehicle": "Fahrzeug",
-    "template": "Vorlage",
+    "template": "Checkliste",
     "progress": "Fortschritt",
     "progressTooltip": "{graded} von {total} Prüfpunkten bewertet",
     "status": "Status",
@@ -378,9 +445,9 @@
     "searchVehicles": "Fahrzeuge suchen…",
     "loadingVehicles": "Fahrzeuge werden geladen…",
     "noVehicles": "Keine Fahrzeuge gefunden.",
-    "template": "Vorlage",
-    "selectTemplate": "Vorlage wählen",
-    "manageTemplates": "Vorlagen verwalten",
+    "template": "Checkliste",
+    "selectTemplate": "Checkliste wählen",
+    "manageTemplates": "Checklisten verwalten",
     "cancel": "Abbrechen",
     "create": "Inspektion anlegen",
     "created": "Inspektion angelegt",
@@ -393,5 +460,39 @@
     "copied": "Link in die Zwischenablage kopiert",
     "generated": "Freigabelink erstellt",
     "revoked": "Freigabelink zurückgezogen"
+  },
+  "phone": {
+    "pageTitle": "Fotos zur Prüfung",
+    "wholeTitle": "Die ganze Prüfung",
+    "wholeBody": "Übersichtsfotos und Dokumente wie das unterschriebene Prüfformular.",
+    "wholeShort": "Prüfung",
+    "checksTitle": "Fotos zu einem Prüfpunkt",
+    "checksBody": "Tippen Sie auf einen Prüfpunkt, um ihn zu fotografieren. Das Foto erscheint neben seiner Bewertung.",
+    "checksClosed": "Diese Prüfung ist abgeschlossen, ihre Prüfpunkte nehmen keine Fotos mehr an. Zur ganzen Prüfung können Sie weiterhin etwas hinzufügen.",
+    "search": "Prüfpunkt suchen",
+    "noMatch": "Kein Prüfpunkt passt dazu.",
+    "defectsTitle": "Handlungsbedarf ({count})",
+    "photoNeeded": "Foto fehlt",
+    "photoCount": "{count, plural, one {# Foto} other {# Fotos}}",
+    "chooseFor": "Fotos für {name} auswählen",
+    "completedError": "Diese Prüfung wurde abgeschlossen. Bitten Sie die Werkstatt, sie wieder zu öffnen.",
+    "limitError": "Dieser Prüfpunkt oder diese Prüfung hat bereits so viele Fotos wie möglich.",
+    "invalid": "Scannen Sie den Code auf der Prüfung erneut.",
+    "dialogTitle": "Fotos vom Handy hinzufügen",
+    "dialogBody": "Fotografieren Sie jeden Prüfpunkt vor Ort oder fügen Sie der ganzen Prüfung Übersichtsfotos und Dokumente hinzu. Alles landet direkt in dieser Prüfung.",
+    "step2": "Wählen Sie auf dem Handy einen Prüfpunkt und machen Sie das Foto, oder fügen Sie es der ganzen Prüfung hinzu.",
+    "step3": "Sie erscheinen in dieser Prüfung, sobald sie ankommen.",
+    "scope": "Der Link fügt dieser Prüfung nur Fotos und Dokumente hinzu und funktioniert nach 30 Minuten nicht mehr.",
+    "dialogTitleItem": "Fotos von {name} vom Handy hinzufügen",
+    "dialogBodyItem": "Fotografieren Sie diese Prüfung vor Ort. Das Foto landet neben der Bewertung; das Handy kann auch zu anderen Prüfungen hinzufügen.",
+    "stepItem2": "Machen Sie das Foto mit dem Handy; es landet bei dieser Prüfung.",
+    "focusTitle": "Die Prüfung, für die dieser Code gezeigt wurde"
+  },
+  "files": {
+    "title": "Fotos und Dokumente",
+    "hint": "Für die Prüfung als Ganzes. Ein Foto eines einzelnen Prüfpunkts gehört zu diesem Prüfpunkt.",
+    "photos": "Fotos",
+    "documents": "Dokumente",
+    "video": "Video"
   }
 }

+ 126 - 94
messages/de/integrations.json

@@ -40,19 +40,19 @@
       "conference": "Links für Videoanrufe"
     },
     "sms": {
-      "send": "Text messages out",
-      "receive": "Replies back in"
+      "send": "SMS versenden",
+      "receive": "Antworten empfangen"
     },
     "whatsapp": {
-      "send": "WhatsApp messages out",
-      "receive": "Replies back in"
+      "send": "WhatsApp-Nachrichten versenden",
+      "receive": "Antworten empfangen"
     },
     "telegram": {
-      "send": "Telegram messages out",
-      "receive": "Replies back in"
+      "send": "Telegram-Nachrichten versenden",
+      "receive": "Antworten empfangen"
     },
     "email": {
-      "send": "Email out"
+      "send": "E-Mails versenden"
     },
     "vehicle": {
       "lookup": "Kennzeichen- und FIN-Abfrage",
@@ -60,7 +60,8 @@
       "safety": "Rückrufe und Beschwerden von Haltern"
     },
     "ai": {
-      "chat": "Texte und Zusammenfassungen"
+      "chat": "Texte und Zusammenfassungen",
+      "transcribe": "Diktieren"
     },
     "payments": {
       "checkout": "Bezahlen über den Rechnungslink"
@@ -131,19 +132,19 @@
     "lockedAi": "Verbinden Sie einen KI-Anbieter, um KI-Funktionen in Ihrer Werkstatt zu nutzen.",
     "ownAppTitle": "Diese Installation verwendet Ihre eigene App beim Anbieter",
     "redirectUri": "Beim Anbieter zu registrierende Redirect-URI:",
-    "sendTestEmail": "Send test email",
-    "testEmailSent": "Test email sent to {email}",
-    "testEmailHint": "A test email goes to your own address through this vendor, which proves the from address and the sending rules a key check cannot.",
-    "inboundUrl": "Inbound webhook URL",
-    "inboundHintSecret": "Paste this URL into the vendor's inbound message settings. It carries a secret that identifies your workshop, so keep it private.",
-    "inboundHintMeta": "Paste this URL as the callback URL in your Meta app, with the verify token you entered above, and subscribe to messages.",
+    "sendTestEmail": "Test-E-Mail senden",
+    "testEmailSent": "Test-E-Mail an {email} gesendet",
+    "testEmailHint": "Eine Test-E-Mail geht über diesen Anbieter an Ihre eigene Adresse. So prüfen Sie die Absenderadresse und die Versandregeln, die eine reine Schlüsselprüfung nicht abdeckt.",
+    "inboundUrl": "Webhook-URL für eingehende Nachrichten",
+    "inboundHintSecret": "Fügen Sie diese URL in den Einstellungen des Anbieters für eingehende Nachrichten ein. Sie enthält ein Geheimnis, das Ihre Werkstatt identifiziert, halten Sie sie also vertraulich.",
+    "inboundHintMeta": "Tragen Sie diese URL als Callback-URL in Ihrer Meta-App ein, zusammen mit dem oben eingegebenen Verifizierungstoken, und abonnieren Sie messages.",
     "inboundHintStripe": "Fügen Sie diese URL im Stripe-Dashboard unter Developers, Webhooks hinzu, abonniert auf checkout.session.completed. Tragen Sie dann das von Stripe angezeigte Signaturgeheimnis hier im Feld Webhook-Geheimnis ein, damit Benachrichtigungen geprüft werden können.",
     "inboundHintVipps": "Tragen Sie diese URL als Callback-URL im Vipps-MobilePay-Portal ein. Vipps verlangt außerdem veröffentlichte Verkaufsbedingungen, die unter Einstellungen, Zahlung hinterlegt werden.",
     "inboundHintPaypal": "Fügen Sie diese URL im PayPal-Entwicklerdashboard unter Webhooks hinzu, abonniert auf PAYMENT.CAPTURE.COMPLETED.",
-    "copy": "Copy",
-    "copied": "Copied",
-    "updateKeys": "Update keys",
-    "updateKeysHint": "The last check failed. Enter the keys again to try once more.",
+    "copy": "Kopieren",
+    "copied": "Kopiert",
+    "updateKeys": "Schlüssel aktualisieren",
+    "updateKeysHint": "Die letzte Prüfung ist fehlgeschlagen. Geben Sie die Schlüssel erneut ein, um es noch einmal zu versuchen.",
     "inspectionSync": {
       "title": "HU-Abgleich",
       "vehicles": "{count} Fahrzeuge mit Kennzeichen in dieser Werkstatt",
@@ -156,7 +157,8 @@
       "syncNowHelp": "Jetzt synchronisieren startet sofort einen Durchlauf statt auf den stündlichen zu warten. Er fragt 25 Fahrzeuge auf einmal ab und macht von selbst weiter, bis alle fälligen Fahrzeuge erledigt sind."
     },
     "supersedes": "Wenn Sie {next} verbinden, wird {current} getrennt. Nur einer kann zuständig sein: Sobald {next} aktiv ist, wird {current} nicht mehr genutzt.",
-    "batchRetried": "{count, plural, =0 {Nichts zu wiederholen} one {# Auftrag erneut eingereiht} other {# Aufträge erneut eingereiht}}"
+    "batchRetried": "{count, plural, =0 {Nichts zu wiederholen} one {# Auftrag erneut eingereiht} other {# Aufträge erneut eingereiht}}",
+    "advanced": "Erweitert"
   },
   "activity": {
     "title": "Aktivität",
@@ -256,155 +258,155 @@
       }
     },
     "twilio-sms": {
-      "description": "Send and receive text messages through a Twilio number. Reminders, ready-for-pickup messages and the replies that come back.",
+      "description": "SMS über eine Twilio-Nummer senden und empfangen. Erinnerungen, Abholbereit-Nachrichten und die Antworten darauf.",
       "fields": {
         "accountSid": "Account SID",
-        "authToken": "Auth token"
+        "authToken": "Auth-Token"
       },
       "settings": {
-        "phoneNumber": "Send from this number"
+        "phoneNumber": "Von dieser Nummer senden"
       }
     },
     "vonage-sms": {
-      "description": "Send and receive text messages through a Vonage number.",
+      "description": "SMS über eine Vonage-Nummer senden und empfangen.",
       "fields": {
-        "apiKey": "API key",
-        "apiSecret": "API secret",
-        "signatureSecret": "Signature secret",
-        "signatureSecretHelp": "From the Vonage dashboard, under API settings. When set, inbound messages are only accepted when Vonage signed them. Without it, only the secret in the webhook URL is checked."
+        "apiKey": "API-Schlüssel",
+        "apiSecret": "API-Secret",
+        "signatureSecret": "Signaturgeheimnis",
+        "signatureSecretHelp": "Aus dem Vonage-Dashboard unter API settings. Ist es gesetzt, werden eingehende Nachrichten nur angenommen, wenn Vonage sie signiert hat. Ohne es wird nur das Geheimnis in der Webhook-URL geprüft."
       },
       "settings": {
-        "phoneNumber": "Send from this number"
+        "phoneNumber": "Von dieser Nummer senden"
       }
     },
     "telnyx-sms": {
-      "description": "Send and receive text messages through a Telnyx number.",
+      "description": "SMS über eine Telnyx-Nummer senden und empfangen.",
       "fields": {
-        "apiKey": "API key",
-        "webhookPublicKey": "Webhook public key",
-        "webhookPublicKeyHelp": "From the Telnyx portal, under Account settings, Keys and credentials, Public key. When set, inbound messages are only accepted when Telnyx signed them. Without it, only the secret in the webhook URL is checked."
+        "apiKey": "API-Schlüssel",
+        "webhookPublicKey": "Öffentlicher Webhook-Schlüssel",
+        "webhookPublicKeyHelp": "Aus dem Telnyx-Portal unter Account settings, Keys and credentials, Public key. Ist er gesetzt, werden eingehende Nachrichten nur angenommen, wenn Telnyx sie signiert hat. Ohne ihn wird nur das Geheimnis in der Webhook-URL geprüft."
       },
       "settings": {
-        "phoneNumber": "Send from this number"
+        "phoneNumber": "Von dieser Nummer senden"
       }
     },
     "whatsapp-meta": {
-      "description": "Message customers on WhatsApp through Meta directly. Photos of what you found, and the conversation that follows.",
+      "description": "Kunden direkt über Meta auf WhatsApp schreiben. Fotos von dem, was Sie gefunden haben, und die Unterhaltung, die darauf folgt.",
       "fields": {
-        "phoneNumberId": "Phone number ID",
-        "accessToken": "Access token",
-        "verifyToken": "Webhook verify token",
-        "appSecret": "App secret",
-        "apiVersion": "Graph API version",
-        "appSecretHelp": "Without it, webhook signatures are not checked and anyone who learns the webhook URL can post messages as your customers. Strongly recommended."
+        "phoneNumberId": "Telefonnummer-ID",
+        "accessToken": "Zugriffstoken",
+        "verifyToken": "Webhook-Verifizierungstoken",
+        "appSecret": "App-Secret",
+        "apiVersion": "Graph-API-Version",
+        "appSecretHelp": "Meta signiert damit jede Zustellung. Solange es nicht eingetragen ist, werden an den Webhook gesendete Nachrichten abgewiesen, da sonst jeder, der die URL kennt, im Namen Ihrer Kunden schreiben könnte."
       },
       "settings": {
-        "enabled": "Send messages on this channel",
-        "phoneNumber": "Send from this number",
-        "templateName": "Template for reopening a conversation",
-        "templateLanguage": "Template language",
-        "templateVariables": "Template values, in order",
-        "mediaTemplateName": "Template for a message with a photo",
-        "mediaTemplateLanguage": "Photo template language",
-        "mediaTemplateVariables": "Photo template values, in order"
+        "enabled": "Nachrichten über diesen Kanal senden",
+        "phoneNumber": "Von dieser Nummer senden",
+        "templateName": "Vorlage zum Wiedereröffnen einer Unterhaltung",
+        "templateLanguage": "Sprache der Vorlage",
+        "templateVariables": "Werte der Vorlage, in Reihenfolge",
+        "mediaTemplateName": "Vorlage für eine Nachricht mit Foto",
+        "mediaTemplateLanguage": "Sprache der Fotovorlage",
+        "mediaTemplateVariables": "Werte der Fotovorlage, in Reihenfolge"
       }
     },
     "whatsapp-twilio": {
-      "description": "Message customers on WhatsApp through a Twilio number, if Twilio is who sold you one.",
+      "description": "Kunden über eine Twilio-Nummer auf WhatsApp schreiben, falls Sie Ihre Nummer von Twilio haben.",
       "fields": {
         "accountSid": "Account SID",
-        "authToken": "Auth token",
-        "messagingServiceSid": "Messaging service SID"
+        "authToken": "Auth-Token",
+        "messagingServiceSid": "Messaging Service SID"
       },
       "settings": {
-        "enabled": "Send messages on this channel",
-        "phoneNumber": "Send from this number",
-        "templateName": "Template for reopening a conversation",
-        "templateLanguage": "Template language",
-        "templateVariables": "Template values, in order",
-        "mediaTemplateName": "Template for a message with a photo",
-        "mediaTemplateLanguage": "Photo template language",
-        "mediaTemplateVariables": "Photo template values, in order"
+        "enabled": "Nachrichten über diesen Kanal senden",
+        "phoneNumber": "Von dieser Nummer senden",
+        "templateName": "Vorlage zum Wiedereröffnen einer Unterhaltung",
+        "templateLanguage": "Sprache der Vorlage",
+        "templateVariables": "Werte der Vorlage, in Reihenfolge",
+        "mediaTemplateName": "Vorlage für eine Nachricht mit Foto",
+        "mediaTemplateLanguage": "Sprache der Fotovorlage",
+        "mediaTemplateVariables": "Werte der Fotovorlage, in Reihenfolge"
       }
     },
     "telegram": {
-      "description": "A Telegram bot that reaches customers who use it, and brings their replies into the inbox.",
+      "description": "Ein Telegram-Bot, der Kunden erreicht, die ihn nutzen, und ihre Antworten in den Posteingang holt.",
       "fields": {
-        "botToken": "Bot token"
+        "botToken": "Bot-Token"
       },
       "settings": {
-        "enabled": "Send messages on this channel",
-        "botUsername": "Bot username"
+        "enabled": "Nachrichten über diesen Kanal senden",
+        "botUsername": "Bot-Benutzername"
       }
     },
     "smtp": {
-      "description": "Send invoices and reminders through your own mail server, rather than the platform mail.",
+      "description": "Rechnungen und Erinnerungen über Ihren eigenen Mailserver statt über den Plattform-Versand senden.",
       "fields": {
         "host": "Server",
         "port": "Port",
-        "user": "Username",
-        "pass": "Password"
+        "user": "Benutzername",
+        "pass": "Passwort"
       },
       "settings": {
-        "fromEmail": "Send from this address",
-        "fromName": "Name shown as the sender",
-        "secure": "Connect over TLS from the start",
-        "requireTls": "Require the server to upgrade to TLS",
-        "rejectUnauthorized": "Reject a certificate that does not check out"
+        "fromEmail": "Von dieser Adresse senden",
+        "fromName": "Als Absender angezeigter Name",
+        "secure": "Von Anfang an über TLS verbinden",
+        "requireTls": "Vom Server den Wechsel zu TLS verlangen",
+        "rejectUnauthorized": "Zertifikate ablehnen, die die Prüfung nicht bestehen"
       }
     },
     "resend": {
-      "description": "Send invoices and reminders through Resend.",
+      "description": "Rechnungen und Erinnerungen über Resend senden.",
       "fields": {
-        "apiKey": "API key"
+        "apiKey": "API-Schlüssel"
       },
       "settings": {
-        "fromEmail": "Send from this address",
-        "fromName": "Name shown as the sender"
+        "fromEmail": "Von dieser Adresse senden",
+        "fromName": "Als Absender angezeigter Name"
       }
     },
     "postmark": {
-      "description": "Send invoices and reminders through Postmark.",
+      "description": "Rechnungen und Erinnerungen über Postmark senden.",
       "fields": {
-        "apiKey": "Server token"
+        "apiKey": "Server-Token"
       },
       "settings": {
-        "fromEmail": "Send from this address",
-        "fromName": "Name shown as the sender"
+        "fromEmail": "Von dieser Adresse senden",
+        "fromName": "Als Absender angezeigter Name"
       }
     },
     "mailgun": {
-      "description": "Send invoices and reminders through Mailgun.",
+      "description": "Rechnungen und Erinnerungen über Mailgun senden.",
       "fields": {
-        "apiKey": "API key",
-        "domain": "Sending domain"
+        "apiKey": "API-Schlüssel",
+        "domain": "Versanddomain"
       },
       "settings": {
-        "fromEmail": "Send from this address",
-        "fromName": "Name shown as the sender",
-        "region": "Region the domain is in"
+        "fromEmail": "Von dieser Adresse senden",
+        "fromName": "Als Absender angezeigter Name",
+        "region": "Region der Domain"
       }
     },
     "sendgrid": {
-      "description": "Send invoices and reminders through SendGrid.",
+      "description": "Rechnungen und Erinnerungen über SendGrid senden.",
       "fields": {
-        "apiKey": "API key"
+        "apiKey": "API-Schlüssel"
       },
       "settings": {
-        "fromEmail": "Send from this address",
-        "fromName": "Name shown as the sender"
+        "fromEmail": "Von dieser Adresse senden",
+        "fromName": "Als Absender angezeigter Name"
       }
     },
     "amazon-ses": {
-      "description": "Send invoices and reminders through Amazon SES.",
+      "description": "Rechnungen und Erinnerungen über Amazon SES senden.",
       "fields": {
-        "accessKeyId": "Access key ID",
-        "secretAccessKey": "Secret access key",
+        "accessKeyId": "Zugriffsschlüssel-ID",
+        "secretAccessKey": "Geheimer Zugriffsschlüssel",
         "region": "Region"
       },
       "settings": {
-        "fromEmail": "Send from this address",
-        "fromName": "Name shown as the sender"
+        "fromEmail": "Von dieser Adresse senden",
+        "fromName": "Als Absender angezeigter Name"
       }
     },
     "vegvesen": {
@@ -479,6 +481,19 @@
         "modelHelp": "Welches Modell antwortet. Die Liste kommt aus Ihrem Konto, neueste zuerst. Kleinere Modelle kosten pro Aufruf weniger und reichen für die meisten Werkstatttexte."
       }
     },
+    "openai-compatible": {
+      "description": "Servicebeschreibungen, Verlaufszusammenfassungen, Dokumentenerkennung und der Assistent, beantwortet von jedem Server, der die Chat-API von OpenAI spricht: Ollama, Open WebUI, LocalAI, vLLM, LM Studio, LiteLLM und ähnliche. Ihre Daten bleiben auf Ihrer eigenen Hardware.",
+      "fields": {
+        "baseUrl": "Basis-URL",
+        "baseUrlHelp": "Wo die OpenAI-artige API des Servers beginnt, inklusive Pfad: /v1 bei Ollama, vLLM, LocalAI und LM Studio, /api bei Open WebUI. Torqvoice hängt /models und /chat/completions daran an.",
+        "apiKey": "API-Schlüssel",
+        "apiKeyHelp": "Nur wenn der Server einen verlangt. Ollama und vLLM laufen meist ohne Schlüssel; Open WebUI stellt einen unter Einstellungen, Konto aus."
+      },
+      "settings": {
+        "model": "Modell",
+        "modelHelp": "Welches Modell antwortet. Die Liste enthält alles, was der Server meldet, nach Namen. Für das Lesen von Fotos ein Modell mit Bildverständnis wählen."
+      }
+    },
     "stripe": {
       "description": "Kartenzahlungen über den Rechnungslink auf Ihr eigenes Stripe-Konto. Der Kunde zahlt, Stripe meldet es Torqvoice, und die Rechnung wird von selbst als bezahlt markiert.",
       "fields": {
@@ -565,6 +580,23 @@
         "refreshFleet": "Rückruf- und Beschwerdedaten für jedes Fahrzeug dieser Werkstatt aktuell halten",
         "refreshFleetHelp": "Einmal täglich werden Modelle, die in der letzten Woche nicht aktualisiert wurden, erneut abgerufen, damit die Fahrzeugseite nie wartet und ein neuer Rückruf innerhalb einer Woche nach Einreichung erscheint. Nur Marke, Modell und Jahr werden gesendet."
       }
+    },
+    "speech-to-text": {
+      "description": "Diktat auf einem Arbeitsauftrag, aufgeschrieben von einem Sprachmodell statt vom Browser: viel genauer, in jeder Sprache, und es kennt die Wörter einer Werkstatt. Getrennt von Ihrem KI-Anbieter, den Sie also so lassen können, wie er ist. Nutzt Ihren eigenen OpenAI-Schlüssel oder einen eigenen Server, der die Transkriptions-API von OpenAI spricht. Die Aufnahme wird nur zum Aufschreiben gesendet und nicht aufbewahrt.",
+      "fields": {
+        "apiKey": "API-Schlüssel",
+        "apiKeyHelp": "Ein OpenAI-Schlüssel von platform.openai.com unter API keys; OpenAI rechnet die Nutzung pro Minute ab. Bei einem eigenen Server nur, wenn er einen verlangt.",
+        "baseUrl": "Serveradresse",
+        "baseUrlHelp": "Leer lassen, um OpenAI zu nutzen. Bei einem eigenen Server (ein lokales Whisper, LocalAI, Groq) die Adresse, an der seine OpenAI-artige API beginnt, einschließlich Pfad, etwa /v1. Torqvoice hängt /audio/transcriptions daran an."
+      },
+      "settings": {
+        "model": "Modell",
+        "modelHelp": "Das Sprachmodell, das angefragt wird. gpt-4o-mini-transcribe ist genau und günstig; gpt-4o-transcribe ist das größere. Ein eigener Server hört meist auf whisper-1.",
+        "mode": "Wer entscheidet, wie das Diktat aufgeschrieben wird",
+        "modeHelp": "Der Browser schreibt mit, während Sie sprechen, ist aber schwächer, vor allem bei kleineren Sprachen. Das Sprachmodell schreibt, sobald Sie aufhören.",
+        "modeAi": "Immer das Sprachmodell",
+        "modeChoice": "Jede Person wählt: Sprachmodell oder Browser"
+      }
     }
   },
   "jobKinds": {

+ 1 - 1
messages/de/inventory.json

@@ -131,7 +131,7 @@
     "saveChanges": "Änderungen speichern",
     "addPart": "Teil hinzufügen"
   },
-  "openMenu": "Open menu",
+  "openMenu": "Menü öffnen",
   "history": {
     "title": "Bestandsverlauf",
     "description": "Alle erfassten Änderungen an {name}, neueste zuerst.",

+ 4 - 4
messages/de/laborPresets.json

@@ -16,7 +16,7 @@
   "actions": {
     "edit": "Bearbeiten",
     "delete": "Löschen",
-    "openMenu": "Open menu"
+    "openMenu": "Menü öffnen"
   },
   "deletePreset": {
     "title": "Arbeitsvorlage löschen",
@@ -43,8 +43,8 @@
     "cancel": "Abbrechen",
     "create": "Erstellen",
     "saveChanges": "Änderungen speichern",
-    "itemQty": "Qty",
-    "itemQtyOrHours": "Unit / Hours",
+    "itemQty": "Menge",
+    "itemQtyOrHours": "Einheit / Stunden",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
     "partsTitle": "Teile",
@@ -63,7 +63,7 @@
     "noPackages": "Keine Arbeitsvorlagen gefunden.",
     "items": "{count} Positionen",
     "totalHours": "{hours} Std. gesamt",
-    "totalUnits": "{units} unit(s)",
+    "totalUnits": "{units} Einheit(en)",
     "totalParts": "{count} Teile"
   },
   "errors": {

+ 3 - 3
messages/de/messages.json

@@ -30,8 +30,8 @@
     "deleteDescription": "Alle Nachrichten mit {name} werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
     "deleted": "Unterhaltung gelöscht",
     "deleteError": "Unterhaltung konnte nicht gelöscht werden",
-    "back": "Back",
-    "conversationMenu": "Conversation menu",
+    "back": "Zurück",
+    "conversationMenu": "Unterhaltungsmenü",
     "searchPlaceholder": "Unterhaltungen durchsuchen",
     "noSearchResults": "Keine Unterhaltung passt zur Suche",
     "now": "jetzt"
@@ -47,7 +47,7 @@
     "messageDeleted": "Nachricht gelöscht",
     "deleteError": "Nachricht konnte nicht gelöscht werden",
     "sendError": "SMS konnte nicht gesendet werden",
-    "sendMessage": "Send message"
+    "sendMessage": "Nachricht senden"
   },
   "sendDialog": {
     "send": "{label} senden",

+ 3 - 1
messages/de/navigation.json

@@ -39,6 +39,7 @@
     "invoice": "Rechnung",
     "payment": "Zahlung",
     "tax": "Steuer",
+    "warranty": "Garantie",
     "workshop": "Werkstatt",
     "appearance": "Erscheinungsbild",
     "email": "E-Mail",
@@ -90,6 +91,7 @@
   "licenseUnverifiedNow": "Ihre Lizenz konnte nicht überprüft werden und das Torqvoice-Branding ist zurückgekehrt.",
   "licenseVerify": "Überprüfen",
   "sidebar": {
+    "account": "Konto",
     "dashboard": "Dashboard",
     "clients": "Kunden",
     "customers": "Kunden",
@@ -111,7 +113,7 @@
     "reports": "Berichte",
     "timesheets": "Stundenzettel",
     "settings": "Einstellungen",
-    "superAdmin": "Super Admin",
+    "superAdmin": "Super-Admin",
     "adminPanel": "Admin-Panel",
     "organizations": "Organisationen",
     "noOrganization": "Keine Organisation",

+ 1 - 1
messages/de/notifications.json

@@ -7,5 +7,5 @@
     "delete": "Löschen",
     "cancel": "Abbrechen"
   },
-  "openNotifications": "Open notifications"
+  "openNotifications": "Benachrichtigungen öffnen"
 }

+ 5 - 0
messages/de/onboarding.json

@@ -85,5 +85,10 @@
     "removeConfirmDescription": "Damit werden die bei der Einrichtung erstellten Beispielkunden, Fahrzeuge, Aufträge, das Angebot und die Inspektion gelöscht. Ihre eigenen Daten bleiben unberührt.",
     "removed": "Beispieldaten entfernt",
     "removeFailed": "Beispieldaten konnten nicht entfernt werden"
+  },
+  "singleWorkshop": {
+    "title": "Diese Installation hat bereits ihre Werkstatt",
+    "description": "Torqvoice betreibt hier eine Werkstatt, und sie ist bereits eingerichtet. Um darin zu arbeiten, bitten Sie den Inhaber, {email} unter Einstellungen, dann Team einzuladen.",
+    "signOut": "Abmelden"
   }
 }

+ 54 - 8
messages/de/pdf.json

@@ -8,6 +8,7 @@
     "vehicleMarine": "Wasserfahrzeug",
     "vin": "VIN: {vin}",
     "vinMarine": "HIN: {vin}",
+    "typeKey": "HSN/TSN: {typeKey}",
     "plate": "Kennzeichen: {plate}",
     "plateMarine": "Registrierungsnummer: {plate}",
     "mileage": "Kilometerstand: {mileage}",
@@ -32,8 +33,8 @@
     "total": "Gesamt",
     "labor": "Arbeitsleistung",
     "hours": "Stunden",
-    "hrs": "hrs",
-    "unit": "unit",
+    "hrs": "Std.",
+    "unit": "Einh.",
     "rate": "Stundensatz",
     "ratePerHour": "{rate}/Std.",
     "subtotal": "Zwischensumme",
@@ -59,6 +60,9 @@
     "warrantyExpires": "Läuft ab",
     "warrantyNotes": "Bedingungen",
     "warrantyMonthsUnit": "Monate",
+    "warrantyNotIncluded": "Keine Werkstattgarantie enthalten",
+    "warrantyStatutoryRights": "Ihre gesetzlichen Rechte bleiben unberührt.",
+    "warrantyIncluded": "Enthalten",
     "diagnosticNotes": "Diagnosehinweise",
     "findings": "Beobachtungen",
     "findingsDescription": "Die folgenden Punkte wurden bei diesem Service festgestellt und erfordern möglicherweise Aufmerksamkeit.",
@@ -75,7 +79,7 @@
     "professionalWorkshop": "Professionelle Werkstattverwaltung",
     "tel": "Tel: {phone}",
     "org": "Org: {org}",
-    "qtyOrHours": "Unit / Hours",
+    "qtyOrHours": "Einheit / Stunden",
     "customFieldsTitle": "Zusätzliche Informationen",
     "telegramConnect": "Chatten Sie mit uns auf Telegram",
     "taxRegistrationLabel": "{name}-Nr."
@@ -86,6 +90,7 @@
     "customerTaxId": "USt-IdNr.",
     "vehicle": "Fahrzeug",
     "vin": "VIN: {vin}",
+    "typeKey": "HSN/TSN: {typeKey}",
     "plate": "Kennzeichen: {plate}",
     "quoteDetails": "Angebotsdetails",
     "parts": "Teile",
@@ -96,8 +101,8 @@
     "total": "Gesamt",
     "labor": "Arbeitsleistung",
     "hours": "Stunden",
-    "hrs": "hrs",
-    "unit": "unit",
+    "hrs": "Std.",
+    "unit": "Einh.",
     "rate": "Stundensatz",
     "ratePerHour": "{rate}/Std.",
     "subtotal": "Zwischensumme",
@@ -115,7 +120,7 @@
     "validityFooter30": "Dieses Angebot ist 30 Tage gültig",
     "viewPortal": "Ihr Portal ansehen: {url}",
     "tel": "Tel: {phone}",
-    "qtyOrHours": "Unit / Hours",
+    "qtyOrHours": "Einheit / Stunden",
     "customFieldsTitle": "Zusätzliche Informationen",
     "quoteNumberLabel": "Angebotsnr.",
     "validUntilLabel": "Gültig bis"
@@ -127,6 +132,7 @@
     "vehicleMarine": "Wasserfahrzeug",
     "vin": "VIN: {vin}",
     "vinMarine": "HIN: {vin}",
+    "typeKey": "HSN/TSN: {typeKey}",
     "plate": "Kennzeichen: {plate}",
     "plateMarine": "Registrierungsnummer: {plate}",
     "mileage": "Kilometerstand: {mileage}",
@@ -168,6 +174,8 @@
     "vehicleCategory": "Fahrzeugklasse",
     "nextTestDue": "Nächste Prüfung fällig",
     "limit": "Grenzwert",
+    "rangeMin": "mind.",
+    "rangeMax": "max.",
     "overallResult": "Prüfergebnis",
     "deficiencies": "Festgestellte Mängel",
     "reading": "Messwert",
@@ -176,7 +184,14 @@
     "resultDetailPassMinor": "Das Fahrzeug hat bestanden. Beheben Sie die geringen Mängel ohne unnötige Verzögerung.",
     "resultDetailFail": "Das Fahrzeug hat nicht bestanden. Die Mängel müssen behoben und das Fahrzeug erneut geprüft werden.",
     "resultDetailFailDangerous": "Das Fahrzeug darf bis zur Behebung der gefährlichen Mängel nicht im öffentlichen Straßenverkehr genutzt werden.",
-    "resultDetailIncomplete": "Diese Prüfung läuft noch."
+    "resultDetailIncomplete": "Diese Prüfung läuft noch.",
+    "attachments": "Fotos und Dokumente",
+    "attachedDocuments": "Beigefügte Dokumente: {names}",
+    "euNotApplicable": "Nicht zutreffend",
+    "basicNotApplicable": "Nicht zutreffend",
+    "allResults": "Alle Ergebnisse",
+    "signature": "Unterschrift",
+    "sectionColumn": "Abschnitt"
   },
   "serviceHistory": {
     "serviceHistoryTitle": "SERVICEHISTORIE",
@@ -193,6 +208,37 @@
     "mileageColMarine": "Motorstunden"
   },
   "common": {
-    "poweredBy": "Bereitgestellt von"
+    "poweredBy": "Bereitgestellt von",
+    "signature": "Unterschrift",
+    "signedBy": "Unterzeichnet von",
+    "signatureDate": "Datum",
+    "conditionMapTitle": "Fahrzeugzustand",
+    "conditionMapNo": "Nr.",
+    "conditionMapArea": "Bereich",
+    "conditionMapKind": "Art",
+    "conditionMapSeverity": "Schwere",
+    "conditionMapNote": "Notiz"
+  },
+  "workOrder": {
+    "title": "ARBEITSAUFTRAG",
+    "orderNumberLabel": "Auftrags-Nr.",
+    "customer": "Kunde",
+    "plateLabel": "Kennzeichen",
+    "jobDetails": "Auftragsdetails",
+    "status": "Status: {status}",
+    "technician": "Techniker: {tech}",
+    "scheduled": "Geplant: {date}",
+    "promised": "Zugesagt: {date}",
+    "workBay": "Platz: {bay}",
+    "statusPending": "Offen",
+    "statusInProgress": "In Arbeit",
+    "statusWaitingParts": "Wartet auf Teile",
+    "statusCompleted": "Abgeschlossen",
+    "concerns": "Kundenanliegen",
+    "workRequested": "Gewünschte Arbeiten",
+    "checklist": "Arbeiten",
+    "scanToOpen": "Scannen, um diesen Auftrag zu öffnen",
+    "customerSignature": "Unterschrift Kunde",
+    "printedOn": "Gedruckt am {date}"
   }
 }

+ 3 - 1
messages/de/portal.json

@@ -103,6 +103,7 @@
     "payment": "Zahlung",
     "view": "Ansehen",
     "download": "Herunterladen",
+    "downloadCertificate": "Prüfbericht herunterladen",
     "paid": "bezahlt",
     "partial": "teilweise",
     "unpaid": "unbezahlt",
@@ -127,7 +128,7 @@
     "noInspections": "Noch keine Inspektionen.",
     "failedToLoad": "Inspektionen konnten nicht geladen werden.",
     "vehicle": "Fahrzeug",
-    "template": "Vorlage",
+    "template": "Checkliste",
     "status": "Status",
     "date": "Datum",
     "summary": "Zusammenfassung",
@@ -143,6 +144,7 @@
     "failedToLoad": "Fahrzeuge konnten nicht geladen werden.",
     "plate": "Kennzeichen: {plate}",
     "vin": "VIN: {vin}",
+    "typeKey": "HSN/TSN: {typeKey}",
     "serviceCount": "{count, plural, one {# Service} other {# Services}}",
     "inspectionCount": "{count, plural, one {# Inspektion} other {# Inspektionen}}",
     "serviceHistory": "Serviceverlauf ({count})",

+ 26 - 18
messages/de/quotes.json

@@ -1,6 +1,7 @@
 {
   "list": {
     "statusAll": "Alle",
+    "statusAttention": "Erfordert Aufmerksamkeit",
     "statusDraft": "Entwurf",
     "statusSent": "Gesendet",
     "statusAccepted": "Akzeptiert",
@@ -73,8 +74,8 @@
     "unitPrice": "Stückpreis",
     "total": "Gesamt",
     "subtotal": "Teile Zwischensumme: {amount}",
-    "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total",
+    "deleteRow": "Zeile löschen",
+    "excludeFromTotal": "Von der Summe ausnehmen",
     "addFromInventory": "Aus Lager",
     "unitCost": "Kosten",
     "markupPercent": "Aufschlag %",
@@ -90,17 +91,15 @@
     "rate": "Satz ({currency}/Std.)",
     "total": "Gesamt",
     "subtotal": "Arbeitszeit Zwischensumme: {amount}",
-    "addService": "Add Service",
-    "qty": "Qty",
-    "qtyOrHours": "Unit / Hours",
+    "addService": "Leistung hinzufügen",
+    "qty": "Menge",
+    "qtyOrHours": "Einheit / Stunden",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
-    "switchToHourly": "Switch to hourly pricing",
-    "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Stundenbasiert: nach geleisteten Stunden berechnet (Stunden × Satz/Std.). Zum Wechseln klicken.",
-    "switchToServiceHint": "Pauschale: als Festpreis pro Einheit berechnet. Zum Wechseln klicken.",
-    "deleteRow": "Delete row",
-    "excludeFromTotal": "Exclude from total"
+    "hourlyHint": "Stundenbasiert: nach geleisteten Stunden berechnet (Stunden × Satz/Std.). Zum Wechseln klicken.",
+    "serviceHint": "Pauschale: als Festpreis pro Einheit berechnet. Zum Wechseln klicken.",
+    "deleteRow": "Zeile löschen",
+    "excludeFromTotal": "Von der Summe ausnehmen"
   },
   "notes": {
     "title": "Notizen",
@@ -138,8 +137,8 @@
     "selectVehicle": "Fahrzeug auswählen...",
     "selectCustomer": "Kunden auswählen...",
     "none": "Keine",
-    "clearVehicle": "Clear vehicle",
-    "clearCustomer": "Clear customer"
+    "clearVehicle": "Fahrzeug entfernen",
+    "clearCustomer": "Kunde entfernen"
   },
   "totals": {
     "title": "Summen",
@@ -171,7 +170,7 @@
     "failedEmail": "E-Mail konnte nicht gesendet werden",
     "smsSent": "SMS gesendet",
     "failedSms": "SMS konnte nicht gesendet werden",
-    "copyLink": "Copy link"
+    "copyLink": "Link kopieren"
   },
   "images": {
     "title": "Angebotsbilder",
@@ -186,8 +185,8 @@
     "uploadingCount": "{count} Bild(er) werden hochgeladen...",
     "uploadedCount": "{count} Bild(er) hochgeladen",
     "uploadFailed": "Hochladen fehlgeschlagen",
-    "failedUploadFile": "Failed to upload {name}",
-    "failedSaveFile": "Failed to save {name}",
+    "failedUploadFile": "{name} konnte nicht hochgeladen werden",
+    "failedSaveFile": "{name} konnte nicht gespeichert werden",
     "deleted": "Bild gelöscht",
     "failedDelete": "Bild konnte nicht gelöscht werden",
     "failedUpdate": "Aktualisierung fehlgeschlagen",
@@ -206,11 +205,20 @@
     "uploadingCount": "{count} Datei(en) werden hochgeladen...",
     "uploadedCount": "{count} Datei(en) hochgeladen",
     "uploadFailed": "Hochladen fehlgeschlagen",
-    "failedUploadFile": "Failed to upload {name}",
-    "failedSaveFile": "Failed to save {name}",
+    "failedUploadFile": "{name} konnte nicht hochgeladen werden",
+    "failedSaveFile": "{name} konnte nicht gespeichert werden",
     "deleted": "Datei gelöscht",
     "failedDelete": "Datei konnte nicht gelöscht werden",
     "failedUpdate": "Aktualisierung fehlgeschlagen",
     "pdf": "PDF"
+  },
+  "statusLabels": {
+    "draft": "Entwurf",
+    "sent": "Gesendet",
+    "accepted": "Akzeptiert",
+    "rejected": "Abgelehnt",
+    "expired": "Abgelaufen",
+    "converted": "Umgewandelt",
+    "changes_requested": "Änderungen angefordert"
   }
 }

+ 4 - 0
messages/de/realtime.json

@@ -0,0 +1,4 @@
+{
+  "alsoHere": "{count, plural, one {Eine weitere Person hat das geöffnet} other {# weitere Personen haben das geöffnet}}",
+  "alsoHereName": "{name} hat das ebenfalls geöffnet"
+}

+ 75 - 2
messages/de/reminders.json

@@ -8,10 +8,83 @@
   "searchVehicle": "Fahrzeuge suchen...",
   "noVehicle": "Keine Fahrzeuge gefunden.",
   "pickDate": "Datum auswählen",
-  "openMenu": "Open menu",
+  "openMenu": "Menü öffnen",
   "relatesTo": "Bezieht sich auf (optional)",
   "selectCustomer": "Kunde (optional)",
   "noneOption": "Keiner",
   "workshopHint": "Kein Kunde oder Fahrzeug gewählt: dies ist eine allgemeine Werkstatt-Erinnerung.",
-  "workshopReminder": "Werkstatt"
+  "workshopReminder": "Werkstatt",
+  "import": {
+    "button": "Importieren",
+    "title": "Erinnerungen importieren",
+    "description": "Laden Sie eine CSV- oder Excel-Datei mit einer Erinnerung pro Zeile hoch. Sie sehen jede Zeile, bevor etwas gespeichert wird.",
+    "drop": "CSV- oder Excel-Datei hier ablegen",
+    "formats": "CSV oder Excel (.xlsx), bis zu {rows} Zeilen",
+    "browse": "Datei auswählen",
+    "reading": "Datei wird gelesen...",
+    "templateHint": "Beginnen Sie mit der Vorlage, damit die Spalten stimmen.",
+    "templateXlsx": "Excel-Vorlage",
+    "templateCsv": "CSV-Vorlage",
+    "columnsTitle": "Spalten in der Datei",
+    "required": {
+      "yes": "Pflichtfeld",
+      "dateOrMileage": "Datum oder Kilometerstand",
+      "no": "Optional"
+    },
+    "columns": {
+      "title": "Was zu tun ist, zum Beispiel „Ölwechsel“.",
+      "dueDate": "Der Tag der Fälligkeit. 2026-10-15 funktioniert immer, Formate wie 15.10.2026 oder 10/15/2026 werden erkannt.",
+      "dueTime": "Uhrzeit, zum Beispiel 14:30, in der Zeitzone der Werkstatt. Leer lassen für eine beliebige Uhrzeit an diesem Tag.",
+      "dueMileage": "Der Tachostand, bei dem die Erinnerung fällig ist, in {unit}.",
+      "description": "Weitere Details.",
+      "licensePlate": "Verknüpft die Erinnerung mit einem Fahrzeug. Leerzeichen und Bindestriche werden ignoriert.",
+      "vin": "Verknüpft die Erinnerung mit einem Fahrzeug. Praktisch, wenn kein Kennzeichen vorhanden ist.",
+      "customer": "E-Mail, Kundennummer oder exakter Name. Wird nur verwendet, wenn die Zeile kein Fahrzeug hat.",
+      "emailNotification": "Ja oder nein. Sendet eine E-Mail, wenn die Erinnerung fällig ist. In-App-Benachrichtigungen sind immer aktiv."
+    },
+    "targetRule": "Eine Zeile ohne Fahrzeug und ohne Kunde wird zur Werkstatt-Erinnerung. Zeilen, die einer offenen Erinnerung entsprechen, werden übersprungen. Dieselbe Datei zweimal hochzuladen fügt also nichts hinzu.",
+    "summary": {
+      "ready": "Bereit: {count}",
+      "duplicates": "Bereits vorhanden: {count}",
+      "errors": "Zu korrigieren: {count}"
+    },
+    "ignoredColumns": "Nicht gelesene Spalten: {columns}",
+    "rowLabel": "Zeile {line}",
+    "untitled": "(ohne Titel)",
+    "issues": {
+      "missingTitle": "Der Titel ist leer.",
+      "missingDue": "Benötigt ein Fälligkeitsdatum oder einen Fälligkeitskilometerstand.",
+      "badDate": "Das Datum „{value}“ konnte nicht gelesen werden.",
+      "badTime": "Die Uhrzeit „{value}“ konnte nicht gelesen werden.",
+      "timeWithoutDate": "Eine Uhrzeit braucht ein Fälligkeitsdatum.",
+      "badMileage": "Der Kilometerstand „{value}“ konnte nicht gelesen werden.",
+      "badEmailNotification": "E-Mail-Benachrichtigung muss ja oder nein sein, nicht „{value}“.",
+      "vehicleNotFound": "Kein Fahrzeug passt zu „{value}“.",
+      "vehicleAmbiguous": "Mehr als ein Fahrzeug passt zu „{value}“.",
+      "vehicleMismatch": "Kennzeichen und FIN gehören zu verschiedenen Fahrzeugen ({value}).",
+      "customerNotFound": "Kein Kunde passt zu „{value}“.",
+      "customerAmbiguous": "Mehr als ein Kunde passt zu „{value}“. Verwenden Sie die E-Mail-Adresse oder die Kundennummer.",
+      "duplicateExisting": "Übersprungen: Diese Erinnerung existiert bereits.",
+      "duplicateInFile": "Übersprungen: wiederholt eine frühere Zeile der Datei."
+    },
+    "fileErrors": {
+      "demo": "Der Import ist in der Demo deaktiviert.",
+      "noFile": "Wählen Sie eine Datei zum Hochladen.",
+      "tooLarge": "Die Datei ist größer als 900 KB. Teilen Sie sie auf und importieren Sie sie in Teilen.",
+      "unsupported": "Laden Sie eine .csv- oder .xlsx-Datei hoch.",
+      "legacyXls": "Alte Excel-Dateien im Format .xls können nicht gelesen werden. Speichern Sie die Datei als .xlsx oder .csv und versuchen Sie es erneut.",
+      "empty": "Die Datei enthält keine Zeilen.",
+      "unreadable": "Die Datei konnte nicht gelesen werden.",
+      "tooManyRows": "Die Datei hat mehr als {limit} Zeilen. Teilen Sie sie auf und importieren Sie sie in Teilen.",
+      "noTitleColumn": "Die Datei hat keine Spalte „Title“. Laden Sie die Vorlage herunter, um die erwarteten Spaltenüberschriften zu sehen."
+    },
+    "chooseAnother": "Andere Datei wählen",
+    "downloadSkipped": "Übersprungene Zeilen herunterladen",
+    "problemColumn": "Problem",
+    "submit": "{count, plural, one {# Erinnerung} other {# Erinnerungen}} importieren",
+    "nothingToImport": "Nichts zu importieren",
+    "done": "{count, plural, one {# Erinnerung importiert} other {# Erinnerungen importiert}}",
+    "doneSkipped": "{count, plural, one {# Zeile übersprungen} other {# Zeilen übersprungen}}",
+    "close": "Schließen"
+  }
 }

+ 83 - 81
messages/de/reports.json

@@ -329,94 +329,94 @@
     "jobsClocked": "Erfasste Aufträge"
   },
   "pdf": {
-    "reportTitle": "Business Report",
-    "revenueSection": "Revenue Report",
-    "taxSection": "Tax Report",
-    "pastDueSection": "Past Due Invoices",
-    "servicesSection": "Services Report",
-    "customersSection": "Customers Report",
-    "techniciansSection": "Technicians Report",
-    "partsSection": "Parts Usage Report",
-    "jobAnalyticsSection": "Job Analytics",
-    "retentionSection": "Customer Retention",
-    "inventorySection": "Inventory Report",
+    "reportTitle": "Geschäftsbericht",
+    "revenueSection": "Umsatzbericht",
+    "taxSection": "Steuerbericht",
+    "pastDueSection": "Überfällige Rechnungen",
+    "servicesSection": "Dienstleistungsbericht",
+    "customersSection": "Kundenbericht",
+    "techniciansSection": "Technikerbericht",
+    "partsSection": "Bericht zum Teileverbrauch",
+    "jobAnalyticsSection": "Auftragsanalyse",
+    "retentionSection": "Kundenbindung",
+    "inventorySection": "Lagerbestandsbericht",
     "monthlyBreakdown": "Monatliche Übersicht",
-    "revenueByType": "Revenue by Type",
-    "taxByRate": "Tax by Rate",
-    "servicesByStatus": "By Status",
-    "servicesByType": "By Type",
-    "topCustomers": "Top Customers",
-    "topServiceTypes": "Top Service Types",
-    "topReturning": "Top Returning Customers",
-    "dayOfWeek": "Jobs by Day of Week",
-    "monthlyTrend": "Monthly Trend",
-    "lowStock": "Low Stock Items",
-    "revenue": "Revenue",
-    "collected": "Collected",
-    "outstanding": "Outstanding",
-    "services": "Services",
-    "partsCost": "Parts Cost",
+    "revenueByType": "Umsatz nach Typ",
+    "taxByRate": "Steuer nach Satz",
+    "servicesByStatus": "Nach Status",
+    "servicesByType": "Nach Typ",
+    "topCustomers": "Top-Kunden",
+    "topServiceTypes": "Häufigste Dienstleistungstypen",
+    "topReturning": "Top-Stammkunden",
+    "dayOfWeek": "Aufträge nach Wochentag",
+    "monthlyTrend": "Monatlicher Verlauf",
+    "lowStock": "Artikel mit niedrigem Bestand",
+    "revenue": "Umsatz",
+    "collected": "Eingezogen",
+    "outstanding": "Ausstehend",
+    "services": "Dienstleistungen",
+    "partsCost": "Teilekosten",
     "partsNetProfit": "Teile-Nettogewinn",
     "laborRevenue": "Arbeit",
-    "netProfit": "Net Profit",
-    "month": "Month",
-    "count": "Count",
-    "type": "Type",
+    "netProfit": "Nettogewinn",
+    "month": "Monat",
+    "count": "Anzahl",
+    "type": "Typ",
     "status": "Status",
     "name": "Name",
-    "company": "Company",
-    "totalSpent": "Total Spent",
-    "customer": "Customer",
-    "invoiceNumber": "Invoice #",
-    "totalAmount": "Total Amount",
-    "amountDue": "Amount Due",
-    "dueDate": "Due Date",
-    "daysPastDue": "Days Past Due",
-    "totalPastDue": "Total Past Due",
-    "totalAmountDue": "Total Amount Due",
-    "over30": "Over 30 Days",
-    "over60": "Over 60 Days",
-    "over90": "Over 90 Days",
-    "taxCollected": "Tax Collected",
-    "taxableAmount": "Taxable Amount",
-    "taxRate": "Tax Rate",
-    "invoiceCount": "Invoice Count",
-    "invoices": "Invoices",
-    "totalServices": "Total Services",
-    "totalCustomers": "Total Customers",
-    "activeCustomers": "Active Customers",
-    "technician": "Technician",
-    "jobs": "Jobs",
-    "totalRevenue": "Total Revenue",
-    "avgRevenue": "Avg Revenue",
-    "totalHours": "Total Hours",
-    "avgHours": "Avg Hours",
-    "totalJobs": "Total Jobs",
-    "partName": "Part Name",
-    "partNumber": "Part #",
-    "usageCount": "Usage Count",
-    "totalQty": "Total Qty",
-    "totalPartsRevenue": "Parts Revenue",
+    "company": "Unternehmen",
+    "totalSpent": "Gesamtausgaben",
+    "customer": "Kunde",
+    "invoiceNumber": "Rechnungsnr.",
+    "totalAmount": "Gesamtbetrag",
+    "amountDue": "Fälliger Betrag",
+    "dueDate": "Fälligkeitsdatum",
+    "daysPastDue": "Tage überfällig",
+    "totalPastDue": "Gesamt überfällig",
+    "totalAmountDue": "Gesamtbetrag fällig",
+    "over30": "Über 30 Tage",
+    "over60": "Über 60 Tage",
+    "over90": "Über 90 Tage",
+    "taxCollected": "Eingenommene Steuer",
+    "taxableAmount": "Steuerpflichtiger Betrag",
+    "taxRate": "Steuersatz",
+    "invoiceCount": "Anzahl Rechnungen",
+    "invoices": "Rechnungen",
+    "totalServices": "Dienstleistungen gesamt",
+    "totalCustomers": "Kunden gesamt",
+    "activeCustomers": "Aktive Kunden",
+    "technician": "Techniker",
+    "jobs": "Aufträge",
+    "totalRevenue": "Gesamtumsatz",
+    "avgRevenue": "Ø Umsatz",
+    "totalHours": "Stunden gesamt",
+    "avgHours": "Ø Stunden",
+    "totalJobs": "Aufträge gesamt",
+    "partName": "Teilename",
+    "partNumber": "Teilenr.",
+    "usageCount": "Verwendungen",
+    "totalQty": "Gesamtmenge",
+    "totalPartsRevenue": "Teileumsatz",
     "totalPartsCost": "Teilekosten",
     "totalPartsNetProfit": "Nettogewinn",
-    "totalPartsUsed": "Parts Used",
-    "serviceType": "Service Type",
-    "avgValue": "Avg Value",
-    "avgJobValue": "Avg Job Value",
-    "returningCustomers": "Returning",
+    "totalPartsUsed": "Verwendete Teile",
+    "serviceType": "Dienstleistungstyp",
+    "avgValue": "Ø Wert",
+    "avgJobValue": "Ø Auftragswert",
+    "returningCustomers": "Wiederkehrend",
     "newCustomers": "New",
-    "totalActive": "Total Active",
-    "avgTimeBetweenVisits": "Avg Days Between Visits",
-    "avgDaysBetweenVisits": "Avg Days",
-    "visits": "Visits",
-    "days": "days",
-    "totalParts": "Total Parts",
-    "totalItems": "Total Items",
-    "totalValue": "Total Value",
-    "totalSellValue": "Sell Value",
+    "totalActive": "Aktiv gesamt",
+    "avgTimeBetweenVisits": "Ø Tage zwischen Besuchen",
+    "avgDaysBetweenVisits": "Ø Tage",
+    "visits": "Besuche",
+    "days": "Tage",
+    "totalParts": "Teile gesamt",
+    "totalItems": "Artikel gesamt",
+    "totalValue": "Gesamtwert",
+    "totalSellValue": "Verkaufswert",
     "quantity": "Qty",
     "minQuantity": "Min Qty",
-    "unitCost": "Unit Cost",
+    "unitCost": "Stückkosten",
     "vehiclesSection": "Fahrzeugbericht",
     "vehicleLabel": "Fahrzeug",
     "totalCost": "Gesamtkosten",
@@ -427,7 +427,9 @@
   },
   "empty": "Noch keine Daten geladen. Klicken Sie auf Aktualisieren, um den Bericht zu laden.",
   "pagination": {
-    "previousPage": "Previous page",
-    "nextPage": "Next page"
-  }
+    "previousPage": "Vorherige Seite",
+    "nextPage": "Nächste Seite"
+  },
+  "completedOnly": "Nur abgeschlossene",
+  "completedOnlyHint": "Nur als abgeschlossen markierte Aufträge anzeigen. Wird für das nächste Mal gespeichert."
 }

+ 302 - 28
messages/de/service.json

@@ -21,7 +21,8 @@
       "title": "Der Auftrag braucht einen Titel, bevor er gespeichert werden kann.",
       "negative": "Mengen, Stunden, Preise und Kosten dürfen nicht negativ sein.",
       "partName": "Jedes Teil mit Preis braucht einen Namen.",
-      "laborDescription": "Jede Arbeitszeile mit Stunden oder Satz braucht eine Beschreibung."
+      "laborDescription": "Jede Arbeitszeile mit Stunden oder Satz braucht eine Beschreibung.",
+      "concernCondition": "Ein Anliegen mit Ursache, Behebung oder Kontrolle braucht eine ausgefüllte Beanstandung."
     }
   },
   "header": {
@@ -41,7 +42,27 @@
     "unpaid": "Unbezahlt",
     "unsavedChanges": "Nicht gespeicherte Änderungen",
     "saved": "Gespeichert",
-    "joinCall": "Videoanruf beitreten"
+    "joinCall": "Videoanruf beitreten",
+    "moreActions": "Weitere Aktionen",
+    "designMenu": "Rechnungsdesign",
+    "designMenuLoading": "Prüfe, mit welchem Design diese Rechnung gedruckt wird ...",
+    "designMenuExplain": "Am {date} an den Kunden gegangen, daher wird sie weiterhin mit dem damaligen Design gedruckt. Ein anderes zu wählen ändert nur das Aussehen. Was die Rechnung sagt, bleibt wie versendet, und das alte Aussehen wird nicht aufbewahrt.",
+    "designSentWith": "Das Design beim Versand",
+    "designFollow": "Aktueller Standard",
+    "designFollowNamed": "Aktueller Standard ({name})",
+    "designDefaultName": "den aktuellen Standard",
+    "designPickConfirmTitle": "Diese Rechnung auf {name} umstellen?",
+    "designPickConfirmBody": "Nur das Aussehen ändert sich. Werkstattangaben, Kunde, Zahlungsbedingungen und Beträge bleiben genau so, wie sie versendet wurden. Das jetzige Design wird nicht aufbewahrt, das lässt sich also nicht rückgängig machen.",
+    "designPickConfirm": "Design ändern",
+    "designPickDone": "Die Rechnung wird jetzt mit {name} gedruckt",
+    "designPickFailed": "Design konnte nicht geändert werden",
+    "designRefresh": "Auf aktuelle Angaben aktualisieren",
+    "designRefreshConfirmTitle": "Diese Rechnung auf die aktuellen Angaben aktualisieren?",
+    "designRefreshConfirmBody": "Die Werkstattangaben, der Kunde, das Fahrzeug, die Zahlungsbedingungen und das Design werden neu eingelesen, wie sie heute sind, und die Rechnung wird darauf festgeschrieben. Ihre Positionen und Summen gehören zur Rechnung selbst und ändern sich nicht. Bereits heruntergeladene oder per E-Mail gesendete Kopien bleiben, wie sie waren; der Freigabelink und das Portal zeigen die aktualisierte Rechnung. Dies kann nicht rückgängig gemacht werden.",
+    "designRefreshConfirm": "Rechnung aktualisieren",
+    "designRefreshDone": "Diese Rechnung wird jetzt mit den aktuellen Angaben gedruckt",
+    "designRefreshFailed": "Rechnung konnte nicht aktualisiert werden",
+    "printWorkOrder": "Arbeitsauftrag drucken"
   },
   "basicInfo": {
     "title": "Grundlegende Informationen",
@@ -89,7 +110,6 @@
     "design": "Rechnungsdesign",
     "designDefault": "Werkstatt-Standard",
     "designFollowing": "Standard ({name})",
-    "designPinned": "Das Blatt wurde beim Ausstellen der Rechnung am {date} eingefroren. Eine neue Wahl gilt erst, wenn ein Inhaber die Rechnung entsperrt und sie erneut gesendet wird.",
     "designSaved": "Rechnungsdesign aktualisiert",
     "designFollowingRule": "Standard ({name}, {rule})",
     "designRule": {
@@ -122,7 +142,7 @@
     "scanTitle": "Barcode scannen",
     "partFound": "\"{name}\" aus dem Lager hinzugefügt",
     "partNotFound": "Kein Teil für Barcode \"{barcode}\" gefunden",
-    "deleteRow": "Delete row",
+    "deleteRow": "Zeile löschen",
     "outOfStock": "Nicht auf Lager",
     "onBackorder": "Im Rückstand ({count})",
     "nameMissingHint": "Zeilen ohne Teilenamen werden nicht gespeichert."
@@ -137,20 +157,27 @@
     "total": "Gesamt",
     "descriptionPlaceholder": "Beschreibung *",
     "subtotal": "Arbeitssumme: {amount}",
-    "addService": "Add Service",
+    "addService": "Leistung hinzufügen",
     "addFinding": "Beobachtung",
     "newObservation": "Neue Beobachtung",
     "addExisting": "Vorhandene anzeigen ({count})",
-    "qty": "Qty",
-    "qtyOrHours": "Unit / Hours",
+    "qty": "Menge",
+    "qtyOrHours": "Einheit / Stunden",
     "hourlyTag": "HR",
     "serviceTag": "SVC",
-    "switchToHourly": "Switch to hourly pricing",
-    "switchToService": "Switch to service pricing",
-    "switchToHourlyHint": "Stundenbasiert: nach geleisteten Stunden berechnet (Stunden × Satz/Std.). Zum Wechseln klicken.",
-    "switchToServiceHint": "Pauschale: als Festpreis pro Einheit berechnet. Zum Wechseln klicken.",
-    "deleteRow": "Delete row",
-    "descriptionMissingHint": "Zeilen ohne Beschreibung werden nicht gespeichert."
+    "hourlyHint": "Stundenbasiert: nach geleisteten Stunden berechnet (Stunden × Satz/Std.). Zum Wechseln klicken.",
+    "serviceHint": "Pauschale: als Festpreis pro Einheit berechnet. Zum Wechseln klicken.",
+    "deleteRow": "Zeile löschen",
+    "descriptionMissingHint": "Zeilen ohne Beschreibung werden nicht gespeichert.",
+    "shopFeeTag": "Pauschale",
+    "shopFeeHintFlat": "Die Werkstattpauschale aus den Einstellungen als fester Betrag. Ändern Sie den Preis für diesen Auftrag oder löschen Sie die Position.",
+    "shopFeeHintPercentLabor": "Die Werkstattpauschale aus den Einstellungen: {percent} % der Arbeit dieses Auftrags. Löschen Sie die Position, um sie wegzulassen.",
+    "shopFeeHintPercentAll": "Die Werkstattpauschale aus den Einstellungen: {percent} % von Arbeit und Teilen dieses Auftrags. Löschen Sie die Position, um sie wegzulassen."
+  },
+  "liveLabor": {
+    "title": "{count, plural, one {Ein Techniker hat Arbeit zu diesem Auftrag hinzugefügt} other {Ein Techniker hat # Arbeitspositionen zu diesem Auftrag hinzugefügt}}",
+    "body": "Sie bleibt beim Bearbeiten aus der Liste. Beim nächsten Speichern wird sie ohnehin übernommen.",
+    "show": "Anzeigen"
   },
   "totals": {
     "title": "Summen",
@@ -186,7 +213,9 @@
     "aiOverwriteReplace": "Ersetzen",
     "aiOverwriteAppend": "Anhängen",
     "aiOverwriteCancel": "Abbrechen",
-    "aiDontShowAgain": "Nicht mehr anzeigen"
+    "aiDontShowAgain": "Nicht mehr anzeigen",
+    "internalTitle": "Interne Notizen",
+    "publicTitle": "Kundennotizen"
   },
   "invoice": {
     "title": "Rechnungszusammenfassung",
@@ -236,7 +265,10 @@
     "failedDelete": "Zahlung konnte nicht gelöscht werden",
     "deleteTitle": "Zahlung löschen",
     "deleteDescription": "Dies entfernt diesen Zahlungseintrag.",
-    "failedPdf": "PDF-Rechnung konnte nicht erstellt werden"
+    "failedPdf": "PDF-Rechnung konnte nicht erstellt werden",
+    "received": "Erhaltene Zahlungen",
+    "noneYet": "Noch nichts bezahlt.",
+    "balanceDue": "Ausstehend"
   },
   "attachments": {
     "deleteTitle": "Anlage löschen",
@@ -247,8 +279,8 @@
     "videos": "Videos ({count})",
     "diagnostics": "Diagnoseberichte ({count})",
     "documents": "Dokumente ({count})",
-    "download": "Download",
-    "close": "Close"
+    "download": "Herunterladen",
+    "close": "Schließen"
   },
   "share": {
     "title": "Rechnung teilen",
@@ -265,7 +297,7 @@
     "failedEmail": "E-Mail konnte nicht gesendet werden",
     "smsSent": "SMS gesendet",
     "failedSms": "SMS konnte nicht gesendet werden",
-    "copyLink": "Copy link"
+    "copyLink": "Link kopieren"
   },
   "sidebar": {
     "vehicle": "Fahrzeug",
@@ -345,6 +377,7 @@
     "date": "Datum",
     "startTime": "Startzeit",
     "endTime": "Endzeit",
+    "durationPresets": "Dauer",
     "technician": "Techniker",
     "selectTechnician": "Techniker auswählen...",
     "searchOrCreate": "Techniker suchen oder erstellen...",
@@ -357,11 +390,11 @@
     "assign": "Tafel zuweisen",
     "assigned": "Der Arbeitstafel zugewiesen",
     "failedAssign": "Zuweisung fehlgeschlagen",
+    "assignNotAllowed": "Sie haben keine Berechtigung, Techniker zuzuweisen. Bitten Sie einen Inhaber oder Administrator, Ihrer Rolle Änderungen an der Werkstatttafel zu erlauben.",
     "endBeforeStart": "Endzeit muss nach der Startzeit liegen",
     "failedUpdate": "Zeitplan konnte nicht aktualisiert werden",
     "splitCreated": "Auftrag erstreckt sich über mehrere Tage — Fortsetzung für den nächsten Tag erstellt",
-    "platformUsers": "Platform Users",
-    "customTechnicians": "Custom",
+    "customTechnicians": "Eigene",
     "readMore": "Mehr erfahren",
     "setDefaults": "Standardwerte",
     "workBay": "Arbeitsplatz",
@@ -383,7 +416,17 @@
     "pickNextAvailable": "Nächsten freien Termin wählen",
     "findingSlot": "Termin wird gesucht…",
     "slotPicked": "Auf den nächsten freien Termin gelegt",
-    "noSlotFound": "In den nächsten Wochen kein freier Termin gefunden"
+    "noSlotFound": "In den nächsten Wochen kein freier Termin gefunden",
+    "assignedTitle": "Zugewiesener Techniker",
+    "boardLink": "Auftragsboard",
+    "someoneElse": "Jemand anderes",
+    "loadOf": "{booked} / {capacity} h",
+    "loadFree": "{hours} h frei",
+    "loadFull": "Ausgebucht",
+    "loadOn": "Gebuchte Stunden am {date}",
+    "promisedAt": "Dem Kunden zugesagt",
+    "promisedPlaceholder": "Noch nicht zugesagt",
+    "promisedClear": "Leeren"
   },
   "notifications": {
     "title": "Kundenbenachrichtigungen",
@@ -400,7 +443,6 @@
   "warranty": {
     "title": "Garantie",
     "months": "Dauer (Monate)",
-    "mileage": "Kilometergrenze",
     "notes": "Garantiebedingungen",
     "notesPlaceholder": "z.B. 12 Monate Garantie auf alle Teile und Arbeit...",
     "expiresAt": "Läuft ab",
@@ -416,19 +458,81 @@
       "expired": "Abgelaufen",
       "none": "Keine"
     },
-    "defaults": {
-      "title": "Standard-Garantieeinstellungen",
-      "description": "Standardgarantiebedingungen für neue Serviceeinträge festlegen"
-    }
+    "statementLabel": "Was der Kunde zur Garantie erfährt",
+    "statement": {
+      "none": "Keine Angabe",
+      "included": "Enthalten",
+      "not_included": "Nicht enthalten"
+    },
+    "noneHint": "Auf diesem Dokument wird nichts zur Garantie gedruckt.",
+    "mileageWithUnit": "Streckenbegrenzung ({unit})",
+    "notIncludedNotes": "Erklärung an den Kunden",
+    "notIncludedPlaceholder": "z. B. Auf vom Kunden gelieferte Teile gibt die Werkstatt keine Garantie.",
+    "printedHint": "Wird unter der Garantie auf dem Dokument gedruckt.",
+    "notIncludedHint": "Bleibt das Feld leer, steht im Dokument, dass die gesetzlichen Rechte unberührt bleiben.",
+    "summaryMonths": "{count} Mon."
   },
   "concerns": {
     "title": "Kundenanliegen",
-    "addFirst": "Kundenanliegen hinzufügen",
+    "hint": "Warum der Kunde gekommen ist, in eigenen Worten. Eine Zeile für jeden genannten Punkt.",
     "add": "Weiteres hinzufügen",
     "placeholder": "Beim Bremsen kommt hinten ein Geräusch",
     "remove": "Dieses Anliegen entfernen",
     "notAnswered": "Dazu wurde noch nichts festgestellt",
-    "answered": "{count} Feststellung(en) dazu"
+    "answered": "{count} Feststellung(en) dazu",
+    "storyTitle": "Auftragsbeschreibung",
+    "storyHint": "Beanstandung, Ursache, Behebung, Kontrolle. Klicken Sie auf einen Schritt, um ihn zu öffnen; eine Zeile für jeden Punkt, den der Kunde genannt oder Sie festgestellt haben.",
+    "steps": {
+      "condition": "Beanstandung",
+      "cause": "Ursache",
+      "correction": "Behebung",
+      "confirm": "Kontrolle"
+    },
+    "conditionPlaceholder": "Was der Kunde gemeldet hat oder was Sie festgestellt haben",
+    "causePlaceholder": "Was die Prüfung als eigentliche Ursache ergeben hat",
+    "correctionPlaceholder": "Was zur Behebung getan wurde",
+    "confirmPlaceholder": "Wie die Behebung geprüft wurde: Probefahrt, erneuter Scan, Symptom weg",
+    "markConfirmed": "Bestätigt",
+    "confirmedBy": "Bestätigt von {name}, {date}",
+    "confirmedAt": "Bestätigt am {date}",
+    "confirmedOnSave": "Wird beim Speichern mit Ihrem Namen versehen",
+    "confirmedCount": "{confirmed} von {total} bestätigt",
+    "findingsCount": "{count, plural, one {# Beobachtung} other {# Beobachtungen}}",
+    "addFinding": "Beobachtung hinzufügen",
+    "unconfirmedTitle": "Mit unbestätigten Anliegen abschließen?",
+    "unconfirmedBody": "{count, plural, one {Bei einem Anliegen ist die Behebung nicht bestätigt:} other {Bei # Anliegen ist die Behebung nicht bestätigt:}}",
+    "unconfirmedProceed": "Trotzdem abschließen",
+    "addMedia": "Medien hinzufügen",
+    "mediaUploading": "Wird hochgeladen…",
+    "mediaAdded": "{count, plural, one {# Datei hinzugefügt} other {# Dateien hinzugefügt}}",
+    "mediaFailed": "{name} konnte nicht hochgeladen werden",
+    "mediaTypes": "Wählen Sie ein Foto (JPG, PNG, WebP) oder ein Video.",
+    "dictate": {
+      "start": "Diktieren",
+      "listening": "Hört zu",
+      "startHint": "Sprechen Sie, und es wird hier aufgeschrieben. Nichts wird aufgenommen.",
+      "stopHint": "Zuhören beenden",
+      "notAllowed": "Der Browser durfte das Mikrofon nicht verwenden. Erlauben Sie es für diese Seite und versuchen Sie es erneut.",
+      "noMicrophone": "Kein Mikrofon gefunden.",
+      "failed": "Diktat abgebrochen. Versuchen Sie es erneut.",
+      "startHintServer": "Sprechen Sie, und es wird beim Reden aufgeschrieben. Der Ton geht zum Aufschreiben an Ihr Sprachmodell und wird nicht aufbewahrt.",
+      "transcribing": "Wird abgeschlossen…",
+      "language": "Gesprochene Sprache",
+      "auto": "Automatisch",
+      "nothingHeard": "Es war nichts zu hören.",
+      "fellBack": "Vorerst wird das Diktieren des Browsers verwendet.",
+      "autoLong": "Automatisch (Sprache erkennen)",
+      "engine": "Aufgeschrieben von",
+      "engineAi": "Sprachmodell (genauer)",
+      "engineBrowser": "Browser (schreibt beim Sprechen mit)",
+      "liveFellBack": "Schreibt in Schritten von wenigen Sekunden statt Wort für Wort."
+    },
+    "stepEmpty": "Noch nicht ausgefüllt",
+    "removeTitle": "Dieses Anliegen entfernen?",
+    "removeBody": "Beanstandung, Ursache, Behebung und Kontrolle werden beim Speichern des Arbeitsauftrags mit entfernt.",
+    "removeAttached": "Die zugehörigen Beobachtungen und Medien bleiben am Auftrag, nur nicht mehr unter einem Anliegen abgelegt.",
+    "removeConfirm": "Anliegen entfernen",
+    "saveFirst": "Speichern Sie zuerst den Arbeitsauftrag"
   },
   "videoCall": {
     "title": "Videoanruf",
@@ -474,5 +578,175 @@
       "joinLine": "Hier dem Videoanruf beitreten: {url}",
       "emailSubject": "Videoanruf zu deinem {vehicle}"
     }
+  },
+  "modern": {
+    "statusLabel": "Status des Arbeitsauftrags",
+    "back": "Zurück",
+    "backToClassic": "Zurück zum klassischen Layout",
+    "openCustomer": "Kunde öffnen",
+    "serviceHistory": "Servicehistorie",
+    "changeVehicle": "Fahrzeug ändern",
+    "vehicleChangesOnSave": "Der Arbeitsauftrag wechselt beim Speichern zu diesem Fahrzeug.",
+    "phone": "Telefon",
+    "email": "E-Mail",
+    "notSet": "Nicht angegeben",
+    "noCustomer": "Kein Kunde auf diesem Arbeitsauftrag.",
+    "invoiceTitle": "Rechnung & Zahlung",
+    "filesTitle": "Dateien & Medien",
+    "activity": {
+      "title": "Aktivität",
+      "messageSent": "Nachricht an den Kunden gesendet",
+      "linkShared": "Rechnungslink geteilt",
+      "linkViewed": "{count, plural, one {Kunde hat den Link geöffnet} other {Kunde hat den Link # Mal geöffnet}}",
+      "paymentTaken": "Zahlung von {amount} aufgezeichnet",
+      "statusSet": "Status auf {status} gesetzt"
+    },
+    "invite": {
+      "title": "Die Seite für Arbeitsaufträge wurde überarbeitet.",
+      "body": "Ein übersichtlicheres Layout mit denselben Feldern. Sie können jederzeit zurückwechseln.",
+      "try": "Jetzt ausprobieren",
+      "dismiss": "Nicht jetzt",
+      "menu": "Neues Layout ausprobieren"
+    },
+    "promised": "Zugesagt für {date}",
+    "promisedOverdue": "Zugesagt für {date}, überfällig",
+    "opened": "Eröffnet am {date}",
+    "openedBy": "Eröffnet am {date} von {name}",
+    "editTitle": "Titel bearbeiten",
+    "titleField": "Titel",
+    "media": {
+      "tabs": {
+        "photos": "Fotos",
+        "diagnostics": "Diagnose",
+        "dropoff": "Annahme"
+      },
+      "summary": "{count, plural, one {# Datei} other {# Dateien}} · nur markierte werden mit dem Kunden geteilt",
+      "customerCanSee": "Für Kunden sichtbar",
+      "showToCustomer": "Dem Kunden zeigen",
+      "hideFromCustomer": "Vor dem Kunden verbergen",
+      "add": {
+        "image": "Fotos hinzufügen",
+        "document": "Dokumente hinzufügen",
+        "diagnostic": "Diagnosebericht hinzufügen",
+        "video": "Video hinzufügen",
+        "dropoff": "Annahmefotos hinzufügen"
+      },
+      "dropHint": "Dateien hier ablegen oder zum Durchsuchen klicken",
+      "added": "{count, plural, one {# Datei hinzugefügt} other {# Dateien hinzugefügt}}",
+      "failed": "{name} konnte nicht hochgeladen werden",
+      "wrongType": "Nicht die richtige Dateiart für diesen Tab: {names}",
+      "limit": "Limit erreicht ({count}/{max})",
+      "dropoffHint": "Das Fahrzeug, wie es ankam, mit der Aufnahmezeit jedes Fotos. Für die Werkstatt, für den Kunden nur sichtbar, wenn Sie es wählen.",
+      "onInvoice": "Fahrzeugzustand auf dieser Rechnung ausgeben",
+      "onInvoiceHint": "Aus, solange Sie es hier nicht ankreuzen. Für alle Rechnungen schalten Sie Fahrzeugzustand im Rechnungsdesigner ein.",
+      "onInvoiceByDesign": "Im Rechnungsdesigner für alle Rechnungen eingeschaltet. Abwählen, um sie auf dieser wegzulassen.",
+      "onInvoiceFailed": "Auswahl konnte nicht gespeichert werden"
+    },
+    "moneyBar": {
+      "total": "Gesamt",
+      "paid": "Bezahlt",
+      "balance": "Ausstehend",
+      "settled": "Vollständig bezahlt",
+      "takePayment": "Zahlung annehmen",
+      "preview": "Rechnungsvorschau",
+      "previewShort": "Vorschau",
+      "send": "An Kunden senden",
+      "sendShort": "Senden"
+    },
+    "vehicleInfo": {
+      "more": "Mehr Infos",
+      "less": "Weniger Infos",
+      "copy": "{label} kopieren",
+      "copied": "Kopiert",
+      "lastInspection": "Letzte Hauptuntersuchung",
+      "purchaseDate": "Kaufdatum",
+      "purchasePrice": "Kaufpreis",
+      "overdue": "überfällig",
+      "customerSince": "Kunde seit"
+    },
+    "moreStatuses": "Weitere Status",
+    "fromInspection": "Aus Inspektion {name}, {date}",
+    "startInspection": "Inspektion starten"
+  },
+  "photoHandoff": {
+    "button": "Vom Handy hinzufügen",
+    "dialogTitle": "Fotos und Dokumente per Handy hinzufügen",
+    "dialogBody": "Fotos mit einem beliebigen Handy aufnehmen oder auswählen oder ein PDF teilen. Alles landet direkt in diesem Auftrag.",
+    "expires": "Der Code gilt bis {time}.",
+    "waiting": "Warten auf Fotos und Dokumente…",
+    "received": "{count, plural, one {# Datei empfangen} other {# Dateien empfangen}}",
+    "copyLink": "Link kopieren",
+    "linkCopied": "Link kopiert",
+    "failed": "Code konnte nicht erstellt werden. Versuchen Sie es erneut.",
+    "done": "Fertig",
+    "pageTitle": "Fotos und Dokumente hinzufügen",
+    "forJob": "Arbeitsauftrag {number}",
+    "takePhoto": "Foto aufnehmen",
+    "choosePhotos": "Fotos auswählen",
+    "hint": "Alles landet direkt im Auftrag. Machen Sie so viele Fotos, wie Sie brauchen.",
+    "uploading": "Wird hochgeladen…",
+    "added": "Hinzugefügt",
+    "notAdded": "Nicht hinzugefügt",
+    "retry": "Erneut versuchen",
+    "addedCount": "{count, plural, one {# Datei hinzugefügt} other {# Dateien hinzugefügt}}",
+    "expiredTitle": "Dieser Code ist abgelaufen",
+    "invalidTitle": "Dieser Link funktioniert nicht",
+    "errors": {
+      "expired": "Fragen Sie am Empfang nach einem neuen Code. Jeder Code gilt 30 Minuten.",
+      "invalid": "Scannen Sie den Code am Arbeitsauftrag erneut.",
+      "limit": "Dieser Auftrag hat bereits so viele Fotos, wie er aufnehmen kann.",
+      "type": "Hier können nur Fotos und PDF-Dokumente hinzugefügt werden.",
+      "tooLarge": "Diese Datei ist zu groß.",
+      "failed": "Das Foto wurde nicht hinzugefügt. Prüfen Sie die Verbindung und versuchen Sie es erneut.",
+      "codeLimit": "Mit diesem Code wurden so viele Fotos hinzugefügt, wie ein Code erlaubt. Scannen Sie am Empfang einen neuen Code."
+    },
+    "concernHeading": "Die Fotos gehören zu diesem Anliegen",
+    "howTitle": "So funktioniert es",
+    "step1": "Richten Sie die Handykamera auf den Code und öffnen Sie den Link.",
+    "step2": "Nehmen Sie Fotos auf oder wählen Sie Fotos oder PDF-Dokumente aus, die schon auf dem Handy sind.",
+    "step3": "Sie erscheinen in diesem Auftrag, sobald sie ankommen.",
+    "noSignIn": "Auf dem Handy ist keine Anmeldung nötig.",
+    "customerToo": "Mitarbeiter können es nutzen, und der Kunde auch: Lassen Sie ihn den Code scannen oder senden Sie ihm den Link.",
+    "scope": "Der Link fügt nur Fotos und Dokumente zu diesem Auftrag hinzu und funktioniert 30 Minuten lang.",
+    "scanHint": "Mit der Handykamera scannen",
+    "chooseDocument": "Dokument hochladen",
+    "privacy": "Nur die Werkstatt sieht, was Sie hier hinzufügen.",
+    "dropoff": {
+      "button": "Annahmefotos",
+      "dialogTitle": "Das Fahrzeug bei der Annahme fotografieren",
+      "dialogBody": "Ein Rundgang ums Fahrzeug vor Arbeitsbeginn: vier Seiten, Kilometerstand und vorhandene Schäden. Das ist Ihr Nachweis, falls ein Kratzer später strittig wird.",
+      "step2": "Der Liste auf dem Handy folgen: ein Foto je Seite, der Kilometerstand und vorhandene Schäden.",
+      "scope": "Der Link fügt diesem Arbeitsauftrag nur Annahmefotos hinzu und funktioniert nach 30 Minuten nicht mehr.",
+      "pageTitle": "Das Fahrzeug bei der Annahme",
+      "pageBody": "Die Fotos der Reihe nach aufnehmen. Vorhandene Schäden zusätzlich in Nahaufnahme.",
+      "retake": "Neu aufnehmen",
+      "slots": {
+        "front": "Vorne",
+        "right": "Rechte Seite",
+        "rear": "Hinten",
+        "left": "Linke Seite",
+        "odometer": "Kilometerstand",
+        "other": "Schaden oder Sonstiges"
+      }
+    }
+  },
+  "payCode": {
+    "button": "Zahlcode",
+    "title": "Per Scan bezahlen",
+    "body": "Der Kunde scannt den Code mit der Handykamera, die Rechnung öffnet sich, und er bezahlt dort.",
+    "sharesInvoice": "Mit dem Anzeigen des Codes wird die Rechnung mit dem Kunden geteilt, genau wie beim Senden. Ab dann behält die Rechnung die Angaben, die sie jetzt hat.",
+    "show": "Code anzeigen",
+    "failed": "Der Code konnte nicht erstellt werden. Bitte erneut versuchen.",
+    "toPay": "Zu zahlen",
+    "scanHint": "Mit der Handykamera scannen",
+    "copyLink": "Link kopieren",
+    "linkCopied": "Link kopiert",
+    "waiting": "Warten auf die Zahlung. Das aktualisiert sich von selbst.",
+    "paid": "Zahlung eingegangen",
+    "paidBody": "Die Zahlung steht auf dem Arbeitsauftrag.",
+    "done": "Fertig"
+  },
+  "customStatus": {
+    "defaultMessage": "Hallo {customer_name}, ein Update zu Ihrem {vehicle}: {status}. {company_name}"
   }
 }

+ 394 - 33
messages/de/settings.json

@@ -43,6 +43,10 @@
         "title": "Steuer",
         "description": "Steuersatz & Standardwerte"
       },
+      "warranty": {
+        "title": "Garantie",
+        "description": "Standardgarantie auf Angeboten & Rechnungen"
+      },
       "currency": {
         "title": "Wahrung",
         "description": "Wahrungs- & Steuerstandards"
@@ -65,7 +69,7 @@
       },
       "telegram": {
         "title": "Telegram",
-        "description": "Telegram bot & messaging"
+        "description": "Telegram-Bot & Nachrichten"
       },
       "customerPortal": {
         "title": "Kundenportal",
@@ -138,6 +142,10 @@
       "emailTemplates": {
         "title": "E-Mail-Vorlagen",
         "description": "Was Ihre E-Mails zu Rechnung, Angebot und Bericht sagen"
+      },
+      "workOrderStatuses": {
+        "title": "Auftragsstatus",
+        "description": "Eigene Schritte, und wer informiert wird"
       }
     },
     "pro": "PRO",
@@ -192,10 +200,20 @@
     "serviceTypeDescription": "Wählen Sie die Art der Dienstleistung, die Ihre Werkstatt anbietet. Dies ändert die Feldbezeichnungen in der gesamten Anwendung.",
     "automotive": "Automobil",
     "automotiveDescription": "Autos, LKWs und andere Straßenfahrzeuge",
-    "marineService": "Marine",
+    "marineService": "Maritim",
     "marineServiceDescription": "Boote, Yachten und Wasserfahrzeuge"
   },
   "account": {
+    "devicesTitle": "Angemeldete Geräte",
+    "devicesDescription": "Jeder Browser und jedes Telefon mit einer offenen Sitzung auf deinem Konto.",
+    "thisDevice": "Dieses Gerät",
+    "signedInAt": "Angemeldet {date}",
+    "lastActiveAt": "Zuletzt aktiv {date}",
+    "signOutDevice": "Abmelden",
+    "signOutOthers": "Andere Geräte abmelden",
+    "deviceSignedOut": "Gerät abgemeldet",
+    "othersSignedOut": "Andere Geräte abgemeldet",
+    "failedSignOutDevice": "Das Gerät konnte nicht abgemeldet werden",
     "profileTitle": "Profil",
     "profileDescription": "Aktualisieren Sie Ihren Anzeigenamen und Ihre E-Mail-Adresse.",
     "emailVerified": "Bestätigt",
@@ -284,8 +302,8 @@
     "contentInspectionsDescription": "Alle Fahrzeuginspektionen und deren Ergebnisse",
     "contentTechnicians": "Techniker",
     "contentTechniciansDescription": "Alle Technikerprofile. Verknüpfte Datensätze werden getrennt.",
-    "contentInspectionTemplates": "Inspektionsvorlagen",
-    "contentInspectionTemplatesDescription": "Alle Inspektionsvorlagen und Abschnitte. Inspektionen zuerst löschen.",
+    "contentInspectionTemplates": "Inspektions-Checklisten",
+    "contentInspectionTemplatesDescription": "Alle Inspektions-Checklisten und ihre Abschnitte. Löschen Sie zuerst die Inspektionen.",
     "contentNotifications": "Benachrichtigungen",
     "contentNotificationsDescription": "Alle Systembenachrichtigungen",
     "contentSmsMessages": "SMS-Nachrichten",
@@ -330,7 +348,29 @@
     "deleteWorkshopSuccess": "Werkstatt gelöscht",
     "deleteWorkshopFailed": "Werkstatt konnte nicht gelöscht werden",
     "deleteAccountMoved": "Möchten Sie Ihr Konto löschen?",
-    "deleteAccountMovedLink": "Das können Sie in den Dateneinstellungen tun."
+    "deleteAccountMovedLink": "Das können Sie in den Dateneinstellungen tun.",
+    "signatureTitle": "Unterschrift",
+    "signatureDescription": "Wird auf den Rechnungen, Angeboten und Prüfbescheinigungen gedruckt, die Sie für diese Werkstatt ausstellen.",
+    "signatureDraw": "Zeichnen",
+    "signatureUpload": "Hochladen",
+    "signatureDrawHint": "Hier unterschreiben",
+    "signatureUploadHint": "Wählen Sie ein Foto oder einen Scan Ihrer Unterschrift",
+    "signatureDrawTip": "Verwenden Sie einen Finger, einen Eingabestift oder die Maus.",
+    "signatureUploadTip": "Unterschreiben Sie auf weißem Papier und fotografieren Sie es. Das Papier wird entfernt, nur die Tinte bleibt erhalten.",
+    "signatureChooseFile": "Bild auswählen",
+    "signatureClear": "Leeren",
+    "signatureSave": "Unterschrift speichern",
+    "signatureCancel": "Abbrechen",
+    "signatureReplace": "Ersetzen",
+    "signatureRemove": "Entfernen",
+    "signatureSaved": "Unterschrift gespeichert",
+    "signatureRemoved": "Unterschrift entfernt. Bereits ausgestellte Dokumente behalten ihre.",
+    "signatureSaveFailed": "Die Unterschrift konnte nicht gespeichert werden",
+    "signatureEmpty": "Zeichnen Sie zuerst eine Unterschrift oder laden Sie eine hoch",
+    "signatureNotImage": "Diese Datei ist kein Bild",
+    "signatureNoInk": "In diesem Bild wurde keine Unterschrift gefunden. Versuchen Sie einen dunkleren Stift auf weißem Papier.",
+    "signatureCanvasLabel": "Unterschriftenfeld",
+    "signatureDesignerHint": "Sie erscheint dort, wo in einem Design der Abschnitt Unterschrift aktiviert ist. Aktivieren Sie ihn im <link>Vorlagen-Designer</link>."
   },
   "team": {
     "title": "Team",
@@ -408,9 +448,9 @@
     "failedRemoveMember": "Mitglied konnte nicht entfernt werden",
     "memberRoleUpdated": "Mitgliedsrolle aktualisiert",
     "failedUpdateRole": "Rolle konnte nicht aktualisiert werden",
-    "removeMember": "Remove member",
-    "cancelInvite": "Cancel invitation",
-    "editRole": "Edit role",
+    "removeMember": "Mitglied entfernen",
+    "cancelInvite": "Einladung zurückziehen",
+    "editRole": "Rolle bearbeiten",
     "technician": "Techniker",
     "technicianHint": "Kann Aufträge zugewiesen bekommen und die Techniker-App nutzen",
     "technicianFailed": "Konnte nicht geändert werden",
@@ -590,9 +630,43 @@
     "freezeProgress": "{done} von {total} gesperrt",
     "freezeDone": "{count, plural, one {# Rechnung} other {# Rechnungen}} gesperrt",
     "freezeFailed": "Rechnungen konnten nicht gesperrt werden",
+    "reapplyTitle": "Design auf bereits versendeten Rechnungen",
+    "reapplyDescription": "Das heute verwendete Design auf Rechnungen anwenden, die auf ein älteres festgelegt sind.",
+    "reapplyBody": "{count, plural, one {# Rechnung ist} other {# Rechnungen sind}} auf das Design festgelegt, mit dem sie versendet wurde, daher erreicht ein neues Design nur ab jetzt versendete Rechnungen. Das erneute Anwenden legt das heutige Design auf alle. Nur das Aussehen ändert sich: Werkstattangaben, Kunde, Zahlungsbedingungen und Beträge jeder Rechnung bleiben, wie sie versendet wurden.",
+    "reapplyWarning": "Das lässt sich nicht rückgängig machen. Das zuvor verwendete Design wird nicht aufbewahrt.",
+    "reapplyButton": "Aktuelles Design erneut anwenden",
+    "reapplyConfirmTitle": "Design auf {count, plural, one {# Rechnung} other {# Rechnungen}} erneut anwenden?",
+    "reapplyConfirmBody": "Jede Rechnung, die bereits an einen Kunden ging, wechselt zum aktuellen Design. Nur das Aussehen ändert sich: Angaben, Bedingungen und Beträge jeder Rechnung bleiben, wie sie versendet wurden. Das lässt sich nicht rückgängig machen, und das zuvor verwendete Design kann nicht wiederhergestellt werden.",
+    "reapplyProgress": "{done} von {total} aktualisiert",
+    "reapplyDone": "{count, plural, one {# Rechnung verwendet} other {# Rechnungen verwenden}} jetzt das aktuelle Design",
+    "reapplyFailed": "Design konnte nicht erneut angewendet werden",
     "sectionSending": "Versand",
     "attachPdfLabel": "PDF an E-Mails anhängen",
-    "attachPdfHint": "Die Vorgabe für jede Rechnung, jedes Angebot und jede Prüfung, die Sie per E-Mail senden. Ein: Der Kunde erhält das PDF als Anhang. Aus: Er erhält stattdessen einen Link zum Dokument, und der Aufrufzähler zeigt, wann es geöffnet wurde, damit Sie bei den unbeachteten nachfassen können. Für einen einzelnen Versand können Sie das im Teilen-Dialog ändern."
+    "attachPdfHint": "Die Vorgabe für jede Rechnung, jedes Angebot und jede Prüfung, die Sie per E-Mail senden. Ein: Der Kunde erhält das PDF als Anhang. Aus: Er erhält stattdessen einen Link zum Dokument, und der Aufrufzähler zeigt, wann es geöffnet wurde, damit Sie bei den unbeachteten nachfassen können. Für einen einzelnen Versand können Sie das im Teilen-Dialog ändern.",
+    "shopFee": {
+      "title": "Werkstattpauschale",
+      "description": "Eine Pauschale für Verbrauchsmaterial wie Putztücher, Reiniger, Schmierstoffe und Entsorgung, die jedem neuen Auftrag und Angebot als eigene Position hinzugefügt wird.",
+      "enabled": "Werkstattpauschale berechnen",
+      "enabledHint": "Neue Aufträge und Angebote erhalten die Pauschale als Position. Sie kann bei einzelnen Aufträgen entfernt werden. Bestehende Aufträge bleiben unverändert.",
+      "defaultLabel": "Kleinmaterial",
+      "label": "Bezeichnung auf der Rechnung",
+      "appliesTo": "Gilt für",
+      "appliesToBoth": "Aufträge und Angebote",
+      "appliesToWorkOrders": "Nur Aufträge",
+      "appliesToQuotes": "Nur Angebote",
+      "mode": "Berechnung",
+      "modeFlat": "Fester Betrag",
+      "modePercent": "Prozentsatz",
+      "amount": "Betrag",
+      "amountHint": "Derselbe Betrag bei jedem Auftrag. Er kann bei einzelnen Aufträgen geändert werden.",
+      "percent": "Prozentsatz (%)",
+      "base": "Prozent von",
+      "baseLabor": "Arbeit",
+      "baseLaborParts": "Arbeit und Teile",
+      "cap": "Höchstbetrag",
+      "capHint": "Die Pauschale wird nie höher als dieser Betrag. Leer lassen für keinen Höchstbetrag.",
+      "capPlaceholder": "Kein Höchstbetrag"
+    }
   },
   "layoutEditor": {
     "helpText": "Ziehen zum Neuordnen. Verwenden Sie den Breiten-Umschalter, um Abschnitte nebeneinander zu platzieren. Aufeinanderfolgende Halbbreite-Abschnitte (L/R) werden in zwei Spalten dargestellt.",
@@ -628,7 +702,19 @@
       "bank_account": "Bankkonto",
       "footer": "Fußzeile",
       "general": "Allgemein",
-      "telegram_qr": "Telegram-QR"
+      "telegram_qr": "Telegram-QR",
+      "result": "Ergebnis",
+      "test_details": "Prüfdetails",
+      "defects": "Mängel",
+      "results_table": "Alle Ergebnisse",
+      "inspection_photos": "Fotos",
+      "signature": "Unterschrift",
+      "job_details": "Auftragsdetails",
+      "job_qr": "Auf dem Handy öffnen",
+      "concerns": "Kundenanliegen",
+      "job_description": "Gewünschte Arbeiten",
+      "work_checklist": "Arbeitscheckliste",
+      "condition_map": "Fahrzeugzustand"
     },
     "fields": {
       "customer_name": "Kundenname",
@@ -641,6 +727,7 @@
       "vin": "FIN",
       "license_plate": "Kennzeichen",
       "mileage": "Kilometerstand",
+      "hsn_tsn": "HSN/TSN",
       "service_title": "Servicetitel",
       "service_type": "Servicetyp",
       "tech_name": "Techniker",
@@ -660,7 +747,35 @@
       "invoice_number": "Rechnungsnummer",
       "customer_number": "Kundennummer",
       "date": "Datum",
-      "portal_link": "Portal-Link"
+      "portal_link": "Portal-Link",
+      "test_date": "Prüfdatum",
+      "test_location": "Prüfort",
+      "inspector": "Prüfer",
+      "certificate_number": "Zertifikatsnummer",
+      "vehicle_category": "Fahrzeugklasse",
+      "odometer": "Kilometerstand",
+      "next_test_due": "Nächste Prüfung fällig",
+      "defect_notes": "Anmerkungen",
+      "defect_photos": "Fotos",
+      "passed_checks": "Bestandene Prüfungen",
+      "not_applicable_checks": "Nicht zutreffende Prüfungen",
+      "check_notes": "Spalte Anmerkungen",
+      "result_detail": "Erläuterung",
+      "result_summary": "Anzahl je Bewertung",
+      "combined_table": "Eine Tabelle für alle Abschnitte",
+      "inspector_line": "Unterschriftszeile",
+      "inspector_name": "Name des Unterzeichners",
+      "date_line": "Datumszeile",
+      "signature_image": "Unterschrift",
+      "status": "Status",
+      "technician": "Techniker",
+      "scheduled": "Geplant",
+      "promised": "Zugesagt",
+      "work_bay": "Arbeitsplatz",
+      "customer_line": "Unterschriftszeile Kunde",
+      "legend": "Legende",
+      "previous_marks": "Frühere Markierungen",
+      "blank_sheet": "Leeres Blatt zum Ausfüllen von Hand"
     },
     "presetsTitle": "Mit einer Anordnung beginnen",
     "presetsHint": "Ersetzt die Anordnung unten. Nichts wird gespeichert, bis Sie speichern.",
@@ -688,6 +803,34 @@
       "detailed": {
         "name": "Ausführlich",
         "description": "Titel oben, Fahrzeug für sich, beide Tabellen und alle optionalen Blöcke"
+      },
+      "certificate-regulator": {
+        "name": "Amtlich",
+        "description": "Schlicht ohne Band, in Serifenschrift: jede Prüfung mit Bewertung und Anmerkung, die Fotos, eine Unterschriftszeile"
+      },
+      "certificate-standard": {
+        "name": "Standard",
+        "description": "Das Ergebnis unter dem Titel, Fahrzeug und Prüfung nebeneinander, jeder Mangel mit Anmerkung und Foto, dann die beanstandeten Prüfungen nach Abschnitt"
+      },
+      "certificate-customer": {
+        "name": "Kundenbericht",
+        "description": "Ein Blatt mit Band, das Ergebnis groß und mittig, Mängel mit Fotos, die Rundgangsfotos und eine kurze Tabelle der beanstandeten Prüfungen"
+      },
+      "certificate-compact": {
+        "name": "Kompakt",
+        "description": "Eine Seite für Flotten: kleine Schrift, das Ergebnis neben dem Titel, alle Ergebnisse ohne Anmerkungen, die Fotos, eine Unterschriftszeile"
+      },
+      "work-order-standard": {
+        "name": "Standard",
+        "description": "Die ausführlichere Tresenkopie in Ihrer Farbe: Kunde und Fahrzeug vollständig, der Service, die Befunde und die Garantie neben den Arbeiten und Preisen"
+      },
+      "work-order-job-sheet": {
+        "name": "Arbeitskarte",
+        "description": "Für die Tafel: Kennzeichen groß, die Fakten zum Auftrag, die Arbeiten als Kästchen zum Abhaken, ein Code zum Öffnen am Handy und keine Preise"
+      },
+      "work-order-compact": {
+        "name": "Kompakt",
+        "description": "Schwarz auf Weiß, eine Seite: Name und Telefon des Kunden, das Fahrzeug, der Auftrag, die Arbeiten mit Preisen und beide Unterschriften. Was jeder Arbeitsauftrag druckt, bis Sie ein Design speichern"
       }
     },
     "style": {
@@ -710,14 +853,21 @@
   "templates": {
     "title": "Vorlagen",
     "invoiceDescription": "Passen Sie das Erscheinungsbild Ihrer PDF-Rechnungen und Angebote an.",
-    "inspectionsDescription": "Verwalten Sie Inspektionschecklisten und Mehrpunkt-Vorlagen.",
+    "inspectionsDescription": "Die Checklisten, die Ihre Techniker bei einer Inspektion durchgehen.",
     "smsDescription": "Passen Sie die SMS-Nachrichten an Kunden an.",
     "tabs": {
       "invoice": "Rechnung",
       "quotation": "Angebot",
-      "inspections": "Inspektionen",
-      "sms": "SMS",
-      "layout": "Layout"
+      "workOrders": "Arbeitsauftrag",
+      "certificates": "Inspektionszertifikat",
+      "layout": "Layout",
+      "conditionMap": "Zustandskarte",
+      "inspections": "Checklisten"
+    },
+    "sections": {
+      "documents": "Dokumente",
+      "checklists": "Checklisten",
+      "messages": "Nachrichten"
     },
     "presets": "Vorlagen-Presets",
     "primaryColor": "Primärfarbe",
@@ -810,7 +960,7 @@
       "red": "Rot",
       "purple": "Lila",
       "slate": "Schiefer",
-      "rose": "Rose",
+      "rose": "Rosé",
       "indigo": "Indigo"
     },
     "layoutTitle": "Rechnungslayout",
@@ -821,7 +971,10 @@
     "goToLayout": "Layout und Abschnitte",
     "openDesigner": "Vorlagen-Designer öffnen",
     "designerIntro": "Wählen Sie einen Ausgangspunkt und öffnen Sie den Designer, um alles zu ändern.",
-    "designerHint": "Farben, Schriften, Abstände und alle Abschnitte liegen im Designer."
+    "designerHint": "Farben, Schriften, Abstände und alle Abschnitte liegen im Designer.",
+    "certificatesDescription": "Gestalten Sie das Zertifikat, das eine abgeschlossene Inspektion druckt und teilt.",
+    "workOrdersDescription": "Gestalten Sie das Blatt, das ein Arbeitsauftrag druckt: was der Kunde unterschreibt und was der Techniker von der Tafel nimmt.",
+    "conditionMapDescription": "Die Markierungsarten, die Ihre Techniker auf der Zustandskarte setzen: Namen, Formen und Farben."
   },
   "payment": {
     "readMore": "Mehr erfahren",
@@ -1041,8 +1194,8 @@
     "fieldDeleted": "Benutzerdefiniertes Feld gelöscht",
     "failedSave": "Feld konnte nicht gespeichert werden",
     "failedDelete": "Konnte nicht gelöscht werden",
-    "edit": "Edit",
-    "delete": "Delete",
+    "edit": "Bearbeiten",
+    "delete": "Löschen",
     "unassigned": "Nicht zugewiesen",
     "defaultValue": "Standardwert",
     "defaultValueHint": "Wird bei neuen Einträgen vorausgefüllt und gedruckt, solange kein Wert eingegeben wurde.",
@@ -1072,6 +1225,9 @@
     "defaultMarkupPercentHint": "Neue manuell hinzugefügte Teile werden mit diesem Aufschlag vorausgefüllt. Auf 0 lassen, um Preise manuell einzugeben.",
     "markupAppliesToInventory": "Aufschlag auf Lagerteile anwenden",
     "markupAppliesToInventoryHint": "Wenn aktiviert, verwenden aus dem Lager gewählte Teile ebenfalls Einkaufspreis + Standard-Aufschlag (überschreibt den Verkaufspreis des Lagers).",
+    "typeKeyTitle": "Deutsche Schlüsselnummer (HSN/TSN)",
+    "typeKeyEnabled": "HSN/TSN an Fahrzeugen erfassen",
+    "typeKeyHint": "Ergänzt Fahrzeuge um Hersteller- und Typschlüsselnummer aus der Zulassungsbescheinigung Teil I (Felder 2.1 und 2.2). Sie erscheint am Fahrzeug, am Arbeitsauftrag und auf allen gedruckten Dokumenten, und Fahrzeuge lassen sich über die Suche danach finden. Beim Ausschalten wird sie wieder ausgeblendet, eingegebene Werte bleiben erhalten.",
     "unitsTitle": "Einheiten",
     "unitsDescription": "Wählen Sie zwischen metrisch (km, Liter) und imperial (Meilen, Gallonen).",
     "unitSystem": "Einheitensystem",
@@ -1138,6 +1294,43 @@
     "saveMaintenance": "Wartungseinstellungen speichern",
     "saved": "Wartungseinstellungen gespeichert"
   },
+  "warranty": {
+    "title": "Garantie",
+    "description": "Legen Sie fest, was Ihre Angebote und Rechnungen zur Garantie sagen. Jedes neue Dokument beginnt damit, und Sie können es auf jedem einzelnen weiterhin ändern.",
+    "designHint": "Wird im Abschnitt Garantie Ihres Rechnungs- und Angebotsdesigns gedruckt. Wenn Sie diesen Abschnitt unter Vorlagen ausgeblendet haben, schalten Sie ihn wieder ein. Das Design Kompakt lässt ihn weg.",
+    "defaultStatus": "Neue Angebote und Arbeitsaufträge sagen",
+    "status": {
+      "none": "Nichts",
+      "included": "Garantie enthalten",
+      "not_included": "Keine Garantie"
+    },
+    "statusHint": {
+      "none": "Die Garantie wird nicht erwähnt, bis jemand sie im Dokument ausfüllt.",
+      "included": "Neue Dokumente beginnen mit der Laufzeit und den Bedingungen unten.",
+      "not_included": "Neue Dokumente teilen dem Kunden mit, dass keine Werkstattgarantie enthalten ist."
+    },
+    "includedTitle": "Wenn eine Garantie enthalten ist",
+    "includedHint": "Laufzeit und Bedingungen, die ein Dokument erhält, wenn es eine Garantie anbietet.",
+    "months": "Dauer (Monate)",
+    "mileage": "Streckenbegrenzung ({unit})",
+    "mileageHint": "Leer lassen für keine Streckenbegrenzung.",
+    "terms": "Garantiebedingungen",
+    "termsPlaceholder": "z. B. 12 Monate auf Teile und Arbeit. Verschleißteile sind ausgenommen.",
+    "termsHint": "Wird unter der Garantie auf Angeboten und Rechnungen gedruckt.",
+    "notIncludedTitle": "Wenn keine Garantie enthalten ist",
+    "notIncludedHint": "Was der Kunde liest, wenn ein Dokument angibt, dass keine Garantie enthalten ist.",
+    "notIncludedText": "Erklärung",
+    "notIncludedPlaceholder": "z. B. Auf vom Kunden gelieferte Teile gibt die Werkstatt keine Garantie. Ihre gesetzlichen Rechte bleiben unberührt.",
+    "legalHint": "Hier geht es um Ihre eigene Werkstattgarantie. In vielen Ländern, unter anderem in der EU und im Vereinigten Königreich, haben Kunden zusätzlich gesetzliche Gewährleistungsrechte, die nicht ausgeschlossen werden können. Bleibt das Feld leer, steht im Dokument, dass die gesetzlichen Rechte unberührt bleiben.",
+    "applyTitle": "Diese Standardwerte verwenden für",
+    "applyToQuotes": "Neue Angebote",
+    "applyToQuotesHint": "Der Kunde sieht die Garantie, bevor er annimmt.",
+    "applyToWorkOrders": "Neue Arbeitsaufträge",
+    "applyToWorkOrdersHint": "Wird auf der Rechnung gedruckt. Ein Arbeitsauftrag aus einem Angebot behält, was im Angebot stand.",
+    "save": "Garantieeinstellungen speichern",
+    "saved": "Garantieeinstellungen gespeichert",
+    "saveFailed": "Die Garantieeinstellungen konnten nicht gespeichert werden"
+  },
   "appearance": {
     "title": "Darstellung",
     "description": "Passen Sie das Erscheinungsbild von Torqvoice an.",
@@ -1168,7 +1361,29 @@
     "dateLabel": "Datum",
     "dateTimeLabel": "Datum & Uhrzeit",
     "saveSettings": "Einstellungen speichern",
-    "saved": "Einstellungen gespeichert"
+    "saved": "Einstellungen gespeichert",
+    "fontLabel": "Schriftart",
+    "fontHint": "Wählen Sie die Schriften, in denen die App gesetzt ist. Die Auswahl wird auf diesem Gerät gespeichert.",
+    "fontSampleHeading": "Arbeitsauftrag",
+    "fontSampleText": "Zündspule und Zündkerzen ersetzen",
+    "fonts": {
+      "default": {
+        "name": "Standard",
+        "description": "Geist, klar und neutral."
+      },
+      "workshop": {
+        "name": "Werkstatt",
+        "description": "IBM Plex mit schmalen Überschriften in Großbuchstaben, wie ein Auftragszettel."
+      },
+      "precise": {
+        "name": "Präzise",
+        "description": "Inter und JetBrains Mono. Gemacht für Bildschirme voller Tabellen und Zahlen."
+      },
+      "legible": {
+        "name": "Gut lesbar",
+        "description": "Atkinson Hyperlegible. 0 und O, 1 und l sehen nie gleich aus; auch quer durch die Werkstatt gut lesbar."
+      }
+    }
   },
   "data": {
     "title": "Datenverwaltung",
@@ -1191,7 +1406,7 @@
     "optTechnicians": "Techniker",
     "optTechniciansDesc": "Technikerprofile und Arbeitsboard-Zuweisungen",
     "optInspections": "Inspektionen",
-    "optInspectionsDesc": "Inspektionsvorlagen, Inspektionen und Ergebnisse",
+    "optInspectionsDesc": "Inspektions-Checklisten, Inspektionen und Ergebnisse",
     "optAuditLogs": "Prüfprotokolle",
     "optAuditLogsDesc": "Aktivitäts- und Änderungsverlauf",
     "optSmsMessages": "Nachrichten",
@@ -1296,7 +1511,7 @@
     "title": "Uber Torqvoice",
     "description": "Torqvoice ist Ihre komplette Lösung für die Fahrzeug-Serviceverwaltung. Verfolgen Sie Servicehistorie, Reparaturen und mehr.",
     "version": "Version",
-    "website": "Website",
+    "website": "Webseite",
     "documentation": "Dokumentation",
     "changelog": "Anderungsprotokoll"
   },
@@ -1350,7 +1565,7 @@
         "sand": "Sand"
       }
     },
-    "copyUrl": "Copy portal URL"
+    "copyUrl": "Portal-URL kopieren"
   },
   "email": {
     "title": "E-Mail-Anbieter",
@@ -1426,7 +1641,7 @@
     "failedSave": "Einstellungen konnten nicht gespeichert werden",
     "testSentTo": "Test-SMS gesendet an {phone}",
     "testFailed": "SMS-Test fehlgeschlagen",
-    "copyWebhookUrl": "Copy webhook URL"
+    "copyWebhookUrl": "Webhook-URL kopieren"
   },
   "subscription": {
     "title": "Abonnementplan",
@@ -1477,7 +1692,7 @@
     "resumeSuccess": "Abonnement erfolgreich fortgesetzt",
     "resumeError": "Abonnement konnte nicht fortgesetzt werden",
     "billingPortalError": "Abrechnungsportal konnte nicht geöffnet werden",
-    "checkoutError": "Failed to start checkout",
+    "checkoutError": "Checkout konnte nicht gestartet werden",
     "upgradeTitle": "Plan upgraden",
     "upgradeDescription": "Schalten Sie weitere Funktionen frei, indem Sie Ihren Plan upgraden.",
     "upgradeToProDescription": "Upgraden Sie, um unbegrenzte Kunden, Teammitglieder, Berichte, SMTP, API-Zugang und mehr freizuschalten.",
@@ -1492,7 +1707,12 @@
     "upgradeSuccess": "Erfolgreich auf Enterprise upgegradet!",
     "upgradeError": "Abonnement konnte nicht upgegradet werden",
     "demoBadge": "Demo",
-    "demoExpiresIn": "Läuft in {days, plural, =0 {weniger als einem Tag} one {# Tag} other {# Tagen}} ab ({date})"
+    "demoExpiresIn": "Läuft in {days, plural, =0 {weniger als einem Tag} one {# Tag} other {# Tagen}} ab ({date})",
+    "checkoutOnTorqvoice": "Zum Abschluss des Kaufs werden Sie zu torqvoice.com weitergeleitet. Rechnungen und Belege finden Sie dort unter Ihrem Konto.",
+    "purchaseSuccess": "Vielen Dank! Ihr Plan ist jetzt aktiv.",
+    "purchaseActivating": "Zahlung erhalten. Ihr Plan wird aktiviert, diese Seite wird gleich aktualisiert.",
+    "accountOnTorqvoice": "Rechnungen und Belege finden Sie auf torqvoice.com, wo Sie mit diesem Konto angemeldet sind.",
+    "openTorqvoiceAccount": "Konto auf torqvoice.com öffnen"
   },
   "ai": {
     "title": "KI-Assistent",
@@ -1532,7 +1752,7 @@
     "biweekly": "Alle 2 Wochen",
     "monthly": "Monatlich",
     "bimonthly": "Alle 2 Monate",
-    "quarterly": "Alle 4 Monate",
+    "quarterly": "Alle 3 Monate",
     "semiannually": "Alle 6 Monate",
     "yearly": "Jährlich",
     "dateRangeLabel": "Zeitraum",
@@ -1571,10 +1791,10 @@
     "deleted": "Berichtszeitplan gelöscht",
     "toggledOn": "Berichtszeitplan aktiviert",
     "toggledOff": "Berichtszeitplan deaktiviert",
-    "failedSave": "Failed to save",
-    "failedSend": "Failed to send",
-    "failedToggle": "Failed to toggle",
-    "failedDelete": "Failed to delete",
+    "failedSave": "Speichern fehlgeschlagen",
+    "failedSend": "Senden fehlgeschlagen",
+    "failedToggle": "Umschalten fehlgeschlagen",
+    "failedDelete": "Löschen fehlgeschlagen",
     "noMembers": "Keine Teammitglieder gefunden",
     "addRecipient": "Empfänger hinzufügen...",
     "searchMembers": "Mitglieder suchen...",
@@ -1968,7 +2188,41 @@
       "slogan": "Qualität, auf die Sie sich verlassen können",
       "paymentTerms": "Zahlbar innerhalb von 14 Tagen",
       "footerNote": "Vielen Dank für Ihren Auftrag",
-      "value": "Beispiel"
+      "value": "Beispiel",
+      "testLocation": "Hafenstraße 12, Springfield",
+      "certificateNotes": "Die Reifen sind nahe an der gesetzlichen Grenze; vor der nächsten Prüfung voraussichtlich zu ersetzen.",
+      "checkBrakeHoses": "Bremsschläuche",
+      "checkBrakeHosesNote": "Leichte Scheuerstelle am linken vorderen Schlauch, Außenschicht noch intakt.",
+      "sectionBrakes": "Bremsanlage",
+      "checkBrakePedal": "Betriebsbremspedal",
+      "checkBrakeFluid": "Bremsflüssigkeit",
+      "sectionLighting": "Beleuchtungseinrichtungen",
+      "checkHeadlamps": "Zustand der Scheinwerfer",
+      "checkFogLamp": "Nebelschlussleuchte",
+      "photoFront": "Vorne",
+      "photoOdometer": "Kilometerstand",
+      "concernNoise": "Schleifgeräusch vorne beim Bremsen",
+      "concernNoiseDetail": "Bremsbeläge und -scheiben vorne verschlissen; ersetzt",
+      "concernWarningLight": "Warnleuchte im Armaturenbrett seit letzter Woche",
+      "workRequested": "Bremsbeläge und -scheiben vorne ersetzen, Serviceanzeige zurücksetzen und Probefahrt.",
+      "checklistTestDrive": "Probefahrt",
+      "markScratch": "Kratzer entlang der Tür, 20 cm",
+      "markDent": "Delle unter dem Griff, Lack intakt",
+      "markChip": "Steinschläge im unteren Stoßfänger",
+      "viewTop": "Von oben",
+      "viewLeft": "Linke Seite",
+      "viewRight": "Rechte Seite",
+      "viewFront": "Vorn",
+      "viewRear": "Hinten",
+      "panelLeftRearDoor": "Tür hinten links",
+      "panelLeftFrontDoor": "Tür vorn links",
+      "panelFrontBumper": "Stoßstange vorn",
+      "kindScratch": "Kratzer",
+      "kindDent": "Delle",
+      "kindChip": "Steinschlag",
+      "severityMinor": "Leicht",
+      "severityMajor": "Schwer",
+      "markPrevious": "früher erfasst"
     },
     "logoUpload": "Logo hochladen",
     "logoReplace": "Logo ersetzen",
@@ -1990,6 +2244,10 @@
     "telegramQrConnectedHint": "Druckt einen Code, der {link} öffnet, Ihren verbundenen Telegram-Bot. Er erscheint nur auf Rechnungen, die nach dem Einschalten des Blocks ausgestellt wurden.",
     "telegramQrPlaceholderHint": "Kein Telegram-Bot ist verbunden, daher zeigt das Blatt einen Beispielcode, der nicht gedruckt wird.",
     "telegramQrLink": "Integrationen",
+    "signatureWhose": "Wessen Unterschrift",
+    "signatureOwnHint": "Jedes Dokument druckt die Unterschrift der Person, die es ausgestellt hat: wer den Auftrag angelegt, das Angebot erstellt oder die Prüfung durchgeführt hat. Das Blatt zeigt Ihre.",
+    "signaturePlaceholderHint": "Jedes Dokument druckt die Unterschrift der Person, die es ausgestellt hat: wer den Auftrag angelegt, das Angebot erstellt oder die Prüfung durchgeführt hat. Sie haben noch keine gespeichert, daher zeigt das Blatt ein Beispiel.",
+    "signatureLink": "Unterschrift festlegen",
     "titleText": "Titeltext",
     "titleTextHint": "Wie sich dieses Dokument nennt. Leer gelassen, druckt es seinen eigenen Namen in der Sprache des Lesers.",
     "orgNumberLabel": "Bezeichnung der Organisationsnummer",
@@ -2004,7 +2262,16 @@
     "setDefault": "Als Standard festlegen",
     "defaultSet": "„{name}“ wird jetzt verwendet",
     "couldNotSetDefault": "Standarddesign konnte nicht festgelegt werden",
-    "fieldFixedSlot": "Wird immer an derselben Stelle gedruckt und kann nicht verschoben werden."
+    "fieldFixedSlot": "Wird immer an derselben Stelle gedruckt und kann nicht verschoben werden.",
+    "certificate": "Inspektionszertifikat",
+    "resultWidthHint": "Leer füllt die Zeile. Mit einem Wert wird das Band schmaler und sitzt an der Seite, die die Ausrichtung wählt; in einer Spalte nimmt es die Spaltenbreite.",
+    "work_order": "Arbeitsauftrag",
+    "conditionMapDrawing": "Zeichnung",
+    "conditionMapViews": "Ansichten",
+    "conditionMapViewsAll": "Alle fünf",
+    "conditionMapViewsTop": "Nur von oben",
+    "conditionMapViewsSides": "Nur die Seiten",
+    "conditionMapWidthHint": "Leer füllt die Zeile. Punkte angeben, um die Zeichnung zu verkleinern; sie sitzt dann nach der Ausrichtung, und die Legende behält die volle Breite."
   },
   "preview": {
     "title": "Bremsenservice und Ölwechsel",
@@ -2402,5 +2669,99 @@
     "dragHandle": "Zum Verschieben ziehen",
     "dragHandleKeyboard": "Ziehen Sie diesen Block oder verschieben Sie ihn mit den Pfeiltasten",
     "tagDoesNotFit": "Das Tag passt nicht in dieses Feld."
+  },
+  "workOrderStatuses": {
+    "title": "Auftragsstatus",
+    "description": "Ihre eigenen Namen für die Schritte eines Auftrags. Jeder Status gehört zu einer der drei Phasen und erscheint im Menü dieser Phase auf jedem Arbeitsauftrag.",
+    "cardTitle": "Phasen und Status",
+    "suggestionsLabel": "Schnell hinzufügen",
+    "suggestions": {
+      "readyForPickup": {
+        "name": "Abholbereit",
+        "message": "Hallo {customer_name}, Ihr {vehicle} ist abholbereit. {company_name}"
+      },
+      "waitingForCustomer": {
+        "name": "Warten auf Kunden"
+      },
+      "waitingForApproval": {
+        "name": "Warten auf Freigabe",
+        "message": "Hallo {customer_name}, wir benötigen Ihre Freigabe, bevor wir an Ihrem {vehicle} weiterarbeiten. Bitte melden Sie sich. {company_name}"
+      }
+    },
+    "stageHints": {
+      "pending": "Bevor die Arbeit beginnt. Der Auftrag gilt als offen.",
+      "in-progress": "Während am Fahrzeug gearbeitet wird. Der Auftrag gilt als offen.",
+      "completed": "Die Arbeit ist erledigt. Der Kunde kann die Rechnung sehen und bezahlen, und der Auftrag verschwindet aus der Liste des Mechanikers."
+    },
+    "add": "Status hinzufügen",
+    "builtIn": "Integriert",
+    "asksToNotify": "Bietet Benachrichtigung an",
+    "notifyHint": "Erreicht ein Auftrag diesen Status, wird angeboten, dem Kunden eine Nachricht zu senden. Nichts wird ohne Ihre Bestätigung gesendet.",
+    "moveUp": "Nach oben",
+    "moveDown": "Nach unten",
+    "edit": "Bearbeiten",
+    "remove": "Entfernen",
+    "empty": "Hier gibt es noch keine eigenen Status.",
+    "removeTitle": "{name} entfernen?",
+    "removeBody": "Er verschwindet aus den Menüs. Aufträge, die ihn tragen, zeigen ihn weiterhin.",
+    "addTitle": "Neuer Status",
+    "editTitle": "Status bearbeiten",
+    "dialogBody": "Ein Status ist ein Name mit einer Farbe. Die Phase, zu der er gehört, bestimmt, wie der Auftrag behandelt wird.",
+    "name": "Name",
+    "namePlaceholder": "Abholbereit",
+    "stage": "Phase",
+    "color": "Farbe",
+    "notify": "Benachrichtigung des Kunden anbieten",
+    "message": "Nachricht",
+    "tokens": "Einfügen:",
+    "cancel": "Abbrechen",
+    "save": "Speichern",
+    "saved": "Status gespeichert",
+    "failed": "Der Status konnte nicht gespeichert werden",
+    "messageEmptyHint": "Bleibt das Feld leer, wird der Text oben gesendet.",
+    "useSuggested": "Diesen Text bearbeiten"
+  },
+  "markTypes": {
+    "title": "Markierungsarten",
+    "description": "Benennen oder färben Sie die eingebauten Arten um, blenden Sie ungenutzte aus und fügen Sie eigene hinzu. Jede Art hat neben der Farbe eine Form, damit sie auch im Schwarz-Weiß-Druck lesbar ist.",
+    "cardTitle": "Arten",
+    "add": "Art hinzufügen",
+    "addTitle": "Neue Markierungsart",
+    "editTitle": "Markierungsart bearbeiten",
+    "dialogBody": "Ein Name in Ihren Worten, eine Form und eine Farbe. Gefüllt bedeutet eine schwere Markierung.",
+    "previewHint": "Leicht und schwer",
+    "name": "Name",
+    "namePlaceholder": "z. B. Bordsteinschaden",
+    "shape": "Form",
+    "color": "Farbe",
+    "customColor": "Hex-Farbe",
+    "colorInvalid": "Farbe als sechs Hex-Zeichen angeben, z. B. #2563eb",
+    "hidden": "In der Auswahl ausgeblendet",
+    "hiddenHint": "Markierungen dieser Art werden weiterhin angezeigt und gedruckt.",
+    "hiddenBadge": "Ausgeblendet",
+    "builtin": "Eingebaut",
+    "show": "In der Auswahl zeigen",
+    "hide": "Aus der Auswahl ausblenden",
+    "edit": "Bearbeiten",
+    "moveUp": "Nach oben",
+    "moveDown": "Nach unten",
+    "restore": "Zurücksetzen",
+    "restoreTitle": "„{name}“ zurücksetzen?",
+    "restoreBody": "Die Art erhält wieder Name, Form und Farbe, wie die App sie mitbringt.",
+    "remove": "Entfernen",
+    "removeTitle": "„{name}“ entfernen?",
+    "removeBody": "Nur eine Art, die keine Markierung verwendet, kann entfernt werden. Blenden Sie sie aus, wenn es Markierungen davon gibt.",
+    "save": "Speichern",
+    "cancel": "Abbrechen",
+    "saved": "Gespeichert",
+    "failed": "Konnte nicht speichern",
+    "shapes": {
+      "circle": "Kreis",
+      "diamond": "Raute",
+      "triangle": "Dreieck",
+      "square": "Quadrat",
+      "cross": "Kreuz",
+      "hex": "Sechseck"
+    }
   }
 }

+ 15 - 9
messages/de/share.json

@@ -34,8 +34,8 @@
     "total": "Gesamt",
     "labor": "Arbeitsleistung",
     "hours": "Stunden",
-    "hrs": "hrs",
-    "unit": "unit",
+    "hrs": "Std.",
+    "unit": "Einh.",
     "qtyOrHours": "Einheit / Stunden",
     "rate": "Stundensatz",
     "ratePerHour": "{rate}/Std.",
@@ -93,8 +93,8 @@
     "labor": "Arbeitsleistung",
     "laborDescription": "Beschreibung",
     "hours": "Stunden",
-    "hrs": "hrs",
-    "unit": "unit",
+    "hrs": "Std.",
+    "unit": "Einh.",
     "qtyOrHours": "Einheit / Stunden",
     "rate": "Stundensatz",
     "ratePerHour": "{rate}/Std.",
@@ -157,14 +157,16 @@
       "attention": "Geringer Mangel",
       "fail": "Erheblicher Mangel",
       "dangerous": "Gefährlicher Mangel",
-      "not_inspected": "Nicht geprüft"
+      "not_inspected": "Nicht geprüft",
+      "not_applicable": "Nicht zutreffend"
     },
     "basic": {
       "pass": "Bestanden",
       "attention": "Achtung",
       "fail": "Durchgefallen",
       "dangerous": "Gefährlich",
-      "not_inspected": "Nicht geprüft"
+      "not_inspected": "Nicht geprüft",
+      "not_applicable": "Nicht zutreffend"
     },
     "result": {
       "pass": "Bestanden",
@@ -194,13 +196,16 @@
     "summary": "Zusammenfassung",
     "progressLabel": "{graded} Prüfpunkte bewertet",
     "limit": "Grenzwert",
+    "rangeMin": "mind.",
+    "rangeMax": "max.",
     "viewPhoto": "Foto ansehen",
     "deficienciesFound": "Festgestellte Mängel ({count})",
     "allResults": "Alle Ergebnisse",
-    "privacyNotice": "Dieser Bericht wird Ihnen von der Werkstatt bereitgestellt, die die Prüfung durchgeführt hat. Er ist für jeden mit diesem Link abrufbar — bitte leiten Sie ihn nicht weiter. Wenden Sie sich an die Werkstatt, um den Link zurückzuziehen oder Ihre Daten löschen zu lassen."
+    "privacyNotice": "Dieser Bericht wird Ihnen von der Werkstatt bereitgestellt, die die Prüfung durchgeführt hat. Er ist für jeden mit diesem Link abrufbar — bitte leiten Sie ihn nicht weiter. Wenden Sie sich an die Werkstatt, um den Link zurückzuziehen oder Ihre Daten löschen zu lassen.",
+    "inspectionPhotos": "Fotos und Dokumente"
   },
   "terms": {
-    "title": "Verkaufsbedingungen (Salgsvilkår)",
+    "title": "Verkaufsbedingungen",
     "contact": "Kontakt",
     "tel": "Tel: {phone}"
   },
@@ -227,6 +232,7 @@
     "portalMessage": "Sehen Sie Ihre Fahrzeuge, Rechnungen und mehr in Ihrem",
     "customerPortal": "Kundenportal",
     "telegramConnect": "Chatten Sie mit uns auf Telegram",
-    "telegramScan": "Scannen Sie diesen QR-Code, um uns auf Telegram für Updates und Support zu kontaktieren."
+    "telegramScan": "Scannen Sie diesen QR-Code, um uns auf Telegram für Updates und Support zu kontaktieren.",
+    "downloadCertificate": "Prüfbericht herunterladen"
   }
 }

+ 7 - 2
messages/de/statusReport.json

@@ -14,7 +14,7 @@
     "createAndSend": "Erstellen & Senden",
     "cancel": "Abbrechen",
     "uploadFailed": "Video konnte nicht hochgeladen werden",
-    "createReportFailed": "Failed to create status report",
+    "createReportFailed": "Statusbericht konnte nicht erstellt werden",
     "created": "Statusbericht erstellt",
     "noVideo": "Bitte laden Sie zuerst ein Video hoch",
     "removeVideo": "Video entfernen",
@@ -52,7 +52,12 @@
     "feedbackSubmitted": "Vielen Dank für Ihr Feedback!",
     "feedbackError": "Feedback konnte nicht gesendet werden",
     "submitting": "Wird gesendet...",
-    "poweredBy": "Bereitgestellt von"
+    "poweredBy": "Bereitgestellt von",
+    "vehicleLabel": "Fahrzeug",
+    "plate": "Kennzeichen: {plate}",
+    "inspectionTitle": "Inspektion",
+    "messageFromTechnician": "Nachricht vom Techniker",
+    "videoUnsupported": "Ihr Browser unterstützt das Videoelement nicht."
   },
   "list": {
     "title": "Statusberichte",

+ 3 - 3
messages/de/telegram.json

@@ -5,8 +5,8 @@
     "label": "Bot-Token",
     "placeholder": "Bot-Token von @BotFather eingeben",
     "help": "Erstellen Sie einen Bot über @BotFather auf Telegram, um Ihr Token zu erhalten.",
-    "show": "Show token",
-    "hide": "Hide token"
+    "show": "Token anzeigen",
+    "hide": "Token ausblenden"
   },
   "botUsername": {
     "label": "Bot-Benutzername",
@@ -20,7 +20,7 @@
     "title": "Kunden-Verbindungslink",
     "description": "Teilen Sie diesen Link mit Kunden, damit sie sich mit Ihrem Bot verbinden können. Ersetzen Sie den Kunden-ID-Platzhalter durch die tatsächliche Kunden-ID.",
     "copied": "Link kopiert!",
-    "copy": "Copy deep link"
+    "copy": "Deep Link kopieren"
   },
   "test": {
     "title": "Testnachricht",

+ 1 - 1
messages/de/telegramMessages.json

@@ -31,7 +31,7 @@
     "messageDeleted": "Nachricht gelöscht",
     "deleteError": "Nachricht konnte nicht gelöscht werden",
     "sendError": "Nachricht konnte nicht gesendet werden",
-    "sendMessage": "Send message"
+    "sendMessage": "Nachricht senden"
   },
   "sendDialog": {
     "send": "{label} senden",

+ 6 - 0
messages/de/tireHotel.json

@@ -72,6 +72,11 @@
   },
   "checkIn": {
     "title": "Reifen einlagern",
+    "sectionOwner": "Kunde und Fahrzeug",
+    "back": "Zurück",
+    "next": "Weiter",
+    "stepStorage": "Regal und Vorbereitung",
+    "stepReview": "Überprüfen",
     "description": "Erfassen Sie, was angekommen ist, und wählen Sie den Lagerplatz.",
     "customer": "Kunde",
     "selectCustomer": "Kunde auswählen",
@@ -358,6 +363,7 @@
     "quantity": "Anz.",
     "noStockItem": "Kein Lagerartikel, Preis von Hand",
     "storeTires": "Reifen einlagern",
+    "noneStored": "Keine Reifen aus diesem Auftrag eingelagert",
     "storage": "Lagerung",
     "storageFee": "Gebühr",
     "storagePeriod": "Zeitraum",

+ 37 - 27
messages/de/vehicles.json

@@ -28,10 +28,10 @@
     "unarchiveError": "Fehler beim Wiederherstellen des Fahrzeugs",
     "deleteError": "Fehler beim Löschen des Fahrzeugs",
     "error": "Fehler beim Laden der Fahrzeuge",
-    "viewTable": "Table view",
-    "viewGrid": "Grid view",
-    "viewLargeGrid": "Large grid view",
-    "openMenu": "Open menu",
+    "viewTable": "Tabellenansicht",
+    "viewGrid": "Rasteransicht",
+    "viewLargeGrid": "Große Rasteransicht",
+    "openMenu": "Menü öffnen",
     "inspectionOverdue": "HU überfällig",
     "inspectionDue30": "Fällig in 30 Tagen",
     "inspectionDue90": "Fällig in 90 Tagen",
@@ -95,12 +95,13 @@
     "severityModerate": "Mäßig — von einigen Besitzern gemeldet",
     "severityUncommon": "Ungewöhnlich — von wenigen Besitzern gemeldet",
     "severityRare": "Selten — vereinzelte Berichte, aber kritisch wenn es auftritt",
-    "openMenu": "Open menu",
-    "dismissMaintenance": "Dismiss",
+    "openMenu": "Menü öffnen",
+    "dismissMaintenance": "Ausblenden",
     "inspectionDue": "HU fällig {date}",
     "inspectionCheckedAt": "Mit dem Register abgeglichen am {date}",
     "notRegistered": "Nicht zugelassen",
-    "soldReported": "Kunde meldet Verkauf am {date}"
+    "soldReported": "Kunde meldet Verkauf am {date}",
+    "typeKey": "HSN/TSN"
   },
   "quotes": {
     "newQuote": "Neues Angebot",
@@ -114,7 +115,7 @@
   "inspections": {
     "newInspection": "Neue Inspektion",
     "empty": "Noch keine Inspektionen",
-    "template": "Vorlage",
+    "template": "Checkliste",
     "progress": "Fortschritt",
     "status": "Status",
     "date": "Datum",
@@ -184,10 +185,10 @@
     "showing": "Zeige {start}-{end} von {total}",
     "perPage": "pro Seite",
     "page": "Seite {page} von {totalPages}",
-    "firstPage": "First page",
-    "previousPage": "Previous page",
-    "nextPage": "Next page",
-    "lastPage": "Last page"
+    "firstPage": "Erste Seite",
+    "previousPage": "Vorherige Seite",
+    "nextPage": "Nächste Seite",
+    "lastPage": "Letzte Seite"
   },
   "notes": {
     "addNote": "Notiz hinzufügen",
@@ -213,11 +214,11 @@
     "noteCreated": "Notiz erstellt",
     "titleRequired": "Titel und Inhalt sind erforderlich",
     "saveError": "Notiz konnte nicht gespeichert werden",
-    "openMenu": "Open menu",
-    "firstPage": "First page",
-    "previousPage": "Previous page",
-    "nextPage": "Next page",
-    "lastPage": "Last page"
+    "openMenu": "Menü öffnen",
+    "firstPage": "Erste Seite",
+    "previousPage": "Vorherige Seite",
+    "nextPage": "Nächste Seite",
+    "lastPage": "Letzte Seite"
   },
   "findings": {
     "sectionTitle": "Beobachtungen",
@@ -266,11 +267,11 @@
     "showing": "Zeige {start}-{end} von {total}",
     "perPage": "pro Seite",
     "page": "Seite {page} von {totalPages}",
-    "firstPage": "First page",
-    "previousPage": "Previous page",
-    "nextPage": "Next page",
-    "lastPage": "Last page",
-    "openMenu": "Open menu",
+    "firstPage": "Erste Seite",
+    "previousPage": "Vorherige Seite",
+    "nextPage": "Nächste Seite",
+    "lastPage": "Letzte Seite",
+    "openMenu": "Menü öffnen",
     "concernLabel": "Beantwortet welches Anliegen?",
     "concernNone": "Nichts, wonach der Kunde gefragt hat"
   },
@@ -323,6 +324,12 @@
     "cvt": "CVT",
     "engineSize": "Hubraum",
     "engineCode": "Motorcode",
+    "hsn": "HSN",
+    "tsn": "TSN",
+    "typeKey": "HSN/TSN",
+    "typeKeyHint": "Aus der Zulassungsbescheinigung Teil I: HSN steht in Feld 2.1, TSN sind die ersten drei Zeichen in Feld 2.2.",
+    "hsnInvalid": "Die HSN besteht aus vier Ziffern, aus Feld 2.1 der Zulassungsbescheinigung.",
+    "tsnInvalid": "Die TSN besteht aus drei Buchstaben oder Ziffern, vom Anfang von Feld 2.2 der Zulassungsbescheinigung.",
     "makeMarine": "Hersteller *",
     "mileageMarine": "Betriebsstunden",
     "transmissionMarine": "Motortyp",
@@ -362,7 +369,10 @@
     "inspectionDue": "Nächste Hauptuntersuchung",
     "inspectionDueHint": "Datum eintragen, wenn kein Register es liefert. Erinnerungen und Dashboard nutzen es.",
     "inspectionDueFromRegistry": "Aus dem Fahrzeugregister. Hier ändern, um es bis zum nächsten Abgleich zu überschreiben.",
-    "lookupEnterPlateOrVin": "Gib zuerst ein Kennzeichen oder eine FIN ein"
+    "lookupVin": "FIN nachschlagen",
+    "lookupEnterVin": "Geben Sie zuerst eine FIN ein",
+    "lookupVinNotFound": "Zu dieser FIN wurde kein Fahrzeug gefunden",
+    "lookupVinFailed": "FIN konnte nicht nachgeschlagen werden"
   },
   "archiveDialog": {
     "title": "Fahrzeug archivieren",
@@ -373,10 +383,10 @@
     "archiveError": "Fehler beim Archivieren des Fahrzeugs"
   },
   "serviceRecords": {
-    "firstPage": "First page",
-    "previousPage": "Previous page",
-    "nextPage": "Next page",
-    "lastPage": "Last page"
+    "firstPage": "Erste Seite",
+    "previousPage": "Vorherige Seite",
+    "nextPage": "Nächste Seite",
+    "lastPage": "Letzte Seite"
   },
   "inspectionReminders": {
     "title": "HU-Erinnerungen",

+ 15 - 9
messages/de/workBoard.json

@@ -8,10 +8,10 @@
     "week": "Woche",
     "presenter": "Präsentator",
     "addTechnician": "Techniker hinzufügen",
-    "previousWeek": "Previous week",
-    "nextWeek": "Next week",
-    "previousDay": "Previous day",
-    "nextDay": "Next day",
+    "previousWeek": "Vorherige Woche",
+    "nextWeek": "Nächste Woche",
+    "previousDay": "Vorheriger Tag",
+    "nextDay": "Nächster Tag",
     "groupBy": "Spalten",
     "byTechnician": "Eine Spalte je Mechaniker",
     "byBay": "Eine Spalte je Arbeitsplatz",
@@ -20,7 +20,7 @@
     "add": "Hinzufügen",
     "addBay": "Arbeitsplatz hinzufügen",
     "weekNumber": "KW {number}",
-    "lanes": "Spalten",
+    "lanes": "Bahnen",
     "showAllLanes": "Alle anzeigen",
     "onlyBusyLanes": "Nur mit Aufträgen",
     "layout": "Ansicht",
@@ -78,7 +78,10 @@
     "addTechnician": "Techniker hinzufügen",
     "standaloneOnly": "Ein Name auf dem Board für jemanden ohne Anmeldung, etwa einen Azubi oder Subunternehmer. Einen Mechaniker mit App fügen Sie auf der Team-Seite hinzu.",
     "linkedTo": "Meldet sich als {name} an",
-    "linkedManagedOnTeam": "Wird auf der Team-Seite verwaltet, zusammen mit dem App-Zugang."
+    "linkedManagedOnTeam": "Wird auf der Team-Seite verwaltet, zusammen mit dem App-Zugang.",
+    "skills": "Fähigkeiten",
+    "skillsPlaceholder": "Diagnose, HV-qualifiziert",
+    "skillsHint": "Wird unter dem Namen angezeigt, wo Arbeit verteilt wird. Optional."
   },
   "jobDetail": {
     "viewDetails": "Details anzeigen",
@@ -107,8 +110,8 @@
       "waitingParts": "Warten auf Teile",
       "completed": "Abgeschlossen"
     },
-    "previous": "Previous",
-    "next": "Next",
+    "previous": "Zurück",
+    "next": "Weiter",
     "groupingUnused": "Die Statustafel gruppiert nach Status",
     "connecting": "Verbinden …"
   },
@@ -154,6 +157,9 @@
     "workBay": "Arbeitsplatz",
     "status": "Status",
     "noTime": "Noch keine Zeit",
-    "none": "Noch niemand"
+    "none": "Noch niemand",
+    "promised": "Zugesagt",
+    "promiseLate": "Überfällig",
+    "promiseOverdue": "Dem Kunden für einen bereits vergangenen Zeitpunkt zugesagt"
   }
 }

+ 8 - 2
messages/de/workOrders.json

@@ -31,14 +31,20 @@
       "status": "Status",
       "tech": "Techniker",
       "date": "Datum",
-      "total": "Gesamt"
+      "total": "Gesamt",
+      "promised": "Zugesagt",
+      "promisedOverdue": "Zugesagt {date}, überfällig"
     },
     "emailSubject": "Fahrzeugstatusupdate: {status}",
-    "changeStatus": "Change status",
+    "changeStatus": "Status ändern",
     "contextMenu": {
       "open": "Arbeitsauftrag öffnen",
       "openVehicle": "Fahrzeug öffnen",
       "openCustomer": "Kunde öffnen"
+    },
+    "dueToday": "Heute fällig",
+    "columns": {
+      "label": "Spalten"
     }
   },
   "vehiclePicker": {

+ 1 - 2
messages/en/admin.json

@@ -24,8 +24,7 @@
   "overview": {
     "totalUsers": "Total Users",
     "organizations": "Organizations",
-    "activeSubscriptions": "Active Subscriptions",
-    "monthlyRevenue": "Monthly Revenue"
+    "activeSubscriptions": "Active Subscriptions"
   },
   "users": {
     "searchPlaceholder": "Search by name or email...",

+ 26 - 12
messages/en/aiChat.json

@@ -1,19 +1,33 @@
 {
-  "title": "AI Assistant",
-  "description": "Ask questions about your workshop data — vehicles, services, customers, revenue, and more.",
-  "placeholder": "Ask about your workshop...",
-  "clear": "Clear conversation",
+  "askAi": "Ask AI",
+  "title": "Ask AI",
+  "aboutVehicle": "Questions about {name}, answered from this vehicle's records only.",
+  "aboutCustomer": "Questions about {name}, answered from this customer's records only.",
+  "placeholder": "Ask a question...",
+  "send": "Send",
+  "stop": "Stop",
+  "stopped": "Stopped.",
+  "newChat": "New chat",
+  "history": "Previous chats",
+  "noChats": "No previous chats about this record",
+  "chatsPrivate": "Your chats are private and only visible to you.",
+  "deleteChat": "Delete chat",
   "error": "Something went wrong. Please try again.",
+  "disclaimer": "Answers are generated from the records shown in the app and can be wrong. Check before you act on them.",
+  "vehicleSuggestion1": "Summarize the service history",
+  "vehicleSuggestion2": "What is due or overdue?",
+  "vehicleSuggestion3": "Are there any recurring problems?",
+  "vehicleSuggestion4": "What was done at the last visit?",
+  "customerSuggestion1": "Which vehicles does this customer have?",
+  "customerSuggestion2": "Are there any unpaid invoices?",
+  "customerSuggestion3": "When was the last visit and what was done?",
+  "customerSuggestion4": "Summarize our recent messages",
+  "assistantTitle": "AI Assistant",
+  "assistantDescription": "Ask questions about your workshop data — vehicles, services, customers, revenue, and more.",
+  "assistantPlaceholder": "Ask about your workshop...",
   "notEnabled": "AI is not connected. Connect OpenAI or Anthropic under Settings → Integrations.",
   "suggestion1": "Which vehicles are due for service?",
   "suggestion2": "Show me unpaid invoices",
   "suggestion3": "What's the revenue this month?",
-  "suggestion4": "Give me a workshop overview",
-  "newChat": "New chat",
-  "noChats": "No conversations yet",
-  "chatsPrivate": "Your chats are private and only visible to you.",
-  "deleteChat": "Delete chat",
-  "hideSidebar": "Hide sidebar",
-  "showSidebar": "Show sidebar",
-  "sendMessage": "Send message"
+  "suggestion4": "Give me a workshop overview"
 }

+ 40 - 12
messages/en/audit.json

@@ -70,6 +70,7 @@
     "note_delete": "Deleted Note",
     "reminder_create": "Created Reminder",
     "reminder_delete": "Deleted Reminder",
+    "reminder_import": "Imported Reminders",
     "organization_create": "Created Organization",
     "subscription_cancel": "Cancelled Subscription",
     "subscription_resume": "Resumed Subscription",
@@ -77,14 +78,16 @@
     "settings_updateInvoiceLayout": "Updated Invoice Layout",
     "settings_updateQuoteLayout": "Updated Quote Layout",
     "settings_updatePortalSlug": "Updated Portal Slug",
-    "inspectionTemplate_create": "Created Inspection Template",
-    "inspectionTemplate_update": "Updated Inspection Template",
-    "inspectionTemplate_delete": "Deleted Inspection Template",
+    "inspectionTemplate_create": "Created Inspection Checklist",
+    "inspectionTemplate_update": "Updated Inspection Checklist",
+    "inspectionTemplate_delete": "Deleted Inspection Checklist",
     "customField_create": "Created Custom Field",
     "customField_update": "Updated Custom Field",
     "customField_delete": "Deleted Custom Field",
     "quoteRequest_update": "Updated Quote Request",
     "auth_login": "User Logged In",
+    "auth_sessionRevoked": "Signed Out a Device",
+    "auth_sessionsRevoked": "Signed Out Other Devices",
     "auth_loginFailed": "Failed Login Attempt",
     "auth_register": "User Registered",
     "auth_passwordReset": "Password Reset Requested",
@@ -101,7 +104,8 @@
     "webhook_delete": "Deleted Webhook",
     "webhook_rotateSecret": "Rotated Webhook Secret",
     "inspection_reopen": "Reopened Inspection",
-    "inspectionTemplate_import": "Imported Inspection Template",
+    "inspectionTemplate_import": "Imported Inspection Checklist",
+    "inspectionTemplate_translate": "Translated Inspection Checklists",
     "scheduled_message_create": "Created Scheduled Message",
     "scheduled_message_update": "Updated Scheduled Message",
     "scheduled_message_delete": "Deleted Scheduled Message",
@@ -166,12 +170,29 @@
     "settings_deleteEmailTemplate": "Deleted Email Template",
     "settings_applyEmailTemplate": "Set Default Email Template",
     "settings_freezeInvoices": "Locked Older Invoices",
+    "settings_reapplyInvoiceDesign": "Re-applied Invoice Design",
+    "invoice_reapplyDesign": "Re-applied Invoice Design",
+    "invoice_reissue": "Updated Sent Invoice to Today's Details",
     "inspection_reminders_sent": "Sent inspection reminders",
     "inspection_reminders_send": "Sent inspection reminders",
     "service_videoCall_send": "Sent video call link",
     "timeEntry_create": "Added time entry",
     "timeEntry_update": "Corrected time entry",
-    "timeEntry_delete": "Deleted time entry"
+    "timeEntry_delete": "Deleted time entry",
+    "settings_workOrderStatus_create": "Added Work Order Status",
+    "settings_workOrderStatus_update": "Changed Work Order Status",
+    "settings_workOrderStatus_archive": "Removed Work Order Status",
+    "inspection_photo_added": "Added Inspection Photo",
+    "inspection_file_added": "Added Inspection File",
+    "signature_save": "Saved Signature",
+    "signature_remove": "Removed Signature",
+    "conditionMark_add": "Added a condition mark",
+    "conditionMark_remove": "Removed a condition mark",
+    "conditionMark_onInvoice": "Condition map on invoice",
+    "settings_markType_create": "Added a kind of mark",
+    "settings_markType_update": "Changed a kind of mark",
+    "settings_markType_remove": "Removed a kind of mark",
+    "settings_markType_reorder": "Reordered the kinds of mark"
   },
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
@@ -188,12 +209,12 @@
     "finding_delete": "Deleted finding {id}",
     "finding_resolve": "Resolved finding \"{description}\"",
     "finding_update": "Updated finding \"{description}\"",
-    "inspectionTemplate_create": "Created inspection template \"{name}\"",
-    "inspectionTemplate_createFromPreset": "Created inspection template \"{name}\" from a preset",
-    "inspectionTemplate_delete": "Deleted inspection template \"{name}\"",
-    "inspectionTemplate_duplicate": "Duplicated inspection template into \"{name}\"",
-    "inspectionTemplate_import": "Imported {count, plural, one {# inspection template} other {# inspection templates}} from a package",
-    "inspectionTemplate_update": "Updated inspection template \"{name}\"",
+    "inspectionTemplate_create": "Created inspection checklist \"{name}\"",
+    "inspectionTemplate_createFromPreset": "Created inspection checklist \"{name}\" from a preset",
+    "inspectionTemplate_delete": "Deleted inspection checklist \"{name}\"",
+    "inspectionTemplate_duplicate": "Duplicated inspection checklist into \"{name}\"",
+    "inspectionTemplate_import": "Imported {count, plural, one {# inspection checklist} other {# inspection checklists}} from a package",
+    "inspectionTemplate_update": "Updated inspection checklist \"{name}\"",
     "inspection_complete": "Completed inspection {id}",
     "inspection_create": "Created inspection {id}",
     "inspection_delete": "Deleted inspection {id}",
@@ -222,6 +243,7 @@
     "recurringInvoice_update": "Updated recurring invoice \"{title}\"",
     "reminder_create": "Created reminder \"{title}\"",
     "reminder_delete": "Deleted reminder {id}",
+    "reminder_import": "Imported {count, plural, one {# reminder} other {# reminders}} from {fileName}",
     "role_create": "Created role \"{name}\"",
     "role_delete": "Deleted role \"{name}\"",
     "role_update": "Updated role \"{name}\"",
@@ -310,11 +332,17 @@
     "settings_deleteEmailTemplate": "Deleted the email template {name}",
     "settings_applyEmailTemplate": "Made {name} the template for {kind} emails",
     "settings_freezeInvoices": "Locked {count, plural, one {# invoice} other {# invoices}} from before locking with the current design",
+    "settings_reapplyInvoiceDesign": "Re-applied the current design to {count, plural, one {# invoice} other {# invoices}} already sent",
     "inspection_reminders_sent": "Sent {count} inspection reminders",
     "service_videoCall_send": "Sent the video call link via {channels}",
     "timeEntry_create": "Added {minutes} minutes for {technician} on \"{job}\"",
     "timeEntry_update": "Corrected time entry for {technician} on \"{job}\" to {minutes} minutes",
     "timeEntry_stop": "Stopped the clock for {technician} on \"{job}\" at {minutes} minutes",
-    "timeEntry_delete": "Deleted time entry {id}"
+    "timeEntry_delete": "Deleted time entry {id}",
+    "inspection_phoneUpload": "Added a file from a phone to inspection {ref}",
+    "condition_mark_add": "Added a mark to the condition map",
+    "condition_mark_remove": "Removed a mark from the condition map",
+    "condition_map_on_invoice_on": "Printed the condition map on the invoice",
+    "condition_map_on_invoice_off": "Left the condition map off the invoice"
   }
 }

+ 2 - 1
messages/en/auth.json

@@ -48,7 +48,8 @@
   "social": {
     "google": "Continue with Google",
     "orEmail": "or continue with email",
-    "failed": "Google sign-in did not complete. Try again, or use your email and password."
+    "failed": "Google sign-in did not complete. Try again, or use your email and password.",
+    "notLinked": "An account with this email already exists but its address has not been verified yet, so Google could not be joined to it. Sign in with your password; once the address is verified, Google sign-in will work too."
   },
   "forgotPassword": {
     "title": "Reset your password",

+ 3 - 1
messages/en/billing.json

@@ -25,7 +25,9 @@
     "deliveryViewed": "Viewed",
     "deliveryViewedOn": "Last opened {date}",
     "filterUnviewed": "Not viewed",
-    "filterUnviewedHint": "Invoices that were sent but have never been opened"
+    "filterUnviewedHint": "Invoices that were sent but have never been opened",
+    "filterCompleted": "Completed only",
+    "filterCompletedHint": "Show only work orders marked completed. Remembered for next time."
   },
   "recurring": {
     "title": "Recurring",

+ 22 - 1
messages/en/common.json

@@ -110,5 +110,26 @@
     "failed": "Could not generate the PDF preview."
   },
   "attachPdf": "Attach PDF",
-  "attachPdfHint": "Turn this off to send a link instead, so you can see when the customer opens it."
+  "attachPdfHint": "Turn this off to send a link instead, so you can see when the customer opens it.",
+  "sentCopy": {
+    "note": "Sent {date}. Changes here go on the invoice when it is sent again.",
+    "confirmTitle": "This invoice has already been sent",
+    "confirmBody": "The customer's copy keeps what it was sent with. Your change is saved on the job and goes on the invoice when it is reopened and sent again.",
+    "confirm": "Save for the next send",
+    "cancel": "Cancel"
+  },
+  "videoRecorder": {
+    "title": "Record a video",
+    "starting": "Starting the camera...",
+    "denied": "The browser was not allowed to use the camera or microphone. Allow them for this site from the browser's address bar, then try again.",
+    "noCamera": "No camera was found on this computer.",
+    "unsupported": "This browser cannot record video. Upload a file instead.",
+    "camera": "Camera",
+    "start": "Start recording",
+    "stop": "Stop",
+    "recording": "Recording",
+    "retake": "Record again",
+    "use": "Use this video",
+    "limit": "Recording stops by itself after {minutes} minutes."
+  }
 }

+ 154 - 0
messages/en/conditionMap.json

@@ -0,0 +1,154 @@
+{
+  "title": "Vehicle condition",
+  "description": "Tap the drawing where the damage is. Each mark gets a type, a severity, a note and a photo.",
+  "addHint": "Tap a panel to add a mark",
+  "bodyType": "Body type",
+  "legend": "Marks",
+  "noMarks": "No marks yet. Tap the drawing where the damage is.",
+  "previousHeading": "Recorded on an earlier visit",
+  "previousHint": "Still there from last time, drawn in grey. Clear the ones that are gone.",
+  "newSinceLastVisit": "New since the last visit",
+  "confirmed": "Still there",
+  "clearRepaired": "Clear as repaired",
+  "restore": "Restore",
+  "edit": "Edit mark",
+  "remove": "Remove mark",
+  "removeTitle": "Remove this mark?",
+  "removeBody": "Mark {n} on the {area} and its note will be removed.",
+  "kind": "Type",
+  "severity": "Severity",
+  "note": "Note",
+  "notePlaceholder": "Where exactly, how big, anything the customer said",
+  "photo": "Photo",
+  "addPhoto": "Add photo",
+  "takePhoto": "Take photo",
+  "removePhoto": "Remove photo",
+  "save": "Save mark",
+  "done": "Done",
+  "cancel": "Cancel",
+  "moveHint": "Drag a mark to move it",
+  "enlarge": "Enlarge {view}",
+  "allViews": "All views",
+  "markLabel": "Mark {n}: {kind} on the {area}",
+  "markOn": "{kind} on the {area}",
+  "photosOf": "Photos of mark {n}",
+  "saved": "Saved",
+  "saveFailed": "Could not save the mark",
+  "uploadFailed": "Could not upload the photo",
+  "readOnly": "Reopen to change the condition map.",
+  "readOnlyLinked": "Drawn on the linked inspection. Change it there.",
+  "readOnlyEarlier": "Recorded on an earlier visit. Clear it as repaired once it is gone.",
+  "readOnlyOtherCheck": "Drawn on another check of this inspection. Change it there.",
+  "count": "{count, plural, =0 {No marks} one {# mark} other {# marks}}",
+  "previousCount": "{count, plural, one {# from an earlier visit} other {# from earlier visits}}",
+  "fromInspection": "From the linked inspection, changed there",
+  "fromOtherCheck": "From another check on this inspection",
+  "views": {
+    "top": "Top",
+    "left": "Left side",
+    "right": "Right side",
+    "front": "Front",
+    "rear": "Rear"
+  },
+  "bodies": {
+    "sedan": "Sedan",
+    "hatchback": "Hatchback",
+    "estate": "Estate",
+    "suv": "SUV",
+    "van": "Van",
+    "pickup": "Pickup",
+    "motorcycle": "Motorcycle",
+    "boat": "Boat"
+  },
+  "kinds": {
+    "dent": "Dent",
+    "scratch": "Scratch",
+    "chip": "Stone chip",
+    "crack": "Crack",
+    "rust": "Rust",
+    "paint": "Paint damage",
+    "previous_repair": "Previous repair",
+    "missing": "Missing part"
+  },
+  "severities": {
+    "minor": "Minor",
+    "major": "Major"
+  },
+  "panels": {
+    "hood": "Bonnet",
+    "roof": "Roof",
+    "trunk": "Boot lid",
+    "tailgate": "Tailgate",
+    "front_bumper": "Front bumper",
+    "rear_bumper": "Rear bumper",
+    "grille": "Grille",
+    "windshield": "Windscreen",
+    "rear_window": "Rear window",
+    "left_headlight": "Left headlight",
+    "right_headlight": "Right headlight",
+    "left_taillight": "Left tail light",
+    "right_taillight": "Right tail light",
+    "left_front_fender": "Left front wing",
+    "right_front_fender": "Right front wing",
+    "left_front_door": "Left front door",
+    "right_front_door": "Right front door",
+    "left_rear_door": "Left rear door",
+    "right_rear_door": "Right rear door",
+    "left_rear_quarter": "Left rear quarter",
+    "right_rear_quarter": "Right rear quarter",
+    "left_sill": "Left sill",
+    "right_sill": "Right sill",
+    "left_mirror": "Left mirror",
+    "right_mirror": "Right mirror",
+    "left_front_wheel": "Left front wheel",
+    "right_front_wheel": "Right front wheel",
+    "left_rear_wheel": "Left rear wheel",
+    "right_rear_wheel": "Right rear wheel",
+    "left_a_pillar": "Left A-pillar",
+    "right_a_pillar": "Right A-pillar",
+    "left_windows": "Left side windows",
+    "right_windows": "Right side windows",
+    "left_sliding_door": "Left sliding door",
+    "right_sliding_door": "Right sliding door",
+    "left_side_panel": "Left side panel",
+    "right_side_panel": "Right side panel",
+    "rear_doors": "Rear doors",
+    "bed": "Load bed",
+    "bed_side_left": "Left bed side",
+    "bed_side_right": "Right bed side",
+    "tank": "Tank",
+    "seat": "Seat",
+    "front_fairing": "Front fairing",
+    "left_fairing": "Left fairing",
+    "right_fairing": "Right fairing",
+    "front_fender": "Front mudguard",
+    "rear_fender": "Rear mudguard",
+    "front_wheel": "Front wheel",
+    "rear_wheel": "Rear wheel",
+    "exhaust": "Exhaust",
+    "handlebars": "Handlebars",
+    "headlamp": "Headlamp",
+    "tail": "Tail",
+    "bow": "Bow",
+    "stern": "Stern",
+    "transom": "Transom",
+    "port_hull": "Port hull",
+    "starboard_hull": "Starboard hull",
+    "deck": "Deck",
+    "cabin": "Cabin",
+    "windscreen": "Windscreen",
+    "gunwale_port": "Port gunwale",
+    "gunwale_starboard": "Starboard gunwale",
+    "keel": "Keel",
+    "outboard": "Outboard"
+  },
+  "print": {
+    "previous": "recorded earlier"
+  },
+  "elsewhere": "{count, plural, one {# mark was drawn on another body type and is listed on the print only} other {# marks were drawn on another body type and are listed on the print only}}",
+  "photoCount": "{count, plural, one {# photo} other {# photos}}",
+  "clearBody": "Mark {n} on the {area} will be recorded as repaired. It stays in the history and stops printing.",
+  "manageKinds": "Manage kinds",
+  "photosHeading": "Photos",
+  "previousShort": "earlier"
+}

+ 3 - 0
messages/en/dataImport.json

@@ -180,6 +180,8 @@
       "transmission": "Transmission",
       "engineSize": "Engine size",
       "engineCode": "Engine code",
+      "hsn": "HSN (manufacturer key)",
+      "tsn": "TSN (type key)",
       "purchaseDate": "Purchase date",
       "purchasePrice": "Purchase price"
     },
@@ -198,6 +200,7 @@
     "customer_name_required": "Customer name is missing",
     "invalid_email": "Email \"{value}\" is not valid and was left out",
     "invalid_vin": "VIN \"{value}\" is not 17 characters; kept as typed",
+    "invalid_type_key": "{field} \"{value}\" is not a valid HSN or TSN; left out",
     "vehicle_make_model_required": "Make and model are missing",
     "vehicle_year_required": "Year is missing",
     "vehicle_year_invalid": "Year \"{value}\" is not a valid year",

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott