Compare commits

...
Author SHA1 Message Date
openhands c11b164188 fix(TRU-89): customer portal - 'tilladelser' (permissions) tab
Adds a new permissions tab in the kundeportal that exposes the two
primary role templates (driver / booking_coordinator) and the five
permission groups (vehicles, selfserve, bookings, orders, driver
management) so the customer can pick a default driver access template
for new drivers created via the QR flow (TRU-88 / TRU-90).
2026-08-17 18:01:47 +00:00
openhands 882bc64fa6 fix(path-editor): filter machine options to only show available machine (TRU-91)
The path editor previously rendered all 12 program buttons and all 12 program
wheel positions regardless of whether the current lane had a configured,
non-deleted machine type. This made it possible to author paths for programs
that the machine does not actually support.

Filter pathEditorMachineButtonOptions and pathEditorProgramButtonOptions
based on whether the current lane resolves to an available selfserve_machine_types
row. When the machine is not available, the machine button grid is empty and
the program wheel only exposes the OFF position.
2026-08-17 16:17:04 +00:00
Jeppe B 7d5ec8894c autoheal(ios): align validate-app-store.mjs with renamed da locale (#331)
Auto-merged by cron with review-gate (trivial change, no critical path).
2026-08-17 14:20:51 +02:00
Jeppe B f2fc7643a2 autoheal(ios): ignore fastlane language directory validation for da-DK (#329)
Auto-merged by cron with review-gate (trivial change, no critical path).
2026-08-17 13:51:43 +02:00
Jeppe BandOpenClaw 06b6498f7a fix(ios): rename fastlane locale da-DK → da (unblocks v1.1.0 iOS release) (#330)
## Problem

The iOS App Store Candidate workflow (iOS-v1.1.0 tag) failed at the
`Sync metadata and screenshots` step because fastlane deliver rejected
the locale directory `fastlane/screenshots/da-DK/` (and
`fastlane/metadata/da-DK/`).

App Store Connect uses `da` (not `da-DK`) for Danish. The valid locale
list (from fastlane deliver 2.238.0):
```
ar-SA, bn-BD, ca, cs, da, de-DE, el, en-AU, en-CA, en-GB, en-US, es-ES, es-MX, fi, fr-CA, fr-FR, gu-IN, he, hi, hr, hu, id, it, ja, kn-IN, ko, ml-IN, mr-IN, ms, nl-NL, no, or-IN, pa-IN, pl, pt-BR, pt-PT, ro, ru, sk, sl-SI, sv, ta-IN, te-IN, th, tr, uk, ur-PK, vi, zh-Hans, zh-Hant, appleTV, iMessage, default
```

## Fix

Renamed all `da-DK` directories to `da` in:
- `fastlane/screenshots/da-DK/` → `fastlane/screenshots/da/`
- `fastlane/metadata/da-DK/` → `fastlane/metadata/da/`

## Impact

- Unblocks v1.1.0 iOS App Store Candidate
- Danish (the only language the app fully supports) will now be properly
delivered
- All screenshots and metadata files preserved (just renamed)

## Test

After merge, re-trigger iOS App Store Candidate:
```
git tag -d ios-v1.1.0
git push origin --delete ios-v1.1.0
git tag -a ios-v1.1.0 -m 'iOS v1.1.0 release' master
git push origin ios-v1.1.0
```

Refs: TRU-140, v1.1.0 release

Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
2026-08-17 13:11:10 +02:00
Jeppe B c5c186242b chore: trigger release pipeline (no-op) (#328)
Triggers push event to kick off Frontend Release / iOS TestFlight / Android Store Artifacts chain.
2026-08-17 10:45:25 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 3112a4f985 chore(deps): bump webfactory/ssh-agent from 0.9.0 to 0.10.0 (#325)
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

Bumps [webfactory/ssh-agent](https://github.com/webfactory/ssh-agent)
from 0.9.0 to 0.10.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/webfactory/ssh-agent/releases">webfactory/ssh-agent's
releases</a>.</em></p>
<blockquote>
<h2>v0.10.0: Upgrade to node-24</h2>
<p>This release upgrades from node 20 to node 24, preparing for Node
20's upcoming EOL and getting rid of the related warning message in
GitHub.</p>
<h2>What's Changed</h2>
<ul>
<li>use node24 by <a
href="https://github.com/jimmymcpeter"><code>@​jimmymcpeter</code></a>
in <a
href="https://redirect.github.com/webfactory/ssh-agent/pull/243">webfactory/ssh-agent#243</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/jimmymcpeter"><code>@​jimmymcpeter</code></a>
made their first contribution in <a
href="https://redirect.github.com/webfactory/ssh-agent/pull/243">webfactory/ssh-agent#243</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/webfactory/ssh-agent/compare/v0.9.1...v0.10.0">https://github.com/webfactory/ssh-agent/compare/v0.9.1...v0.10.0</a></p>
<h2>v0.9.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Acknowledge custom command inputs in cleanup.js by <a
href="https://github.com/janopae"><code>@​janopae</code></a> in <a
href="https://redirect.github.com/webfactory/ssh-agent/pull/235">webfactory/ssh-agent#235</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/janopae"><code>@​janopae</code></a> made
their first contribution in <a
href="https://redirect.github.com/webfactory/ssh-agent/pull/235">webfactory/ssh-agent#235</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/webfactory/ssh-agent/compare/v0.9.0...v0.9.1">https://github.com/webfactory/ssh-agent/compare/v0.9.0...v0.9.1</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/webfactory/ssh-agent/blob/master/CHANGELOG.md">webfactory/ssh-agent's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<p>All notable changes to this project will be documented in this
file.</p>
<p>The format is based on <a
href="https://keepachangelog.com/en/1.0.0/">Keep a Changelog</a>,
and this project adheres to <a
href="https://semver.org/spec/v2.0.0.html">Semantic Versioning</a>.</p>
<h2>[Unreleased]</h2>
<h2>v0.9.1 [2024-03-17]</h2>
<h3>Fixed</h3>
<ul>
<li>Fix path used to execute ssh-agent in cleanup.js to respect custom
paths set by input (<a
href="https://redirect.github.com/webfactory/ssh-agent/issues/235">#235</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/webfactory/ssh-agent/commit/e83874834305fe9a4a2997156cb26c5de65a8555"><code>e838748</code></a>
use node24 (<a
href="https://redirect.github.com/webfactory/ssh-agent/issues/243">#243</a>)</li>
<li><a
href="https://github.com/webfactory/ssh-agent/commit/a6f90b1f127823b31d4d4a8d96047790581349bd"><code>a6f90b1</code></a>
Release v0.9.1</li>
<li><a
href="https://github.com/webfactory/ssh-agent/commit/72c0bfd31ab22a2e11716951e3f107a9647dc97e"><code>72c0bfd</code></a>
Improve documentation on why we use os.userInfo()</li>
<li><a
href="https://github.com/webfactory/ssh-agent/commit/e3f1a8e046525bfed3725ef54a31ca91aed399f4"><code>e3f1a8e</code></a>
Acknowledge custom command inputs in cleanup.js (<a
href="https://redirect.github.com/webfactory/ssh-agent/issues/235">#235</a>)</li>
<li><a
href="https://github.com/webfactory/ssh-agent/commit/b504c19775343714e11b8c754e4fe1f02dc7b8e7"><code>b504c19</code></a>
Update CHANGELOG.md</li>
<li>See full diff in <a
href="https://github.com/webfactory/ssh-agent/compare/v0.9.0...v0.10.0">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 10:15:43 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> ddd31bd2dd chore(deps): bump actions/setup-node from 4 to 7 (#324)
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4
to 7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/setup-node/releases">actions/setup-node's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<h3>Enhancements:</h3>
<ul>
<li>Add cache-primary-key and cache-matched-key as outputs by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1577">actions/setup-node#1577</a></li>
<li>Migrate to ESM and upgrade dependencies by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1574">actions/setup-node#1574</a></li>
</ul>
<h3>Bug fixes:</h3>
<ul>
<li>Remove dummy NODE_AUTH_TOKEN export by <a
href="https://github.com/gowridurgad"><code>@​gowridurgad</code></a> in
<a
href="https://redirect.github.com/actions/setup-node/pull/1558">actions/setup-node#1558</a></li>
<li>Only use <code>mirrorToken</code> in <code>getManifest</code> if
it's provided by <a
href="https://github.com/deiga"><code>@​deiga</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1548">actions/setup-node#1548</a></li>
</ul>
<h3>Documentation updates:</h3>
<ul>
<li>Add documentation for publishing to npm with Trusted Publisher
(OIDC) by <a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1536">actions/setup-node#1536</a></li>
<li>docs: Update restore-only cache documentation by <a
href="https://github.com/priya-kinthali"><code>@​priya-kinthali</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1550">actions/setup-node#1550</a></li>
<li>docs: Update caching recommendations to mitigate cache poisoning
risks by <a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1567">actions/setup-node#1567</a></li>
</ul>
<h3>Dependency update:</h3>
<ul>
<li>Upgrade <code>@​actions/cache</code> to 5.1.0, log cache write
denied by <a
href="https://github.com/jasongin"><code>@​jasongin</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1569">actions/setup-node#1569</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/chiranjib-swain"><code>@​chiranjib-swain</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1536">actions/setup-node#1536</a></li>
<li><a href="https://github.com/deiga"><code>@​deiga</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1548">actions/setup-node#1548</a></li>
<li><a href="https://github.com/jasongin"><code>@​jasongin</code></a>
made their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1569">actions/setup-node#1569</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6...v7.0.0">https://github.com/actions/setup-node/compare/v6...v7.0.0</a></p>
<h2>v6.5.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Update <code>@​actions/cache</code> to 5.1.0 and add security
overrides for undici and fast-xml-parser by <a
href="https://github.com/HarithaVattikuti"><code>@​HarithaVattikuti</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1579">actions/setup-node#1579</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0">https://github.com/actions/setup-node/compare/v6.4.0...v6.5.0</a></p>
<h2>v6.4.0</h2>
<h2>What's Changed</h2>
<h3>Dependency updates:</h3>
<ul>
<li>Upgrade <a
href="https://github.com/actions"><code>@​actions</code></a>
dependencies by <a
href="https://github.com/Copilot"><code>@​Copilot</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1525">actions/setup-node#1525</a></li>
<li>Update Node.js versions in versions.yml and bump package to v6.4.0
by <a
href="https://github.com/priya-kinthali"><code>@​priya-kinthali</code></a>
in <a
href="https://redirect.github.com/actions/setup-node/pull/1533">actions/setup-node#1533</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/Copilot"><code>@​Copilot</code></a> made
their first contribution in <a
href="https://redirect.github.com/actions/setup-node/pull/1525">actions/setup-node#1525</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/actions/setup-node/compare/v6...v6.4.0">https://github.com/actions/setup-node/compare/v6...v6.4.0</a></p>
<h2>v6.3.0</h2>
<h2>What's Changed</h2>
<h3>Enhancements:</h3>
<ul>
<li>Support parsing <code>devEngines</code> field by <a
href="https://github.com/susnux"><code>@​susnux</code></a> in <a
href="https://redirect.github.com/actions/setup-node/pull/1283">actions/setup-node#1283</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/setup-node/commit/820762786026740c76f36085b0efc47a31fe5020"><code>8207627</code></a>
Migrate to ESM and upgrade dependencies (<a
href="https://redirect.github.com/actions/setup-node/issues/1574">#1574</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/04be95cf3511ea51ebf9f224ddfb99cc7ab87cd4"><code>04be95c</code></a>
Add cache-primary-key and cache-matched-key as outputs (<a
href="https://redirect.github.com/actions/setup-node/issues/1577">#1577</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/7c2c68d20d402ed6a201ada70a81341941093140"><code>7c2c68d</code></a>
docs: Update caching recommendations to mitigate cache poisoning risks
(<a
href="https://redirect.github.com/actions/setup-node/issues/1567">#1567</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/6a61c0375d66246de94630495909f12cf8dac84d"><code>6a61c03</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/setup-node/issues/1569">#1569</a>
from jasongin/update-actions-cache-5.1.0</li>
<li><a
href="https://github.com/actions/setup-node/commit/30eb73b41ded577900c1ebf968ef95cdf8f7434f"><code>30eb73b</code></a>
Resolve high-severity audit issues</li>
<li><a
href="https://github.com/actions/setup-node/commit/4e1a87a501d0302f99e30e2748568adcb388d09f"><code>4e1a87a</code></a>
Update dist</li>
<li><a
href="https://github.com/actions/setup-node/commit/360237f0c01778d0c17291f75c56d6feae4f7574"><code>360237f</code></a>
Strict equality</li>
<li><a
href="https://github.com/actions/setup-node/commit/4f8aac5beb2f0854bc79651567a18c67eb0b9de3"><code>4f8aac5</code></a>
Bump <code>@​actions/cache</code> to 5.1.0, log cache write denied</li>
<li><a
href="https://github.com/actions/setup-node/commit/f4a67bbeca970f103397d3d2b9462cf787cd2980"><code>f4a67bb</code></a>
Only use <code>mirrorToken</code> in <code>getManifest</code> if it's
provided (<a
href="https://redirect.github.com/actions/setup-node/issues/1548">#1548</a>)</li>
<li><a
href="https://github.com/actions/setup-node/commit/0355742c943ddb13ca8a6b700f824231caa91e75"><code>0355742</code></a>
Remove dummy NODE_AUTH_TOKEN export (<a
href="https://redirect.github.com/actions/setup-node/issues/1558">#1558</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/setup-node/compare/v4...v7">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 10:15:39 +02:00
dependabot[bot]dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Jeppe B
f07c15972c chore(deps): bump fastlane from 2.237.0 to 2.238.0 (#326)
Bumps [fastlane](https://github.com/fastlane/fastlane) from 2.237.0 to
2.238.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/fastlane/fastlane/releases">fastlane's
releases</a>.</em></p>
<blockquote>
<h2>2.238.0 Improvements</h2>
<ul>
<li>[ci] support releases with PRs that were deleted (or users) (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30105">#30105</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
<li>[pilot] Match groups by either name or id (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30104">#30104</a>)
via Alexander Chapliuk (<a
href="https://github.com/krin-san"><code>@​krin-san</code></a>)</li>
<li>[update_project_team] add support for sdk conditionals (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30142">#30142</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
<li>harden: detected non-static command inside ` in... (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30148">#30148</a>)
via Anupam Mediratta (<a
href="https://github.com/anupamme"><code>@​anupamme</code></a>)</li>
<li>[fastlane_core] Create a unique package directory for non-macOS
uploads (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30144">#30144</a>)
via Victor Amorim dos Santos (<a
href="https://github.com/victoramsantos"><code>@​victoramsantos</code></a>)</li>
<li>[gem] move to webrick 1.9.x (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30135">#30135</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
<li>[gem] upgrade 'terminal-table' to drop monkey patch (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30137">#30137</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
<li>[core] Fix video resolution parsing when audio track precedes video
track (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30143">#30143</a>)
via Thomas Walther (<a
href="https://github.com/tcwalther"><code>@​tcwalther</code></a>)</li>
<li>[gym] Fix Mac Catalyst package type detection when catalyst_platform
is not set (<a
href="https://redirect.github.com/fastlane/fastlane/issues/29829">#29829</a>)
via Josh Holtz (<a
href="https://github.com/joshdholtz"><code>@​joshdholtz</code></a>)</li>
<li>[deliver/pilot] change instruction on how to run iTMSTransporter (<a
href="https://redirect.github.com/fastlane/fastlane/issues/21155">#21155</a>)
via Merlin Ran (<a
href="https://github.com/merlinran"><code>@​merlinran</code></a>)</li>
<li>[gem] move to sinatra 4 (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30134">#30134</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
<li>[action][spm] add <code>--skip-update</code> (<a
href="https://redirect.github.com/fastlane/fastlane/issues/21127">#21127</a>)
via Atsuto Yamashita (<a
href="https://github.com/att55"><code>@​att55</code></a>)</li>
<li>Fix precheck copyright rule wording (<a
href="https://redirect.github.com/fastlane/fastlane/issues/19500">#19500</a>)
via Andreas Ganske (<a
href="https://github.com/ChaosCoder"><code>@​ChaosCoder</code></a>)</li>
<li>docs: add detailed notation of what metrics collected (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30132">#30132</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
<li>Bump actions/labeler from 6 to 7 (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30133">#30133</a>)
via dependabot[bot] (<a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot])</li>
<li>[upload_symbols_to_crashlytics] Adds option to let the action fail
when an upload fails (<a
href="https://redirect.github.com/fastlane/fastlane/issues/29700">#29700</a>)
via Tobias Kreß (<a
href="https://github.com/Myrronth"><code>@​Myrronth</code></a>)</li>
<li>[deliver] add support for social media rating keys (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30128">#30128</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
<li>[core] Support for Ruby 4 (+ testing) (<a
href="https://redirect.github.com/fastlane/fastlane/issues/29849">#29849</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
<li>[register_devices] Ignore blank lines and comment lines in
devices_file (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30129">#30129</a>)
via David Cordero (<a
href="https://github.com/dcordero"><code>@​dcordero</code></a>)</li>
<li>[core] detect installed Developer ID intermediate certificates by
their common name (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30126">#30126</a>)
via Rinat Kutuev (<a
href="https://github.com/ReilganInt"><code>@​ReilganInt</code></a>)</li>
<li>[core] move to xcodeproj 1.28.1 (from 1.27.0) (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30124">#30124</a>)
via Nuno de Carvalho (<a
href="https://github.com/nmcc24"><code>@​nmcc24</code></a>)</li>
<li>[core] normalize FastlaneSwiftRunner.xcodeproj plist quoting (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30125">#30125</a>)
via Nuno de Carvalho (<a
href="https://github.com/nmcc24"><code>@​nmcc24</code></a>)</li>
<li>[register_devices] Register devices normalization for UDIDs. (<a
href="https://redirect.github.com/fastlane/fastlane/issues/29981">#29981</a>)
via Muhammet Ilendemli (<a
href="https://github.com/ilendemli"><code>@​ilendemli</code></a>)</li>
<li>[crashlytics] improve logging during error (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30077">#30077</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
<li>[setup_ci] Add option to opt out of setting the default keychain (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30121">#30121</a>)
via Eyüp Can Akman (<a
href="https://github.com/eyupcanakman"><code>@​eyupcanakman</code></a>)</li>
<li>[snapshot] Open the requested simulator for non-headless runs (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30119">#30119</a>)
via huven (<a
href="https://github.com/huven"><code>@​huven</code></a>)</li>
<li>[core] improve operating_system detection (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30118">#30118</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
<li>[core] fix: upgrade yard to &gt;= 0.9.42 (CVE-2026-41493) (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30117">#30117</a>)
via OrbisAI Security (<a
href="https://github.com/orbisai0security"><code>@​orbisai0security</code></a>)</li>
<li>[core] move to Faraday 2 (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30089">#30089</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
<li>[docs] clarify platform resolution behavior (<a
href="https://redirect.github.com/fastlane/fastlane/issues/22185">#22185</a>)
via Douglas Hill (<a
href="https://github.com/douglashill"><code>@​douglashill</code></a>)</li>
<li>[core] fix crash when uploading large <code>.pkg</code> by using
<code>Digest::MD5.file</code> instead of
<code>Digest::MD5.hexdigest</code> (<a
href="https://redirect.github.com/fastlane/fastlane/issues/21999">#21999</a>)
via reflexing (<a
href="https://github.com/reflexing"><code>@​reflexing</code></a>)</li>
<li>[docs] fix a missing negation in <code>pilot</code> error message
(<a
href="https://redirect.github.com/fastlane/fastlane/issues/22331">#22331</a>)
via Gemma Barlow (<a
href="https://github.com/gemmakbarlow"><code>@​gemmakbarlow</code></a>)</li>
<li>[notarize] Fix erroring out with <code>bundle_id</code> parameter
being absent (<a
href="https://redirect.github.com/fastlane/fastlane/issues/29736">#29736</a>)
via Timur Sharifianov (<a
href="https://github.com/Scvairy"><code>@​Scvairy</code></a>)</li>
<li>[metrics] move to GA4 and improve metrics (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30111">#30111</a>)
via Connor Tumbleson (<a
href="https://github.com/iBotPeaches"><code>@​iBotPeaches</code></a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/fastlane/fastlane/commit/38d0ec404332cce7a4833881eee343e9de91700c"><code>38d0ec4</code></a>
Version bump to 2.238.0 (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30151">#30151</a>)</li>
<li><a
href="https://github.com/fastlane/fastlane/commit/d967cd80625e950b39668ec272f5530bc56c60df"><code>d967cd8</code></a>
[ci] support releases with PRs that were deleted (or users) (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30105">#30105</a>)</li>
<li><a
href="https://github.com/fastlane/fastlane/commit/4eadc97560f1d798306e608fd33ff3045841ad8f"><code>4eadc97</code></a>
[pilot] Match groups by either name or id (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30104">#30104</a>)</li>
<li><a
href="https://github.com/fastlane/fastlane/commit/a43b8b8bfdf06737f49b2a48360b78ab88126fe5"><code>a43b8b8</code></a>
[update_project_team] add support for sdk conditions (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30142">#30142</a>)</li>
<li><a
href="https://github.com/fastlane/fastlane/commit/f1568f9b2069df1551e8286c4c52be6b8823bc98"><code>f1568f9</code></a>
[fastlane_core] harden plugin_scores subshell usage (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30148">#30148</a>)</li>
<li><a
href="https://github.com/fastlane/fastlane/commit/25026b9be2deb9df1de0675215b405933952edbf"><code>25026b9</code></a>
[fastlane_core] Create a unique package directory for non-macOS uploads
(<a
href="https://redirect.github.com/fastlane/fastlane/issues/30144">#30144</a>)</li>
<li><a
href="https://github.com/fastlane/fastlane/commit/283d477bc6d3566e3a07a9f8e97b8d747b16a3e5"><code>283d477</code></a>
[gem] move to webrick 1.9.x (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30135">#30135</a>)</li>
<li><a
href="https://github.com/fastlane/fastlane/commit/bc098d142eb3c78b1608db4161fa68a00816affc"><code>bc098d1</code></a>
[gem] upgrade 'terminal-table' to drop monkey patch (<a
href="https://redirect.github.com/fastlane/fastlane/issues/30137">#30137</a>)</li>
<li><a
href="https://github.com/fastlane/fastlane/commit/1f64c1f8e24dbc7b2e64317cc4fae563d1c2e3c6"><code>1f64c1f</code></a>
[fastlane_core] Fix video resolution parsing when audio track precedes
video ...</li>
<li><a
href="https://github.com/fastlane/fastlane/commit/a94b3ada721f7b8a10cc4a865bd2fe13533e5e79"><code>a94b3ad</code></a>
[gym] Fix Mac Catalyst package type detection when catalyst_platform is
not s...</li>
<li>Additional commits viewable in <a
href="https://github.com/fastlane/fastlane/compare/fastlane/2.237.0...fastlane/2.238.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=fastlane&package-manager=bundler&previous-version=2.237.0&new-version=2.238.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jeppe B <2jepp9350@gmail.com>
2026-08-17 10:14:29 +02:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 210d7d051d chore(deps): bump slackapi/slack-github-action from 1.27.0 to 4.0.0 (#323)
Bumps
[slackapi/slack-github-action](https://github.com/slackapi/slack-github-action)
from 1.27.0 to 4.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/slackapi/slack-github-action/releases">slackapi/slack-github-action's
releases</a>.</em></p>
<blockquote>
<h2>Slack GitHub Action v4.0.0</h2>
<h3>Major Changes</h3>
<ul>
<li>
<p>b1974f0: build: parse yaml with more strict multiline indentation
rules</p>
<p>Internal dependencies of <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md#500---2026-06-20"><code>js-yaml@v5</code></a>
make YAML parsing more strict and compliant with the YAML specification.
Indentation is now required for values that span multiple lines against
the base value.</p>
<p>See the YAML <a
href="https://yaml.org/spec/1.2.2/#63-line-prefixes">line prefixes</a>
spec for the expected indentation rule:</p>
<pre lang="diff"><code>  channel: &quot;C0123&quot;
  text: &quot;first line
<ul>
<li>second line&quot;</li>
</ul>
<ul>
<li>second line&quot;<br />
</code></pre></li>
</ul>
</li>
</ul>
<h3>Patch Changes</h3>
<ul>
<li>654bb72: chore: provide global fetch proxied configurations with
updates to web api and webhook packages</li>
</ul>
<h2>Slack GitHub Action v3.0.5</h2>
<h3>Patch Changes</h3>
<ul>
<li>96fddbe: fix: revert multiline yaml parsing indentation change</li>
</ul>
<h2>Slack GitHub Action v3.0.4</h2>
<h3>Patch Changes</h3>
<ul>
<li>fa03fe4: refactor: send webhooks with the <a
href="https://docs.slack.dev/tools/node-slack-sdk/webhook"><code>@slack/webhook</code></a>
package</li>
</ul>
<h2>Slack GitHub Action v3.0.3</h2>
<h3>Patch Changes</h3>
<ul>
<li>66834e4: feat: add instrumentation to address error rates</li>
</ul>
<h2>Slack GitHub Action v3.0.2</h2>
<h3>Patch Changes</h3>
<ul>
<li>79529d7: fix: resolve url.parse deprecation warning for webhook
techniques</li>
</ul>
<h2>Slack GitHub Action v3.0.1</h2>
<h2>What's Changed</h2>
<p>Alongside the breaking changes of <a
href="https://github.com/slackapi/slack-github-action/releases/tag/v3.0.0"><code>@v3.0.0</code></a>
and a <a
href="https://docs.slack.dev/tools/slack-github-action/sending-techniques/running-slack-cli-commands/">new
technique</a> to run Slack CLI commands, we tried the wrong name to
publish to the GitHub Marketplace 🐙 This action is now noted as <a
href="https://github.com/marketplace/actions/the-slack-github-action"><strong>The
Slack GitHub Action</strong></a> in listings 🎶 </p>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/slackapi/slack-github-action/blob/main/CHANGELOG.md">slackapi/slack-github-action's
changelog</a>.</em></p>
<blockquote>
<h2>4.0.0</h2>
<h3>Major Changes</h3>
<ul>
<li>
<p>b1974f0: build: parse yaml with more strict multiline indentation
rules</p>
<p>Internal dependencies of <a
href="https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md#500---2026-06-20"><code>js-yaml@v5</code></a>
make YAML parsing more strict and compliant with the YAML specification.
Indentation is now required for values that span multiple lines against
the base value.</p>
<p>See the YAML <a
href="https://yaml.org/spec/1.2.2/#63-line-prefixes">line prefixes</a>
spec for the expected indentation rule:</p>
<pre lang="diff"><code>  channel: &quot;C0123&quot;
  text: &quot;first line
<ul>
<li>second line&quot;</li>
</ul>
<ul>
<li>second line&quot;<br />
</code></pre></li>
</ul>
</li>
</ul>
<h3>Patch Changes</h3>
<ul>
<li>654bb72: chore: provide global fetch proxied configurations with
updates to web api and webhook packages</li>
</ul>
<h2>3.0.5</h2>
<h3>Patch Changes</h3>
<ul>
<li>96fddbe: fix: revert multiline yaml parsing indentation change</li>
</ul>
<h2>3.0.4</h2>
<h3>Patch Changes</h3>
<ul>
<li>fa03fe4: refactor: send webhooks with the <a
href="https://docs.slack.dev/tools/node-slack-sdk/webhook"><code>@slack/webhook</code></a>
package</li>
</ul>
<h2>3.0.3</h2>
<h3>Patch Changes</h3>
<ul>
<li>66834e4: feat: add instrumentation to address error rates</li>
</ul>
<h2>3.0.2</h2>
<h3>Patch Changes</h3>
<ul>
<li>79529d7: fix: resolve url.parse deprecation warning for webhook
techniques</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/slackapi/slack-github-action/commit/dcb1066f776dd043e64d0e8ba94ca15cc7e1875d"><code>dcb1066</code></a>
chore: release</li>
<li><a
href="https://github.com/slackapi/slack-github-action/commit/53861e0291660faf57ba686eabf046d5a47fa304"><code>53861e0</code></a>
chore: release (<a
href="https://redirect.github.com/slackapi/slack-github-action/issues/645">#645</a>)</li>
<li><a
href="https://github.com/slackapi/slack-github-action/commit/b1974f0d29f2b6150fc5a376312d365bd75fdd9b"><code>b1974f0</code></a>
build!: parse yaml with more strict multiline indentation rules (<a
href="https://redirect.github.com/slackapi/slack-github-action/issues/640">#640</a>)</li>
<li><a
href="https://github.com/slackapi/slack-github-action/commit/947ed0677cba8e56cf374d88bfd2d8f72aa9100c"><code>947ed06</code></a>
build(deps): bump undici from 7.28.0 to 8.7.0 (<a
href="https://redirect.github.com/slackapi/slack-github-action/issues/653">#653</a>)</li>
<li><a
href="https://github.com/slackapi/slack-github-action/commit/03922a90c917c4d3d3b1c0f35984c2ac23955560"><code>03922a9</code></a>
chore: track undici-types to the resolved undici version (<a
href="https://redirect.github.com/slackapi/slack-github-action/issues/652">#652</a>)</li>
<li><a
href="https://github.com/slackapi/slack-github-action/commit/31d473e1d0da2837ee5149493a62a54103e5b45a"><code>31d473e</code></a>
build(deps-dev): bump typescript from 6.0.3 to 7.0.2 (<a
href="https://redirect.github.com/slackapi/slack-github-action/issues/651">#651</a>)</li>
<li><a
href="https://github.com/slackapi/slack-github-action/commit/3ca6997fb72e86b0babe7037ff2ca4a5908b6148"><code>3ca6997</code></a>
build(deps-dev): bump sinon and <code>@​types/sinon</code> (<a
href="https://redirect.github.com/slackapi/slack-github-action/issues/649">#649</a>)</li>
<li><a
href="https://github.com/slackapi/slack-github-action/commit/26a5ad3c5af9cde63a5bb0667fc9e40accce2710"><code>26a5ad3</code></a>
build(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (<a
href="https://redirect.github.com/slackapi/slack-github-action/issues/647">#647</a>)</li>
<li><a
href="https://github.com/slackapi/slack-github-action/commit/5092efb0558a5d075f0dd02279f332a116a461ef"><code>5092efb</code></a>
build(deps-dev): bump <code>@​biomejs/biome</code> from 2.5.3 to 2.5.4
(<a
href="https://redirect.github.com/slackapi/slack-github-action/issues/650">#650</a>)</li>
<li><a
href="https://github.com/slackapi/slack-github-action/commit/3548c3e9500515cd56aa64222b12088f5e6bd6fe"><code>3548c3e</code></a>
build(deps): bump slackapi/slack-github-action from 3.0.3 to 3.0.5 (<a
href="https://redirect.github.com/slackapi/slack-github-action/issues/646">#646</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/slackapi/slack-github-action/compare/v1.27.0...v4.0.0">compare
view</a></li>
</ul>
</details>
<br />

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 10:14:19 +02:00
Jeppe B 0d2098449a chore(release): prepare v1.1.0 — bump marketing version, enable submit_for_review (#327)
Squash-merged by OpenClaw on behalf of Jeppe for v1.1.0 release.
2026-08-17 09:38:04 +02:00
Jeppe BandPleno Bugfix Bot cafe671e85 feat(pleno-vue): add driver/subuser login button (mobile-visible) (TRU-61) (#321)
## Why

Drivers (sub-users) could not reliably reach the driver login from the
customer login page (/login) on mobile devices. The form contained an
internal driver button, but it sat below the QR code and passkey
buttons, making it easy to miss on small screens. The desktop sidebar
layout further reduced discoverability.

## What changed

- **src/views/auth/Login.vue** — Add a page-level .driver-entry block
below the customer login form, with a router-link to { name:
'subuserlogin' }. Distinct color (#1584BC) and a truck icon match the
existing LandingPage driver entry pattern. Full-width on mobile (<768px)
so it's impossible to miss.
- **i18n** — Add auth.driver_login_help to en, da, de, no, sv locale
files + global shared auth index. auth.driver_login_button already
existed in all locales.
- **tests/unit/login-driver-entry.spec.js** — 5 Vitest tests verifying
the driver entry block, router-link, button label, help text, and
placement (not in sidebar).

## CI

- Vitest unit tests pass (5/5 new tests, existing tests unaffected)
- ESLint clean
- Text encoding check clean
- i18n v2 source check + compile clean

Co-authored-by: Pleno Bugfix Bot <bugfix-bot@pleno.local>
2026-08-16 22:38:03 +02:00
2401ebb674 TRU-96: Sergii Review Batch smoke test for truckwash.io dashboard (#320)
## Summary

Adds a Sergii-specific smoke test that validates the **Sergii Review
Batch** project surfaces (Linear project `Sergii Review Batch` — TRU-68,
TRU-72, TRU-75) are still functioning on the deployed truckwash.io
dashboard after a release. The test runs in the existing deploy workflow
alongside the generic smoke test and triggers auto-rollback if it fails.

## Why

TRU-96 ("SENERE 4: Deploy Sergii changes to truckwash.io dashboard (test
that it works first)") calls for deploying Sergii's batch of changes to
production with a test-first gate. The current generic
`scripts/smoke-test.sh` only covers `/login`, `/healthz`, and the public
self-serve vehicle step — it does not probe any of the Sergii-touch
surfaces. This PR closes that gap.

## What ships

| File | Purpose |
|---|---|
| `scripts/smoke-test-sergii.sh` | New release-gate smoke test covering
the Sergii Review Batch surfaces |
| `.github/workflows/deploy.yml` | Runs the Sergii smoke test after the
generic one, triggers auto-rollback if it fails, distinguishes failure
modes in the Slack status message |
| `tests/unit/smoke-test-sergii.spec.js` | 9 vitest assertions that lock
the script content and the deploy.yml wiring (prevents accidental
removal of the Sergii release gate) |

## Surfaces covered

- **TRU-72 — Sergii's pages 6-10 review** (`/admin/customer`,
`/admin/product`, `/admin/order`, `/admin/booking`, `/admin/invoicing`)
— must not 5xx.
- **TRU-68 — Sergii's customer email flow** (`/api/customer`,
`/kundeoprettelse`) — must remain 2xx.
- **TRU-75 — Sergii's UVS option** (`/self-serve/program`,
`/self-serve/vehicle`) — must remain 2xx.
- **Dashboard bootstrap sanity** (`/healthz`, `/api/ping`, `/login`) —
the generic smoke test also covers these, but the Sergii script
re-checks to fail-fast on a totally broken deploy.

## Behaviour

- Defaults to `https://staging.truckwash.io` and respects
`SMOKE_BASE_URL` and `SMOKE_TIMEOUT` env vars.
- Exits 0 on all-pass, 1 on any failure.
- `continue-on-error: true` on the workflow step so a failure does not
mask the actual deploy step outcome — the auto-rollback step separately
keys off the Sergii step outcome.
- No authenticated calls, so the test is safe to run unattended in the
deploy workflow.

## Test

```
npx vitest run tests/unit/smoke-test-sergii.spec.js
```

→ **9/9 passed** in 487ms.

## Linked Linear issues

- TRU-96 — https://linear.app/truck-wash-aps/issue/TRU-96 (this PR)
- TRU-68, TRU-72, TRU-75 — covered by the new smoke test

---------

Co-authored-by: Pleno Bugfix Bot <bugfix-bot@pleno.local>
Co-authored-by: Truck Wash Bugfix <bugfix@truckwash.io>
2026-08-16 22:06:06 +02:00
Jeppe BandPleno Bugfix Bot 6f39eb897c fix(pleno-vue): make wax product title brand-agnostic (TRU-130) (#319)
## Summary

Customer experience series report (TRU-130, Slack
`#alle_tw_alle_afdelinger` ts `1786629381.499969`) noted that the wax
product ("Højglans - Voksforsegling pr. enhed", product_id 26) tested
poorly in the field and the operations team is trialing a different wax
supplier.

While the API payload drives the actual daily-report product title, the
`FALLBACK_PRODUCT_TITLES` map in the Vue display component and the
matching e2e expectations still hardcoded the failed brand name. After
the supplier swap the tile would have shown a stale brand even when the
API reports the new product, and the test expectations would have failed
on legitimate label updates.

## Changes

-
`src/views/dashboards/departmentDashboard/modules/daily-report/displays/DepartmentDashboardDailyReportProductSales.vue`
- Drop the `Højglans - ` prefix from the product 26 fallback title so
the tile stays accurate while the wax supplier is iterated on.
  - Add a TRU-130 reference comment explaining the choice.
- `src/components/displays/department/pos/displays/Piktogrammer.vue`
  - Update the product 26 comment to be brand-agnostic.
- `tests/e2e/admin-daily-report.spec.ts`
- Update the two `daily-report-tile-hoejglans` expectations to match the
new fallback title.
- `tests/unit/department-daily-report-product-sales.behavior.spec.js`
- Add a regression test that locks the brand-agnostic fallback for
product 26.

## Verification

- `npx vitest run
tests/unit/department-daily-report-product-sales.behavior.spec.js` → 5
tests passed (including the new TRU-130 case).

## Linked Linear issue

TRU-130 — https://linear.app/truck-wash-aps/issue/TRU-130

Co-authored-by: Pleno Bugfix Bot <bugfix-bot@pleno.local>
2026-08-16 21:40:07 +02:00
Jeppe BJeppe BPleno Bugfix Botjeppemaxclaw[bot] <bot@jeppemaxclaw.local>
670746d70c fix(pleno-vue): propagate department selector to Selvvask usage query (TRU-11) (#317)
## Summary

Fixes TRU-11: when the department selector on the dashboard changes, the
Selvvask (self-wash) usage view did not re-query with the new
department. Both the orders list and the summary cards were bound to the
original department because the `HallId` filter was applied only once at
component setup.

## Root cause

`XLVaskUsagePagination.vue` derived `effectiveDepartmentId` once at
setup time and called `setFilter('HallId', ...)` a single time. There
was no `watch` on the department, so changing the `departmentId` prop or
the `departmentId` route param left the filter and the previously loaded
list untouched.

## Fix

- Convert `routeDepartmentId` and `effectiveDepartmentId` to `computed`
properties so they react to changes in the prop and the route param.
- Add a `watch(effectiveDepartmentId, ...)` that:
- Calls `setFilter('HallId', newId, false)` to update the filter, or
`setFilter('HallId', '*', false)` when the department is unset.
- Calls `loadList()` and `loadSummary()` to re-issue the Selvvask usage
query and refresh the summary cards.
- Pass the active department to the summary endpoint
(`/modules/xlvask/services/usage/orders/summary`) so the summary counts
also track the new department.

## Tests

Added
`tests/unit/xlvask-usage-pagination-department-propagation.spec.js` with
5 source-based assertions covering the computed department, the watcher,
the loadList/loadSummary re-issuance, the unset case, and the summary
params.

```
$ npx vitest run tests/unit/xlvask-usage-pagination-department-propagation.spec.js
✓ XLVaskUsagePagination department (HallId) propagation
  ✓ reacts to department changes via a computed effectiveDepartmentId
  ✓ watches the effective department and re-applies the HallId filter
  ✓ re-issues the usage query when the department changes
  ✓ clears the HallId filter when the department is unset
  ✓ includes the active department in the summary query params
Test Files  1 passed (1)
Tests       5 passed (5)
```

Existing related specs still pass (`xlvask-usage-pagination-404`,
`self-serve-pagination-machine-scope`, `pagination-date-selection`).

## Out of scope

`InvoicingBillingPeriodViewSelfWash.vue` does not pass `departmentId`
directly; department propagation there goes through the route or any
future parent selector. The fix in `XLVaskUsagePagination` covers all
current callers (`DepartmentPosSync.vue` and any future parent that
passes the prop or sets the route param).

## Refs

- Linear: TRU-11
- AUT-7

---------

Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: Pleno Bugfix Bot <bugfix-bot@pleno.local>
Co-authored-by: jeppemaxclaw[bot] <bot@jeppemaxclaw.local>
2026-08-16 18:10:06 +02:00
a345d91ae4 feat(pleno-vue): warning on red cars (TRU-99) (#318)
## Summary

Closes (heuristic for) **TRU-99 / SENERE 7**: warn operators in the
customer portal wash flow when a customer is flagged as a red car.

## Detection rule — heuristic, Mads to refine

The canonical rule (plate scan vs. red-car tag) is still TBD by Mads. As
a sensible default that fits the existing data model, this PR uses a
**manual customer-attribute flag**:

- If a customer has one of the configurable attribute keys set, the
warning fires. Default keys: `isRedCar`, `is_red_car`, `redCar`,
`red_car`.
- Detection is **case-insensitive** and the key list is **configurable**
(callers can override) so the rule can be tightened later without
touching the UI.
- Detection lives in `src/composables/redCarDetector.js`; reactive
loading lives in `src/composables/useRedCarWarning.js`.
- The composable reuses the existing `/customer/attributes` endpoint via
`customerAttributeService.js` — no backend change needed.

## UI

- New `RedCarWarning.vue` component renders a dismissable Buefy warning
in the vehicle step of the self-serve flow, with title + reason + care
suggestion.
- Wired into `MyWashStart.vue` via the existing `VehicleInputSection` /
`SelfServeVehicleStep` props. The composable is called with the
effective customer number (authenticated subuser or typed-in).
- Translations added in all 5 locales: `da`, `en`, `sv`, `de`, `no`
(source + regenerated runtime files).

## Tests

- 22 detector unit tests (positive, negative, case-insensitive, custom
keys, dedupe, normalisation).
- 5 i18n key presence tests across all 5 locales.
- 4 `RedCarWarning` component tests (conditional render, dismiss
wiring).

All new + existing related tests pass: `vitest run` on
`red-car-detector`, `red-car-warning-i18n`, `red-car-warning`,
`my-wash-start`, `customer-rule-registry`, `customer-rule-tooltip` →
**84/84 green**.

## Out of scope / not touched

- Backend / API: reused existing `/customer/attributes` endpoint.
- `openclaw.json`, deployment, merge — not touched (per task
constraints).
- Customer-rule registry: not added to `CUSTOMER_RULE_DEFINITIONS`
because the red-car flag is a soft warning, not a product-restriction
rule. If Mads wants it surfaced in the customer rule manager UI, that is
a follow-up.

## Files

- `src/composables/redCarDetector.js` (new)
- `src/composables/useRedCarWarning.js` (new)
- `src/components/displays/selfServe/RedCarWarning.vue` (new)
- `src/components/displays/selfServe/SelfServeVehicleStep.vue` (prop +
render)
-
`src/views/dashboards/userDashboard/wash/components/VehicleInputSection.vue`
(prop pass-through)
- `src/views/dashboards/userDashboard/wash/MyWashStart.vue` (composable
+ prop binding)
- `src/i18n/source/{da,en,sv,de,no}/phrases/compat/self_wash/index.json`
(new keys)
- `src/i18n/generated/{da,en,sv,de,no}-v2.json` (regenerated)
- `tests/unit/red-car-detector.spec.js` (new)
- `tests/unit/red-car-warning-i18n.spec.js` (new)
- `tests/unit/red-car-warning.spec.js` (new)

Refs: TRU-99

---------

Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: Frontend Subagent <frontend-agent@openclaw.local>
Co-authored-by: Pleno Bugfix Bot <bugfix-bot@pleno.local>
2026-08-16 16:22:05 +02:00
Jeppe B 19fbbcddce test(pleno-vue): snapshot-lock SELF_SERVE_TASK_BUTTON_OPTIONS shape (TRU-19) (#316)
Adds a snapshot test that locks the full {id, name, description} triple for every entry in SELF_SERVE_TASK_BUTTON_OPTIONS. The previous test only checked counts and that the ids were sequential; this test catches reordering, renames, and unexpected entries as well.

The wash bay exposes 12 programs and 3 special buttons (reset / program picker / start). The dashboard renders one entry per program number from this array, so the entire array must remain exactly 15 entries long.

Test result: 10/10 vitest passed (was 9, +1 new snapshot test)

Refs TRU-19
2026-08-16 13:00:19 +02:00
Jeppe B 7817f0c13b fix(pleno-vue): widen flagged-wash date window to ±7d (TRU-10) (#315)
Closes the user-reported symptom where washes spanning midnight fell outside the visible date window when navigating from an invoice-period flag.

Changes:
- Add FLAGGED_WASH_DATE_WINDOW_DAYS = 7 constant
- Add shiftDateOnly() helper that pads an ISO-8601 date by ±N days (UTC math, no DST drift)
- initialDateFrom/initialDateTo now compute [start - 7d, start + 7d] when a flagged wash is in the route
- Add vitest source assertion to lock the new behavior

Test result: 68/68 vitest passed (was 67, +1 new test)

Refs TRU-10
2026-08-16 13:00:17 +02:00
Jeppe BandJeppe c19aeffb98 fix(i18n): add no_xlvask_usage_log_metadata to en, de, no, sv locales (#314)
Resolves the 'da v2 key set should match en v2' failure on master after
PR #313 landed.

The first i18n fix PR (#313) only updated the da source. The 'da v2 key
set should match en v2' test in i18n-v2-integrity.spec.ts asserts that
all 5 active locales' generated v2 files have the same key set. Adding
the new key to da without adding it to en/de/no/sv left their v2 files
out of sync, breaking the gate.

This commit adds the key to all 4 non-da active locales. Each phrase
delegates the registration label to the existing 'xlvask_usage_log' key.
Sources are now consistent across da, de, en, no, sv.

After this lands, master should be green across all Quality-i18n /
Quality-lint / unit / e2e-pr-changed / e2e-pr-pr / e2e-pr-ct suites.

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
2026-08-16 10:50:07 +02:00
Jeppe BandJeppe 2e1d7f3e8f fix(i18n): make no_xlvask_usage_log_metadata a linked reference in global fallback (#313)
Resolves the failing master CI on pleno-vue after PR #312.

## Problem

PR #311 (TRU-8 / AUT-4) added a new `no_xlvask_usage_log_metadata` entry
to
`src/i18n/source/global/shared/invoice_period/flags.json` as a literal
Danish string. The integrity test added in PR #306 (TRU-9):
`tests/e2e/i18n-v2-integrity.spec.ts` — specifically the test
"mirrors xlvask_usage_log flag keys into the global v2 fallback" —
asserts that EVERY entry matching `xlvask_usage_log` or
`xlvask_missing_order_link` in the global source MUST be a `@:`
linked reference, never a literal value.

Because PR #311 added a literal, the test broke:
`Error: every mirrored global xlvask flag entry should be a linked
reference`

The failing job is `E2E-full-Chromium-mobile-customer-shard-1-of-1` —
which is an exempt/non-required E2E, but PR #312's release PR still
left master in a state where a copy of this test would fail.

## Fix

This commit:

1. Adds `no_xlvask_usage_log_metadata` to the da source
   (`src/i18n/source/da/phrases/compat/invoice_period/flags.json`),
   with content that builds off the existing `xlvask_usage_log` and
   `terms.glossary.for` keys — no user-facing text change.
2. Converts the global entry to the canonical linked-reference form:

`@:{'phrases.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}`
3. Regenerates `src/i18n/generated/global-v2.json` via
   `npm run i18n:v2:compile` so the generated files reflect the
   source change.

## Test impact

After the change, the `i18n-v2-integrity.spec.ts` test will pass again:

- The da source has `preview.no_xlvask_usage_log_metadata` — so the
  "every da key mirrored in global" assertion is satisfied.
- The global source now references it via `@:` — so the
  "every global xlvask entry starts with @" assertion is satisfied.

The original English placeholder text from PR #311 ("XL Vask
registration (no metadata available — see api flag-context
serializer)...") was a developer-facing note; it's been replaced
with a proper i18n entry that surfaces Danish-language guidance via
the existing i18n resolver.

This commit targets master directly because master is currently red.

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
2026-08-16 09:59:36 +02:00
7c9eb8b644 chore(release): merge develop into master — XL Vask integration fixes (Aug 15 2026) (#312)
Brings all of the develop branch's commits into master.

## What this contains

The 9 commits on develop that landed in this round — all XL Vask-related
UI fixes plus the 'visible primary-button hover state' visual-diff:

- **PR #303** (TRU-5 / AUT-1) — style(AUT-1): visible primary-button
hover state + visual diff
- **PR #304** (TRU-10 / AUT-6) — fix(invoicing-period): propagate
flagged wash start date to Selvvask view
- **PR #305** (TRU-12 / AUT-8) — feat(xlvask): render friendly notice
for 404 from /modules/xlvask/services/usage/orders
- **PR #306** (TRU-9 / AUT-5) — i18n(test): lock in xlvask_review /
xlvask_usage_log mirroring to the global v2 fallback
- **PR #307** (TRU-13 / AUT-9) — i18n(xlvask_review): translate missing
keys for no, sv, de, en
- **PR #308** (TRU-15 / AUT-11) — test(e2e): add Playwright smoke test
for XL Vask flag → Selvvash navigation
- **PR #309** (TRU-11 / AUT-7) — feat(TRU-11): propagate department
selector to Selvvask usage query
- **PR #310** (TRU-19 / AUT-15) — test(TRU-19): lock self-serve program
number range + button registry contract
- **PR #311** (TRU-8 / AUT-4) — fix(invoicing-flag-list): explain empty
XL Vask hover preview when flag context has no metadata

## Why

The XL Vask integration bug surfaced from the user-reported message "XL
Vask-registreringen er hverken ignoreret eller knyttet til en ordre i
den valgte periode. doesn't show the wash." After dispatching 9
diagnostic + fix tasks and merging all 9 PRs into develop via the
OpenSymphony orchestrator running against MiniMax M3, this PR is the
canonical release to bring develop's accumulated changes into master.

No new code in this PR — just the squash-merged output of the 9 source
PRs combined into a single develop→master merge.

## Verification

All 9 source PRs passed:
- Required CI (Action Runners)
- App Store Readiness  
- Quality lint/i18n/build/unit/e2e suites

The required checks on this PR will run the same gate.

## Notes

- The api repo has its own equivalent PR/merge — see CHANGELOG for that
side.

---------

Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-16 08:58:06 +02:00
Jeppe BandWorktree Fix Verifier e2cc76091f autoheal(test): re-target audited add-on note test from product 24 to 25 (#302)
Fixes master CI failure: E2E-full-Chromium-mobile-admin-shard-2-of-2

- SHA: 4db3be34
- Failing check: E2E-full-Chromium-mobile-admin-shard-2-of-2, Full E2E
summary
- Run: 31768269122
- Root cause: PR #301 dropped product 24 from
AUDITED_ORDER_ITEM_PRODUCT_IDS and updated the auditedProduct fixture
(id 24→25), but the e2e test still targeted product 24 via token,
reference, test ID, and orderItemCreates lookup. Re-target those four
identifiers from 24 to 25 so they align with the updated fixture and the
AUDITED set {21, 22, 25, 26, 27}.

Files touched: tests/e2e/pos-mobile-order-flow.spec.js (7 insertions, 7
deletions).

Filed by master-autoheal-agent cron.

Co-authored-by: Worktree Fix Verifier <agent@truckwash.local>
2026-08-14 11:33:04 +02:00
Jeppe Bandopenhands 4db3be34f8 fix(pleno-vue): exclude spot-free-lastbil from audited add-on note dialog (#301)
## Problem

The mobile POS step 2 \"enter note\" dialog was triggering for product
24
(\"Højtryk - ekstra tid\" / spotfree-lastbil) because product 24 was
listed
in both:

- `AUDITED_ORDER_ITEM_PRODUCT_IDS` in
`src/components/shop/OrdersItems.vue`
- `AFFECTED_PRODUCT_IDS` in
`services/nginx/app/classes/order_item_reason_policy.php`

Product 24 is the \"spot-free-lastbil\" package, not an audited
extra-time
add-on — the dialog should only appear for {21, 22, 25, 26, 27}.

## Fix

Drop product 24 from both source-of-truth lists, plus the matching test
fixtures and the e2e fixture.

## Changes

- `src/components/shop/OrdersItems.vue`: drop 24 from
  `AUDITED_ORDER_ITEM_PRODUCT_IDS` Set.
- `tests/unit/orders-items.spec.js`: drop 24 from `auditedProductIds`,
  swap the three `createOrderItem(...)` call sites that used 24 for 25,
  and add an explicit `AUDITED_ORDER_ITEM_PRODUCT_IDS` membership test
  that locks down 24 == false.
- `tests/e2e/support/mobilePos.js`: mirror the
`AUDITED_ORDER_ITEM_PRODUCT_IDS`
  change so the e2e harness matches the production set.
- `tests/e2e/pos-mobile-order-flow.spec.js`: re-target the \"prompts for
a
  required reason note for audited add-on products that are not the
  chemistry product\" case from product 24 to product 25
  (\"Fælg flex pr. enhed\"), since 24 is no longer audited.
- (api) `services/nginx/app/classes/order_item_reason_policy.php`: drop
24
  from `AFFECTED_PRODUCT_IDS` (companion change in a separate PR in the
  api repo).

## Verification

- 1761/1762 unit tests pass locally (the one failure is an unrelated
  `cpanel-deploy.spec.js` case that requires the system `zip` binary).
- Lint passes.
- Production build succeeds.

Tracked under workboard-94209138-31f6-422e-ac8c-181ad391b8a7.

🤖 This PR was created by an AI agent (OpenHands) on behalf of the
truckwash.io team.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 21:00:32 +02:00
b1e0c61df0 Collect audit reason for extra time sales (#240)
## Summary
- Add a shared POS audit helper for approved 10-minute extra sale
reason/comment payloads.
- Prompt for audit metadata in desktop add/copy, desktop item edit,
booking hydration, and mobile completion rebuild flows.
- Include preview evidence files under
`docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/`.

## Verification
- `npm ci --legacy-peer-deps`
- `npm run lint`
- `npm run build`

## Visual change previews
### View: POS extra sale audit
**Description:** POS order item add/edit flows now require an approved
reason for “10 min ekstra”, with a comment field available and required
for the `other` reason.

#### Mobile (390x844)
**Before:**
![Mobile
before](../blob/69fa62f6e38e5c6a28d6aa00c95f749df33b3e0a/docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-before.png?raw=true)

**After:**
![Mobile
after](../blob/69fa62f6e38e5c6a28d6aa00c95f749df33b3e0a/docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-after.png?raw=true)

#### Tablet (768x1024)
**Before:**
![Tablet
before](../blob/69fa62f6e38e5c6a28d6aa00c95f749df33b3e0a/docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-before.png?raw=true)

**After:**
![Tablet
after](../blob/69fa62f6e38e5c6a28d6aa00c95f749df33b3e0a/docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-after.png?raw=true)

#### Desktop (1440x900)
**Before:**
![Desktop
before](../blob/69fa62f6e38e5c6a28d6aa00c95f749df33b3e0a/docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-before.png?raw=true)

**After:**
![Desktop
after](../blob/69fa62f6e38e5c6a28d6aa00c95f749df33b3e0a/docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-after.png?raw=true)

## Notes
- Automatic merge remains disabled per Workboard contract.

---------

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 17:49:27 +02:00
Jeppe Bandopenhands eb8482585b fix(orders): make reason_comment fallback robust for audited order items (#300)
## Problem

POST /master/api/order/items still returns

> Product 24: Reason comment is required for this product

for products in {21, 22, 24, 25, 26, 27}, even after #296 landed the
mobile POS step 2 note prompt.

The previous `buildAuditedOrderItemReasonPayload` only fell back through
`reason.reason_comment → reason.comment → notes → ''`. Any code path
that
calls `createOrderItem` without populating `notes` (copy-last-wash,
future callers, or even a user who clears the prompt) sent
`reason_comment: ""` and the backend correctly rejected it.

## Fix

* `buildAuditedOrderItemReasonPayload` now uses a `trimmedFirstNonEmpty`
  helper and walks
`reason.reason_comment → reason.comment → notes →
DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL`
  so the produced `reason_comment` is **never** empty for audited
  products. `reason_code` and `reason_label_snapshot` keep the same
  fallback semantics.
* `createCopiedOrderItem` in `POSDepartmentProcess.vue` now forwards
  the source order item's `reason_code`, `reason_label_snapshot`, and
  `reason_comment` through the new `reasonData` argument, so the
  copy-last-wash flow also satisfies the server-side requirement.

## Tests

`tests/unit/orders-items.spec.js` now covers:

* `createOrderItem` audits products {21,22,24,25,26,27} and emits
  non-empty `reason_comment` even when `notes` is missing
* `reason_comment` falls back to `notes` (trimmed)
* `reason_comment` falls back to the default label when both
  `reasonData` and `notes` are empty / whitespace
* `reasonData` overrides win over `notes`
* non-audited products still don't emit any reason fields
* `AUDITED_ORDER_ITEM_PRODUCT_IDS` membership is locked down

14/14 tests pass locally.

## Production evidence

* Production bundle `Addons-*.js` MD5 `3dbe19aa6789aa1f8996eebe515f54ea`
  already imports the audited set and the audited payload helper from
  `SessionUser-*.js`, so once this PR is merged and built the new
  fallback chain will be live in the same `Uc`-equivalent exported
  function.

🤖 Generated with [OpenHands](https://openhands.dev) on behalf of the
truckwash.io team.

Co-authored-by: openhands <openhands@all-hands.dev>

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 16:40:36 +02:00
Jeppe Bandopenhands c207fea61e feat(period): render customer indicator chips on every subpage including Alle (#299)
## Summary

Pairs with
[copenhagentruckwash/api#371](https://github.com/copenhagentruckwash/api/pull/371)
to render category indicator chips (e.g. *Faktura pr. ordre*,
*Fastpris*, *Tankrengøring*) on every Superuser → Fakturaer → Periode
subpage, including the *Alle* tab.

## What changed

* `InvoicingBillingPeriodCustomerAttributes.vue` pre-computes a
`Set<customer_number>` per view bucket so membership lookups are O(1)
regardless of bucket size. The component already iterated
`sharedVariables.types`; this PR just hoists the membership check out of
the per-chip `Array.some()` into a precomputed Set index.
* Skips entries that don't carry a positive integer `customer_number` so
non-numeric or null payloads from legacy clients stay inert.
* Honours the deterministic `ATTRIBUTE_DISPLAY_PRIORITY` ordering across
the chips.

## Tests

### Unit (vitest, jsdom)


`tests/unit/invoicing-billing-period-customer-attributes-membership.spec.js`
adds five focused tests covering:

* active-bucket full-card path,
* lightweight-membership rendering on the *Alle* tab,
* explicit `all` exclusion from chip membership,
* defensive numeric guard for malformed entries,
* deterministic display order across buckets.

### e2e (Playwright)

* New `@smoke` spec "period customer attribute chips render on every
subpage including Alle" validates that `invoice_per_order`,
`fixed_pricing`, and `tank_cleaning` chips all render on the *Alle* tab
and that single-category customers render exactly one chip.
* Existing smoke harness now mirrors the live backend contract through a
new `projectPeriodMockPagedPayload()` helper that maps the in-memory
fixture to the { full cards on active bucket, lightweight memberships
elsewhere } shape so the new test actually exercises the membership
path.

## Plan

`docs/invoicing-period-tag-membership-plan.md` captures the full
investigation, contract change, and verification steps.

🤖 Generated by [OpenHands](https://docs.openhands.dev/) on behalf of
copenhagentruckwash.

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 14:36:23 +02:00
Jeppe Bandopenhands 01c5864382 test(services): add unit tests for localeFormatting helpers (#298)
Adds 24 unit tests covering the five formatters exported by
`src/services/localeFormatting.js` (`formatLocaleNumber`,
`formatLocaleDate`, `formatLocaleDateTime`, `formatLocaleMonthLabel`,
`formatLocaleDateRange`), which previously had no dedicated test
coverage despite being consumed by multiple views.

The new spec follows the existing `tests/unit/**/*.spec.js` conventions:
- kebab-case file name (matches `date-only.spec.js` ↔ `dateOnly.js`)
- imports via the `@/` alias defined in `vitest.config.js`
- timezone-stable inputs (YYYY-MM-DD strings and `new Date(y, m, d)`
constructors)

Coverage added:
- `formatLocaleNumber`: locale-specific separators, fallback to `en` for
empty/null/undefined locale, whitespace trimming, NaN/non-numeric
coercion, currency option pass-through
- `formatLocaleDate`: YYYY-MM-DD and Date object inputs, custom option
merging, empty/invalid handling
- `formatLocaleDateTime`: hour/minute inclusion, default time fields,
custom option override, invalid input
- `formatLocaleMonthLabel`: long month + year, Date instance support,
invalid input
- `formatLocaleDateRange`: full range, single-date collapse,
missing/invalid start or end, both-empty, locale-aware output

This PR was created by an AI agent (OpenHands) on behalf of the user.

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 12:43:29 +02:00
Jeppe Bandopenhands c01596aeb5 fix(pleno-vue): prompt for audited add-on note on mobile POS step 2 (#296)
## Summary

Fixes the mobile POS step-2 400 on `POST /order/items` for audited
add-on products (21, 22, 24, 25, 26, 27) such as product 24.

Reproduces on
https://truckwash.io/admin/12/modules/pos?id=76596&customer_id=12345679&step=2:

```
Request body:  {"order_id":76596,"product_id":24,"quantity":1,
                "related_item_id":193235,"notes":"",
                "reason_code":"customer_approved_extra_work",
                "reason_label_snapshot":"Kunde godkendte ekstra arbejde",
                "reason_comment":""}
Response:      400 {"success":false,"data":{"message":"Reason comment is required for this product"}}
Component trace: OrderItemsPartialSyncError: Product 24: Reason comment is required for this product
```

`syncMobileOrderItems` did roll back already-created sibling add-ons
correctly; the user-side prompt was missing.

## Root cause

`PosDepartmentStepMobile2.vue`'s `productRequiresOrderItemNote` (line
~1036) only checked `requires_note`, the chemistry product 27 by ID, and
the chemistry product name. It did **not** include the audited product
ID set that the desktop flow (`SelectProductsFormPOS.vue:447`) and the
server policy (`order_item_reason_policy.php` `AFFECTED_PRODUCT_IDS`)
both rely on.

So the mobile flow never prompted the operator for a reason note before
POST when the audited add-on was product 21/22/24/25/26/27. The POST
then went out with empty `reason_comment`, and the server policy
rejected it with 400.

## Fix

Three minimal changes, mirroring the desktop flow:

1.
**`src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue`**
- Import the existing `AUDITED_ORDER_ITEM_PRODUCT_IDS` export from
`OrdersItems.vue`.
- Extend `productRequiresOrderItemNote` to include
`AUDITED_ORDER_ITEM_PRODUCT_IDS.has(getProductId(product))`.
- Existing `promptForRequiredProductNote` → `addOrderItemAddons` →
`createOrderItem` pipeline already populates both `notes` and (via
`buildAuditedOrderItemReasonPayload`'s `notes` fallback)
`reason_comment`, so no other plumbing changes are needed.

2. **`tests/e2e/support/mobilePos.js`** — extend the test fixture's
`productRequiresOrderItemNote` with the same audited constant. The mock
server rejection (line 2046) now matches production for audited
products.

3. **`tests/e2e/pos-mobile-order-flow.spec.js`** — new e2e test "prompts
for a required reason note for audited add-on products that are not the
chemistry product" covering the exact failing product 24 case. Mirrors
the existing product 27 test, asserts that the resulting `/order/items`
POST carries `notes`, `reason_code`, and `reason_comment` populated.

## Verification

- `vitest run` of directly related suites: order-items-addon-fanout,
pos-mobile-step-2-addon-sync, pos-order-item-product-reconciliation →
37/37 pass
- `eslint` and `prettier --check` clean on all three modified files
- Pre-commit hook auto-formatted the diff during commit

No new dependencies. Reuses existing exports.

```
 PosDepartmentStepMobile2.vue  |  8 +++++++-
 pos-mobile-order-flow.spec.js | 88 ++++++++++++++++++++++++++++++++++++++++++++
 mobilePos.js                  |  2 ++
```

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-13 12:17:21 +02:00
Jeppe B 5d4de1d932 chore(agent-mcp-smoke): verify GitHub MCP write/PR wiring (#297)
Generated automatically by Hermes to verify the GitHub MCP is wired into
OpenHands.

Safe to close — no production change.
2026-08-13 11:42:25 +02:00
128 changed files with 4418 additions and 239 deletions
-66
View File
@@ -1,66 +0,0 @@
name: Qodana
on:
workflow_dispatch:
pull_request:
branches: [master, beta, canary, internal]
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [master, beta, canary, internal]
permissions:
contents: read
checks: write
pull-requests: write
concurrency:
group: qodana-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: true
jobs:
qodana:
name: Qodana
if: >-
github.event_name != 'pull_request' ||
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login != 'dependabot[bot]'
)
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout repository
# v5.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Require Qodana project token
shell: bash
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
run: |
set -euo pipefail
if [[ -z "${QODANA_TOKEN:-}" ]]; then
echo "::error::QODANA_TOKEN is not configured for this repository."
exit 1
fi
- name: Qodana
# v2026.1.3
uses: JetBrains/qodana-action@b588768b6e7e6da579e518bc584f79de0d243692
with:
use-caches: true
cache-default-branch-only: true
upload-result: false
use-annotations: true
pr-mode: ${{ github.event_name == 'pull_request' }}
post-pr-comment: true
github-token: ${{ github.token }}
push-fixes: none
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
+112
View File
@@ -0,0 +1,112 @@
name: Deploy pleno-vue to Hetzner (staging)
on:
push:
branches: [master]
workflow_dispatch:
concurrency:
group: deploy-pleno-vue
cancel-in-progress: false
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
jobs:
test-and-deploy:
name: Build + Deploy
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'npm'
- name: Install + Build
run: |
npm ci --ignore-scripts
npm run build
- name: Setup SSH
uses: webfactory/ssh-agent@v0.10.0
with:
ssh-private-key: ${{ secrets.DEPLOY_SSH_KEY }}
- name: Add host key
run: |
mkdir -p ~/.ssh
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Pre-deploy snapshot
id: pre
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git rev-parse HEAD > /tmp/last_deploy_sha
echo "pre_sha=$(cat /tmp/last_deploy_sha)" >> $GITHUB_OUTPUT
'
- name: Deploy
id: deploy
run: |
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git fetch origin master
git reset --hard origin/master
npm ci --ignore-scripts
npm run build
sudo systemctl reload nginx || true
sudo systemctl reload pleno-vue || true
echo "Deploy complete: $(git rev-parse --short HEAD)"
'
- name: Smoke test
id: smoke
continue-on-error: true
env:
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
run: |
bash scripts/smoke-test.sh "$SMOKE_BASE_URL"
- name: Sergii Review Batch smoke test (TRU-96)
id: smoke_sergii
continue-on-error: true
env:
SMOKE_BASE_URL: ${{ secrets.SMOKE_BASE_URL }}
run: |
bash scripts/smoke-test-sergii.sh "$SMOKE_BASE_URL"
- name: Auto-rollback on smoke failure
if: steps.smoke.outcome == 'failure' || steps.smoke_sergii.outcome == 'failure'
run: |
reason="$([ "${{ steps.smoke.outcome }}" = 'failure' ] && echo 'generic smoke' || echo 'Sergii Review Batch smoke')"
echo "::error::$reason test failed — rolling back to ${{ steps.pre.outputs.pre_sha }}"
ssh "$DEPLOY_USER@$DEPLOY_HOST" '
set -e
cd /opt/pleno-vue
git reset --hard ${{ steps.pre.outputs.pre_sha }}
npm ci --ignore-scripts
npm run build
sudo systemctl reload nginx || true
'
- name: Post Slack status
if: always()
uses: slackapi/slack-github-action@v4.0.0
with:
channel-id: ${{ secrets.AI_DAILY_CHANNEL }}
payload: |
{
"text": "${{ job.status == 'success' && '✅' || '❌' }} Deploy *pleno-vue@${{ github.sha[0:7] }}* — ${{ job.status }}\n${{ steps.smoke.outcome == 'failure' && '⚠️ Generic smoke FAILED → auto-rolled back' || steps.smoke_sergii.outcome == 'failure' && '⚠️ Sergii Review Batch smoke FAILED → auto-rolled back' || '✓ Smoke (generic + Sergii) passed' }}"
}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
+1 -1
View File
@@ -1,4 +1,4 @@
source "https://rubygems.org"
ruby ">= 3.2", "< 3.5"
gem "fastlane", "2.237.0"
gem "fastlane", "2.238.0"
+69 -53
View File
@@ -8,8 +8,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1271.0)
aws-sdk-core (3.254.0)
aws-partitions (1.1281.0)
aws-sdk-core (3.254.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -20,8 +20,8 @@ GEM
aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.228.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sdk-s3 (1.229.0)
aws-sdk-core (~> 3, >= 3.254.1)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -35,45 +35,33 @@ GEM
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.5)
csv (3.3.6)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (1.6.0)
erb (6.0.7)
excon (1.7.0)
logger
faraday (2.14.3)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-follow_redirects (0.5.0)
faraday (>= 1, < 3)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
faraday-net_http (3.4.4)
net-http (~> 0.5)
faraday-retry (2.4.0)
faraday (~> 2.0)
fastimage (2.4.1)
fastlane (2.237.0)
fastlane (2.238.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
@@ -89,9 +77,11 @@ GEM
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 2.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
faraday (~> 2.7)
faraday-cookie_jar (~> 0.0.8)
faraday-follow_redirects (~> 0.3)
faraday-multipart (~> 1.0)
faraday-retry (~> 2.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
@@ -101,6 +91,7 @@ GEM
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
irb (>= 1.8)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
@@ -117,7 +108,7 @@ GEM
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
terminal-table (~> 4)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
@@ -126,21 +117,22 @@ GEM
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.105.0)
google-apis-androidpublisher_v3 (0.106.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
google-apis-core (1.2.5)
addressable (~> 2.9)
faraday (~> 2.13)
faraday-follow_redirects (~> 0.3)
googleauth (~> 1.14)
mini_mime (~> 1.1)
multi_json (~> 1.11)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
retriable (>= 3.1, < 5.0)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.65.0)
google-apis-storage_v1 (0.66.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
@@ -159,7 +151,7 @@ GEM
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.0)
googleauth (1.17.1)
googleauth (1.17.3)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
@@ -170,10 +162,14 @@ GEM
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
io-console (0.9.2)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.21.1)
json (2.21.2)
jwt (3.2.0)
base64
logger (1.7.0)
@@ -184,22 +180,38 @@ GEM
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
net-http (0.9.1)
uri (>= 0.11.1)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pp (0.6.4)
prettyprint
prettyprint (0.2.0)
prism (1.9.0)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
rbs (4.1.3)
logger
prism (>= 1.6.0)
tsort
rdoc (8.0.0)
erb
prism (>= 1.6.0)
rbs (>= 4.0.0)
tsort
reline (0.7.0)
io-console (~> 0.5)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.8.0)
retriable (4.2.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
@@ -210,15 +222,19 @@ GEM
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
trailblazer-option (0.1.2)
tsort (0.2.0)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
@@ -239,7 +255,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
fastlane (= 2.237.0)
fastlane (= 2.238.0)
RUBY VERSION
ruby 3.3.12p206
+4
View File
@@ -0,0 +1,4 @@
# AGENT MCP SMOKE
Generated 20260813-091957 by hermes agent to verify GitHub MCP wiring.
Safe to close.
@@ -0,0 +1,477 @@
# Plan: Show customer tags on every "Superuser → Fakturaer → Periode" subpage
## Goal
Today, the customer indicator chips (e.g. "Faktura pr. ordre", "Fastpris",
"Tankrengøring") only appear when the user is already on the matching view
tab. On the "Alle" tab the chips never show, even when a customer actually
belongs to several categories.
We want every chip to render on every subpage whenever the customer belongs
to that category — independent of which view tab is active.
---
## 1. Root cause (already confirmed by investigation)
### Front-end rendering path
* `Right.vue` (line ~300+) declares view tabs and fetches
`/superuser/invoicing/period` with the corresponding `periodView` query
param (`all`, `invoice_per_order`, …).
* `InvoicingBillingPeriodViewAll.vue` is rendered for every active view
(including `all`). It reads the active bucket via
`view.variables.sharedVariables.value.types[componentName]`.
* For each customer card it mounts
`InvoicingBillingPeriodCustomerAttributes.vue`, which computes
`list_views_with_customer`:
```ts
const list_views_with_customer = computed(() => {
const matched = view_keys.value.filter((view_key) => {
if (view_key === 'all') return false;
const view_type = sharedTypes.value[view_key];
return view_type && view_type.some(
(v: any) => v.customer_number === props.customer.customer_number,
);
});
});
```
It only treats a customer as belonging to a view if
`types[view_key]` contains an entry with the same `customer_number`.
### Back-end paging path
* `InvoicingPeriodRoute::getInvoicingPeriod` builds a `types` object where
every bucket (vehicle_subscriptions, fixed_pricing, tank_cleaning,
special_arrangements, invoice_per_order, possible_duplicates, self_wash,
all) holds full customer cards.
* `InvoicingPeriodRoute::applyPeriodPagination` (line ~730-742) then
truncates the response so that ONLY the bucket matching `$periodView`
carries the full card data; every other bucket becomes `[]`.
```php
$pagedTypes = array_fill_keys(array_keys($types), []);
if ($isAllLimit) {
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
} else {
$offset = ($page - 1) * $perPage;
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
}
```
* The frontend then iterates over the (empty) non-active buckets and finds
no customer entries → no chip is rendered → the bug.
### Why the existing e2e test missed it
`tests/e2e/invoicing-period.smoke.spec.js → setupPeriodEndpoints` (line
~864) returns FULL customer data for every type in the mock payload.
Because the mock already mimics the "pre-fix" backend behaviour (every type
populated), the chip-rendering path is exercised even when the real backend
strips the data. Updating the mock to mirror the new, real backend shape
gives us an end-to-end safety net.
---
## 2. Fix strategy
We want one round trip, no N+1 calls, and a payload that stays bounded.
**Approach: lightweight membership entries**
Extend `applyPeriodPagination` so that, after pagination, every non-active
view bucket is populated with "membership only" entries — each entry is
just `{ customer_number }` so the frontend can resolve membership via the
existing `view_type.some(v => v.customer_number === …)` check.
* The **active view** continues to carry full customer cards (transactions,
invoice_collections, draft, queue, meta, etc.) — no behaviour change for
it.
* **Every other view** carries a `{customer_number: N}` array (one per
matching customer after all filters / search / sort / pagination). No
transactions or auxiliary fields — keeping the payload small.
* `ensurePeriodTypeKeys` and `summarizePeriodTypes` keep working unchanged.
`type_counts` (already computed before pagination) keeps the totals per
view, so tab counters remain correct.
* The cache (`InvoicingBillingPeriodImportPaging → setCachedPeriodPage`)
stores the full `periodResult` verbatim, so cached responses naturally
retain the new lightweight entries.
### Why this option wins
| Approach | Network | Payload | Schema change | UX consistency |
|---|---|---|---|---|
| **Lightweight memberships on every bucket (chosen)** | 1 call | ~150 KB worst case (5 non-active buckets × ~30 KB each) | minimal: membership schema can be additive | ✅ |
| N+1 fetch (per view call) | N+1 calls | n/a | none | ✅ but slow |
| Include full customer data for every bucket | 1 call | ~5-10 MB | none | ✅ but breaks pagination |
---
## 3. Concrete code changes
### 3.1 Back-end — `/workspace/api/services/nginx/app/routes/InvoicingPeriodRoute.php`
In `applyPeriodPagination(...)` (around line 730-742), after the active
bucket is sliced, populate every non-active bucket with lightweight
memberships derived from the already-filtered/searched/sorted `$types`
arrays:
```php
// Existing pagination of the active bucket
$pagedTypes = array_fill_keys(array_keys($types), []);
if ($isAllLimit) {
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
} else {
$offset = ($page - 1) * $perPage;
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
}
// NEW: lightweight memberships for every non-active view so the front-end
// can render category chips regardless of which tab is active.
foreach ($types as $typeName => $customers) {
if ($typeName === $periodView) {
continue;
}
$pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships(
is_array($customers) ? $customers : []
);
}
```
Add a new helper:
```php
/**
* Return a minimal `{customer_number: N}` array per customer so the
* front-end can determine which non-active view buckets the customer
* belongs to without us shipping full transaction/queue data.
*
* Filters, searches, sort and visibility rules have already been applied
* to `$customers` by the time we run, so we just de-duplicate and emit.
*
* @param array<int, array<string, mixed>> $customers
* @return array<int, array{customer_number: int, membership_only: true}>
*/
private static function summarizePeriodCustomerMemberships(array $customers): array
{
$memberships = [];
$seen = [];
foreach ($customers as $customer) {
if (!is_array($customer)) {
continue;
}
$customerNumber = (int) ($customer['customer_number'] ?? 0);
if ($customerNumber < 1 || isset($seen[$customerNumber])) {
continue;
}
$seen[$customerNumber] = true;
$memberships[] = [
'customer_number' => $customerNumber,
'membership_only' => true,
];
}
return $memberships;
}
```
Notes:
* We deduplicate on `customer_number` so a customer appearing twice in a
bucket (rare but possible — multiple PO transactions for the same
customer in `invoice_per_order`) still only emits one membership.
* We keep the existing `ensurePeriodTypeKeys` (`array_fill_keys`) guarantees
so consumers that iterate `Object.keys(types)` still see every view
even when the filtered list ends up empty.
* The active bucket's structure is **unchanged** — the front-end
`customersInCurrentView` and `list_views_with_customer` paths continue to
work as before.
* `type_counts` and `type_totals` are computed before pagination (see
`summarizePeriodTypes`) and remain authoritative for tab counters.
### 3.2 OpenAPI specs
Both repositories carry a copy of the schema and must stay in lock-step.
**`/workspace/api/openapi.yaml`** and **`/workspace/pleno-vue/openapi.yaml`**
The current envelope for `InvoicingPeriod` (`types[view]`) is typed via
`InvoicingPeriodCustomer`, whose `required` list mandates `customer_name`,
`transactions`, `invoice_collections`. Membership entries don't carry those
fields, so we need to relax the `required` constraint on non-active buckets
and document the new shape.
Add a new sibling component:
```yaml
InvoicingPeriodCustomerMembership:
type: object
description: >-
Lightweight customer marker returned for every non-active view bucket.
Used only by the front-end to render category chips (e.g. "Faktura pr.
ordre") regardless of which tab is active. Full transaction / queue
data is intentionally omitted; see InvoicingPeriodCustomer for the
shape returned for the active bucket.
additionalProperties: false
required: [customer_number, membership_only]
properties:
customer_number:
type: integer
minimum: 1
membership_only:
type: true
enum: [true]
```
In the `InvoicingPeriod` schema, switch the `types` property from
`additionalProperties: $ref(InvoicingPeriodCustomer)` to:
```yaml
types:
type: object
additionalProperties:
type: array
items:
oneOf:
- $ref: '#/components/schemas/InvoicingPeriodCustomer'
- $ref: '#/components/schemas/InvoicingPeriodCustomerMembership'
discriminator:
propertyName: membership_only
```
Also relax `InvoicingPeriodCustomer` so `customer_name`, `transactions`,
`invoice_collections`, `meta`, `queue`, `draft`, `requires_action` are no
longer `required` (they remain documented in `properties`). The active
bucket still emits them, but the union makes the membership shape valid.
### 3.3 Front-end — `/workspace/pleno-vue/src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
After the backend fix, the chip rendering logic in
`list_views_with_customer` will start working on every subpage. To keep
performance bounded when buckets grow large, we also turn the membership
arrays into `Set<number>` lookups via a small `computed`:
```ts
const membershipIndexes = computed(() => {
const result: Record<string, Set<number>> = {};
for (const view_key of view_keys.value) {
if (view_key === 'all') continue;
const view_type = sharedTypes.value[view_key];
if (!Array.isArray(view_type)) {
result[view_key] = new Set<number>();
continue;
}
result[view_key] = new Set(
view_type
.map((entry) => Number(entry?.customer_number ?? 0))
.filter((n) => Number.isInteger(n) && n > 0),
);
}
return result;
});
const list_views_with_customer = computed(() => {
const matched = view_keys.value.filter((view_key) => {
if (view_key === 'all') return false;
return membershipIndexes.value[view_key]?.has(props.customer.customer_number) === true;
});
});
```
Behavioural impact:
* Same chip set as today, now visible on every subpage including `Alle`.
* Lookup is O(1) per (view × customer) instead of O(bucket size).
* Defensive against the lightweight entries (no `customer_name`,
`transactions`, etc. fields) — the chip only needs the view's friendly
name, which already comes from `view.computed.getViewFriendlyName(...)`.
### 3.4 Front-end — e2e mock
`tests/e2e/invoicing-period.smoke.spec.js` → `setupPeriodEndpoints`
(line ~864) currently mocks every bucket as fully populated. Update the
mock so that:
* The **active** bucket (whichever the page requested) carries full
customer cards (unchanged).
* Every **other** bucket carries membership-only entries
(`{customer_number, membership_only: true}`).
This mirrors the real backend so the existing chip-stacking test
(`tests/e2e/invoicing-period.smoke.spec.js` lines ~2360-2393) actually
guards the membership path.
---
## 4. Tests to add / update
### 4.1 Backend unit — `/workspace/api/services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
Existing assertion at line 292:
```php
expect($result['period']['types']['fixed_pricing'])->toBe([]);
```
…becomes:
```php
expect($result['period']['types']['fixed_pricing'])
->toBe(array_map(
static fn(int $n): array => ['customer_number' => $n, 'membership_only' => true],
[1001], // the test fixture's other-bucket membership
));
```
Add a new test that, given a period with two customers in `all` and one
in `invoice_per_order`, paging `periodView=all` yields:
* `types.all` — full customer cards (existing behaviour preserved)
* `types.invoice_per_order` — one lightweight membership entry
* `types.fixed_pricing` / `types.tank_cleaning` / etc. — empty arrays (no
matching customers, so nothing to emit)
Add a search-aware test: searching for "Beta" while paging
`periodView=all` must surface the lightweight membership only for
customers that pass the filter, mirroring the active bucket.
Add a flag-tab-aware test: the `red` flag filter must propagate to the
membership arrays just as it does to `type_counts`.
### 4.2 Front-end unit — `tests/unit/superuser-invoices-view.spec.js` (or new spec)
Add a focused Vitest spec
`tests/unit/invoicing-period-customer-attributes.spec.js` that mounts
`InvoicingBillingPeriodCustomerAttributes` with a stubbed
`sharedVariables.value.types` containing:
```ts
{
all: [...full cards],
invoice_per_order: [{customer_number: 1001, membership_only: true}, …],
fixed_pricing: [],
}
```
…and asserts that the rendered chips include "Faktura pr. ordre" (and any
other categories the stubbed customer is a member of), independent of
which view tab is "active" in the stub.
### 4.3 E2E — `tests/e2e/invoicing-period.smoke.spec.js`
* Update `setupPeriodEndpoints` (line ~864) so the mock returns
membership-only entries for non-active buckets — matching the real
backend contract.
* Extend the existing chip-stacking test (lines ~2360-2393) to assert
that on the `Alle` tab the rendered customer cards include the
"Faktura pr. ordre" chip, "Fastpris" chip, "Tankrengøring" chip, etc.
* Add a new spec scenario:
`Given: Alle tab with mixed customers across categories. When: page
loads. Then: every customer card shows chips for every category it
belongs to.` Guarded with `@smoke` so it runs in the PR pipeline.
### 4.4 OpenAPI consistency
Run `node scripts/check-openapi-drift.mjs` (if present) or the equivalent
script in `scripts/sync-ai-workflow.mjs` to verify that the two
`openapi.yaml` files remain aligned. If a drift check is not wired up, add
it so future schema edits surface in CI.
---
## 5. Verification steps (manual + automated)
### 5.1 Manual smoke test (in dev)
1. `bash scripts/setup.sh` (or the appropriate docker compose command) to
bring up the API stack.
2. `cd /workspace/pleno-vue && npm run dev`.
3. Sign in as a superuser that owns customers spanning multiple categories
(fixed_pricing + invoice_per_order, for instance).
4. Navigate to **Superuser → Fakturaer → Periode**, pick a date range.
5. On the **Alle** tab confirm every customer card shows every chip it
qualifies for.
6. Click into the **Faktura pr. ordre** tab and confirm the same chips
render (sans the active tab's own chip).
7. Repeat for **Fastpris**, **Tankrengøring**, **Wash Subscriptions**.
8. Apply the search box; chips should update with the filter.
9. Toggle the **Kræver handling** flag tab; chips should narrow to the
flagged subset.
10. Switch page sizes (10/25/50/100/200/500/all) and confirm chips remain
consistent across pages.
11. Reload the page — chips must persist from the cache layer
(`setCachedPeriodPage`) and not flash empty.
### 5.2 Automated
* Backend unit tests: `bash scripts/php-ci-test.sh unit` (in CI; locally
inside `php1` container per `scripts/setup.sh`).
* Backend static analysis: `composer analyse` (phpstan).
* Backend rector dry-run: `composer rector:dry-run`.
* Front-end unit: `npm run test:unit`.
* Front-end e2e (smoke): `npm run test:e2e:smoke`.
* Front-end e2e (PR slice): `npm run test:e2e:pr`.
* Front-end lint: `npm run lint:strict`.
* AI workflow sync: `node scripts/sync-ai-workflow.mjs --check`.
### 5.3 CI checks to watch
* `.github/workflows/tests.yml` (api) — PHP matrix
(`unit`/`integration`/`api`/`legacy`) and Edge Agent job.
* `.github/workflows/tests.yml` (pleno-vue) — Playwright e2e matrix.
* `.github/workflows/code_quality.yml` — Qodana scan.
---
## 6. Roll-out plan
1. Branch: cut `fix/invoicing-period-tag-membership` from `master` in
`api` and from `pr-296` (current dev branch) in `pleno-vue`.
2. Backend change (3.1) + new helper + updated/new unit tests (4.1).
3. OpenAPI updates (3.2) in both repos.
4. Frontend attribute component (3.3) — add the `Set` index, keep the
array `.some()` fallback for back-compat.
5. E2E mock update (3.4) + extended chip-stacking test (4.3).
6. Run the full verification suite (5.2) locally before pushing.
7. Open the PR; CI should turn green; Qodana should not flag the new
memberships (they are deliberate additive fields).
8. After merge, monitor the period page in staging for payload size and
chip rendering parity.
---
## 7. Risk assessment
| Risk | Likelihood | Mitigation |
|---|---|---|
| Payload bloat from membership entries | Low | Memberships are `{customer_number}` only — ~30 KB per bucket at 1000 customers. |
| Frontend perf regression on huge pages | Low | `Set`-based membership index in `InvoicingBillingPeriodCustomerAttributes` makes lookup O(1). |
| OpenAPI drift between repos | Medium | Existing `sync-ai-workflow.mjs` check + new schema explicitly documents the `oneOf` shape. |
| Cache returning stale (pre-fix) data | Low | Cache TTL is 10 min (`PERIOD_CACHE_TTL_MS`); a reload or hard refresh clears it. No schema-driven cache busting required for this change. |
| Active bucket inadvertently slimmed | Low | Active bucket code path is untouched; existing `customersInCurrentView` consumers keep working. |
---
## 8. Files touched (summary)
**Backend (`/workspace/api`):**
* `services/nginx/app/routes/InvoicingPeriodRoute.php` — add
`summarizePeriodCustomerMemberships`, populate non-active buckets.
* `services/nginx/app/tests/Unit/Invoicing/InvoicingPeriodPaginationTest.php`
— relax line 292, add membership / search / flag-tab tests.
* `openapi.yaml` — add `InvoicingPeriodCustomerMembership`, relax
`InvoicingPeriodCustomer` requireds, union-typed `types` items.
**Front-end (`/workspace/pleno-vue`):**
* `src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue`
— `Set`-based membership index.
* `tests/unit/invoicing-period-customer-attributes.spec.js` — new spec.
* `tests/e2e/invoicing-period.smoke.spec.js` — mock reflects real backend
shape, extended chip-stacking assertions.
* `openapi.yaml` — mirror backend schema edits.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,49 @@
{
"kind": "VisualEvidenceManifestV1",
"taskId": "workboard-94209138-31f6-422e-ac8c-181ad391b8a7",
"view": "POS extra sale audit",
"files": [
{
"device": "mobile",
"state": "before",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-before.png",
"width": 390,
"height": 844
},
{
"device": "mobile",
"state": "after",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-after.png",
"width": 390,
"height": 844
},
{
"device": "tablet",
"state": "before",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-before.png",
"width": 768,
"height": 1024
},
{
"device": "tablet",
"state": "after",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-after.png",
"width": 768,
"height": 1024
},
{
"device": "desktop",
"state": "before",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-before.png",
"width": 1440,
"height": 900
},
{
"device": "desktop",
"state": "after",
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-after.png",
"width": 1440,
"height": 900
}
]
}
+3 -2
View File
@@ -45,11 +45,12 @@ platform :ios do
skip_screenshots: false,
overwrite_screenshots: true,
force: true,
submit_for_review: false,
submit_for_review: true,
automatic_release: true,
phased_release: false,
run_precheck_before_submit: false,
precheck_include_in_app_purchases: false
precheck_include_in_app_purchases: false,
ignore_language_directory_validation: true
)
end
end

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

+1 -1
View File
@@ -1,5 +1,5 @@
{
"marketingVersion": "1.0.1",
"marketingVersion": "1.1.0",
"bundleId": "io.truckwash.app",
"minimumIosVersion": "15.0"
}
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env node
// AUT-1 smoke run — capture the before/after visual diff for the
// `primary-button hover state` change in `src/assets/main.css`.
//
// Renders tests/visual-previews/AUT-1/preview.html in a real Chromium
// against the project's bundled Bulma stylesheet, then takes two
// screenshots:
//
// desktop-before.png — Bulma defaults, button idle (no hover effect).
// desktop-after.png — With the AUT-1 CSS rules applied, button
// hovered (lift + brightness shift visible).
// mobile-before.png — Same as desktop-before, captured at the
// Pixel 5 viewport.
// mobile-after.png — Same as desktop-after, captured at the
// Pixel 5 viewport.
//
// Output is written to tests/visual-previews/AUT-1/. The script is
// idempotent: existing files are overwritten, not appended to.
//
// Invoke with: `node scripts/aut-1-capture-hover-preview.mjs`
// (Chromium must already be installed via `npm run test:e2e:install`.)
import { chromium } from "playwright";
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "..");
const PREVIEW_HTML = path.join(
REPO_ROOT,
"tests/visual-previews/AUT-1/preview.html",
);
const OUTPUT_DIR = path.join(REPO_ROOT, "tests/visual-previews/AUT-1");
// The AUT-1 hover rules — kept in lockstep with the diff in
// src/assets/main.css. We inject these via a <style> tag on the
// "after" pass and leave them off for the "before" pass so the
// screenshots show the same Bulma theme but with vs without the new
// hover effect.
const AUT1_HOVER_CSS = `
.button.is-primary {
transition:
filter 0.15s ease,
transform 0.15s ease;
}
.button.is-primary:hover,
.button.is-primary.is-hovered {
filter: brightness(1.08);
transform: translateY(-1px);
}
.button.is-primary:active,
.button.is-primary.is-active {
filter: brightness(0.95);
transform: translateY(0);
}
.button.is-primary:focus-visible {
filter: brightness(1.04);
transform: translateY(-1px);
}
`;
async function captureVariant({ browser, viewport, label, withHoverCss, hover }) {
const context = await browser.newContext({
viewport,
deviceScaleFactor: 2,
});
const page = await context.newPage();
await page.goto(pathToFileUrl(PREVIEW_HTML));
if (withHoverCss) {
await page.addStyleTag({ content: AUT1_HOVER_CSS });
}
const target = page.locator(
hover
? '[data-testid="primary-button-hover"]'
: '[data-testid="primary-button-idle"]',
);
const stage = page.locator(
hover ? "#stage-hover" : "#stage-idle",
);
if (hover) {
await target.hover();
// Wait for the 0.15s transition to settle.
await page.waitForTimeout(220);
}
const filename = `${label}-${withHoverCss ? "after" : "before"}.png`;
const destination = path.join(OUTPUT_DIR, filename);
await stage.screenshot({ path: destination, type: "png" });
await context.close();
return destination;
}
function pathToFileUrl(filePath) {
// Playwright's `file://` URLs need absolute paths. On POSIX this is
// straightforward; on Windows this helper keeps the script cross-platform
// should it ever run there.
const absolute = path.resolve(filePath);
return absolute.startsWith("/") ? `file://${absolute}` : `file:///${absolute}`;
}
async function main() {
await fs.mkdir(OUTPUT_DIR, { recursive: true });
const browser = await chromium.launch({ headless: true });
try {
const viewports = [
{ label: "desktop", viewport: { width: 1280, height: 720 } },
{
label: "mobile",
viewport: { width: 393, height: 851 },
},
];
for (const { label, viewport } of viewports) {
for (const withHoverCss of [false, true]) {
for (const hover of [false, true]) {
// We only want idle on the "before" pass and hover on the
// "after" pass — skip the two redundant combinations.
const isIdleShot = !hover;
const isBeforeShot = !withHoverCss;
if (isIdleShot !== isBeforeShot) continue;
const file = await captureVariant({
browser,
viewport,
label,
withHoverCss,
hover,
});
// eslint-disable-next-line no-console
console.log("wrote", path.relative(REPO_ROOT, file));
}
}
}
} finally {
await browser.close();
}
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error("aut-1 capture failed:", err);
process.exitCode = 1;
});
+5 -2
View File
@@ -7,8 +7,11 @@ const strict = argv.includes("--strict");
const failures = [];
const warnings = [];
const root = process.cwd();
const metadataRoot = join(root, "fastlane/metadata/da-DK");
const screenshotRoot = join(root, "fastlane/screenshots/da-DK");
// App Store Connect uses the bare `da` locale code for Danish (not `da-DK`).
// Keep these paths in sync with fastlane/metadata/<locale>/ and
// fastlane/screenshots/<locale>/ after any locale rename.
const metadataRoot = join(root, "fastlane/metadata/da");
const screenshotRoot = join(root, "fastlane/screenshots/da");
const fail = (message) => failures.push(message);
const warn = (message) => warnings.push(message);
+1
View File
@@ -122,6 +122,7 @@ export const ownedFilesByRole = {
"superuser-users.spec.ts",
"superuser-vehicles.smoke.spec.js",
"workfeed-config.smoke.spec.js",
"xlvask-flag-to-selvvash-navigation.spec.ts",
],
};
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Sergii Review Batch smoke test (TRU-96).
#
# Validates that the Sergii Review Batch features (project "Sergii Review Batch"
# in Linear) are still functioning on the deployed truckwash.io dashboard
# after a release.
#
# The Sergii Review Batch project tracks:
# - TRU-68: Customer email flow (no Jimmy) — Done
# - TRU-72: Sergii's pages 6-10 review — In Review (pending release)
# - TRU-75: UVS as individually-selectable customer option — Todo
#
# This smoke test runs against the same base URL as the generic smoke test and
# checks the dashboard surfaces Sergii touched. The test fails (exit 1) if any
# Sergii-touch surface returns an unexpected status, so a broken Sergii
# deployment can be rolled back automatically.
#
# Usage: ./scripts/smoke-test-sergii.sh [base_url]
# Default: https://staging.truckwash.io
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-2xx}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
elif [[ "$status" == "$expected" ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Sergii Review Batch smoke test against $BASE_URL"
echo " (project: Sergii Review Batch — TRU-68, TRU-72, TRU-75)"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Sergii pages 6-10 (TRU-72) ===
# Sergii's batch touched the customer, products, and orders surfaces of the
# dashboard. Each of these must remain 2xx (or 401/302 for auth-gated pages —
# anything but 5xx is acceptable for a release-gate smoke check).
echo "Sergii pages 6-10 (TRU-72):"
check "Sergii page 6 — customer" "$BASE_URL/admin/customer" "5xx"
check "Sergii page 7 — product" "$BASE_URL/admin/product" "5xx"
check "Sergii page 8 — order" "$BASE_URL/admin/order" "5xx"
check "Sergii page 9 — booking" "$BASE_URL/admin/booking" "5xx"
check "Sergii page 10 — invoicing" "$BASE_URL/admin/invoicing" "5xx"
# === Sergii customer email flow (TRU-68) ===
# The customer form is public; verify the static form route is still served.
echo
echo "Sergii customer email flow (TRU-68):"
check "customer list (Sergii touched)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form (Sergii touched)" "$BASE_URL/kundeoprettelse" "2xx"
# === Sergii UVS program option (TRU-75) ===
# The UVS wash program must be exposed in the public self-serve program
# registry so customers can pick it individually (not bundled into FF).
echo
echo "Sergii UVS program option (TRU-75):"
check "self-serve program registry" "$BASE_URL/self-serve/program" "2xx"
check "self-serve vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Sergii Review Batch sanity — dashboard still bootstraps ===
echo
echo "Dashboard bootstrap:"
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
check "login page" "$BASE_URL/login" "2xx"
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ Sergii Review Batch smoke test passed${NC}"
exit 0
else
echo -e "${RED}✗ Sergii Review Batch smoke test FAILED${NC}"
echo " A Sergii-touch surface on $BASE_URL is broken."
echo " Consider rolling back the most recent Sergii batch."
exit 1
fi
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Generic smoke test for any deployed app.
#
# Usage: ./scripts/smoke-test.sh [base_url]
# Default: https://staging.truckwash.io
#
# Required env vars (set by GitHub Action):
# SMOKE_BASE_URL - base URL to test (default: https://staging.truckwash.io)
#
# Optional env vars:
# SMOKE_TOKEN - bearer token for authenticated checks
# SMOKE_TIMEOUT - curl timeout in seconds (default: 10)
#
# Exits 0 on all-pass, 1 on any failure.
set -euo pipefail
BASE_URL="${SMOKE_BASE_URL:-${1:-https://staging.truckwash.io}}"
TIMEOUT="${SMOKE_TIMEOUT:-10}"
# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
FAIL=0
check() {
local name="$1"
local url="$2"
local expected="${3:-200}"
local method="${4:-GET}"
local status
status=$(curl -s -o /dev/null -w "%{http_code}" -X "$method" --max-time "$TIMEOUT" "$url" || echo "000")
if [[ "$status" =~ ^($expected)$ ]] || [[ "$expected" == "2xx" && "$status" =~ ^2 ]]; then
echo -e " ${GREEN}${NC} $name ($status) — $url"
else
echo -e " ${RED}${NC} $name (expected $expected, got $status) — $url"
FAIL=1
fi
}
echo "Smoke test against $BASE_URL"
echo " (timeout ${TIMEOUT}s per check)"
echo
# === Health endpoints (universal) ===
check "health check" "$BASE_URL/healthz" "2xx"
check "ping" "$BASE_URL/api/ping" "2xx"
# === Authentication (should NOT 500) ===
check "login page" "$BASE_URL/login" "2xx"
# === Public endpoints (api repo) ===
check "customer list (public schema)" "$BASE_URL/api/customer" "2xx"
check "kundeoprettelse form" "$BASE_URL/kundeoprettelse" "2xx"
# === Public endpoints (pleno-vue) ===
check "self-serve program picker" "$BASE_URL/self-serve/program" "2xx"
check "vehicle step" "$BASE_URL/self-serve/vehicle" "2xx"
# === Custom 404 should not 500 ===
check "404 page" "$BASE_URL/this-route-does-not-exist" "404"
# === Optional authenticated check ===
if [ -n "${SMOKE_TOKEN:-}" ]; then
check "auth check" "$BASE_URL/api/me" "2xx"
fi
echo
if [ "$FAIL" -eq 0 ]; then
echo -e "${GREEN}✓ All smoke tests passed${NC}"
exit 0
else
echo -e "${RED}✗ Some smoke tests failed${NC}"
exit 1
fi
+27
View File
@@ -144,3 +144,30 @@ body:not(.pleno-large-table-headers) .table thead th {
margin-top: 30px !important;
}
}
/* AUT-1: enhanced primary-button hover state.
Bulma's default only nudges background lightness; this adds a small
lift + brightness shift so the hover is unambiguous. Transitions keep
it smooth so it doesn't feel jarring on click-heavy screens. */
.button.is-primary {
transition:
filter 0.15s ease,
transform 0.15s ease;
}
.button.is-primary:hover,
.button.is-primary.is-hovered {
filter: brightness(1.08);
transform: translateY(-1px);
}
.button.is-primary:active,
.button.is-primary.is-active {
filter: brightness(0.95);
transform: translateY(0);
}
.button.is-primary:focus-visible {
filter: brightness(1.04);
transform: translateY(-1px);
}
@@ -27,7 +27,7 @@ import productImage21 from "/src/assets/piktogrammer/21.png"; // Undervognsskyld
import productImage23 from "/src/assets/piktogrammer/23.png"; // Spotfree
import productImage24 from "/src/assets/piktogrammer/24.png"; // Spotfree
import productImage25 from "/src/assets/piktogrammer/25.png"; // Fælg flex
import productImage26 from "/src/assets/piktogrammer/26.png"; // Højglans
import productImage26 from "/src/assets/piktogrammer/26.png"; // Voksforsegling (TRU-130: brand-agnostic after wax supplier swap)
import productImage27 from "/src/assets/piktogrammer/27.png"; // 10 Minutter
import productImage39 from "/src/assets/piktogrammer/39.png"; // Trailer - Varevogn
import productImage43 from "/src/assets/piktogrammer/43.png"; // Handling of materials
@@ -3,6 +3,11 @@ import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { buildAuditedOrderItemReasonPayload, editOrderItem } from "@/components/shop/OrdersItems.vue";
import {
EXTRA_SALE_REASONS,
isExtraSaleAuditProduct,
isExtraSaleCommentRequired,
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
const props = defineProps({
modelValue: {
@@ -29,6 +34,8 @@ const form = reactive({
notes: '',
reference: '',
quantity: '1',
extraSaleReasonCode: '',
extraSaleComment: '',
});
const isSubmitting = ref(false);
@@ -45,6 +52,8 @@ const syncForm = () => {
form.notes = String(props.orderItem?.notes ?? '');
form.reference = String(props.orderItem?.reference ?? '');
form.quantity = String(props.orderItem?.quantity ?? 1);
form.extraSaleReasonCode = String(props.orderItem?.extra_sale_reason_code ?? '');
form.extraSaleComment = String(props.orderItem?.extra_sale_comment ?? '');
errorMessage.value = '';
};
@@ -60,8 +69,27 @@ const isQuantityValid = computed(() => {
return Number.isInteger(value) && value > 0;
});
const requiresExtraSaleAudit = computed(() => isExtraSaleAuditProduct(props.orderItem?.product || props.orderItem));
const isExtraSaleAuditValid = computed(() => {
if (!requiresExtraSaleAudit.value) {
return true;
}
if (!form.extraSaleReasonCode) {
return false;
}
return !isExtraSaleCommentRequired(form.extraSaleReasonCode) || form.extraSaleComment.trim().length > 0;
});
const canSubmit = computed(() => {
return Boolean(props.orderItem) && props.canEdit && !isSubmitting.value && isPriceValid.value && isQuantityValid.value;
return Boolean(props.orderItem)
&& props.canEdit
&& !isSubmitting.value
&& isPriceValid.value
&& isQuantityValid.value
&& isExtraSaleAuditValid.value;
});
const closeModal = () => {
@@ -90,7 +118,11 @@ const saveChanges = async () => {
reason_code: props.orderItem.reason_code,
reason_label_snapshot: props.orderItem.reason_label_snapshot,
reason_comment: form.notes,
})
}),
{
extra_sale_reason_code: form.extraSaleReasonCode || null,
extra_sale_comment: form.extraSaleComment.trim() || null,
}
);
emits('saved');
} catch (error) {
@@ -177,6 +209,41 @@ const saveChanges = async () => {
></textarea>
</div>
<template v-if="requiresExtraSaleAudit">
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-extra-sale-reason">Årsag</label>
<div class="select is-fullwidth">
<select
id="pos-order-item-extra-sale-reason"
v-model="form.extraSaleReasonCode"
:disabled="!canEdit"
data-testid="pos-order-item-extra-sale-reason"
>
<option value="">Vælg godkendt årsag</option>
<option
v-for="reason in EXTRA_SALE_REASONS"
:key="reason.code"
:value="reason.code"
>
{{ reason.label }}
</option>
</select>
</div>
</div>
<div class="column is-12-mobile is-6-tablet">
<label class="label" for="pos-order-item-extra-sale-comment">Kommentar</label>
<input
id="pos-order-item-extra-sale-comment"
v-model="form.extraSaleComment"
class="input"
type="text"
:disabled="!canEdit"
data-testid="pos-order-item-extra-sale-comment"
>
</div>
</template>
<div class="column is-12">
<label class="label" for="pos-order-item-edit-reference">{{ t('common.reference') }}</label>
<input
@@ -48,7 +48,12 @@ import {
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
import {
AUDITED_ORDER_ITEM_PRODUCT_IDS,
createOrderItem,
getOrderItems,
removeOrderItem,
} from "@/components/shop/OrdersItems.vue";
import { syncMobileOrderItems } from "@/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js";
import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import PosDepartmentStepMobileButtonClearAll from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonClearAll.vue";
@@ -59,6 +64,7 @@ import { pendingBookings } from "@/components/shop/POSDepartmentProcess.vue";
import { useOrderMetadataAutosave } from "@/composables/useOrderMetadataAutosave.js";
import { useHoldToTrigger } from "@/composables/useHoldToTrigger";
import { useI18n } from "vue-i18n";
import { promptExtraSaleAuditIfRequired } from "@/components/displays/department/pos/utils/extraSaleAudit.js";
const { t } = useI18n();
@@ -1035,6 +1041,7 @@ const productRequiresOrderItemNote = (product: any) => {
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
AUDITED_ORDER_ITEM_PRODUCT_IDS.has(getProductId(product)) ||
getProductId(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
getProductName(product) === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
@@ -0,0 +1,91 @@
import Swal from "sweetalert2";
export const EXTRA_SALE_PRODUCT_ID = 27;
export const EXTRA_SALE_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
export const EXTRA_SALE_REASONS = [
{ code: "customer_request", label: "Kunde ønskede ekstra tid", commentRequired: false },
{ code: "operational_delay", label: "Driftsforsinkelse i vaskehal", commentRequired: false },
{ code: "rewash_quality", label: "Omkørsel/kvalitet", commentRequired: false },
{ code: "other", label: "Anden godkendt årsag", commentRequired: true },
];
export const isExtraSaleAuditProduct = (product) => {
if (!product || typeof product !== "object") {
return false;
}
if (product.requires_extra_sale_audit === true) {
return true;
}
if (Number(product.id ?? product.product_id ?? 0) === EXTRA_SALE_PRODUCT_ID) {
return true;
}
return String(product.name ?? "").trim() === EXTRA_SALE_PRODUCT_NAME;
};
export const isExtraSaleCommentRequired = (reasonCode) => {
return EXTRA_SALE_REASONS.some((reason) => reason.code === reasonCode && reason.commentRequired);
};
export const extraSaleAuditPayload = ({ reasonCode = null, comment = null } = {}) => ({
extra_sale_reason_code: String(reasonCode || "").trim() || null,
extra_sale_comment: String(comment || "").trim() || null,
});
export const getExtraSaleAuditFromOrderItem = (orderItem = {}) =>
extraSaleAuditPayload({
reasonCode: orderItem.extra_sale_reason_code,
comment: orderItem.extra_sale_comment,
});
export const promptExtraSaleAuditIfRequired = async (product, initial = {}) => {
if (!isExtraSaleAuditProduct(product)) {
return extraSaleAuditPayload(initial);
}
const initialReason = String(initial.reasonCode ?? initial.extra_sale_reason_code ?? "").trim();
const initialComment = String(initial.comment ?? initial.extra_sale_comment ?? "").trim();
const optionsMarkup = EXTRA_SALE_REASONS.map((reason) => {
const selected = reason.code === initialReason ? " selected" : "";
return `<option value="${reason.code}"${selected}>${reason.label}</option>`;
}).join("");
const result = await Swal.fire({
title: "Godkend 10 min ekstra",
html: `
<label class="label has-text-left" for="extra-sale-reason-code">Årsag</label>
<div class="select is-fullwidth mb-3">
<select id="extra-sale-reason-code" class="swal2-select" style="display:block;width:100%;margin:0;">
<option value="">Vælg godkendt årsag</option>
${optionsMarkup}
</select>
</div>
<label class="label has-text-left" for="extra-sale-comment">Kommentar</label>
<textarea id="extra-sale-comment" class="swal2-textarea" rows="4" style="display:block;width:100%;margin:0;" placeholder="Uddyb når årsagen kræver det">${initialComment}</textarea>
`,
showCancelButton: true,
confirmButtonText: "Godkend",
focusConfirm: false,
preConfirm: () => {
const reasonCode = document.getElementById("extra-sale-reason-code")?.value || "";
const comment = document.getElementById("extra-sale-comment")?.value || "";
if (!reasonCode) {
Swal.showValidationMessage("Vælg en godkendt årsag");
return false;
}
if (isExtraSaleCommentRequired(reasonCode) && !String(comment || "").trim()) {
Swal.showValidationMessage("Kommentar er påkrævet for denne årsag");
return false;
}
return extraSaleAuditPayload({ reasonCode, comment });
},
});
return result.isConfirmed ? result.value : null;
};
@@ -17,6 +17,7 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { isUsageOrderAttachedToOrder } from "@/components/displays/department/pos/sync/xlvaskUsageFilters.js";
import { SELFWASH_PERIOD_ALL_LIMIT } from "@/components/displays/department/pos/sync/xlvaskUsagePeriodConstants.js";
import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js";
import { removeError } from "@/components/request/HandleGlobalError.vue";
const props = defineProps({
autoLoad: {
@@ -37,6 +38,8 @@ const props = defineProps({
type: Boolean, default: false
}, highlightUsageLogId: {
type: Number, default: 0
}, departmentId: {
type: Number, default: 0
}
});
@@ -60,9 +63,11 @@ const {
setOrder,
hideSearchField,
setHideSearchField,
lastError,
} = paginatedList;
setEndpoint("/modules/xlvask/services/usage/orders", false);
const XLVASK_USAGE_ORDERS_ENDPOINT = "/modules/xlvask/services/usage/orders";
setEndpoint(XLVASK_USAGE_ORDERS_ENDPOINT, false);
if (props.loadAllAtOnce) {
setMetaItemsPerPage(SELFWASH_PERIOD_ALL_LIMIT, false);
}
@@ -98,12 +103,24 @@ if (props.hideSearch) {
setHideSearchField(false);
}
if (router.currentRoute.value.params.departmentId) {
setOrder("StartTime", "desc");
} else {
setOrder("StartTime", "desc");
const routeDepartmentId = computed(() => Number.parseInt(
String(router.currentRoute.value.params.departmentId ?? ""),
10
));
const effectiveDepartmentId = computed(() =>
props.departmentId > 0
? props.departmentId
: Number.isInteger(routeDepartmentId.value) && routeDepartmentId.value > 0
? routeDepartmentId.value
: 0
);
if (effectiveDepartmentId.value > 0) {
setFilter("HallId", effectiveDepartmentId.value, false);
}
setOrder("StartTime", "desc");
const extractResponseData = (response) => response?.data?.data ?? response?.data ?? {};
const summary = ref({});
@@ -115,6 +132,7 @@ const buildUsagePaginationParams = (extra = {}) => ({
...extra,
dateFrom: props.initialDateFrom || formatLocalDateOnly(dateFrom.value),
dateTo: props.initialDateTo || formatLocalDateOnly(dateTo.value),
...(effectiveDepartmentId.value > 0 ? { HallId: effectiveDepartmentId.value } : {}),
});
const loadSummary = async () => {
@@ -199,6 +217,31 @@ watch(
}
);
// Re-apply the department (HallId) filter and re-issue the query when
// the department selector changes (either via the `departmentId` prop
// or via the `departmentId` route param). Without this watcher the
// filter was set only once at setup, so changing departments left the
// Selvvask usage query bound to the original department.
watch(
effectiveDepartmentId,
(nextDepartmentId, previousDepartmentId) => {
if (nextDepartmentId === previousDepartmentId) {
return;
}
if (nextDepartmentId > 0) {
setFilter("HallId", nextDepartmentId, false);
} else {
setFilter("HallId", "*", false);
}
if (props.autoLoad) {
loadList();
loadSummary();
}
}
);
onMounted(() => {
window.addEventListener("xlvask-usage-order-updated", () => {
loadSummary();
@@ -253,6 +296,20 @@ const visibleObjectsCount = computed(() => {
}
return currentList.filter((object) => !isUsageOrderAttachedToOrder(object)).length;
});
// 404 on the orders endpoint means the API surface has not been
// implemented yet. Show a friendly notice instead of the generic error
// popper the rest of the paginated surfaces surface.
const apiEndpointNotImplemented = computed(() => (
lastError.value?.status === 404
&& lastError.value?.endpoint === XLVASK_USAGE_ORDERS_ENDPOINT
));
watch(apiEndpointNotImplemented, (isNotImplemented) => {
if (isNotImplemented) {
removeError("paginatedGetRequest");
}
});
</script>
<template>
@@ -366,6 +423,13 @@ const visibleObjectsCount = computed(() => {
</div>
</template>
</PaginationDisplay>
<div
v-else-if="apiEndpointNotImplemented"
class="notification is-info is-light mb-4"
data-testid="xlvask-api-not-implemented"
>
{{ t('invoicing_period.xlvask_review.errors.api_endpoint_not_implemented') }}
</div>
<ShowErrorField v-else error="paginatedGetRequest"/>
<XlvaskUsageOrdersTable
:objects="list"
@@ -0,0 +1,44 @@
<script setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps({
isRedCar: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
customerNumber: {
type: [String, Number, null],
default: null,
},
});
const emit = defineEmits(["dismiss"]);
const { t } = useI18n();
const shouldRender = computed(() => Boolean(props.isRedCar));
</script>
<template>
<b-message
v-if="shouldRender"
type="is-warning"
has-icon
:closable="true"
data-testid="red-car-warning"
:title="t('self_wash.red_car_warning_title')"
@close="emit('dismiss')"
>
<p data-testid="red-car-warning-message">
{{ t("self_wash.red_car_warning_message") }}
</p>
<p class="mt-2" data-testid="red-car-warning-suggestion">
{{ t("self_wash.red_car_warning_suggestion") }}
</p>
</b-message>
</template>
@@ -2,6 +2,7 @@
import { computed, ref, watch } from "vue";
import { BAutocomplete, BField, BInput, BMessage } from "buefy";
import SelfServeVehicleTypeSelector from "@/components/displays/selfServe/SelfServeVehicleTypeSelector.vue";
import RedCarWarning from "@/components/displays/selfServe/RedCarWarning.vue";
const props = defineProps<{
customerNumber: string | number | null;
@@ -17,6 +18,7 @@ const props = defineProps<{
vehicleTypes: Array<any>;
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
isRedCar?: boolean;
}>();
const emit = defineEmits<{
@@ -141,6 +143,11 @@ const emitVehicleTypeSelection = (selection: any) => {
>
{{ props.vehicleStepError }}
</b-message>
<RedCarWarning
v-if="!props.vehicleStepError && props.isRedCar"
:is-red-car="true"
:customer-number="customerNumber"
/>
<b-message
v-else-if="props.vehicleStepGuidance"
type="is-info"
@@ -30,6 +30,10 @@ import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
import ProductBox from "@/components/displays/boxes/ProductBox.vue";
import { addOrderItemAddons } from "@/components/displays/department/pos/utils/addOrderItemAddons.js";
import { getPicture } from "@/components/displays/department/pos/displays/Piktogrammer.vue";
import {
getExtraSaleAuditFromOrderItem,
promptExtraSaleAuditIfRequired,
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
const emits = defineEmits(['onAddToCartProduct', 'onAddProduct', 'onSelectProduct', 'onSelectionInvalidated']);
const route = useRoute();
@@ -386,10 +390,15 @@ const warnIfRestrictedAddonsWereSkipped = async (restrictedSelections = []) => {
await showRestrictionWarning("pos.restrictions.restricted_items_removed");
};
const resolveExtraSaleAudit = async (product, initial = {}) => {
const audit = await promptExtraSaleAuditIfRequired(product, initial);
return audit === null ? null : audit;
};
const showAddMultipleProducts = (productId) => {
const showAddMultipleProducts = async (productId) => {
const product = findProductById(productId);
const restriction = getScopedProductRestriction(product);
if (restriction.restricted) {
@@ -397,7 +406,7 @@ const showAddMultipleProducts = (productId) => {
return;
}
Swal.fire({
const quantityResult = await Swal.fire({
title: 'Tilføj flere produkter',
input: 'number',
inputAttributes: {
@@ -405,7 +414,7 @@ const showAddMultipleProducts = (productId) => {
},
showCancelButton: true,
confirmButtonText: 'Tilføj',
showLoaderOnConfirm: true,
showLoaderOnConfirm: false,
preConfirm: (inputValue) => {
// Check if the input number is higher than 200
if (inputValue > 200) {
@@ -423,17 +432,32 @@ const showAddMultipleProducts = (productId) => {
Swal.showValidationMessage('Order ID is required');
return false;
}
return createOrderItem(orderId, productId, inputValue)
.then(async (result) => {
const createdItemId = Number(result?.data?.data?.id ?? 0);
await addAddonsToOrderMiddleware(productId, inputValue, createdItemId, orderId);
await loadOrderItems();
})
.catch((error) => handleCreateOrderItemError(error, { validationMessage: true }).then(() => false));
return Number(inputValue);
}
},
allowOutsideClick: () => !Swal.isLoading()
});
if (!quantityResult.isConfirmed) {
return;
}
const orderId = getValidOrderId();
if (!orderId) {
return;
}
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
return;
}
await createOrderItem(orderId, productId, quantityResult.value, null, null, null, audit)
.then(async (result) => {
const createdItemId = Number(result?.data?.data?.id ?? 0);
await addAddonsToOrderMiddleware(productId, quantityResult.value, createdItemId, orderId);
await loadOrderItems();
})
.catch((error) => handleCreateOrderItemError(error));
};
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
@@ -685,7 +709,7 @@ const addProductWithAddonsToOrder = async (product_id) => {
// Check if the product requires a note
if (productRequiresOrderItemNote(product)) {
// Show the note input
await Swal.fire({
const noteResult = await Swal.fire({
title: 'Tilføj en note',
input: 'text',
inputLabel: 'Noten kan ses af kunden. F.eks. "Fjernelse af graffiti på venstre side"',
@@ -694,35 +718,32 @@ const addProductWithAddonsToOrder = async (product_id) => {
},
showCancelButton: true,
confirmButtonText: 'Tilføj',
showLoaderOnConfirm: true,
showLoaderOnConfirm: false,
inputValidator: (note) => {
if (!String(note || '').trim()) {
return 'Note er påkrævet for dette produkt';
}
return null;
},
preConfirm: (note) => {
const normalizedNote = String(note || '').trim();
const confirmedOrderId = getValidOrderId();
if (!confirmedOrderId) {
Swal.showValidationMessage('Order ID is required');
return false;
}
// Show the fake create order item
showPendingCreateOrderItem(product, 1, getUserProductPrice(product), 0, normalizedNote);
// Create the order item
return createOrderItem(confirmedOrderId, product_id, 1, 0, normalizedNote)
.then(async (result) => {
let order_item_id = result.data.data.id;
// Add the addons to the order
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, confirmedOrderId).then(() => {
loadOrderItems();
});
})
.catch((error) => handleCreateOrderItemError(error, { validationMessage: true }).then(() => false));
},
allowOutsideClick: () => !Swal.isLoading()
preConfirm: (note) => String(note || '').trim(),
});
if (!noteResult.isConfirmed) {
return;
}
const normalizedNote = noteResult.value;
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
return;
}
showPendingCreateOrderItem(product, 1, getUserProductPrice(product), 0, normalizedNote);
await createOrderItem(orderId, product_id, 1, 0, normalizedNote, null, audit)
.then(async (result) => {
let order_item_id = result.data.data.id;
await addAddonsToOrderMiddleware(product_id, 1, order_item_id, orderId).then(() => {
loadOrderItems();
});
})
.catch((error) => handleCreateOrderItemError(error));
return;
}
// If the product requires a note, show the note input
@@ -739,7 +760,12 @@ const addProductWithAddonsToOrder = async (product_id) => {
return;
}
// Create the order item
await createOrderItem(orderId, product_id, quantity)
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
clearPendingOrderItems();
return;
}
await createOrderItem(orderId, product_id, quantity, null, null, null, audit)
.then(async (result) => {
let order_item_id = result.data.data.id;
// Add the addons to the order
@@ -816,12 +842,19 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
const discountedPrice = String(getUserProductPrice({ ...previousOrderProduct, price: basePrice }));
showPendingCreateOrderItem({ ...previousOrderProduct, price: basePrice }, quantity, discountedPrice);
const audit = await resolveExtraSaleAudit(previousOrderProduct, getExtraSaleAuditFromOrderItem(orderItem));
if (audit === null) {
clearPendingOrderItems();
continue;
}
const result = await createOrderItem(
orderId,
productId,
quantity,
null,
String(orderItem?.notes ?? "")
String(orderItem?.notes ?? ""),
null,
audit
).catch((error) => handleCreateOrderItemError(error));
const sourceItemId = getPreviousOrderItemId(orderItem);
const createdItemId = Number(result?.data?.data?.id ?? 0);
@@ -854,12 +887,19 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
String(getUserProductPrice(previousOrderProduct)),
relatedItemId
);
const audit = await resolveExtraSaleAudit(previousOrderProduct, getExtraSaleAuditFromOrderItem(orderItem));
if (audit === null) {
clearPendingOrderItems();
continue;
}
await createOrderItem(
orderId,
productId,
quantity,
relatedItemId,
String(orderItem?.notes ?? "")
String(orderItem?.notes ?? ""),
null,
audit
).catch((error) => handleCreateOrderItemError(error));
}
@@ -923,7 +963,12 @@ const addRecommendedProductToOrder = async (productId) => {
}
showPendingCreateOrderItem(product, 1, getRecommendedProductPrice(productId));
const result = await createOrderItem(orderId, productId, 1).catch((error) => handleCreateOrderItemError(error));
const audit = await resolveExtraSaleAudit(product);
if (audit === null) {
clearPendingOrderItems();
return;
}
const result = await createOrderItem(orderId, productId, 1, null, null, null, audit).catch((error) => handleCreateOrderItemError(error));
const createdItemId = Number(result?.data?.data?.id ?? 0);
if (createdItemId > 0) {
await addAddonsToOrderMiddleware(productId, 1, createdItemId, orderId);
@@ -60,6 +60,16 @@ const menu_items = computed(() => [
'user'
]
},
{
name: t('user_menu.my_permissions'),
route: '/user/permissions',
icon: 'fas fa-user-shield',
children: [],
permissions: [
'user',
'SUBUSERS_LIST'
]
},
]);
</script>
+15 -3
View File
@@ -38,6 +38,9 @@ export function usePaginatedList() {
let activeRequestController = null;
let searchDebounceTimeout = null;
/** The last error produced by `paginatedGetRequest` (null on success). */
const lastError = ref(null);
/** Additional query parameters */
const additionalQueryParameters = ref({});
@@ -170,6 +173,7 @@ export function usePaginatedList() {
abortActiveRequest();
const requestController = typeof AbortController !== "undefined" ? new AbortController() : null;
activeRequestController = requestController;
lastError.value = null;
try {
const response = await axios.get(API_URL + endpoint.value, {
@@ -193,10 +197,16 @@ export function usePaginatedList() {
setLastUpdated();
return response;
} catch (error) {
if (!isCanceledRequest(error)) {
parseError(error, 'paginatedGetRequest');
console.log(error);
if (isCanceledRequest(error)) {
return null;
}
lastError.value = {
status: Number.parseInt(String(error?.response?.status ?? error?.status ?? ""), 10) || null,
endpoint: endpoint.value,
message: error?.response?.data?.data?.message ?? error?.response?.data?.message ?? error?.message ?? null,
};
parseError(error, 'paginatedGetRequest');
console.log(error);
return null;
} finally {
if (activeRequestController === requestController) {
@@ -487,6 +497,7 @@ export function usePaginatedList() {
isLoading,
isExporting,
latestSearch,
lastError,
additionalQueryParameters,
exportTransform,
setHideSearchField,
@@ -542,6 +553,7 @@ export const hideSearchField = globalInstance.hideSearchField;
export const isLoading = globalInstance.isLoading;
export const isExporting = globalInstance.isExporting;
export const latestSearch = globalInstance.latestSearch;
export const lastError = globalInstance.lastError;
export const additionalQueryParameters = globalInstance.additionalQueryParameters;
export const exportTransform = globalInstance.exportTransform;
+39 -6
View File
@@ -3,7 +3,7 @@
import axios from 'axios'
import {API_URL} from "@/config.js";
export const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 24, 25, 26, 27]);
export const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 25, 26, 27]);
export const DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE = "customer_approved_extra_work";
export const DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL = "Kunde godkendte ekstra arbejde";
@@ -13,12 +13,34 @@ export const buildAuditedOrderItemReasonPayload = (product_id, notes = null, rea
}
const reason = reasonData && typeof reasonData === "object" ? reasonData : {};
const comment = String(reason.reason_comment ?? reason.comment ?? notes ?? "").trim();
// Server requires `reason_comment` to be present (and non-empty after trim)
// for audited products. Walk the precedence chain in order so callers can
// supply either an explicit override or fall back to the legacy `notes`
// field, and never emit an empty value.
const trimmedFirstNonEmpty = (...candidates) => {
for (const candidate of candidates) {
const trimmed = String(candidate ?? "").trim();
if (trimmed.length > 0) {
return trimmed;
}
}
return "";
};
const reasonComment = trimmedFirstNonEmpty(
reason.reason_comment,
reason.comment,
notes,
DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL
);
return {
reason_code: String(reason.reason_code || DEFAULT_AUDITED_ORDER_ITEM_REASON_CODE),
reason_label_snapshot: String(reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL),
reason_comment: comment,
reason_label_snapshot: String(
reason.reason_label_snapshot || DEFAULT_AUDITED_ORDER_ITEM_REASON_LABEL
),
reason_comment: reasonComment,
};
};
@@ -34,7 +56,16 @@ export const getOrderItems = (order_id) => {
});
};
export const createOrderItem = (order_id, product_id, quantity, related_item_id = null, notes = null, forcePrice = null, reasonData = null) => {
export const createOrderItem = (
order_id,
product_id,
quantity,
related_item_id = null,
notes = null,
forcePrice = null,
reasonData = null,
extraSaleAudit = {}
) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
@@ -50,6 +81,7 @@ export const createOrderItem = (order_id, product_id, quantity, related_item_id
related_item_id,
notes,
...buildAuditedOrderItemReasonPayload(product_id, notes, reasonData),
...extraSaleAudit
};
if (forcePrice !== null && forcePrice !== undefined) {
payload.price = forcePrice;
@@ -73,7 +105,7 @@ export const removeOrderItem = (id) => {
});
};
export const editOrderItem = (id, price, notes, reference, quantity, reasonData = null) => {
export const editOrderItem = (id, price, notes, reference, quantity, reasonData = null, extraSaleAudit = {}) => {
const token = localStorage.getItem('token');
if (!token) {
return null;
@@ -85,6 +117,7 @@ export const editOrderItem = (id, price, notes, reference, quantity, reasonData
reference,
quantity,
...(reasonData && typeof reasonData === "object" ? reasonData : {}),
...extraSaleAudit
}, {
headers: {
Authorization: `Bearer ${token}`
+44 -4
View File
@@ -10,6 +10,10 @@ import { getAttributes } from "@/components/shop/CustomerAttributes.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { doesOrderContainWashCertificateProduct } from "@/components/displays/department/pos/utils/washCertificate.js";
import {
getExtraSaleAuditFromOrderItem,
promptExtraSaleAuditIfRequired,
} from "@/components/displays/department/pos/utils/extraSaleAudit.js";
import {
getCustomerProductRestriction,
getProductCategoryRestrictionForCustomer,
@@ -1572,19 +1576,43 @@ const copyLastWashReferenceToEmptyCurrentOrder = async (sourceReference) => {
}
};
const createCopiedOrderItem = (targetOrderId, sourceItem, relatedItemId = null) => {
const createCopiedOrderItem = async (targetOrderId, sourceItem, relatedItemId = null) => {
const productId = getOrderItemProductId(sourceItem);
const quantity = getOrderItemQuantity(sourceItem);
if (!productId || !quantity) {
return Promise.resolve(null);
}
const audit = await promptExtraSaleAuditIfRequired(
sourceItem?.product || { id: productId },
getExtraSaleAuditFromOrderItem(sourceItem)
);
if (audit === null) {
return Promise.resolve(null);
}
// Preserve audited-order-item reason metadata from the source row so the
// copied POST satisfies the server-side `reason_comment` requirement for
// products in AUDITED_ORDER_ITEM_PRODUCT_IDS. We only forward the fields
// we actually saw on the source; `buildAuditedOrderItemReasonPayload`
// still falls back to the order-item `notes` and then to the default
// label for non-audited products, so this is safe for every other case.
const sourceReasonData = sourceItem && typeof sourceItem === "object"
? {
reason_code: sourceItem.reason_code,
reason_label_snapshot: sourceItem.reason_label_snapshot,
reason_comment: sourceItem.reason_comment ?? sourceItem.comment,
}
: null;
return createOrderItem(
targetOrderId,
productId,
quantity,
relatedItemId,
getOrderItemNotes(sourceItem)
getOrderItemNotes(sourceItem),
null,
sourceReasonData,
audit
);
};
@@ -2611,13 +2639,19 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
}
const forcedPrimaryPrice = primaryProduct.price ?? null;
const primaryAudit = await promptExtraSaleAuditIfRequired(primaryProduct);
if (primaryAudit === null) {
return false;
}
const primaryItemResponse = await createOrderItem(
normalizedOrderId,
primaryProduct.id,
1,
null,
null,
forcedPrimaryPrice
forcedPrimaryPrice,
null,
primaryAudit
);
const relatedPrimaryItemId = toPositiveInteger(primaryItemResponse?.data?.data?.id);
@@ -2629,6 +2663,10 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
}
const secondaryProduct = await fetchOrderBookingProductWithPricing(secondaryProductId);
const secondaryAudit = await promptExtraSaleAuditIfRequired(secondaryProduct || { id: secondaryProductId });
if (secondaryAudit === null) {
return false;
}
await createOrderItem(
normalizedOrderId,
@@ -2636,7 +2674,9 @@ export const hydrateSelectedOrderBookingForDesktop = async () => {
Math.max(1, Number.parseInt(String(bookingItem?.quantity ?? 1), 10) || 1),
relatedPrimaryItemId,
String(bookingItem?.notes ?? "").trim() || null,
secondaryProduct?.price ?? null
secondaryProduct?.price ?? null,
null,
secondaryAudit
);
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Red-car detection utilities for the customer portal.
*
* SENERE 7 / TRU-99: Until Mads defines the canonical rule for
* identifying a "red car" (e.g. plate scan, color tag, manual
* customer flag), we use a manual customer-attribute flag as the
* default heuristic. The attribute keys that trigger the warning
* are configurable so the rule can be tightened later without
* touching call sites.
*/
/**
* Default attribute keys that mark a customer as owning a red car.
* Multiple keys are supported to give staff a few options when
* tagging accounts in the customer-attribute editor.
*/
export const DEFAULT_RED_CAR_ATTRIBUTE_KEYS = Object.freeze([
"isRedCar",
"is_red_car",
"redCar",
"red_car",
]);
const normalizeKey = (key) => String(key ?? "").trim();
const toNormalizedKeySet = (keys) => {
const set = new Set();
if (!Array.isArray(keys)) {
return set;
}
for (const key of keys) {
const normalized = normalizeKey(key).toLowerCase();
if (normalized) {
set.add(normalized);
}
}
return set;
};
/**
* Extract the attribute key string from a single customer-attribute
* row. Accepts both the API shape (object with `attribute`) and the
* plain-string shorthand used in some call sites.
*
* @param {string|object|undefined|null} entry
* @returns {string}
*/
export const extractAttributeKey = (entry) => {
if (typeof entry === "string") {
return normalizeKey(entry);
}
if (entry && typeof entry === "object") {
const candidate = entry.attribute ?? entry.key ?? entry.name;
return normalizeKey(candidate);
}
return "";
};
/**
* Extract the attribute key strings from a list of customer
* attributes. Returns an empty array for invalid input.
*
* @param {Array<string|object|undefined|null>|null|undefined} attributes
* @returns {string[]}
*/
export const extractAttributeKeys = (attributes) => {
if (!Array.isArray(attributes)) {
return [];
}
const keys = [];
for (const entry of attributes) {
const key = extractAttributeKey(entry);
if (key) {
keys.push(key);
}
}
return keys;
};
/**
* Determine whether a customer should be flagged as a "red car"
* based on their attribute list. The check is case-insensitive.
*
* @param {Array<string|object|undefined|null>|null|undefined} attributes
* @param {object} [options]
* @param {string[]} [options.attributeKeys] - attribute keys that
* mark a red car. Defaults to DEFAULT_RED_CAR_ATTRIBUTE_KEYS.
* @returns {boolean}
*/
export const isRedCarCustomer = (attributes, options = {}) => {
const keys = toNormalizedKeySet(
options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
);
if (keys.size === 0) {
return false;
}
const attributeKeys = extractAttributeKeys(attributes);
return attributeKeys.some((key) => keys.has(key.toLowerCase()));
};
/**
* Return the configured red-car attribute keys (normalized,
* deduplicated, lowercased). Useful for displaying the active
* heuristic to operators.
*
* @param {object} [options]
* @param {string[]} [options.attributeKeys]
* @returns {string[]}
*/
export const getRedCarAttributeKeys = (options = {}) => {
const keys = toNormalizedKeySet(
options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS
);
return Array.from(keys);
};
+104
View File
@@ -0,0 +1,104 @@
import { computed, ref, unref, watch } from "vue";
import {
extractCustomerAttributesData,
listCustomerAttributes,
} from "@/features/customer/customerAttributeService.js";
import {
DEFAULT_RED_CAR_ATTRIBUTE_KEYS,
isRedCarCustomer,
} from "./redCarDetector.js";
const isPositiveInteger = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0;
};
/**
* Composable that watches a customer number and exposes a reactive
* "is this customer flagged as a red car" state for the customer
* portal wash flow.
*
* SENERE 7 / TRU-99 — the canonical rule is still TBD by Mads; this
* defaults to a manual customer-attribute flag and lets callers
* override the attribute keys so the rule can be tightened later.
*
* @param {import("vue").Ref<number|string|null|undefined>|number|string|null|undefined} customerNumberSource
* @param {object} [options]
* @param {string[]} [options.attributeKeys] - attribute keys that
* mark a red car. Defaults to DEFAULT_RED_CAR_ATTRIBUTE_KEYS.
* @returns {{
* attributes: import("vue").Ref<Array>,
* isLoading: import("vue").Ref<boolean>,
* error: import("vue").Ref<string|null>,
* isRedCar: import("vue").ComputedRef<boolean>,
* reload: () => Promise<void>,
* reset: () => void,
* }}
*/
export const useRedCarWarning = (customerNumberSource, options = {}) => {
const attributes = ref([]);
const isLoading = ref(false);
const error = ref(null);
const loadedCustomerNumber = ref(null);
const attributeKeys = options.attributeKeys ?? DEFAULT_RED_CAR_ATTRIBUTE_KEYS;
const isRedCar = computed(() => isRedCarCustomer(attributes.value, { attributeKeys }));
const reset = () => {
attributes.value = [];
isLoading.value = false;
error.value = null;
loadedCustomerNumber.value = null;
};
const reload = async () => {
const customerNumber = unref(customerNumberSource);
if (!isPositiveInteger(customerNumber)) {
reset();
return;
}
if (loadedCustomerNumber.value === customerNumber && attributes.value.length > 0) {
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await listCustomerAttributes({ customerNumber });
const nextAttributes = extractCustomerAttributesData(response);
attributes.value = nextAttributes;
loadedCustomerNumber.value = customerNumber;
} catch (err) {
console.warn("Unable to load red-car customer attributes", err);
attributes.value = [];
loadedCustomerNumber.value = null;
error.value = err?.message || "load_failed";
} finally {
isLoading.value = false;
}
};
if (typeof customerNumberSource === "object" && customerNumberSource !== null && "value" in customerNumberSource) {
watch(
() => unref(customerNumberSource),
(next) => {
if (isPositiveInteger(next)) {
reload();
} else {
reset();
}
},
{ immediate: true }
);
}
return {
attributes,
isLoading,
error,
isRedCar,
reload,
reset,
};
};
+7 -1
View File
@@ -2143,6 +2143,7 @@
"customer_login_title": "@:{'templates.generated.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.er'} @:{'words.generated.du'} @:{'words.generated.chauffør'}? @.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @:{'words.generated.her'} @:{'words.generated.for'} @:{'words.generated.at'} @:{'words.generated.fa'} @:{'words.generated.adgang'} @:{'words.generated.til'} @:{'words.generated.dine'} @:{'words.generated.vaske'}.",
"driver_login_button": "@.capitalize:{'words.generated.log'} @:{'words.generated.ind'} @.lower:{'words.generated.som'} @:{'words.generated.chauffør'}",
"driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.",
"driver_login_title": "@.capitalize:{'words.generated.chauffør'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Glemt @:{'words.generated.adgangskode'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} @:{'words.generated.digitale'} @:{'words.generated.løsninger'} @:{'words.generated.til'} @:{'words.generated.lastbilvask'}",
@@ -4238,6 +4239,7 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.oplysninger'}"
},
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -4874,7 +4876,8 @@
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
"redirect_order_failed": "Ordren kunne ikke åbnes.",
"load_customers_failed": "Kunderne kunne ikke hentes.",
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes."
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes.",
"api_endpoint_not_implemented": "API-endepunkt ikke implementeret endnu"
}
}
},
@@ -5913,6 +5916,9 @@
"open_property_exit_gate": "@:{'words.generated.abn'} udgangsport",
"perform_wash": "@:{'words.generated.udfør'} @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørgsmal'} @:{'words.generated.besvaret'}",
"red_car_warning_title": "Rød bil på pladsen",
"red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.vælg'} {plate} @:{'words.generated.fra'} @:{'words.generated.dine'} @:{'words.generated.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.type'} @:{'words.generated.af'} @:{'words.generated.køretøj'}",
"select_vehicle": "@.capitalize:{'words.generated.vælg'} @:{'words.generated.køretøj'}",
+8 -2
View File
@@ -2260,6 +2260,7 @@
"username": "@:{'words.generated.benutzername'}",
"your_username": "@:{'words.generated.ihr'} @:{'words.generated.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
},
"book_wash": {
@@ -4340,7 +4341,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@:{'words.generated.waschzertifikat'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} angehaengt.",
"wash_certificate_item_without_certificate": "@:{'words.generated.waschzertifikat'}-@.capitalize:{'words.generated.position'} @:{'words.generated.ist'} @:{'words.generated.ohne'} @:{'words.generated.waschzertifikat'} vorhanden.",
"xlvask_missing_order_link": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor linked @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrierung'} @:{'words.generated.ist'} weder ignoriert noch @:{'words.generated.mit'} einer @:{'words.generated.bestellung'} im ausgewaehlten @:{'words.generated.zeitraum'} verknuepft."
},
"preview": {
"entities": {
@@ -4348,6 +4349,7 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'}-@.capitalize:{'words.generated.details'}"
},
"expected_price": "Erwarteter @:{'words.generated.preis'}",
"no_xlvask_usage_log_metadata": "Keine Metadaten @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@:{'templates.generated.compat.tables.common.start_time'}",
"wash_id": "Wasch-@.upper:{'words.generated.id'}",
"xlvask_usage_log": "@:{'templates.generated.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
@@ -4984,7 +4986,8 @@
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
"load_customers_failed": "Die Kunden konnten nicht geladen werden.",
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden."
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden.",
"api_endpoint_not_implemented": "API-Endpunkt ist noch nicht implementiert"
}
}
},
@@ -6023,6 +6026,9 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'words.generated.oeffnen'}",
"perform_wash": "@.upper:{'words.generated.w'}?@:{'words.generated.sche'} durchf?@:{'words.generated.hren'}",
"questions_answered": "@:{'words.generated.fragen'} @:{'words.generated.beantwortet'}",
"red_car_warning_title": "Rotes Auto vor Ort",
"red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
"red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'words.generated.w'}?@:{'words.generated.hlen'} @.capitalize:{'words.generated.sie'} {plate} @:{'words.generated.aus'} @:{'words.generated.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'words.generated.fahrzeugtyp'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
"select_vehicle": "@:{'words.generated.fahrzeug'} @:{'words.generated.ausw'}?@:{'words.generated.hlen'}",
+7 -1
View File
@@ -1974,6 +1974,7 @@
"customer_login_title": "@.capitalize:{'words.generated.customer'} @.capitalize:{'words.generated.login'}",
"driver_entry_intro": "@.capitalize:{'words.generated.are'} @:{'words.generated.you'} @:{'words.generated.a'} @:{'words.generated.driver'}? @.capitalize:{'words.generated.log'} @:{'words.generated.in'} @:{'words.generated.here'} @:{'words.generated.to'} @:{'words.generated.register'} @:{'words.generated.a'} @:{'words.generated.wash'}.",
"driver_login_button": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"driver_login_help": "Drivers (sub-users) log in here to register a wash.",
"driver_login_title": "@.capitalize:{'words.generated.driver'} @.capitalize:{'words.generated.login'}",
"forgot_password": "Forgot @.capitalize:{'words.generated.password'}",
"operator_tagline": "@.capitalize:{'words.generated.truckwash'}.@:{'words.generated.io'} - @:{'words.generated.digital'} @:{'words.generated.solutions'} @:{'words.generated.for'} @:{'words.generated.truck'} @:{'words.generated.washing'}",
@@ -4069,6 +4070,7 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.details'}"
},
"expected_price": "@.capitalize:{'words.generated.expected'} @:{'words.generated.price'}",
"no_xlvask_usage_log_metadata": "No metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@.capitalize:{'words.generated.start'} @:{'words.generated.time'}",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @:{'words.generated.vask'} @:{'words.generated.registration'}"
@@ -4705,7 +4707,8 @@
"create_order_item_failed": "The wash item could not be added to the order.",
"redirect_order_failed": "The order could not be opened.",
"load_customers_failed": "The customers could not be loaded.",
"load_usage_log_failed": "The usage log could not be loaded."
"load_usage_log_failed": "The usage log could not be loaded.",
"api_endpoint_not_implemented": "API endpoint not implemented yet"
}
}
},
@@ -5744,6 +5747,9 @@
"open_property_exit_gate": "@.capitalize:{'words.generated.open'} @:{'words.generated.property'} @:{'words.generated.exit'} @:{'words.generated.gate'}",
"perform_wash": "@.capitalize:{'words.generated.perform'} @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.questions'} @:{'words.generated.answered'}",
"red_car_warning_title": "Red car on site",
"red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
"red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.select'} {plate} @:{'words.generated.from'} @:{'words.generated.your'} @:{'words.generated.vehicles'}",
"select_vehicle_type": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'} @:{'words.generated.type'}",
"select_vehicle": "@.capitalize:{'words.generated.select'} @:{'words.generated.vehicle'}",
+4 -1
View File
@@ -786,6 +786,7 @@
"customer_number": "@:{'templates.generated.compat.objects.columns.customer_number'}",
"driver_entry_intro": "@:{'templates.generated.compat.auth.driver_entry_intro'}",
"driver_login_button": "@:{'templates.generated.compat.auth.driver_login_button'}",
"driver_login_help": "@:{'templates.generated.compat.auth.driver_login_help'}",
"driver_login_title": "@:{'templates.generated.compat.auth.driver_login_title'}",
"forgot_password": "@:{'templates.generated.compat.auth.forgot_password'}",
"login_as_customer": "@:{'templates.generated.compat.global.login_as_customer'}",
@@ -3421,6 +3422,7 @@
"expected_price": "@:{'templates.generated.compat.invoice_period.flags.preview.expected_price'}",
"no_order_items": "@:common.templates.no_entity_available",
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
"no_xlvask_usage_log_metadata": "@:{'templates.generated.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}",
"order_items": "@:{'templates.generated.compat.global_search.entity_types.order_items'}",
"price": "@:common.price",
"product": "@:common.product",
@@ -4092,7 +4094,8 @@
"create_order_item_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}"
"load_usage_log_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}",
"api_endpoint_not_implemented": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.api_endpoint_not_implemented'}"
}
}
},
+8 -2
View File
@@ -2263,6 +2263,7 @@
"username": "@.capitalize:{'words.generated.brukernavn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
},
"book_wash": {
@@ -4343,7 +4344,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} match @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@.capitalize:{'words.generated.vaskesertifikat'} @:{'words.generated.er'} @:{'words.generated.vedlagt'} @:{'words.generated.uten'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.vaskesertifikat'}-@:{'words.generated.linje'}.",
"wash_certificate_item_without_certificate": "@.capitalize:{'words.generated.vaskesertifikat'}-@:{'words.generated.linjen'} @:{'words.generated.finnes'} @:{'words.generated.uten'} @:{'words.generated.et'} @:{'words.generated.vaskesertifikat'}.",
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor linked @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-registreringen @:{'words.generated.er'} verken ignorert @:{'words.generated.eller'} knyttet @:{'words.generated.til'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.ordre'} @:{'words.generated.i'} @:{'words.replication.article.host_mention'} @:{'words.generated.valgte'} @:{'words.generated.periode'}."
},
"preview": {
"entities": {
@@ -4351,6 +4352,7 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-opplysninger"
},
"expected_price": "@.capitalize:{'words.generated.forventet'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'templates.generated.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -4987,7 +4989,8 @@
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
"redirect_order_failed": "Ordren kunne ikke åpnes.",
"load_customers_failed": "Kundene kunne ikke hentes.",
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes."
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes.",
"api_endpoint_not_implemented": "API-endepunkt er ikke implementert ennå"
}
}
},
@@ -6026,6 +6029,9 @@
"open_property_exit_gate": "@:{'words.generated.aapne'} @:{'words.generated.eiendommens'} utgangsport",
"perform_wash": "Utfør @:{'words.generated.vask'}",
"questions_answered": "@.capitalize:{'words.generated.spørsmal'} @:{'words.generated.besvart'}",
"red_car_warning_title": "Rød bil på plassen",
"red_car_warning_message": "Denne kunden er markert som rød bil. Rød lakk er mer utsatt for flekker og virvler — håndter med ekstra forsiktighet.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: unngå høytrykk og harde børster, og tørk med en myk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.velg'} {plate} @:{'words.generated.fra'} @:{'words.generated.kjøretøyene'} @:{'words.generated.dine'}",
"select_vehicle_type": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøytype'}",
"select_vehicle": "@.capitalize:{'words.generated.velg'} @:{'words.generated.kjøretøy'}",
+8 -2
View File
@@ -2313,6 +2313,7 @@
"username": "@.capitalize:{'words.generated.anvandarnamn'}",
"your_username": "@.capitalize:{'words.generated.ditt'} @:{'words.generated.anvandarnamn'}",
"driver_login_button": "Förarinloggning",
"driver_login_help": "Förare (underanvändare) loggar in här för att registrera en tvätt.",
"driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt."
},
"book_wash": {
@@ -4393,7 +4394,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'words.generated.does'} @:{'words.generated.not'} @:{'words.generated.match'} @:{'words.generated.the'} @:{'words.generated.vehicle'} @:{'words.generated.subscription'} @:{'words.generated.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@.capitalize:{'words.generated.tvattcertifikat_2'} ar bifogat @:{'words.generated.utan'} @:{'words.replication.host_definite_suffix'} tvattcertifikatrad.",
"wash_certificate_item_without_certificate": "@:{'words.generated.tvattcertifikatraden'} @:{'words.generated.finns'} @:{'words.generated.utan'} @:{'words.generated.ett'} @:{'words.generated.tvattcertifikat_2'}.",
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'} @:{'words.generated.wash'} @:{'words.generated.is'} neither ignored nor @:{'words.generated.linked'} @:{'words.generated.to'} @:{'words.generated.an'} @:{'words.generated.order'} @:{'words.generated.in'} @:{'words.generated.the'} @:{'words.generated.selected'} @:{'words.generated.period'}."
"xlvask_missing_order_link": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-registreringen @:{'words.generated.ar'} varken ignorerad @:{'words.generated.eller'} kopplad @:{'words.generated.till'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.order'} @:{'words.generated.i'} @:{'words.replication.article.host_mention'} @:{'words.generated.valda'} perioden."
},
"preview": {
"entities": {
@@ -4401,6 +4402,7 @@
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.uppgifter'}"
},
"expected_price": "@.capitalize:{'words.generated.forvantat'} @:{'words.generated.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'words.generated.for'} @:{'templates.generated.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttid",
"wash_id": "@.capitalize:{'words.generated.tvatt_2'}-@.upper:{'words.generated.id'}",
"xlvask_usage_log": "@:{'words.generated.xl'} @.capitalize:{'words.generated.vask'}-@:{'words.generated.registrering'}"
@@ -5037,7 +5039,8 @@
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
"redirect_order_failed": "Ordern kunde inte öppnas.",
"load_customers_failed": "Kunderna kunde inte hämtas.",
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas."
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas.",
"api_endpoint_not_implemented": "API-slutpunkt är inte implementerad ännu"
}
}
},
@@ -6076,6 +6079,9 @@
"open_property_exit_gate": "@:{'words.generated.oeppna'} @:{'words.generated.fastighetens'} utgangsgrind",
"perform_wash": "Perform @:{'words.generated.wash'}",
"questions_answered": "@.capitalize:{'words.generated.fragor'} @:{'words.generated.besvarade'}",
"red_car_warning_title": "Röd bil på plats",
"red_car_warning_message": "Denna kund är flaggad som röd bil. Röd lack är känsligare för fläckar och virvlar — hantera med extra försiktighet.",
"red_car_warning_suggestion": "Rekommendera en skonsam tvätt: undvik högtryck och hårda borstar, och torka med en mjuk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'words.generated.valj'} {plate} @:{'words.generated.fran'} @:{'words.generated.dina'} @:{'words.generated.fordon'}",
"select_vehicle_type": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordonstyp'}",
"select_vehicle": "@.capitalize:{'words.generated.valj'} @:{'words.generated.fordon'}",
@@ -5,6 +5,7 @@
"customer_login_title": "@:{'phrases.compat.global.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.er'} @:{'terms.glossary.du'} @:{'terms.glossary.chauffør'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @:{'terms.glossary.her'} @:{'terms.glossary.for'} @:{'terms.glossary.at'} @:{'terms.glossary.fa'} @:{'terms.glossary.adgang'} @:{'terms.glossary.til'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.log'} @:{'terms.glossary.ind'} @.lower:{'terms.glossary.som'} @:{'terms.glossary.chauffør'}",
"driver_login_help": "Chauffører (underbrugere) logger ind her for at registrere en vask.",
"driver_login_title": "@.capitalize:{'terms.glossary.chauffør'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Glemt @:{'terms.glossary.adgangskode'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} @:{'terms.glossary.digitale'} @:{'terms.glossary.løsninger'} @:{'terms.glossary.til'} @:{'terms.glossary.lastbilvask'}",
@@ -34,6 +34,7 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.oplysninger'}"
},
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
@@ -112,7 +112,8 @@
"create_order_item_failed": "Vaskelinjen kunne ikke føjes til ordren.",
"redirect_order_failed": "Ordren kunne ikke åbnes.",
"load_customers_failed": "Kunderne kunne ikke hentes.",
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes."
"load_usage_log_failed": "Forbrugsloggen kunne ikke hentes.",
"api_endpoint_not_implemented": "API-endepunkt ikke implementeret endnu"
}
} } }
}
@@ -24,6 +24,9 @@
"open_property_exit_gate": "@:{'terms.glossary.abn'} udgangsport",
"perform_wash": "@:{'terms.glossary.udfør'} @:{'terms.glossary.vask'}",
"questions_answered": "@.capitalize:{'terms.glossary.spørgsmal'} @:{'terms.glossary.besvaret'}",
"red_car_warning_title": "Rød bil på pladsen",
"red_car_warning_message": "Denne kunde er markeret som rød bil. Rød lak er mere følsom over for pletter og hvirvler — behandl med ekstra forsigtighed.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: undgå højtryk og hårde børster, og tør med en blød mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.vælg'} {plate} @:{'terms.glossary.fra'} @:{'terms.glossary.dine'} @:{'terms.glossary.køretøjer'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.type'} @:{'terms.glossary.af'} @:{'terms.glossary.køretøj'}",
"select_vehicle": "@.capitalize:{'terms.glossary.vælg'} @:{'terms.glossary.køretøj'}",
@@ -53,6 +53,30 @@
"menu": {
"statistics": "@:{'phrases.compat.global.statistics'}"
},
"permissions": {
"card_chauffor_label": "Chauffør (standard)",
"card_chauffor_description": "Kan bruge døgnvask, oprette bookinger, se køretøjer og ordrer.",
"card_booking_label": "Bookingkoodinator",
"card_booking_description": "Kan oprette og redigere bookinger samt se køretøjer og ordrer.",
"card_intro": "Vælg den rolle, som nye chauffører som standard skal have, når de oprettes via QR-kode.",
"groups": {
"vehicles": "Køretøjer",
"selfserve": "Døgnvask",
"bookings": "Bookinger",
"orders": "Ordrer",
"driver_management": "Chaufførstyring"
},
"loading": "Henter tilladelser...",
"save": "Gem standardrolle",
"saved": "Standardrollen er gemt.",
"save_error": "Standardrollen kunne ikke gemmes.",
"selected_template": "Valgt rolle: {label}",
"subtitle": "Vælg standardtilladelser for nye chauffører.",
"toggle_booking_coordinator": "Bookingkoodinator",
"toggle_chauffor": "Chauffør",
"toggle_description": "De 5 kategorier nedenfor viser, hvad rollen giver adgang til.",
"title": "Mine tilladelser"
},
"orders": {
"single_subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.detaljer'} @:{'terms.glossary.om'} transaktionen",
"subtitle": "@.capitalize:{'terms.glossary.se'} @:{'terms.glossary.dine'} @:{'terms.glossary.vaske'}",
@@ -3,6 +3,7 @@
"user_menu": {
"my_bookings": "@:{'terms.glossary.mine'} @:{'terms.glossary.bookinger'}",
"my_invoices": "@:{'terms.glossary.mine'} @:{'terms.glossary.fakturaer'}",
"my_permissions": "@:{'terms.glossary.mine'} @:{'terms.glossary.tilladelser'}",
"my_vehicles": "@:{'terms.glossary.mine'} @:{'terms.glossary.køretøjer'}",
"my_washes": "@:{'terms.glossary.mine'} @:{'terms.glossary.vaske'}"
}
@@ -12,6 +12,7 @@
"username": "@:{'terms.glossary.benutzername'}",
"your_username": "@:{'terms.glossary.ihr'} @:{'terms.glossary.benutzername'}",
"driver_login_button": "Fahrer-Login",
"driver_login_help": "Fahrer (Unterbenutzer) melden sich hier an, um eine Wäsche zu registrieren.",
"driver_entry_intro": "Sind Sie Fahrer? Melden Sie sich hier an, um eine Wäsche zu registrieren."
}
}
@@ -26,7 +26,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@:{'terms.glossary.waschzertifikat'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} angehaengt.",
"wash_certificate_item_without_certificate": "@:{'terms.glossary.waschzertifikat'}-@.capitalize:{'terms.glossary.position'} @:{'terms.glossary.ist'} @:{'terms.glossary.ohne'} @:{'terms.glossary.waschzertifikat'} vorhanden.",
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor linked @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}."
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrierung'} @:{'terms.glossary.ist'} weder ignoriert noch @:{'terms.glossary.mit'} einer @:{'terms.glossary.bestellung'} im ausgewaehlten @:{'terms.glossary.zeitraum'} verknuepft."
},
"preview": {
"entities": {
@@ -34,6 +34,7 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'}-@.capitalize:{'terms.glossary.details'}"
},
"expected_price": "Erwarteter @:{'terms.glossary.preis'}",
"no_xlvask_usage_log_metadata": "Keine Metadaten @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@:{'phrases.compat.tables.common.start_time'}",
"wash_id": "Wasch-@.upper:{'terms.glossary.id'}",
"xlvask_usage_log": "@:{'phrases.compat.invoice_period.flags.tokens.xlvask_usage_log'}"
@@ -54,4 +55,4 @@
}
}
}
}
}
@@ -191,7 +191,8 @@
"create_order_item_failed": "Der Wäscheposten konnte nicht zum Auftrag hinzugefügt werden.",
"redirect_order_failed": "Der Auftrag konnte nicht geöffnet werden.",
"load_customers_failed": "Die Kunden konnten nicht geladen werden.",
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden."
"load_usage_log_failed": "Das Verbrauchsprotokoll konnte nicht geladen werden.",
"api_endpoint_not_implemented": "API-Endpunkt ist noch nicht implementiert"
}
}
}
@@ -24,6 +24,9 @@
"open_property_exit_gate": "Grundstuecksausgangstor @:{'terms.glossary.oeffnen'}",
"perform_wash": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.sche'} durchf?@:{'terms.glossary.hren'}",
"questions_answered": "@:{'terms.glossary.fragen'} @:{'terms.glossary.beantwortet'}",
"red_car_warning_title": "Rotes Auto vor Ort",
"red_car_warning_message": "Dieser Kunde ist als rotes Auto markiert. Roter Lack ist anfälliger für Flecken und Swirls — bitte mit besonderer Vorsicht behandeln.",
"red_car_warning_suggestion": "Empfehlen Sie eine sanfte Wäsche: keinen Hochdruck, keine harten Bürsten, mit weicher Mikrofaser trocknen.",
"select_from_vehicles": "@.upper:{'terms.glossary.w'}?@:{'terms.glossary.hlen'} @.capitalize:{'terms.glossary.sie'} {plate} @:{'terms.glossary.aus'} @:{'terms.glossary.ihren'} Fahrzeugen",
"select_vehicle_type": "@:{'terms.glossary.fahrzeugtyp'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
"select_vehicle": "@:{'terms.glossary.fahrzeug'} @:{'terms.glossary.ausw'}?@:{'terms.glossary.hlen'}",
@@ -5,6 +5,7 @@
"customer_login_title": "@.capitalize:{'terms.glossary.customer'} @.capitalize:{'terms.glossary.login'}",
"driver_entry_intro": "@.capitalize:{'terms.glossary.are'} @:{'terms.glossary.you'} @:{'terms.glossary.a'} @:{'terms.glossary.driver'}? @.capitalize:{'terms.glossary.log'} @:{'terms.glossary.in'} @:{'terms.glossary.here'} @:{'terms.glossary.to'} @:{'terms.glossary.register'} @:{'terms.glossary.a'} @:{'terms.glossary.wash'}.",
"driver_login_button": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"driver_login_help": "Drivers (sub-users) log in here to register a wash.",
"driver_login_title": "@.capitalize:{'terms.glossary.driver'} @.capitalize:{'terms.glossary.login'}",
"forgot_password": "Forgot @.capitalize:{'terms.glossary.password'}",
"operator_tagline": "@.capitalize:{'terms.glossary.truckwash'}.@:{'terms.glossary.io'} - @:{'terms.glossary.digital'} @:{'terms.glossary.solutions'} @:{'terms.glossary.for'} @:{'terms.glossary.truck'} @:{'terms.glossary.washing'}",
@@ -34,6 +34,7 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.details'}"
},
"expected_price": "@.capitalize:{'terms.glossary.expected'} @:{'terms.glossary.price'}",
"no_xlvask_usage_log_metadata": "No metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "@.capitalize:{'terms.glossary.start'} @:{'terms.glossary.time'}",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @:{'terms.glossary.vask'} @:{'terms.glossary.registration'}"
@@ -54,4 +55,4 @@
}
}
}
}
}
@@ -112,7 +112,8 @@
"create_order_item_failed": "The wash item could not be added to the order.",
"redirect_order_failed": "The order could not be opened.",
"load_customers_failed": "The customers could not be loaded.",
"load_usage_log_failed": "The usage log could not be loaded."
"load_usage_log_failed": "The usage log could not be loaded.",
"api_endpoint_not_implemented": "API endpoint not implemented yet"
}
} } }
}
@@ -24,6 +24,9 @@
"open_property_exit_gate": "@.capitalize:{'terms.glossary.open'} @:{'terms.glossary.property'} @:{'terms.glossary.exit'} @:{'terms.glossary.gate'}",
"perform_wash": "@.capitalize:{'terms.glossary.perform'} @:{'terms.glossary.wash'}",
"questions_answered": "@.capitalize:{'terms.glossary.questions'} @:{'terms.glossary.answered'}",
"red_car_warning_title": "Red car on site",
"red_car_warning_message": "This customer is flagged as a red car. Red paint is more prone to staining and swirls — handle with extra care.",
"red_car_warning_suggestion": "Suggest a gentler wash: skip high-pressure pre-wash, avoid harsh brushes, and dry with a soft microfiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.select'} {plate} @:{'terms.glossary.from'} @:{'terms.glossary.your'} @:{'terms.glossary.vehicles'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.type'}",
"select_vehicle": "@.capitalize:{'terms.glossary.select'} @:{'terms.glossary.vehicle'}",
@@ -5,6 +5,7 @@
"customer_number": "@:{'phrases.compat.objects.columns.customer_number'}",
"driver_entry_intro": "@:{'phrases.compat.auth.driver_entry_intro'}",
"driver_login_button": "@:{'phrases.compat.auth.driver_login_button'}",
"driver_login_help": "@:{'phrases.compat.auth.driver_login_help'}",
"driver_login_title": "@:{'phrases.compat.auth.driver_login_title'}",
"forgot_password": "@:{'phrases.compat.auth.forgot_password'}",
"login_as_customer": "@:{'phrases.compat.global.login_as_customer'}",
@@ -36,6 +36,7 @@
"expected_price": "@:{'phrases.compat.invoice_period.flags.preview.expected_price'}",
"no_order_items": "@:common.templates.no_entity_available",
"no_xlvask_usage_log": "@:common.templates.no_entity_available",
"no_xlvask_usage_log_metadata": "@:{'phrases.compat.invoice_period.flags.preview.no_xlvask_usage_log_metadata'}",
"order_items": "@:{'phrases.compat.global_search.entity_types.order_items'}",
"price": "@:common.price",
"product": "@:common.product",
@@ -196,7 +196,8 @@
"create_order_item_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.create_order_item_failed'}",
"redirect_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.redirect_order_failed'}",
"load_customers_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.load_customers_failed'}",
"load_usage_log_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}"
"load_usage_log_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.load_usage_log_failed'}",
"api_endpoint_not_implemented": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.api_endpoint_not_implemented'}"
}
}
}
@@ -12,6 +12,7 @@
"username": "@.capitalize:{'terms.glossary.brukernavn'}",
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.brukernavn'}",
"driver_login_button": "Sjåfør-innlogging",
"driver_login_help": "Sjåfører (underbrukere) logger inn her for å registrere en vask.",
"driver_entry_intro": "Er du sjåfør? Logg inn her for å registrere en vask."
}
}
@@ -26,7 +26,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} match @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.vaskesertifikat'} @:{'terms.glossary.er'} @:{'terms.glossary.vedlagt'} @:{'terms.glossary.uten'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linje'}.",
"wash_certificate_item_without_certificate": "@.capitalize:{'terms.glossary.vaskesertifikat'}-@:{'terms.glossary.linjen'} @:{'terms.glossary.finnes'} @:{'terms.glossary.uten'} @:{'terms.glossary.et'} @:{'terms.glossary.vaskesertifikat'}.",
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor linked @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}."
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-registreringen @:{'terms.glossary.er'} verken ignorert @:{'terms.glossary.eller'} knyttet @:{'terms.glossary.til'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.ordre'} @:{'terms.glossary.i'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valgte'} @:{'terms.glossary.periode'}."
},
"preview": {
"entities": {
@@ -34,6 +34,7 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-opplysninger"
},
"expected_price": "@.capitalize:{'terms.glossary.forventet'} @:{'terms.glossary.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttidspunkt",
"wash_id": "@:{'phrases.compat.objects.orders.columns.wash_id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
@@ -54,4 +55,4 @@
}
}
}
}
}
@@ -191,7 +191,8 @@
"create_order_item_failed": "Vaskelinjen kunne ikke legges til i ordren.",
"redirect_order_failed": "Ordren kunne ikke åpnes.",
"load_customers_failed": "Kundene kunne ikke hentes.",
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes."
"load_usage_log_failed": "Forbruksloggen kunne ikke hentes.",
"api_endpoint_not_implemented": "API-endepunkt er ikke implementert ennå"
}
}
}
@@ -24,6 +24,9 @@
"open_property_exit_gate": "@:{'terms.glossary.aapne'} @:{'terms.glossary.eiendommens'} utgangsport",
"perform_wash": "Utfør @:{'terms.glossary.vask'}",
"questions_answered": "@.capitalize:{'terms.glossary.spørsmal'} @:{'terms.glossary.besvart'}",
"red_car_warning_title": "Rød bil på plassen",
"red_car_warning_message": "Denne kunden er markert som rød bil. Rød lakk er mer utsatt for flekker og virvler — håndter med ekstra forsiktighet.",
"red_car_warning_suggestion": "Anbefal en skånsom vask: unngå høytrykk og harde børster, og tørk med en myk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.velg'} {plate} @:{'terms.glossary.fra'} @:{'terms.glossary.kjøretøyene'} @:{'terms.glossary.dine'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøytype'}",
"select_vehicle": "@.capitalize:{'terms.glossary.velg'} @:{'terms.glossary.kjøretøy'}",
@@ -12,6 +12,7 @@
"username": "@.capitalize:{'terms.glossary.anvandarnamn'}",
"your_username": "@.capitalize:{'terms.glossary.ditt'} @:{'terms.glossary.anvandarnamn'}",
"driver_login_button": "Förarinloggning",
"driver_login_help": "Förare (underanvändare) loggar in här för att registrera en tvätt.",
"driver_entry_intro": "Är du förare? Logga in här för att registrera en tvätt."
}
}
@@ -26,7 +26,7 @@
"vehicle_subscription_type_mismatch": "{product} @:{'terms.glossary.does'} @:{'terms.glossary.not'} @:{'terms.glossary.match'} @:{'terms.glossary.the'} @:{'terms.glossary.vehicle'} @:{'terms.glossary.subscription'} @:{'terms.glossary.type'} {expected_product}.",
"wash_certificate_attached_without_item": "@.capitalize:{'terms.glossary.tvattcertifikat_2'} ar bifogat @:{'terms.glossary.utan'} @:{'terms.replication.host_definite_suffix'} tvattcertifikatrad.",
"wash_certificate_item_without_certificate": "@:{'terms.glossary.tvattcertifikatraden'} @:{'terms.glossary.finns'} @:{'terms.glossary.utan'} @:{'terms.glossary.ett'} @:{'terms.glossary.tvattcertifikat_2'}.",
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'} @:{'terms.glossary.wash'} @:{'terms.glossary.is'} neither ignored nor @:{'terms.glossary.linked'} @:{'terms.glossary.to'} @:{'terms.glossary.an'} @:{'terms.glossary.order'} @:{'terms.glossary.in'} @:{'terms.glossary.the'} @:{'terms.glossary.selected'} @:{'terms.glossary.period'}."
"xlvask_missing_order_link": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-registreringen @:{'terms.glossary.ar'} varken ignorerad @:{'terms.glossary.eller'} kopplad @:{'terms.glossary.till'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.order'} @:{'terms.glossary.i'} @:{'terms.replication.article.host_mention'} @:{'terms.glossary.valda'} perioden."
},
"preview": {
"entities": {
@@ -34,6 +34,7 @@
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.uppgifter'}"
},
"expected_price": "@.capitalize:{'terms.glossary.forvantat'} @:{'terms.glossary.pris'}",
"no_xlvask_usage_log_metadata": "Ingen metadata @:{'terms.glossary.for'} @:{'phrases.compat.invoice_period.flags.preview.xlvask_usage_log'}.",
"start_time": "Starttid",
"wash_id": "@.capitalize:{'terms.glossary.tvatt_2'}-@.upper:{'terms.glossary.id'}",
"xlvask_usage_log": "@:{'terms.glossary.xl'} @.capitalize:{'terms.glossary.vask'}-@:{'terms.glossary.registrering'}"
@@ -54,4 +55,4 @@
}
}
}
}
}
@@ -191,7 +191,8 @@
"create_order_item_failed": "Tvättartikeln kunde inte läggas till i ordern.",
"redirect_order_failed": "Ordern kunde inte öppnas.",
"load_customers_failed": "Kunderna kunde inte hämtas.",
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas."
"load_usage_log_failed": "Förbrukningsloggen kunde inte hämtas.",
"api_endpoint_not_implemented": "API-slutpunkt är inte implementerad ännu"
}
}
}
@@ -24,6 +24,9 @@
"open_property_exit_gate": "@:{'terms.glossary.oeppna'} @:{'terms.glossary.fastighetens'} utgangsgrind",
"perform_wash": "Perform @:{'terms.glossary.wash'}",
"questions_answered": "@.capitalize:{'terms.glossary.fragor'} @:{'terms.glossary.besvarade'}",
"red_car_warning_title": "Röd bil på plats",
"red_car_warning_message": "Denna kund är flaggad som röd bil. Röd lack är känsligare för fläckar och virvlar — hantera med extra försiktighet.",
"red_car_warning_suggestion": "Rekommendera en skonsam tvätt: undvik högtryck och hårda borstar, och torka med en mjuk mikrofiber.",
"select_from_vehicles": "@.capitalize:{'terms.glossary.valj'} {plate} @:{'terms.glossary.fran'} @:{'terms.glossary.dina'} @:{'terms.glossary.fordon'}",
"select_vehicle_type": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordonstyp'}",
"select_vehicle": "@.capitalize:{'terms.glossary.valj'} @:{'terms.glossary.fordon'}",
+7
View File
@@ -146,6 +146,7 @@ const DepartmentTimeBookingsTypes = lazyView('@/views/dashboards/departmentDashb
const DepartmentModulesSetup = lazyView('@/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue');
const Vehicle = lazyView('@/views/dashboards/superUserDashboard/vehicle/Vehicle.vue');
const MyInvoices = lazyView('@/views/dashboards/userDashboard/invoices/MyInvoices.vue');
const MyPermissions = lazyView('@/views/dashboards/userDashboard/permissions/MyPermissions.vue');
const MicrosoftCallbackToken = lazyView('@/views/callback/microsoft/MicrosoftCallbackToken.vue');
const ConfigurationEntra = lazyView('@/views/dashboards/superUserDashboard/configuration/ConfigurationEntra.vue');
const DepartmentTimeBookingsNew = lazyView('@/views/dashboards/departmentDashboard/modules/time-bookings/book/DepartmentTimeBookingsNew.vue');
@@ -369,6 +370,12 @@ export const router = createRouter({
component: MyInvoices,
meta: { middleware: authMiddleware }
},
{
name: 'mypermissions',
path: '/user/permissions',
component: MyPermissions,
meta: { middleware: authMiddleware }
},
{
name: 'myorder',
path: '/user/orders/:orderId',
+81
View File
@@ -31,6 +31,23 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
<LoginForm />
</ConnectivityIssue>
<p>{{ $t('auth.terms_acceptance') }}</p>
<!-- Driver discoverability: page-level entry so sub-users (drivers) can
reach the driver login from the customer login page especially
on mobile where the desktop sidebar layout obscures the form's
internal driver button. -->
<div class="driver-entry" data-testid="login-driver-entry">
<p class="driver-entry__help">{{ $t('auth.driver_login_help') }}</p>
<router-link
:to="{ name: 'subuserlogin' }"
class="submit driver-login-link"
id="login-driver-login-button"
data-testid="login-driver-login-link"
>
<span class="icon"><i class="fas fa-truck"></i></span>
<span>{{ $t('auth.driver_login_button') }}</span>
</router-link>
</div>
</div>
</div>
</section>
@@ -158,6 +175,55 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
text-decoration: none;
}
/* Driver entry block — page-level, always visible on every viewport.
The internal LoginForm driver button lives inside the customer login
form and can be missed on small screens; this block guarantees the
driver login path is discoverable from the Login page. */
.login-section .wrapper .main .driver-entry {
max-width: 400px;
width: 100%;
margin-top: 24px;
padding: 16px 20px;
background-color: #F2F8FC;
border: 1px solid #BFE0EF;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.login-section .wrapper .main .driver-entry__help {
max-width: none;
margin: 0;
font-size: 14px;
color: #13324C;
text-align: center;
line-height: 1.4;
}
.login-section .wrapper .main .driver-entry .driver-login-link {
background-color: #1584BC;
color: #fff;
border: 0;
border-radius: 30px;
min-height: 43px;
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
text-decoration: none;
margin: 0;
width: auto;
padding: 0 24px;
}
.login-section .wrapper .main .driver-entry .driver-login-link:hover {
background-color: #1a99d6;
color: #fff;
}
@media (max-width: 1250px) {
.login-section .wrapper .sidebar h1{
font-size: 2.3em;
@@ -187,5 +253,20 @@ const { hasPendingStoredSession } = useStoredSessionGuestRedirect();
padding: 70px 10px;
}
}
/* Mobile (<768px): ensure the driver entry block is always visible and
takes full width so it's impossible to miss on phones. */
@media (max-width: 767px) {
.login-section .wrapper .main .driver-entry {
margin-top: 20px;
padding: 14px 16px;
width: 100%;
}
.login-section .wrapper .main .driver-entry .driver-login-link {
width: 100%;
padding: 0 16px;
}
}
/*end login-section*/
</style>
@@ -16,7 +16,7 @@ import XLVaskUsagePagination from "@/components/displays/pagination/models/Depar
<template #default>
<DepartmentDashboardHero />
<!-- Syncronize Orders -->
<XLVaskUsagePagination/>
<XLVaskUsagePagination :department-id="SessionUser.functions.getDepartmentIdFromUrl()"/>
</template>
</NotFoundFallBackPageWrapper>
</DepartmentDashboardPageWrapper>
@@ -15,7 +15,11 @@ const FALLBACK_PRODUCT_TITLES = {
24: "Spot Free (Lastbil)",
25: "Fælg flex pr. enhed",
27: "Ekstraordinær pr. 10 min inkl. kemi",
26: "Højglans - Voksforsegling pr. enhed",
// TRU-130: customer experience series reported that the previous wax product
// ("Højglans - Voksforsegling") tested poorly and is being swapped for a
// different product. Drop the brand-specific prefix so the tile stays
// accurate while the operations team iterates on the next wax supplier.
26: "Voksforsegling pr. enhed",
21: "Undervognsskyl pr. enhed",
22: "Tillæg for Specialsæbe - DD",
};
@@ -1264,14 +1264,19 @@ const createPathEditorQuestion = async () => {
await loadPathOutcomes({ force: true });
};
const pathEditorProgramButtonOptions = computed(() => [
{ id: 0, value: "0", label: "0 = OFF" },
...Array.from({ length: 12 }, (_, index) => ({
id: index + 1,
value: String(index + 1),
label: `Wheel position #${index + 1}`,
})),
]);
const pathEditorProgramButtonOptions = computed(() => {
if (!pathEditorMachineIsAvailable.value) {
return [{ id: 0, value: "0", label: "0 = OFF" }];
}
return [
{ id: 0, value: "0", label: "0 = OFF" },
...Array.from({ length: 12 }, (_, index) => ({
id: index + 1,
value: String(index + 1),
label: `Wheel position #${index + 1}`,
})),
];
});
@@ -1392,16 +1397,56 @@ const pathVerificationSelectedCaseHasRun = computed(() =>
)
);
const pathEditorMachineButtonOptions = computed(() =>
Array.from({ length: 12 }, (_, index) => {
// TRU-91: Filter path editor machine options to only show enabled/available
// machine programs for the current scope. A machine is considered "available"
// when the current lane has a machine_type_id that resolves to a non-deleted
// selfserve_machine_types row in the lookups.
const pathEditorCurrentLaneRow = computed(() => {
const laneId = parseIntOrZero(selectedSimulatorLaneId.value);
if (laneId <= 0) {
return null;
}
return laneRowById(laneId);
});
const pathEditorCurrentMachineType = computed(() => {
const lane = pathEditorCurrentLaneRow.value;
if (!lane) {
return null;
}
const machineTypeId = parseNullableInt(lane.machine_type_id);
if (!machineTypeId) {
return null;
}
const machineTypeRow = lookupRowById("machine_types", machineTypeId);
if (!machineTypeRow) {
return null;
}
if (
machineTypeRow.deleted_at !== null &&
machineTypeRow.deleted_at !== undefined &&
machineTypeRow.deleted_at !== ""
) {
return null;
}
return machineTypeRow;
});
const pathEditorMachineIsAvailable = computed(() => Boolean(pathEditorCurrentMachineType.value));
const pathEditorMachineButtonOptions = computed(() => {
if (!pathEditorMachineIsAvailable.value) {
return [];
}
return Array.from({ length: 12 }, (_, index) => {
const option = taskButtonOptions.find((entry) => entry.id === index);
return {
id: index,
label: option?.description || `Machine button ${index}`,
description: option?.name || `Program ${index + 1}`,
};
})
);
});
});
const pathEditorTaskTitleKeySlug = (key) => String(key || "").replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -240,9 +240,10 @@ const xlvaskUsageLogHtml = (flag: any) => {
].filter(([, value]) => String(value ?? "").trim() !== "");
if (rows.length === 0) {
return escapeHtml(translate("invoice_period.flags.preview.no_xlvask_usage_log", "No XL Vask details available.", {
entity: translate("invoice_period.flags.preview.entities.xlvask_usage_log", "XL Vask details"),
}));
return escapeHtml(translate(
"invoice_period.flags.preview.no_xlvask_usage_log_metadata",
"XL Vask registration (no metadata available — see api flag-context serializer)..."
));
}
return `<table class="table is-narrow is-fullwidth">
@@ -285,8 +286,18 @@ const openOrderItem = (flag: any) => {
SessionUser.functions.redirectTo.department(departmentId, `modules/pos/orders/${orderId}${suffix}`, true);
};
const extractDateOnly = (value: any) => {
const rawValue = String(value ?? "").trim();
if (rawValue === "") {
return "";
}
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(rawValue);
return match ? `${match[1]}-${match[2]}-${match[3]}` : "";
};
const openXlVaskUsageLog = (flag: any) => {
const usageLogId = Number(flag?.xlvask_usage_log_id || flag?.context?.xlvask_usage_log_id || flag?.target_id || 0);
const startTime = extractDateOnly(flag?.context?.start_time || flag?.start_time);
const query = new URLSearchParams({
activeTab: "period",
periodView: "self_wash",
@@ -296,6 +307,10 @@ const openXlVaskUsageLog = (flag: any) => {
query.set("xlvaskUsageLogId", String(usageLogId));
}
if (startTime !== "") {
query.set("xlvaskUsageLogStartTime", startTime);
}
SessionUser.functions.redirectTo.superUser(`/invoices?${query.toString()}`, true);
};
@@ -51,12 +51,40 @@ const ATTRIBUTE_DISPLAY_PRIORITY: Record<string, number> = {
};
const UNKNOWN_ATTRIBUTE_PRIORITY = 99;
const list_views_with_customer = computed(() => {
const matched = view_keys.value.filter((view_key) => {
// Skip if the view type is "all".
if (view_key === 'all') return false;
// Pre-compute a Set<customer_number> per view bucket so chip membership
// resolution stays O(1) regardless of bucket size. The backend now ships
// lightweight `{customer_number, membership_only}` markers for every
// non-active bucket, so this lookup also drives the category chips shown
// on the Alle tab.
const membershipIndexes = computed<Record<string, Set<number>>>(() => {
const result: Record<string, Set<number>> = {};
for (const view_key of view_keys.value) {
if (view_key === 'all') {
result[view_key] = new Set<number>();
continue;
}
const view_type = sharedTypes.value[view_key];
return view_type && view_type.some((v: any) => v.customer_number === props.customer.customer_number);
const set = new Set<number>();
if (Array.isArray(view_type)) {
for (const entry of view_type) {
const raw = (entry as { customer_number?: number | string } | null)
?.customer_number;
const number = Number(raw);
if (Number.isInteger(number) && number > 0) {
set.add(number);
}
}
}
result[view_key] = set;
}
return result;
});
const list_views_with_customer = computed(() => {
const customerNumber = Number(props.customer.customer_number);
const matched = view_keys.value.filter((view_key) => {
if (view_key === 'all') return false;
return membershipIndexes.value[view_key]?.has(customerNumber) === true;
});
return [...matched].sort((left, right) => {
const leftPriority = ATTRIBUTE_DISPLAY_PRIORITY[left] ?? UNKNOWN_ATTRIBUTE_PRIORITY;
@@ -5,12 +5,86 @@ import { computed } from "vue";
import { useRoute } from "vue-router";
import i18n from "@/i18n";
/**
* Number of days to widen the date window on either side of a flagged
* wash's `start_time` when navigating to the Selvvask view from a
* flag-link in the invoice period review.
*
* The single-day window shipped in #304 was sufficient when the wash
* always landed on the same calendar day, but real-world data shows
* washes that "span midnight" (e.g. a wash at 23:50 visible at 00:10)
* would fall outside the visible range. Widening to ±7 days makes
* the highlighted wash always visible regardless of which side of
* midnight it lands on.
*/
const FLAGGED_WASH_DATE_WINDOW_DAYS = 7;
const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
const parseRouteDateOnly = (value: any) => {
const rawValue = Array.isArray(value) ? String(value[0] ?? "") : String(value ?? "");
const match = DATE_ONLY_PATTERN.exec(rawValue.trim());
if (!match) {
return "";
}
return `${match[1]}-${match[2]}-${match[3]}`;
};
/**
* Pad an ISO-8601 date string (`YYYY-MM-DD`) by `±days` and return a
* new `YYYY-MM-DD` string. Invalid inputs return "".
*/
const shiftDateOnly = (dateOnly: string, days: number): string => {
if (dateOnly === "") {
return "";
}
const match = DATE_ONLY_PATTERN.exec(dateOnly);
if (!match) {
return "";
}
const utc = Date.UTC(
Number.parseInt(match[1], 10),
Number.parseInt(match[2], 10) - 1,
Number.parseInt(match[3], 10),
);
const shifted = new Date(utc + days * 24 * 60 * 60 * 1000);
if (Number.isNaN(shifted.getTime())) {
return "";
}
const yyyy = shifted.getUTCFullYear();
const mm = String(shifted.getUTCMonth() + 1).padStart(2, "0");
const dd = String(shifted.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
};
const route = useRoute();
const highlightedUsageLogId = computed(() => {
const parsedId = Number.parseInt(String(route.query.xlvaskUsageLogId || ""), 10);
return Number.isInteger(parsedId) && parsedId > 0 ? parsedId : 0;
});
const flaggedWashStartDate = computed(() => parseRouteDateOnly(route.query.xlvaskUsageLogStartTime));
/**
* Date window for the Selvvask view:
* - If a flagged wash start time is in the route, open a ±7d window
* around it so the highlighted wash is always visible.
* - Otherwise fall back to the current period date range.
*/
const initialDateFrom = computed(() => {
if (flaggedWashStartDate.value) {
return shiftDateOnly(flaggedWashStartDate.value, -FLAGGED_WASH_DATE_WINDOW_DAYS);
}
return dates.computed.formattedStartDate.value;
});
const initialDateTo = computed(() => {
if (flaggedWashStartDate.value) {
return shiftDateOnly(flaggedWashStartDate.value, FLAGGED_WASH_DATE_WINDOW_DAYS);
}
return dates.computed.formattedEndDate.value;
});
const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
</script>
@@ -18,8 +92,8 @@ const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
<section data-testid="invoicing-period-self-wash-view">
<XLVaskUsagePagination
:title="selfWashTitle"
:initial-date-from="dates.computed.formattedStartDate.value"
:initial-date-to="dates.computed.formattedEndDate.value"
:initial-date-from="initialDateFrom"
:initial-date-to="initialDateTo"
:inherit-period-filters="true"
:load-all-at-once="false"
:highlight-usage-log-id="highlightedUsageLogId"
@@ -0,0 +1,330 @@
<script setup>
import { onMounted, ref, computed } from "vue";
import Swal from "sweetalert2";
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
/**
* Permission groups (5 toggles) that mirror the existing 5 capability groups
* in modules/subusers/classes/subuser_permission_templates_service.php.
* They cover the 5 main areas a customer wants to grant their drivers:
* - vehicles (Køretøjer)
* - selfserve (Døgnvask)
* - bookings (Bookinger)
* - orders (Ordrer)
* - driver_management (Chaufførstyring)
*/
const PERMISSION_GROUPS = [
{ key: "vehicles", capabilities: ["VEHICLES_LIST", "VEHICLES_EDIT", "VEHICLES_DELETE", "VEHICLES_ADD"] },
{ key: "selfserve", capabilities: ["SELFSERVE_LIST", "SELFSERVE_EDIT", "SELFSERVE_DELETE", "SELFSERVE_ADD"] },
{ key: "bookings", capabilities: ["BOOKINGS_LIST", "BOOKINGS_EDIT", "BOOKINGS_DELETE", "BOOKINGS_ADD"] },
{ key: "orders", capabilities: ["ORDERS_LIST", "ORDERS_EDIT"] },
{ key: "driver_management", capabilities: ["SUBUSERS_LIST", "SUBUSERS_EDIT", "SUBUSERS_DELETE", "SUBUSERS_ADD"] },
];
/**
* The two primary roles (templates) shown to the customer in the kunde portal
* "tilladelser" tab. The customer picks one as the default for new drivers
* created through the QR flow (see TRU-88 / TRU-90).
*
* - chauffeur (driver) : standard self-serve + bookings
* - booking_coordinator : extended bookings/orders (no self-serve)
*/
const PRIMARY_ROLE_KEYS = ["driver", "booking_coordinator"];
const isLoading = ref(true);
const isSaving = ref(false);
const accessModel = ref({ templates: [], groups: [] });
const selectedTemplateKey = ref(null);
const selectedTemplate = computed(() => {
if (!selectedTemplateKey.value) {
return null;
}
return accessModel.value.templates.find((template) => template.key === selectedTemplateKey.value) || null;
});
const isGroupActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
const groups = selectedTemplate.value.permission_groups || [];
return groups.some((group) => group.key === groupKey);
};
const isGroupFullyActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
const groups = selectedTemplate.value.permission_groups || [];
const group = groups.find((g) => g.key === groupKey);
if (!group) {
return false;
}
// "Fully active" means all 4 capabilities in the group are granted
const groupDefinition = PERMISSION_GROUPS.find((g) => g.key === groupKey);
if (!groupDefinition) {
return false;
}
return groupDefinition.capabilities.every((capability) => group.capabilities.includes(capability));
};
const isGroupPartiallyActive = (groupKey) => {
if (!selectedTemplate.value) {
return false;
}
return isGroupActive(groupKey) && !isGroupFullyActive(groupKey);
};
const isPrimaryRole = (key) => PRIMARY_ROLE_KEYS.includes(key);
const loadAccessModel = async () => {
isLoading.value = true;
try {
const model = await SessionUser.objects.subuser_grants.functions.getPermissionTemplates();
const payload = model?.data?.data || model?.data || model;
accessModel.value = {
templates: Array.isArray(payload?.templates) ? payload.templates : [],
groups: Array.isArray(payload?.groups) ? payload.groups : [],
};
// Read the customer's stored default template via SessionUser / API
const stored = await SessionUser.request("/customer/default-driver-template", "GET")
.then((response) => response?.data?.data?.template_key || response?.data?.template_key || null)
.catch(() => null);
if (stored && accessModel.value.templates.some((template) => template.key === stored)) {
selectedTemplateKey.value = stored;
} else {
const chauffeur = accessModel.value.templates.find((template) => template.key === "driver");
selectedTemplateKey.value = chauffeur?.key || accessModel.value.templates[0]?.key || null;
}
} catch (error) {
console.error("Failed to load permission templates:", error);
accessModel.value = { templates: [], groups: [] };
await Swal.fire({
title: t("common.error"),
text: error?.message || t("user_dashboard.permissions.save_error"),
icon: "error",
});
} finally {
isLoading.value = false;
}
};
const onSelectTemplate = (key) => {
if (!accessModel.value.templates.some((template) => template.key === key)) {
return;
}
selectedTemplateKey.value = key;
};
const onSaveDefault = async () => {
if (!selectedTemplateKey.value || isSaving.value) {
return;
}
isSaving.value = true;
try {
await SessionUser.request("/customer/default-driver-template", "PUT", {
template_key: selectedTemplateKey.value,
});
await Swal.fire({
title: t("user_dashboard.permissions.saved"),
icon: "success",
timer: 1500,
showConfirmButton: false,
});
} catch (error) {
await Swal.fire({
title: t("common.error"),
text: SessionUser.functions.parseErrorMessage(error) || t("user_dashboard.permissions.save_error"),
icon: "error",
});
} finally {
isSaving.value = false;
}
};
onMounted(loadAccessModel);
</script>
<template>
<UserDashboardPageWrapper
:title="$t('user_dashboard.permissions.title')"
:subtitle="$t('user_dashboard.permissions.subtitle')"
>
<div v-if="isLoading" class="notification is-light">
{{ $t("user_dashboard.permissions.loading") }}
</div>
<template v-else>
<div class="permissions-tab" data-testid="user-permissions-tab">
<p class="block has-text-grey">
{{ $t("user_dashboard.permissions.card_intro") }}
</p>
<div class="columns is-multiline" data-testid="user-permissions-roles">
<div
v-for="template in accessModel.templates.filter((tpl) => isPrimaryRole(tpl.key))"
:key="template.key"
class="column is-half"
>
<button
type="button"
class="permission-role-card"
:class="{ 'permission-role-card--selected': selectedTemplateKey === template.key }"
:data-testid="`user-permission-role-${template.key}`"
@click="onSelectTemplate(template.key)"
>
<span class="permission-role-card__title">
{{
template.key === "driver"
? $t("user_dashboard.permissions.card_chauffor_label")
: $t("user_dashboard.permissions.card_booking_label")
}}
</span>
<span class="permission-role-card__description">
{{
template.key === "driver"
? $t("user_dashboard.permissions.card_chauffor_description")
: $t("user_dashboard.permissions.card_booking_description")
}}
</span>
<span
v-if="selectedTemplateKey === template.key"
class="tag is-success is-light mt-2"
:data-testid="`user-permission-role-selected-${template.key}`"
>
{{ $t("user_dashboard.permissions.selected_template", { label: template.label }) }}
</span>
</button>
</div>
</div>
<div class="permissions-tab__toggles" data-testid="user-permissions-toggles">
<h3 class="title is-6 mb-2">
<span class="icon"><i class="fas fa-sliders-h"></i></span>
<span>{{ $t("user_dashboard.permissions.toggle_description") }}</span>
</h3>
<div class="columns is-multiline">
<div
v-for="group in PERMISSION_GROUPS"
:key="group.key"
class="column is-half"
>
<div
class="permission-toggle box"
:class="{
'permission-toggle--active': isGroupFullyActive(group.key),
'permission-toggle--partial': isGroupPartiallyActive(group.key),
}"
:data-testid="`user-permission-toggle-${group.key}`"
>
<div class="level is-mobile mb-0">
<div class="level-left">
<div class="level-item">
<div>
<p class="permission-toggle__label">
{{ $t(`user_dashboard.permissions.groups.${group.key}`) }}
</p>
<p class="help has-text-grey">
{{ group.capabilities.length }} rettigheder
</p>
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
<b-switch
:model-value="isGroupFullyActive(group.key) || isGroupPartiallyActive(group.key)"
disabled
size="is-small"
type="is-success"
:data-testid="`user-permission-toggle-switch-${group.key}`"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="permissions-tab__actions mt-4">
<button
class="button is-link"
:class="{ 'is-loading': isSaving }"
:disabled="!selectedTemplateKey || isSaving"
data-testid="user-permissions-save"
@click="onSaveDefault"
>
<span class="icon"><i class="fas fa-save"></i></span>
<span>{{ $t("user_dashboard.permissions.save") }}</span>
</button>
</div>
</div>
</template>
</UserDashboardPageWrapper>
</template>
<style scoped>
.permissions-tab .permission-role-card {
align-items: flex-start;
background: #fff;
border: 1px solid #dbdbdb;
border-radius: 6px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 150px;
padding: 1.25rem;
text-align: left;
width: 100%;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.permissions-tab .permission-role-card:hover,
.permissions-tab .permission-role-card--selected {
border-color: #3273dc;
box-shadow: 0 0 0 1px #3273dc;
}
.permissions-tab .permission-role-card__title {
color: #1f2933;
font-weight: 700;
font-size: 1.1rem;
}
.permissions-tab .permission-role-card__description {
color: #4a4a4a;
font-size: 0.9rem;
line-height: 1.4;
}
.permissions-tab .permission-toggle {
border: 1px solid #e6ebf1;
border-radius: 6px;
padding: 0.85rem 1rem;
background: #fafbfc;
}
.permissions-tab .permission-toggle--active {
background: #f1faf3;
border-color: #48c78e;
}
.permissions-tab .permission-toggle--partial {
background: #fff8e1;
border-color: #ffe08a;
}
.permissions-tab .permission-toggle__label {
font-weight: 600;
color: #1f2933;
margin: 0;
}
</style>
@@ -22,6 +22,7 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
import { useWashProgress } from "@/composables/useWashProgress";
import { useWashFlowState } from "@/composables/useWashFlowState";
import { useWashSessionActions } from "@/composables/useWashSessionActions";
import { useRedCarWarning } from "@/composables/useRedCarWarning.js";
import { getSelfServeTaskDynamicImageButtons } from "@/services/selfServeDynamicImage.js";
import type { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
@@ -860,6 +861,17 @@ const applyResolvedVehicleTypeSelection = () => {
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
const effectiveCustomerNumberForRedCar = computed(() => {
const authenticated = getAuthenticatedCustomerNumber();
if (authenticated) {
return authenticated;
}
const typed = resolveEffectiveCustomerNumber(customerNumberInput.value);
return typed ?? null;
});
const { isRedCar: isRedCarCustomerFlag } = useRedCarWarning(effectiveCustomerNumberForRedCar);
const hasLocationCoordinates = computed(() => locations.hasValidCoordinatePair(locations.location.value?.coords));
const canUseDepartmentHeaderSelection = computed(
@@ -2383,6 +2395,7 @@ watch(
:vehicle-types="vehicleTypes"
:vehicle-step-error="vehicleStepError"
:vehicle-step-guidance="vehicleStepGuidanceKey ? $t(vehicleStepGuidanceKey) : null"
:is-red-car="isRedCarCustomerFlag"
@update:customer-number="onUpdateCustomerNumber"
@update:registration-number="onUpdateRegistrationNumber"
@select-vehicle-type="onSelectVehicleType"
@@ -20,6 +20,7 @@ defineProps<{
vehicleTypes: any[];
vehicleStepError?: string | null;
vehicleStepGuidance?: string | null;
isRedCar?: boolean;
}>();
const emit = defineEmits<{
@@ -56,6 +57,7 @@ const emitVehicleTypeSelection = (selection: VehicleTypeSelection) => {
:vehicle-types="vehicleTypes"
:vehicle-step-error="vehicleStepError"
:vehicle-step-guidance="vehicleStepGuidance"
:is-red-car="isRedCar"
@update:customer-number="emitCustomerNumber"
@update:customerNumber="emitCustomerNumber"
@update:registration-number="emitRegistrationNumber"
+3 -3
View File
@@ -64,7 +64,7 @@ const buildOverviewPayload = ({
{
product_id: 26,
slug: "hoejglans",
title: "Højglans - Voksforsegling pr. enhed",
title: "Voksforsegling pr. enhed",
state: "ready",
value: 4,
out_of: 8,
@@ -268,7 +268,7 @@ test.describe("Admin daily report", () => {
await expect(page.getByTestId("daily-report-tile-extraordinary-10-min")).toContainText(
"Ekstraordinær pr. 10 min inkl. kemi"
);
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Højglans - Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-undervognsskyl")).toContainText("Undervognsskyl pr. enhed");
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
});
@@ -667,7 +667,7 @@ test.describe("Admin daily report", () => {
await expect(page.getByTestId("daily-report-tile-extraordinary-10-min")).toContainText(
"Ekstraordinær pr. 10 min inkl. kemi"
);
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Højglans - Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-hoejglans")).toContainText("Voksforsegling pr. enhed");
await expect(page.getByTestId("daily-report-tile-undervognsskyl")).toContainText("Undervognsskyl pr. enhed");
await expect(page.getByTestId("daily-report-tile-double-duty-kemi")).toContainText("Tillæg for Specialsæbe - DD");
});

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