Bernt Christian Egeland 3 недель назад
Родитель
Сommit
43e65a1e6b
100 измененных файлов с 3503 добавлено и 686 удалено
  1. 10 0
      .dockerignore
  2. 4 0
      .env.example
  3. 3 0
      .github/workflows/deploy-demo.yml
  4. 5 1
      .github/workflows/deploy-prod.yml
  5. 5 1
      .github/workflows/deploy-staging.yml
  6. 8 4
      .github/workflows/e2e.yml
  7. 34 53
      Dockerfile
  8. 3 3
      e2e/README.md
  9. 10 1
      e2e/specs/cloud/subscription-handoff.spec.ts
  10. 513 0
      e2e/specs/condition-map/carry-forward.spec.ts
  11. 653 0
      e2e/specs/quotes/add-to-work-order-conflicts.spec.ts
  12. 396 0
      e2e/specs/quotes/add-to-work-order.spec.ts
  13. 4 1
      e2e/specs/smoke/app.spec.ts
  14. 16 2
      e2e/specs/work-orders/modern-page.spec.ts
  15. 57 0
      e2e/support/condition-map-db.ts
  16. 246 0
      e2e/support/db.ts
  17. 155 0
      e2e/support/quote-conversion.ts
  18. 11 0
      e2e/torqvoice-com-standin.ts
  19. 7 0
      init-db.sh
  20. 3 0
      messages/de/audit.json
  21. 3 0
      messages/de/conditionMap.json
  22. 39 0
      messages/de/integrations.json
  23. 2 0
      messages/de/pdf.json
  24. 40 0
      messages/de/quotes.json
  25. 2 0
      messages/de/service.json
  26. 32 62
      messages/de/settings.json
  27. 15 0
      messages/de/timeTracking.json
  28. 3 0
      messages/en/audit.json
  29. 3 0
      messages/en/conditionMap.json
  30. 39 0
      messages/en/integrations.json
  31. 2 0
      messages/en/pdf.json
  32. 40 0
      messages/en/quotes.json
  33. 2 0
      messages/en/service.json
  34. 32 62
      messages/en/settings.json
  35. 15 0
      messages/en/timeTracking.json
  36. 3 0
      messages/es/audit.json
  37. 3 0
      messages/es/conditionMap.json
  38. 39 0
      messages/es/integrations.json
  39. 2 0
      messages/es/pdf.json
  40. 40 0
      messages/es/quotes.json
  41. 2 0
      messages/es/service.json
  42. 32 62
      messages/es/settings.json
  43. 15 0
      messages/es/timeTracking.json
  44. 3 0
      messages/fr/audit.json
  45. 3 0
      messages/fr/conditionMap.json
  46. 39 0
      messages/fr/integrations.json
  47. 2 0
      messages/fr/pdf.json
  48. 40 0
      messages/fr/quotes.json
  49. 2 0
      messages/fr/service.json
  50. 32 62
      messages/fr/settings.json
  51. 15 0
      messages/fr/timeTracking.json
  52. 3 0
      messages/it/audit.json
  53. 3 0
      messages/it/conditionMap.json
  54. 39 0
      messages/it/integrations.json
  55. 2 0
      messages/it/pdf.json
  56. 40 0
      messages/it/quotes.json
  57. 2 0
      messages/it/service.json
  58. 32 62
      messages/it/settings.json
  59. 15 0
      messages/it/timeTracking.json
  60. 3 0
      messages/lt/audit.json
  61. 3 0
      messages/lt/conditionMap.json
  62. 39 0
      messages/lt/integrations.json
  63. 2 0
      messages/lt/pdf.json
  64. 40 0
      messages/lt/quotes.json
  65. 2 0
      messages/lt/service.json
  66. 32 62
      messages/lt/settings.json
  67. 15 0
      messages/lt/timeTracking.json
  68. 3 0
      messages/nb/audit.json
  69. 3 0
      messages/nb/conditionMap.json
  70. 39 0
      messages/nb/integrations.json
  71. 2 0
      messages/nb/pdf.json
  72. 40 0
      messages/nb/quotes.json
  73. 2 0
      messages/nb/service.json
  74. 32 62
      messages/nb/settings.json
  75. 15 0
      messages/nb/timeTracking.json
  76. 3 0
      messages/nl/audit.json
  77. 3 0
      messages/nl/conditionMap.json
  78. 39 0
      messages/nl/integrations.json
  79. 2 0
      messages/nl/pdf.json
  80. 40 0
      messages/nl/quotes.json
  81. 2 0
      messages/nl/service.json
  82. 32 62
      messages/nl/settings.json
  83. 15 0
      messages/nl/timeTracking.json
  84. 3 0
      messages/pl/audit.json
  85. 3 0
      messages/pl/conditionMap.json
  86. 39 0
      messages/pl/integrations.json
  87. 2 0
      messages/pl/pdf.json
  88. 40 0
      messages/pl/quotes.json
  89. 2 0
      messages/pl/service.json
  90. 32 62
      messages/pl/settings.json
  91. 15 0
      messages/pl/timeTracking.json
  92. 3 0
      messages/pt-BR/audit.json
  93. 3 0
      messages/pt-BR/conditionMap.json
  94. 39 0
      messages/pt-BR/integrations.json
  95. 2 0
      messages/pt-BR/pdf.json
  96. 40 0
      messages/pt-BR/quotes.json
  97. 2 0
      messages/pt-BR/service.json
  98. 32 62
      messages/pt-BR/settings.json
  99. 15 0
      messages/pt-BR/timeTracking.json
  100. 3 0
      messages/pt-PT/audit.json

+ 10 - 0
.dockerignore

@@ -9,3 +9,13 @@ plans
 public/uploads
 backups
 data/uploads
+.github
+.claude
+.vscode
+e2e
+test-results
+playwright-report
+playwright.config.ts
+vitest.config.ts
+tsconfig.tsbuildinfo
+src/__tests__

+ 4 - 0
.env.example

@@ -47,3 +47,7 @@ ZOOM_INTEGRATION_CLIENT_SECRET=""
 # the connector works out which it was given.
 QUICKBOOKS_INTEGRATION_CLIENT_ID=""
 QUICKBOOKS_INTEGRATION_CLIENT_SECRET=""
+# The app registered in Fiken under Rediger konto, API. While it is in
+# development only the Fiken users listed on the app can connect.
+FIKEN_INTEGRATION_CLIENT_ID=""
+FIKEN_INTEGRATION_CLIENT_SECRET=""

+ 3 - 0
.github/workflows/deploy-demo.yml

@@ -189,7 +189,10 @@ jobs:
         # dead. A boolean input is a real boolean in the `inputs` context.
         if: ${{ github.event_name == 'workflow_dispatch' && inputs.reseed }}
         run: |
+          # The seed runs from /app/tools, beside the full Prisma client and
+          # tsx the image keeps there for it (see Dockerfile).
           docker exec \
+            -w /app/tools \
             -e DEMO_USER_EMAIL="demo@torqvoice.com" \
             -e DEMO_USER_PASSWORD="demo" \
             -e DATA_ROOT=/app/data \

+ 5 - 1
.github/workflows/deploy-prod.yml

@@ -70,11 +70,13 @@ jobs:
           ZOOM_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_ZOOM_INTEGRATION_CLIENT_SECRET }}
           QUICKBOOKS_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_QUICKBOOKS_INTEGRATION_CLIENT_ID }}
           QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
+          FIKEN_INTEGRATION_CLIENT_ID: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_ID }}
+          FIKEN_INTEGRATION_CLIENT_SECRET: ${{ secrets.CLOUD_FIKEN_INTEGRATION_CLIENT_SECRET }}
         run: |
           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|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .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_|FIKEN_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -120,6 +122,8 @@ jobs:
                 ZOOM_INTEGRATION_CLIENT_SECRET: ${ZOOM_INTEGRATION_CLIENT_SECRET}
                 QUICKBOOKS_INTEGRATION_CLIENT_ID: ${QUICKBOOKS_INTEGRATION_CLIENT_ID}
                 QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
+                FIKEN_INTEGRATION_CLIENT_ID: ${FIKEN_INTEGRATION_CLIENT_ID}
+                FIKEN_INTEGRATION_CLIENT_SECRET: ${FIKEN_INTEGRATION_CLIENT_SECRET}
               networks:
                 - proxy
           COMPOSE

+ 5 - 1
.github/workflows/deploy-staging.yml

@@ -54,11 +54,13 @@ jobs:
           ZOOM_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_ZOOM_INTEGRATION_CLIENT_SECRET }}
           QUICKBOOKS_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_QUICKBOOKS_INTEGRATION_CLIENT_ID }}
           QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_QUICKBOOKS_INTEGRATION_CLIENT_SECRET }}
+          FIKEN_INTEGRATION_CLIENT_ID: ${{ secrets.STAGING_FIKEN_INTEGRATION_CLIENT_ID }}
+          FIKEN_INTEGRATION_CLIENT_SECRET: ${{ secrets.STAGING_FIKEN_INTEGRATION_CLIENT_SECRET }}
         run: |
           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|TORQVOICE_|POSTHOG_|BACKUP_|INTEGRATIONS_|GOOGLE_INTEGRATION_|GOOGLE_AUTH_|MICROSOFT_INTEGRATION_|ZOOM_INTEGRATION_|QUICKBOOKS_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .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_|FIKEN_INTEGRATION_|NEXT_SERVER_ACTIONS_)' > .env
 
           cat > docker-compose.yml << 'COMPOSE'
           networks:
@@ -101,6 +103,8 @@ jobs:
                 ZOOM_INTEGRATION_CLIENT_SECRET: ${ZOOM_INTEGRATION_CLIENT_SECRET}
                 QUICKBOOKS_INTEGRATION_CLIENT_ID: ${QUICKBOOKS_INTEGRATION_CLIENT_ID}
                 QUICKBOOKS_INTEGRATION_CLIENT_SECRET: ${QUICKBOOKS_INTEGRATION_CLIENT_SECRET}
+                FIKEN_INTEGRATION_CLIENT_ID: ${FIKEN_INTEGRATION_CLIENT_ID}
+                FIKEN_INTEGRATION_CLIENT_SECRET: ${FIKEN_INTEGRATION_CLIENT_SECRET}
               networks:
                 - proxy
           COMPOSE

+ 8 - 4
.github/workflows/e2e.yml

@@ -28,10 +28,14 @@ jobs:
       fail-fast: false
       matrix:
         include:
-          - { id: shard-1, name: shard 1 of 4, shard: 1/4 }
-          - { id: shard-2, name: shard 2 of 4, shard: 2/4 }
-          - { id: shard-3, name: shard 3 of 4, shard: 3/4 }
-          - { id: shard-4, name: shard 4 of 4, shard: 4/4 }
+          - { id: shard-1, name: shard 1 of 8, shard: 1/8 }
+          - { id: shard-2, name: shard 2 of 8, shard: 2/8 }
+          - { id: shard-3, name: shard 3 of 8, shard: 3/8 }
+          - { id: shard-4, name: shard 4 of 8, shard: 4/8 }
+          - { id: shard-5, name: shard 5 of 8, shard: 5/8 }
+          - { id: shard-6, name: shard 6 of 8, shard: 6/8 }
+          - { id: shard-7, name: shard 7 of 8, shard: 7/8 }
+          - { id: shard-8, name: shard 8 of 8, shard: 8/8 }
           # The same build started in cloud mode: plan limits, the sign-up
           # pitch and Google sign-in only exist there.
           - { id: cloud, name: cloud, mode: cloud }

+ 34 - 53
Dockerfile

@@ -27,6 +27,17 @@ ENV NEXT_TELEMETRY_DISABLED=1
 
 RUN npm run build
 
+# What the container runs beside the server: the Prisma CLI for the migrations
+# at start, and tsx with a full Prisma client for the demo seed. Installed in
+# a folder of its own, with no package.json beside it. Run inside /app, where
+# the standalone build keeps the project's package.json, the same install
+# pulled in every production dependency the project has, replaced the patched
+# next the build had traced, and made the image well over a gigabyte larger.
+FROM base AS tools
+WORKDIR /tools
+RUN npm install --no-save --no-audit --no-fund \
+      prisma@7.6.0 @prisma/client@7.6.0 @prisma/adapter-pg@7.6.0 pg dotenv tsx
+
 # Production image, copy all the files and run next
 FROM base AS runner
 WORKDIR /app
@@ -49,65 +60,35 @@ RUN mkdir .next && chown nextjs:nodejs .next
 COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
 COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
-# Copy prisma schema and config for migrations
-COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
-COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./prisma.config.ts
-
 # Copy generated Prisma client
 COPY --from=builder --chown=nextjs:nodejs /app/src/generated ./src/generated
 
-# Give the install below the lockfile the app was built with. Without it npm
-# re-resolves the whole tree against the live registry on every build, so the
-# runtime image drifts from what was tested, and a registry change can break
-# the build with nothing changed here (npm 10 crashed with "Cannot read
-# properties of null (reading 'edgesOut')" resolving vitest's peers this way).
-COPY --from=builder --chown=nextjs:nodejs /app/package-lock.json ./package-lock.json
-
-# Remove Next.js standalone's traced stubs for pg/prisma (tracer copies package.json
-# but not all files), then install the full runtime packages fresh.
-RUN rm -rf \
-      /app/node_modules/pg \
-      /app/node_modules/pg-types \
-      /app/node_modules/pg-pool \
-      /app/node_modules/pg-connection-string \
-      /app/node_modules/pg-protocol \
-      /app/node_modules/pg-int8 \
-      /app/node_modules/pg-cloudflare \
-      /app/node_modules/pgpass \
-      /app/node_modules/postgres-array \
-      /app/node_modules/postgres-bytea \
-      /app/node_modules/postgres-date \
-      /app/node_modules/postgres-interval \
-      /app/node_modules/split2 \
-      /app/node_modules/prisma \
-      /app/node_modules/@prisma \
-      /app/node_modules/.prisma \
-    && npm install --omit=dev prisma@7.6.0 @prisma/client@7.6.0 @prisma/adapter-pg@7.6.0 pg dotenv tsx sharp
-
-# The npm install above re-resolves the dependency tree and replaces the
-# standalone build's PATCHED next package with a fresh unpatched copy from the
-# registry, which silently breaks all WebSocket routes (live updates, work
-# board sync). Re-apply the next-ws patch so the runtime server can accept
-# WebSocket upgrades. next-ws itself is already in the standalone bundle.
+# The server runs on the node_modules Next traced for it. That is exactly what
+# the build ran with: next carrying the next-ws patch and the first-upgrade fix
+# the prepare script applied in the deps stage, and sharp with the binary for
+# this image's platform. Nothing is installed over it, so none of that has to
+# be patched or resolved a second time here.
 #
-# sharp is named in that install for the same reason. It is the only native
-# module in the tree, its binary lives in a platform-specific optional
-# dependency (@img/sharp-linuxmusl-x64 on this image), and re-resolving the
-# tree can leave the traced copy without one. Installing it by name makes npm
-# resolve the binary against the platform the image will actually run on.
-RUN npx next-ws patch --yes
-
-# next-ws reads a route module's exports the moment an upgrade arrives, and
-# Next 16.3 loads route modules lazily, so the first WebSocket connection after
-# every boot died with "The lazy module is still loading" until the fix below
-# is applied to the fresh copy the install above pulled in.
+# The traced node_modules is not a full install, though: several packages are
+# a package.json with their entry point pruned away, and a plain Node script
+# run in /app dies on a missing module. So what runs beside the server lives
+# in /app/tools with its own complete node_modules: the migrations (see
+# init-db.sh) and the demo seed (see .github/workflows/deploy-demo.yml), each
+# next to the files it reads.
+COPY --from=tools --chown=nextjs:nodejs /tools/node_modules ./tools/node_modules
+COPY --from=builder --chown=nextjs:nodejs /app/prisma ./tools/prisma
+COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./tools/prisma.config.ts
+COPY --from=builder --chown=nextjs:nodejs /app/src/generated ./tools/src/generated
+
+# Fail the build here rather than at the first live update or certificate
+# download. WebSocket routes only work on a next that carries the next-ws
+# patch, with next-ws waiting for the lazily loaded route module; a native
+# module that cannot be loaded throws while its route is being evaluated,
+# which reaches the browser as an empty HTTP 500 with nothing to explain it.
 COPY --from=builder --chown=nextjs:nodejs /app/scripts/patch-next-ws-first-upgrade.mjs ./scripts/patch-next-ws-first-upgrade.mjs
+RUN grep -qs "next-ws" node_modules/next/dist/server/next-server.js \
+      || { echo "the traced next is missing the next-ws patch"; exit 1; }
 RUN node scripts/patch-next-ws-first-upgrade.mjs
-
-# Fail the build here rather than at the first certificate download: a native
-# module that cannot be loaded throws while the route module is being
-# evaluated, which reaches the browser as an empty HTTP 500 with nothing in it
-# to explain itself.
 RUN node -e "require('sharp'); console.log('sharp loads')"
 
 # Copy init script

+ 3 - 3
e2e/README.md

@@ -137,8 +137,8 @@ you are about to read twice in one file.
 ## In CI
 
 `.github/workflows/e2e.yml` runs the suite on every pull request to main, and on
-demand from the Actions tab. The specs are split into four shards
-(`--shard=1/4` and so on) plus a job for the cloud specs, all at once. Each job
+demand from the Actions tab. The specs are split into eight shards
+(`--shard=1/8` and so on) plus a job for the cloud specs, all at once. Each job
 has its own `postgres:16-alpine` service holding `torqvoice_e2e`, installs
 Chromium, builds with `NEXT_PUBLIC_APP_URL=http://127.0.0.1:3100`, and seeds its
 own database, so the one-test-at-a-time rule still holds inside every job.
@@ -147,7 +147,7 @@ A spec that only passes because another file ran before it will fail here.
 To run one shard the way CI does:
 
 ```bash
-npx playwright test --shard=2/4
+npx playwright test --shard=2/8
 ```
 
 On CI every job writes a blob report, and the last job, `Playwright`, merges them

+ 10 - 1
e2e/specs/cloud/subscription-handoff.spec.ts

@@ -102,19 +102,28 @@ test.describe('buying a plan', () => {
     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('interval')).toHaveText('year')
     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 }) => {
+  test('names this app as the origin of the purchase, and the interval chosen', async ({
+    page,
+    baseURL,
+  }) => {
     await page.goto('/settings/subscription')
     await settle(page)
+    // The prices come from the stand-in, quoted per month either way.
+    await expect(page.getByText('$290 billed annually')).toBeVisible()
+    await page.getByRole('radio', { name: 'Monthly' }).click()
+    await expect(page.getByText('$79')).toBeVisible()
     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('interval')).toHaveText('month')
     await expect(page.getByTestId('appUrl')).toHaveText(new URL(baseURL ?? '').origin)
   })
 })

+ 513 - 0
e2e/specs/condition-map/carry-forward.spec.ts

@@ -0,0 +1,513 @@
+import { expect, type Locator, type Page, type Request, test } from '@playwright/test'
+import {
+  certificatePdf,
+  conditionMap,
+  earlierSection,
+  markEditor,
+  openDropoff,
+  ownMarks,
+  printsMap,
+  workOrderPdf,
+} from '../../support/condition-map'
+import { type CarryMarkRow, marksWithCarry, setMarkPhotos } from '../../support/condition-map-db'
+import {
+  ownerOrganizationId,
+  plantConditionInspection,
+  plantConditionJob,
+  plantConditionMark,
+  plantJobOnVehicle,
+  userIdFor,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { TINY_PNG } from '../../support/pdf'
+
+/**
+ * "Confirm all as still there": the marks still open from earlier visits,
+ * listed grey under the map, recorded again on this sheet as this visit's
+ * own in one press. Each earlier mark is closed in the same go and points at
+ * the mark that took its place (`carriedToId`), so:
+ *
+ * - this sheet now holds them in colour, with the note and photos they had;
+ * - the earlier sheet still shows and prints them as its own record, read
+ *   only, since the newer copy is the one that changes now;
+ * - the vehicle meets the dent once: a newer visit sees the copies, never
+ *   the originals as well;
+ * - pressing it again finds nothing left to take.
+ *
+ * Every test plants a car of its own, with the earlier job a month back and
+ * the job doing the confirming ten days back, so a third job opened today is
+ * the newest visit.
+ */
+
+const stamp = Date.now()
+const DAY = 24 * 60 * 60 * 1000
+/** The grey every earlier visit's mark is drawn in (PREVIOUS_MARK_COLOR). */
+const GREY = '#9ca3af'
+const CARRIED_TEXT =
+  "Confirmed again on a later visit. It stays here as this visit's record and is changed there."
+
+// Each test opens three or four pages and reads PDFs; a minute is tight.
+test.describe.configure({ timeout: 180_000 })
+
+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}`
+
+/** Every mark drawn on the map itself (the legend's icons carry no role). */
+const glyphs = (map: Locator) => map.locator('svg[role="img"] g[role="button"]')
+/** The ones drawn grey: an earlier visit's. */
+const greyGlyphs = (map: Locator) =>
+  glyphs(map).filter({ has: map.page().locator(`[stroke="${GREY}"]`) })
+/** The buttons that open a mark listed under "Recorded on an earlier visit". */
+const earlierMarks = (map: Locator) =>
+  earlierSection(map).getByRole('button', { name: /^Mark \d+:/ })
+
+/**
+ * A car with two marks from a visit a month ago (a dent on the bonnet with a
+ * note, a major scratch on the left front door), and the job ten days ago
+ * that is to confirm them.
+ */
+async function carWithTwoEarlierMarks(label: string) {
+  const earlier = await plantConditionJob(
+    organizationId,
+    userId,
+    `${label} earlier ${stamp}`,
+    new Date(Date.now() - 30 * DAY)
+  )
+  const note = `Dent by the badge ${label} ${stamp}`
+  const hood = await plantConditionMark(
+    organizationId,
+    earlier.vehicleId,
+    { serviceRecordId: earlier.serviceRecordId },
+    {
+      view: 'top',
+      panel: 'hood',
+      kind: 'dent',
+      note,
+      recordedAt: new Date(Date.now() - 30 * DAY),
+    }
+  )
+  const door = await plantConditionMark(
+    organizationId,
+    earlier.vehicleId,
+    { serviceRecordId: earlier.serviceRecordId },
+    {
+      view: 'left',
+      panel: 'left_front_door',
+      kind: 'scratch',
+      severity: 'major',
+      recordedAt: new Date(Date.now() - 30 * DAY + 60_000),
+    }
+  )
+  const later = await plantJobOnVehicle(
+    organizationId,
+    earlier.vehicleId,
+    `${label} later ${stamp}`,
+    new Date(Date.now() - 10 * DAY)
+  )
+  return { earlier, later, hood, door, note, vehicleId: earlier.vehicleId }
+}
+
+/**
+ * Presses "Confirm all as still there" and waits for the earlier marks to be
+ * closed in the database. Repeated until the grey list goes: a press before
+ * hydration does nothing.
+ */
+async function confirmAll(map: Locator, vehicleId: string, expected: number) {
+  const button = map.getByTestId('condition-map-confirm-all')
+  await expect(button).toHaveText('Confirm all as still there')
+  await expect(async () => {
+    await button.click()
+    await expect(earlierSection(map)).toHaveCount(0, { timeout: 3_000 })
+  }).toPass({ timeout: 30_000 })
+  await expect
+    .poll(async () => (await marksWithCarry(vehicleId)).filter((m) => m.carriedToId).length, {
+      message: 'the earlier marks are closed and point at their copies',
+    })
+    .toBe(expected)
+}
+
+/** The planted originals and the copies that took their place, by the original's id. */
+function carried(rows: CarryMarkRow[], originalIds: string[]) {
+  return originalIds.map((id) => {
+    const original = rows.find((m) => m.id === id)
+    if (!original) throw new Error(`mark ${id} is gone`)
+    const copy = rows.find((m) => m.id === original.carriedToId)
+    if (!copy) throw new Error(`mark ${id} has no copy`)
+    return { original, copy }
+  })
+}
+
+/** The carry action's request from this job's page: a server action naming the sheet. */
+function isCarryRequest(request: Request, serviceRecordId: string): boolean {
+  return (
+    request.method() === 'POST' &&
+    Boolean(request.headers()['next-action']) &&
+    (request.postData() ?? '').includes(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
+}
+
+test('confirming all makes the earlier marks this job’s own, with their note and photos', async ({
+  page,
+}) => {
+  const car = await carWithTwoEarlierMarks('E2E carry')
+  // A real photo on the earlier dent, uploaded the way the editor uploads one.
+  const uploaded = await page.request.post('/api/protected/upload/service-files', {
+    multipart: {
+      file: { name: `e2e-carry-${stamp}.png`, mimeType: 'image/png', buffer: TINY_PNG },
+    },
+  })
+  expect(uploaded.status()).toBe(200)
+  const photo = ((await uploaded.json()) as { url: string }).url
+  await setMarkPhotos(car.hood, [photo])
+
+  // A job with no marks of its own prints no map yet.
+  expect(printsMap(await workOrderPdf(page, car.later.serviceRecordId))).toBe(false)
+
+  const map = await openDropoff(page, jobUrl(car.later))
+  await expect(ownMarks(map)).toHaveCount(0)
+  await expect(earlierMarks(map)).toHaveCount(2)
+  await expect(glyphs(map)).toHaveCount(2)
+  await expect(greyGlyphs(map)).toHaveCount(2)
+  await expect(map.getByTestId('condition-map-confirm-all')).toHaveAttribute(
+    'title',
+    "Records them on this sheet as this visit's marks, in colour."
+  )
+
+  await confirmAll(map, car.vehicleId, 2)
+
+  // On screen: the grey list is gone, the own list grew by two, the drawing is in colour.
+  await expect(earlierSection(map)).toHaveCount(0)
+  await expect(ownMarks(map)).toHaveCount(2)
+  await expect(ownMarks(map).nth(0)).toHaveAccessibleName(/^Mark 1: Dent on the Bonnet/)
+  await expect(ownMarks(map).nth(0)).toContainText(car.note)
+  await expect(ownMarks(map).nth(1)).toHaveAccessibleName(/^Mark 2: Scratch on the Left front door/)
+  await expect(glyphs(map)).toHaveCount(2)
+  await expect(greyGlyphs(map)).toHaveCount(0)
+
+  // In the database: each original closed and pointing at its copy, the copy
+  // this job's and open, with everything the original said.
+  const rows = await marksWithCarry(car.vehicleId)
+  expect(rows).toHaveLength(4)
+  for (const { original, copy } of carried(rows, [car.hood, car.door])) {
+    expect(original.serviceRecordId).toBe(car.earlier.serviceRecordId)
+    expect(original.resolvedAt).not.toBeNull()
+    expect(copy).toMatchObject({
+      serviceRecordId: car.later.serviceRecordId,
+      inspectionId: null,
+      inspectionItemId: null,
+      resolvedAt: null,
+      carriedToId: null,
+      bodyType: original.bodyType,
+      view: original.view,
+      panel: original.panel,
+      x: original.x,
+      y: original.y,
+      kind: original.kind,
+      severity: original.severity,
+      note: original.note,
+      imageUrls: original.imageUrls,
+    })
+  }
+  const [hood] = carried(rows, [car.hood])
+  expect(hood.copy.note).toBe(car.note)
+  expect(hood.copy.imageUrls).toEqual([photo])
+
+  // A reload draws the same: this job's, in colour, its photo captioned as this visit's.
+  const again = await openDropoff(page, jobUrl(car.later))
+  await expect(ownMarks(again)).toHaveCount(2)
+  await expect(earlierSection(again)).toHaveCount(0)
+  await expect(greyGlyphs(again)).toHaveCount(0)
+  await expect(
+    page
+      .getByTestId('condition-map-photos')
+      .getByRole('button', { name: 'Photos of mark 1: 1. Dent on the Bonnet', exact: true })
+  ).toBeVisible()
+
+  // It is this sheet's to change now.
+  await ownMarks(again).first().click()
+  const editor = markEditor(page)
+  await expect(editor).toBeVisible()
+  await expect(editor.getByRole('button', { name: 'Remove mark' })).toBeVisible()
+  await expect(editor).not.toContainText(CARRIED_TEXT)
+  await page.keyboard.press('Escape')
+  await expect(editor).toBeHidden()
+
+  // And the work order prints them as its own, not as earlier ones.
+  const pdf = await workOrderPdf(page, car.later.serviceRecordId)
+  expect(printsMap(pdf), 'the job now has marks of its own').toBe(true)
+  expect(pdf.flat).toContain('Bonnet')
+  expect(pdf.flat).toContain('Left front door')
+  expect(pdf.flat).toContain(car.note)
+  expect(pdf.flat).not.toContain('recorded earlier')
+})
+
+test('the earlier job still shows and prints its marks as its own record, read only', async ({
+  page,
+}) => {
+  const car = await carWithTwoEarlierMarks('E2E carry kept')
+  await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
+
+  const map = await openDropoff(page, jobUrl(car.earlier))
+  await expect(ownMarks(map)).toHaveCount(2)
+  await expect(earlierSection(map)).toHaveCount(0)
+  await expect(ownMarks(map).nth(0)).toHaveAccessibleName(/^Mark 1: Dent on the Bonnet/)
+  await expect(ownMarks(map).nth(1)).toHaveAccessibleName(/^Mark 2: Scratch on the Left front door/)
+  await expect(glyphs(map)).toHaveCount(2)
+  await expect(greyGlyphs(map)).toHaveCount(0)
+
+  // Opened, it says where it lives on, and cannot be changed or removed here.
+  await ownMarks(map).first().click()
+  const editor = markEditor(page)
+  await expect(editor).toBeVisible()
+  await expect(editor).toContainText(CARRIED_TEXT)
+  await expect(editor.getByRole('button', { name: 'Scratch' })).toBeDisabled()
+  await expect(editor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
+  await page.keyboard.press('Escape')
+  await expect(editor).toBeHidden()
+
+  // Its work order still prints the map with both, as this job's own.
+  const pdf = await workOrderPdf(page, car.earlier.serviceRecordId)
+  expect(printsMap(pdf), 'the earlier job keeps its map').toBe(true)
+  expect(pdf.flat).toContain('Bonnet')
+  expect(pdf.flat).toContain('Left front door')
+  expect(pdf.flat).toContain(car.note)
+  expect(pdf.flat).not.toContain('recorded earlier')
+
+  // And the later one prints them as its own too.
+  const later = await workOrderPdf(page, car.later.serviceRecordId)
+  expect(printsMap(later)).toBe(true)
+  expect(later.flat).toContain('Bonnet')
+  expect(later.flat).toContain(car.note)
+  expect(later.flat).not.toContain('recorded earlier')
+})
+
+test('the vehicle shows the dent once: a newer job meets the copies, not the originals', async ({
+  page,
+}) => {
+  const car = await carWithTwoEarlierMarks('E2E carry once')
+  await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
+  const copies = carried(await marksWithCarry(car.vehicleId), [car.hood, car.door]).map(
+    ({ copy }) => copy.id
+  )
+
+  // Today's job, the newest visit.
+  const newest = await plantJobOnVehicle(organizationId, car.vehicleId, `E2E carry newest ${stamp}`)
+  const map = await openDropoff(page, jobUrl(newest))
+  await expect(ownMarks(map)).toHaveCount(0)
+  await expect(earlierMarks(map)).toHaveCount(2)
+  await expect(earlierSection(map).getByRole('button', { name: /on the Bonnet/ })).toHaveCount(1)
+  await expect(
+    earlierSection(map).getByRole('button', { name: /on the Left front door/ })
+  ).toHaveCount(1)
+  await expect(glyphs(map)).toHaveCount(2)
+  await expect(greyGlyphs(map)).toHaveCount(2)
+
+  // What is still open on the car is exactly the copies.
+  const open = (await marksWithCarry(car.vehicleId)).filter((m) => !m.resolvedAt).map((m) => m.id)
+  expect(open.sort()).toEqual([...copies].sort())
+
+  // Its work order has nothing of its own to print, so no map.
+  expect(printsMap(await workOrderPdf(page, newest.serviceRecordId))).toBe(false)
+})
+
+test('pressing it again from a tab opened before changes nothing', async ({ page }) => {
+  const car = await carWithTwoEarlierMarks('E2E carry twice')
+  // A second tab on the same job, opened while the marks were still earlier ones.
+  const stale = await page.context().newPage()
+  const staleMap = await openDropoff(stale, jobUrl(car.later))
+  await expect(earlierMarks(staleMap)).toHaveCount(2)
+
+  await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
+  const before = await marksWithCarry(car.vehicleId)
+  expect(before).toHaveLength(4)
+
+  // The stale tab still offers the button; pressing it reaches the server and takes nothing.
+  const button = staleMap.getByTestId('condition-map-confirm-all')
+  await expect(button).toBeVisible()
+  const answered = stale.waitForResponse((response) =>
+    isCarryRequest(response.request(), car.later.serviceRecordId)
+  )
+  await button.click()
+  await answered
+
+  await expect
+    .poll(async () => (await marksWithCarry(car.vehicleId)).length, {
+      message: 'no further marks were written',
+    })
+    .toBe(4)
+  const after = await marksWithCarry(car.vehicleId)
+  expect(after.map((m) => [m.id, m.carriedToId, m.resolvedAt?.toISOString() ?? null])).toEqual(
+    before.map((m) => [m.id, m.carriedToId, m.resolvedAt?.toISOString() ?? null])
+  )
+
+  // Both tabs, reloaded, show the same two marks as this job's own.
+  for (const tab of [page, stale]) {
+    const map = await openDropoff(tab, jobUrl(car.later))
+    await expect(ownMarks(map)).toHaveCount(2)
+    await expect(earlierSection(map)).toHaveCount(0)
+  }
+  await stale.close()
+})
+
+test('a tab that confirms what another already confirmed shows them as this job’s', async ({
+  page,
+}) => {
+  const car = await carWithTwoEarlierMarks('E2E carry stale')
+  const stale = await page.context().newPage()
+  const staleMap = await openDropoff(stale, jobUrl(car.later))
+  await expect(earlierMarks(staleMap)).toHaveCount(2)
+
+  await confirmAll(await openDropoff(page, jobUrl(car.later)), car.vehicleId, 2)
+
+  const answered = stale.waitForResponse((response) =>
+    isCarryRequest(response.request(), car.later.serviceRecordId)
+  )
+  await staleMap.getByTestId('condition-map-confirm-all').click()
+  await answered
+
+  // Without a reload: the marks are this job's now, whoever pressed first,
+  // so the tab should not go on offering them as earlier ones.
+  await expect(ownMarks(staleMap)).toHaveCount(2)
+  await expect(earlierSection(staleMap)).toHaveCount(0)
+  await stale.close()
+})
+
+test('a mark cleared as repaired leaves its own sheet; a confirmed one stays there', async ({
+  page,
+}) => {
+  const car = await carWithTwoEarlierMarks('E2E carry repaired')
+  const map = await openDropoff(page, jobUrl(car.later))
+
+  // The bonnet was repaired; the scratch is still there.
+  const grey = earlierSection(map)
+  await grey
+    .getByRole('listitem')
+    .filter({ hasText: 'Bonnet' })
+    .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(earlierMarks(map)).toHaveCount(1)
+  await expect
+    .poll(async () => (await marksWithCarry(car.vehicleId)).find((m) => m.id === car.hood))
+    .toMatchObject({ resolvedAt: expect.any(Date), carriedToId: null })
+
+  await confirmAll(map, car.vehicleId, 1)
+  await expect(ownMarks(map)).toHaveCount(1)
+  await expect(ownMarks(map).first()).toHaveAccessibleName(/on the Left front door/)
+
+  // The earlier job keeps the confirmed scratch and has lost the repaired dent.
+  const earlier = await openDropoff(page, jobUrl(car.earlier))
+  await expect(ownMarks(earlier)).toHaveCount(1)
+  await expect(ownMarks(earlier).first()).toHaveAccessibleName(/on the Left front door/)
+})
+
+test.describe('on an inspection’s map check', () => {
+  test('confirming all records them on the check and leaves the earlier job its record', async ({
+    page,
+  }) => {
+    const earlier = await plantConditionJob(
+      organizationId,
+      userId,
+      `E2E carry inspection earlier ${stamp}`,
+      new Date(Date.now() - 30 * DAY)
+    )
+    const note = `Chip by the mirror ${stamp}`
+    const hood = await plantConditionMark(
+      organizationId,
+      earlier.vehicleId,
+      { serviceRecordId: earlier.serviceRecordId },
+      { view: 'top', panel: 'hood', kind: 'dent', recordedAt: new Date(Date.now() - 30 * DAY) }
+    )
+    const door = await plantConditionMark(
+      organizationId,
+      earlier.vehicleId,
+      { serviceRecordId: earlier.serviceRecordId },
+      {
+        view: 'right',
+        panel: 'right_front_door',
+        kind: 'chip',
+        note,
+        recordedAt: new Date(Date.now() - 30 * DAY + 60_000),
+      }
+    )
+    // Today's inspection on the same car, not linked to that job.
+    const inspection = await plantConditionInspection(
+      organizationId,
+      earlier.vehicleId,
+      `E2E carry ${stamp}`
+    )
+
+    const map = await openInspection(page, inspection.inspectionId)
+    await expect(ownMarks(map)).toHaveCount(0)
+    await expect(earlierMarks(map)).toHaveCount(2)
+    await expect(greyGlyphs(map)).toHaveCount(2)
+
+    await confirmAll(map, earlier.vehicleId, 2)
+    await expect(ownMarks(map)).toHaveCount(2)
+    await expect(earlierSection(map)).toHaveCount(0)
+    await expect(greyGlyphs(map)).toHaveCount(0)
+    await expect(ownMarks(map).nth(1)).toContainText(note)
+
+    // The copies are the check's, the originals closed and pointing at them.
+    const rows = await marksWithCarry(earlier.vehicleId)
+    expect(rows).toHaveLength(4)
+    for (const { original, copy } of carried(rows, [hood, door])) {
+      expect(original.resolvedAt).not.toBeNull()
+      expect(copy).toMatchObject({
+        serviceRecordId: null,
+        inspectionId: inspection.inspectionId,
+        inspectionItemId: inspection.inspectionItemId,
+        resolvedAt: null,
+        view: original.view,
+        panel: original.panel,
+        kind: original.kind,
+        severity: original.severity,
+        note: original.note,
+      })
+    }
+
+    // A reload keeps them on the check.
+    const again = await openInspection(page, inspection.inspectionId)
+    await expect(ownMarks(again)).toHaveCount(2)
+    await expect(earlierSection(again)).toHaveCount(0)
+
+    // The certificate prints them as the inspection's own.
+    const certificate = await certificatePdf(page, inspection.inspectionId)
+    expect(printsMap(certificate)).toBe(true)
+    expect(certificate.flat).toContain('Right front door')
+    expect(certificate.flat).toContain(note)
+
+    // The earlier job still holds them as its own record, read only, and prints them.
+    const onJob = await openDropoff(page, jobUrl(earlier))
+    await expect(ownMarks(onJob)).toHaveCount(2)
+    await expect(earlierSection(onJob)).toHaveCount(0)
+    await ownMarks(onJob).nth(1).click()
+    const editor = markEditor(page)
+    await expect(editor).toContainText(CARRIED_TEXT)
+    await expect(editor.getByRole('button', { name: 'Remove mark' })).toHaveCount(0)
+    await page.keyboard.press('Escape')
+
+    const pdf = await workOrderPdf(page, earlier.serviceRecordId)
+    expect(printsMap(pdf)).toBe(true)
+    expect(pdf.flat).toContain('Right front door')
+    expect(pdf.flat).toContain(note)
+  })
+})

+ 653 - 0
e2e/specs/quotes/add-to-work-order-conflicts.spec.ts

@@ -0,0 +1,653 @@
+import { expect, type Locator, type Page, type Response, test } from '@playwright/test'
+import {
+  addServiceRecordLines,
+  type DocumentFields,
+  ownerOrganizationId,
+  plantJob,
+  plantJobOnVehicle,
+  plantQuote,
+  quoteConversion,
+  type ServiceRecordFields,
+  servicePartLines,
+  setQuoteFields,
+  setServiceRecordFields,
+  storedServiceRecord,
+  userIdFor,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { expectWarrantyStatement } from '../../support/warranty'
+import { partRowOf, partRows, totalsRow } from '../../support/work-order'
+
+/**
+ * A quote added to a work order the car already has open, where the two
+ * disagree on a standing answer.
+ *
+ * The lines come across either way. What the dialog cannot decide for the
+ * workshop is which discount, tax basis and warranty the job carries
+ * afterwards: its own, or the quote's, which is what the customer accepted.
+ * So it asks, one question per disagreement, and "Add to work order" waits
+ * until every question has an answer. Each answer is followed onto the job's
+ * Totals panel or warranty panel, and into what the job stores.
+ *
+ * Every difference is written straight onto the quote's and the job's own
+ * columns. The workshop's tax and warranty settings are shared with specs
+ * running beside this one and are never touched. Each test plants its own
+ * car, job and quote, so no other job on the car can be offered.
+ *
+ * The figures: the job has a part at 300 and an hour and a half at 400, 900
+ * in all, taxed 25% on top; the quote has one part at 200. Added together the
+ * job's lines come to 1,100 before discount and tax.
+ */
+
+test.describe.configure({ mode: 'serial', timeout: 120_000 })
+
+const stamp = Date.now()
+const OWNER = 'demo@torqvoice.com'
+
+let organizationId = ''
+let userId = ''
+
+/** The job as it stands before the quote: 900 of lines at 25% on top, nothing else. */
+const JOB_BASE: ServiceRecordFields = {
+  status: 'in-progress',
+  subtotal: 900,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  taxRate: 25,
+  taxInclusive: false,
+  taxComponents: null,
+  taxAmount: 225,
+  totalAmount: 1125,
+  warrantyStatus: null,
+  warrantyMonths: null,
+  warrantyMileage: null,
+  warrantyNotes: null,
+}
+
+/** The quote as it stands with nothing to disagree about: 200 at 25% on top. */
+const QUOTE_BASE: DocumentFields = {
+  subtotal: 200,
+  discountType: null,
+  discountValue: 0,
+  discountAmount: 0,
+  taxRate: 25,
+  taxInclusive: false,
+  taxComponents: null,
+  taxAmount: 50,
+  totalAmount: 250,
+  warrantyStatus: null,
+  warrantyMonths: null,
+  warrantyMileage: null,
+  warrantyNotes: null,
+}
+
+/** A 10% discount on the job: 90 of its 900, so 810 taxed at 25%. */
+const JOB_TEN_PERCENT: ServiceRecordFields = {
+  discountType: 'percentage',
+  discountValue: 10,
+  discountAmount: 90,
+  taxAmount: 202.5,
+  totalAmount: 1012.5,
+}
+
+/** A discount of 18 on the quote. `discountAmount` is what the conflict reads. */
+const QUOTE_EIGHTEEN_OFF: DocumentFields = {
+  discountType: 'fixed',
+  discountValue: 18,
+  discountAmount: 18,
+  taxAmount: 45.5,
+  totalAmount: 227.5,
+}
+
+/** The quote priced with no tax at all. */
+const QUOTE_UNTAXED: DocumentFields = { taxRate: 0, taxAmount: 0, totalAmount: 200 }
+
+/** The quote stating twelve months of the workshop's warranty. */
+const QUOTE_TWELVE_MONTHS: DocumentFields = { warrantyStatus: 'included', warrantyMonths: 12 }
+
+interface Scenario {
+  jobId: string
+  jobTitle: string
+  vehicleId: string
+  quoteId: string
+  quoteUrl: string
+  jobPart: string
+  quotePart: string
+}
+
+/** One car with one open job and one quote for it, differing only as asked. */
+async function plantScenario(
+  label: string,
+  differences: { job?: ServiceRecordFields; quote?: DocumentFields } = {}
+): Promise<Scenario> {
+  const jobTitle = `E2E conflicts ${label} ${stamp}`
+  const { serviceRecordId: jobId, vehicleId } = await plantJob(organizationId, userId, jobTitle)
+  const jobPart = `E2E job part ${label} ${stamp}`
+  const quotePart = `E2E quote part ${label} ${stamp}`
+  await addServiceRecordLines(jobId, {
+    parts: [{ name: jobPart, quantity: 1, unitPrice: 300 }],
+    labor: [{ description: `E2E job labour ${label}`, hours: 1.5, rate: 400 }],
+  })
+  await setServiceRecordFields(jobId, { ...JOB_BASE, ...differences.job })
+
+  const quoteId = await plantQuote(organizationId, userId, {
+    vehicleId,
+    title: `E2E conflicts quote ${label} ${stamp}`,
+    parts: [{ name: quotePart, quantity: 1, unitPrice: 200 }],
+  })
+  await setQuoteFields(quoteId, { ...QUOTE_BASE, ...differences.quote })
+
+  return {
+    jobId,
+    jobTitle,
+    vehicleId,
+    quoteId,
+    quoteUrl: `/quotes/${quoteId}`,
+    jobPart,
+    quotePart,
+  }
+}
+
+/** The server action that asks whether the quote and this job disagree. */
+function conflictCheckFor(jobId: string) {
+  return (response: Response) => {
+    const request = response.request()
+    return (
+      request.method() === 'POST' &&
+      Boolean(request.headers()['next-action']) &&
+      (request.postData() ?? '').includes(jobId)
+    )
+  }
+}
+
+function conflictBox(dialog: Locator): Locator {
+  return dialog.getByText('Before adding, decide what the work order keeps', { exact: true })
+}
+
+function addButton(dialog: Locator): Locator {
+  return dialog.getByRole('button', { name: 'Add to work order', exact: true })
+}
+
+/**
+ * Opens the convert dialog on the quote, picks "Add to an existing work
+ * order", and waits for the answer to whether the quote and the job the
+ * dialog picked first disagree.
+ */
+async function openAddToWorkOrder(page: Page, scenario: Scenario, firstJobId = scenario.jobId) {
+  await page.goto(scenario.quoteUrl)
+  await settle(page)
+
+  const dialog = page.getByRole('dialog', { name: 'Convert Quote to Work Order' })
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Convert to Work Order', exact: true }).click()
+    await expect(dialog).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+
+  // Enabled once the car's open jobs have been read.
+  const existing = dialog.getByRole('radio', { name: 'Add to an existing work order' })
+  await expect(existing).toBeEnabled({ timeout: 30_000 })
+  const checked = page.waitForResponse(conflictCheckFor(firstJobId), { timeout: 30_000 })
+  await existing.check()
+  await checked
+  await expect(dialog.getByText('Checking the quote against the work order...')).toHaveCount(0)
+  return dialog
+}
+
+/** Adds the quote and lands on the job it went to. */
+async function addAndOpenJob(page: Page, dialog: Locator, jobId: string) {
+  const add = addButton(dialog)
+  await expect(add).toBeEnabled()
+  await add.click()
+  await page.waitForURL(new RegExp(`/vehicles/[^/]+/service/${jobId}$`), { timeout: 60_000 })
+  await settle(page)
+}
+
+async function expectTotals(page: Page, lines: Record<string, string>) {
+  for (const [label, figure] of Object.entries(lines)) {
+    await expect(totalsRow(page, label), `${label} on the work order`).toContainText(figure)
+  }
+}
+
+/** The editor row of the part with this name, wherever it sits in the list. */
+async function partRowNamed(page: Page, name: string): Promise<Locator> {
+  const rows = partRows(page)
+  let index = -1
+  await expect(async () => {
+    const names = await rows.evaluateAll((inputs) =>
+      inputs.map((input) => (input as HTMLInputElement).value)
+    )
+    index = names.indexOf(name)
+    expect(index, `${name} among ${names.join(', ')}`).toBeGreaterThanOrEqual(0)
+  }).toPass({ timeout: 30_000 })
+  return partRowOf(rows.nth(index))
+}
+
+/** The quote's part is on the job at the unit price it was quoted at, beside the job's own. */
+async function expectQuotePartArrived(page: Page, scenario: Scenario) {
+  const row = await partRowNamed(page, scenario.quotePart)
+  // Quantity, cost, markup, unit price.
+  await expect(row.locator('input[type="number"]').nth(3)).toHaveValue('200')
+  await partRowNamed(page, scenario.jobPart)
+
+  const lines = await servicePartLines(scenario.jobId)
+  expect(lines.map((line) => line.name).sort()).toEqual(
+    [scenario.jobPart, scenario.quotePart].sort()
+  )
+  const carried = lines.find((line) => line.name === scenario.quotePart)
+  expect(carried?.unitPrice).toBe(200)
+  expect(carried?.total).toBe(200)
+
+  expect(await quoteConversion(scenario.quoteId)).toEqual({
+    status: 'converted',
+    convertedToId: scenario.jobId,
+  })
+}
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId(OWNER)
+  userId = await userIdFor(OWNER)
+})
+
+test.describe('nothing to decide', () => {
+  test('a quote that agrees with the job is added without a question', async ({ page }) => {
+    const scenario = await plantScenario('agree')
+    const dialog = await openAddToWorkOrder(page, scenario)
+
+    await expect(addButton(dialog)).toBeEnabled()
+    await expect(conflictBox(dialog)).toHaveCount(0)
+
+    await addAndOpenJob(page, dialog, scenario.jobId)
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Tax: '$275.00',
+      Total: '$1,375.00',
+    })
+    await expectQuotePartArrived(page, scenario)
+  })
+
+  test("a job with a fixed discount takes the quote's on top without asking", async ({ page }) => {
+    const scenario = await plantScenario('fixed', {
+      job: {
+        discountType: 'fixed',
+        discountValue: 50,
+        discountAmount: 50,
+        taxAmount: 212.5,
+        totalAmount: 1062.5,
+      },
+      quote: QUOTE_EIGHTEEN_OFF,
+    })
+    const dialog = await openAddToWorkOrder(page, scenario)
+
+    await expect(addButton(dialog)).toBeEnabled()
+    await expect(conflictBox(dialog)).toHaveCount(0)
+
+    await addAndOpenJob(page, dialog, scenario.jobId)
+    // 50 of the job's and 18 of the quote's: 1,100 less 68 is 1,032, taxed 258.
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Discount: '-$68.00',
+      Tax: '$258.00',
+      Total: '$1,290.00',
+    })
+    await expect(totalsRow(page, 'Discount').getByRole('combobox')).toContainText('Fixed')
+    await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('68')
+    await expectQuotePartArrived(page, scenario)
+
+    const stored = await storedServiceRecord(scenario.jobId)
+    expect(stored).toMatchObject({
+      discountType: 'fixed',
+      discountValue: 68,
+      discountAmount: 68,
+      taxAmount: 258,
+      totalAmount: 1290,
+    })
+  })
+})
+
+test.describe('a percentage discount on the job and a discount on the quote', () => {
+  async function askedAboutDiscount(page: Page, scenario: Scenario) {
+    const dialog = await openAddToWorkOrder(page, scenario)
+    await expect(conflictBox(dialog)).toBeVisible()
+    const question = dialog.getByTestId('convert-conflict-discount')
+    await expect(question).toBeVisible()
+    await expect(question).toContainText(
+      'The work order has a 10% discount, $90.00 as it stands. The quote carries a discount of $18.00.'
+    )
+    // Only the discount is in question; the tax and the warranty agree.
+    await expect(dialog.getByTestId('convert-conflict-tax')).toHaveCount(0)
+    await expect(dialog.getByTestId('convert-conflict-warranty')).toHaveCount(0)
+    await expect(addButton(dialog)).toBeDisabled()
+    return { dialog, question }
+  }
+
+  test('combined: the percentage becomes its amount and the quote adds its own', async ({
+    page,
+  }) => {
+    const scenario = await plantScenario('combine', {
+      job: JOB_TEN_PERCENT,
+      quote: QUOTE_EIGHTEEN_OFF,
+    })
+    const { dialog, question } = await askedAboutDiscount(page, scenario)
+
+    await question
+      .getByRole('radio', {
+        name: "Turn the 10% into $90.00 and add the quote's $18.00, $108.00 in all",
+      })
+      .check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    // A fixed 108: 1,100 less 108 is 992, taxed 248.
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Discount: '-$108.00',
+      Tax: '$248.00',
+      Total: '$1,240.00',
+    })
+    await expect(totalsRow(page, 'Discount').getByRole('combobox')).toContainText('Fixed')
+    await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('108')
+    await expectQuotePartArrived(page, scenario)
+
+    expect(await storedServiceRecord(scenario.jobId)).toMatchObject({
+      discountType: 'fixed',
+      discountValue: 108,
+      discountAmount: 108,
+      taxAmount: 248,
+      totalAmount: 1240,
+    })
+  })
+
+  test("kept: the job's percentage stays and now covers the quote's lines too", async ({
+    page,
+  }) => {
+    const scenario = await plantScenario('keep-discount', {
+      job: JOB_TEN_PERCENT,
+      quote: QUOTE_EIGHTEEN_OFF,
+    })
+    const { dialog, question } = await askedAboutDiscount(page, scenario)
+
+    await question
+      .getByRole('radio', { name: "Keep the 10% discount and drop the quote's $18.00" })
+      .check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    // 10% of 1,100 is 110: 990 taxed 247.50.
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Discount: '-$110.00',
+      Tax: '$247.50',
+      Total: '$1,237.50',
+    })
+    await expect(totalsRow(page, 'Discount').getByRole('combobox')).toContainText('Percentage')
+    await expect(totalsRow(page, 'Discount').locator('input[type="number"]')).toHaveValue('10')
+    await expectQuotePartArrived(page, scenario)
+
+    expect(await storedServiceRecord(scenario.jobId)).toMatchObject({
+      discountType: 'percentage',
+      discountValue: 10,
+      discountAmount: 110,
+      taxAmount: 247.5,
+      totalAmount: 1237.5,
+    })
+  })
+})
+
+test.describe('the job taxed at 25% on top, the quote priced without tax', () => {
+  async function askedAboutTax(page: Page, scenario: Scenario) {
+    const dialog = await openAddToWorkOrder(page, scenario)
+    await expect(conflictBox(dialog)).toBeVisible()
+    const question = dialog.getByTestId('convert-conflict-tax')
+    await expect(question).toContainText(
+      'The work order is priced with 25% tax added on top. The quote was priced with 0% tax added on top.'
+    )
+    await expect(dialog.getByTestId('convert-conflict-discount')).toHaveCount(0)
+    await expect(dialog.getByTestId('convert-conflict-warranty')).toHaveCount(0)
+    await expect(addButton(dialog)).toBeDisabled()
+    return { dialog, question }
+  }
+
+  test("kept: the quote's part comes across at its price and is taxed like the rest", async ({
+    page,
+  }) => {
+    const scenario = await plantScenario('keep-tax', { quote: QUOTE_UNTAXED })
+    const { dialog, question } = await askedAboutTax(page, scenario)
+
+    await question
+      .getByRole('radio', {
+        name: "Keep the work order's tax. The quote's lines come across at the amounts quoted.",
+      })
+      .check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Tax: '$275.00',
+      Total: '$1,375.00',
+    })
+    await expect(totalsRow(page, 'Tax').locator('input[type="number"]')).toHaveValue('25')
+    await expectQuotePartArrived(page, scenario)
+
+    expect(await storedServiceRecord(scenario.jobId)).toMatchObject({
+      taxRate: 25,
+      taxInclusive: false,
+      taxAmount: 275,
+      totalAmount: 1375,
+    })
+  })
+
+  test("switched: the whole job is re-totalled at the quote's tax", async ({ page }) => {
+    const scenario = await plantScenario('use-tax', { quote: QUOTE_UNTAXED })
+    const { dialog, question } = await askedAboutTax(page, scenario)
+
+    await question
+      .getByRole('radio', {
+        name: "Switch the work order to the quote's tax. Everything on it is re-totalled.",
+      })
+      .check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Tax: '$0.00',
+      Total: '$1,100.00',
+    })
+    await expect(totalsRow(page, 'Tax').locator('input[type="number"]')).toHaveValue('0')
+    await expectQuotePartArrived(page, scenario)
+
+    expect(await storedServiceRecord(scenario.jobId)).toMatchObject({
+      taxRate: 0,
+      taxInclusive: false,
+      taxAmount: 0,
+      totalAmount: 1100,
+    })
+  })
+})
+
+test.describe('the quote states a warranty the job does not', () => {
+  async function askedAboutWarranty(page: Page, scenario: Scenario) {
+    const dialog = await openAddToWorkOrder(page, scenario)
+    await expect(conflictBox(dialog)).toBeVisible()
+    const question = dialog.getByTestId('convert-conflict-warranty')
+    await expect(question).toContainText(
+      'The work order says nothing about warranty. The quote the customer accepted says warranty included, 12 months.'
+    )
+    await expect(dialog.getByTestId('convert-conflict-discount')).toHaveCount(0)
+    await expect(dialog.getByTestId('convert-conflict-tax')).toHaveCount(0)
+    await expect(addButton(dialog)).toBeDisabled()
+    return { dialog, question }
+  }
+
+  test("the quote's: the job now includes twelve months from its own date", async ({ page }) => {
+    const scenario = await plantScenario('use-warranty', { quote: QUOTE_TWELVE_MONTHS })
+    const { dialog, question } = await askedAboutWarranty(page, scenario)
+
+    await question
+      .getByRole('radio', { name: "Use the quote's warranty, as the customer accepted it" })
+      .check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    const panel = await expectWarrantyStatement(page, 'Included')
+    await expect(panel.locator('#warrantyMonths')).toHaveValue('12')
+    await expect(panel.getByText('Expires', { exact: true })).toBeVisible()
+    await expectQuotePartArrived(page, scenario)
+
+    const stored = await storedServiceRecord(scenario.jobId)
+    expect(stored).toMatchObject({ warrantyStatus: 'included', warrantyMonths: 12 })
+    expect(stored.warrantyExpiresAt).not.toBeNull()
+  })
+
+  test("the job's: it still says nothing about warranty", async ({ page }) => {
+    const scenario = await plantScenario('keep-warranty', { quote: QUOTE_TWELVE_MONTHS })
+    const { dialog, question } = await askedAboutWarranty(page, scenario)
+
+    await question.getByRole('radio', { name: "Keep the work order's warranty" }).check()
+    await addAndOpenJob(page, dialog, scenario.jobId)
+
+    await expectWarrantyStatement(page, 'Not stated')
+    await expectQuotePartArrived(page, scenario)
+
+    const stored = await storedServiceRecord(scenario.jobId)
+    expect(stored).toMatchObject({ warrantyStatus: null, warrantyMonths: null })
+    expect(stored.warrantyExpiresAt).toBeNull()
+  })
+})
+
+test.describe('every question needs an answer', () => {
+  test('Add waits for the last answer, and answers belong to the job they were given for', async ({
+    page,
+  }) => {
+    // The job in question disagrees on all three; a second, newer job on the
+    // same car agrees with the quote on everything, and the dialog offers the
+    // newest first.
+    const scenario = await plantScenario('all-three', {
+      job: JOB_TEN_PERCENT,
+      quote: { ...QUOTE_EIGHTEEN_OFF, ...QUOTE_UNTAXED, ...QUOTE_TWELVE_MONTHS },
+    })
+    const agreeingTitle = `${scenario.jobTitle} agreeing`
+    const { serviceRecordId: agreeingId } = await plantJobOnVehicle(
+      organizationId,
+      scenario.vehicleId,
+      agreeingTitle
+    )
+    await setServiceRecordFields(agreeingId, {
+      ...JOB_BASE,
+      subtotal: 0,
+      taxRate: 0,
+      taxAmount: 0,
+      totalAmount: 0,
+      warrantyStatus: 'included',
+      warrantyMonths: 12,
+    })
+
+    const dialog = await openAddToWorkOrder(page, scenario, agreeingId)
+    // The vehicle picker comes first; the job picker is the dialog's other combobox.
+    const jobPicker = dialog.getByRole('combobox').last()
+    await expect(jobPicker).toContainText(agreeingTitle)
+    await expect(conflictBox(dialog)).toHaveCount(0)
+    await expect(addButton(dialog)).toBeEnabled()
+
+    async function pickJob(title: string, id: string) {
+      const checked = page.waitForResponse(conflictCheckFor(id), { timeout: 30_000 })
+      await jobPicker.click()
+      await page.getByRole('option', { name: title, exact: true }).click()
+      await checked
+      await expect(jobPicker).toContainText(title)
+    }
+
+    const discount = dialog.getByTestId('convert-conflict-discount')
+    const tax = dialog.getByTestId('convert-conflict-tax')
+    const warranty = dialog.getByTestId('convert-conflict-warranty')
+    const combine = discount.getByRole('radio', {
+      name: "Turn the 10% into $90.00 and add the quote's $18.00, $108.00 in all",
+    })
+    const keepTax = tax.getByRole('radio', {
+      name: "Keep the work order's tax. The quote's lines come across at the amounts quoted.",
+    })
+    const quoteWarranty = warranty.getByRole('radio', {
+      name: "Use the quote's warranty, as the customer accepted it",
+    })
+
+    await pickJob(scenario.jobTitle, scenario.jobId)
+    await expect(conflictBox(dialog)).toBeVisible()
+    await expect(discount).toBeVisible()
+    await expect(tax).toBeVisible()
+    await expect(warranty).toBeVisible()
+    await expect(addButton(dialog)).toBeDisabled()
+
+    // Two answers of three: still waiting.
+    await quoteWarranty.check()
+    await expect(addButton(dialog)).toBeDisabled()
+    await combine.check()
+    await expect(addButton(dialog)).toBeDisabled()
+
+    // Another job is another set of questions; this one has none.
+    await pickJob(agreeingTitle, agreeingId)
+    await expect(conflictBox(dialog)).toHaveCount(0)
+    await expect(addButton(dialog)).toBeEnabled()
+
+    // Back again, the answers given for it are gone: they were about the job
+    // as it was read then, and it is read afresh.
+    await pickJob(scenario.jobTitle, scenario.jobId)
+    await expect(conflictBox(dialog)).toBeVisible()
+    for (const question of [discount, tax, warranty]) {
+      for (const choice of await question.getByRole('radio').all()) {
+        await expect(choice).not.toBeChecked()
+      }
+    }
+    await expect(addButton(dialog)).toBeDisabled()
+
+    // Answered in another order than listed: enabled by the last, whichever it is.
+    await keepTax.check()
+    await expect(addButton(dialog)).toBeDisabled()
+    await quoteWarranty.check()
+    await expect(addButton(dialog)).toBeDisabled()
+    await combine.check()
+    await expect(addButton(dialog)).toBeEnabled()
+
+    // Each answer is applied on its own: a fixed 108, the job's 25%, and the
+    // quote's twelve months.
+    await addAndOpenJob(page, dialog, scenario.jobId)
+    await expectTotals(page, {
+      Subtotal: '$1,100.00',
+      Discount: '-$108.00',
+      Tax: '$248.00',
+      Total: '$1,240.00',
+    })
+    const panel = await expectWarrantyStatement(page, 'Included')
+    await expect(panel.locator('#warrantyMonths')).toHaveValue('12')
+    await expectQuotePartArrived(page, scenario)
+
+    // The other job was not touched.
+    expect(await servicePartLines(agreeingId)).toEqual([])
+  })
+
+  test('a job changed after the dialog asked is refused, and nothing moves', async ({ page }) => {
+    const scenario = await plantScenario('changed', { quote: QUOTE_EIGHTEEN_OFF })
+    const dialog = await openAddToWorkOrder(page, scenario)
+    await expect(conflictBox(dialog)).toHaveCount(0)
+    await expect(addButton(dialog)).toBeEnabled()
+
+    // Someone gives the job a percentage while the dialog is open: the quote's
+    // 18 now needs an answer the dialog never asked for.
+    await setServiceRecordFields(scenario.jobId, JOB_TEN_PERCENT)
+
+    await addButton(dialog).click()
+    await expect(
+      page.getByText('The quote and the work order differ; choose what the work order keeps')
+    ).toBeVisible({ timeout: 30_000 })
+    expect(page.url()).toContain(scenario.quoteUrl)
+
+    expect(await quoteConversion(scenario.quoteId)).toEqual({
+      status: 'draft',
+      convertedToId: null,
+    })
+    expect((await servicePartLines(scenario.jobId)).map((line) => line.name)).toEqual([
+      scenario.jobPart,
+    ])
+    expect(await storedServiceRecord(scenario.jobId)).toMatchObject({
+      discountType: 'percentage',
+      discountValue: 10,
+      totalAmount: 1012.5,
+    })
+  })
+})

+ 396 - 0
e2e/specs/quotes/add-to-work-order.spec.ts

@@ -0,0 +1,396 @@
+import { expect, type Page, test } from '@playwright/test'
+import { partQuantity, stockMovements } from '../../support/db'
+import { settle } from '../../support/hydration'
+import { addQuoteLabor, addQuotePart, newQuote, saveQuote } from '../../support/quote'
+import {
+  archiveInventoryPart,
+  chooseExistingOrder,
+  existingOrderRadio,
+  newOrderRadio,
+  openConvertDialog,
+  type PlantedVehicle,
+  plantInventoryPart,
+  plantVehicle,
+  quoteNumberOf,
+} from '../../support/quote-conversion'
+import {
+  addLabor,
+  addPart,
+  jobIdOf,
+  laborRowOf,
+  laborRows,
+  newWorkOrder,
+  partRowOf,
+  partRows,
+  saveWorkOrder,
+  totalsRow,
+} from '../../support/work-order'
+
+/**
+ * A quote added to a work order the car already has open.
+ *
+ * Diagnostics usually come first: by the time the customer accepts the quote
+ * the car is already on a job, and converting it the old way raised a second
+ * job and split one visit across two invoices. The convert dialog now offers
+ * the vehicle's open jobs. What the quote puts on the job has to be what the
+ * customer accepted (its included lines, not the ones left out of the total),
+ * appended after what the job already said, which stays exactly as it was;
+ * stock moves once, and the job and the quote point at each other.
+ *
+ * Each describe gets a vehicle of its own, written straight into the
+ * database. The seeded Camry has open jobs of the seed's and of other specs,
+ * and `/service/new` hands back any untouched draft on the same car younger
+ * than five seconds, which another spec running beside this one could own.
+ *
+ * The quote and the job are left on the workshop's tax with no discount and
+ * no warranty of their own, so the dialog has no conflict to ask about; that
+ * prompt is another spec's.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+
+/** What the dialog lists a job as: its number and title, unless the title has the number. */
+function targetLabel(invoiceNumber: string, title: string): string {
+  return title.includes(invoiceNumber) ? title : `${invoiceNumber} · ${title}`
+}
+
+interface PartLine {
+  name: string
+  quantity: number
+  unitPrice: number
+}
+
+interface LaborLine {
+  description: string
+  hours: number
+  rate: number
+}
+
+/** Every part line on the open work order: name, quantity and unit price. */
+async function jobParts(page: Page): Promise<PartLine[]> {
+  const rows = partRows(page)
+  const lines: PartLine[] = []
+  for (let i = 0; i < (await rows.count()); i++) {
+    const numbers = partRowOf(rows.nth(i)).locator('input[type="number"]')
+    lines.push({
+      name: await rows.nth(i).inputValue(),
+      quantity: Number(await numbers.nth(0).inputValue()),
+      // Quantity, cost, markup, unit price, in the order the editor prints them.
+      unitPrice: Number(await numbers.nth(3).inputValue()),
+    })
+  }
+  return lines
+}
+
+/** Every labour line on the open work order: description, hours and rate. */
+async function jobLabor(page: Page): Promise<LaborLine[]> {
+  const rows = laborRows(page)
+  const lines: LaborLine[] = []
+  for (let i = 0; i < (await rows.count()); i++) {
+    const numbers = laborRowOf(rows.nth(i)).locator('input[type="number"]')
+    lines.push({
+      description: await rows.nth(i).inputValue(),
+      hours: Number(await numbers.nth(0).inputValue()),
+      rate: Number(await numbers.nth(1).inputValue()),
+    })
+  }
+  return lines
+}
+
+/**
+ * The quote's last part row, for its exclude switch and its numbers. The
+ * quote editor appends, and its number inputs carry no placeholders, so the
+ * row is the nearest ancestor holding one.
+ */
+function lastQuotePartRow(page: Page) {
+  return page
+    .getByPlaceholder('Name *')
+    .last()
+    .locator('xpath=ancestor::div[.//input[@type="number"]][1]')
+}
+
+test.describe('a quote added to the open work order', () => {
+  // The job as it was written up before the quote: two units of a part and an hour.
+  const JOB_TITLE = `E2E open job ${stamp}`
+  const JOB_PART: PartLine = { name: `E2E job brake fluid ${stamp}`, quantity: 2, unitPrice: 150 }
+  const JOB_LABOR: LaborLine = { description: `E2E job diagnosis ${stamp}`, hours: 1, rate: 400 }
+
+  // What the quote prices, and a line the customer left out of the total.
+  const QUOTE_TITLE = `E2E add to job quote ${stamp}`
+  const QUOTE_PART: PartLine = { name: `E2E quote pads ${stamp}`, quantity: 1, unitPrice: 275 }
+  const QUOTE_LABOR: LaborLine = {
+    description: `E2E quote fit pads ${stamp}`,
+    hours: 1.5,
+    rate: 600,
+  }
+  const EXCLUDED_PART: PartLine = {
+    name: `E2E quote left out ${stamp}`,
+    quantity: 1,
+    unitPrice: 999,
+  }
+
+  let vehicle: PlantedVehicle
+  let jobUrl = ''
+  let invoiceNumber = ''
+  let quoteUrl = ''
+
+  test.beforeAll(async () => {
+    vehicle = await plantVehicle(`AddToJob${stamp}`)
+  })
+
+  test('the job is written up and the quote priced, one line left out', async ({ page }) => {
+    jobUrl = await newWorkOrder(page, `/vehicles/${vehicle.id}`, JOB_TITLE)
+    await addPart(page, JOB_PART)
+    await addLabor(page, JOB_LABOR)
+    await saveWorkOrder(page)
+    await expect(totalsRow(page, 'Subtotal')).toContainText('$700.00')
+    invoiceNumber = await page.getByLabel('Invoice Number').inputValue()
+    expect(invoiceNumber).not.toBe('')
+
+    quoteUrl = await newQuote(page, QUOTE_TITLE, vehicle.model)
+    await addQuotePart(page, QUOTE_PART)
+    await addQuoteLabor(page, {
+      description: QUOTE_LABOR.description,
+      hours: QUOTE_LABOR.hours,
+      rate: QUOTE_LABOR.rate,
+    })
+    await addQuotePart(page, EXCLUDED_PART)
+    // The per-line switch at the end of the row: struck through, out of the total.
+    const exclude = lastQuotePartRow(page).getByRole('checkbox', {
+      name: 'Exclude from total',
+    })
+    await exclude.check()
+    await expect(exclude).toBeChecked()
+    await saveQuote(page)
+
+    // Read back after a reload: the switch is what the conversion reads.
+    await page.reload()
+    await settle(page)
+    await expect(page.getByPlaceholder('Name *').last()).toHaveValue(EXCLUDED_PART.name)
+    await expect(
+      lastQuotePartRow(page).getByRole('checkbox', { name: 'Exclude from total' })
+    ).toBeChecked()
+  })
+
+  test('the dialog offers the open job and adds the quote to it', async ({ page }) => {
+    await page.goto(quoteUrl)
+    const dialog = await openConvertDialog(page)
+
+    await expect(newOrderRadio(dialog)).toBeChecked()
+    const confirm = await chooseExistingOrder(page, dialog, targetLabel(invoiceNumber, JOB_TITLE))
+
+    // Enabled only once the dialog has finished comparing the two, so the
+    // check for a conflict box below is made after that answer is in.
+    await expect(confirm).toBeEnabled({ timeout: 15_000 })
+    // Same tax, no discount, no warranty: nothing for the workshop to decide.
+    // If this box appears the fixture has drifted from the job, not the app.
+    await expect(dialog.getByText('Before adding, decide what the work order keeps')).toHaveCount(0)
+    await expect(dialog.getByRole('button', { name: 'Convert', exact: true })).toHaveCount(0)
+    await confirm.click()
+
+    // The same job, not a new one.
+    await page.waitForURL(jobUrl, { timeout: 60_000 })
+    await settle(page)
+    await expect(page.getByLabel('Invoice Number')).toHaveValue(invoiceNumber)
+  })
+
+  test("the job carries the quote's included lines and the totals add up", async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+
+    // Two parts and two labour lines: the left-out part stayed on the quote.
+    await expect(partRows(page)).toHaveCount(2)
+    await expect(laborRows(page)).toHaveCount(2)
+
+    const parts = await jobParts(page)
+    expect(parts).toContainEqual(QUOTE_PART)
+    expect(parts.map((p) => p.name)).not.toContain(EXCLUDED_PART.name)
+    const labor = await jobLabor(page)
+    expect(labor).toContainEqual(QUOTE_LABOR)
+
+    // 300 + 400 from the job, 275 + 900 from the quote.
+    await expect(totalsRow(page, 'Parts')).toContainText('$575.00')
+    await expect(totalsRow(page, 'Labor')).toContainText('$1,300.00')
+    await expect(totalsRow(page, 'Subtotal')).toContainText('$1,875.00')
+  })
+
+  test("the job's own lines are as they were", async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+
+    const parts = await jobParts(page)
+    const labor = await jobLabor(page)
+    // Untouched: one line each, with the name, quantity and price it was saved with.
+    expect(parts.filter((p) => p.name === JOB_PART.name)).toEqual([JOB_PART])
+    expect(labor.filter((l) => l.description === JOB_LABOR.description)).toEqual([JOB_LABOR])
+  })
+
+  test('the job links back to the quote, which reads converted', async ({ page }) => {
+    const quoteNumber = await quoteNumberOf(quoteUrl)
+
+    await page.goto(jobUrl)
+    await settle(page)
+    const link = page.getByTestId('service-from-quote')
+    await expect(link).toHaveText(`From quote ${quoteNumber}`)
+    await expect(async () => {
+      await link.click()
+      await page.waitForURL(quoteUrl, { timeout: 5_000 })
+    }).toPass({ timeout: 30_000 })
+    await settle(page)
+
+    await expect(page.locator('#title')).toHaveValue(QUOTE_TITLE)
+    await expect(page.getByRole('combobox', { name: 'Status' })).toContainText('Converted')
+    // A quote already on a job cannot be put on one again.
+    await expect(
+      page.getByRole('button', { name: 'Convert to Work Order', exact: true })
+    ).toHaveCount(0)
+  })
+})
+
+test.describe('a finished job is not offered', () => {
+  const JOB_TITLE = `E2E job to finish ${stamp}`
+  let vehicle: PlantedVehicle
+  let jobUrl = ''
+  let invoiceNumber = ''
+  let quoteUrl = ''
+
+  /** The status stepper in the work order's header: one button per stage. */
+  function stage(page: Page, name: string) {
+    return page.getByTestId('status-stepper').getByRole('button', { name: new RegExp(name) })
+  }
+
+  test.beforeAll(async () => {
+    vehicle = await plantVehicle(`Finished${stamp}`)
+  })
+
+  test('while the job is open it is offered', async ({ page }) => {
+    jobUrl = await newWorkOrder(page, `/vehicles/${vehicle.id}`, JOB_TITLE)
+    await addPart(page, { name: `E2E finished job part ${stamp}`, quantity: 1, unitPrice: 100 })
+    await saveWorkOrder(page)
+    invoiceNumber = await page.getByLabel('Invoice Number').inputValue()
+
+    quoteUrl = await newQuote(page, `E2E quote after finish ${stamp}`, vehicle.model)
+    await addQuotePart(page, { name: `E2E later part ${stamp}`, quantity: 1, unitPrice: 80 })
+    await saveQuote(page)
+
+    // The control for the test below: this car's one job is listed while open.
+    const dialog = await openConvertDialog(page)
+    await chooseExistingOrder(page, dialog, targetLabel(invoiceNumber, JOB_TITLE))
+    await dialog.getByRole('button', { name: 'Cancel', exact: true }).click()
+    await expect(dialog).toBeHidden()
+  })
+
+  test('once completed, the vehicle has no open work orders to add to', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+    await expect(async () => {
+      await stage(page, 'Completed').click()
+      await expect(stage(page, 'Completed')).toHaveAttribute('aria-current', 'step', {
+        timeout: 2_000,
+      })
+    }).toPass({ timeout: 30_000 })
+    await saveWorkOrder(page)
+    await page.reload()
+    await settle(page)
+    await expect(stage(page, 'Completed')).toHaveAttribute('aria-current', 'step')
+
+    await page.goto(quoteUrl)
+    const dialog = await openConvertDialog(page)
+    await expect(dialog.getByText('This vehicle has no open work orders.')).toBeVisible({
+      timeout: 15_000,
+    })
+    await expect(existingOrderRadio(dialog)).toBeDisabled()
+    await expect(newOrderRadio(dialog)).toBeChecked()
+    // Only the vehicle picker: no list of jobs to choose from.
+    await expect(dialog.getByRole('combobox')).toHaveCount(1)
+    await expect(dialog.getByRole('button', { name: 'Convert', exact: true })).toBeEnabled()
+    await expect(dialog.getByRole('button', { name: 'Add to work order' })).toHaveCount(0)
+  })
+})
+
+test.describe('a stocked part on the quote', () => {
+  const PART_NAME = `E2E quote stock part ${stamp}`
+  // Below ten on hand on purpose: `stockedPart` in support/db.ts picks a part
+  // with ten or more, so the inventory spec never takes this one.
+  const OPENING = 5
+  const JOB_TITLE = `E2E stock job ${stamp}`
+  let vehicle: PlantedVehicle
+  let partId = ''
+  let jobUrl = ''
+  let invoiceNumber = ''
+  let quoteUrl = ''
+
+  test.beforeAll(async () => {
+    vehicle = await plantVehicle(`StockJob${stamp}`)
+    partId = await plantInventoryPart({
+      name: PART_NAME,
+      quantity: OPENING,
+      unitCost: 100,
+      sellPrice: 250,
+    })
+  })
+
+  test.afterAll(async () => {
+    if (partId) await archiveInventoryPart(partId)
+  })
+
+  test('leaves the shelf once when the quote is added to the job', async ({ page }) => {
+    jobUrl = await newWorkOrder(page, `/vehicles/${vehicle.id}`, JOB_TITLE)
+    await addLabor(page, { description: `E2E stock job check ${stamp}`, hours: 1, rate: 300 })
+    await saveWorkOrder(page)
+    invoiceNumber = await page.getByLabel('Invoice Number').inputValue()
+
+    quoteUrl = await newQuote(page, `E2E stock quote ${stamp}`, vehicle.model)
+    await settle(page)
+
+    // The picker is the only way a quote line carries the stock link.
+    const rows = page.getByPlaceholder('Name *')
+    const before = await rows.count()
+    const picker = page.getByRole('dialog', { name: 'Select Part from Inventory' })
+    await expect(async () => {
+      await page.getByRole('button', { name: 'From stock', exact: true }).click()
+      await expect(picker).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await picker.getByPlaceholder('Search inventory...').fill(PART_NAME)
+    await picker
+      .getByRole('button', { name: new RegExp(PART_NAME) })
+      .first()
+      .click()
+    await expect(rows).toHaveCount(before + 1)
+    await expect(rows.last()).toHaveValue(PART_NAME)
+    await lastQuotePartRow(page).locator('input[type="number"]').nth(0).fill('2')
+    await saveQuote(page)
+
+    // An estimate moves nothing.
+    expect(await partQuantity(partId), 'a quote moves no stock').toBe(OPENING)
+
+    await page.reload()
+    const dialog = await openConvertDialog(page)
+    const confirm = await chooseExistingOrder(page, dialog, targetLabel(invoiceNumber, JOB_TITLE))
+    await expect(confirm).toBeEnabled({ timeout: 15_000 })
+    await confirm.click()
+    await page.waitForURL(jobUrl, { timeout: 60_000 })
+    await settle(page)
+
+    const parts = await jobParts(page)
+    expect(parts).toContainEqual({ name: PART_NAME, quantity: 2, unitPrice: 250 })
+
+    expect(await partQuantity(partId)).toBe(OPENING - 2)
+    const ledger = await stockMovements(partId, jobIdOf(jobUrl))
+    expect(ledger).toHaveLength(1)
+    expect(ledger[0].delta).toBe(-2)
+    expect(ledger[0].quantityAfter, 'the ledger agrees with the count').toBe(OPENING - 2)
+    expect(ledger[0].reason).toBe('quote_conversion')
+  })
+
+  test('and not again when the job is saved afterwards', async ({ page }) => {
+    await page.goto(jobUrl)
+    await settle(page)
+    await saveWorkOrder(page)
+
+    expect(await partQuantity(partId)).toBe(OPENING - 2)
+    expect(await stockMovements(partId, jobIdOf(jobUrl))).toHaveLength(1)
+  })
+})

+ 4 - 1
e2e/specs/smoke/app.spec.ts

@@ -16,7 +16,10 @@ test.describe('smoke', () => {
   test('the seeded workshop has customers and vehicles', async ({ page }) => {
     // Lists render a card for phones and a table for wider screens and hide
     // one of them; the table is the visible one at the desktop size used here.
-    await page.goto('/customers')
+    // Searched, like the vehicles below: the list shows the twenty customers
+    // touched last, and the specs that run before this one plant their own,
+    // so the seed's are not on the first page of an unsearched list.
+    await page.goto('/customers?search=James%20Mitchell')
     await expect(page.getByRole('table').getByText('James Mitchell').first()).toBeVisible()
 
     // The vehicle list opens as a grid of cards, each headed by the vehicle's

+ 16 - 2
e2e/specs/work-orders/modern-page.spec.ts

@@ -295,15 +295,24 @@ test.describe('the overhauled work order page', () => {
 
     // 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.
+    // The server reads the bytes before it believes the file is a video, and
+    // these are not one, so the upload's answer is stood in for. What is
+    // under test here is the card and the player, not the upload.
     const clip = `e2e-clip-${stamp}.mp4`
+    const clipUrl = `/api/protected/files/${await ownerOrganizationId()}/services/e2e-clip-${stamp}.mp4`
+    await page.route('**/api/protected/upload/service-files', (route) =>
+      route.fulfill({
+        json: { url: clipUrl, fileName: clip, fileType: 'video/mp4', fileSize: 1024 },
+      })
+    )
     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 page.unroute('**/api/protected/upload/service-files')
     await expect(tiles.first()).toContainText('Customer can see')
     await tiles.first().getByRole('button', { name: clip }).click()
     const player = page.getByRole('dialog', { name: clip })
@@ -471,11 +480,16 @@ test.describe('the overhauled work order page', () => {
       }).toPass({ timeout: 30_000 })
       await page.getByRole('menuitem', { name: 'Waiting Parts' }).click()
       await expectStatus(page, /Waiting Parts/)
+      // The status says "Saved" for two seconds. Let that one go, or it answers
+      // for the notes below before they have been written.
+      const saved = page.getByText('Saved', { exact: true })
+      await expect(saved).toBeVisible()
+      await expect(saved).toBeHidden()
 
       // 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()
+      await expect(saved).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.

+ 57 - 0
e2e/support/condition-map-db.ts

@@ -0,0 +1,57 @@
+import { Client } from 'pg'
+
+/**
+ * Database peeks for the condition map's carry forward: what "Confirm all as
+ * still there" wrote, read back with the column that links an earlier mark to
+ * the one a later visit recorded in its place. Kept apart from `db.ts`, in the
+ * same plain-pg style, so the carry specs can grow without touching it.
+ */
+async function withDb<T>(fn: (db: Client) => Promise<T>): Promise<T> {
+  const url = process.env.E2E_DATABASE_URL
+  if (!url) throw new Error('E2E_DATABASE_URL is not set. See e2e/README.md.')
+  const db = new Client({ connectionString: url })
+  await db.connect()
+  try {
+    return await fn(db)
+  } finally {
+    await db.end()
+  }
+}
+
+export interface CarryMarkRow {
+  id: string
+  serviceRecordId: string | null
+  inspectionId: string | null
+  inspectionItemId: string | null
+  bodyType: string
+  view: string
+  panel: string
+  x: number
+  y: number
+  kind: string
+  severity: string
+  note: string | null
+  imageUrls: string[]
+  resolvedAt: Date | null
+  carriedToId: string | null
+}
+
+/** Every mark on the vehicle with its carry link, oldest first, resolved ones included. */
+export async function marksWithCarry(vehicleId: string): Promise<CarryMarkRow[]> {
+  return withDb(async (db) => {
+    const result = await db.query<CarryMarkRow>(
+      `select id, "serviceRecordId", "inspectionId", "inspectionItemId", "bodyType", view, panel,
+              x, y, kind, severity, note, "imageUrls", "resolvedAt", "carriedToId"
+         from condition_marks where "vehicleId" = $1 order by "recordedAt", "createdAt"`,
+      [vehicleId]
+    )
+    return result.rows
+  })
+}
+
+/** Puts photos on a planted mark, as if they had been taken in its editor. */
+export async function setMarkPhotos(markId: string, urls: string[]): Promise<void> {
+  await withDb((db) =>
+    db.query(`update condition_marks set "imageUrls" = $2 where id = $1`, [markId, urls])
+  )
+}

+ 246 - 0
e2e/support/db.ts

@@ -1821,3 +1821,249 @@ export async function plantOwnMarkKind(
 export async function removeOwnMarkKind(id: string): Promise<void> {
   await withDb((db) => db.query(`delete from condition_mark_types where id = $1`, [id]))
 }
+
+// ─── A quote added to an open work order ─────────────────────────────────────
+
+/**
+ * The columns a spec may write on a quote or a work order to make the two
+ * disagree, without touching the workshop's own settings (which other specs
+ * read at the same time). The stored totals are columns too: a discount or a
+ * tax written here leaves them stale until the document is saved or
+ * re-totalled, so a spec writes them alongside when it reads them back.
+ */
+export interface DocumentFields {
+  subtotal?: number
+  discountType?: 'percentage' | 'fixed' | null
+  discountValue?: number
+  discountAmount?: number
+  taxRate?: number
+  taxInclusive?: boolean
+  taxComponents?: { name: string; rate: number }[] | null
+  taxAmount?: number
+  totalAmount?: number
+  warrantyStatus?: 'included' | 'not_included' | null
+  warrantyMonths?: number | null
+  warrantyMileage?: number | null
+  warrantyNotes?: string | null
+}
+
+export interface ServiceRecordFields extends DocumentFields {
+  status?: string
+}
+
+const DOCUMENT_COLUMNS = new Set([
+  'subtotal',
+  'discountType',
+  'discountValue',
+  'discountAmount',
+  'taxRate',
+  'taxInclusive',
+  'taxComponents',
+  'taxAmount',
+  'totalAmount',
+  'warrantyStatus',
+  'warrantyMonths',
+  'warrantyMileage',
+  'warrantyNotes',
+  'status',
+])
+
+/** `"col" = $n, ...` for the given fields, refusing anything not listed above. */
+function assignments(fields: object, offset: number): { sql: string; values: unknown[] } {
+  const entries = Object.entries(fields).filter(([, value]) => value !== undefined)
+  if (entries.length === 0) throw new Error('nothing to write')
+  for (const [column] of entries) {
+    if (!DOCUMENT_COLUMNS.has(column)) throw new Error(`not a writable column: ${column}`)
+  }
+  return {
+    sql: entries.map(([column], i) => `"${column}" = $${i + offset}`).join(', '),
+    values: entries.map(([column, value]) =>
+      column === 'taxComponents' && value !== null ? JSON.stringify(value) : value
+    ),
+  }
+}
+
+/** Writes columns on a quote, found by its id or its number. */
+export async function setQuoteFields(
+  quoteNumberOrId: string,
+  fields: DocumentFields
+): Promise<void> {
+  await withDb(async (db) => {
+    const { sql, values } = assignments(fields, 2)
+    const result = await db.query(
+      `update quotes set ${sql}, "updatedAt" = now() where id = $1 or "quoteNumber" = $1`,
+      [quoteNumberOrId, ...values]
+    )
+    if (result.rowCount !== 1) {
+      throw new Error(`expected one quote for ${quoteNumberOrId}, found ${result.rowCount}`)
+    }
+  })
+}
+
+/** Writes columns on a work order. */
+export async function setServiceRecordFields(
+  serviceRecordId: string,
+  fields: ServiceRecordFields
+): Promise<void> {
+  await withDb(async (db) => {
+    const { sql, values } = assignments(fields, 2)
+    const result = await db.query(
+      `update service_records set ${sql}, "updatedAt" = now() where id = $1`,
+      [serviceRecordId, ...values]
+    )
+    if (result.rowCount !== 1) throw new Error(`no work order ${serviceRecordId}`)
+  })
+}
+
+export interface PlantedPartLine {
+  name: string
+  quantity: number
+  unitPrice: number
+}
+
+export interface PlantedLaborLine {
+  description: string
+  hours: number
+  rate: number
+}
+
+/**
+ * Part and labour lines written onto a work order, priced the way the editor
+ * prices them (quantity × unit price, hours × rate). Nothing is re-totalled:
+ * the spec writes the stored totals it wants with `setServiceRecordFields`.
+ */
+export async function addServiceRecordLines(
+  serviceRecordId: string,
+  lines: { parts?: PlantedPartLine[]; labor?: PlantedLaborLine[] }
+): Promise<void> {
+  await withDb(async (db) => {
+    for (const part of lines.parts ?? []) {
+      await db.query(
+        `insert into service_parts (id, name, quantity, "unitPrice", total, "serviceRecordId")
+         values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
+        [part.name, part.quantity, part.unitPrice, part.quantity * part.unitPrice, serviceRecordId]
+      )
+    }
+    for (const labor of lines.labor ?? []) {
+      await db.query(
+        `insert into service_labor (id, description, hours, rate, total, "serviceRecordId")
+         values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
+        [labor.description, labor.hours, labor.rate, labor.hours * labor.rate, serviceRecordId]
+      )
+    }
+  })
+}
+
+/**
+ * A draft quote for a vehicle and its owner, with its lines, as if typed into
+ * the editor and saved. Its tax, discount and warranty are whatever the spec
+ * writes next with `setQuoteFields`; the stored subtotal and total follow the
+ * lines here so the quote is consistent before that.
+ */
+export async function plantQuote(
+  organizationId: string,
+  userId: string,
+  input: {
+    vehicleId: string
+    title: string
+    parts?: PlantedPartLine[]
+    labor?: PlantedLaborLine[]
+  }
+): Promise<string> {
+  return withDb(async (db) => {
+    const parts = input.parts ?? []
+    const labor = input.labor ?? []
+    const subtotal =
+      parts.reduce((sum, p) => sum + p.quantity * p.unitPrice, 0) +
+      labor.reduce((sum, l) => sum + l.hours * l.rate, 0)
+    const quote = await db.query<{ id: string }>(
+      `insert into quotes (id, title, status, subtotal, "totalAmount", "userId", "organizationId",
+                           "customerId", "vehicleId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, 'draft', $2, $2, $3, $4,
+               (select "customerId" from vehicles where id = $5), $5, now())
+       returning id`,
+      [input.title, subtotal, userId, organizationId, input.vehicleId]
+    )
+    const quoteId = quote.rows[0].id
+    for (const part of parts) {
+      await db.query(
+        `insert into quote_parts (id, name, quantity, "unitPrice", total, "quoteId")
+         values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
+        [part.name, part.quantity, part.unitPrice, part.quantity * part.unitPrice, quoteId]
+      )
+    }
+    for (const line of labor) {
+      await db.query(
+        `insert into quote_labor (id, description, hours, rate, total, "quoteId")
+         values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5)`,
+        [line.description, line.hours, line.rate, line.hours * line.rate, quoteId]
+      )
+    }
+    return quoteId
+  })
+}
+
+export interface StoredServiceRecord {
+  status: string
+  subtotal: number
+  discountType: string | null
+  discountValue: number
+  discountAmount: number
+  taxRate: number
+  taxInclusive: boolean
+  taxAmount: number
+  totalAmount: number
+  warrantyStatus: string | null
+  warrantyMonths: number | null
+  warrantyExpiresAt: Date | null
+}
+
+/** What a work order stores about its money and its warranty. */
+export async function storedServiceRecord(serviceRecordId: string): Promise<StoredServiceRecord> {
+  return withDb(async (db) => {
+    const result = await db.query<StoredServiceRecord>(
+      `select status, subtotal, "discountType", "discountValue", "discountAmount", "taxRate",
+              "taxInclusive", "taxAmount", "totalAmount", "warrantyStatus", "warrantyMonths",
+              "warrantyExpiresAt"
+         from service_records where id = $1`,
+      [serviceRecordId]
+    )
+    const row = result.rows[0]
+    if (!row) throw new Error(`no work order ${serviceRecordId}`)
+    return row
+  })
+}
+
+/** The work order's part lines, by name. */
+export async function servicePartLines(
+  serviceRecordId: string
+): Promise<{ name: string; quantity: number; unitPrice: number; total: number }[]> {
+  return withDb(async (db) => {
+    const result = await db.query<{
+      name: string
+      quantity: number
+      unitPrice: number
+      total: number
+    }>(
+      `select name, quantity, "unitPrice", total from service_parts
+        where "serviceRecordId" = $1 order by name`,
+      [serviceRecordId]
+    )
+    return result.rows
+  })
+}
+
+/** A quote's status and the job it went to, if any. */
+export async function quoteConversion(
+  quoteId: string
+): Promise<{ status: string; convertedToId: string | null }> {
+  return withDb(async (db) => {
+    const result = await db.query<{ status: string; convertedToId: string | null }>(
+      `select status, "convertedToId" from quotes where id = $1`,
+      [quoteId]
+    )
+    const row = result.rows[0]
+    if (!row) throw new Error(`no quote ${quoteId}`)
+    return row
+  })
+}

+ 155 - 0
e2e/support/quote-conversion.ts

@@ -0,0 +1,155 @@
+import { expect, type Locator, type Page } from '@playwright/test'
+import { Client } from 'pg'
+import { ownerOrganizationId, userIdFor } from './db'
+import { settle } from './hydration'
+
+/**
+ * Adding a quote to a work order the vehicle already has open.
+ *
+ * The fixtures are written straight into the database because they are not
+ * what is under test: a vehicle of the spec's own (so no other spec's jobs are
+ * on it, and `/service/new` cannot hand back another spec's five-second draft)
+ * and a stocked part nobody else counts. Everything the feature does is then
+ * driven through the page.
+ */
+
+async function withDb<T>(fn: (db: Client) => Promise<T>): Promise<T> {
+  const url = process.env.E2E_DATABASE_URL
+  if (!url) throw new Error('E2E_DATABASE_URL is not set. See e2e/README.md.')
+  const db = new Client({ connectionString: url })
+  await db.connect()
+  try {
+    return await fn(db)
+  } finally {
+    await db.end()
+  }
+}
+
+export interface PlantedVehicle {
+  id: string
+  /** Unique across runs, so the quote dialog's vehicle search finds this one only. */
+  model: string
+}
+
+/** A customer and a vehicle of their own in the seeded workshop, with no jobs on it. */
+export async function plantVehicle(model: string): Promise<PlantedVehicle> {
+  const organizationId = await ownerOrganizationId()
+  const userId = await userIdFor('demo@torqvoice.com')
+  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`,
+      [`${model} owner`, userId, organizationId]
+    )
+    const vehicle = await db.query<{ id: string }>(
+      `insert into vehicles (id, make, model, year, "licensePlate", "userId", "organizationId", "customerId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), 'E2E', $1, 2021, $2, $3, $4, $5, now())
+       returning id`,
+      [model, `E2E-${model.slice(-6)}`, userId, organizationId, customer.rows[0].id]
+    )
+    return { id: vehicle.rows[0].id, model }
+  })
+}
+
+/** The number a quote was given, which is what the job's hero names it by. */
+export async function quoteNumberOf(quoteUrl: string): Promise<string> {
+  const id = new URL(quoteUrl).pathname.split('/').pop()
+  return withDb(async (db) => {
+    const result = await db.query<{ quoteNumber: string | null }>(
+      `select "quoteNumber" from quotes where id = $1`,
+      [id]
+    )
+    const number = result.rows[0]?.quoteNumber
+    if (!number) throw new Error(`quote ${id} has no number`)
+    return number
+  })
+}
+
+/** A stocked part of the spec's own, so no other spec moves its count. */
+export async function plantInventoryPart(part: {
+  name: string
+  quantity: number
+  unitCost: number
+  sellPrice: number
+}): Promise<string> {
+  const organizationId = await ownerOrganizationId()
+  const userId = await userIdFor('demo@torqvoice.com')
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into inventory_parts (id, name, quantity, "unitCost", "sellPrice", "userId", "organizationId", "updatedAt")
+       values (md5(random()::text || clock_timestamp()::text), $1, $2, $3, $4, $5, $6, now())
+       returning id`,
+      [part.name, part.quantity, part.unitCost, part.sellPrice, userId, organizationId]
+    )
+    return result.rows[0].id
+  })
+}
+
+/** Takes a planted part out of the pickers again, keeping its ledger. */
+export async function archiveInventoryPart(id: string): Promise<void> {
+  await withDb((db) =>
+    db.query(`update inventory_parts set "isArchived" = true where id = $1`, [id])
+  )
+}
+
+/** The convert dialog, by name: an announcement card is a dialog too. */
+export function convertDialog(page: Page): Locator {
+  return page.getByRole('dialog', { name: 'Convert Quote to Work Order' })
+}
+
+/** Opens the convert dialog on the quote open in the page. */
+export async function openConvertDialog(page: Page): Promise<Locator> {
+  await settle(page)
+  const dialog = convertDialog(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Convert to Work Order', exact: true }).click()
+    await expect(dialog).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  return dialog
+}
+
+/** The two ways the dialog offers, as radios named by their labels. */
+export function newOrderRadio(dialog: Locator): Locator {
+  return dialog.getByRole('radio', { name: 'Create a new work order' })
+}
+
+export function existingOrderRadio(dialog: Locator): Locator {
+  return dialog.getByRole('radio', { name: 'Add to an existing work order' })
+}
+
+/**
+ * The open-job picker. The dialog holds two comboboxes, the vehicle picker
+ * first and this one under the "existing" radio, so it is the last.
+ */
+export function workOrderPicker(dialog: Locator): Locator {
+  return dialog.getByRole('combobox').last()
+}
+
+/**
+ * Chooses "Add to an existing work order" and the job named by its label.
+ * Returns the dialog's confirm button, which then reads "Add to work order".
+ */
+export async function chooseExistingOrder(
+  page: Page,
+  dialog: Locator,
+  label: string
+): Promise<Locator> {
+  const existing = existingOrderRadio(dialog)
+  // Enabled once the vehicle's open jobs have loaded.
+  await expect(existing).toBeEnabled({ timeout: 15_000 })
+  await existing.check()
+
+  const picker = workOrderPicker(dialog)
+  await expect(picker).toBeVisible()
+  await expect(async () => {
+    await picker.click()
+    await expect(page.getByRole('option', { name: label, exact: true })).toBeVisible({
+      timeout: 2_000,
+    })
+  }).toPass({ timeout: 30_000 })
+  await page.getByRole('option', { name: label, exact: true }).click()
+  await expect(picker).toHaveText(label)
+
+  return dialog.getByRole('button', { name: 'Add to work order', exact: true })
+}

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

@@ -116,6 +116,7 @@ const server = createServer(async (req, res) => {
     return html(res, 200, 'Stand-in checkout', {
       org: String(result.org),
       plan: String(result.plan),
+      interval: String(result.interval),
       email: String(result.email),
       appUrl: String(result.appUrl),
     })
@@ -169,6 +170,16 @@ const server = createServer(async (req, res) => {
         return json(res, 200, { success: true })
       case 'sync':
         return json(res, 200, { checked: 1, synced: 0, errors: 0 })
+      case 'prices':
+        return json(res, 200, {
+          prices: {
+            pro: { month: { amount: 29, currency: 'usd' }, year: { amount: 290, currency: 'usd' } },
+            enterprise: {
+              month: { amount: 79, currency: 'usd' },
+              year: { amount: 790, currency: 'usd' },
+            },
+          },
+        })
       default:
         return json(res, 404, { error: 'Not found' })
     }

+ 7 - 0
init-db.sh

@@ -17,6 +17,11 @@ until pg_isready -d "$DATABASE_URL" -q; do
 done
 echo "PostgreSQL is ready!"
 
+# The Prisma CLI, the schema and the migrations live in their own folder so
+# the CLI's dependencies stay out of the server's node_modules (see Dockerfile).
+app_dir="$PWD"
+cd "$app_dir/tools"
+
 echo "Applying database migrations..."
 # Keep stderr rather than discarding it: when the retry below also fails, the
 # first error is the one that says why, and swallowing it left the container
@@ -35,6 +40,8 @@ fi
 rm -f "$migrate_err"
 echo "Migrations applied successfully!"
 
+cd "$app_dir"
+
 # Photo embedding is the one thing here that depends on a native module, and
 # sharp's prebuilt binaries need an x86-64-v2 CPU. Some virtualised hosts do not
 # advertise one — Proxmox's default kvm64 processor model, for instance, reports

+ 3 - 0
messages/de/audit.json

@@ -188,6 +188,7 @@
     "signature_remove": "Unterschrift entfernt",
     "conditionMark_add": "Zustandsmarkierung hinzugefügt",
     "conditionMark_remove": "Zustandsmarkierung entfernt",
+    "conditionMark_carry": "Frühere Zustandsmarkierungen bestätigt",
     "conditionMark_onInvoice": "Zustandskarte auf Rechnung",
     "settings_markType_create": "Markierungsart hinzugefügt",
     "settings_markType_update": "Markierungsart geändert",
@@ -234,6 +235,7 @@
     "payment_delete": "Zahlung {id} gelöscht",
     "quoteRequest_update": "Status der Angebotsanfrage geändert auf {status, select, pending {offen} approved {genehmigt} rejected {abgelehnt} completed {abgeschlossen} other {{status}}}",
     "quote_convert": "Angebot {ref} in Auftrag {serviceRecordId} umgewandelt",
+    "quote_convert_existing": "Angebot {ref} zum bestehenden Arbeitsauftrag {jobRef} hinzugefügt",
     "quote_create": "Angebot {ref} erstellt",
     "quote_delete": "Angebot {ref} gelöscht",
     "quote_status": "Angebotsstatus geändert auf {status, select, draft {Entwurf} sent {gesendet} accepted {angenommen} rejected {abgelehnt} expired {abgelaufen} converted {umgewandelt} other {{status}}}",
@@ -342,6 +344,7 @@
     "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_mark_carry": "{count, plural, one {# frühere Markierung} other {# frühere Markierungen}} auf der Zustandskarte als noch vorhanden bestätigt",
     "condition_map_on_invoice_on": "Zustandskarte auf die Rechnung gesetzt",
     "condition_map_on_invoice_off": "Zustandskarte von der Rechnung genommen"
   }

+ 3 - 0
messages/de/conditionMap.json

@@ -9,6 +9,8 @@
   "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",
+  "confirmAll": "Alle als noch vorhanden bestätigen",
+  "confirmAllHint": "Erfasst sie auf diesem Blatt als Markierungen dieses Besuchs, in Farbe.",
   "clearRepaired": "Als repariert löschen",
   "restore": "Wiederherstellen",
   "edit": "Markierung bearbeiten",
@@ -38,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Bei einem späteren Besuch erneut bestätigt. Bleibt hier als Aufzeichnung dieses Besuchs und wird dort geändert.",
   "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}}",

+ 39 - 0
messages/de/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Alle 30 Minuten. Eine in QuickBooks gegen eine Rechnung aus Torqvoice erhaltene Zahlung wird hier auf der Rechnung erfasst."
       }
     },
+    "fiken": {
+      "description": "Sende ausgestellte Rechnungen, ihre Kunden und ihre Zahlungen an Fiken und hole dort erfasste Zahlungen zurück.",
+      "tenantHelp": "Erstelle in Fiken unter Rediger konto, API eine App für deine eigenen Firmen. Trage die Redirect-URI unten in der App ein und kopiere Client-ID und Client-Secret hierher. Die Firma braucht das API-Modul von Fiken.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Client-Secret"
+      },
+      "settings": {
+        "companySlug": "Firma in Fiken",
+        "companySlugHelp": "Die Firma, in deren Buchhaltung die Rechnungen gehen. Dein Fiken-Benutzer kann Zugriff auf mehrere haben.",
+        "pushInvoices": "Ausgestellte Rechnungen an Fiken senden",
+        "pushOnComplete": "Rechnungen auch senden, wenn der Auftrag als abgeschlossen markiert wird",
+        "pushOnCompleteHelp": "Ohne diese Option geht eine Rechnung erst über, wenn sie gesendet, geteilt oder bezahlt wurde. Mit ihr geht ein abgeschlossener Auftrag sofort über und wird bei Änderungen aktualisiert.",
+        "startDate": "Nur Rechnungen mit Datum ab",
+        "startDateHelp": "Leer lassen, um jede ausgestellte Rechnung zu senden. Nutze das Datum, an dem deine Bücher zu Fiken gewechselt sind, damit ältere Rechnungen nicht doppelt erfasst werden.",
+        "attachPdf": "Rechnungs-PDF anhängen",
+        "attachPdfHelp": "Eine Rechnung geht als Verkauf (Annet salg) mit ihrer Torqvoice-Nummer nach Fiken. Ist dies an, wird das PDF, das der Kunde erhalten hat, als Beleg am Verkauf abgelegt.",
+        "laborAccount": "Erlöskonto für Arbeit",
+        "laborAccountHelp": "Leer lassen, um 3000 zu verwenden.",
+        "partsAccount": "Erlöskonto für Teile",
+        "partsAccountHelp": "Leer lassen, um 3000 zu verwenden.",
+        "zeroVatType": "MwSt-Typ für Rechnungen ohne MwSt",
+        "zeroVatTypeHelp": "Wie Fiken einen Verkauf ohne MwSt bucht. Eine Firma, die nicht für die MwSt registriert ist, muss hier nichts wählen. Eine registrierte muss wählen, bevor eine solche Rechnung gesendet wird; frage deinen Buchhalter, was gilt.",
+        "vatNone": "Keine (keine MwSt-Behandlung)",
+        "vatExempt": "Befreit (fritatt)",
+        "vatOutside": "Außerhalb des MwSt-Bereichs (utenfor)",
+        "vatExport": "Befreit, Export",
+        "vatReverse": "Befreit, Reverse Charge",
+        "zeroAccount": "Erlöskonto für Rechnungen ohne MwSt",
+        "zeroAccountHelp": "Leer lassen, um die Konten für Arbeit und Teile oben zu verwenden. Lehnt Fiken das Konto mit dem MwSt-Typ ab, steht der Grund im Aktivitätsprotokoll.",
+        "pushPayments": "Zahlungen in Fiken erfassen",
+        "paymentAccount": "Zahlungen einzahlen auf",
+        "paymentAccountHelp": "Leer lassen, um das erste Bankkonto der Firma zu verwenden.",
+        "manualPaidAsPayment": "Zahlung erfassen, wenn eine Rechnung von Hand als bezahlt markiert wird",
+        "manualPaidAsPaymentHelp": "Eine in Torqvoice ohne Zahlungseintrag als bezahlt markierte Rechnung erhält in Fiken eine Zahlung über den dort noch offenen Betrag. Ausgeschaltet bleibt sie dort offen, bis eine Zahlung erfasst wird.",
+        "pullPayments": "In Fiken erfasste Zahlungen zurückholen",
+        "pullPaymentsHelp": "Alle 30 Minuten. Eine in Fiken gegen eine Rechnung aus Torqvoice erhaltene Zahlung wird hier auf der Rechnung erfasst."
+      }
+    },
     "nhtsa": {
       "description": "Fülle ein Fahrzeug aus seiner FIN aus und sieh Rückrufe, Halterbeschwerden und Crashtest-Bewertungen für jedes Modelljahr, von der US-Sicherheitsbehörde. Kostenlos, ohne Schlüssel.",
       "fields": {},

+ 2 - 0
messages/de/pdf.json

@@ -20,6 +20,8 @@
     "type": "Typ: {type}",
     "tech": "Techniker: {tech}",
     "parts": "Teile",
+    "otherParts": "Sonstige Teile",
+    "groupSubtotal": "Zwischensumme {group}",
     "partNumber": "Teilenr.",
     "description": "Beschreibung",
     "qty": "Menge",

+ 40 - 0
messages/de/quotes.json

@@ -36,6 +36,45 @@
     "selectVehicle": "Bitte wählen Sie ein Fahrzeug",
     "convertTitle": "Angebot in Arbeitsauftrag umwandeln",
     "convertDescription": "Es wird ein neuer Serviceauftrag (Arbeitsauftrag) aus diesem Angebot erstellt. Wählen Sie ein Fahrzeug für den Arbeitsauftrag.",
+    "convertChoiceDescription": "Wählen Sie ein Fahrzeug und erstellen Sie aus diesem Angebot einen neuen Arbeitsauftrag oder fügen Sie es einem bereits offenen hinzu.",
+    "convertNew": "Neuen Arbeitsauftrag erstellen",
+    "convertExisting": "Zu einem bestehenden Arbeitsauftrag hinzufügen",
+    "convertExistingHint": "Teile, Arbeitszeit und Dateien des Angebots werden dem Arbeitsauftrag hinzugefügt. Was bereits darauf steht, bleibt unverändert.",
+    "convertNoOpenWorkOrders": "Für dieses Fahrzeug gibt es keine offenen Arbeitsaufträge.",
+    "convertSelectWorkOrder": "Arbeitsauftrag auswählen",
+    "convertAdd": "Zum Arbeitsauftrag hinzufügen",
+    "convertConflicts": {
+      "heading": "Vor dem Hinzufügen: Was behält der Arbeitsauftrag?",
+      "checking": "Angebot wird mit dem Arbeitsauftrag abgeglichen...",
+      "unanswered": "Legen Sie fest, was der Arbeitsauftrag behält, bevor das Angebot hinzugefügt wird.",
+      "discount": {
+        "title": "Rabatt",
+        "summary": "Der Arbeitsauftrag hat {percent}% Rabatt, derzeit {jobAmount}. Das Angebot enthält einen Rabatt von {quoteAmount}.",
+        "keepJob": "{percent}% Rabatt behalten und die {quoteAmount} des Angebots verwerfen",
+        "combine": "{percent}% in {jobAmount} umwandeln und die {quoteAmount} des Angebots hinzufügen, insgesamt {total}"
+      },
+      "tax": {
+        "title": "Steuer",
+        "summary": "Der Arbeitsauftrag ist mit {job} kalkuliert. Das Angebot wurde mit {quote} kalkuliert.",
+        "keepJob": "Steuer des Arbeitsauftrags behalten. Die Positionen des Angebots werden zu den angebotenen Beträgen übernommen.",
+        "useQuote": "Arbeitsauftrag auf die Steuer des Angebots umstellen. Alles darauf wird neu summiert.",
+        "inclusive": "{rate}% Steuer in den Positionen enthalten",
+        "exclusive": "{rate}% Steuer kommt hinzu",
+        "components": "{rate}% Steuer ({names})"
+      },
+      "warranty": {
+        "title": "Garantie",
+        "summary": "Der Arbeitsauftrag sagt {job}. Das vom Kunden angenommene Angebot sagt {quote}.",
+        "keepJob": "Garantie des Arbeitsauftrags behalten",
+        "useQuote": "Garantie des Angebots übernehmen, wie der Kunde sie angenommen hat",
+        "none": "nichts zur Garantie",
+        "notIncluded": "keine Werkstattgarantie",
+        "included": "Garantie inbegriffen",
+        "includedWithTerms": "Garantie inbegriffen, {terms}",
+        "months": "{count, plural, one {# Monat} other {# Monate}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Umwandeln",
     "convertToWorkOrder": "In Arbeitsauftrag umwandeln",
     "cancel": "Abbrechen",
@@ -68,6 +107,7 @@
     "title": "Teile",
     "addPart": "Teil hinzufügen",
     "partNumber": "Teilenr.",
+    "category": "Kategorie",
     "name": "Name",
     "namePlaceholder": "Name *",
     "qty": "Menge",

+ 2 - 0
messages/de/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Aus Bestand",
     "addPart": "Teil hinzufügen",
     "partNumber": "Teil #",
+    "category": "Kategorie",
     "name": "Name",
     "qty": "Menge",
     "unitCost": "Kosten",
@@ -666,6 +667,7 @@
     },
     "moreStatuses": "Weitere Status",
     "fromInspection": "Aus Inspektion {name}, {date}",
+    "fromQuote": "Aus Angebot {ref}",
     "startInspection": "Inspektion starten"
   },
   "photoHandoff": {

+ 32 - 62
messages/de/settings.json

@@ -348,6 +348,14 @@
     "deleteWorkshopConfirmPrompt": "Geben Sie <bold>{name}</bold> ein, um zu bestätigen.",
     "deleteWorkshopSuccess": "Werkstatt gelöscht",
     "deleteWorkshopFailed": "Werkstatt konnte nicht gelöscht werden",
+    "reauthPasswordLabel": "Ihr Passwort",
+    "reauthTotpLabel": "Code aus Ihrer Authenticator-App",
+    "reauthFreshSessionHint": "Ihr Konto hat kein Passwort, daher funktioniert dies nur innerhalb von {minutes} Minuten nach der Anmeldung. Ist es länger her, melden Sie sich zuerst ab und wieder an.",
+    "reauthWrongPassword": "Falsches Passwort.",
+    "reauthWrongCode": "Falscher Code.",
+    "reauthTooManyAttempts": "Zu viele Versuche. Warten Sie einige Minuten und versuchen Sie es erneut.",
+    "reauthSignInAgainAccount": "Melden Sie sich erneut an, bevor Sie Ihr Konto löschen.",
+    "reauthSignInAgainWorkshop": "Melden Sie sich erneut an, bevor Sie die Werkstatt löschen.",
     "deleteAccountMoved": "Möchten Sie Ihr Konto löschen?",
     "deleteAccountMovedLink": "Das können Sie in den Dateneinstellungen tun.",
     "signatureTitle": "Unterschrift",
@@ -758,12 +766,14 @@
       "next_test_due": "Nächste Prüfung fällig",
       "defect_notes": "Anmerkungen",
       "defect_photos": "Fotos",
+      "no_defects_note": "Hinweis, wenn keine Mängel vorliegen",
       "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",
+      "ungraded_checks": "Noch nicht bewertete Prüfungen",
       "inspector_line": "Unterschriftszeile",
       "inspector_name": "Name des Unterzeichners",
       "date_line": "Datumszeile",
@@ -1079,64 +1089,6 @@
     "applyTaxNoRecords": "Keine Einträge benötigen den Standardsteuersatz.",
     "saveSettings": "Einstellungen speichern",
     "saved": "Einstellungen gespeichert",
-    "currencies": {
-      "USD": "US-Dollar",
-      "EUR": "Euro",
-      "GBP": "Britisches Pfund",
-      "NOK": "Norwegische Krone",
-      "SEK": "Schwedische Krone",
-      "DKK": "Danische Krone",
-      "CHF": "Schweizer Franken",
-      "CAD": "Kanadischer Dollar",
-      "AUD": "Australischer Dollar",
-      "NZD": "Neuseelandischer Dollar",
-      "JPY": "Japanischer Yen",
-      "CNY": "Chinesischer Yuan",
-      "INR": "Indische Rupie",
-      "BRL": "Brasilianischer Real",
-      "MXN": "Mexikanischer Peso",
-      "PLN": "Polnischer Zloty",
-      "CZK": "Tschechische Krone",
-      "HUF": "Ungarischer Forint",
-      "TRY": "Turkische Lira",
-      "ZAR": "Sudafrikanischer Rand",
-      "KRW": "Sudkoreanischer Won",
-      "SGD": "Singapur-Dollar",
-      "HKD": "Hongkong-Dollar",
-      "THB": "Thailandischer Baht",
-      "ISK": "Islandische Krone",
-      "RON": "Rumanischer Leu",
-      "ILS": "Israelischer Schekel",
-      "PHP": "Philippinischer Peso",
-      "IDR": "Indonesische Rupiah",
-      "MYR": "Malaysischer Ringgit",
-      "LKR": "Sri-Lanka-Rupie",
-      "AED": "VAE-Dirham",
-      "SAR": "Saudi-Riyal",
-      "RUB": "Russischer Rubel",
-      "TWD": "Taiwan-Dollar",
-      "VND": "Vietnamesischer Dong",
-      "PKR": "Pakistanische Rupie",
-      "BDT": "Bangladesch-Taka",
-      "NPR": "Nepalesische Rupie",
-      "BGN": "Bulgarischer Lew",
-      "UAH": "Ukrainische Hrywnja",
-      "CLP": "Chilenischer Peso",
-      "COP": "Kolumbianischer Peso",
-      "ARS": "Argentinischer Peso",
-      "PEN": "Peruanischer Sol",
-      "EGP": "Ägyptisches Pfund",
-      "NGN": "Nigerianischer Naira",
-      "KES": "Kenia-Schilling",
-      "MAD": "Marokkanischer Dirham",
-      "DZD": "Algerischer Dinar",
-      "GHS": "Ghanaischer Cedi",
-      "QAR": "Katar-Riyal",
-      "KWD": "Kuwait-Dinar",
-      "BHD": "Bahrain-Dinar",
-      "OMR": "Omanischer Rial",
-      "JOD": "Jordanischer Dinar"
-    },
     "formatTitle": "Währungsformat",
     "formatSymbolName": "Symbol",
     "formatCodeName": "Code",
@@ -1697,9 +1649,18 @@
     "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.",
-    "upgradeToEnterpriseDescription": "Benötigen Sie mehr als 5 Teammitglieder? Upgraden Sie auf Enterprise für bis zu 50 Benutzer.",
-    "upgradeToPro": "Auf Pro upgraden — $99/Jahr",
-    "upgradeToEnterprise": "Enterprise — $140/Jahr",
+    "upgradeToEnterpriseDescription": "Mehr als eine Werkstatt? Enterprise umfasst bis zu 10 Werkstätten und 50 Teammitglieder.",
+    "upgradeToPro": "Auf Pro upgraden",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Abrechnungszeitraum",
+    "billingMonthly": "Monatlich",
+    "billingAnnual": "Jährlich",
+    "billingSave": "{percent}% sparen",
+    "billingMonthsFree": "{months, plural, one {# Monat gratis} other {# Monate gratis}}",
+    "billedAnnually": "{amount} jährlich abgerechnet",
+    "discountBadge": "{value} Rabatt",
+    "discountOnce": "Erste Zahlung, danach {amount}",
+    "discountRepeating": "{months, plural, one {Erster Monat} other {Erste # Monate}}, danach {amount}",
     "upgradeDialogTitle": "Auf Enterprise upgraden",
     "upgradeDialogDescription": "Ihr aktueller Pro-Plan wird auf Enterprise aufgerüstet. Es wird Ihnen nur die anteilige Differenz für den verbleibenden Abrechnungszeitraum berechnet.",
     "upgradeConfirm": "Upgrade bestätigen",
@@ -2086,6 +2047,10 @@
     "itemsTableHint": "Eine nummerierte Liste aller Positionen des Auftrags, erst Arbeit, dann Teile, anstelle der getrennten Teile- und Arbeitstabellen.",
     "discardChanges": "Änderungen verwerfen",
     "tablesExclusiveHint": "Das Blatt druckt eine Anordnung: eine nummerierte Liste oder getrennte Teile- und Arbeitstabellen. Wird die eine eingeschaltet, geht die andere aus.",
+    "groupLines": "Zeilen gruppieren",
+    "groupLinesOff": "Aus",
+    "groupLinesCategory": "Nach Kategorie",
+    "groupLinesHint": "Nach Kategorie druckt die Teile unter einer Überschrift je Kategorie mit eigener Zwischensumme, in der gemeinsamen Liste zuerst die Arbeit. Teile ohne Kategorie werden als „Sonstige Teile“ zusammengefasst.",
     "innerPadding": "Innenabstand",
     "totalsWidth": "Breite",
     "totalsWidthHint": "Leer füllt die Zeile. Ein Wert in Punkt hängt den Kasten rechts an; in einer Spalte nimmt er deren Breite.",
@@ -2174,6 +2139,8 @@
       "itemLabor": "Vordere Bremsen erneuert",
       "itemDisc": "Bremsscheibe, vorne",
       "itemPads": "Bremsbelagsatz, Vorderachse",
+      "categoryBrakes": "Bremsen",
+      "categoryConsumables": "Werkstattbedarf",
       "itemConsumables": "Verbrauchsmaterial",
       "unitPcs": "Stk",
       "unitSet": "Satz",
@@ -2200,6 +2167,7 @@
       "sectionLighting": "Beleuchtungseinrichtungen",
       "checkHeadlamps": "Zustand der Scheinwerfer",
       "checkFogLamp": "Nebelschlussleuchte",
+      "checkStopLamps": "Bremsleuchten",
       "photoFront": "Vorne",
       "photoOdometer": "Kilometerstand",
       "concernNoise": "Schleifgeräusch vorne beim Bremsen",
@@ -2272,7 +2240,9 @@
     "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."
+    "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.",
+    "conditionMapDense": "Kompakte Tabelle",
+    "conditionMapDenseHint": "Setzt die Zeilen der Tabelle unter der Zeichnung enger, sodass sie weniger Platz auf der Seite braucht."
   },
   "preview": {
     "title": "Bremsenservice und Ölwechsel",

+ 15 - 0
messages/de/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Anwenden",
     "allTechnicians": "Alle Techniker",
     "export": "CSV exportieren",
+    "print": "Stundenzettel drucken",
+    "client": "Kunde",
+    "noClient": "Kein Kunde",
+    "signature": "Unterschrift",
+    "sortBy": "Sortieren nach",
+    "sort": {
+      "default": "Techniker / Tag",
+      "client": "Kunde",
+      "vehicle": "Fahrzeug",
+      "clientVehicle": "Kunde, dann Fahrzeug"
+    },
+    "sortDirection": "Sortierrichtung",
+    "ascending": "Aufsteigend",
+    "descending": "Absteigend",
     "addEntry": "Eintrag hinzufügen",
     "stats": {
       "total": "Gesamtzeit",
@@ -101,6 +115,7 @@
       "duration": "Dauer",
       "hours": "Stunden",
       "job": "Auftrag",
+      "client": "Kunde",
       "vehicle": "Fahrzeug",
       "plate": "Kennzeichen",
       "source": "Quelle",

+ 3 - 0
messages/en/audit.json

@@ -188,6 +188,7 @@
     "signature_remove": "Removed Signature",
     "conditionMark_add": "Added a condition mark",
     "conditionMark_remove": "Removed a condition mark",
+    "conditionMark_carry": "Confirmed earlier condition marks",
     "conditionMark_onInvoice": "Condition map on invoice",
     "settings_markType_create": "Added a kind of mark",
     "settings_markType_update": "Changed a kind of mark",
@@ -234,6 +235,7 @@
     "payment_delete": "Deleted payment {id}",
     "quoteRequest_update": "Updated quote request status to {status, select, pending {pending} approved {approved} rejected {rejected} completed {completed} other {{status}}}",
     "quote_convert": "Converted quote {ref} to service record {serviceRecordId}",
+    "quote_convert_existing": "Added quote {ref} to existing work order {jobRef}",
     "quote_create": "Created quote {ref}",
     "quote_delete": "Deleted quote {ref}",
     "quote_status": "Changed quote status to {status, select, draft {draft} sent {sent} accepted {accepted} rejected {rejected} expired {expired} converted {converted} other {{status}}}",
@@ -342,6 +344,7 @@
     "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_mark_carry": "Confirmed {count, plural, one {# earlier mark} other {# earlier marks}} as still there on 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"
   }

+ 3 - 0
messages/en/conditionMap.json

@@ -9,6 +9,8 @@
   "previousHint": "Still there from last time, drawn in grey. Clear the ones that are gone.",
   "newSinceLastVisit": "New since the last visit",
   "confirmed": "Still there",
+  "confirmAll": "Confirm all as still there",
+  "confirmAllHint": "Records them on this sheet as this visit's marks, in colour.",
   "clearRepaired": "Clear as repaired",
   "restore": "Restore",
   "edit": "Edit mark",
@@ -38,6 +40,7 @@
   "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.",
+  "readOnlyCarried": "Confirmed again on a later visit. It stays here as this visit's record and is changed there.",
   "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}}",

+ 39 - 0
messages/en/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Every 30 minutes. A payment received in QuickBooks against an invoice from Torqvoice is recorded on the invoice here."
       }
     },
+    "fiken": {
+      "description": "Send issued invoices, their customers and their payments to Fiken, and bring back payments recorded there.",
+      "tenantHelp": "Create an app in Fiken under Rediger konto, API, for your own companies. Enter the redirect URI below on the app, then copy its client ID and client secret here. The company needs Fiken's API module.",
+      "fields": {
+        "clientId": "Client ID",
+        "clientSecret": "Client secret"
+      },
+      "settings": {
+        "companySlug": "Fiken company",
+        "companySlugHelp": "The company whose books the invoices go into. Your Fiken user may have access to several.",
+        "pushInvoices": "Send issued invoices to Fiken",
+        "pushOnComplete": "Also send invoices when the job is marked completed",
+        "pushOnCompleteHelp": "Without this an invoice goes over once it is sent, shared or paid. With it, a completed job goes over straight away and is updated if it changes.",
+        "startDate": "Only invoices dated on or after",
+        "startDateHelp": "Leave empty to send every issued invoice. Use the date your books moved to Fiken so older invoices are not entered twice.",
+        "attachPdf": "Attach the invoice PDF",
+        "attachPdfHelp": "An invoice goes into Fiken as a sale (Annet salg) under its Torqvoice number. With this on, the PDF the customer received is filed on the sale as its voucher.",
+        "laborAccount": "Income account for labour",
+        "laborAccountHelp": "Leave empty to use 3000.",
+        "partsAccount": "Income account for parts",
+        "partsAccountHelp": "Leave empty to use 3000.",
+        "zeroVatType": "VAT type for invoices without VAT",
+        "zeroVatTypeHelp": "How Fiken books a sale that carries no VAT. A company that is not VAT registered needs no choice here. A registered one must choose before such an invoice is sent; ask your accountant which applies.",
+        "vatNone": "None (no VAT handling)",
+        "vatExempt": "Exempt (fritatt)",
+        "vatOutside": "Outside the VAT area (utenfor)",
+        "vatExport": "Exempt, export",
+        "vatReverse": "Exempt, reverse charge",
+        "zeroAccount": "Income account for invoices without VAT",
+        "zeroAccountHelp": "Leave empty to use the labour and parts accounts above. If Fiken refuses the account with the VAT type, the reason is shown in the activity log.",
+        "pushPayments": "Record payments in Fiken",
+        "paymentAccount": "Deposit payments to",
+        "paymentAccountHelp": "Leave empty to use the company's first bank account.",
+        "manualPaidAsPayment": "Record a payment when an invoice is marked paid by hand",
+        "manualPaidAsPaymentHelp": "An invoice marked as paid in Torqvoice without a payment entry gets one payment in Fiken for whatever it still shows as owed. Off, it stays open there until a payment is recorded.",
+        "pullPayments": "Bring back payments recorded in Fiken",
+        "pullPaymentsHelp": "Every 30 minutes. A payment received in Fiken against an invoice from Torqvoice is recorded on the invoice here."
+      }
+    },
     "nhtsa": {
       "description": "Fill in a vehicle from its VIN, and see recalls, owner complaints and crash ratings for every model year, from the US safety authority. Free, no key.",
       "fields": {},

+ 2 - 0
messages/en/pdf.json

@@ -20,6 +20,8 @@
     "type": "Type: {type}",
     "tech": "Tech: {tech}",
     "parts": "Parts",
+    "otherParts": "Other parts",
+    "groupSubtotal": "{group} subtotal",
     "partNumber": "Part #",
     "description": "Description",
     "qty": "Qty",

+ 40 - 0
messages/en/quotes.json

@@ -36,6 +36,45 @@
     "selectVehicle": "Please select a vehicle",
     "convertTitle": "Convert Quote to Work Order",
     "convertDescription": "This will create a new service record (work order) from this quote. Select a vehicle for the work order.",
+    "convertChoiceDescription": "Select a vehicle, then create a new work order from this quote or add it to one that is already open.",
+    "convertNew": "Create a new work order",
+    "convertExisting": "Add to an existing work order",
+    "convertExistingHint": "The quote's parts, labor and files are added to the work order. What is already on it stays as it is.",
+    "convertNoOpenWorkOrders": "This vehicle has no open work orders.",
+    "convertSelectWorkOrder": "Select a work order",
+    "convertAdd": "Add to work order",
+    "convertConflicts": {
+      "heading": "Before adding, decide what the work order keeps",
+      "checking": "Checking the quote against the work order...",
+      "unanswered": "Choose what the work order keeps before adding the quote.",
+      "discount": {
+        "title": "Discount",
+        "summary": "The work order has a {percent}% discount, {jobAmount} as it stands. The quote carries a discount of {quoteAmount}.",
+        "keepJob": "Keep the {percent}% discount and drop the quote's {quoteAmount}",
+        "combine": "Turn the {percent}% into {jobAmount} and add the quote's {quoteAmount}, {total} in all"
+      },
+      "tax": {
+        "title": "Tax",
+        "summary": "The work order is priced with {job}. The quote was priced with {quote}.",
+        "keepJob": "Keep the work order's tax. The quote's lines come across at the amounts quoted.",
+        "useQuote": "Switch the work order to the quote's tax. Everything on it is re-totalled.",
+        "inclusive": "{rate}% tax included in the lines",
+        "exclusive": "{rate}% tax added on top",
+        "components": "{rate}% tax ({names})"
+      },
+      "warranty": {
+        "title": "Warranty",
+        "summary": "The work order says {job}. The quote the customer accepted says {quote}.",
+        "keepJob": "Keep the work order's warranty",
+        "useQuote": "Use the quote's warranty, as the customer accepted it",
+        "none": "nothing about warranty",
+        "notIncluded": "no workshop warranty",
+        "included": "warranty included",
+        "includedWithTerms": "warranty included, {terms}",
+        "months": "{count, plural, one {# month} other {# months}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Convert",
     "convertToWorkOrder": "Convert to Work Order",
     "cancel": "Cancel",
@@ -68,6 +107,7 @@
     "title": "Parts",
     "addPart": "Add Part",
     "partNumber": "Part #",
+    "category": "Category",
     "name": "Name",
     "namePlaceholder": "Name *",
     "qty": "Qty",

+ 2 - 0
messages/en/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "From Inventory",
     "addPart": "Add Part",
     "partNumber": "Part #",
+    "category": "Category",
     "name": "Name",
     "qty": "Qty",
     "unitCost": "Cost",
@@ -666,6 +667,7 @@
     },
     "moreStatuses": "More statuses",
     "fromInspection": "From inspection {name}, {date}",
+    "fromQuote": "From quote {ref}",
     "startInspection": "Start inspection"
   },
   "photoHandoff": {

+ 32 - 62
messages/en/settings.json

@@ -348,6 +348,14 @@
     "deleteWorkshopConfirmPrompt": "Type <bold>{name}</bold> to confirm.",
     "deleteWorkshopSuccess": "Workshop deleted",
     "deleteWorkshopFailed": "Failed to delete the workshop",
+    "reauthPasswordLabel": "Your password",
+    "reauthTotpLabel": "Code from your authenticator app",
+    "reauthFreshSessionHint": "Your account has no password, so this only works within {minutes} minutes of signing in. If it has been longer, sign out and sign in again first.",
+    "reauthWrongPassword": "Wrong password.",
+    "reauthWrongCode": "Wrong code.",
+    "reauthTooManyAttempts": "Too many attempts. Wait a few minutes and try again.",
+    "reauthSignInAgainAccount": "Sign in again before deleting your account.",
+    "reauthSignInAgainWorkshop": "Sign in again before deleting the workshop.",
     "deleteAccountMoved": "Want to delete your account?",
     "deleteAccountMovedLink": "You can do that in Data settings.",
     "signatureTitle": "Signature",
@@ -758,12 +766,14 @@
       "next_test_due": "Next test due",
       "defect_notes": "Notes",
       "defect_photos": "Photos",
+      "no_defects_note": "Note when there are no defects",
       "passed_checks": "Passed checks",
       "not_applicable_checks": "Not applicable checks",
       "check_notes": "Notes column",
       "result_detail": "Explanation",
       "result_summary": "Counts per grade",
       "combined_table": "One table for all sections",
+      "ungraded_checks": "Checks not graded yet",
       "inspector_line": "Signature line",
       "inspector_name": "Signer's name",
       "date_line": "Date line",
@@ -1079,64 +1089,6 @@
     "applyTaxNoRecords": "No records need the default tax rate applied.",
     "saveSettings": "Save Settings",
     "saved": "Settings saved",
-    "currencies": {
-      "USD": "US Dollar",
-      "EUR": "Euro",
-      "GBP": "British Pound",
-      "NOK": "Norwegian Krone",
-      "SEK": "Swedish Krona",
-      "DKK": "Danish Krone",
-      "CHF": "Swiss Franc",
-      "CAD": "Canadian Dollar",
-      "AUD": "Australian Dollar",
-      "NZD": "New Zealand Dollar",
-      "JPY": "Japanese Yen",
-      "CNY": "Chinese Yuan",
-      "INR": "Indian Rupee",
-      "BRL": "Brazilian Real",
-      "MXN": "Mexican Peso",
-      "PLN": "Polish Zloty",
-      "CZK": "Czech Koruna",
-      "HUF": "Hungarian Forint",
-      "TRY": "Turkish Lira",
-      "ZAR": "South African Rand",
-      "KRW": "South Korean Won",
-      "SGD": "Singapore Dollar",
-      "HKD": "Hong Kong Dollar",
-      "THB": "Thai Baht",
-      "ISK": "Icelandic Krona",
-      "RON": "Romanian Leu",
-      "ILS": "Israeli Shekel",
-      "PHP": "Philippine Peso",
-      "IDR": "Indonesian Rupiah",
-      "MYR": "Malaysian Ringgit",
-      "LKR": "Sri Lankan Rupee",
-      "AED": "UAE Dirham",
-      "SAR": "Saudi Riyal",
-      "RUB": "Russian Ruble",
-      "TWD": "Taiwan Dollar",
-      "VND": "Vietnamese Dong",
-      "PKR": "Pakistani Rupee",
-      "BDT": "Bangladeshi Taka",
-      "NPR": "Nepalese Rupee",
-      "BGN": "Bulgarian Lev",
-      "UAH": "Ukrainian Hryvnia",
-      "CLP": "Chilean Peso",
-      "COP": "Colombian Peso",
-      "ARS": "Argentine Peso",
-      "PEN": "Peruvian Sol",
-      "EGP": "Egyptian Pound",
-      "NGN": "Nigerian Naira",
-      "KES": "Kenyan Shilling",
-      "MAD": "Moroccan Dirham",
-      "DZD": "Algerian Dinar",
-      "GHS": "Ghanaian Cedi",
-      "QAR": "Qatari Riyal",
-      "KWD": "Kuwaiti Dinar",
-      "BHD": "Bahraini Dinar",
-      "OMR": "Omani Rial",
-      "JOD": "Jordanian Dinar"
-    },
     "formatTitle": "Currency Format",
     "formatSymbolName": "Symbol",
     "formatCodeName": "Code",
@@ -1697,9 +1649,18 @@
     "upgradeTitle": "Upgrade Plan",
     "upgradeDescription": "Unlock more features by upgrading your plan.",
     "upgradeToProDescription": "Upgrade to unlock unlimited customers, team members, reports, SMTP, API access, and more.",
-    "upgradeToEnterpriseDescription": "Need more than 5 team members? Upgrade to Enterprise for up to 50 users.",
-    "upgradeToPro": "Upgrade to Pro — $99/yr",
-    "upgradeToEnterprise": "Enterprise — $140/yr",
+    "upgradeToEnterpriseDescription": "Running more than one workshop? Enterprise covers up to 10 workshops and 50 team members.",
+    "upgradeToPro": "Upgrade to Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Billing interval",
+    "billingMonthly": "Monthly",
+    "billingAnnual": "Annual",
+    "billingSave": "Save {percent}%",
+    "billingMonthsFree": "{months, plural, one {# month free} other {# months free}}",
+    "billedAnnually": "{amount} billed annually",
+    "discountBadge": "{value} off",
+    "discountOnce": "First payment, then {amount}",
+    "discountRepeating": "First {months, plural, one {month} other {# months}}, then {amount}",
     "upgradeDialogTitle": "Upgrade to Enterprise",
     "upgradeDialogDescription": "Your current Pro plan will be upgraded to Enterprise. You will only be charged the prorated difference for the remaining billing period.",
     "upgradeConfirm": "Confirm Upgrade",
@@ -2086,6 +2047,10 @@
     "itemsTableHint": "One numbered list of every line on the job, labor first and parts after, in place of the separate parts and labor tables.",
     "discardChanges": "Discard changes",
     "tablesExclusiveHint": "The sheet prints one arrangement: a single numbered list, or separate parts and labor tables. Switching one on switches the other off.",
+    "groupLines": "Group lines",
+    "groupLinesOff": "Off",
+    "groupLinesCategory": "By category",
+    "groupLinesHint": "By category prints the parts under a heading per category with a subtotal for each, labor first in the combined list. Parts without a category are added up as \"Other parts\".",
     "innerPadding": "Inner spacing",
     "totalsWidth": "Width",
     "totalsWidthHint": "Empty spans the row. Set points to hang the box on the right; in a column it takes the column's width.",
@@ -2174,6 +2139,8 @@
       "itemLabor": "Front brakes replaced",
       "itemDisc": "Brake disc, front",
       "itemPads": "Brake pad set, front axle",
+      "categoryBrakes": "Brakes",
+      "categoryConsumables": "Shop supplies",
       "itemConsumables": "Consumables",
       "unitPcs": "pcs",
       "unitSet": "set",
@@ -2200,6 +2167,7 @@
       "sectionLighting": "Lighting equipment",
       "checkHeadlamps": "Headlamp condition",
       "checkFogLamp": "Rear fog lamp",
+      "checkStopLamps": "Stop lamps",
       "photoFront": "Front",
       "photoOdometer": "Odometer",
       "concernNoise": "Grinding noise from the front when braking",
@@ -2272,7 +2240,9 @@
     "conditionMapViewsAll": "All five",
     "conditionMapViewsTop": "Top only",
     "conditionMapViewsSides": "Sides only",
-    "conditionMapWidthHint": "Empty spans the row. Set points to make the drawing smaller; it then sits where the alignment puts it, and the legend keeps the full width."
+    "conditionMapWidthHint": "Empty spans the row. Set points to make the drawing smaller; it then sits where the alignment puts it, and the legend keeps the full width.",
+    "conditionMapDense": "Dense table",
+    "conditionMapDenseHint": "Sets the rows of the table under the drawing closer together, so it takes less of the page."
   },
   "preview": {
     "title": "Brake Service & Oil Change",

+ 15 - 0
messages/en/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Apply",
     "allTechnicians": "All technicians",
     "export": "Export CSV",
+    "print": "Print timesheet",
+    "client": "Client",
+    "noClient": "No client",
+    "signature": "Signature",
+    "sortBy": "Sort by",
+    "sort": {
+      "default": "Technician / day",
+      "client": "Client",
+      "vehicle": "Vehicle",
+      "clientVehicle": "Client, then vehicle"
+    },
+    "sortDirection": "Sort direction",
+    "ascending": "Ascending",
+    "descending": "Descending",
     "addEntry": "Add entry",
     "stats": {
       "total": "Total time",
@@ -101,6 +115,7 @@
       "duration": "Duration",
       "hours": "Hours",
       "job": "Job",
+      "client": "Client",
       "vehicle": "Vehicle",
       "plate": "Plate",
       "source": "Source",

+ 3 - 0
messages/es/audit.json

@@ -188,6 +188,7 @@
     "signature_remove": "Eliminó la firma",
     "conditionMark_add": "Marca de estado añadida",
     "conditionMark_remove": "Marca de estado eliminada",
+    "conditionMark_carry": "Confirmó marcas de estado anteriores",
     "conditionMark_onInvoice": "Mapa de estado en la factura",
     "settings_markType_create": "Tipo de marca añadido",
     "settings_markType_update": "Tipo de marca modificado",
@@ -234,6 +235,7 @@
     "payment_delete": "Pago {id} eliminado",
     "quoteRequest_update": "Estado de la solicitud de presupuesto cambiado a {status, select, pending {pendiente} approved {aprobada} rejected {rechazada} completed {completada} other {{status}}}",
     "quote_convert": "Presupuesto {ref} convertido en servicio {serviceRecordId}",
+    "quote_convert_existing": "Presupuesto {ref} añadido a la orden de trabajo existente {jobRef}",
     "quote_create": "Presupuesto {ref} creado",
     "quote_delete": "Presupuesto {ref} eliminado",
     "quote_status": "Estado del presupuesto cambiado a {status, select, draft {borrador} sent {enviado} accepted {aceptado} rejected {rechazado} expired {caducado} converted {convertido} other {{status}}}",
@@ -342,6 +344,7 @@
     "inspection_phoneUpload": "Añadió un archivo desde un teléfono a la inspección {ref}",
     "condition_mark_add": "Añadió una marca al mapa de estado",
     "condition_mark_remove": "Eliminó una marca del mapa de estado",
+    "condition_mark_carry": "Confirmó que {count, plural, one {# marca anterior sigue} other {# marcas anteriores siguen}} ahí en el mapa de estado",
     "condition_map_on_invoice_on": "Puso el mapa de estado en la factura",
     "condition_map_on_invoice_off": "Quitó el mapa de estado de la factura"
   }

+ 3 - 0
messages/es/conditionMap.json

@@ -9,6 +9,8 @@
   "previousHint": "Sigue ahí desde la última vez, en gris. Borre las que ya no estén.",
   "newSinceLastVisit": "Nuevo desde la última visita",
   "confirmed": "Sigue ahí",
+  "confirmAll": "Confirmar que todas siguen ahí",
+  "confirmAllHint": "Las registra en esta hoja como marcas de esta visita, en color.",
   "clearRepaired": "Borrar como reparado",
   "restore": "Restaurar",
   "edit": "Editar marca",
@@ -38,6 +40,7 @@
   "readOnly": "Reabra para cambiar el mapa de estado.",
   "readOnlyLinked": "Marcada en la inspección vinculada. Cámbiela allí.",
   "readOnlyEarlier": "Registrada en una visita anterior. Márquela como reparada cuando ya no esté.",
+  "readOnlyCarried": "Confirmada de nuevo en una visita posterior. Se conserva aquí como registro de esta visita y se modifica allí.",
   "readOnlyOtherCheck": "Marcada en otro punto de esta inspección. Cámbiela allí.",
   "count": "{count, plural, =0 {Sin marcas} one {# marca} other {# marcas}}",
   "previousCount": "{count, plural, one {# de una visita anterior} other {# de visitas anteriores}}",

+ 39 - 0
messages/es/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Cada 30 minutos. Un pago recibido en QuickBooks contra una factura de Torqvoice se registra aquí en la factura."
       }
     },
+    "fiken": {
+      "description": "Envía las facturas emitidas, sus clientes y sus pagos a Fiken y trae de vuelta los pagos registrados allí.",
+      "tenantHelp": "Crea una app en Fiken en Rediger konto, API, para tus propias empresas. Introduce en la app la URI de redirección de abajo y copia aquí su ID de cliente y su secreto de cliente. La empresa necesita el módulo de API de Fiken.",
+      "fields": {
+        "clientId": "ID de cliente",
+        "clientSecret": "Secreto de cliente"
+      },
+      "settings": {
+        "companySlug": "Empresa en Fiken",
+        "companySlugHelp": "La empresa en cuya contabilidad entran las facturas. Tu usuario de Fiken puede tener acceso a varias.",
+        "pushInvoices": "Enviar facturas emitidas a Fiken",
+        "pushOnComplete": "Enviar también las facturas cuando el trabajo se marque como completado",
+        "pushOnCompleteHelp": "Sin esto, una factura pasa cuando se envía, se comparte o se paga. Con esto, un trabajo completado pasa de inmediato y se actualiza si cambia.",
+        "startDate": "Solo facturas con fecha igual o posterior a",
+        "startDateHelp": "Déjalo vacío para enviar todas las facturas emitidas. Usa la fecha en que tu contabilidad pasó a Fiken para no registrar dos veces las facturas antiguas.",
+        "attachPdf": "Adjuntar el PDF de la factura",
+        "attachPdfHelp": "Una factura entra en Fiken como una venta (Annet salg) con su número de Torqvoice. Con esto activado, el PDF que recibió el cliente se archiva en la venta como justificante.",
+        "laborAccount": "Cuenta de ingresos para mano de obra",
+        "laborAccountHelp": "Déjalo vacío para usar la 3000.",
+        "partsAccount": "Cuenta de ingresos para piezas",
+        "partsAccountHelp": "Déjalo vacío para usar la 3000.",
+        "zeroVatType": "Tipo de IVA para facturas sin IVA",
+        "zeroVatTypeHelp": "Cómo contabiliza Fiken una venta sin IVA. Una empresa no registrada a efectos de IVA no necesita elegir nada aquí. Una registrada debe elegir antes de que se envíe una factura así; pregunta a tu contable cuál corresponde.",
+        "vatNone": "Ninguno (sin tratamiento de IVA)",
+        "vatExempt": "Exento (fritatt)",
+        "vatOutside": "Fuera del ámbito del IVA (utenfor)",
+        "vatExport": "Exento, exportación",
+        "vatReverse": "Exento, inversión del sujeto pasivo",
+        "zeroAccount": "Cuenta de ingresos para facturas sin IVA",
+        "zeroAccountHelp": "Déjalo vacío para usar las cuentas de mano de obra y piezas de arriba. Si Fiken rechaza la cuenta con el tipo de IVA, el motivo aparece en el registro de actividad.",
+        "pushPayments": "Registrar pagos en Fiken",
+        "paymentAccount": "Depositar pagos en",
+        "paymentAccountHelp": "Déjalo vacío para usar la primera cuenta bancaria de la empresa.",
+        "manualPaidAsPayment": "Registrar un pago cuando una factura se marca como pagada a mano",
+        "manualPaidAsPaymentHelp": "Una factura marcada como pagada en Torqvoice sin registro de pago recibe en Fiken un pago por lo que aún figure pendiente. Desactivado, queda abierta allí hasta que se registre un pago.",
+        "pullPayments": "Traer los pagos registrados en Fiken",
+        "pullPaymentsHelp": "Cada 30 minutos. Un pago recibido en Fiken contra una factura de Torqvoice se registra aquí en la factura."
+      }
+    },
     "nhtsa": {
       "description": "Rellena un vehículo a partir de su VIN y consulta llamadas a revisión, quejas de propietarios y calificaciones de choque de cada año de modelo, de la autoridad de seguridad de EE. UU. Gratis, sin clave.",
       "fields": {},

+ 2 - 0
messages/es/pdf.json

@@ -20,6 +20,8 @@
     "type": "Tipo: {type}",
     "tech": "Técnico: {tech}",
     "parts": "Piezas",
+    "otherParts": "Otras piezas",
+    "groupSubtotal": "Subtotal {group}",
     "partNumber": "N.º de pieza",
     "description": "Descripción",
     "qty": "Cant.",

+ 40 - 0
messages/es/quotes.json

@@ -36,6 +36,45 @@
     "selectVehicle": "Por favor seleccione un vehículo",
     "convertTitle": "Convertir presupuesto en orden de trabajo",
     "convertDescription": "Se creará un nuevo registro de servicio (orden de trabajo) a partir de este presupuesto. Seleccione un vehículo para la orden de trabajo.",
+    "convertChoiceDescription": "Seleccione un vehículo y cree una nueva orden de trabajo a partir de este presupuesto o añádalo a una que ya esté abierta.",
+    "convertNew": "Crear una nueva orden de trabajo",
+    "convertExisting": "Añadir a una orden de trabajo existente",
+    "convertExistingHint": "Las piezas, la mano de obra y los archivos del presupuesto se añaden a la orden de trabajo. Lo que ya contiene se queda como está.",
+    "convertNoOpenWorkOrders": "Este vehículo no tiene órdenes de trabajo abiertas.",
+    "convertSelectWorkOrder": "Seleccione una orden de trabajo",
+    "convertAdd": "Añadir a la orden de trabajo",
+    "convertConflicts": {
+      "heading": "Antes de añadir, decida qué conserva la orden de trabajo",
+      "checking": "Comparando el presupuesto con la orden de trabajo...",
+      "unanswered": "Elija qué conserva la orden de trabajo antes de añadir el presupuesto.",
+      "discount": {
+        "title": "Descuento",
+        "summary": "La orden de trabajo tiene un descuento del {percent}%, {jobAmount} a día de hoy. El presupuesto incluye un descuento de {quoteAmount}.",
+        "keepJob": "Mantener el {percent}% de descuento y descartar los {quoteAmount} del presupuesto",
+        "combine": "Convertir el {percent}% en {jobAmount} y añadir los {quoteAmount} del presupuesto, {total} en total"
+      },
+      "tax": {
+        "title": "Impuestos",
+        "summary": "La orden de trabajo está valorada con {job}. El presupuesto se valoró con {quote}.",
+        "keepJob": "Mantener los impuestos de la orden de trabajo. Las líneas del presupuesto pasan por los importes presupuestados.",
+        "useQuote": "Cambiar la orden de trabajo a los impuestos del presupuesto. Se recalcula todo su contenido.",
+        "inclusive": "{rate}% de impuestos incluido en las líneas",
+        "exclusive": "{rate}% de impuestos añadido aparte",
+        "components": "{rate}% de impuestos ({names})"
+      },
+      "warranty": {
+        "title": "Garantía",
+        "summary": "La orden de trabajo indica {job}. El presupuesto que aceptó el cliente indica {quote}.",
+        "keepJob": "Mantener la garantía de la orden de trabajo",
+        "useQuote": "Usar la garantía del presupuesto, tal como la aceptó el cliente",
+        "none": "nada sobre la garantía",
+        "notIncluded": "sin garantía del taller",
+        "included": "garantía incluida",
+        "includedWithTerms": "garantía incluida, {terms}",
+        "months": "{count, plural, one {# mes} other {# meses}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Convertir",
     "convertToWorkOrder": "Convertir en orden de trabajo",
     "cancel": "Cancelar",
@@ -68,6 +107,7 @@
     "title": "Repuestos",
     "addPart": "Agregar repuesto",
     "partNumber": "N.º de repuesto",
+    "category": "Categoría",
     "name": "Nombre",
     "namePlaceholder": "Nombre *",
     "qty": "Cant.",

+ 2 - 0
messages/es/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Del Inventario",
     "addPart": "Agregar Pieza",
     "partNumber": "N° de Pieza",
+    "category": "Categoría",
     "name": "Nombre",
     "qty": "Cantidad",
     "unitCost": "Costo",
@@ -666,6 +667,7 @@
     },
     "moreStatuses": "Más estados",
     "fromInspection": "De la inspección {name}, {date}",
+    "fromQuote": "Del presupuesto {ref}",
     "startInspection": "Iniciar inspección"
   },
   "photoHandoff": {

+ 32 - 62
messages/es/settings.json

@@ -348,6 +348,14 @@
     "deleteWorkshopConfirmPrompt": "Escriba <bold>{name}</bold> para confirmar.",
     "deleteWorkshopSuccess": "Taller eliminado",
     "deleteWorkshopFailed": "No se pudo eliminar el taller",
+    "reauthPasswordLabel": "Su contraseña",
+    "reauthTotpLabel": "Código de su aplicación de autenticación",
+    "reauthFreshSessionHint": "Su cuenta no tiene contraseña, por lo que esto solo funciona durante los {minutes} minutos siguientes a iniciar sesión. Si ha pasado más tiempo, cierre sesión y vuelva a iniciarla primero.",
+    "reauthWrongPassword": "Contraseña incorrecta.",
+    "reauthWrongCode": "Código incorrecto.",
+    "reauthTooManyAttempts": "Demasiados intentos. Espere unos minutos e inténtelo de nuevo.",
+    "reauthSignInAgainAccount": "Vuelva a iniciar sesión antes de eliminar su cuenta.",
+    "reauthSignInAgainWorkshop": "Vuelva a iniciar sesión antes de eliminar el taller.",
     "deleteAccountMoved": "¿Quiere eliminar su cuenta?",
     "deleteAccountMovedLink": "Puede hacerlo en la configuración de datos.",
     "signatureTitle": "Firma",
@@ -758,12 +766,14 @@
       "next_test_due": "Próxima prueba",
       "defect_notes": "Notas",
       "defect_photos": "Fotos",
+      "no_defects_note": "Nota cuando no hay deficiencias",
       "passed_checks": "Comprobaciones superadas",
       "not_applicable_checks": "Comprobaciones no aplicables",
       "check_notes": "Columna de notas",
       "result_detail": "Explicación",
       "result_summary": "Recuento por valoración",
       "combined_table": "Una sola tabla para todas las secciones",
+      "ungraded_checks": "Comprobaciones aún sin valorar",
       "inspector_line": "Línea de firma",
       "inspector_name": "Nombre del firmante",
       "date_line": "Línea de fecha",
@@ -1079,64 +1089,6 @@
     "applyTaxNoRecords": "Ningún registro necesita la tasa de impuesto predeterminada.",
     "saveSettings": "Guardar configuración",
     "saved": "Configuración guardada",
-    "currencies": {
-      "USD": "Dolar estadounidense",
-      "EUR": "Euro",
-      "GBP": "Libra esterlina",
-      "NOK": "Corona noruega",
-      "SEK": "Corona sueca",
-      "DKK": "Corona danesa",
-      "CHF": "Franco suizo",
-      "CAD": "Dolar canadiense",
-      "AUD": "Dolar australiano",
-      "NZD": "Dolar neozelandes",
-      "JPY": "Yen japones",
-      "CNY": "Yuan chino",
-      "INR": "Rupia india",
-      "BRL": "Real brasileno",
-      "MXN": "Peso mexicano",
-      "PLN": "Zloty polaco",
-      "CZK": "Corona checa",
-      "HUF": "Forinto hungaro",
-      "TRY": "Lira turca",
-      "ZAR": "Rand sudafricano",
-      "KRW": "Won surcoreano",
-      "SGD": "Dolar de Singapur",
-      "HKD": "Dolar de Hong Kong",
-      "THB": "Baht tailandes",
-      "ISK": "Corona islandesa",
-      "RON": "Leu rumano",
-      "ILS": "Shekel israeli",
-      "PHP": "Peso filipino",
-      "IDR": "Rupia indonesia",
-      "MYR": "Ringgit malayo",
-      "LKR": "Rupia de Sri Lanka",
-      "AED": "Dirham de los EAU",
-      "SAR": "Riyal saudi",
-      "RUB": "Rublo ruso",
-      "TWD": "Dolar taiwanes",
-      "VND": "Dong vietnamita",
-      "PKR": "Rupia pakistani",
-      "BDT": "Taka de Bangladesh",
-      "NPR": "Rupia nepali",
-      "BGN": "Lev bulgaro",
-      "UAH": "Grivna ucraniana",
-      "CLP": "Peso chileno",
-      "COP": "Peso colombiano",
-      "ARS": "Peso argentino",
-      "PEN": "Sol peruano",
-      "EGP": "Libra egipcia",
-      "NGN": "Naira nigeriano",
-      "KES": "Chelin keniano",
-      "MAD": "Dirham marroqui",
-      "DZD": "Dinar argelino",
-      "GHS": "Cedi ghanes",
-      "QAR": "Riyal qatari",
-      "KWD": "Dinar kuwaiti",
-      "BHD": "Dinar bahreini",
-      "OMR": "Rial omani",
-      "JOD": "Dinar jordano"
-    },
     "formatTitle": "Formato de moneda",
     "formatSymbolName": "Simbolo",
     "formatCodeName": "Código",
@@ -1697,9 +1649,18 @@
     "upgradeTitle": "Mejorar plan",
     "upgradeDescription": "Desbloquee más funciones mejorando su plan.",
     "upgradeToProDescription": "Mejore para desbloquear clientes ilimitados, miembros del equipo, informes, SMTP, acceso API y más.",
-    "upgradeToEnterpriseDescription": "¿Necesita más de 5 miembros del equipo? Mejore a Enterprise para hasta 50 usuarios.",
-    "upgradeToPro": "Mejorar a Pro — $99/año",
-    "upgradeToEnterprise": "Enterprise — $140/año",
+    "upgradeToEnterpriseDescription": "¿Tienes más de un taller? Enterprise cubre hasta 10 talleres y 50 miembros del equipo.",
+    "upgradeToPro": "Mejorar a Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Periodo de facturación",
+    "billingMonthly": "Mensual",
+    "billingAnnual": "Anual",
+    "billingSave": "Ahorra un {percent}%",
+    "billingMonthsFree": "{months, plural, one {# mes gratis} other {# meses gratis}}",
+    "billedAnnually": "{amount} facturado anualmente",
+    "discountBadge": "{value} de descuento",
+    "discountOnce": "Primer pago, después {amount}",
+    "discountRepeating": "{months, plural, one {Primer mes} other {Primeros # meses}}, después {amount}",
     "upgradeDialogTitle": "Mejorar a Enterprise",
     "upgradeDialogDescription": "Su plan Pro actual se mejorará a Enterprise. Solo se le cobrará la diferencia prorrateada por el período de facturación restante.",
     "upgradeConfirm": "Confirmar mejora",
@@ -2086,6 +2047,10 @@
     "itemsTableHint": "Una lista numerada de todas las líneas del trabajo, primero mano de obra y luego piezas, en lugar de las tablas separadas.",
     "discardChanges": "Descartar cambios",
     "tablesExclusiveHint": "La hoja imprime una disposición: una lista numerada única o tablas separadas de piezas y mano de obra. Activar una desactiva la otra.",
+    "groupLines": "Agrupar líneas",
+    "groupLinesOff": "No",
+    "groupLinesCategory": "Por categoría",
+    "groupLinesHint": "Por categoría imprime las piezas bajo un encabezado por categoría con un subtotal para cada una, con la mano de obra primero en la lista combinada. Las piezas sin categoría se suman como «Otras piezas».",
     "innerPadding": "Espaciado interno",
     "totalsWidth": "Ancho",
     "totalsWidthHint": "Vacío ocupa toda la fila. Con un valor en puntos, el bloque cuelga a la derecha; en una columna toma el ancho de la columna.",
@@ -2174,6 +2139,8 @@
       "itemLabor": "Frenos delanteros sustituidos",
       "itemDisc": "Disco de freno, delantero",
       "itemPads": "Juego de pastillas, eje delantero",
+      "categoryBrakes": "Frenos",
+      "categoryConsumables": "Consumibles de taller",
       "itemConsumables": "Consumibles",
       "unitPcs": "uds",
       "unitSet": "juego",
@@ -2200,6 +2167,7 @@
       "sectionLighting": "Equipo de alumbrado",
       "checkHeadlamps": "Estado de los faros",
       "checkFogLamp": "Luz antiniebla trasera",
+      "checkStopLamps": "Luces de freno",
       "photoFront": "Frontal",
       "photoOdometer": "Cuentakilómetros",
       "concernNoise": "Ruido de roce delante al frenar",
@@ -2272,7 +2240,9 @@
     "conditionMapViewsAll": "Las cinco",
     "conditionMapViewsTop": "Solo desde arriba",
     "conditionMapViewsSides": "Solo los laterales",
-    "conditionMapWidthHint": "Vacío ocupa toda la fila. Indique puntos para reducir el dibujo; se colocará según la alineación y la leyenda conserva todo el ancho."
+    "conditionMapWidthHint": "Vacío ocupa toda la fila. Indique puntos para reducir el dibujo; se colocará según la alineación y la leyenda conserva todo el ancho.",
+    "conditionMapDense": "Tabla compacta",
+    "conditionMapDenseHint": "Junta las filas de la tabla bajo el dibujo para que ocupe menos espacio en la página."
   },
   "preview": {
     "title": "Servicio de frenos y cambio de aceite",

+ 15 - 0
messages/es/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Aplicar",
     "allTechnicians": "Todos los técnicos",
     "export": "Exportar CSV",
+    "print": "Imprimir hoja de horas",
+    "client": "Cliente",
+    "noClient": "Sin cliente",
+    "signature": "Firma",
+    "sortBy": "Ordenar por",
+    "sort": {
+      "default": "Técnico / día",
+      "client": "Cliente",
+      "vehicle": "Vehículo",
+      "clientVehicle": "Cliente, luego vehículo"
+    },
+    "sortDirection": "Sentido de ordenación",
+    "ascending": "Ascendente",
+    "descending": "Descendente",
     "addEntry": "Añadir entrada",
     "stats": {
       "total": "Tiempo total",
@@ -101,6 +115,7 @@
       "duration": "Duración",
       "hours": "Horas",
       "job": "Trabajo",
+      "client": "Cliente",
       "vehicle": "Vehículo",
       "plate": "Matrícula",
       "source": "Origen",

+ 3 - 0
messages/fr/audit.json

@@ -188,6 +188,7 @@
     "signature_remove": "Signature supprimée",
     "conditionMark_add": "Marque d'état ajoutée",
     "conditionMark_remove": "Marque d'état supprimée",
+    "conditionMark_carry": "A confirmé des marques d'état antérieures",
     "conditionMark_onInvoice": "Carte d'état sur la facture",
     "settings_markType_create": "Type de marque ajouté",
     "settings_markType_update": "Type de marque modifié",
@@ -234,6 +235,7 @@
     "payment_delete": "Paiement {id} supprimé",
     "quoteRequest_update": "Statut de la demande de devis passé à {status, select, pending {en attente} approved {approuvée} rejected {refusée} completed {terminée} other {{status}}}",
     "quote_convert": "Devis {ref} converti en intervention {serviceRecordId}",
+    "quote_convert_existing": "Devis {ref} ajouté à l'ordre de travail existant {jobRef}",
     "quote_create": "Devis {ref} créé",
     "quote_delete": "Devis {ref} supprimé",
     "quote_status": "Statut du devis passé à {status, select, draft {brouillon} sent {envoyé} accepted {accepté} rejected {refusé} expired {expiré} converted {converti} other {{status}}}",
@@ -342,6 +344,7 @@
     "inspection_phoneUpload": "Fichier ajouté depuis un téléphone à l'inspection {ref}",
     "condition_mark_add": "A ajouté une marque à la carte d'état",
     "condition_mark_remove": "A supprimé une marque de la carte d'état",
+    "condition_mark_carry": "A confirmé que {count, plural, one {# marque antérieure est} other {# marques antérieures sont}} toujours là sur la carte d'état",
     "condition_map_on_invoice_on": "A mis la carte d'état sur la facture",
     "condition_map_on_invoice_off": "A retiré la carte d'état de la facture"
   }

+ 3 - 0
messages/fr/conditionMap.json

@@ -9,6 +9,8 @@
   "previousHint": "Toujours là depuis la dernière fois, en gris. Effacez celles qui ont disparu.",
   "newSinceLastVisit": "Nouveau depuis la dernière visite",
   "confirmed": "Toujours là",
+  "confirmAll": "Confirmer que toutes sont toujours là",
+  "confirmAllHint": "Les enregistre sur cette fiche comme marques de cette visite, en couleur.",
   "clearRepaired": "Effacer comme réparé",
   "restore": "Restaurer",
   "edit": "Modifier la marque",
@@ -38,6 +40,7 @@
   "readOnly": "Rouvrez pour modifier la carte d'état.",
   "readOnlyLinked": "Placée sur l'inspection liée. Modifiez-la là-bas.",
   "readOnlyEarlier": "Relevée lors d'une visite précédente. Marquez-la comme réparée une fois disparue.",
+  "readOnlyCarried": "Confirmée à nouveau lors d'une visite ultérieure. Elle reste ici comme trace de cette visite et se modifie là-bas.",
   "readOnlyOtherCheck": "Placée sur un autre point de cette inspection. Modifiez-la là-bas.",
   "count": "{count, plural, =0 {Aucune marque} one {# marque} other {# marques}}",
   "previousCount": "{count, plural, one {# d'une visite précédente} other {# de visites précédentes}}",

+ 39 - 0
messages/fr/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Toutes les 30 minutes. Un paiement reçu dans QuickBooks sur une facture Torqvoice est enregistré ici sur la facture."
       }
     },
+    "fiken": {
+      "description": "Envoyez les factures émises, leurs clients et leurs paiements vers Fiken, et récupérez les paiements enregistrés là-bas.",
+      "tenantHelp": "Créez une application dans Fiken sous Rediger konto, API, pour vos propres sociétés. Saisissez l'URI de redirection ci-dessous dans l'application, puis copiez ici son identifiant client et son secret client. La société doit avoir le module API de Fiken.",
+      "fields": {
+        "clientId": "Identifiant client",
+        "clientSecret": "Secret client"
+      },
+      "settings": {
+        "companySlug": "Société dans Fiken",
+        "companySlugHelp": "La société dont la comptabilité reçoit les factures. Votre utilisateur Fiken peut avoir accès à plusieurs sociétés.",
+        "pushInvoices": "Envoyer les factures émises vers Fiken",
+        "pushOnComplete": "Envoyer aussi les factures quand le travail est marqué terminé",
+        "pushOnCompleteHelp": "Sans cette option, une facture part une fois envoyée, partagée ou payée. Avec, un travail terminé part tout de suite et est mis à jour s'il change.",
+        "startDate": "Uniquement les factures datées à partir du",
+        "startDateHelp": "Laissez vide pour envoyer toutes les factures émises. Utilisez la date où votre comptabilité est passée sur Fiken pour ne pas saisir deux fois les anciennes factures.",
+        "attachPdf": "Joindre le PDF de la facture",
+        "attachPdfHelp": "Une facture entre dans Fiken comme une vente (Annet salg) sous son numéro Torqvoice. Avec cette option, le PDF reçu par le client est classé sur la vente comme pièce justificative.",
+        "laborAccount": "Compte de produits pour la main-d'œuvre",
+        "laborAccountHelp": "Laissez vide pour utiliser le 3000.",
+        "partsAccount": "Compte de produits pour les pièces",
+        "partsAccountHelp": "Laissez vide pour utiliser le 3000.",
+        "zeroVatType": "Type de TVA pour les factures sans TVA",
+        "zeroVatTypeHelp": "Comment Fiken comptabilise une vente sans TVA. Une société non assujettie à la TVA n'a rien à choisir ici. Une société assujettie doit choisir avant l'envoi d'une telle facture ; demandez à votre comptable ce qui s'applique.",
+        "vatNone": "Aucun (pas de traitement de TVA)",
+        "vatExempt": "Exonéré (fritatt)",
+        "vatOutside": "Hors champ de la TVA (utenfor)",
+        "vatExport": "Exonéré, exportation",
+        "vatReverse": "Exonéré, autoliquidation",
+        "zeroAccount": "Compte de produits pour les factures sans TVA",
+        "zeroAccountHelp": "Laissez vide pour utiliser les comptes main-d'œuvre et pièces ci-dessus. Si Fiken refuse le compte avec le type de TVA, la raison figure dans le journal d'activité.",
+        "pushPayments": "Enregistrer les paiements dans Fiken",
+        "paymentAccount": "Déposer les paiements sur",
+        "paymentAccountHelp": "Laissez vide pour utiliser le premier compte bancaire de la société.",
+        "manualPaidAsPayment": "Enregistrer un paiement quand une facture est marquée payée à la main",
+        "manualPaidAsPaymentHelp": "Une facture marquée payée dans Torqvoice sans écriture de paiement reçoit dans Fiken un paiement du montant qu'elle y affiche encore comme dû. Désactivé, elle y reste ouverte jusqu'à l'enregistrement d'un paiement.",
+        "pullPayments": "Récupérer les paiements enregistrés dans Fiken",
+        "pullPaymentsHelp": "Toutes les 30 minutes. Un paiement reçu dans Fiken sur une facture Torqvoice est enregistré ici sur la facture."
+      }
+    },
     "nhtsa": {
       "description": "Remplissez un véhicule à partir de son VIN et consultez les rappels, les plaintes de propriétaires et les notes aux crash-tests pour chaque année-modèle, auprès de l'autorité de sécurité américaine. Gratuit, sans clé.",
       "fields": {},

+ 2 - 0
messages/fr/pdf.json

@@ -20,6 +20,8 @@
     "type": "Type : {type}",
     "tech": "Technicien : {tech}",
     "parts": "Pièces",
+    "otherParts": "Autres pièces",
+    "groupSubtotal": "Sous-total {group}",
     "partNumber": "N° de pièce",
     "description": "Description",
     "qty": "Qté",

+ 40 - 0
messages/fr/quotes.json

@@ -36,6 +36,45 @@
     "selectVehicle": "Veuillez sélectionner un véhicule",
     "convertTitle": "Convertir le devis en ordre de travail",
     "convertDescription": "Un nouveau dossier de service (ordre de travail) sera créé à partir de ce devis. Sélectionnez un véhicule pour l'ordre de travail.",
+    "convertChoiceDescription": "Sélectionnez un véhicule, puis créez un nouvel ordre de travail à partir de ce devis ou ajoutez-le à un ordre déjà ouvert.",
+    "convertNew": "Créer un nouvel ordre de travail",
+    "convertExisting": "Ajouter à un ordre de travail existant",
+    "convertExistingHint": "Les pièces, la main-d'œuvre et les fichiers du devis sont ajoutés à l'ordre de travail. Ce qui s'y trouve déjà reste inchangé.",
+    "convertNoOpenWorkOrders": "Ce véhicule n'a aucun ordre de travail ouvert.",
+    "convertSelectWorkOrder": "Sélectionnez un ordre de travail",
+    "convertAdd": "Ajouter à l'ordre de travail",
+    "convertConflicts": {
+      "heading": "Avant d'ajouter, décidez ce que l'ordre de travail conserve",
+      "checking": "Comparaison du devis avec l'ordre de travail...",
+      "unanswered": "Choisissez ce que l'ordre de travail conserve avant d'ajouter le devis.",
+      "discount": {
+        "title": "Remise",
+        "summary": "L'ordre de travail a une remise de {percent} %, soit {jobAmount} à ce jour. Le devis comporte une remise de {quoteAmount}.",
+        "keepJob": "Conserver la remise de {percent} % et abandonner les {quoteAmount} du devis",
+        "combine": "Convertir les {percent} % en {jobAmount} et ajouter les {quoteAmount} du devis, soit {total} au total"
+      },
+      "tax": {
+        "title": "Taxe",
+        "summary": "L'ordre de travail est établi avec {job}. Le devis a été établi avec {quote}.",
+        "keepJob": "Conserver la taxe de l'ordre de travail. Les lignes du devis sont reprises aux montants devisés.",
+        "useQuote": "Passer l'ordre de travail à la taxe du devis. Tout son contenu est recalculé.",
+        "inclusive": "{rate} % de taxe inclus dans les lignes",
+        "exclusive": "{rate} % de taxe ajoutés en sus",
+        "components": "{rate} % de taxe ({names})"
+      },
+      "warranty": {
+        "title": "Garantie",
+        "summary": "L'ordre de travail indique {job}. Le devis accepté par le client indique {quote}.",
+        "keepJob": "Conserver la garantie de l'ordre de travail",
+        "useQuote": "Utiliser la garantie du devis, telle que le client l'a acceptée",
+        "none": "rien sur la garantie",
+        "notIncluded": "pas de garantie atelier",
+        "included": "garantie incluse",
+        "includedWithTerms": "garantie incluse, {terms}",
+        "months": "{count, plural, one {# mois} other {# mois}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Convertir",
     "convertToWorkOrder": "Convertir en ordre de travail",
     "cancel": "Annuler",
@@ -68,6 +107,7 @@
     "title": "Pièces",
     "addPart": "Ajouter une pièce",
     "partNumber": "N° de pièce",
+    "category": "Catégorie",
     "name": "Nom",
     "namePlaceholder": "Nom *",
     "qty": "Qté",

+ 2 - 0
messages/fr/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "De l'Inventaire",
     "addPart": "Ajouter une Pièce",
     "partNumber": "Nº de Pièce",
+    "category": "Catégorie",
     "name": "Nom",
     "qty": "Qté",
     "unitCost": "Coût",
@@ -666,6 +667,7 @@
     },
     "moreStatuses": "Autres statuts",
     "fromInspection": "Issu de l'inspection {name}, {date}",
+    "fromQuote": "Issu du devis {ref}",
     "startInspection": "Commencer l'inspection"
   },
   "photoHandoff": {

+ 32 - 62
messages/fr/settings.json

@@ -348,6 +348,14 @@
     "deleteWorkshopConfirmPrompt": "Saisissez <bold>{name}</bold> pour confirmer.",
     "deleteWorkshopSuccess": "Atelier supprimé",
     "deleteWorkshopFailed": "Échec de la suppression de l'atelier",
+    "reauthPasswordLabel": "Votre mot de passe",
+    "reauthTotpLabel": "Code de votre application d'authentification",
+    "reauthFreshSessionHint": "Votre compte n'a pas de mot de passe : cette action n'est donc possible que dans les {minutes} minutes qui suivent la connexion. Si c'est plus ancien, déconnectez-vous puis reconnectez-vous d'abord.",
+    "reauthWrongPassword": "Mot de passe incorrect.",
+    "reauthWrongCode": "Code incorrect.",
+    "reauthTooManyAttempts": "Trop de tentatives. Patientez quelques minutes puis réessayez.",
+    "reauthSignInAgainAccount": "Reconnectez-vous avant de supprimer votre compte.",
+    "reauthSignInAgainWorkshop": "Reconnectez-vous avant de supprimer l'atelier.",
     "deleteAccountMoved": "Vous souhaitez supprimer votre compte ?",
     "deleteAccountMovedLink": "Vous pouvez le faire dans les paramètres des données.",
     "signatureTitle": "Signature",
@@ -758,12 +766,14 @@
       "next_test_due": "Prochain contrôle",
       "defect_notes": "Remarques",
       "defect_photos": "Photos",
+      "no_defects_note": "Mention en l'absence de défaillance",
       "passed_checks": "Contrôles réussis",
       "not_applicable_checks": "Contrôles sans objet",
       "check_notes": "Colonne remarques",
       "result_detail": "Explication",
       "result_summary": "Nombre par note",
       "combined_table": "Un seul tableau pour toutes les sections",
+      "ungraded_checks": "Contrôles pas encore évalués",
       "inspector_line": "Ligne de signature",
       "inspector_name": "Nom du signataire",
       "date_line": "Ligne de date",
@@ -1079,64 +1089,6 @@
     "applyTaxNoRecords": "Aucun enregistrement ne necessite le taux de taxe par défaut.",
     "saveSettings": "Enregistrer les paramètres",
     "saved": "Paramètres enregistrés",
-    "currencies": {
-      "USD": "Dollar americain",
-      "EUR": "Euro",
-      "GBP": "Livre sterling",
-      "NOK": "Couronne norvegienne",
-      "SEK": "Couronne suedoise",
-      "DKK": "Couronne danoise",
-      "CHF": "Franc suisse",
-      "CAD": "Dollar canadien",
-      "AUD": "Dollar australien",
-      "NZD": "Dollar neo-zelandais",
-      "JPY": "Yen japonais",
-      "CNY": "Yuan chinois",
-      "INR": "Roupie indienne",
-      "BRL": "Real bresilien",
-      "MXN": "Peso mexicain",
-      "PLN": "Zloty polonais",
-      "CZK": "Couronne tcheque",
-      "HUF": "Forint hongrois",
-      "TRY": "Livre turque",
-      "ZAR": "Rand sud-africain",
-      "KRW": "Won sud-coreen",
-      "SGD": "Dollar de Singapour",
-      "HKD": "Dollar de Hong Kong",
-      "THB": "Baht thailandais",
-      "ISK": "Couronne islandaise",
-      "RON": "Leu roumain",
-      "ILS": "Shekel israelien",
-      "PHP": "Peso philippin",
-      "IDR": "Roupie indonesienne",
-      "MYR": "Ringgit malaisien",
-      "LKR": "Roupie srilankaise",
-      "AED": "Dirham des EAU",
-      "SAR": "Riyal saoudien",
-      "RUB": "Rouble russe",
-      "TWD": "Dollar taiwanais",
-      "VND": "Dong vietnamien",
-      "PKR": "Roupie pakistanaise",
-      "BDT": "Taka bangladais",
-      "NPR": "Roupie nepalaise",
-      "BGN": "Lev bulgare",
-      "UAH": "Hryvnia ukrainienne",
-      "CLP": "Peso chilien",
-      "COP": "Peso colombien",
-      "ARS": "Peso argentin",
-      "PEN": "Sol peruvien",
-      "EGP": "Livre egyptienne",
-      "NGN": "Naira nigerian",
-      "KES": "Shilling kenyan",
-      "MAD": "Dirham marocain",
-      "DZD": "Dinar algérien",
-      "GHS": "Cedi ghaneen",
-      "QAR": "Riyal qatari",
-      "KWD": "Dinar koweitien",
-      "BHD": "Dinar bahreini",
-      "OMR": "Rial omanais",
-      "JOD": "Dinar jordanien"
-    },
     "formatTitle": "Format de la monnaie",
     "formatSymbolName": "Symbole",
     "formatCodeName": "Code",
@@ -1697,9 +1649,18 @@
     "upgradeTitle": "Améliorer le plan",
     "upgradeDescription": "Débloquez plus de fonctionnalités en améliorant votre plan.",
     "upgradeToProDescription": "Améliorez pour débloquer des clients illimités, des membres d'équipe, des rapports, SMTP, l'accès API et plus encore.",
-    "upgradeToEnterpriseDescription": "Besoin de plus de 5 membres d'équipe ? Passez à Enterprise pour jusqu'à 50 utilisateurs.",
-    "upgradeToPro": "Passer à Pro — 99 $/an",
-    "upgradeToEnterprise": "Enterprise — 140 $/an",
+    "upgradeToEnterpriseDescription": "Plusieurs ateliers ? Enterprise couvre jusqu'à 10 ateliers et 50 membres d'équipe.",
+    "upgradeToPro": "Passer à Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Période de facturation",
+    "billingMonthly": "Mensuel",
+    "billingAnnual": "Annuel",
+    "billingSave": "Économisez {percent}%",
+    "billingMonthsFree": "{months, plural, one {# mois offert} other {# mois offerts}}",
+    "billedAnnually": "{amount} facturé annuellement",
+    "discountBadge": "{value} de réduction",
+    "discountOnce": "Premier paiement, puis {amount}",
+    "discountRepeating": "{months, plural, one {Premier mois} other {# premiers mois}}, puis {amount}",
     "upgradeDialogTitle": "Passer à Enterprise",
     "upgradeDialogDescription": "Votre plan Pro actuel sera mis à niveau vers Enterprise. Seule la différence au prorata pour la période de facturation restante vous sera facturée.",
     "upgradeConfirm": "Confirmer la mise à niveau",
@@ -2086,6 +2047,10 @@
     "itemsTableHint": "Une liste numérotée de toutes les lignes du travail, main-d'œuvre d'abord puis pièces, à la place des tableaux séparés.",
     "discardChanges": "Annuler les modifications",
     "tablesExclusiveHint": "La feuille imprime une disposition : une liste numérotée unique, ou des tableaux séparés pour pièces et main-d'œuvre. Activer l'une désactive l'autre.",
+    "groupLines": "Grouper les lignes",
+    "groupLinesOff": "Non",
+    "groupLinesCategory": "Par catégorie",
+    "groupLinesHint": "Par catégorie imprime les pièces sous un titre par catégorie avec un sous-total pour chacune, la main-d’œuvre en premier dans la liste combinée. Les pièces sans catégorie sont additionnées sous « Autres pièces ».",
     "innerPadding": "Marge intérieure",
     "totalsWidth": "Largeur",
     "totalsWidthHint": "Vide, le bloc occupe toute la ligne. Une valeur en points l'accroche à droite ; dans une colonne, il prend la largeur de la colonne.",
@@ -2174,6 +2139,8 @@
       "itemLabor": "Freins avant remplacés",
       "itemDisc": "Disque de frein, avant",
       "itemPads": "Jeu de plaquettes, essieu avant",
+      "categoryBrakes": "Freins",
+      "categoryConsumables": "Fournitures d’atelier",
       "itemConsumables": "Consommables",
       "unitPcs": "pcs",
       "unitSet": "jeu",
@@ -2200,6 +2167,7 @@
       "sectionLighting": "Équipement d'éclairage",
       "checkHeadlamps": "État des projecteurs",
       "checkFogLamp": "Feu de brouillard arrière",
+      "checkStopLamps": "Feux stop",
       "photoFront": "Avant",
       "photoOdometer": "Compteur",
       "concernNoise": "Bruit de frottement à l'avant au freinage",
@@ -2272,7 +2240,9 @@
     "conditionMapViewsAll": "Les cinq",
     "conditionMapViewsTop": "Dessus seulement",
     "conditionMapViewsSides": "Côtés seulement",
-    "conditionMapWidthHint": "Vide occupe toute la ligne. Indiquez des points pour réduire le dessin ; il se place selon l'alignement et la légende garde toute la largeur."
+    "conditionMapWidthHint": "Vide occupe toute la ligne. Indiquez des points pour réduire le dessin ; il se place selon l'alignement et la légende garde toute la largeur.",
+    "conditionMapDense": "Tableau compact",
+    "conditionMapDenseHint": "Resserre les lignes du tableau sous le dessin pour qu'il prenne moins de place sur la page."
   },
   "preview": {
     "title": "Révision des freins et vidange",

+ 15 - 0
messages/fr/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Appliquer",
     "allTechnicians": "Tous les techniciens",
     "export": "Exporter en CSV",
+    "print": "Imprimer la feuille de temps",
+    "client": "Client",
+    "noClient": "Aucun client",
+    "signature": "Signature",
+    "sortBy": "Trier par",
+    "sort": {
+      "default": "Technicien / jour",
+      "client": "Client",
+      "vehicle": "Véhicule",
+      "clientVehicle": "Client, puis véhicule"
+    },
+    "sortDirection": "Sens du tri",
+    "ascending": "Croissant",
+    "descending": "Décroissant",
     "addEntry": "Ajouter une entrée",
     "stats": {
       "total": "Temps total",
@@ -101,6 +115,7 @@
       "duration": "Durée",
       "hours": "Heures",
       "job": "Travail",
+      "client": "Client",
       "vehicle": "Véhicule",
       "plate": "Immatriculation",
       "source": "Source",

+ 3 - 0
messages/it/audit.json

@@ -188,6 +188,7 @@
     "signature_remove": "Firma rimossa",
     "conditionMark_add": "Segno di stato aggiunto",
     "conditionMark_remove": "Segno di stato rimosso",
+    "conditionMark_carry": "Ha confermato segni di stato precedenti",
     "conditionMark_onInvoice": "Mappa dello stato in fattura",
     "settings_markType_create": "Tipo di segno aggiunto",
     "settings_markType_update": "Tipo di segno modificato",
@@ -234,6 +235,7 @@
     "payment_delete": "Pagamento {id} eliminato",
     "quoteRequest_update": "Stato della richiesta di preventivo cambiato in {status, select, pending {in attesa} approved {approvata} rejected {rifiutata} completed {completata} other {{status}}}",
     "quote_convert": "Preventivo {ref} convertito nell'intervento {serviceRecordId}",
+    "quote_convert_existing": "Preventivo {ref} aggiunto all'ordine di lavoro esistente {jobRef}",
     "quote_create": "Preventivo {ref} creato",
     "quote_delete": "Preventivo {ref} eliminato",
     "quote_status": "Stato del preventivo cambiato in {status, select, draft {bozza} sent {inviato} accepted {accettato} rejected {rifiutato} expired {scaduto} converted {convertito} other {{status}}}",
@@ -342,6 +344,7 @@
     "inspection_phoneUpload": "Aggiunto un file da telefono all'ispezione {ref}",
     "condition_mark_add": "Ha aggiunto un segno alla mappa dello stato",
     "condition_mark_remove": "Ha rimosso un segno dalla mappa dello stato",
+    "condition_mark_carry": "Ha confermato {count, plural, one {# segno precedente come ancora presente} other {# segni precedenti come ancora presenti}} sulla mappa dello stato",
     "condition_map_on_invoice_on": "Ha messo la mappa dello stato sulla fattura",
     "condition_map_on_invoice_off": "Ha tolto la mappa dello stato dalla fattura"
   }

+ 3 - 0
messages/it/conditionMap.json

@@ -9,6 +9,8 @@
   "previousHint": "Ancora presente dall'ultima volta, in grigio. Cancella quelli che non ci sono più.",
   "newSinceLastVisit": "Nuovo dall'ultima visita",
   "confirmed": "Ancora presente",
+  "confirmAll": "Conferma tutti come ancora presenti",
+  "confirmAllHint": "Li registra su questa scheda come segni di questa visita, a colori.",
   "clearRepaired": "Cancella come riparato",
   "restore": "Ripristina",
   "edit": "Modifica segno",
@@ -38,6 +40,7 @@
   "readOnly": "Riapri per modificare la mappa dello stato.",
   "readOnlyLinked": "Segnata sull'ispezione collegata. Modificala lì.",
   "readOnlyEarlier": "Registrata in una visita precedente. Segnala come riparata quando non c'è più.",
+  "readOnlyCarried": "Confermato di nuovo in una visita successiva. Resta qui come registrazione di questa visita e si modifica lì.",
   "readOnlyOtherCheck": "Segnata su un altro controllo di questa ispezione. Modificala lì.",
   "count": "{count, plural, =0 {Nessun segno} one {# segno} other {# segni}}",
   "previousCount": "{count, plural, one {# da una visita precedente} other {# da visite precedenti}}",

+ 39 - 0
messages/it/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Ogni 30 minuti. Un pagamento ricevuto in QuickBooks su una fattura di Torqvoice viene registrato qui sulla fattura."
       }
     },
+    "fiken": {
+      "description": "Invia le fatture emesse, i loro clienti e i loro pagamenti a Fiken e riporta i pagamenti registrati lì.",
+      "tenantHelp": "Crea un'app in Fiken in Rediger konto, API, per le tue aziende. Inserisci nell'app l'URI di reindirizzamento qui sotto, poi copia qui l'ID client e il segreto client. L'azienda deve avere il modulo API di Fiken.",
+      "fields": {
+        "clientId": "ID client",
+        "clientSecret": "Segreto client"
+      },
+      "settings": {
+        "companySlug": "Azienda in Fiken",
+        "companySlugHelp": "L'azienda nella cui contabilità entrano le fatture. Il tuo utente Fiken può avere accesso a più aziende.",
+        "pushInvoices": "Invia le fatture emesse a Fiken",
+        "pushOnComplete": "Invia le fatture anche quando il lavoro viene segnato come completato",
+        "pushOnCompleteHelp": "Senza questa opzione una fattura passa quando viene inviata, condivisa o pagata. Con questa, un lavoro completato passa subito e viene aggiornato se cambia.",
+        "startDate": "Solo fatture con data a partire dal",
+        "startDateHelp": "Lascia vuoto per inviare ogni fattura emessa. Usa la data in cui la contabilità è passata a Fiken, così le fatture più vecchie non vengono registrate due volte.",
+        "attachPdf": "Allega il PDF della fattura",
+        "attachPdfHelp": "Una fattura entra in Fiken come vendita (Annet salg) con il suo numero Torqvoice. Con questa opzione attiva, il PDF ricevuto dal cliente viene archiviato sulla vendita come giustificativo.",
+        "laborAccount": "Conto ricavi per la manodopera",
+        "laborAccountHelp": "Lascia vuoto per usare il 3000.",
+        "partsAccount": "Conto ricavi per i ricambi",
+        "partsAccountHelp": "Lascia vuoto per usare il 3000.",
+        "zeroVatType": "Tipo di IVA per le fatture senza IVA",
+        "zeroVatTypeHelp": "Come Fiken registra una vendita senza IVA. Un'azienda non registrata ai fini IVA non deve scegliere nulla qui. Una registrata deve scegliere prima che una fattura del genere venga inviata; chiedi al tuo commercialista quale si applica.",
+        "vatNone": "Nessuno (nessun trattamento IVA)",
+        "vatExempt": "Esente (fritatt)",
+        "vatOutside": "Fuori campo IVA (utenfor)",
+        "vatExport": "Esente, esportazione",
+        "vatReverse": "Esente, inversione contabile",
+        "zeroAccount": "Conto ricavi per le fatture senza IVA",
+        "zeroAccountHelp": "Lascia vuoto per usare i conti di manodopera e ricambi qui sopra. Se Fiken rifiuta il conto con il tipo di IVA, il motivo compare nel registro attività.",
+        "pushPayments": "Registra i pagamenti in Fiken",
+        "paymentAccount": "Deposita i pagamenti su",
+        "paymentAccountHelp": "Lascia vuoto per usare il primo conto bancario dell'azienda.",
+        "manualPaidAsPayment": "Registrare un pagamento quando una fattura viene segnata come pagata a mano",
+        "manualPaidAsPaymentHelp": "Una fattura segnata come pagata in Torqvoice senza registrazione di pagamento riceve in Fiken un pagamento per quanto risulta ancora dovuto lì. Disattivato, resta aperta lì finché non viene registrato un pagamento.",
+        "pullPayments": "Riporta i pagamenti registrati in Fiken",
+        "pullPaymentsHelp": "Ogni 30 minuti. Un pagamento ricevuto in Fiken su una fattura di Torqvoice viene registrato qui sulla fattura."
+      }
+    },
     "nhtsa": {
       "description": "Compila un veicolo dal suo VIN e consulta richiami, reclami dei proprietari e valutazioni dei crash test per ogni anno modello, dall'autorità per la sicurezza statunitense. Gratuito, senza chiave.",
       "fields": {},

+ 2 - 0
messages/it/pdf.json

@@ -20,6 +20,8 @@
     "type": "Tipo: {type}",
     "tech": "Tecnico: {tech}",
     "parts": "Ricambi",
+    "otherParts": "Altri ricambi",
+    "groupSubtotal": "Subtotale {group}",
     "partNumber": "N. pezzo",
     "description": "Descrizione",
     "qty": "Qtà",

+ 40 - 0
messages/it/quotes.json

@@ -36,6 +36,45 @@
     "selectVehicle": "Seleziona un veicolo",
     "convertTitle": "Converti preventivo in ordine di lavoro",
     "convertDescription": "Verrà creato un nuovo record di servizio (ordine di lavoro) da questo preventivo. Seleziona un veicolo per l'ordine di lavoro.",
+    "convertChoiceDescription": "Seleziona un veicolo, poi crea un nuovo ordine di lavoro da questo preventivo oppure aggiungilo a uno già aperto.",
+    "convertNew": "Crea un nuovo ordine di lavoro",
+    "convertExisting": "Aggiungi a un ordine di lavoro esistente",
+    "convertExistingHint": "Ricambi, manodopera e file del preventivo vengono aggiunti all'ordine di lavoro. Ciò che contiene già resta invariato.",
+    "convertNoOpenWorkOrders": "Questo veicolo non ha ordini di lavoro aperti.",
+    "convertSelectWorkOrder": "Seleziona un ordine di lavoro",
+    "convertAdd": "Aggiungi all'ordine di lavoro",
+    "convertConflicts": {
+      "heading": "Prima di aggiungere, decidi cosa mantiene l'ordine di lavoro",
+      "checking": "Confronto del preventivo con l'ordine di lavoro...",
+      "unanswered": "Scegli cosa mantiene l'ordine di lavoro prima di aggiungere il preventivo.",
+      "discount": {
+        "title": "Sconto",
+        "summary": "L'ordine di lavoro ha uno sconto del {percent}%, pari a {jobAmount} allo stato attuale. Il preventivo include uno sconto di {quoteAmount}.",
+        "keepJob": "Mantieni lo sconto del {percent}% e scarta i {quoteAmount} del preventivo",
+        "combine": "Trasforma il {percent}% in {jobAmount} e aggiungi i {quoteAmount} del preventivo, {total} in totale"
+      },
+      "tax": {
+        "title": "Imposte",
+        "summary": "L'ordine di lavoro è calcolato con {job}. Il preventivo è stato calcolato con {quote}.",
+        "keepJob": "Mantieni le imposte dell'ordine di lavoro. Le righe del preventivo passano agli importi preventivati.",
+        "useQuote": "Passa l'ordine di lavoro alle imposte del preventivo. Tutto il contenuto viene ricalcolato.",
+        "inclusive": "{rate}% di imposte incluso nelle righe",
+        "exclusive": "{rate}% di imposte aggiunto a parte",
+        "components": "{rate}% di imposte ({names})"
+      },
+      "warranty": {
+        "title": "Garanzia",
+        "summary": "L'ordine di lavoro indica {job}. Il preventivo accettato dal cliente indica {quote}.",
+        "keepJob": "Mantieni la garanzia dell'ordine di lavoro",
+        "useQuote": "Usa la garanzia del preventivo, come accettata dal cliente",
+        "none": "nulla sulla garanzia",
+        "notIncluded": "nessuna garanzia dell'officina",
+        "included": "garanzia inclusa",
+        "includedWithTerms": "garanzia inclusa, {terms}",
+        "months": "{count, plural, one {# mese} other {# mesi}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Converti",
     "convertToWorkOrder": "Converti in ordine di lavoro",
     "cancel": "Annulla",
@@ -68,6 +107,7 @@
     "title": "Ricambi",
     "addPart": "Aggiungi ricambio",
     "partNumber": "N. ricambio",
+    "category": "Categoria",
     "name": "Nome",
     "namePlaceholder": "Nome *",
     "qty": "Qtà",

+ 2 - 0
messages/it/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Da Inventario",
     "addPart": "Aggiungi Pezzo",
     "partNumber": "Numero Pezzo",
+    "category": "Categoria",
     "name": "Nome",
     "qty": "Qtà",
     "unitCost": "Costo",
@@ -666,6 +667,7 @@
     },
     "moreStatuses": "Altri stati",
     "fromInspection": "Dall'ispezione {name}, {date}",
+    "fromQuote": "Dal preventivo {ref}",
     "startInspection": "Avvia ispezione"
   },
   "photoHandoff": {

+ 32 - 62
messages/it/settings.json

@@ -348,6 +348,14 @@
     "deleteWorkshopConfirmPrompt": "Digita <bold>{name}</bold> per confermare.",
     "deleteWorkshopSuccess": "Officina eliminata",
     "deleteWorkshopFailed": "Impossibile eliminare l'officina",
+    "reauthPasswordLabel": "La tua password",
+    "reauthTotpLabel": "Codice dalla tua app di autenticazione",
+    "reauthFreshSessionHint": "Il tuo account non ha una password, quindi questa operazione è possibile solo entro {minutes} minuti dall'accesso. Se è passato più tempo, esci e accedi di nuovo prima.",
+    "reauthWrongPassword": "Password errata.",
+    "reauthWrongCode": "Codice errato.",
+    "reauthTooManyAttempts": "Troppi tentativi. Attendi qualche minuto e riprova.",
+    "reauthSignInAgainAccount": "Accedi di nuovo prima di eliminare il tuo account.",
+    "reauthSignInAgainWorkshop": "Accedi di nuovo prima di eliminare l'officina.",
     "deleteAccountMoved": "Vuoi eliminare il tuo account?",
     "deleteAccountMovedLink": "Puoi farlo nelle impostazioni dei dati.",
     "signatureTitle": "Firma",
@@ -758,12 +766,14 @@
       "next_test_due": "Prossimo controllo",
       "defect_notes": "Note",
       "defect_photos": "Foto",
+      "no_defects_note": "Nota quando non ci sono carenze",
       "passed_checks": "Controlli superati",
       "not_applicable_checks": "Controlli non applicabili",
       "check_notes": "Colonna note",
       "result_detail": "Spiegazione",
       "result_summary": "Conteggio per esito",
       "combined_table": "Una sola tabella per tutte le sezioni",
+      "ungraded_checks": "Controlli non ancora valutati",
       "inspector_line": "Riga della firma",
       "inspector_name": "Nome del firmatario",
       "date_line": "Riga della data",
@@ -1079,64 +1089,6 @@
     "applyTaxNoRecords": "Nessun record necessita dell'aliquota fiscale predefinita.",
     "saveSettings": "Salva impostazioni",
     "saved": "Impostazioni salvate",
-    "currencies": {
-      "USD": "Dollaro USA",
-      "EUR": "Euro",
-      "GBP": "Sterlina britannica",
-      "NOK": "Corona norvegese",
-      "SEK": "Corona svedese",
-      "DKK": "Corona danese",
-      "CHF": "Franco svizzero",
-      "CAD": "Dollaro canadese",
-      "AUD": "Dollaro australiano",
-      "NZD": "Dollaro neozelandese",
-      "JPY": "Yen giapponese",
-      "CNY": "Yuan cinese",
-      "INR": "Rupia indiana",
-      "BRL": "Real brasiliano",
-      "MXN": "Peso messicano",
-      "PLN": "Zloty polacco",
-      "CZK": "Corona ceca",
-      "HUF": "Fiorino ungherese",
-      "TRY": "Lira turca",
-      "ZAR": "Rand sudafricano",
-      "KRW": "Won sudcoreano",
-      "SGD": "Dollaro di Singapore",
-      "HKD": "Dollaro di Hong Kong",
-      "THB": "Baht tailandese",
-      "ISK": "Corona islandese",
-      "RON": "Leu romeno",
-      "ILS": "Shekel israeliano",
-      "PHP": "Peso filippino",
-      "IDR": "Rupia indonesiana",
-      "MYR": "Ringgit malese",
-      "LKR": "Rupia dello Sri Lanka",
-      "AED": "Dirham degli EAU",
-      "SAR": "Riyal saudita",
-      "RUB": "Rublo russo",
-      "TWD": "Dollaro taiwanese",
-      "VND": "Dong vietnamita",
-      "PKR": "Rupia pakistana",
-      "BDT": "Taka del Bangladesh",
-      "NPR": "Rupia nepalese",
-      "BGN": "Lev bulgaro",
-      "UAH": "Grivnia ucraina",
-      "CLP": "Peso cileno",
-      "COP": "Peso colombiano",
-      "ARS": "Peso argentino",
-      "PEN": "Sol peruviano",
-      "EGP": "Sterlina egiziana",
-      "NGN": "Naira nigeriana",
-      "KES": "Scellino keniota",
-      "MAD": "Dirham marocchino",
-      "DZD": "Dinaro algerino",
-      "GHS": "Cedi ghanese",
-      "QAR": "Riyal del Qatar",
-      "KWD": "Dinaro kuwaitiano",
-      "BHD": "Dinaro del Bahrein",
-      "OMR": "Rial omanita",
-      "JOD": "Dinaro giordano"
-    },
     "formatTitle": "Formato valuta",
     "formatSymbolName": "Simbolo",
     "formatCodeName": "Codice",
@@ -1697,9 +1649,18 @@
     "upgradeTitle": "Aggiorna piano",
     "upgradeDescription": "Sblocca più funzionalità aggiornando il tuo piano.",
     "upgradeToProDescription": "Aggiorna per sbloccare clienti illimitati, membri del team, report, SMTP, accesso API e altro.",
-    "upgradeToEnterpriseDescription": "Hai bisogno di più di 5 membri del team? Passa a Enterprise per fino a 50 utenti.",
-    "upgradeToPro": "Passa a Pro — $99/anno",
-    "upgradeToEnterprise": "Enterprise — $140/anno",
+    "upgradeToEnterpriseDescription": "Gestisci più di un'officina? Enterprise copre fino a 10 officine e 50 membri del team.",
+    "upgradeToPro": "Passa a Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Periodo di fatturazione",
+    "billingMonthly": "Mensile",
+    "billingAnnual": "Annuale",
+    "billingSave": "Risparmia il {percent}%",
+    "billingMonthsFree": "{months, plural, one {# mese gratis} other {# mesi gratis}}",
+    "billedAnnually": "{amount} fatturato annualmente",
+    "discountBadge": "{value} di sconto",
+    "discountOnce": "Primo pagamento, poi {amount}",
+    "discountRepeating": "{months, plural, one {Primo mese} other {Primi # mesi}}, poi {amount}",
     "upgradeDialogTitle": "Passa a Enterprise",
     "upgradeDialogDescription": "Il tuo piano Pro attuale verrà aggiornato a Enterprise. Ti verrà addebitata solo la differenza proporzionale per il periodo di fatturazione rimanente.",
     "upgradeConfirm": "Conferma aggiornamento",
@@ -2086,6 +2047,10 @@
     "itemsTableHint": "Un elenco numerato di tutte le righe del lavoro, prima la manodopera e poi i ricambi, al posto delle tabelle separate.",
     "discardChanges": "Scarta le modifiche",
     "tablesExclusiveHint": "Il foglio stampa una disposizione: un unico elenco numerato oppure tabelle separate per ricambi e manodopera. Attivarne una disattiva l'altra.",
+    "groupLines": "Raggruppa righe",
+    "groupLinesOff": "No",
+    "groupLinesCategory": "Per categoria",
+    "groupLinesHint": "Per categoria stampa i ricambi sotto un’intestazione per categoria con un subtotale per ciascuna, con la manodopera per prima nell’elenco combinato. I ricambi senza categoria sono sommati come «Altri ricambi».",
     "innerPadding": "Spaziatura interna",
     "totalsWidth": "Larghezza",
     "totalsWidthHint": "Vuoto occupa tutta la riga. Un valore in punti appende il blocco a destra; in una colonna prende la larghezza della colonna.",
@@ -2174,6 +2139,8 @@
       "itemLabor": "Freni anteriori sostituiti",
       "itemDisc": "Disco freno, anteriore",
       "itemPads": "Kit pastiglie, asse anteriore",
+      "categoryBrakes": "Freni",
+      "categoryConsumables": "Materiali di consumo",
       "itemConsumables": "Materiali di consumo",
       "unitPcs": "pz",
       "unitSet": "kit",
@@ -2200,6 +2167,7 @@
       "sectionLighting": "Dispositivi di illuminazione",
       "checkHeadlamps": "Stato dei proiettori",
       "checkFogLamp": "Retronebbia",
+      "checkStopLamps": "Luci di arresto",
       "photoFront": "Frontale",
       "photoOdometer": "Contachilometri",
       "concernNoise": "Rumore di sfregamento davanti in frenata",
@@ -2272,7 +2240,9 @@
     "conditionMapViewsAll": "Tutte e cinque",
     "conditionMapViewsTop": "Solo dall’alto",
     "conditionMapViewsSides": "Solo i lati",
-    "conditionMapWidthHint": "Vuoto occupa tutta la riga. Indica i punti per rimpicciolire il disegno; si posiziona secondo l’allineamento e la legenda mantiene tutta la larghezza."
+    "conditionMapWidthHint": "Vuoto occupa tutta la riga. Indica i punti per rimpicciolire il disegno; si posiziona secondo l’allineamento e la legenda mantiene tutta la larghezza.",
+    "conditionMapDense": "Tabella compatta",
+    "conditionMapDenseHint": "Avvicina le righe della tabella sotto il disegno, così occupa meno spazio sulla pagina."
   },
   "preview": {
     "title": "Servizio freni e cambio olio",

+ 15 - 0
messages/it/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Applica",
     "allTechnicians": "Tutti i tecnici",
     "export": "Esporta CSV",
+    "print": "Stampa foglio ore",
+    "client": "Cliente",
+    "noClient": "Nessun cliente",
+    "signature": "Firma",
+    "sortBy": "Ordina per",
+    "sort": {
+      "default": "Tecnico / giorno",
+      "client": "Cliente",
+      "vehicle": "Veicolo",
+      "clientVehicle": "Cliente, poi veicolo"
+    },
+    "sortDirection": "Direzione di ordinamento",
+    "ascending": "Crescente",
+    "descending": "Decrescente",
     "addEntry": "Aggiungi voce",
     "stats": {
       "total": "Tempo totale",
@@ -101,6 +115,7 @@
       "duration": "Durata",
       "hours": "Ore",
       "job": "Lavoro",
+      "client": "Cliente",
       "vehicle": "Veicolo",
       "plate": "Targa",
       "source": "Origine",

+ 3 - 0
messages/lt/audit.json

@@ -188,6 +188,7 @@
     "signature_remove": "Pašalintas parašas",
     "conditionMark_add": "Pridėta būklės žyma",
     "conditionMark_remove": "Pašalinta būklės žyma",
+    "conditionMark_carry": "Patvirtino ankstesnes būklės žymas",
     "conditionMark_onInvoice": "Būklės žemėlapis sąskaitoje",
     "settings_markType_create": "Pridėtas žymos tipas",
     "settings_markType_update": "Pakeistas žymos tipas",
@@ -234,6 +235,7 @@
     "payment_delete": "Ištrintas mokėjimas {id}",
     "quoteRequest_update": "Pasiūlymo užklausos būsena pakeista į „{status, select, pending {laukiama} approved {patvirtinta} rejected {atmesta} completed {užbaigta} other {{status}}}“",
     "quote_convert": "Pasiūlymas {ref} paverstas darbu {serviceRecordId}",
+    "quote_convert_existing": "Pasiūlymas {ref} pridėtas prie esamo darbo užsakymo {jobRef}",
     "quote_create": "Sukurtas pasiūlymas {ref}",
     "quote_delete": "Ištrintas pasiūlymas {ref}",
     "quote_status": "Pasiūlymo būsena pakeista į „{status, select, draft {juodraštis} sent {išsiųstas} accepted {priimtas} rejected {atmestas} expired {nebegalioja} converted {paverstas} other {{status}}}“",
@@ -342,6 +344,7 @@
     "inspection_phoneUpload": "Pridėtas failas iš telefono prie apžiūros {ref}",
     "condition_mark_add": "Pridėjo žymą būklės žemėlapyje",
     "condition_mark_remove": "Pašalino žymą iš būklės žemėlapio",
+    "condition_mark_carry": "Patvirtino, kad {count, plural, one {# ankstesnė žyma} few {# ankstesnės žymos} other {# ankstesnių žymų}} vis dar yra būklės žemėlapyje",
     "condition_map_on_invoice_on": "Įtraukė būklės žemėlapį į sąskaitą",
     "condition_map_on_invoice_off": "Pašalino būklės žemėlapį iš sąskaitos"
   }

+ 3 - 0
messages/lt/conditionMap.json

@@ -9,6 +9,8 @@
   "previousHint": "Vis dar yra nuo praėjusio karto, pilka spalva. Pašalinkite tas, kurių nebėra.",
   "newSinceLastVisit": "Nauja nuo praėjusio vizito",
   "confirmed": "Vis dar yra",
+  "confirmAll": "Patvirtinti, kad visos vis dar yra",
+  "confirmAllHint": "Užfiksuoja jas šiame lape kaip šio vizito žymas, spalvotai.",
   "clearRepaired": "Pašalinti kaip sutaisytą",
   "restore": "Atkurti",
   "edit": "Redaguoti žymą",
@@ -38,6 +40,7 @@
   "readOnly": "Norėdami keisti būklės žemėlapį, atidarykite iš naujo.",
   "readOnlyLinked": "Pažymėta susietoje apžiūroje. Keiskite ten.",
   "readOnlyEarlier": "Užfiksuota ankstesnio apsilankymo metu. Pažymėkite kaip sutaisytą, kai jos nebeliks.",
+  "readOnlyCarried": "Dar kartą patvirtinta vėlesnio apsilankymo metu. Lieka čia kaip šio apsilankymo įrašas, o keičiama ten.",
   "readOnlyOtherCheck": "Pažymėta kitame šios apžiūros punkte. Keiskite ten.",
   "count": "{count, plural, =0 {Žymų nėra} one {# žyma} few {# žymos} other {# žymų}}",
   "previousCount": "{count, plural, one {# iš ankstesnio vizito} few {# iš ankstesnių vizitų} other {# iš ankstesnių vizitų}}",

+ 39 - 0
messages/lt/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Kas 30 minučių. QuickBooks gautas mokėjimas už Torqvoice sąskaitą užregistruojamas čia prie sąskaitos."
       }
     },
+    "fiken": {
+      "description": "Siųskite išrašytas sąskaitas, jų klientus ir mokėjimus į Fiken ir parsisiųskite ten užregistruotus mokėjimus.",
+      "tenantHelp": "Sukurkite programėlę Fiken skiltyje Rediger konto, API, savo įmonėms. Programėlėje įveskite žemiau pateiktą peradresavimo URI, tada čia nukopijuokite jos kliento ID ir kliento slaptažodį. Įmonei reikalingas Fiken API modulis.",
+      "fields": {
+        "clientId": "Kliento ID",
+        "clientSecret": "Kliento slaptažodis"
+      },
+      "settings": {
+        "companySlug": "Įmonė Fiken sistemoje",
+        "companySlugHelp": "Įmonė, į kurios apskaitą patenka sąskaitos. Jūsų Fiken naudotojas gali turėti prieigą prie kelių.",
+        "pushInvoices": "Siųsti išrašytas sąskaitas į Fiken",
+        "pushOnComplete": "Siųsti sąskaitas ir tada, kai darbas pažymimas kaip užbaigtas",
+        "pushOnCompleteHelp": "Be šio nustatymo sąskaita perduodama, kai ji išsiunčiama, pasidalinama ar apmokama. Su juo užbaigtas darbas perduodamas iš karto ir atnaujinamas, jei pasikeičia.",
+        "startDate": "Tik sąskaitos, datuotos nuo",
+        "startDateHelp": "Palikite tuščią, kad būtų siunčiamos visos išrašytos sąskaitos. Naudokite datą, kada apskaita perkelta į Fiken, kad senesnės sąskaitos nebūtų įvestos dukart.",
+        "attachPdf": "Pridėti sąskaitos PDF",
+        "attachPdfHelp": "Sąskaita į Fiken patenka kaip pardavimas (Annet salg) su savo Torqvoice numeriu. Įjungus, kliento gautas PDF pridedamas prie pardavimo kaip pagrindžiantis dokumentas.",
+        "laborAccount": "Pajamų sąskaita darbui",
+        "laborAccountHelp": "Palikite tuščią, kad būtų naudojama 3000.",
+        "partsAccount": "Pajamų sąskaita dalims",
+        "partsAccountHelp": "Palikite tuščią, kad būtų naudojama 3000.",
+        "zeroVatType": "PVM tipas sąskaitoms be PVM",
+        "zeroVatTypeHelp": "Kaip Fiken apskaito pardavimą be PVM. Įmonei, kuri nėra PVM mokėtoja, čia nieko rinktis nereikia. PVM mokėtoja turi pasirinkti prieš siunčiant tokią sąskaitą; paklauskite savo buhalterio, kuris tipas taikomas.",
+        "vatNone": "Nėra (PVM netaikomas)",
+        "vatExempt": "Atleista (fritatt)",
+        "vatOutside": "Už PVM taikymo srities (utenfor)",
+        "vatExport": "Atleista, eksportas",
+        "vatReverse": "Atleista, atvirkštinis apmokestinimas",
+        "zeroAccount": "Pajamų sąskaita sąskaitoms be PVM",
+        "zeroAccountHelp": "Palikite tuščią, kad būtų naudojamos aukščiau nurodytos darbo ir dalių sąskaitos. Jei Fiken atmeta sąskaitą su šiuo PVM tipu, priežastis rodoma veiklos žurnale.",
+        "pushPayments": "Registruoti mokėjimus Fiken",
+        "paymentAccount": "Mokėjimus įnešti į",
+        "paymentAccountHelp": "Palikite tuščią, kad būtų naudojama pirmoji įmonės banko sąskaita.",
+        "manualPaidAsPayment": "Registruoti mokėjimą, kai sąskaita ranka pažymima kaip apmokėta",
+        "manualPaidAsPaymentHelp": "Torqvoice be mokėjimo įrašo kaip apmokėta pažymėta sąskaita Fiken gauna vieną mokėjimą likusiai ten rodomai sumai. Išjungus ji ten lieka atvira, kol užregistruojamas mokėjimas.",
+        "pullPayments": "Parsisiųsti Fiken užregistruotus mokėjimus",
+        "pullPaymentsHelp": "Kas 30 minučių. Fiken gautas mokėjimas už Torqvoice sąskaitą užregistruojamas čia prie sąskaitos."
+      }
+    },
     "nhtsa": {
       "description": "Užpildykite transporto priemonę pagal VIN ir matykite kiekvienų modelio metų atšaukimus, savininkų skundus ir smūgio bandymų įvertinimus iš JAV saugos institucijos. Nemokamai, be rakto.",
       "fields": {},

+ 2 - 0
messages/lt/pdf.json

@@ -20,6 +20,8 @@
     "type": "Tipas: {type}",
     "tech": "Technikas: {tech}",
     "parts": "Dalys",
+    "otherParts": "Kitos dalys",
+    "groupSubtotal": "{group} – tarpinė suma",
     "partNumber": "Dalies Nr.",
     "description": "Aprašymas",
     "qty": "Kiekis",

+ 40 - 0
messages/lt/quotes.json

@@ -36,6 +36,45 @@
     "selectVehicle": "Pasirinkite transporto priemonę",
     "convertTitle": "Konvertuoti pasiūlymą į darbo užsakymą",
     "convertDescription": "Bus sukurtas naujas paslaugos įrašas (darbo užsakymas) iš šio pasiūlymo. Pasirinkite transporto priemonę darbo užsakymui.",
+    "convertChoiceDescription": "Pasirinkite transporto priemonę ir iš šio pasiūlymo sukurkite naują darbo užsakymą arba pridėkite jį prie jau atidaryto.",
+    "convertNew": "Sukurti naują darbo užsakymą",
+    "convertExisting": "Pridėti prie esamo darbo užsakymo",
+    "convertExistingHint": "Pasiūlymo dalys, darbai ir failai pridedami prie darbo užsakymo. Tai, kas jame jau yra, lieka nepakeista.",
+    "convertNoOpenWorkOrders": "Ši transporto priemonė neturi atidarytų darbo užsakymų.",
+    "convertSelectWorkOrder": "Pasirinkite darbo užsakymą",
+    "convertAdd": "Pridėti prie darbo užsakymo",
+    "convertConflicts": {
+      "heading": "Prieš pridedant nuspręskite, ką darbo užsakymas pasilieka",
+      "checking": "Pasiūlymas lyginamas su darbo užsakymu...",
+      "unanswered": "Prieš pridėdami pasiūlymą pasirinkite, ką darbo užsakymas pasilieka.",
+      "discount": {
+        "title": "Nuolaida",
+        "summary": "Darbo užsakymas turi {percent}% nuolaidą, šiuo metu {jobAmount}. Pasiūlyme yra {quoteAmount} nuolaida.",
+        "keepJob": "Palikti {percent}% nuolaidą ir atmesti pasiūlymo {quoteAmount}",
+        "combine": "Paversti {percent}% į {jobAmount} ir pridėti pasiūlymo {quoteAmount}, iš viso {total}"
+      },
+      "tax": {
+        "title": "Mokesčiai",
+        "summary": "Darbo užsakymas įkainotas su {job}. Pasiūlymas buvo įkainotas su {quote}.",
+        "keepJob": "Palikti darbo užsakymo mokesčius. Pasiūlymo eilutės perkeliamos pasiūlytomis sumomis.",
+        "useQuote": "Perjungti darbo užsakymą į pasiūlymo mokesčius. Viskas jame perskaičiuojama.",
+        "inclusive": "{rate}% mokesčiai įskaičiuoti į eilutes",
+        "exclusive": "{rate}% mokesčiai pridedami papildomai",
+        "components": "{rate}% mokesčiai ({names})"
+      },
+      "warranty": {
+        "title": "Garantija",
+        "summary": "Darbo užsakyme nurodyta {job}. Kliento priimtame pasiūlyme nurodyta {quote}.",
+        "keepJob": "Palikti darbo užsakymo garantiją",
+        "useQuote": "Naudoti pasiūlymo garantiją, kokią klientas priėmė",
+        "none": "nieko apie garantiją",
+        "notIncluded": "be dirbtuvių garantijos",
+        "included": "garantija įtraukta",
+        "includedWithTerms": "garantija įtraukta, {terms}",
+        "months": "{count, plural, one {# mėnuo} few {# mėnesiai} other {# mėnesių}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Konvertuoti",
     "convertToWorkOrder": "Konvertuoti į darbo užsakymą",
     "cancel": "Atšaukti",
@@ -68,6 +107,7 @@
     "title": "Dalys",
     "addPart": "Pridėti dalį",
     "partNumber": "Dalies Nr.",
+    "category": "Kategorija",
     "name": "Pavadinimas",
     "namePlaceholder": "Pavadinimas *",
     "qty": "Kiekis",

+ 2 - 0
messages/lt/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Iš inventoriaus",
     "addPart": "Pridėti dalį",
     "partNumber": "Dalies Nr.",
+    "category": "Kategorija",
     "name": "Pavadinimas",
     "qty": "Kiekis",
     "unitCost": "Savikaina",
@@ -666,6 +667,7 @@
     },
     "moreStatuses": "Daugiau būsenų",
     "fromInspection": "Iš apžiūros {name}, {date}",
+    "fromQuote": "Iš pasiūlymo {ref}",
     "startInspection": "Pradėti apžiūrą"
   },
   "photoHandoff": {

+ 32 - 62
messages/lt/settings.json

@@ -348,6 +348,14 @@
     "deleteWorkshopConfirmPrompt": "Įveskite <bold>{name}</bold>, kad patvirtintumėte.",
     "deleteWorkshopSuccess": "Dirbtuvės ištrintos",
     "deleteWorkshopFailed": "Nepavyko ištrinti dirbtuvių",
+    "reauthPasswordLabel": "Jūsų slaptažodis",
+    "reauthTotpLabel": "Kodas iš jūsų autentifikavimo programėlės",
+    "reauthFreshSessionHint": "Jūsų paskyra neturi slaptažodžio, todėl tai veikia tik per {minutes} min. nuo prisijungimo. Jei praėjo daugiau laiko, pirmiausia atsijunkite ir vėl prisijunkite.",
+    "reauthWrongPassword": "Neteisingas slaptažodis.",
+    "reauthWrongCode": "Neteisingas kodas.",
+    "reauthTooManyAttempts": "Per daug bandymų. Palaukite kelias minutes ir bandykite dar kartą.",
+    "reauthSignInAgainAccount": "Prieš ištrindami paskyrą, prisijunkite iš naujo.",
+    "reauthSignInAgainWorkshop": "Prieš ištrindami dirbtuves, prisijunkite iš naujo.",
     "deleteAccountMoved": "Norite ištrinti savo paskyrą?",
     "deleteAccountMovedLink": "Tai galite padaryti duomenų nustatymuose.",
     "signatureTitle": "Parašas",
@@ -758,12 +766,14 @@
       "next_test_due": "Kita patikra",
       "defect_notes": "Pastabos",
       "defect_photos": "Nuotraukos",
+      "no_defects_note": "Pastaba, kai trūkumų nėra",
       "passed_checks": "Išlaikyti patikrinimai",
       "not_applicable_checks": "Netaikomi patikrinimai",
       "check_notes": "Pastabų stulpelis",
       "result_detail": "Paaiškinimas",
       "result_summary": "Kiekis pagal įvertinimą",
       "combined_table": "Viena lentelė visiems skyriams",
+      "ungraded_checks": "Dar neįvertinti patikrinimai",
       "inspector_line": "Parašo eilutė",
       "inspector_name": "Pasirašiusiojo vardas",
       "date_line": "Datos eilutė",
@@ -1079,64 +1089,6 @@
     "applyTaxNoRecords": "Jokie įrašai nereikalauja numatytojo mokesčių tarifo.",
     "saveSettings": "Išsaugoti nustatymus",
     "saved": "Nustatymai išsaugoti",
-    "currencies": {
-      "USD": "JAV doleris",
-      "EUR": "Euras",
-      "GBP": "Svaras sterlingų",
-      "NOK": "Norvegijos krona",
-      "SEK": "Švedijos krona",
-      "DKK": "Danijos krona",
-      "CHF": "Šveicarijos frankas",
-      "CAD": "Kanados doleris",
-      "AUD": "Australijos doleris",
-      "NZD": "Naujosios Zelandijos doleris",
-      "JPY": "Japonijos jena",
-      "CNY": "Kinijos juanis",
-      "INR": "Indijos rupija",
-      "BRL": "Brazilijos realas",
-      "MXN": "Meksikos pesas",
-      "PLN": "Lenkijos zlotas",
-      "CZK": "Čekijos krona",
-      "HUF": "Vengrijos forintas",
-      "TRY": "Turkijos lira",
-      "ZAR": "Pietų Afrikos randas",
-      "KRW": "Pietų Korėjos vonas",
-      "SGD": "Singapūro doleris",
-      "HKD": "Honkongo doleris",
-      "THB": "Tailando batas",
-      "ISK": "Islandijos krona",
-      "RON": "Rumunijos lėja",
-      "ILS": "Izraelio šekelis",
-      "PHP": "Filipinų pesas",
-      "IDR": "Indonezijos rupija",
-      "MYR": "Malaizijos ringitas",
-      "LKR": "Šri Lankos rupija",
-      "AED": "JAE dirhamas",
-      "SAR": "Saudo Arabijos rijalas",
-      "RUB": "Rusijos rublis",
-      "TWD": "Taivano doleris",
-      "VND": "Vietnamo donas",
-      "PKR": "Pakistano rupija",
-      "BDT": "Bangladešo taka",
-      "NPR": "Nepalo rupija",
-      "BGN": "Bulgarijos levas",
-      "UAH": "Ukrainos grivina",
-      "CLP": "Čilės pesas",
-      "COP": "Kolumbijos pesas",
-      "ARS": "Argentinos pesas",
-      "PEN": "Peru solis",
-      "EGP": "Egipto svaras",
-      "NGN": "Nigerijos naira",
-      "KES": "Kenijos šilingas",
-      "MAD": "Maroko dirhamas",
-      "DZD": "Alžyro dinaras",
-      "GHS": "Ganos sedis",
-      "QAR": "Kataro rialas",
-      "KWD": "Kuveito dinaras",
-      "BHD": "Bahreino dinaras",
-      "OMR": "Omano rialas",
-      "JOD": "Jordanijos dinaras"
-    },
     "formatTitle": "Valiutos formatas",
     "formatSymbolName": "Simbolis",
     "formatCodeName": "Kodas",
@@ -1697,9 +1649,18 @@
     "upgradeTitle": "Atnaujinti planą",
     "upgradeDescription": "Atrakinkite daugiau funkcijų atnaujindami planą.",
     "upgradeToProDescription": "Atnaujinkite, kad atrakintumėte neribotą klientų, komandos narių, ataskaitų, SMTP, API prieigos skaičių ir daugiau.",
-    "upgradeToEnterpriseDescription": "Reikia daugiau nei 5 komandos narių? Atnaujinkite į Enterprise iki 50 naudotojų.",
-    "upgradeToPro": "Atnaujinti į Pro — $99/m.",
-    "upgradeToEnterprise": "Enterprise — $140/m.",
+    "upgradeToEnterpriseDescription": "Turite daugiau nei vieną dirbtuvę? Enterprise apima iki 10 dirbtuvių ir 50 komandos narių.",
+    "upgradeToPro": "Atnaujinti į Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Atsiskaitymo laikotarpis",
+    "billingMonthly": "Kas mėnesį",
+    "billingAnnual": "Kasmet",
+    "billingSave": "Sutaupykite {percent}%",
+    "billingMonthsFree": "{months, plural, one {# mėnuo nemokamai} few {# mėnesiai nemokamai} other {# mėnesių nemokamai}}",
+    "billedAnnually": "{amount} apmokestinama kasmet",
+    "discountBadge": "{value} nuolaida",
+    "discountOnce": "Pirmas mokėjimas, vėliau {amount}",
+    "discountRepeating": "Pirmus {months} mėn., vėliau {amount}",
     "upgradeDialogTitle": "Atnaujinti į Enterprise",
     "upgradeDialogDescription": "Jūsų dabartinis Pro planas bus atnaujintas į Enterprise. Bus apmokestinta tik proporcinga skirtuma likusiam atsiskaitymo laikotarpiui.",
     "upgradeConfirm": "Patvirtinti atnaujinimą",
@@ -2086,6 +2047,10 @@
     "itemsTableHint": "Vienas numeruotas visų darbo eilučių sąrašas, pirmiausia darbai, paskui dalys, vietoj atskirų lentelių.",
     "discardChanges": "Atmesti pakeitimus",
     "tablesExclusiveHint": "Lape spausdinamas vienas išdėstymas: vienas numeruotas sąrašas arba atskiros dalių ir darbų lentelės. Įjungus vieną, kitas išsijungia.",
+    "groupLines": "Grupuoti eilutes",
+    "groupLinesOff": "Išjungta",
+    "groupLinesCategory": "Pagal kategoriją",
+    "groupLinesHint": "Pagal kategoriją spausdina dalis po atskira kiekvienos kategorijos antrašte su tarpine suma, bendrame sąraše pirmiausia darbus. Dalys be kategorijos sumuojamos kaip „Kitos dalys“.",
     "innerPadding": "Vidinis tarpas",
     "totalsWidth": "Plotis",
     "totalsWidthHint": "Tuščias užima visą eilutę. Nurodžius taškus, blokas kabinamas dešinėje; stulpelyje jis užima stulpelio plotį.",
@@ -2174,6 +2139,8 @@
       "itemLabor": "Pakeisti priekiniai stabdžiai",
       "itemDisc": "Stabdžių diskas, priekinis",
       "itemPads": "Kaladėlių komplektas, priekinė ašis",
+      "categoryBrakes": "Stabdžiai",
+      "categoryConsumables": "Dirbtuvės medžiagos",
       "itemConsumables": "Eksploatacinės medžiagos",
       "unitPcs": "vnt.",
       "unitSet": "kompl.",
@@ -2200,6 +2167,7 @@
       "sectionLighting": "Apšvietimo įranga",
       "checkHeadlamps": "Priekinių žibintų būklė",
       "checkFogLamp": "Galinis rūko žibintas",
+      "checkStopLamps": "Stabdžių žibintai",
       "photoFront": "Priekis",
       "photoOdometer": "Odometras",
       "concernNoise": "Šlifavimo garsas priekyje stabdant",
@@ -2272,7 +2240,9 @@
     "conditionMapViewsAll": "Visi penki",
     "conditionMapViewsTop": "Tik iš viršaus",
     "conditionMapViewsSides": "Tik šonai",
-    "conditionMapWidthHint": "Tuščia užima visą eilutę. Nurodykite taškus, kad brėžinys būtų mažesnis; jis dedamas pagal lygiavimą, o legenda išlaiko visą plotį."
+    "conditionMapWidthHint": "Tuščia užima visą eilutę. Nurodykite taškus, kad brėžinys būtų mažesnis; jis dedamas pagal lygiavimą, o legenda išlaiko visą plotį.",
+    "conditionMapDense": "Glausta lentelė",
+    "conditionMapDenseHint": "Suglaudina lentelės po brėžiniu eilutes, kad ji užimtų mažiau vietos puslapyje."
   },
   "preview": {
     "title": "Stabdžių aptarnavimas ir tepalų keitimas",

+ 15 - 0
messages/lt/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Taikyti",
     "allTechnicians": "Visi technikai",
     "export": "Eksportuoti CSV",
+    "print": "Spausdinti darbo laiko žiniaraštį",
+    "client": "Klientas",
+    "noClient": "Nėra kliento",
+    "signature": "Parašas",
+    "sortBy": "Rikiuoti pagal",
+    "sort": {
+      "default": "Technikas / diena",
+      "client": "Klientas",
+      "vehicle": "Transporto priemonė",
+      "clientVehicle": "Klientas, tada transporto priemonė"
+    },
+    "sortDirection": "Rikiavimo kryptis",
+    "ascending": "Didėjančia tvarka",
+    "descending": "Mažėjančia tvarka",
     "addEntry": "Pridėti įrašą",
     "stats": {
       "total": "Bendras laikas",
@@ -101,6 +115,7 @@
       "duration": "Trukmė",
       "hours": "Valandos",
       "job": "Darbas",
+      "client": "Klientas",
       "vehicle": "Transporto priemonė",
       "plate": "Numeris",
       "source": "Šaltinis",

+ 3 - 0
messages/nb/audit.json

@@ -188,6 +188,7 @@
     "signature_remove": "Fjernet signatur",
     "conditionMark_add": "La til tilstandsmerke",
     "conditionMark_remove": "Fjernet tilstandsmerke",
+    "conditionMark_carry": "Bekreftet tidligere tilstandsmerker",
     "conditionMark_onInvoice": "Tilstandskart på faktura",
     "settings_markType_create": "La til en merketype",
     "settings_markType_update": "Endret en merketype",
@@ -234,6 +235,7 @@
     "payment_delete": "Slettet betaling {id}",
     "quoteRequest_update": "Endret status på tilbudsforespørsel til {status, select, pending {venter} approved {godkjent} rejected {avslått} completed {fullført} other {{status}}}",
     "quote_convert": "Gjorde tilbud {ref} om til jobb {serviceRecordId}",
+    "quote_convert_existing": "La tilbud {ref} til eksisterende arbeidsordre {jobRef}",
     "quote_create": "Opprettet tilbud {ref}",
     "quote_delete": "Slettet tilbud {ref}",
     "quote_status": "Endret tilbudsstatus til {status, select, draft {utkast} sent {sendt} accepted {godtatt} rejected {avslått} expired {utløpt} converted {konvertert} other {{status}}}",
@@ -342,6 +344,7 @@
     "inspection_phoneUpload": "La til en fil fra telefon på kontroll {ref}",
     "condition_mark_add": "La til et merke på tilstandskartet",
     "condition_mark_remove": "Fjernet et merke fra tilstandskartet",
+    "condition_mark_carry": "Bekreftet at {count, plural, one {# tidligere merke} other {# tidligere merker}} fortsatt er der på tilstandskartet",
     "condition_map_on_invoice_on": "Satte tilstandskartet på fakturaen",
     "condition_map_on_invoice_off": "Tok tilstandskartet av fakturaen"
   }

+ 3 - 0
messages/nb/conditionMap.json

@@ -9,6 +9,8 @@
   "previousHint": "Fortsatt der siden sist, tegnet i grått. Fjern dem som er borte.",
   "newSinceLastVisit": "Nytt siden forrige besøk",
   "confirmed": "Fortsatt der",
+  "confirmAll": "Bekreft at alle fortsatt er der",
+  "confirmAllHint": "Registrerer dem på dette arket som dette besøkets merker, i farger.",
   "clearRepaired": "Fjern som reparert",
   "restore": "Gjenopprett",
   "edit": "Rediger merke",
@@ -38,6 +40,7 @@
   "readOnly": "Åpne på nytt for å endre tilstandskartet.",
   "readOnlyLinked": "Tegnet på den koblede inspeksjonen. Endre den der.",
   "readOnlyEarlier": "Registrert ved et tidligere besøk. Fjern som reparert når skaden er borte.",
+  "readOnlyCarried": "Bekreftet på nytt ved et senere besøk. Den blir stående her som dette besøkets registrering og endres der.",
   "readOnlyOtherCheck": "Tegnet på et annet kontrollpunkt i denne inspeksjonen. Endre den der.",
   "count": "{count, plural, =0 {Ingen merker} one {# merke} other {# merker}}",
   "previousCount": "{count, plural, one {# fra et tidligere besøk} other {# fra tidligere besøk}}",

+ 39 - 0
messages/nb/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Hvert 30. minutt. En betaling mottatt i QuickBooks mot en faktura fra Torqvoice registreres her på fakturaen."
       }
     },
+    "fiken": {
+      "description": "Send utstedte fakturaer, kundene deres og betalingene deres til Fiken, og hent tilbake betalinger registrert der.",
+      "tenantHelp": "Opprett en app i Fiken under Rediger konto, API, for dine egne foretak. Legg inn omdirigerings-URI-en nedenfor på appen, og kopier klient-ID og klienthemmelighet hit. Foretaket må ha Fikens API-modul.",
+      "fields": {
+        "clientId": "Klient-ID",
+        "clientSecret": "Klienthemmelighet"
+      },
+      "settings": {
+        "companySlug": "Foretak i Fiken",
+        "companySlugHelp": "Foretaket som fakturaene skal bokføres i. Fiken-brukeren din kan ha tilgang til flere.",
+        "pushInvoices": "Send utstedte fakturaer til Fiken",
+        "pushOnComplete": "Send også fakturaer når jobben markeres som fullført",
+        "pushOnCompleteHelp": "Uten dette går en faktura over når den er sendt, delt eller betalt. Med det går en fullført jobb over med en gang og oppdateres om den endres.",
+        "startDate": "Bare fakturaer datert fra og med",
+        "startDateHelp": "La stå tomt for å sende alle utstedte fakturaer. Bruk datoen regnskapet flyttet til Fiken, så eldre fakturaer ikke føres to ganger.",
+        "attachPdf": "Legg ved faktura-PDF-en",
+        "attachPdfHelp": "En faktura går inn i Fiken som et salg (Annet salg) med fakturanummeret fra Torqvoice. Med dette på legges PDF-en kunden fikk ved salget som bilag.",
+        "laborAccount": "Inntektskonto for arbeid",
+        "laborAccountHelp": "La stå tom for å bruke 3000.",
+        "partsAccount": "Inntektskonto for deler",
+        "partsAccountHelp": "La stå tom for å bruke 3000.",
+        "zeroVatType": "Mva-type for fakturaer uten mva",
+        "zeroVatTypeHelp": "Hvordan Fiken bokfører et salg uten mva. Et foretak som ikke er mva-registrert trenger ikke velge noe her. Et registrert foretak må velge før en slik faktura sendes; spør regnskapsføreren hva som gjelder.",
+        "vatNone": "Ingen (ingen mva-behandling)",
+        "vatExempt": "Fritatt",
+        "vatOutside": "Utenfor avgiftsområdet",
+        "vatExport": "Fritatt, eksport",
+        "vatReverse": "Fritatt, omvendt avgiftsplikt",
+        "zeroAccount": "Inntektskonto for fakturaer uten mva",
+        "zeroAccountHelp": "La stå tom for å bruke kontoene for arbeid og deler ovenfor. Hvis Fiken avviser kontoen sammen med mva-typen, står årsaken i aktivitetsloggen.",
+        "pushPayments": "Registrer betalinger i Fiken",
+        "paymentAccount": "Sett betalinger inn på",
+        "paymentAccountHelp": "La stå tom for å bruke foretakets første bankkonto.",
+        "manualPaidAsPayment": "Registrer en betaling når en faktura markeres som betalt for hånd",
+        "manualPaidAsPaymentHelp": "En faktura markert som betalt i Torqvoice uten betalingsføring får én betaling i Fiken for det den fortsatt viser som skyldig der. Avslått blir den stående åpen der til en betaling registreres.",
+        "pullPayments": "Hent tilbake betalinger registrert i Fiken",
+        "pullPaymentsHelp": "Hvert 30. minutt. En betaling mottatt i Fiken mot en faktura fra Torqvoice registreres her på fakturaen."
+      }
+    },
     "nhtsa": {
       "description": "Fyll ut et kjøretøy fra VIN, og se tilbakekallinger, eierklager og kollisjonsvurderinger for hver årsmodell, fra den amerikanske trafikksikkerhetsmyndigheten. Gratis, uten nøkkel.",
       "fields": {},

+ 2 - 0
messages/nb/pdf.json

@@ -20,6 +20,8 @@
     "type": "Type: {type}",
     "tech": "Tekniker: {tech}",
     "parts": "Deler",
+    "otherParts": "Andre deler",
+    "groupSubtotal": "Delsum {group}",
     "partNumber": "Del #",
     "description": "Beskrivelse",
     "qty": "Ant.",

+ 40 - 0
messages/nb/quotes.json

@@ -36,6 +36,45 @@
     "selectVehicle": "Vennligst velg et kjøretøy",
     "convertTitle": "Konverter tilbud til arbeidsordre",
     "convertDescription": "Dette vil opprette en ny servicepost (arbeidsordre) fra dette tilbudet. Velg et kjøretøy for arbeidsordren.",
+    "convertChoiceDescription": "Velg et kjøretøy, og opprett en ny arbeidsordre fra tilbudet eller legg det til en som allerede er åpen.",
+    "convertNew": "Opprett en ny arbeidsordre",
+    "convertExisting": "Legg til en eksisterende arbeidsordre",
+    "convertExistingHint": "Tilbudets deler, arbeid og filer legges til arbeidsordren. Det som allerede står på den, blir stående som det er.",
+    "convertNoOpenWorkOrders": "Dette kjøretøyet har ingen åpne arbeidsordrer.",
+    "convertSelectWorkOrder": "Velg en arbeidsordre",
+    "convertAdd": "Legg til arbeidsordre",
+    "convertConflicts": {
+      "heading": "Før du legger til: bestem hva arbeidsordren beholder",
+      "checking": "Sammenligner tilbudet med arbeidsordren...",
+      "unanswered": "Velg hva arbeidsordren beholder før tilbudet legges til.",
+      "discount": {
+        "title": "Rabatt",
+        "summary": "Arbeidsordren har {percent} % rabatt, {jobAmount} slik den står nå. Tilbudet har en rabatt på {quoteAmount}.",
+        "keepJob": "Behold {percent} % rabatt og dropp tilbudets {quoteAmount}",
+        "combine": "Gjør {percent} % om til {jobAmount} og legg til tilbudets {quoteAmount}, {total} til sammen"
+      },
+      "tax": {
+        "title": "Avgift",
+        "summary": "Arbeidsordren er priset med {job}. Tilbudet ble priset med {quote}.",
+        "keepJob": "Behold arbeidsordrens avgift. Tilbudets linjer kommer over med beløpene som ble tilbudt.",
+        "useQuote": "Bytt arbeidsordren til tilbudets avgift. Alt på den summeres på nytt.",
+        "inclusive": "{rate} % avgift inkludert i linjene",
+        "exclusive": "{rate} % avgift lagt til",
+        "components": "{rate} % avgift ({names})"
+      },
+      "warranty": {
+        "title": "Garanti",
+        "summary": "Arbeidsordren sier {job}. Tilbudet kunden godtok sier {quote}.",
+        "keepJob": "Behold arbeidsordrens garanti",
+        "useQuote": "Bruk tilbudets garanti, slik kunden godtok den",
+        "none": "ingenting om garanti",
+        "notIncluded": "ingen verkstedgaranti",
+        "included": "garanti inkludert",
+        "includedWithTerms": "garanti inkludert, {terms}",
+        "months": "{count, plural, one {# måned} other {# måneder}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Konverter",
     "convertToWorkOrder": "Konverter til arbeidsordre",
     "cancel": "Avbryt",
@@ -68,6 +107,7 @@
     "title": "Deler",
     "addPart": "Legg til del",
     "partNumber": "Delnr.",
+    "category": "Kategori",
     "name": "Navn",
     "namePlaceholder": "Navn *",
     "qty": "Ant.",

+ 2 - 0
messages/nb/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Fra beholdning",
     "addPart": "Legg til del",
     "partNumber": "Del #",
+    "category": "Kategori",
     "name": "Navn",
     "qty": "Antall",
     "unitCost": "Kostnad",
@@ -666,6 +667,7 @@
     },
     "moreStatuses": "Flere statuser",
     "fromInspection": "Fra inspeksjon {name}, {date}",
+    "fromQuote": "Fra tilbud {ref}",
     "startInspection": "Start inspeksjon"
   },
   "photoHandoff": {

+ 32 - 62
messages/nb/settings.json

@@ -348,6 +348,14 @@
     "deleteWorkshopConfirmPrompt": "Skriv <bold>{name}</bold> for å bekrefte.",
     "deleteWorkshopSuccess": "Verksted slettet",
     "deleteWorkshopFailed": "Kunne ikke slette verkstedet",
+    "reauthPasswordLabel": "Passordet ditt",
+    "reauthTotpLabel": "Kode fra autentiseringsappen din",
+    "reauthFreshSessionHint": "Kontoen din har ikke passord, så dette fungerer bare innen {minutes} minutter etter at du logget inn. Har det gått lenger tid, logg ut og inn igjen først.",
+    "reauthWrongPassword": "Feil passord.",
+    "reauthWrongCode": "Feil kode.",
+    "reauthTooManyAttempts": "For mange forsøk. Vent noen minutter og prøv igjen.",
+    "reauthSignInAgainAccount": "Logg inn på nytt før du sletter kontoen din.",
+    "reauthSignInAgainWorkshop": "Logg inn på nytt før du sletter verkstedet.",
     "deleteAccountMoved": "Vil du slette kontoen din?",
     "deleteAccountMovedLink": "Det kan du gjøre i datainnstillingene.",
     "signatureTitle": "Signatur",
@@ -758,12 +766,14 @@
       "next_test_due": "Neste test",
       "defect_notes": "Merknader",
       "defect_photos": "Bilder",
+      "no_defects_note": "Merknad når det ikke er mangler",
       "passed_checks": "Beståtte kontroller",
       "not_applicable_checks": "Ikke aktuelle kontroller",
       "check_notes": "Merknadskolonne",
       "result_detail": "Forklaring",
       "result_summary": "Antall per vurdering",
       "combined_table": "Én tabell for alle seksjoner",
+      "ungraded_checks": "Kontroller som ikke er vurdert ennå",
       "inspector_line": "Signaturlinje",
       "inspector_name": "Underskriverens navn",
       "date_line": "Datolinje",
@@ -1079,64 +1089,6 @@
     "applyTaxNoRecords": "Ingen poster trenger standard avgiftssats.",
     "saveSettings": "Lagre innstillinger",
     "saved": "Innstillinger lagret",
-    "currencies": {
-      "USD": "Amerikanske dollar",
-      "EUR": "Euro",
-      "GBP": "Britiske pund",
-      "NOK": "Norske kroner",
-      "SEK": "Svenske kroner",
-      "DKK": "Danske kroner",
-      "CHF": "Sveitsiske franc",
-      "CAD": "Kanadiske dollar",
-      "AUD": "Australske dollar",
-      "NZD": "Newzealandske dollar",
-      "JPY": "Japanske yen",
-      "CNY": "Kinesiske yuan",
-      "INR": "Indiske rupier",
-      "BRL": "Brasilianske real",
-      "MXN": "Meksikanske pesos",
-      "PLN": "Polske zloty",
-      "CZK": "Tsjekkiske koruna",
-      "HUF": "Ungarske forint",
-      "TRY": "Tyrkiske lira",
-      "ZAR": "Sorafrikanske rand",
-      "KRW": "Sorkoreanske won",
-      "SGD": "Singaporske dollar",
-      "HKD": "Hongkong-dollar",
-      "THB": "Thailandske baht",
-      "ISK": "Islandske kroner",
-      "RON": "Rumenske leu",
-      "ILS": "Israelske shekel",
-      "PHP": "Filippinske pesos",
-      "IDR": "Indonesiske rupiah",
-      "MYR": "Malaysiske ringgit",
-      "LKR": "Srilankiske rupier",
-      "AED": "Emiratiske dirham",
-      "SAR": "Saudiarabiske riyal",
-      "RUB": "Russisk rubel",
-      "TWD": "Taiwanske dollar",
-      "VND": "Vietnamesisk dong",
-      "PKR": "Pakistanske rupier",
-      "BDT": "Bangladeshisk taka",
-      "NPR": "Nepalesiske rupier",
-      "BGN": "Bulgarsk lev",
-      "UAH": "Ukrainsk hryvnia",
-      "CLP": "Chilenske pesos",
-      "COP": "Colombianske pesos",
-      "ARS": "Argentinske pesos",
-      "PEN": "Peruansk sol",
-      "EGP": "Egyptisk pund",
-      "NGN": "Nigerianske naira",
-      "KES": "Kenyansk shilling",
-      "MAD": "Marokkansk dirham",
-      "DZD": "Algerisk dinar",
-      "GHS": "Ghanesisk cedi",
-      "QAR": "Qatarske riyal",
-      "KWD": "Kuwaitiske dinar",
-      "BHD": "Bahrainske dinar",
-      "OMR": "Omanske rial",
-      "JOD": "Jordanske dinar"
-    },
     "formatTitle": "Valutaformat",
     "formatSymbolName": "Symbol",
     "formatCodeName": "Kode",
@@ -1697,9 +1649,18 @@
     "upgradeTitle": "Oppgrader plan",
     "upgradeDescription": "Lås opp flere funksjoner ved å oppgradere planen din.",
     "upgradeToProDescription": "Oppgrader for å låse opp ubegrensede kunder, teammedlemmer, rapporter, SMTP, API-tilgang og mer.",
-    "upgradeToEnterpriseDescription": "Trenger du mer enn 5 teammedlemmer? Oppgrader til Enterprise for opptil 50 brukere.",
-    "upgradeToPro": "Oppgrader til Pro ($99/år)",
-    "upgradeToEnterprise": "Enterprise ($140/år)",
+    "upgradeToEnterpriseDescription": "Driver du flere verksteder? Enterprise dekker opptil 10 verksteder og 50 teammedlemmer.",
+    "upgradeToPro": "Oppgrader til Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Faktureringsperiode",
+    "billingMonthly": "Månedlig",
+    "billingAnnual": "Årlig",
+    "billingSave": "Spar {percent}%",
+    "billingMonthsFree": "{months, plural, one {# måned gratis} other {# måneder gratis}}",
+    "billedAnnually": "{amount} fakturert årlig",
+    "discountBadge": "{value} rabatt",
+    "discountOnce": "Første betaling, deretter {amount}",
+    "discountRepeating": "{months, plural, one {Første måned} other {Første # måneder}}, deretter {amount}",
     "upgradeDialogTitle": "Oppgrader til Enterprise",
     "upgradeDialogDescription": "Din gjeldende Pro-plan vil bli oppgradert til Enterprise. Du vil kun bli belastet for den forholdsmessige differansen for den gjenværende faktureringsperioden.",
     "upgradeConfirm": "Bekreft oppgradering",
@@ -2086,6 +2047,10 @@
     "itemsTableHint": "Én nummerert liste over alle linjene i jobben, arbeid først og deler etterpå, i stedet for de separate tabellene.",
     "discardChanges": "Forkast endringer",
     "tablesExclusiveHint": "Arket skriver ut én ordning: én nummerert liste, eller separate tabeller for deler og arbeid. Slår du på den ene, slås den andre av.",
+    "groupLines": "Grupper linjer",
+    "groupLinesOff": "Av",
+    "groupLinesCategory": "Etter kategori",
+    "groupLinesHint": "Etter kategori skriver delene under en overskrift per kategori med egen delsum, med arbeid først i den samlede listen. Deler uten kategori summeres som «Andre deler».",
     "innerPadding": "Innvendig avstand",
     "totalsWidth": "Bredde",
     "totalsWidthHint": "Tom fyller hele raden. En verdi i punkter henger boksen til høyre; i en kolonne tar den kolonnens bredde.",
@@ -2174,6 +2139,8 @@
       "itemLabor": "Bremser foran skiftet",
       "itemDisc": "Bremseskive, foran",
       "itemPads": "Bremseklosser, foraksel",
+      "categoryBrakes": "Bremser",
+      "categoryConsumables": "Forbruksmateriell",
       "itemConsumables": "Forbruksmateriell",
       "unitPcs": "stk",
       "unitSet": "sett",
@@ -2200,6 +2167,7 @@
       "sectionLighting": "Lysutstyr",
       "checkHeadlamps": "Frontlyktenes tilstand",
       "checkFogLamp": "Tåkelys bak",
+      "checkStopLamps": "Bremselys",
       "photoFront": "Front",
       "photoOdometer": "Kilometerteller",
       "concernNoise": "Skrapelyd foran ved bremsing",
@@ -2272,7 +2240,9 @@
     "conditionMapViewsAll": "Alle fem",
     "conditionMapViewsTop": "Bare ovenfra",
     "conditionMapViewsSides": "Bare sidene",
-    "conditionMapWidthHint": "Tomt fyller raden. Angi punkter for å gjøre tegningen mindre; den plasseres da etter justeringen, og listen beholder full bredde."
+    "conditionMapWidthHint": "Tomt fyller raden. Angi punkter for å gjøre tegningen mindre; den plasseres da etter justeringen, og listen beholder full bredde.",
+    "conditionMapDense": "Tett tabell",
+    "conditionMapDenseHint": "Setter radene i tabellen under tegningen tettere, så den tar mindre plass på siden."
   },
   "preview": {
     "title": "Bremseservice og oljeskift",

+ 15 - 0
messages/nb/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Bruk",
     "allTechnicians": "Alle teknikere",
     "export": "Eksporter CSV",
+    "print": "Skriv ut timeliste",
+    "client": "Kunde",
+    "noClient": "Ingen kunde",
+    "signature": "Signatur",
+    "sortBy": "Sorter etter",
+    "sort": {
+      "default": "Tekniker / dag",
+      "client": "Kunde",
+      "vehicle": "Kjøretøy",
+      "clientVehicle": "Kunde, deretter kjøretøy"
+    },
+    "sortDirection": "Sorteringsretning",
+    "ascending": "Stigende",
+    "descending": "Synkende",
     "addEntry": "Legg til oppføring",
     "stats": {
       "total": "Total tid",
@@ -101,6 +115,7 @@
       "duration": "Varighet",
       "hours": "Timer",
       "job": "Jobb",
+      "client": "Kunde",
       "vehicle": "Kjøretøy",
       "plate": "Skilt",
       "source": "Kilde",

+ 3 - 0
messages/nl/audit.json

@@ -188,6 +188,7 @@
     "signature_remove": "Handtekening verwijderd",
     "conditionMark_add": "Staatmarkering toegevoegd",
     "conditionMark_remove": "Staatmarkering verwijderd",
+    "conditionMark_carry": "Eerdere staatmarkeringen bevestigd",
     "conditionMark_onInvoice": "Staatkaart op factuur",
     "settings_markType_create": "Markeringstype toegevoegd",
     "settings_markType_update": "Markeringstype gewijzigd",
@@ -234,6 +235,7 @@
     "payment_delete": "Betaling {id} verwijderd",
     "quoteRequest_update": "Status van offerteaanvraag gewijzigd naar {status, select, pending {in behandeling} approved {goedgekeurd} rejected {afgewezen} completed {afgerond} other {{status}}}",
     "quote_convert": "Offerte {ref} omgezet naar opdracht {serviceRecordId}",
+    "quote_convert_existing": "Offerte {ref} toegevoegd aan bestaande werkorder {jobRef}",
     "quote_create": "Offerte {ref} aangemaakt",
     "quote_delete": "Offerte {ref} verwijderd",
     "quote_status": "Offertestatus gewijzigd naar {status, select, draft {concept} sent {verzonden} accepted {geaccepteerd} rejected {afgewezen} expired {verlopen} converted {omgezet} other {{status}}}",
@@ -342,6 +344,7 @@
     "inspection_phoneUpload": "Bestand vanaf een telefoon toegevoegd aan keuring {ref}",
     "condition_mark_add": "Markering aan de staatkaart toegevoegd",
     "condition_mark_remove": "Markering van de staatkaart verwijderd",
+    "condition_mark_carry": "{count, plural, one {# eerdere markering} other {# eerdere markeringen}} op de staatkaart bevestigd als nog aanwezig",
     "condition_map_on_invoice_on": "Staatkaart op de factuur gezet",
     "condition_map_on_invoice_off": "Staatkaart van de factuur gehaald"
   }

+ 3 - 0
messages/nl/conditionMap.json

@@ -9,6 +9,8 @@
   "previousHint": "Nog aanwezig sinds de vorige keer, in grijs. Wis wat er niet meer is.",
   "newSinceLastVisit": "Nieuw sinds het vorige bezoek",
   "confirmed": "Nog aanwezig",
+  "confirmAll": "Alles bevestigen als nog aanwezig",
+  "confirmAllHint": "Legt ze op dit blad vast als markeringen van dit bezoek, in kleur.",
   "clearRepaired": "Wissen als hersteld",
   "restore": "Herstellen",
   "edit": "Markering bewerken",
@@ -38,6 +40,7 @@
   "readOnly": "Heropen om de staatkaart te wijzigen.",
   "readOnlyLinked": "Getekend op de gekoppelde inspectie. Wijzig hem daar.",
   "readOnlyEarlier": "Vastgelegd bij een eerder bezoek. Markeer als gerepareerd zodra de schade weg is.",
+  "readOnlyCarried": "Opnieuw bevestigd bij een later bezoek. Blijft hier staan als vastlegging van dit bezoek en wordt daar gewijzigd.",
   "readOnlyOtherCheck": "Getekend bij een ander controlepunt van deze inspectie. Wijzig hem daar.",
   "count": "{count, plural, =0 {Geen markeringen} one {# markering} other {# markeringen}}",
   "previousCount": "{count, plural, one {# van een eerder bezoek} other {# van eerdere bezoeken}}",

+ 39 - 0
messages/nl/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Elke 30 minuten. Een betaling die in QuickBooks op een factuur van Torqvoice is ontvangen, wordt hier op de factuur geregistreerd."
       }
     },
+    "fiken": {
+      "description": "Stuur uitgegeven facturen, hun klanten en hun betalingen naar Fiken en haal daar geregistreerde betalingen terug.",
+      "tenantHelp": "Maak in Fiken onder Rediger konto, API een app aan voor je eigen bedrijven. Vul de redirect-URI hieronder in bij de app en kopieer de client-ID en het client-secret hierheen. Het bedrijf heeft de API-module van Fiken nodig.",
+      "fields": {
+        "clientId": "Client-ID",
+        "clientSecret": "Clientgeheim"
+      },
+      "settings": {
+        "companySlug": "Bedrijf in Fiken",
+        "companySlugHelp": "Het bedrijf in wiens boekhouding de facturen komen. Je Fiken-gebruiker kan toegang hebben tot meerdere.",
+        "pushInvoices": "Uitgegeven facturen naar Fiken sturen",
+        "pushOnComplete": "Facturen ook sturen zodra de klus als voltooid is gemarkeerd",
+        "pushOnCompleteHelp": "Zonder deze optie gaat een factuur over zodra hij is verzonden, gedeeld of betaald. Met deze optie gaat een voltooide klus direct over en wordt hij bijgewerkt als er iets verandert.",
+        "startDate": "Alleen facturen gedateerd op of na",
+        "startDateHelp": "Laat leeg om elke uitgegeven factuur te sturen. Gebruik de datum waarop je boekhouding naar Fiken is overgegaan, zodat oudere facturen niet dubbel worden ingevoerd.",
+        "attachPdf": "Factuur-pdf bijvoegen",
+        "attachPdfHelp": "Een factuur komt in Fiken als een verkoop (Annet salg) met zijn Torqvoice-nummer. Met dit aan wordt de pdf die de klant ontving als boekstuk bij de verkoop bewaard.",
+        "laborAccount": "Omzetrekening voor arbeid",
+        "laborAccountHelp": "Laat leeg om 3000 te gebruiken.",
+        "partsAccount": "Omzetrekening voor onderdelen",
+        "partsAccountHelp": "Laat leeg om 3000 te gebruiken.",
+        "zeroVatType": "Btw-type voor facturen zonder btw",
+        "zeroVatTypeHelp": "Hoe Fiken een verkoop zonder btw boekt. Een bedrijf dat niet btw-geregistreerd is, hoeft hier niets te kiezen. Een geregistreerd bedrijf moet kiezen voordat zo'n factuur wordt verzonden; vraag je boekhouder welke van toepassing is.",
+        "vatNone": "Geen (geen btw-behandeling)",
+        "vatExempt": "Vrijgesteld (fritatt)",
+        "vatOutside": "Buiten het btw-gebied (utenfor)",
+        "vatExport": "Vrijgesteld, export",
+        "vatReverse": "Vrijgesteld, verlegd",
+        "zeroAccount": "Omzetrekening voor facturen zonder btw",
+        "zeroAccountHelp": "Laat leeg om de rekeningen voor arbeid en onderdelen hierboven te gebruiken. Weigert Fiken de rekening met het btw-type, dan staat de reden in het activiteitenlogboek.",
+        "pushPayments": "Betalingen in Fiken registreren",
+        "paymentAccount": "Betalingen storten op",
+        "paymentAccountHelp": "Laat leeg om de eerste bankrekening van het bedrijf te gebruiken.",
+        "manualPaidAsPayment": "Een betaling registreren als een factuur handmatig als betaald wordt gemarkeerd",
+        "manualPaidAsPaymentHelp": "Een factuur die in Torqvoice zonder betalingsregistratie als betaald is gemarkeerd, krijgt in Fiken één betaling voor wat daar nog openstaat. Uit blijft hij daar open tot er een betaling wordt geregistreerd.",
+        "pullPayments": "In Fiken geregistreerde betalingen terughalen",
+        "pullPaymentsHelp": "Elke 30 minuten. Een betaling die in Fiken op een factuur van Torqvoice is ontvangen, wordt hier op de factuur geregistreerd."
+      }
+    },
     "nhtsa": {
       "description": "Vul een voertuig in vanuit het VIN en bekijk terugroepacties, klachten van eigenaren en botsproefscores per modeljaar, van de Amerikaanse verkeersveiligheidsautoriteit. Gratis, zonder sleutel.",
       "fields": {},

+ 2 - 0
messages/nl/pdf.json

@@ -20,6 +20,8 @@
     "type": "Type: {type}",
     "tech": "Monteur: {tech}",
     "parts": "Onderdelen",
+    "otherParts": "Overige onderdelen",
+    "groupSubtotal": "Subtotaal {group}",
     "partNumber": "Onderdeelnr.",
     "description": "Omschrijving",
     "qty": "Aantal",

+ 40 - 0
messages/nl/quotes.json

@@ -36,6 +36,45 @@
     "selectVehicle": "Selecteer een voertuig",
     "convertTitle": "Offerte omzetten naar werkorder",
     "convertDescription": "Er wordt een nieuw servicerecord (werkorder) aangemaakt op basis van deze offerte. Selecteer een voertuig voor de werkorder.",
+    "convertChoiceDescription": "Selecteer een voertuig en maak van deze offerte een nieuwe werkorder of voeg haar toe aan een werkorder die al openstaat.",
+    "convertNew": "Een nieuwe werkorder aanmaken",
+    "convertExisting": "Toevoegen aan een bestaande werkorder",
+    "convertExistingHint": "De onderdelen, het arbeidsloon en de bestanden van de offerte worden aan de werkorder toegevoegd. Wat er al op staat, blijft ongewijzigd.",
+    "convertNoOpenWorkOrders": "Dit voertuig heeft geen openstaande werkorders.",
+    "convertSelectWorkOrder": "Selecteer een werkorder",
+    "convertAdd": "Toevoegen aan werkorder",
+    "convertConflicts": {
+      "heading": "Bepaal voor het toevoegen wat de werkorder behoudt",
+      "checking": "Offerte wordt vergeleken met de werkorder...",
+      "unanswered": "Kies wat de werkorder behoudt voordat de offerte wordt toegevoegd.",
+      "discount": {
+        "title": "Korting",
+        "summary": "De werkorder heeft {percent}% korting, nu {jobAmount}. De offerte bevat een korting van {quoteAmount}.",
+        "keepJob": "{percent}% korting behouden en de {quoteAmount} van de offerte laten vervallen",
+        "combine": "{percent}% omzetten naar {jobAmount} en de {quoteAmount} van de offerte erbij, {total} in totaal"
+      },
+      "tax": {
+        "title": "Btw",
+        "summary": "De werkorder is geprijsd met {job}. De offerte was geprijsd met {quote}.",
+        "keepJob": "Btw van de werkorder behouden. De offerteregels komen over voor de geoffreerde bedragen.",
+        "useQuote": "Werkorder omzetten naar de btw van de offerte. Alles erop wordt opnieuw getotaliseerd.",
+        "inclusive": "{rate}% btw inbegrepen in de regels",
+        "exclusive": "{rate}% btw erbovenop",
+        "components": "{rate}% btw ({names})"
+      },
+      "warranty": {
+        "title": "Garantie",
+        "summary": "De werkorder vermeldt {job}. De offerte die de klant heeft geaccepteerd vermeldt {quote}.",
+        "keepJob": "Garantie van de werkorder behouden",
+        "useQuote": "Garantie van de offerte gebruiken, zoals de klant die heeft geaccepteerd",
+        "none": "niets over garantie",
+        "notIncluded": "geen werkplaatsgarantie",
+        "included": "garantie inbegrepen",
+        "includedWithTerms": "garantie inbegrepen, {terms}",
+        "months": "{count, plural, one {# maand} other {# maanden}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Omzetten",
     "convertToWorkOrder": "Omzetten naar werkorder",
     "cancel": "Annuleren",
@@ -68,6 +107,7 @@
     "title": "Onderdelen",
     "addPart": "Onderdeel toevoegen",
     "partNumber": "Onderdeelnr.",
+    "category": "Categorie",
     "name": "Naam",
     "namePlaceholder": "Naam *",
     "qty": "Aantal",

+ 2 - 0
messages/nl/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Uit voorraad",
     "addPart": "Onderdeel toevoegen",
     "partNumber": "Onderdeel #",
+    "category": "Categorie",
     "name": "Naam",
     "qty": "Hoeveelheid",
     "unitCost": "Inkoop",
@@ -666,6 +667,7 @@
     },
     "moreStatuses": "Meer statussen",
     "fromInspection": "Uit inspectie {name}, {date}",
+    "fromQuote": "Uit offerte {ref}",
     "startInspection": "Inspectie starten"
   },
   "photoHandoff": {

+ 32 - 62
messages/nl/settings.json

@@ -348,6 +348,14 @@
     "deleteWorkshopConfirmPrompt": "Typ <bold>{name}</bold> om te bevestigen.",
     "deleteWorkshopSuccess": "Werkplaats verwijderd",
     "deleteWorkshopFailed": "Kan de werkplaats niet verwijderen",
+    "reauthPasswordLabel": "Uw wachtwoord",
+    "reauthTotpLabel": "Code uit uw authenticatie-app",
+    "reauthFreshSessionHint": "Uw account heeft geen wachtwoord, dus dit werkt alleen binnen {minutes} minuten na het inloggen. Is het langer geleden, log dan eerst uit en weer in.",
+    "reauthWrongPassword": "Onjuist wachtwoord.",
+    "reauthWrongCode": "Onjuiste code.",
+    "reauthTooManyAttempts": "Te veel pogingen. Wacht een paar minuten en probeer het opnieuw.",
+    "reauthSignInAgainAccount": "Log opnieuw in voordat u uw account verwijdert.",
+    "reauthSignInAgainWorkshop": "Log opnieuw in voordat u de werkplaats verwijdert.",
     "deleteAccountMoved": "Wilt u uw account verwijderen?",
     "deleteAccountMovedLink": "Dat kan in de gegevensinstellingen.",
     "signatureTitle": "Handtekening",
@@ -758,12 +766,14 @@
       "next_test_due": "Volgende keuring",
       "defect_notes": "Opmerkingen",
       "defect_photos": "Foto's",
+      "no_defects_note": "Melding wanneer er geen gebreken zijn",
       "passed_checks": "Geslaagde controles",
       "not_applicable_checks": "Niet-toepasselijke controles",
       "check_notes": "Kolom opmerkingen",
       "result_detail": "Toelichting",
       "result_summary": "Aantal per beoordeling",
       "combined_table": "Eén tabel voor alle secties",
+      "ungraded_checks": "Nog niet beoordeelde controles",
       "inspector_line": "Handtekeningregel",
       "inspector_name": "Naam van de ondertekenaar",
       "date_line": "Datumregel",
@@ -1079,64 +1089,6 @@
     "applyTaxNoRecords": "Geen records hebben het standaard belastingtarief nodig.",
     "saveSettings": "Instellingen opslaan",
     "saved": "Instellingen opgeslagen",
-    "currencies": {
-      "USD": "Amerikaanse dollar",
-      "EUR": "Euro",
-      "GBP": "Brits pond",
-      "NOK": "Noorse kroon",
-      "SEK": "Zweedse kroon",
-      "DKK": "Deense kroon",
-      "CHF": "Zwitserse frank",
-      "CAD": "Canadese dollar",
-      "AUD": "Australische dollar",
-      "NZD": "Nieuw-Zeelandse dollar",
-      "JPY": "Japanse yen",
-      "CNY": "Chinese yuan",
-      "INR": "Indiase roepie",
-      "BRL": "Braziliaanse real",
-      "MXN": "Mexicaanse peso",
-      "PLN": "Poolse zloty",
-      "CZK": "Tsjechische kroon",
-      "HUF": "Hongaarse forint",
-      "TRY": "Turkse lira",
-      "ZAR": "Zuid-Afrikaanse rand",
-      "KRW": "Zuid-Koreaanse won",
-      "SGD": "Singaporese dollar",
-      "HKD": "Hongkongse dollar",
-      "THB": "Thaise baht",
-      "ISK": "IJslandse kroon",
-      "RON": "Roemeense leu",
-      "ILS": "Israëlische sjekel",
-      "PHP": "Filipijnse peso",
-      "IDR": "Indonesische roepia",
-      "MYR": "Maleisische ringgit",
-      "LKR": "Srilankaanse roepie",
-      "AED": "VAE-dirham",
-      "SAR": "Saoedische riyal",
-      "RUB": "Russische roebel",
-      "TWD": "Taiwanese dollar",
-      "VND": "Vietnamese dong",
-      "PKR": "Pakistaanse roepie",
-      "BDT": "Bengaalse taka",
-      "NPR": "Nepalese roepie",
-      "BGN": "Bulgaarse lev",
-      "UAH": "Oekraïense hryvnia",
-      "CLP": "Chileense peso",
-      "COP": "Colombiaanse peso",
-      "ARS": "Argentijnse peso",
-      "PEN": "Peruaanse sol",
-      "EGP": "Egyptisch pond",
-      "NGN": "Nigeriaanse naira",
-      "KES": "Keniaanse shilling",
-      "MAD": "Marokkaanse dirham",
-      "DZD": "Algerijnse dinar",
-      "GHS": "Ghanese cedi",
-      "QAR": "Qatarese rial",
-      "KWD": "Koeweitse dinar",
-      "BHD": "Bahreinse dinar",
-      "OMR": "Omaanse rial",
-      "JOD": "Jordaanse dinar"
-    },
     "formatTitle": "Valutaformaat",
     "formatSymbolName": "Symbool",
     "formatCodeName": "Code",
@@ -1697,9 +1649,18 @@
     "upgradeTitle": "Plan upgraden",
     "upgradeDescription": "Ontgrendel meer functies door uw plan te upgraden.",
     "upgradeToProDescription": "Upgrade om onbeperkte klanten, teamleden, rapporten, SMTP, API-toegang en meer te ontgrendelen.",
-    "upgradeToEnterpriseDescription": "Meer dan 5 teamleden nodig? Upgrade naar Enterprise voor maximaal 50 gebruikers.",
-    "upgradeToPro": "Upgraden naar Pro — $99/jaar",
-    "upgradeToEnterprise": "Enterprise — $140/jaar",
+    "upgradeToEnterpriseDescription": "Meer dan één werkplaats? Enterprise dekt tot 10 werkplaatsen en 50 teamleden.",
+    "upgradeToPro": "Upgraden naar Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Factureringsperiode",
+    "billingMonthly": "Maandelijks",
+    "billingAnnual": "Jaarlijks",
+    "billingSave": "Bespaar {percent}%",
+    "billingMonthsFree": "{months, plural, one {# maand gratis} other {# maanden gratis}}",
+    "billedAnnually": "{amount} jaarlijks gefactureerd",
+    "discountBadge": "{value} korting",
+    "discountOnce": "Eerste betaling, daarna {amount}",
+    "discountRepeating": "{months, plural, one {Eerste maand} other {Eerste # maanden}}, daarna {amount}",
     "upgradeDialogTitle": "Upgraden naar Enterprise",
     "upgradeDialogDescription": "Uw huidige Pro-plan wordt geüpgraded naar Enterprise. U wordt alleen het evenredige verschil voor de resterende factureringsperiode in rekening gebracht.",
     "upgradeConfirm": "Upgrade bevestigen",
@@ -2086,6 +2047,10 @@
     "itemsTableHint": "Eén genummerde lijst van alle regels van de klus, eerst arbeid en dan onderdelen, in plaats van de aparte tabellen.",
     "discardChanges": "Wijzigingen verwerpen",
     "tablesExclusiveHint": "Het blad drukt één opstelling af: één genummerde lijst, of aparte tabellen voor onderdelen en arbeid. De ene inschakelen schakelt de andere uit.",
+    "groupLines": "Regels groeperen",
+    "groupLinesOff": "Uit",
+    "groupLinesCategory": "Per categorie",
+    "groupLinesHint": "Per categorie drukt de onderdelen af onder een kop per categorie met een subtotaal per groep, met arbeid eerst in de gecombineerde lijst. Onderdelen zonder categorie worden opgeteld als \"Overige onderdelen\".",
     "innerPadding": "Binnenruimte",
     "totalsWidth": "Breedte",
     "totalsWidthHint": "Leeg vult de hele rij. Met een waarde in punten hangt het blok rechts; in een kolom neemt het de breedte van de kolom.",
@@ -2174,6 +2139,8 @@
       "itemLabor": "Voorremmen vervangen",
       "itemDisc": "Remschijf, voor",
       "itemPads": "Remblokset, vooras",
+      "categoryBrakes": "Remmen",
+      "categoryConsumables": "Werkplaatsbenodigdheden",
       "itemConsumables": "Verbruiksmateriaal",
       "unitPcs": "st",
       "unitSet": "set",
@@ -2200,6 +2167,7 @@
       "sectionLighting": "Verlichting",
       "checkHeadlamps": "Staat van de koplampen",
       "checkFogLamp": "Mistachterlicht",
+      "checkStopLamps": "Remlichten",
       "photoFront": "Voorkant",
       "photoOdometer": "Kilometerteller",
       "concernNoise": "Schurend geluid voor bij het remmen",
@@ -2272,7 +2240,9 @@
     "conditionMapViewsAll": "Alle vijf",
     "conditionMapViewsTop": "Alleen bovenaanzicht",
     "conditionMapViewsSides": "Alleen de zijkanten",
-    "conditionMapWidthHint": "Leeg vult de rij. Geef punten op om de tekening kleiner te maken; hij staat dan waar de uitlijning hem zet en de legenda houdt de volle breedte."
+    "conditionMapWidthHint": "Leeg vult de rij. Geef punten op om de tekening kleiner te maken; hij staat dan waar de uitlijning hem zet en de legenda houdt de volle breedte.",
+    "conditionMapDense": "Compacte tabel",
+    "conditionMapDenseHint": "Zet de rijen van de tabel onder de tekening dichter op elkaar, zodat die minder ruimte op de pagina inneemt."
   },
   "preview": {
     "title": "Remservice en oliewissel",

+ 15 - 0
messages/nl/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Toepassen",
     "allTechnicians": "Alle monteurs",
     "export": "CSV exporteren",
+    "print": "Urenstaat afdrukken",
+    "client": "Klant",
+    "noClient": "Geen klant",
+    "signature": "Handtekening",
+    "sortBy": "Sorteren op",
+    "sort": {
+      "default": "Monteur / dag",
+      "client": "Klant",
+      "vehicle": "Voertuig",
+      "clientVehicle": "Klant, daarna voertuig"
+    },
+    "sortDirection": "Sorteerrichting",
+    "ascending": "Oplopend",
+    "descending": "Aflopend",
     "addEntry": "Registratie toevoegen",
     "stats": {
       "total": "Totale tijd",
@@ -101,6 +115,7 @@
       "duration": "Duur",
       "hours": "Uren",
       "job": "Klus",
+      "client": "Klant",
       "vehicle": "Voertuig",
       "plate": "Kenteken",
       "source": "Bron",

+ 3 - 0
messages/pl/audit.json

@@ -188,6 +188,7 @@
     "signature_remove": "Usunięto podpis",
     "conditionMark_add": "Dodano oznaczenie stanu",
     "conditionMark_remove": "Usunięto oznaczenie stanu",
+    "conditionMark_carry": "Potwierdzono wcześniejsze oznaczenia stanu",
     "conditionMark_onInvoice": "Mapa stanu na fakturze",
     "settings_markType_create": "Dodano rodzaj oznaczenia",
     "settings_markType_update": "Zmieniono rodzaj oznaczenia",
@@ -234,6 +235,7 @@
     "payment_delete": "Usunięto płatność {id}",
     "quoteRequest_update": "Zmieniono status zapytania o wycenę na {status, select, pending {oczekujące} approved {zatwierdzone} rejected {odrzucone} completed {zakończone} other {{status}}}",
     "quote_convert": "Przekształcono wycenę {ref} w zlecenie {serviceRecordId}",
+    "quote_convert_existing": "Dodano wycenę {ref} do istniejącego zlecenia {jobRef}",
     "quote_create": "Utworzono wycenę {ref}",
     "quote_delete": "Usunięto wycenę {ref}",
     "quote_status": "Zmieniono status wyceny na {status, select, draft {szkic} sent {wysłana} accepted {zaakceptowana} rejected {odrzucona} expired {wygasła} converted {przekształcona} other {{status}}}",
@@ -342,6 +344,7 @@
     "inspection_phoneUpload": "Dodano plik z telefonu do przeglądu {ref}",
     "condition_mark_add": "Dodano oznaczenie do mapy stanu",
     "condition_mark_remove": "Usunięto oznaczenie z mapy stanu",
+    "condition_mark_carry": "Potwierdzono, że {count, plural, one {# wcześniejsze oznaczenie nadal jest} few {# wcześniejsze oznaczenia nadal są} many {# wcześniejszych oznaczeń nadal jest} other {# wcześniejszego oznaczenia nadal jest}} na mapie stanu",
     "condition_map_on_invoice_on": "Umieszczono mapę stanu na fakturze",
     "condition_map_on_invoice_off": "Usunięto mapę stanu z faktury"
   }

+ 3 - 0
messages/pl/conditionMap.json

@@ -9,6 +9,8 @@
   "previousHint": "Nadal obecne od ostatniego razu, na szaro. Usuń te, których już nie ma.",
   "newSinceLastVisit": "Nowe od ostatniej wizyty",
   "confirmed": "Nadal jest",
+  "confirmAll": "Potwierdź, że wszystkie nadal są",
+  "confirmAllHint": "Zapisuje je na tym arkuszu jako oznaczenia z tej wizyty, w kolorze.",
   "clearRepaired": "Usuń jako naprawione",
   "restore": "Przywróć",
   "edit": "Edytuj oznaczenie",
@@ -38,6 +40,7 @@
   "readOnly": "Otwórz ponownie, aby zmienić mapę stanu.",
   "readOnlyLinked": "Zaznaczone na powiązanym przeglądzie. Zmień je tam.",
   "readOnlyEarlier": "Zapisane podczas wcześniejszej wizyty. Oznacz jako naprawione, gdy uszkodzenia już nie ma.",
+  "readOnlyCarried": "Potwierdzone ponownie podczas późniejszej wizyty. Pozostaje tutaj jako zapis tej wizyty, a zmienia się tam.",
   "readOnlyOtherCheck": "Zaznaczone w innym punkcie tego przeglądu. Zmień je tam.",
   "count": "{count, plural, =0 {Brak oznaczeń} one {# oznaczenie} few {# oznaczenia} other {# oznaczeń}}",
   "previousCount": "{count, plural, one {# z wcześniejszej wizyty} few {# z wcześniejszych wizyt} other {# z wcześniejszych wizyt}}",

+ 39 - 0
messages/pl/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "Co 30 minut. Płatność przyjęta w QuickBooks za fakturę z Torqvoice zostaje zarejestrowana tutaj na fakturze."
       }
     },
+    "fiken": {
+      "description": "Wysyłaj wystawione faktury, ich klientów i płatności do Fiken i pobieraj płatności zarejestrowane tam.",
+      "tenantHelp": "Utwórz aplikację w Fiken w sekcji Rediger konto, API, dla własnych firm. Wpisz w aplikacji poniższy adres URI przekierowania, a następnie skopiuj tutaj jej identyfikator klienta i sekret klienta. Firma musi mieć moduł API Fiken.",
+      "fields": {
+        "clientId": "Identyfikator klienta",
+        "clientSecret": "Sekret klienta"
+      },
+      "settings": {
+        "companySlug": "Firma w Fiken",
+        "companySlugHelp": "Firma, do której ksiąg trafiają faktury. Twój użytkownik Fiken może mieć dostęp do kilku.",
+        "pushInvoices": "Wysyłaj wystawione faktury do Fiken",
+        "pushOnComplete": "Wysyłaj faktury także wtedy, gdy zlecenie zostanie oznaczone jako ukończone",
+        "pushOnCompleteHelp": "Bez tej opcji faktura trafia do księgowości po wysłaniu, udostępnieniu lub opłaceniu. Z nią ukończone zlecenie trafia od razu i jest aktualizowane, gdy się zmieni.",
+        "startDate": "Tylko faktury z datą od",
+        "startDateHelp": "Zostaw puste, aby wysyłać każdą wystawioną fakturę. Użyj daty przeniesienia księgowości do Fiken, aby starsze faktury nie zostały wprowadzone dwa razy.",
+        "attachPdf": "Dołącz PDF faktury",
+        "attachPdfHelp": "Faktura trafia do Fiken jako sprzedaż (Annet salg) ze swoim numerem z Torqvoice. Po włączeniu PDF, który otrzymał klient, jest zapisywany przy sprzedaży jako dowód księgowy.",
+        "laborAccount": "Konto przychodów dla robocizny",
+        "laborAccountHelp": "Pozostaw puste, aby użyć 3000.",
+        "partsAccount": "Konto przychodów dla części",
+        "partsAccountHelp": "Pozostaw puste, aby użyć 3000.",
+        "zeroVatType": "Typ VAT dla faktur bez VAT",
+        "zeroVatTypeHelp": "Jak Fiken księguje sprzedaż bez VAT. Firma niezarejestrowana jako podatnik VAT nie musi tu niczego wybierać. Zarejestrowana musi wybrać, zanim taka faktura zostanie wysłana; zapytaj księgowego, który typ ma zastosowanie.",
+        "vatNone": "Brak (bez obsługi VAT)",
+        "vatExempt": "Zwolniona (fritatt)",
+        "vatOutside": "Poza zakresem VAT (utenfor)",
+        "vatExport": "Zwolniona, eksport",
+        "vatReverse": "Zwolniona, odwrotne obciążenie",
+        "zeroAccount": "Konto przychodów dla faktur bez VAT",
+        "zeroAccountHelp": "Pozostaw puste, aby użyć powyższych kont robocizny i części. Jeśli Fiken odrzuci konto z tym typem VAT, powód pojawi się w dzienniku aktywności.",
+        "pushPayments": "Rejestruj płatności w Fiken",
+        "paymentAccount": "Wpłacaj płatności na",
+        "paymentAccountHelp": "Pozostaw puste, aby użyć pierwszego konta bankowego firmy.",
+        "manualPaidAsPayment": "Rejestruj płatność, gdy faktura zostanie ręcznie oznaczona jako opłacona",
+        "manualPaidAsPaymentHelp": "Faktura oznaczona w Torqvoice jako opłacona bez wpisu płatności otrzymuje w Fiken jedną płatność na kwotę, która tam nadal figuruje jako należna. Wyłączone: pozostaje tam otwarta do czasu zarejestrowania płatności.",
+        "pullPayments": "Pobieraj płatności zarejestrowane w Fiken",
+        "pullPaymentsHelp": "Co 30 minut. Płatność przyjęta w Fiken za fakturę z Torqvoice zostaje zarejestrowana tutaj na fakturze."
+      }
+    },
     "nhtsa": {
       "description": "Uzupełnij pojazd na podstawie VIN i zobacz akcje serwisowe, skargi właścicieli i oceny z testów zderzeniowych dla każdego rocznika, z amerykańskiego urzędu bezpieczeństwa ruchu. Za darmo, bez klucza.",
       "fields": {},

+ 2 - 0
messages/pl/pdf.json

@@ -20,6 +20,8 @@
     "type": "Typ: {type}",
     "tech": "Technik: {tech}",
     "parts": "Części",
+    "otherParts": "Inne części",
+    "groupSubtotal": "Suma częściowa: {group}",
     "partNumber": "Nr części",
     "description": "Opis",
     "qty": "Ilość",

+ 40 - 0
messages/pl/quotes.json

@@ -36,6 +36,45 @@
     "selectVehicle": "Proszę wybrać pojazd",
     "convertTitle": "Przekonwertuj wycenę na zlecenie",
     "convertDescription": "Zostanie utworzone nowe zlecenie serwisowe na podstawie tej wyceny. Wybierz pojazd dla zlecenia.",
+    "convertChoiceDescription": "Wybierz pojazd, a następnie utwórz nowe zlecenie z tej wyceny lub dodaj ją do zlecenia, które jest już otwarte.",
+    "convertNew": "Utwórz nowe zlecenie",
+    "convertExisting": "Dodaj do istniejącego zlecenia",
+    "convertExistingHint": "Części, robocizna i pliki z wyceny zostaną dodane do zlecenia. To, co już się na nim znajduje, pozostaje bez zmian.",
+    "convertNoOpenWorkOrders": "Ten pojazd nie ma otwartych zleceń.",
+    "convertSelectWorkOrder": "Wybierz zlecenie",
+    "convertAdd": "Dodaj do zlecenia",
+    "convertConflicts": {
+      "heading": "Przed dodaniem zdecyduj, co zachowuje zlecenie",
+      "checking": "Porównywanie oferty ze zleceniem...",
+      "unanswered": "Wybierz, co zachowuje zlecenie, zanim oferta zostanie dodana.",
+      "discount": {
+        "title": "Rabat",
+        "summary": "Zlecenie ma rabat {percent}%, obecnie {jobAmount}. Oferta zawiera rabat {quoteAmount}.",
+        "keepJob": "Zachowaj rabat {percent}% i odrzuć {quoteAmount} z oferty",
+        "combine": "Zamień {percent}% na {jobAmount} i dodaj {quoteAmount} z oferty, łącznie {total}"
+      },
+      "tax": {
+        "title": "Podatek",
+        "summary": "Zlecenie jest wycenione z {job}. Oferta została wyceniona z {quote}.",
+        "keepJob": "Zachowaj podatek zlecenia. Pozycje z oferty przechodzą w zaoferowanych kwotach.",
+        "useQuote": "Przełącz zlecenie na podatek z oferty. Wszystko na nim zostanie przeliczone.",
+        "inclusive": "{rate}% podatku wliczone w pozycje",
+        "exclusive": "{rate}% podatku doliczane",
+        "components": "{rate}% podatku ({names})"
+      },
+      "warranty": {
+        "title": "Gwarancja",
+        "summary": "Zlecenie mówi {job}. Oferta przyjęta przez klienta mówi {quote}.",
+        "keepJob": "Zachowaj gwarancję zlecenia",
+        "useQuote": "Użyj gwarancji z oferty, tak jak przyjął ją klient",
+        "none": "nic o gwarancji",
+        "notIncluded": "brak gwarancji warsztatu",
+        "included": "gwarancja wliczona",
+        "includedWithTerms": "gwarancja wliczona, {terms}",
+        "months": "{count, plural, one {# miesiąc} few {# miesiące} other {# miesięcy}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Przekonwertuj",
     "convertToWorkOrder": "Przekonwertuj na zlecenie",
     "cancel": "Anuluj",
@@ -68,6 +107,7 @@
     "title": "Części",
     "addPart": "Dodaj część",
     "partNumber": "Nr części",
+    "category": "Kategoria",
     "name": "Nazwa",
     "namePlaceholder": "Nazwa *",
     "qty": "Ilość",

+ 2 - 0
messages/pl/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Z Magazynu",
     "addPart": "Dodaj Część",
     "partNumber": "Nr Części",
+    "category": "Kategoria",
     "name": "Nazwa",
     "qty": "Ilość",
     "unitCost": "Koszt",
@@ -666,6 +667,7 @@
     },
     "moreStatuses": "Więcej statusów",
     "fromInspection": "Z przeglądu {name}, {date}",
+    "fromQuote": "Z wyceny {ref}",
     "startInspection": "Rozpocznij przegląd"
   },
   "photoHandoff": {

+ 32 - 62
messages/pl/settings.json

@@ -348,6 +348,14 @@
     "deleteWorkshopConfirmPrompt": "Wpisz <bold>{name}</bold>, aby potwierdzić.",
     "deleteWorkshopSuccess": "Warsztat usunięty",
     "deleteWorkshopFailed": "Nie udało się usunąć warsztatu",
+    "reauthPasswordLabel": "Twoje hasło",
+    "reauthTotpLabel": "Kod z aplikacji uwierzytelniającej",
+    "reauthFreshSessionHint": "Twoje konto nie ma hasła, więc ta operacja działa tylko w ciągu {minutes} minut od zalogowania. Jeśli minęło więcej czasu, najpierw wyloguj się i zaloguj ponownie.",
+    "reauthWrongPassword": "Nieprawidłowe hasło.",
+    "reauthWrongCode": "Nieprawidłowy kod.",
+    "reauthTooManyAttempts": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.",
+    "reauthSignInAgainAccount": "Zaloguj się ponownie, zanim usuniesz konto.",
+    "reauthSignInAgainWorkshop": "Zaloguj się ponownie, zanim usuniesz warsztat.",
     "deleteAccountMoved": "Chcesz usunąć swoje konto?",
     "deleteAccountMovedLink": "Możesz to zrobić w ustawieniach danych.",
     "signatureTitle": "Podpis",
@@ -758,12 +766,14 @@
       "next_test_due": "Następne badanie",
       "defect_notes": "Uwagi",
       "defect_photos": "Zdjęcia",
+      "no_defects_note": "Adnotacja, gdy nie ma usterek",
       "passed_checks": "Pozycje zaliczone",
       "not_applicable_checks": "Pozycje nie dotyczy",
       "check_notes": "Kolumna uwag",
       "result_detail": "Objaśnienie",
       "result_summary": "Liczba według oceny",
       "combined_table": "Jedna tabela dla wszystkich sekcji",
+      "ungraded_checks": "Pozycje jeszcze nieocenione",
       "inspector_line": "Linia podpisu",
       "inspector_name": "Imię i nazwisko podpisującego",
       "date_line": "Linia daty",
@@ -1079,64 +1089,6 @@
     "applyTaxNoRecords": "Żadne rekordy nie wymagają domyślnej stawki podatku.",
     "saveSettings": "Zapisz ustawienia",
     "saved": "Ustawienia zapisane",
-    "currencies": {
-      "USD": "Dolar amerykanski",
-      "EUR": "Euro",
-      "GBP": "Funt brytyjski",
-      "NOK": "Korona norweska",
-      "SEK": "Korona szwedzka",
-      "DKK": "Korona dunska",
-      "CHF": "Frank szwajcarski",
-      "CAD": "Dolar kanadyjski",
-      "AUD": "Dolar australijski",
-      "NZD": "Dolar nowozelandzki",
-      "JPY": "Jen japonski",
-      "CNY": "Juan chinski",
-      "INR": "Rupia indyjska",
-      "BRL": "Real brazylijski",
-      "MXN": "Peso meksykanskie",
-      "PLN": "Zloty polski",
-      "CZK": "Korona czeska",
-      "HUF": "Forint wegierski",
-      "TRY": "Lira turecka",
-      "ZAR": "Rand poludniowoafrykanski",
-      "KRW": "Won poludniowokoreanskii",
-      "SGD": "Dolar singapurski",
-      "HKD": "Dolar hongkonski",
-      "THB": "Baht tajski",
-      "ISK": "Korona islandzka",
-      "RON": "Lej rumunski",
-      "ILS": "Szekel izraelski",
-      "PHP": "Peso filipinskie",
-      "IDR": "Rupia indonezyjska",
-      "MYR": "Ringgit malezyjski",
-      "LKR": "Rupia lankijska",
-      "AED": "Dirham ZEA",
-      "SAR": "Rial saudyjski",
-      "RUB": "Rubel rosyjski",
-      "TWD": "Dolar tajwanski",
-      "VND": "Dong wietnamski",
-      "PKR": "Rupia pakistanska",
-      "BDT": "Taka bangladeska",
-      "NPR": "Rupia nepalska",
-      "BGN": "Lew bulgarski",
-      "UAH": "Hrywna ukrainska",
-      "CLP": "Peso chilijskie",
-      "COP": "Peso kolumbijskie",
-      "ARS": "Peso argentynskie",
-      "PEN": "Sol peruwianski",
-      "EGP": "Funt egipski",
-      "NGN": "Naira nigeryjska",
-      "KES": "Szyling kenijski",
-      "MAD": "Dirham marokanski",
-      "DZD": "Dinar algierski",
-      "GHS": "Cedi ghanskie",
-      "QAR": "Rial katarski",
-      "KWD": "Dinar kuwejcki",
-      "BHD": "Dinar bahrajnski",
-      "OMR": "Rial omanski",
-      "JOD": "Dinar jordanski"
-    },
     "formatTitle": "Format waluty",
     "formatSymbolName": "Symbol",
     "formatCodeName": "Kod",
@@ -1697,9 +1649,18 @@
     "upgradeTitle": "Ulepsz plan",
     "upgradeDescription": "Odblokuj więcej funkcji, ulepszając swój plan.",
     "upgradeToProDescription": "Ulepsz, aby odblokować nieograniczoną liczbę klientów, członków zespołu, raportów, SMTP, dostęp API i więcej.",
-    "upgradeToEnterpriseDescription": "Potrzebujesz więcej niż 5 członków zespołu? Przejdź na Enterprise dla maksymalnie 50 użytkowników.",
-    "upgradeToPro": "Przejdź na Pro — $99/rok",
-    "upgradeToEnterprise": "Enterprise — $140/rok",
+    "upgradeToEnterpriseDescription": "Prowadzisz więcej niż jeden warsztat? Enterprise obejmuje do 10 warsztatów i 50 członków zespołu.",
+    "upgradeToPro": "Przejdź na Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Okres rozliczeniowy",
+    "billingMonthly": "Miesięcznie",
+    "billingAnnual": "Rocznie",
+    "billingSave": "Oszczędź {percent}%",
+    "billingMonthsFree": "{months, plural, one {# miesiąc gratis} few {# miesiące gratis} many {# miesięcy gratis} other {# miesiąca gratis}}",
+    "billedAnnually": "{amount} rozliczane rocznie",
+    "discountBadge": "{value} zniżki",
+    "discountOnce": "Pierwsza płatność, potem {amount}",
+    "discountRepeating": "Przez pierwsze {months} mies., potem {amount}",
     "upgradeDialogTitle": "Przejdź na Enterprise",
     "upgradeDialogDescription": "Twój aktualny plan Pro zostanie ulepszony do Enterprise. Zostaniesz obciążony jedynie proporcjonalną różnicą za pozostały okres rozliczeniowy.",
     "upgradeConfirm": "Potwierdź ulepszenie",
@@ -2086,6 +2047,10 @@
     "itemsTableHint": "Jedna numerowana lista wszystkich pozycji zlecenia, najpierw robocizna, potem części, zamiast osobnych tabel.",
     "discardChanges": "Odrzuć zmiany",
     "tablesExclusiveHint": "Arkusz drukuje jeden układ: jedną numerowaną listę albo osobne tabele części i robocizny. Włączenie jednego wyłącza drugi.",
+    "groupLines": "Grupuj pozycje",
+    "groupLinesOff": "Wył.",
+    "groupLinesCategory": "Według kategorii",
+    "groupLinesHint": "Według kategorii drukuje części pod nagłówkiem każdej kategorii z osobną sumą częściową, a w połączonej liście najpierw robociznę. Części bez kategorii są sumowane jako „Inne części”.",
     "innerPadding": "Odstęp wewnętrzny",
     "totalsWidth": "Szerokość",
     "totalsWidthHint": "Puste wypełnia cały wiersz. Wartość w punktach zawiesza blok po prawej; w kolumnie przyjmuje szerokość kolumny.",
@@ -2174,6 +2139,8 @@
       "itemLabor": "Wymiana hamulców przednich",
       "itemDisc": "Tarcza hamulcowa, przód",
       "itemPads": "Komplet klocków, oś przednia",
+      "categoryBrakes": "Hamulce",
+      "categoryConsumables": "Materiały warsztatowe",
       "itemConsumables": "Materiały eksploatacyjne",
       "unitPcs": "szt.",
       "unitSet": "kpl.",
@@ -2200,6 +2167,7 @@
       "sectionLighting": "Oświetlenie",
       "checkHeadlamps": "Stan reflektorów",
       "checkFogLamp": "Tylne światło przeciwmgłowe",
+      "checkStopLamps": "Światła stopu",
       "photoFront": "Przód",
       "photoOdometer": "Licznik",
       "concernNoise": "Zgrzytanie z przodu przy hamowaniu",
@@ -2272,7 +2240,9 @@
     "conditionMapViewsAll": "Wszystkie pięć",
     "conditionMapViewsTop": "Tylko z góry",
     "conditionMapViewsSides": "Tylko boki",
-    "conditionMapWidthHint": "Puste wypełnia wiersz. Podaj punkty, aby zmniejszyć rysunek; ustawi się według wyrównania, a legenda zachowa pełną szerokość."
+    "conditionMapWidthHint": "Puste wypełnia wiersz. Podaj punkty, aby zmniejszyć rysunek; ustawi się według wyrównania, a legenda zachowa pełną szerokość.",
+    "conditionMapDense": "Zwarta tabela",
+    "conditionMapDenseHint": "Zmniejsza odstępy między wierszami tabeli pod rysunkiem, dzięki czemu zajmuje ona mniej miejsca na stronie."
   },
   "preview": {
     "title": "Serwis hamulców i wymiana oleju",

+ 15 - 0
messages/pl/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Zastosuj",
     "allTechnicians": "Wszyscy technicy",
     "export": "Eksportuj CSV",
+    "print": "Drukuj ewidencję czasu",
+    "client": "Klient",
+    "noClient": "Brak klienta",
+    "signature": "Podpis",
+    "sortBy": "Sortuj według",
+    "sort": {
+      "default": "Technik / dzień",
+      "client": "Klient",
+      "vehicle": "Pojazd",
+      "clientVehicle": "Klient, następnie pojazd"
+    },
+    "sortDirection": "Kierunek sortowania",
+    "ascending": "Rosnąco",
+    "descending": "Malejąco",
     "addEntry": "Dodaj wpis",
     "stats": {
       "total": "Łączny czas",
@@ -101,6 +115,7 @@
       "duration": "Czas trwania",
       "hours": "Godziny",
       "job": "Zlecenie",
+      "client": "Klient",
       "vehicle": "Pojazd",
       "plate": "Rejestracja",
       "source": "Źródło",

+ 3 - 0
messages/pt-BR/audit.json

@@ -188,6 +188,7 @@
     "signature_remove": "Removeu a assinatura",
     "conditionMark_add": "Marca de estado adicionada",
     "conditionMark_remove": "Marca de estado removida",
+    "conditionMark_carry": "Confirmou marcas de estado anteriores",
     "conditionMark_onInvoice": "Mapa de estado na fatura",
     "settings_markType_create": "Tipo de marca adicionado",
     "settings_markType_update": "Tipo de marca alterado",
@@ -234,6 +235,7 @@
     "payment_delete": "Pagamento {id} excluído",
     "quoteRequest_update": "Status da solicitação de orçamento alterado para {status, select, pending {pendente} approved {aprovada} rejected {recusada} completed {concluída} other {{status}}}",
     "quote_convert": "Orçamento {ref} convertido no serviço {serviceRecordId}",
+    "quote_convert_existing": "Orçamento {ref} adicionado à ordem de serviço existente {jobRef}",
     "quote_create": "Orçamento {ref} criado",
     "quote_delete": "Orçamento {ref} excluído",
     "quote_status": "Status do orçamento alterado para {status, select, draft {rascunho} sent {enviado} accepted {aceito} rejected {recusado} expired {vencido} converted {convertido} other {{status}}}",
@@ -342,6 +344,7 @@
     "inspection_phoneUpload": "Adicionou um arquivo pelo celular à inspeção {ref}",
     "condition_mark_add": "Adicionou uma marca ao mapa de estado",
     "condition_mark_remove": "Removeu uma marca do mapa de estado",
+    "condition_mark_carry": "Confirmou {count, plural, one {# marca anterior como ainda presente} other {# marcas anteriores como ainda presentes}} no mapa de estado",
     "condition_map_on_invoice_on": "Colocou o mapa de estado na fatura",
     "condition_map_on_invoice_off": "Retirou o mapa de estado da fatura"
   }

+ 3 - 0
messages/pt-BR/conditionMap.json

@@ -9,6 +9,8 @@
   "previousHint": "Ainda presente desde a última vez, em cinza. Apague as que não existem mais.",
   "newSinceLastVisit": "Novo desde a última visita",
   "confirmed": "Ainda presente",
+  "confirmAll": "Confirmar todas como ainda presentes",
+  "confirmAllHint": "Registra todas nesta ficha como marcas desta visita, em cores.",
   "clearRepaired": "Apagar como reparado",
   "restore": "Restaurar",
   "edit": "Editar marca",
@@ -38,6 +40,7 @@
   "readOnly": "Reabra para alterar o mapa de estado.",
   "readOnlyLinked": "Marcada na inspeção vinculada. Altere lá.",
   "readOnlyEarlier": "Registrada em uma visita anterior. Marque como reparada quando não existir mais.",
+  "readOnlyCarried": "Confirmada novamente em uma visita posterior. Fica aqui como registro desta visita e é alterada lá.",
   "readOnlyOtherCheck": "Marcada em outro item desta inspeção. Altere lá.",
   "count": "{count, plural, =0 {Sem marcas} one {# marca} other {# marcas}}",
   "previousCount": "{count, plural, one {# de uma visita anterior} other {# de visitas anteriores}}",

+ 39 - 0
messages/pt-BR/integrations.json

@@ -573,6 +573,45 @@
         "pullPaymentsHelp": "A cada 30 minutos. Um pagamento recebido no QuickBooks para uma fatura do Torqvoice é registrado aqui na fatura."
       }
     },
+    "fiken": {
+      "description": "Envie faturas emitidas, seus clientes e seus pagamentos para o Fiken e traga de volta os pagamentos registrados lá.",
+      "tenantHelp": "Crie um app no Fiken em Rediger konto, API, para as suas próprias empresas. Informe no app a URI de redirecionamento abaixo e copie para cá o ID do cliente e o segredo do cliente. A empresa precisa do módulo de API do Fiken.",
+      "fields": {
+        "clientId": "ID do cliente",
+        "clientSecret": "Segredo do cliente"
+      },
+      "settings": {
+        "companySlug": "Empresa no Fiken",
+        "companySlugHelp": "A empresa em cuja contabilidade as faturas entram. Seu usuário do Fiken pode ter acesso a várias.",
+        "pushInvoices": "Enviar faturas emitidas para o Fiken",
+        "pushOnComplete": "Enviar faturas também quando o serviço for marcado como concluído",
+        "pushOnCompleteHelp": "Sem isso, uma fatura vai quando é enviada, compartilhada ou paga. Com isso, um serviço concluído vai na hora e é atualizado se mudar.",
+        "startDate": "Somente faturas com data a partir de",
+        "startDateHelp": "Deixe vazio para enviar toda fatura emitida. Use a data em que sua contabilidade passou para o Fiken para não lançar faturas antigas duas vezes.",
+        "attachPdf": "Anexar o PDF da fatura",
+        "attachPdfHelp": "Uma fatura entra no Fiken como uma venda (Annet salg) com o seu número do Torqvoice. Com isto ativado, o PDF que o cliente recebeu é arquivado na venda como comprovante.",
+        "laborAccount": "Conta de receita para mão de obra",
+        "laborAccountHelp": "Deixe vazio para usar a 3000.",
+        "partsAccount": "Conta de receita para peças",
+        "partsAccountHelp": "Deixe vazio para usar a 3000.",
+        "zeroVatType": "Tipo de IVA para faturas sem IVA",
+        "zeroVatTypeHelp": "Como o Fiken contabiliza uma venda sem IVA. Uma empresa não registrada para IVA não precisa escolher nada aqui. Uma registrada precisa escolher antes que uma fatura assim seja enviada; pergunte ao seu contador qual se aplica.",
+        "vatNone": "Nenhum (sem tratamento de IVA)",
+        "vatExempt": "Isento (fritatt)",
+        "vatOutside": "Fora do âmbito do IVA (utenfor)",
+        "vatExport": "Isento, exportação",
+        "vatReverse": "Isento, inversão do sujeito passivo",
+        "zeroAccount": "Conta de receita para faturas sem IVA",
+        "zeroAccountHelp": "Deixe vazio para usar as contas de mão de obra e peças acima. Se o Fiken recusar a conta com o tipo de IVA, o motivo aparece no registro de atividade.",
+        "pushPayments": "Registrar pagamentos no Fiken",
+        "paymentAccount": "Depositar pagamentos em",
+        "paymentAccountHelp": "Deixe vazio para usar a primeira conta bancária da empresa.",
+        "manualPaidAsPayment": "Registrar um pagamento quando uma fatura for marcada como paga manualmente",
+        "manualPaidAsPaymentHelp": "Uma fatura marcada como paga no Torqvoice sem lançamento de pagamento recebe no Fiken um pagamento pelo valor que ainda consta em aberto lá. Desligado, ela fica aberta lá até que um pagamento seja registrado.",
+        "pullPayments": "Trazer de volta pagamentos registrados no Fiken",
+        "pullPaymentsHelp": "A cada 30 minutos. Um pagamento recebido no Fiken para uma fatura do Torqvoice é registrado aqui na fatura."
+      }
+    },
     "nhtsa": {
       "description": "Preencha um veículo a partir do VIN e veja recalls, reclamações de proprietários e notas de testes de colisão de cada ano-modelo, da autoridade de segurança dos EUA. Grátis, sem chave.",
       "fields": {},

+ 2 - 0
messages/pt-BR/pdf.json

@@ -20,6 +20,8 @@
     "type": "Tipo: {type}",
     "tech": "Técnico: {tech}",
     "parts": "Peças",
+    "otherParts": "Outras peças",
+    "groupSubtotal": "Subtotal {group}",
     "partNumber": "Peça #",
     "description": "Descrição",
     "qty": "Qtd",

+ 40 - 0
messages/pt-BR/quotes.json

@@ -36,6 +36,45 @@
     "selectVehicle": "Por favor, selecione um veículo",
     "convertTitle": "Converter orçamento em ordem de serviço",
     "convertDescription": "Será criado um novo registro de serviço (ordem de serviço) a partir deste orçamento. Selecione um veículo para a ordem de serviço.",
+    "convertChoiceDescription": "Selecione um veículo e crie uma nova ordem de serviço a partir deste orçamento ou adicione-o a uma que já esteja aberta.",
+    "convertNew": "Criar uma nova ordem de serviço",
+    "convertExisting": "Adicionar a uma ordem de serviço existente",
+    "convertExistingHint": "As peças, a mão de obra e os arquivos do orçamento são adicionados à ordem de serviço. O que já está nela permanece como está.",
+    "convertNoOpenWorkOrders": "Este veículo não tem ordens de serviço abertas.",
+    "convertSelectWorkOrder": "Selecione uma ordem de serviço",
+    "convertAdd": "Adicionar à ordem de serviço",
+    "convertConflicts": {
+      "heading": "Antes de adicionar, decida o que a ordem de serviço mantém",
+      "checking": "Comparando o orçamento com a ordem de serviço...",
+      "unanswered": "Escolha o que a ordem de serviço mantém antes de adicionar o orçamento.",
+      "discount": {
+        "title": "Desconto",
+        "summary": "A ordem de serviço tem {percent}% de desconto, {jobAmount} no momento. O orçamento traz um desconto de {quoteAmount}.",
+        "keepJob": "Manter o desconto de {percent}% e descartar os {quoteAmount} do orçamento",
+        "combine": "Transformar os {percent}% em {jobAmount} e somar os {quoteAmount} do orçamento, {total} no total"
+      },
+      "tax": {
+        "title": "Impostos",
+        "summary": "A ordem de serviço está precificada com {job}. O orçamento foi precificado com {quote}.",
+        "keepJob": "Manter os impostos da ordem de serviço. As linhas do orçamento passam pelos valores orçados.",
+        "useQuote": "Mudar a ordem de serviço para os impostos do orçamento. Tudo nela é recalculado.",
+        "inclusive": "{rate}% de impostos incluídos nas linhas",
+        "exclusive": "{rate}% de impostos acrescidos",
+        "components": "{rate}% de impostos ({names})"
+      },
+      "warranty": {
+        "title": "Garantia",
+        "summary": "A ordem de serviço diz {job}. O orçamento que o cliente aceitou diz {quote}.",
+        "keepJob": "Manter a garantia da ordem de serviço",
+        "useQuote": "Usar a garantia do orçamento, como o cliente aceitou",
+        "none": "nada sobre garantia",
+        "notIncluded": "sem garantia da oficina",
+        "included": "garantia incluída",
+        "includedWithTerms": "garantia incluída, {terms}",
+        "months": "{count, plural, one {# mês} other {# meses}}",
+        "mileage": "{count} {unit}"
+      }
+    },
     "convert": "Converter",
     "convertToWorkOrder": "Converter em ordem de serviço",
     "cancel": "Cancelar",
@@ -68,6 +107,7 @@
     "title": "Peças",
     "addPart": "Adicionar peça",
     "partNumber": "N.º da peça",
+    "category": "Categoria",
     "name": "Nome",
     "namePlaceholder": "Nome *",
     "qty": "Qtd.",

+ 2 - 0
messages/pt-BR/service.json

@@ -122,6 +122,7 @@
     "fromInventory": "Do Inventário",
     "addPart": "Adicionar Peça",
     "partNumber": "Nº da Peça",
+    "category": "Categoria",
     "name": "Nome",
     "qty": "Qtd",
     "unitCost": "Custo",
@@ -666,6 +667,7 @@
     },
     "moreStatuses": "Mais status",
     "fromInspection": "Da inspeção {name}, {date}",
+    "fromQuote": "Do orçamento {ref}",
     "startInspection": "Iniciar inspeção"
   },
   "photoHandoff": {

+ 32 - 62
messages/pt-BR/settings.json

@@ -348,6 +348,14 @@
     "deleteWorkshopConfirmPrompt": "Digite <bold>{name}</bold> para confirmar.",
     "deleteWorkshopSuccess": "Oficina excluída",
     "deleteWorkshopFailed": "Falha ao excluir a oficina",
+    "reauthPasswordLabel": "Sua senha",
+    "reauthTotpLabel": "Código do seu aplicativo autenticador",
+    "reauthFreshSessionHint": "Sua conta não tem senha, então isso só funciona até {minutes} minutos depois de entrar. Se já passou mais tempo, saia e entre novamente primeiro.",
+    "reauthWrongPassword": "Senha incorreta.",
+    "reauthWrongCode": "Código incorreto.",
+    "reauthTooManyAttempts": "Muitas tentativas. Aguarde alguns minutos e tente novamente.",
+    "reauthSignInAgainAccount": "Entre novamente antes de excluir sua conta.",
+    "reauthSignInAgainWorkshop": "Entre novamente antes de excluir a oficina.",
     "deleteAccountMoved": "Quer excluir sua conta?",
     "deleteAccountMovedLink": "Você pode fazer isso nas configurações de dados.",
     "signatureTitle": "Assinatura",
@@ -758,12 +766,14 @@
       "next_test_due": "Próximo teste",
       "defect_notes": "Observações",
       "defect_photos": "Fotos",
+      "no_defects_note": "Aviso quando não há deficiências",
       "passed_checks": "Itens aprovados",
       "not_applicable_checks": "Itens não aplicáveis",
       "check_notes": "Coluna de observações",
       "result_detail": "Explicação",
       "result_summary": "Contagem por avaliação",
       "combined_table": "Uma tabela para todas as seções",
+      "ungraded_checks": "Itens ainda não avaliados",
       "inspector_line": "Linha de assinatura",
       "inspector_name": "Nome de quem assina",
       "date_line": "Linha da data",
@@ -1079,64 +1089,6 @@
     "applyTaxNoRecords": "Nenhum registro precisa da taxa de imposto padrão.",
     "saveSettings": "Salvar configurações",
     "saved": "Configurações salvas",
-    "currencies": {
-      "USD": "Dólar americano",
-      "EUR": "Euro",
-      "GBP": "Libra esterlina",
-      "NOK": "Coroa norueguesa",
-      "SEK": "Coroa sueca",
-      "DKK": "Coroa dinamarquesa",
-      "CHF": "Franco suíço",
-      "CAD": "Dólar canadense",
-      "AUD": "Dólar australiano",
-      "NZD": "Dólar neozelandês",
-      "JPY": "Iene japonês",
-      "CNY": "Yuan chinês",
-      "INR": "Rupia indiana",
-      "BRL": "Real brasileiro",
-      "MXN": "Peso mexicano",
-      "PLN": "Zloti polonês",
-      "CZK": "Coroa tcheca",
-      "HUF": "Florim húngaro",
-      "TRY": "Lira turca",
-      "ZAR": "Rand sul-africano",
-      "KRW": "Won sul-coreano",
-      "SGD": "Dólar de Singapura",
-      "HKD": "Dólar de Hong Kong",
-      "THB": "Baht tailandês",
-      "ISK": "Coroa islandesa",
-      "RON": "Leu romeno",
-      "ILS": "Shekel israelense",
-      "PHP": "Peso filipino",
-      "IDR": "Rupia indonésia",
-      "MYR": "Ringgit malaio",
-      "LKR": "Rupia do Sri Lanka",
-      "AED": "Dirham dos EAU",
-      "SAR": "Rial saudita",
-      "RUB": "Rublo russo",
-      "TWD": "Dólar taiwanês",
-      "VND": "Dong vietnamita",
-      "PKR": "Rupia paquistanesa",
-      "BDT": "Taka bengalesa",
-      "NPR": "Rupia nepalesa",
-      "BGN": "Lev búlgaro",
-      "UAH": "Hryvnia ucraniana",
-      "CLP": "Peso chileno",
-      "COP": "Peso colombiano",
-      "ARS": "Peso argentino",
-      "PEN": "Sol peruano",
-      "EGP": "Libra egípcia",
-      "NGN": "Naira nigeriana",
-      "KES": "Xelim queniano",
-      "MAD": "Dirham marroquino",
-      "DZD": "Dinar argelino",
-      "GHS": "Cedi ganês",
-      "QAR": "Rial catariano",
-      "KWD": "Dinar kuwaitiano",
-      "BHD": "Dinar do Bahrein",
-      "OMR": "Rial omanense",
-      "JOD": "Dinar jordaniano"
-    },
     "formatTitle": "Formato da moeda",
     "formatSymbolName": "Símbolo",
     "formatCodeName": "Código",
@@ -1697,9 +1649,18 @@
     "upgradeTitle": "Atualizar plano",
     "upgradeDescription": "Desbloqueie mais recursos atualizando seu plano.",
     "upgradeToProDescription": "Atualize para desbloquear clientes ilimitados, membros da equipe, relatórios, SMTP, acesso à API e mais.",
-    "upgradeToEnterpriseDescription": "Precisa de mais de 5 membros da equipe? Atualize para Enterprise com até 50 usuários.",
-    "upgradeToPro": "Atualizar para Pro: $99/ano",
-    "upgradeToEnterprise": "Enterprise: $140/ano",
+    "upgradeToEnterpriseDescription": "Tem mais de uma oficina? O Enterprise cobre até 10 oficinas e 50 membros da equipe.",
+    "upgradeToPro": "Atualizar para Pro",
+    "upgradeToEnterprise": "Enterprise",
+    "billingInterval": "Período de cobrança",
+    "billingMonthly": "Mensal",
+    "billingAnnual": "Anual",
+    "billingSave": "Economize {percent}%",
+    "billingMonthsFree": "{months, plural, one {# mês grátis} other {# meses grátis}}",
+    "billedAnnually": "{amount} cobrado anualmente",
+    "discountBadge": "{value} de desconto",
+    "discountOnce": "Primeiro pagamento, depois {amount}",
+    "discountRepeating": "{months, plural, one {Primeiro mês} other {Primeiros # meses}}, depois {amount}",
     "upgradeDialogTitle": "Atualizar para Enterprise",
     "upgradeDialogDescription": "Seu plano Pro atual será atualizado para Enterprise. Você será cobrado apenas pela diferença proporcional do período de faturamento restante.",
     "upgradeConfirm": "Confirmar atualização",
@@ -2086,6 +2047,10 @@
     "itemsTableHint": "Uma lista numerada de todas as linhas do serviço, primeiro mão de obra e depois peças, no lugar das tabelas separadas.",
     "discardChanges": "Descartar alterações",
     "tablesExclusiveHint": "A folha imprime uma disposição: uma única lista numerada ou tabelas separadas de peças e mão de obra. Ativar uma desativa a outra.",
+    "groupLines": "Agrupar linhas",
+    "groupLinesOff": "Não",
+    "groupLinesCategory": "Por categoria",
+    "groupLinesHint": "Por categoria imprime as peças sob um título por categoria com um subtotal para cada uma, com a mão de obra primeiro na lista combinada. Peças sem categoria são somadas como \"Outras peças\".",
     "innerPadding": "Espaçamento interno",
     "totalsWidth": "Largura",
     "totalsWidthHint": "Vazio ocupa a linha inteira. Um valor em pontos pendura o bloco à direita; em uma coluna, ele usa a largura da coluna.",
@@ -2174,6 +2139,8 @@
       "itemLabor": "Freios dianteiros substituídos",
       "itemDisc": "Disco de freio, dianteiro",
       "itemPads": "Jogo de pastilhas, eixo dianteiro",
+      "categoryBrakes": "Freios",
+      "categoryConsumables": "Insumos de oficina",
       "itemConsumables": "Materiais de consumo",
       "unitPcs": "un",
       "unitSet": "jogo",
@@ -2200,6 +2167,7 @@
       "sectionLighting": "Iluminação",
       "checkHeadlamps": "Estado dos faróis",
       "checkFogLamp": "Luz de neblina traseira",
+      "checkStopLamps": "Luzes de freio",
       "photoFront": "Frente",
       "photoOdometer": "Hodômetro",
       "concernNoise": "Barulho de raspagem na frente ao frear",
@@ -2272,7 +2240,9 @@
     "conditionMapViewsAll": "Todas as cinco",
     "conditionMapViewsTop": "Só de cima",
     "conditionMapViewsSides": "Só as laterais",
-    "conditionMapWidthHint": "Vazio ocupa a linha toda. Informe pontos para reduzir o desenho; ele fica onde o alinhamento colocar e a legenda mantém a largura total."
+    "conditionMapWidthHint": "Vazio ocupa a linha toda. Informe pontos para reduzir o desenho; ele fica onde o alinhamento colocar e a legenda mantém a largura total.",
+    "conditionMapDense": "Tabela compacta",
+    "conditionMapDenseHint": "Aproxima as linhas da tabela abaixo do desenho, para que ela ocupe menos espaço na página."
   },
   "preview": {
     "title": "Serviço de freios e troca de óleo",

+ 15 - 0
messages/pt-BR/timeTracking.json

@@ -44,6 +44,20 @@
     "apply": "Aplicar",
     "allTechnicians": "Todos os técnicos",
     "export": "Exportar CSV",
+    "print": "Imprimir folha de ponto",
+    "client": "Cliente",
+    "noClient": "Sem cliente",
+    "signature": "Assinatura",
+    "sortBy": "Ordenar por",
+    "sort": {
+      "default": "Técnico / dia",
+      "client": "Cliente",
+      "vehicle": "Veículo",
+      "clientVehicle": "Cliente, depois veículo"
+    },
+    "sortDirection": "Direção da ordenação",
+    "ascending": "Crescente",
+    "descending": "Decrescente",
     "addEntry": "Adicionar registro",
     "stats": {
       "total": "Tempo total",
@@ -101,6 +115,7 @@
       "duration": "Duração",
       "hours": "Horas",
       "job": "Serviço",
+      "client": "Cliente",
       "vehicle": "Veículo",
       "plate": "Placa",
       "source": "Origem",

+ 3 - 0
messages/pt-PT/audit.json

@@ -188,6 +188,7 @@
     "signature_remove": "Removeu a assinatura",
     "conditionMark_add": "Marca de estado adicionada",
     "conditionMark_remove": "Marca de estado removida",
+    "conditionMark_carry": "Confirmou marcas de estado anteriores",
     "conditionMark_onInvoice": "Mapa de estado na fatura",
     "settings_markType_create": "Tipo de marca adicionado",
     "settings_markType_update": "Tipo de marca alterado",
@@ -234,6 +235,7 @@
     "payment_delete": "Pagamento {id} eliminado",
     "quoteRequest_update": "Estado do pedido de orçamento alterado para {status, select, pending {pendente} approved {aprovado} rejected {recusado} completed {concluído} other {{status}}}",
     "quote_convert": "Orçamento {ref} convertido no registo de serviço {serviceRecordId}",
+    "quote_convert_existing": "Orçamento {ref} adicionado à ordem de serviço existente {jobRef}",
     "quote_create": "Orçamento {ref} criado",
     "quote_delete": "Orçamento {ref} eliminado",
     "quote_status": "Estado do orçamento alterado para {status, select, draft {rascunho} sent {enviado} accepted {aceite} rejected {recusado} expired {expirado} converted {convertido} other {{status}}}",
@@ -342,6 +344,7 @@
     "inspection_phoneUpload": "Adicionou um ficheiro a partir de um telemóvel à inspeção {ref}",
     "condition_mark_add": "Adicionou uma marca ao mapa de estado",
     "condition_mark_remove": "Removeu uma marca do mapa de estado",
+    "condition_mark_carry": "Confirmou {count, plural, one {# marca anterior como ainda presente} other {# marcas anteriores como ainda presentes}} no mapa de estado",
     "condition_map_on_invoice_on": "Colocou o mapa de estado na fatura",
     "condition_map_on_invoice_off": "Retirou o mapa de estado da fatura"
   }

Некоторые файлы не были показаны из-за большого количества измененных файлов