Compare commits

...
Author SHA1 Message Date
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
98 changed files with 3949 additions and 224 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
}
]
}
+1 -1
View File
@@ -45,7 +45,7 @@ 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,
+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;
});
+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);
+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'}",
@@ -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'}",
+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",
};
@@ -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"
@@ -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");
});
+45
View File
@@ -814,4 +814,49 @@ test.describe("i18n v2 catalog integrity", () => {
expect(unexpectedGroups).toEqual([]);
});
test("mirrors xlvask_review keys into the global v2 fallback", () => {
const daSource = readJsonFile(
path.join(SOURCE_DIRECTORY, "da", "phrases", "compat", "invoicing_period", "xlvask_review.json")
);
const globalShared = readJsonFile(
path.join(SOURCE_DIRECTORY, "global", "shared", "invoicing_period", "xlvask_review.json")
);
const daEntries = flattenStringEntries(getValueAtPath(daSource, "compat.invoicing_period.xlvask_review"));
const globalEntries = flattenStringEntries(getValueAtPath(globalShared, "invoicing_period.xlvask_review"));
const daKeys = daEntries.map((entry) => entry.key).sort();
const globalKeys = globalEntries.map((entry) => entry.key).sort();
expect(globalKeys, "global fallback should mirror every da xlvask_review key").toEqual(daKeys);
const nonLinked = globalEntries.filter((entry) => !entry.value.startsWith("@")).map((entry) => entry.key);
expect(nonLinked, "every global xlvask_review entry should be a linked reference").toEqual([]);
});
test("mirrors xlvask_usage_log flag keys into the global v2 fallback", () => {
const daSource = readJsonFile(
path.join(SOURCE_DIRECTORY, "da", "phrases", "compat", "invoice_period", "flags.json")
);
const globalShared = readJsonFile(path.join(SOURCE_DIRECTORY, "global", "shared", "invoice_period", "flags.json"));
const daEntries = flattenStringEntries(getValueAtPath(daSource, "compat.invoice_period.flags")).filter(
(entry) => entry.key.includes("xlvask_usage_log") || entry.key.includes("xlvask_missing_order_link")
);
const globalEntries = flattenStringEntries(getValueAtPath(globalShared, "invoice_period.flags")).filter(
(entry) => entry.key.includes("xlvask_usage_log") || entry.key.includes("xlvask_missing_order_link")
);
const daKeySet = new Set(daEntries.map((entry) => entry.key));
const globalKeySet = new Set(globalEntries.map((entry) => entry.key));
const missing = [...daKeySet].filter((key) => !globalKeySet.has(key));
expect(missing, "global flags fallback should mirror every da xlvask flag key").toEqual([]);
const nonLinked = globalEntries.filter((entry) => !entry.value.startsWith("@")).map((entry) => entry.key);
expect(nonLinked, "every mirrored global xlvask flag entry should be a linked reference").toEqual([]);
});
});
+85
View File
@@ -179,6 +179,44 @@ function createPeriodPayload() {
};
}
// Mirrors the real backend's applyPeriodPagination: only the requested
// `periodView` bucket keeps full customer cards; every other bucket is
// replaced with lightweight `{customer_number, membership_only}`
// memberships deduplicated by customer_number. This keeps the e2e mock
// in lock-step with the live API contract so the chip-stacking
// scenarios actually exercise the membership path.
function projectPeriodMockPagedPayload(payload, periodView) {
if (!payload || !payload.types || payload.__rawResponse) {
return payload;
}
const activeView = periodView && payload.types[periodView] !== undefined ? periodView : "all";
const types = payload.types;
const projected = {};
for (const [typeName, customers] of Object.entries(types)) {
if (typeName === activeView) {
projected[typeName] = Array.isArray(customers) ? customers.slice() : [];
continue;
}
const seen = new Set();
const memberships = [];
if (Array.isArray(customers)) {
for (const customer of customers) {
if (!customer || typeof customer !== "object") continue;
const customerNumber = Number(customer.customer_number);
if (!Number.isInteger(customerNumber) || customerNumber < 1) continue;
if (seen.has(customerNumber)) continue;
seen.add(customerNumber);
memberships.push({
customer_number: customerNumber,
membership_only: true,
});
}
}
projected[typeName] = memberships;
}
return { ...payload, types: projected };
}
function createObjectTreePeriodPayload({ dateFrom = "2026-07-14" } = {}) {
const payload = createPeriodPayload();
const fixtureDate = periodFixtureDate(dateFrom);
@@ -905,6 +943,12 @@ async function setupPeriodEndpoints(page, requests, options = {}) {
payload = createPeriodPayload();
}
// Mirror the real backend contract: only the requested periodView
// bucket carries full customer cards; every other bucket carries a
// lightweight `{customer_number, membership_only}` membership so the
// front-end can render category indicator chips on every subpage.
payload = projectPeriodMockPagedPayload(payload, url.searchParams.get("periodView"));
await route.fulfill(
json(
payload?.__rawResponse ?? {
@@ -2460,6 +2504,47 @@ test.describe("Invoicing period tab", () => {
}
});
test("@smoke period customer attribute chips render on every subpage including Alle", async ({ page }) => {
// Pin down the regression: customer indicator chips (e.g. "Faktura pr.
// ordre") were previously invisible on the Alle tab because the backend
// only returned full customer data for the active view bucket. The
// backend now projects lightweight `{customer_number, membership_only}`
// entries on every non-active bucket so the front-end can resolve
// category membership regardless of the active view.
await openPeriodView(page, { payloadFactory: createAttributeStackPeriodPayload });
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page).toHaveURL(/periodView=all/);
const multiAttributeCard = page.getByTestId("invoicing-period-customer-4101");
await expect(multiAttributeCard).toBeVisible();
const multiAttributeStack = multiAttributeCard.locator('[data-testid="invoicing-period-customer-attributes-4101"]');
await expect(multiAttributeStack).toBeVisible();
for (const viewKey of ["invoice_per_order", "fixed_pricing", "tank_cleaning"]) {
const chip = page.getByTestId(`invoicing-period-customer-attribute-4101-${viewKey}`);
await expect(chip, `expected chip "${viewKey}" on the Alle tab`).toBeVisible();
const chipText = (await chip.textContent())?.trim() ?? "";
expect(chipText, `chip ${viewKey} carries a non-empty label`).not.toBe("");
}
// The single-attribute customer should still show exactly one chip
// (matches the "fixed_pricing" bucket in the fixture).
const singleAttributeCard = page.getByTestId("invoicing-period-customer-4102");
await expect(singleAttributeCard).toBeVisible();
const singleStack = singleAttributeCard.locator('[data-testid="invoicing-period-customer-attributes-4102"]');
await expect(singleStack).toBeVisible();
await expect(
page.getByTestId("invoicing-period-customer-attribute-4102-fixed_pricing"),
"single-category customer renders its fixed_pricing chip on the Alle tab"
).toBeVisible();
await expect(
page.getByTestId("invoicing-period-customer-attribute-4102-invoice_per_order"),
"single-category customer does not render an invoice_per_order chip"
).toHaveCount(0);
});
test("@smoke period view selector switch updates visible customer set", async ({ page }) => {
await openPeriodView(page);
+88
View File
@@ -4213,6 +4213,94 @@ test.describe("POS mobile order flow", () => {
await waitForStepReset(page);
});
test("prompts for a required reason note for audited add-on products that are not the chemistry product", async ({
page,
}) => {
const orderId = 9416;
const baseFixture = createMobilePosFixture();
const auditedProduct = {
id: 25,
name: "Fælg flex pr. enhed",
description: "Audited addon that requires a reason comment",
price: 95,
subscription_allowed: true,
category: 8,
piktogram: "25",
apply_category_discount: false,
requires_note: false,
is_wash: false,
display_in_booking_form: true,
order_priority: 6,
addons: [],
};
const primaryProduct = {
...fixtureProduct(53),
addons: [
...fixtureProduct(53).addons,
{
id: auditedProduct.id,
name: auditedProduct.name,
price: auditedProduct.price,
product: { ...auditedProduct },
quantity: 1,
min: 0,
max: -1,
},
],
};
const fixture = createMobilePosFixture({
products: baseFixture.products
.map((product) => {
if (Number(product.id) !== 53) {
return product;
}
return primaryProduct;
})
.concat(auditedProduct),
ordersById: {
[orderId]: buildRegularOrder(orderId),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-product-25-note-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
reference: "PRODUCT-25-NOTE",
primaryItem: primaryProduct,
vehicleType: 53,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-addon-25-value")).toHaveText("1", { timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
await expect(page.locator('[data-testid="pos-mobile-popup"][data-popup-id="add_product_note"]')).toBeVisible({
timeout: 10_000,
});
await page.getByTestId("pos-mobile-product-note-input").fill("Højtryk bagpå venstre side");
await page.getByTestId("pos-mobile-product-note-confirm").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(2);
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
const product25Create = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 25);
expect(product25Create?.notes).toBe("Højtryk bagpå venstre side");
expect(product25Create?.reason_code).toBe("customer_approved_extra_work");
expect(product25Create?.reason_comment).toBe("Højtryk bagpå venstre side");
await waitForStepReset(page);
});
test("booking hydration applies booking items, reference, notes, and po", async ({ page }) => {
const orderId = 9405;
const fixture = createMobilePosFixture({
+2
View File
@@ -11,6 +11,7 @@ export const CARD_CUSTOMER_ID = 999;
export const WASH_CERTIFICATE_PRODUCT_ID = 41;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi";
const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 25, 26, 27]);
export const MOBILE_PERMISSIONS = ["admin", "department_access_1"];
export const MOBILE_NEXT_STEP_COOLDOWN_MS = 2100;
@@ -342,6 +343,7 @@ function productRequiresOrderItemNote(product) {
const productId = Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
return (
isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) ||
AUDITED_ORDER_ITEM_PRODUCT_IDS.has(productId) ||
productId === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID ||
String(product?.product?.name ?? product?.name ?? "").trim() === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME
);
@@ -0,0 +1,172 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
const XL_VASK_USAGE_LOG_ID = 55;
const PERIOD_FROM_DATE = "2026-07-01";
const PERIOD_TO_DATE = "2026-07-31";
const PERIOD_VIEW_URL = `/superuser/invoices?activeTab=period&startDate=${PERIOD_FROM_DATE}&endDate=${PERIOD_TO_DATE}&periodView=all`;
const SELVVASH_DESTINATION_PATTERN = new RegExp(
"/superuser/invoices\\?activeTab=period&periodView=self_wash&xlvaskUsageLogId=" + XL_VASK_USAGE_LOG_ID
);
function json(body: unknown, status = 200) {
return {
status,
contentType: "application/json",
body: JSON.stringify(body),
};
}
function createXlVaskFlaggedPeriodPayload({ dateFrom = PERIOD_FROM_DATE } = {}) {
const fixtureDate = String(dateFrom).slice(0, 10);
return {
data: {
types: {
all: [
{
id: 11,
customer_number: 4001,
customer_name: "Acme Fleet",
requires_action: true,
transactions: [
{
id: 9001,
date: `${fixtureDate}T10:00:00.000Z`,
amount: 120,
booked: false,
excluded: false,
},
],
queue: { has_active_job: false, statuses: [], invoice_collection_ids: [], is_action_blocked: false },
meta: {},
flags: [
{
id: "auto-xlvask-missing-1",
source: "automatic",
severity: "yellow",
status: "active",
target_type: "xlvask_usage_log",
target_id: XL_VASK_USAGE_LOG_ID,
customer_number: 4001,
definition_key: "xlvask_missing_order_link",
fingerprint: "xlvask-missing-fingerprint-1",
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
message_params: {
wash_id: `wash-${XL_VASK_USAGE_LOG_ID}`,
registration_number: "AB12345",
},
message: "XL Vask wash is neither ignored nor linked to an order in the selected period.",
context: {
customer_name: "Acme Fleet",
xlvask_usage_log_id: XL_VASK_USAGE_LOG_ID,
wash_id: `wash-${XL_VASK_USAGE_LOG_ID}`,
registration_number: "AB12345",
start_time: `${fixtureDate}T10:00:00.000Z`,
},
xlvask_usage_log_id: XL_VASK_USAGE_LOG_ID,
},
],
flag_counts: { manual: 0, automatic: 1, total: 1 },
status_indicator: "flag_yellow",
},
],
invoice_per_order: [],
fixed_pricing: [],
tank_cleaning: [],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
},
},
};
}
async function suppressVueDevtoolsOverlay(page) {
await page.addInitScript(() => {
const STYLE_ID = "__e2e-hide-vue-devtools";
localStorage.setItem("lastVersionCheck", String(Date.now()));
const apply = () => {
const target = document.head || document.documentElement;
if (!target) {
return;
}
if (!document.getElementById(STYLE_ID)) {
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent =
"#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }";
target.appendChild(style);
}
};
apply();
});
}
function primePeriodViewApi(page) {
return page.route("**/superuser/invoicing/period**", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
const url = new URL(route.request().url());
const dateFrom = url.searchParams.get("dateFrom") || PERIOD_FROM_DATE;
await route.fulfill(json(createXlVaskFlaggedPeriodPayload({ dateFrom })));
});
}
test.describe("XL Vask flag → Selvvash navigation", () => {
test("@smoke flag token opens the Selvvash tab with the highlighted wash", async ({ page, context }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await page.setViewportSize({ width: 1440, height: 900 });
const token = "superuser-xl-vask-navigation-token";
await suppressVueDevtoolsOverlay(page);
await seedAuthenticatedState(page, token);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
loginToken: token,
});
await primePeriodViewApi(page);
await context.addInitScript((tokenValue) => {
window.localStorage.setItem("token", tokenValue);
}, token);
await page.goto(PERIOD_VIEW_URL, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 30_000 });
const customerRow = page.getByTestId("invoicing-period-customer-4001");
await expect(customerRow).toBeVisible();
const flagRow = page.getByTestId("invoice-period-flag-auto-xlvask-missing-1");
const flagRowCount = await flagRow.count();
test.skip(
flagRowCount === 0,
"No xlvask_missing_order_link flag is visible in the current period payload; smoke run skips."
);
await expect(flagRow).toBeVisible();
const flagToken = flagRow.locator(".invoice-period-flag-token");
await expect(flagToken).toBeVisible();
const popupPromise = page.waitForEvent("popup", { timeout: 15_000 });
await flagToken.click();
const popup = await popupPromise;
await expect(popup).toHaveURL(SELVVASH_DESTINATION_PATTERN);
await popup.waitForLoadState("domcontentloaded").catch(() => {});
await popup.close();
await context.close();
});
});
@@ -106,6 +106,26 @@ describe("DepartmentDashboardDailyReportProductSales behavior", () => {
expect(missingTileWrapper.text()).toContain("Tillæg for Specialsæbe - DD");
});
it("falls back to a brand-agnostic wax title for product 26 (TRU-130)", () => {
// TRU-130: the previous wax product ("Højglans - Voksforsegling") tested
// poorly in the field and is being replaced. The fallback title should
// therefore stay brand-agnostic so the tile keeps matching whatever
// wax product the API eventually reports.
dailyReportProductsState.value = {};
const waxWrapper = mount(DepartmentDashboardDailyReportProductSales, {
props: {
product_id: 26,
subtitle: "Dagens salg af produktet",
dataTestid: "daily-report-tile-hoejglans",
},
});
const rendered = waxWrapper.text();
expect(rendered).toContain("Voksforsegling pr. enhed");
expect(rendered).not.toContain("Højglans");
});
it("shows a saved target percentage below the product percentage", () => {
dailyReportProductsState.value = {
24: {
@@ -0,0 +1,59 @@
// Regression guard for TRU-13 / AUT-9: every `invoicing_period.xlvask_review.*`
// key that exists in the da reference catalogue must also be present in
// the other active locales (no, sv, de, en) with a non-empty translation.
// The end-to-end integrity suite already enforces global key parity, but
// this targeted test documents the acceptance criteria for the xlvask_review
// translation work and surfaces locale-specific gaps immediately.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const activeLocales = ["da", "no", "sv", "de", "en"];
const reviewFileFor = (locale) =>
join(root, `src/i18n/source/${locale}/phrases/compat/invoicing_period/xlvask_review.json`);
const flattenPairs = (node, prefix = "") => {
if (typeof node === "string") {
return [{ key: prefix, value: node }];
}
if (!node || typeof node !== "object" || Array.isArray(node)) {
return [];
}
return Object.entries(node).flatMap(([key, value]) => {
const nextPrefix = prefix ? `${prefix}.${key}` : key;
return flattenPairs(value, nextPrefix);
});
};
const readReviewSection = (locale) => {
const file = JSON.parse(readFileSync(reviewFileFor(locale), "utf8"));
return file?.compat?.invoicing_period?.xlvask_review ?? {};
};
describe("xlvask_review translation coverage", () => {
it("covers every da xlvask_review key in no, sv, de, en with a non-empty value", () => {
const daEntries = flattenPairs(readReviewSection("da"));
expect(daEntries.length, "da xlvask_review should expose translatable strings").toBeGreaterThan(0);
for (const locale of activeLocales.filter((entry) => entry !== "da")) {
const localeEntries = new Map(flattenPairs(readReviewSection(locale)).map((entry) => [entry.key, entry.value]));
const missing = [];
const empty = [];
for (const { key } of daEntries) {
if (!localeEntries.has(key)) {
missing.push(key);
continue;
}
const localized = localeEntries.get(key);
if (typeof localized !== "string" || localized.trim().length === 0) {
empty.push(key);
}
}
expect(missing, `${locale} xlvask_review keys missing from da`).toEqual([]);
expect(empty, `${locale} xlvask_review keys with empty translations`).toEqual([]);
}
});
});
@@ -0,0 +1,193 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { nextTick, ref } from "vue";
const sharedVariablesRef = vi.hoisted(() => ({ value: null }));
vi.mock(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportView.vue",
() => ({
view: {
variables: {
sharedVariables: sharedVariablesRef,
},
computed: {
getViewFriendlyName: (viewKey) => {
const labels = {
all: "Alle",
invoice_per_order: "Faktura pr. ordre",
fixed_pricing: "Fastpris",
tank_cleaning: "Tankrengøring",
vehicle_subscriptions: "Vaskeabonnement",
special_arrangements: "Særaftale",
possible_duplicates: "Mulige dubletter",
};
return labels[viewKey] ?? viewKey;
},
},
},
})
);
vi.mock("@/components/displays/buttons/ColorIndicator.vue", () => ({
default: {
name: "ColorIndicator",
props: ["label", "visibility", "is_button_hover_effect"],
render() {
const { h } = require("vue");
return h(
"span",
{
class: "color-indicator-mock",
"data-testid": this.$attrs["data-testid"],
},
this.label?.text ?? ""
);
},
},
}));
const InvoicingBillingPeriodCustomerAttributes = (
await import(
"@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/layout/InvoicingBillingPeriodCustomerAttributes.vue"
)
).default;
const customer = (customerNumber) => ({ customer_number: customerNumber });
const setSharedTypes = (types) => {
sharedVariablesRef.value = { types };
};
const mountWithCustomer = (customerProps) =>
mount(InvoicingBillingPeriodCustomerAttributes, {
props: customerProps,
});
afterEach(() => {
sharedVariablesRef.value = null;
vi.clearAllMocks();
});
describe("InvoicingBillingPeriodCustomerAttributes lightweight membership", () => {
it("renders category chips for the active view bucket (full customer cards)", async () => {
setSharedTypes({
all: [customer(4001), customer(4002)],
invoice_per_order: [customer(7001), { customer_number: 4001, membership_only: true }],
fixed_pricing: [{ customer_number: 4001, membership_only: true }],
tank_cleaning: [],
});
const wrapper = mountWithCustomer({ customer: customer(4001) });
await nextTick();
expect(wrapper.find('[data-testid="invoicing-period-customer-attributes-4001"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-invoice_per_order"]').exists()).toBe(
true
);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-fixed_pricing"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-tank_cleaning"]').exists()).toBe(false);
});
it("renders chips from lightweight memberships on non-active buckets", async () => {
// Alle tab: only the `all` bucket ships full customer cards. The other
// buckets carry lightweight `{customer_number, membership_only: true}`
// entries — that's the contract the backend emits today.
setSharedTypes({
all: [customer(4001), customer(4002)],
invoice_per_order: [{ customer_number: 4001, membership_only: true }],
fixed_pricing: [{ customer_number: 4001, membership_only: true }],
tank_cleaning: [{ customer_number: 4001, membership_only: true }],
special_arrangements: [],
vehicle_subscriptions: [],
possible_duplicates: [],
});
const wrapper = mountWithCustomer({ customer: customer(4001) });
await nextTick();
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-invoice_per_order"]').exists()).toBe(
true
);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-fixed_pricing"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-tank_cleaning"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-special_arrangements"]').exists()).toBe(
false
);
});
it("ignores the `all` bucket for chip membership even when populated", async () => {
setSharedTypes({
all: [customer(4001), customer(4002)],
invoice_per_order: [{ customer_number: 4001, membership_only: true }],
fixed_pricing: [],
tank_cleaning: [],
});
const wrapper = mountWithCustomer({ customer: customer(4001) });
await nextTick();
// The `all` bucket is intentionally excluded by list_views_with_customer.
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-all"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-invoice_per_order"]').exists()).toBe(
true
);
});
it("skips memberships without a numeric customer_number", async () => {
setSharedTypes({
all: [customer(4001)],
invoice_per_order: [
{ customer_number: "not-a-number", membership_only: true },
{ membership_only: true },
null,
{ customer_number: 0, membership_only: true },
{ customer_number: -1, membership_only: true },
{ customer_number: 4001, membership_only: true },
],
fixed_pricing: [],
});
const wrapper = mountWithCustomer({ customer: customer(4001) });
await nextTick();
expect(wrapper.find('[data-testid="invoicing-period-customer-attribute-4001-invoice_per_order"]').exists()).toBe(
true
);
});
it("preserves the deterministic chip display order across buckets", async () => {
// Provide buckets in REVERSE priority order; the component must sort
// them according to ATTRIBUTE_DISPLAY_PRIORITY so the chip stack stays
// stable regardless of payload shape.
setSharedTypes({
all: [customer(4001)],
possible_duplicates: [{ customer_number: 4001, membership_only: true }],
special_arrangements: [{ customer_number: 4001, membership_only: true }],
tank_cleaning: [{ customer_number: 4001, membership_only: true }],
vehicle_subscriptions: [{ customer_number: 4001, membership_only: true }],
fixed_pricing: [{ customer_number: 4001, membership_only: true }],
invoice_per_order: [{ customer_number: 4001, membership_only: true }],
});
const wrapper = mountWithCustomer({ customer: customer(4001) });
await nextTick();
const renderedKeys = wrapper
.findAll('[data-testid^="invoicing-period-customer-attribute-4001-"]')
.map((node) => node.attributes("data-testid").replace("invoicing-period-customer-attribute-4001-", ""));
// ATTRIBUTE_DISPLAY_PRIORITY order: invoice_per_order, fixed_pricing,
// vehicle_subscriptions, tank_cleaning, special_arrangements,
// possible_duplicates. Special then alphabetical for unknowns.
expect(renderedKeys).toEqual([
"invoice_per_order",
"fixed_pricing",
"vehicle_subscriptions",
"tank_cleaning",
"special_arrangements",
"possible_duplicates",
]);
});
});
+82 -1
View File
@@ -55,6 +55,8 @@ const i18n = createTestI18n({
preview: {
no_order_items: "No order items available.",
no_xlvask_usage_log: "No XL Vask details available.",
no_xlvask_usage_log_metadata:
"XL Vask registration (no metadata available — see api flag-context serializer)...",
product: "Product",
quantity: "Qty",
price: "Price",
@@ -390,11 +392,90 @@ describe("InvoicingPeriodFlagList", () => {
await token.trigger("click");
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenCalledWith(
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=55",
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=55&xlvaskUsageLogStartTime=2026-05-11",
true
);
});
it("propagates the flagged wash start time when navigating to the Selvvask view", async () => {
const wrapper = mountList([
{
id: "auto-xlvask-root",
source: "automatic",
fingerprint: "xlvask-root",
definition_key: "xlvask_missing_order_link",
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
target_type: "xlvask_usage_log",
target_id: 77,
xlvask_usage_log_id: 77,
context: {
xlvask_usage_log_id: 77,
start_time: "2026-04-28T08:15:00",
},
},
]);
await wrapper.get(".invoice-period-flag-token").trigger("click");
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenLastCalledWith(
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=77&xlvaskUsageLogStartTime=2026-04-28",
true
);
});
it("navigates to Selvvask without a start-time query when the flag has no wash date", async () => {
const wrapper = mountList([
{
id: "auto-xlvask-no-date",
source: "automatic",
fingerprint: "xlvask-no-date",
definition_key: "xlvask_missing_order_link",
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
target_type: "xlvask_usage_log",
target_id: 91,
xlvask_usage_log_id: 91,
context: {
xlvask_usage_log_id: 91,
},
},
]);
await wrapper.get(".invoice-period-flag-token").trigger("click");
expect(SessionUser.functions.redirectTo.superUser).toHaveBeenLastCalledWith(
"/invoices?activeTab=period&periodView=self_wash&xlvaskUsageLogId=91",
true
);
});
// When the API flag-context serializer does not provide any of the
// identity fields (wash_id / registration_number / start_time / customer_name),
// the hover preview should not pretend the data is missing — it should
// tell the operator that the metadata simply isn't being supplied.
it("explains the empty XL Vask hover preview when flag context has no metadata fields", async () => {
const wrapper = mountList([
{
id: "auto-xlvask-empty",
source: "automatic",
fingerprint: "xlvask-empty-fingerprint",
definition_key: "xlvask_missing_order_link",
message_key: "invoice_period.flags.automatic.xlvask_missing_order_link",
target_type: "xlvask_usage_log",
target_id: 99,
xlvask_usage_log_id: 99,
context: {},
},
]);
const token = wrapper.get(".invoice-period-flag-token");
expect(token.text()).toBe("XL Vask wash");
await token.trigger("mouseover");
expect(popperBoxMock).toHaveBeenLastCalledWith(
"XL Vask registration",
"XL Vask registration (no metadata available — see api flag-context serializer)..."
);
expect(showPopperMock).toHaveBeenCalledTimes(1);
});
it("renders invoice period warnings in proper Danish", () => {
const wrapper = mountList(
[
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import {
formatLocaleDate,
formatLocaleDateRange,
formatLocaleDateTime,
formatLocaleMonthLabel,
formatLocaleNumber,
} from "@/services/localeFormatting.js";
describe("formatLocaleNumber", () => {
it("formats a plain number using the requested locale", () => {
expect(formatLocaleNumber(1234.5, "en-US")).toBe("1,234.5");
});
it("uses locale-specific decimal and grouping separators", () => {
expect(formatLocaleNumber(1234.5, "da-DK")).toBe("1.234,5");
});
it("falls back to the en locale when the locale argument is empty, null, or undefined", () => {
expect(formatLocaleNumber(1234.5, "")).toBe(formatLocaleNumber(1234.5, "en"));
expect(formatLocaleNumber(1234.5, null)).toBe(formatLocaleNumber(1234.5, "en"));
expect(formatLocaleNumber(1234.5, undefined)).toBe(formatLocaleNumber(1234.5, "en"));
});
it("trims surrounding whitespace around the locale argument", () => {
expect(formatLocaleNumber(42, " en-US ")).toBe(formatLocaleNumber(42, "en-US"));
});
it("coerces NaN and non-numeric values to 0", () => {
expect(formatLocaleNumber(NaN, "en-US")).toBe("0");
expect(formatLocaleNumber("not-a-number", "en-US")).toBe("0");
expect(formatLocaleNumber(null, "en-US")).toBe("0");
expect(formatLocaleNumber(undefined, "en-US")).toBe("0");
});
it("passes Intl options through (currency style)", () => {
expect(formatLocaleNumber(99.95, "en-US", { style: "currency", currency: "USD" })).toBe("$99.95");
});
it("formats integers without a fractional separator", () => {
expect(formatLocaleNumber(42, "en-US")).toBe("42");
});
});
describe("formatLocaleDate", () => {
it("formats a YYYY-MM-DD string with the locale's short month", () => {
expect(formatLocaleDate("2026-03-18", "en-US")).toBe("Mar 18, 2026");
expect(formatLocaleDate("2026-03-18", "da-DK")).toBe("18. mar. 2026");
});
it("formats a Date instance built from local-time components", () => {
const date = new Date(2026, 2, 18);
expect(formatLocaleDate(date, "en-US")).toBe("Mar 18, 2026");
});
it("merges custom Intl options over the defaults", () => {
expect(formatLocaleDate("2026-03-18", "en-US", { month: "long" })).toBe("March 18, 2026");
});
it("returns an empty string for invalid, empty, null, or undefined values", () => {
expect(formatLocaleDate("not-a-date", "en-US")).toBe("");
expect(formatLocaleDate("", "en-US")).toBe("");
expect(formatLocaleDate(" ", "en-US")).toBe("");
expect(formatLocaleDate(null, "en-US")).toBe("");
expect(formatLocaleDate(undefined, "en-US")).toBe("");
expect(formatLocaleDate(new Date("invalid"), "en-US")).toBe("");
});
});
describe("formatLocaleDateTime", () => {
it("includes hour and minute in the formatted output", () => {
const date = new Date(2026, 2, 18, 9, 30);
expect(formatLocaleDateTime(date, "en-US")).toBe("Mar 18, 2026, 09:30 AM");
});
it("formats a YYYY-MM-DD string with the locale's short month and time defaults", () => {
expect(formatLocaleDateTime("2026-03-18", "en-US")).toBe("Mar 18, 2026, 12:00 AM");
});
it("returns an empty string when the value cannot be parsed", () => {
expect(formatLocaleDateTime("not-a-date", "en-US")).toBe("");
expect(formatLocaleDateTime(null, "en-US")).toBe("");
});
it("honours custom Intl options for time and date parts", () => {
const date = new Date(2026, 2, 18, 9, 30);
expect(
formatLocaleDateTime(date, "en-US", {
hour: "2-digit",
minute: "2-digit",
year: undefined,
month: undefined,
day: undefined,
})
).toBe("09:30 AM");
});
});
describe("formatLocaleMonthLabel", () => {
it("returns the long month name paired with the year", () => {
expect(formatLocaleMonthLabel("2026-03-18", "en-US")).toBe("March 2026");
expect(formatLocaleMonthLabel("2026-03-18", "da-DK")).toBe("marts 2026");
});
it("formats a Date instance using only month and year", () => {
expect(formatLocaleMonthLabel(new Date(2026, 2, 18), "en-US")).toBe("March 2026");
});
it("returns an empty string for invalid input", () => {
expect(formatLocaleMonthLabel("not-a-date", "en-US")).toBe("");
expect(formatLocaleMonthLabel(null, "en-US")).toBe("");
});
});
describe("formatLocaleDateRange", () => {
it("joins the formatted from and to values with a dash", () => {
expect(formatLocaleDateRange("2026-03-01", "2026-03-31", "en-US")).toBe("Mar 1, 2026 - Mar 31, 2026");
});
it("returns the single date when both ends resolve to the same formatted value", () => {
expect(formatLocaleDateRange("2026-03-01", "2026-03-01", "en-US")).toBe("Mar 1, 2026");
});
it("returns only the start when the end is missing or invalid", () => {
expect(formatLocaleDateRange("2026-03-01", "", "en-US")).toBe("Mar 1, 2026");
expect(formatLocaleDateRange("2026-03-01", "not-a-date", "en-US")).toBe("Mar 1, 2026");
});
it("returns only the end when the start is missing or invalid", () => {
expect(formatLocaleDateRange("", "2026-03-31", "en-US")).toBe("Mar 31, 2026");
expect(formatLocaleDateRange("not-a-date", "2026-03-31", "en-US")).toBe("Mar 31, 2026");
});
it("returns an empty string when both ends are missing", () => {
expect(formatLocaleDateRange("", "", "en-US")).toBe("");
expect(formatLocaleDateRange(null, undefined, "en-US")).toBe("");
});
it("honours the requested locale for both ends", () => {
expect(formatLocaleDateRange("2026-03-01", "2026-03-31", "da-DK")).toBe("1. mar. 2026 - 31. mar. 2026");
});
});
+134
View File
@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { defineComponent, h, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
hasStoredSessionToken: vi.fn(),
}));
vi.mock("@/services/sessionStorage.js", () => ({
hasStoredSessionToken: mocks.hasStoredSessionToken,
}));
vi.mock("@/composables/useStoredSessionGuestRedirect.js", () => ({
useStoredSessionGuestRedirect: () => ({
hasPendingStoredSession: mocks.hasStoredSessionToken(),
}),
}));
vi.mock("@/components/forms/auth/LoginForm.vue", () => ({
default: defineComponent({
name: "MockLoginForm",
setup() {
return () => h("form", { "data-testid": "login-form" });
},
}),
}));
vi.mock("@/views/errors/ConnectivityIssue.vue", () => ({
default: defineComponent({
name: "MockConnectivityIssue",
setup(_, { slots }) {
return () => h("div", { "data-testid": "connectivity-wrapper" }, slots.default?.());
},
}),
}));
vi.mock("@/components/global/BrandedLoadingScreen.vue", () => ({
default: defineComponent({
name: "MockBrandedLoadingScreen",
props: {
testId: { type: String, default: "branded-loading-screen" },
},
setup(props) {
return () => h("div", { "data-testid": props.testId });
},
}),
}));
import Login from "@/views/auth/Login.vue";
const routerLinkStub = defineComponent({
name: "RouterLinkStub",
props: {
to: {
type: [String, Object],
default: "",
},
},
setup(_, { slots, attrs }) {
return () => h("a", attrs, slots.default?.());
},
});
const mountLogin = () =>
mount(Login, {
global: {
mocks: {
$t: (key) => key,
},
stubs: {
RouterLink: routerLinkStub,
},
},
});
describe("Login.vue — driver entry block (TRU-61)", () => {
beforeEach(() => {
mocks.hasStoredSessionToken.mockReset();
mocks.hasStoredSessionToken.mockReturnValue(false);
});
afterEach(() => {
document.body.innerHTML = "";
});
it("renders the page-level driver entry block on the customer login page", async () => {
const wrapper = mountLogin();
await nextTick();
const entry = wrapper.find('[data-testid="login-driver-entry"]');
expect(entry.exists()).toBe(true);
});
it("renders a router-link to the subuser login route inside the driver entry block", async () => {
const wrapper = mountLogin();
await nextTick();
const link = wrapper.find('[data-testid="login-driver-login-link"]');
expect(link.exists()).toBe(true);
});
it("renders the driver login button with the localized driver_login_button label", async () => {
const wrapper = mountLogin();
await nextTick();
const link = wrapper.find('[data-testid="login-driver-login-link"]');
expect(link.text()).toContain("auth.driver_login_button");
});
it("renders the driver login help text inside the entry block", async () => {
const wrapper = mountLogin();
await nextTick();
const entry = wrapper.find('[data-testid="login-driver-entry"]');
expect(entry.text()).toContain("auth.driver_login_help");
});
it("places the driver entry block in the main content area (not the sidebar)", async () => {
const wrapper = mountLogin();
await nextTick();
// The sidebar should NOT contain the driver entry — it must be in main
const html = wrapper.html();
const entryHtml = wrapper.find('[data-testid="login-driver-entry"]').html();
// Find the sidebar's outer container and ensure the entry is not inside it
const sidebarMatch = html.match(/<div class="sidebar">[\s\S]*?<\/div>\s*<div class="main">/);
if (sidebarMatch) {
expect(sidebarMatch[0]).not.toContain("login-driver-entry");
}
expect(entryHtml).toBeTruthy();
});
});
+88 -1
View File
@@ -14,7 +14,7 @@ vi.mock("axios", () => {
});
import axios from "axios";
import { createOrderItem } from "@/components/shop/OrdersItems.vue";
import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem } from "@/components/shop/OrdersItems.vue";
describe("createOrderItem", () => {
beforeEach(() => {
@@ -66,3 +66,90 @@ describe("createOrderItem", () => {
);
});
});
describe("createOrderItem (audited products)", () => {
beforeEach(() => {
localStorage.clear();
axios.post.mockReset();
localStorage.setItem("token", "test-token");
axios.post.mockResolvedValue({ data: { success: true, data: { id: 1 } } });
});
const auditedProductIds = [21, 22, 25, 26, 27];
const DEFAULT_REASON_LABEL = "Kunde godkendte ekstra arbejde";
it.each(auditedProductIds)(
"always sends reason_comment for audited product %s even when notes is missing",
async (productId) => {
await createOrderItem(51207, productId, 1);
expect(axios.post).toHaveBeenCalledTimes(1);
const [, body] = axios.post.mock.calls[0];
expect(body).toEqual(
expect.objectContaining({
order_id: 51207,
product_id: productId,
quantity: 1,
reason_code: "customer_approved_extra_work",
reason_label_snapshot: DEFAULT_REASON_LABEL,
})
);
expect(typeof body.reason_comment).toBe("string");
expect(body.reason_comment.trim().length).toBeGreaterThan(0);
expect(body.reason_comment).toBe(DEFAULT_REASON_LABEL);
}
);
it("falls back to notes when reason_comment is not provided", async () => {
await createOrderItem(51207, 25, 1, null, " Customer approved graffiti removal ");
const [, body] = axios.post.mock.calls[0];
expect(body.reason_comment).toBe("Customer approved graffiti removal");
expect(body.reason_label_snapshot).toBe(DEFAULT_REASON_LABEL);
expect(body.reason_code).toBe("customer_approved_extra_work");
expect(body.notes).toBe(" Customer approved graffiti removal ");
});
it("prefers an explicit reason_comment when the caller passes reasonData", async () => {
await createOrderItem(51207, 25, 1, null, "free-form notes", null, {
reason_comment: "Explicit override",
reason_label_snapshot: "Custom label",
reason_code: "custom_code",
});
const [, body] = axios.post.mock.calls[0];
expect(body.reason_comment).toBe("Explicit override");
expect(body.reason_label_snapshot).toBe("Custom label");
expect(body.reason_code).toBe("custom_code");
});
it("does not include reason fields for non-audited products", async () => {
await createOrderItem(51207, 7, 1, null, "note");
const [, body] = axios.post.mock.calls[0];
expect(body).not.toHaveProperty("reason_code");
expect(body).not.toHaveProperty("reason_label_snapshot");
expect(body).not.toHaveProperty("reason_comment");
});
it("treats whitespace-only notes as empty and falls back to the default label", async () => {
await createOrderItem(51207, 25, 1, null, " ");
const [, body] = axios.post.mock.calls[0];
expect(body.reason_comment).toBe(DEFAULT_REASON_LABEL);
});
});
describe("AUDITED_ORDER_ITEM_PRODUCT_IDS membership", () => {
it("contains the expected audited product ids", () => {
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(21)).toBe(true);
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(22)).toBe(true);
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(25)).toBe(true);
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(26)).toBe(true);
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(27)).toBe(true);
});
it("does not include the spot-free-lastbil product id", () => {
expect(AUDITED_ORDER_ITEM_PRODUCT_IDS.has(24)).toBe(false);
});
});
+8 -2
View File
@@ -777,8 +777,14 @@ describe("POSDepartmentProcess.hydrateSelectedOrderBookingForDesktop", () => {
await expect(hydrateSelectedOrderBookingForDesktop()).resolves.toBe(true);
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(1, 51207, 10, 1, null, null, 500);
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(2, 51207, 20, 3, 9001, "Addon note", 500);
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(1, 51207, 10, 1, null, null, 500, null, {
extra_sale_comment: null,
extra_sale_reason_code: null,
});
expect(mocks.createOrderItem).toHaveBeenNthCalledWith(2, 51207, 20, 3, 9001, "Addon note", 500, null, {
extra_sale_comment: null,
extra_sale_reason_code: null,
});
expect(SessionUser.objects.products.get.single).toHaveBeenCalledWith(10, {
department_id: 2,
customer_id: 12345679,
+150
View File
@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_RED_CAR_ATTRIBUTE_KEYS,
extractAttributeKey,
extractAttributeKeys,
getRedCarAttributeKeys,
isRedCarCustomer,
} from "@/composables/redCarDetector.js";
describe("redCarDetector.extractAttributeKey", () => {
it("returns trimmed string for plain string entries", () => {
expect(extractAttributeKey("isRedCar")).toBe("isRedCar");
expect(extractAttributeKey(" invoiceAllOrdersIndividually ")).toBe("invoiceAllOrdersIndividually");
});
it("extracts the attribute field from object rows", () => {
expect(extractAttributeKey({ attribute: "isRedCar" })).toBe("isRedCar");
expect(extractAttributeKey({ attribute: "isRedCar", customer_number: 42 })).toBe("isRedCar");
});
it("falls back to key/name aliases when attribute is missing", () => {
expect(extractAttributeKey({ key: "redCar" })).toBe("redCar");
expect(extractAttributeKey({ name: "red_car" })).toBe("red_car");
});
it("returns empty string for invalid input", () => {
expect(extractAttributeKey(null)).toBe("");
expect(extractAttributeKey(undefined)).toBe("");
expect(extractAttributeKey(123)).toBe("");
expect(extractAttributeKey({})).toBe("");
expect(extractAttributeKey({ attribute: "" })).toBe("");
});
});
describe("redCarDetector.extractAttributeKeys", () => {
it("returns the attribute key for each row", () => {
expect(extractAttributeKeys(["isRedCar", { attribute: "redCar" }, { key: "is_red_car" }])).toEqual([
"isRedCar",
"redCar",
"is_red_car",
]);
});
it("filters out invalid rows and empty keys", () => {
expect(
extractAttributeKeys([null, undefined, "isRedCar", "", { attribute: "" }, { attribute: " redCar " }])
).toEqual(["isRedCar", "redCar"]);
});
it("returns an empty array for non-array input", () => {
expect(extractAttributeKeys(null)).toEqual([]);
expect(extractAttributeKeys(undefined)).toEqual([]);
expect(extractAttributeKeys("isRedCar")).toEqual([]);
});
});
describe("redCarDetector.isRedCarCustomer (positive cases)", () => {
it("flags a customer with the default isRedCar attribute", () => {
expect(isRedCarCustomer([{ attribute: "isRedCar" }])).toBe(true);
});
it("flags any of the default attribute keys", () => {
for (const key of DEFAULT_RED_CAR_ATTRIBUTE_KEYS) {
expect(isRedCarCustomer([{ attribute: key }])).toBe(true);
}
});
it("flags when the attribute is a plain string", () => {
expect(isRedCarCustomer(["isRedCar"])).toBe(true);
expect(isRedCarCustomer(["red_car"])).toBe(true);
});
it("matches case-insensitively", () => {
expect(isRedCarCustomer([{ attribute: "ISREDCAR" }])).toBe(true);
expect(isRedCarCustomer([{ attribute: "isredcar" }])).toBe(true);
});
it("flags a customer with a custom configured key", () => {
expect(
isRedCarCustomer([{ attribute: "needsGentleWash" }], {
attributeKeys: ["needsGentleWash"],
})
).toBe(true);
});
it("flags when at least one matching attribute is present among others", () => {
expect(
isRedCarCustomer([
{ attribute: "invoiceAllOrdersIndividually" },
{ attribute: "isRedCar" },
{ attribute: "onlyTankCleaning" },
])
).toBe(true);
});
});
describe("redCarDetector.isRedCarCustomer (negative cases)", () => {
it("returns false for an empty attribute list", () => {
expect(isRedCarCustomer([])).toBe(false);
});
it("returns false when no attribute matches the default keys", () => {
expect(isRedCarCustomer([{ attribute: "onlyTankCleaning" }, { attribute: "invoiceAllOrdersIndividually" }])).toBe(
false
);
});
it("returns false for null/undefined input", () => {
expect(isRedCarCustomer(null)).toBe(false);
expect(isRedCarCustomer(undefined)).toBe(false);
});
it("returns false when configured keys are all empty strings", () => {
expect(isRedCarCustomer([{ attribute: "isRedCar" }], { attributeKeys: ["", " "] })).toBe(false);
});
it("returns false for an unrelated custom key when defaults are used", () => {
expect(isRedCarCustomer([{ attribute: "needsGentleWash" }])).toBe(false);
});
it("returns false for a custom key when a different custom key is configured", () => {
expect(
isRedCarCustomer([{ attribute: "needsGentleWash" }], {
attributeKeys: ["isRedCar"],
})
).toBe(false);
});
});
describe("redCarDetector.getRedCarAttributeKeys", () => {
it("returns the default keys when no override is given", () => {
const keys = getRedCarAttributeKeys();
expect(keys).toContain("isredcar");
expect(keys).toContain("is_red_car");
expect(keys).toContain("redcar");
expect(keys).toContain("red_car");
});
it("normalizes and deduplicates custom keys", () => {
const keys = getRedCarAttributeKeys({
attributeKeys: [" isRedCar ", "ISREDCAR", "", "redCar"],
});
expect(keys).toEqual(["isredcar", "redcar"]);
});
it("returns an empty array when no valid keys are configured", () => {
expect(getRedCarAttributeKeys({ attributeKeys: ["", " "] })).toEqual([]);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
const SOURCE_ROOT = resolve(new URL("../..", import.meta.url).pathname, "src/i18n/source");
const LOCALES = ["da", "en", "sv", "de", "no"];
const REQUIRED_KEYS = ["red_car_warning_title", "red_car_warning_message", "red_car_warning_suggestion"];
const readSelfWashSource = (locale) => {
const filePath = join(SOURCE_ROOT, locale, "phrases/compat/self_wash/index.json");
return JSON.parse(readFileSync(filePath, "utf8"));
};
const getNestedValue = (root, path) => {
let current = root;
for (const segment of path) {
if (current == null || typeof current !== "object") {
return undefined;
}
current = current[segment];
}
return current;
};
describe("red-car warning i18n keys", () => {
for (const locale of LOCALES) {
it(`exposes the required keys in locale '${locale}'`, () => {
const messages = readSelfWashSource(locale);
const selfWash = messages?.compat?.self_wash ?? {};
for (const key of REQUIRED_KEYS) {
const value = selfWash[key];
expect(value, `missing key self_wash.${key} in ${locale}`).toBeDefined();
expect(typeof value === "string" && value.trim() !== "", `empty value for self_wash.${key} in ${locale}`).toBe(
true
);
}
});
}
});
+74
View File
@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import RedCarWarning from "@/components/displays/selfServe/RedCarWarning.vue";
import { createTestI18n } from "./helpers/mountWithApp.js";
const BMessageStub = {
name: "BMessage",
props: {
type: { type: String, default: "" },
title: { type: String, default: "" },
},
emits: ["close"],
template: `
<div
class="b-message-stub"
:data-type="type"
:data-title="title"
data-testid="red-car-warning"
>
<slot />
</div>
`,
};
const factory = (props = {}, messages = {}) => {
const i18n = createTestI18n({
en: {
self_wash: {
red_car_warning_title: "Red car on site",
red_car_warning_message: "Handle with extra care.",
red_car_warning_suggestion: "Use a gentler wash.",
...messages.en?.self_wash,
},
},
});
return mount(RedCarWarning, {
props,
global: {
plugins: [i18n],
stubs: {
BMessage: BMessageStub,
},
},
});
};
describe("RedCarWarning", () => {
it("renders the warning when isRedCar is true", () => {
const wrapper = factory({ isRedCar: true });
expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="red-car-warning-message"]').text()).toBe("Handle with extra care.");
expect(wrapper.find('[data-testid="red-car-warning-suggestion"]').text()).toBe("Use a gentler wash.");
});
it("does not render the warning when isRedCar is false", () => {
const wrapper = factory({ isRedCar: false });
expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(false);
});
it("does not render by default (no prop passed)", () => {
const wrapper = factory();
expect(wrapper.find('[data-testid="red-car-warning"]').exists()).toBe(false);
});
it("forwards the close event from the b-message as a dismiss emit", () => {
const wrapper = factory({ isRedCar: true });
const bMessage = wrapper.findComponent(BMessageStub);
bMessage.vm.$emit("close");
expect(wrapper.emitted()).toHaveProperty("dismiss");
});
});
@@ -2,8 +2,16 @@ import { describe, expect, it } from "vitest";
import {
getSelfServeCompletedDynamicImageStep,
getSelfServeTaskDynamicImagePresentation,
isSelfServeProgramNumberButton,
normalizeSelfServeProgramPickerTaskButtons,
parseSelfServeDynamicImageThumbPosition,
} from "@/services/selfServeDynamicImage.js";
import {
SELF_SERVE_TASK_BUTTON_OPTIONS,
SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER,
SELF_SERVE_TASK_BUTTON_RESET,
SELF_SERVE_TASK_BUTTON_START,
} from "@/components/session/token/SessionUser/Objects/selfServeTaskButtons.js";
describe("selfServeDynamicImage", () => {
it("uses a selected program picker button number as thumb position", () => {
@@ -70,4 +78,94 @@ describe("selfServeDynamicImage", () => {
"start",
]);
});
// Smoke check that the program number range is stable across the codebase.
// The wash bay exposes 12 programs (buttons 0-11, thumb positions 1-12).
// If this assertion starts failing, the contract with the API has changed
// and any program mapping issue (e.g. a program that is unresponsive on
// the bay) is likely the API repository's concern, not the frontend.
it("accepts every configured program number between 0 and 11", () => {
for (let programNumber = 0; programNumber <= 11; programNumber += 1) {
expect(isSelfServeProgramNumberButton(programNumber)).toBe(true);
}
expect(isSelfServeProgramNumberButton(-1)).toBe(false);
expect(isSelfServeProgramNumberButton(12)).toBe(false);
expect(isSelfServeProgramNumberButton(100)).toBe(false);
});
it("maps 1-indexed thumb positions 1..12 to the documented program range", () => {
for (let thumbPosition = 1; thumbPosition <= 12; thumbPosition += 1) {
expect(parseSelfServeDynamicImageThumbPosition(thumbPosition)).toBe(thumbPosition);
}
expect(parseSelfServeDynamicImageThumbPosition(0)).toBeNull();
expect(parseSelfServeDynamicImageThumbPosition(13)).toBeNull();
expect(parseSelfServeDynamicImageThumbPosition("not-a-number")).toBeNull();
expect(parseSelfServeDynamicImageThumbPosition(null)).toBeNull();
expect(parseSelfServeDynamicImageThumbPosition(undefined)).toBeNull();
expect(parseSelfServeDynamicImageThumbPosition("")).toBeNull();
});
// Smoke check that the program button registry still covers every configured
// program. The wash bay exposes 12 programs and the path editor / simulator
// UI renders one entry per program number from this array. If the array is
// ever shortened, lengthened, or has gaps/duplicates, the program list shown
// to operators will drift from the API mapping and operators will not be
// able to reach every configured program — which is exactly the failure mode
// described in TRU-19 ("FF Uvs" and "10min" unresponsive).
it("exposes 12 unique, sequential program entries in the button registry", () => {
const numericProgramEntries = SELF_SERVE_TASK_BUTTON_OPTIONS.filter(
(entry) => Number.isInteger(entry?.id) && entry.id >= 0 && entry.id <= 11
);
expect(numericProgramEntries).toHaveLength(12);
expect(numericProgramEntries.map((entry) => entry.id)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
expect(new Set(numericProgramEntries.map((entry) => entry.id)).size).toBe(12);
expect(new Set(numericProgramEntries.map((entry) => entry.name)).size).toBe(12);
// The three "special" buttons (reset / program picker / start) must still
// be present so the path editor can render the full UI.
const specialIds = new Set(SELF_SERVE_TASK_BUTTON_OPTIONS.map((entry) => entry?.id));
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_RESET)).toBe(true);
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER)).toBe(true);
expect(specialIds.has(SELF_SERVE_TASK_BUTTON_START)).toBe(true);
});
// Snapshot lock for the full button-registry shape (TRU-19).
//
// The wash bay exposes 12 programs and 3 special buttons (reset / program
// picker / start). The dashboard renders one entry per program number
// from SELF_SERVE_TASK_BUTTON_OPTIONS, so the entire array must remain
// exactly 15 entries long: 1 reset + 1 program picker + 12 numeric
// programs (ids 0-11) + 1 start.
//
// If anyone adds, removes, or reorders entries without thinking about
// the operator UI and the api mapping, this snapshot will fail and
// force a deliberate update. The user-facing program names ("FF Uvs",
// "10min", "SF", etc.) live on the wash bay hardware and are NOT in
// this array — see TRU-19 and api SelfserveProgramRegistryContractTest.
it("locks the full SELF_SERVE_TASK_BUTTON_OPTIONS shape as a snapshot (15 entries, fixed order)", () => {
// Exact total length: 1 reset + 1 program picker + 12 programs + 1 start = 15
expect(SELF_SERVE_TASK_BUTTON_OPTIONS).toHaveLength(15);
// Full structural snapshot. The {id, name, description} triples must
// remain exactly as below. If you change one, change them all here
// deliberately.
expect(SELF_SERVE_TASK_BUTTON_OPTIONS.map((entry) => [entry.id, entry.name, entry.description])).toEqual([
[SELF_SERVE_TASK_BUTTON_RESET, "Reset", "Reset button"],
[SELF_SERVE_TASK_BUTTON_PROGRAM_PICKER, "Program picker", "Program picker wheel"],
[0, "Program 1", "Machine button 0"],
[1, "Program 2", "Machine button 1"],
[2, "Program 3", "Machine button 2"],
[3, "Program 4", "Machine button 3"],
[4, "Program 5", "Machine button 4"],
[5, "Program 6", "Machine button 5"],
[6, "Program 7", "Machine button 6"],
[7, "Program 8", "Machine button 7"],
[8, "Program 9", "Machine button 8"],
[9, "Program 10", "Machine button 9"],
[10, "Program 11", "Machine button 10"],
[11, "Program 12", "Machine button 11"],
[SELF_SERVE_TASK_BUTTON_START, "Start", "Start button"],
]);
});
});
+87
View File
@@ -0,0 +1,87 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const SCRIPT_PATH = path.resolve("scripts/smoke-test-sergii.sh");
const WORKFLOW_PATH = path.resolve(".github/workflows/deploy.yml");
const EXISTING_SMOKE_PATH = path.resolve("scripts/smoke-test.sh");
describe("Sergii Review Batch smoke test (TRU-96)", () => {
it("ships a Sergii-specific smoke test script with executable bits set", () => {
expect(fs.existsSync(SCRIPT_PATH)).toBe(true);
const stat = fs.statSync(SCRIPT_PATH);
// owner-execute bit (0o100) is what `chmod +x` sets; we don't care about
// the user the test runs as, just that the file is marked executable.
expect(stat.mode & 0o111).not.toBe(0);
});
it("covers every Sergii Review Batch project issue (TRU-68, TRU-72, TRU-75)", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
// TRU-68: customer email flow
expect(contents).toContain("TRU-68");
expect(contents).toMatch(/customer email flow/i);
// TRU-72: pages 6-10 review
expect(contents).toContain("TRU-72");
expect(contents).toMatch(/pages 6-10/);
// TRU-75: UVS option
expect(contents).toContain("TRU-75");
expect(contents).toMatch(/UVS/);
});
it("probes the Sergii-touch surfaces that the Sergii batch changed (pages 6-10)", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
// Pages 6-10 — Sergii's batch touched customer, product, order, booking, invoicing.
expect(contents).toMatch(/\/admin\/customer/);
expect(contents).toMatch(/\/admin\/product/);
expect(contents).toMatch(/\/admin\/order/);
expect(contents).toMatch(/\/admin\/booking/);
expect(contents).toMatch(/\/admin\/invoicing/);
});
it("probes the Sergii-touch customer email flow surfaces (TRU-68)", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
expect(contents).toMatch(/\/api\/customer/);
expect(contents).toMatch(/\/kundeoprettelse/);
});
it("probes the Sergii-touch UVS self-serve surfaces (TRU-75)", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
expect(contents).toMatch(/\/self-serve\/program/);
expect(contents).toMatch(/\/self-serve\/vehicle/);
});
it("is wired into the pleno-vue deploy workflow alongside the generic smoke test", () => {
const workflow = fs.readFileSync(WORKFLOW_PATH, "utf8");
expect(workflow).toMatch(/smoke-test-sergii\.sh/);
expect(workflow).toMatch(/Sergii Review Batch smoke test \(TRU-96\)/);
// Generic smoke test still runs first.
expect(workflow).toMatch(/smoke-test\.sh "\$SMOKE_BASE_URL"/);
// Auto-rollback now also triggers on Sergii smoke failure.
expect(workflow).toMatch(/steps\.smoke\.outcome == 'failure' \|\| steps\.smoke_sergii\.outcome == 'failure'/);
// Slack status message distinguishes the two failure modes.
expect(workflow).toMatch(/Generic smoke FAILED/);
expect(workflow).toMatch(/Sergii Review Batch smoke FAILED/);
});
it("does not duplicate the generic smoke test (it complements, not replaces)", () => {
expect(fs.existsSync(EXISTING_SMOKE_PATH)).toBe(true);
const generic = fs.readFileSync(EXISTING_SMOKE_PATH, "utf8");
const sergii = fs.readFileSync(SCRIPT_PATH, "utf8");
// Generic covers login + /api/ping + /healthz; Sergii covers the Sergii
// Review Batch surfaces. They are intentionally different sets.
expect(generic).toMatch(/\/login/);
expect(sergii).toMatch(/\/admin\/customer/);
});
it("exits non-zero when any Sergii-touch surface fails, so auto-rollback can fire", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
expect(contents).toMatch(/FAIL=1/);
expect(contents).toMatch(/exit 1/);
expect(contents).toMatch(/Sergii Review Batch smoke test FAILED/);
});
it("defaults to https://staging.truckwash.io so it is safe to run unattended", () => {
const contents = fs.readFileSync(SCRIPT_PATH, "utf8");
expect(contents).toMatch(/SMOKE_BASE_URL:-\$\{1:-https:\/\/staging\.truckwash\.io\}/);
});
});
+41 -2
View File
@@ -577,8 +577,11 @@ describe("Periode tab contract", () => {
});
it("uses the selected period while keeping Selvvask results server-paginated", () => {
expect(periodViewSelfWashSource).toContain(':initial-date-from="dates.computed.formattedStartDate.value"');
expect(periodViewSelfWashSource).toContain(':initial-date-to="dates.computed.formattedEndDate.value"');
expect(periodViewSelfWashSource).toContain(':initial-date-from="initialDateFrom"');
expect(periodViewSelfWashSource).toContain(':initial-date-to="initialDateTo"');
expect(periodViewSelfWashSource).toContain("dates.computed.formattedStartDate.value");
expect(periodViewSelfWashSource).toContain("dates.computed.formattedEndDate.value");
expect(periodViewSelfWashSource).toContain("route.query.xlvaskUsageLogStartTime");
expect(periodViewSelfWashSource).toContain(':inherit-period-filters="true"');
expect(periodViewSelfWashSource).toContain(':load-all-at-once="false"');
expect(xlvaskUsagePaginationSource).toContain("inheritPeriodFilters");
@@ -597,6 +600,21 @@ describe("Periode tab contract", () => {
expect(xlvaskUsagePaginationSource).toContain("buildUsagePaginationParams()");
});
it("widens the date window around a flagged wash start time to ±7 days (TRU-10)", () => {
// The original spec called for a [start_time - 7d, start_time + 7d] window
// so the highlighted wash is always visible even when it spans midnight
// or falls on the edge of the calendar day.
expect(periodViewSelfWashSource).toContain("FLAGGED_WASH_DATE_WINDOW_DAYS");
expect(periodViewSelfWashSource).toContain("FLAGGED_WASH_DATE_WINDOW_DAYS = 7");
expect(periodViewSelfWashSource).toContain("const shiftDateOnly");
expect(periodViewSelfWashSource).toContain(
"shiftDateOnly(flaggedWashStartDate.value, -FLAGGED_WASH_DATE_WINDOW_DAYS)"
);
expect(periodViewSelfWashSource).toContain(
"shiftDateOnly(flaggedWashStartDate.value, FLAGGED_WASH_DATE_WINDOW_DAYS)"
);
});
it("loads Selvvask selector counts and progress from the selected period", () => {
expect(periodRightSource).toContain("normalizeXlvaskAutopilotSummary");
expect(periodRightSource).toContain("const selfWashCounts = ref(emptySelectorCounts())");
@@ -684,6 +702,27 @@ describe("Periode tab contract", () => {
expect(xlvaskUsageOrdersTableSource).not.toContain("'preview'");
});
it("renders a friendly notice when the orders endpoint returns 404", () => {
// The pagination must distinguish a 404 ("API endpoint not implemented
// yet") from other failures and surface a friendly notice instead of
// the generic error popper every other paginated surface uses.
expect(xlvaskUsagePaginationSource).toContain("apiEndpointNotImplemented");
expect(xlvaskUsagePaginationSource).toContain("lastError");
expect(xlvaskUsagePaginationSource).toContain("404");
expect(xlvaskUsagePaginationSource).toContain("xlvask_review.errors.api_endpoint_not_implemented");
expect(xlvaskUsagePaginationSource).toContain("xlvask-api-not-implemented");
expect(xlvaskUsagePaginationSource).toContain('removeError("paginatedGetRequest")');
});
it("ships the API-not-implemented notice in every supported locale", () => {
// The friendly notice must be present in all five source locales so
// the operator reads a real translation rather than a raw key.
for (const locale of localeMessages) {
const notice = locale.messages?.invoicing_period?.xlvask_review?.errors?.api_endpoint_not_implemented;
expect(notice, `locale ${locale.locale} missing api_endpoint_not_implemented`).toBeTruthy();
}
});
it("keeps period subview navigation on valid keys", () => {
expect(periodViewInvoicePerOrderSource).toContain("view.functions.setCurrentView('vehicle_subscriptions')");
expect(periodViewSpecialArrangementsSource).toContain("view.functions.setCurrentView('vehicle_subscriptions')");
@@ -0,0 +1,61 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const readSource = (relativePath) => readFileSync(join(root, relativePath), "utf8");
describe("xlvask usage pagination department selector propagation", () => {
it("declares a departmentId prop on XLVaskUsagePagination", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
expect(source).toMatch(/departmentId:\s*\{\s*type:\s*Number,\s*default:\s*0\s*\}/);
});
it("applies the HallId filter when the departmentId prop is provided", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
// `effectiveDepartmentId` is now a computed (so the value is `.value`)
// and is wired into a watcher that re-applies the HallId filter
// whenever the department changes. The initial setup also seeds
// the filter from the computed.
expect(source).toMatch(/effectiveDepartmentId\.value\s*>\s*0/);
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*effectiveDepartmentId\.value\s*,\s*false\s*\)/);
expect(source).toMatch(/watch\(\s*effectiveDepartmentId\s*,/);
});
it("falls back to the departmentId route param when the prop is not provided", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
expect(source).toMatch(/router\.currentRoute\.value\.params\.departmentId/);
expect(source).toMatch(/Number\.parseInt\(\s*String\(router\.currentRoute\.value\.params\.departmentId/);
});
it("does not apply the HallId filter when no departmentId is provided", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
// `effectiveDepartmentId` is a computed (not a plain const) so the
// value is `.value`, and it is read through `routeDepartmentId.value`
// for the route-param fallback.
expect(source).toMatch(
/const\s+effectiveDepartmentId\s*=\s*computed\(\s*\(\)\s*=>\s*[\s\S]*?props\.departmentId\s*>\s*0[\s\S]*?routeDepartmentId\.value\s*>\s*0[\s\S]*?:\s*0\s*\)/
);
expect(source).toMatch(/if\s*\(effectiveDepartmentId\.value\s*>\s*0\)\s*\{\s*setFilter\(\s*["']HallId["']/);
});
it("DepartmentPosSync forwards the URL departmentId to XLVaskUsagePagination", () => {
const source = readSource("src/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosSync.vue");
expect(source).toMatch(
/<XLVaskUsagePagination[^>]*:department-id="SessionUser\.functions\.getDepartmentIdFromUrl\(\)"/
);
});
it("InvoicingBillingPeriodViewSelfWash does not pass a departmentId", () => {
const source = readSource(
"src/views/dashboards/superUserDashboard/InvoicingBillingPeriod/views/InvoicingBillingPeriodViewSelfWash.vue"
);
expect(source).not.toMatch(/department-id[\s=]/);
});
});
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import axios from "axios";
import { usePaginatedList } from "@/components/pagination/paginatedList.vue";
vi.mock("axios", () => ({
default: {
get: vi.fn(),
},
}));
describe("paginatedList tracks the last error so consumers can detect 404", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
localStorage.setItem("token", "test-token");
});
afterEach(() => {
vi.useRealTimers();
});
it("exposes a reactive lastError ref and clears it on success", async () => {
axios.get.mockResolvedValueOnce({
data: { data: [{ id: 1 }], meta: { pagination: { page: 1, per_page: 100, total: 1 } } },
});
const list = usePaginatedList();
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
expect(list.lastError.value).toBeNull();
await list.paginatedGetRequest();
expect(list.lastError.value).toBeNull();
});
it("captures the HTTP status of a failed request for the current endpoint", async () => {
axios.get.mockRejectedValueOnce({
response: { status: 404, data: { data: { message: "Not Found" } } },
message: "Request failed with status code 404",
});
const list = usePaginatedList();
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
const result = await list.paginatedGetRequest();
expect(result).toBeNull();
expect(list.lastError.value).toEqual({
status: 404,
endpoint: "/modules/xlvask/services/usage/orders",
message: "Not Found",
});
});
it("resets lastError at the start of every request", async () => {
axios.get
.mockRejectedValueOnce({
response: { status: 500, data: { data: { message: "Server Error" } } },
message: "Request failed with status code 500",
})
.mockResolvedValueOnce({
data: { data: [], meta: { pagination: { page: 1, per_page: 100, total: 0 } } },
});
const list = usePaginatedList();
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
await list.paginatedGetRequest();
expect(list.lastError.value?.status).toBe(500);
await list.paginatedGetRequest();
expect(list.lastError.value).toBeNull();
});
it("does not set lastError when the request was cancelled", async () => {
axios.get.mockRejectedValueOnce({
name: "CanceledError",
code: "ERR_CANCELED",
message: "canceled",
});
const list = usePaginatedList();
list.setEndpoint("/modules/xlvask/services/usage/orders", false);
await list.paginatedGetRequest();
expect(list.lastError.value).toBeNull();
});
});
@@ -0,0 +1,51 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = (relativePath) => readFileSync(join(process.cwd(), relativePath), "utf8");
describe("XLVaskUsagePagination department (HallId) propagation", () => {
const source = readSource("src/components/displays/pagination/models/DepartmentPos/XLVaskUsagePagination.vue");
it("reacts to department changes via a computed effectiveDepartmentId", () => {
// The department must be derived reactively from the prop and the
// route param, not captured once at setup time.
expect(source).toMatch(
/const\s+effectiveDepartmentId\s*=\s*computed\(\s*\(\)\s*=>\s*[\s\S]*?props\.departmentId[\s\S]*?routeDepartmentId\.value[\s\S]*?\)\s*\)/
);
});
it("watches the effective department and re-applies the HallId filter", () => {
// The component must watch the computed effective department so
// changing the department selector re-issues the query with the
// new department in the filter.
expect(source).toMatch(/watch\(\s*effectiveDepartmentId\s*,/);
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*nextDepartmentId\s*,\s*false\s*\)/);
});
it("re-issues the usage query when the department changes", () => {
// The watch handler must trigger both loadList and loadSummary so
// the visible orders and the summary cards both reflect the new
// department.
const watchBlockMatch = source.match(/watch\(\s*effectiveDepartmentId\s*,[\s\S]*?\n\)\s*;/);
expect(watchBlockMatch).not.toBeNull();
const watchBlock = watchBlockMatch?.[0] ?? "";
expect(watchBlock).toMatch(/loadList\s*\(\s*\)/);
expect(watchBlock).toMatch(/loadSummary\s*\(\s*\)/);
});
it("clears the HallId filter when the department is unset", () => {
// When the new department is 0 / unset, the filter must be cleared
// (setFilter with '*' removes the key) so the query is not bound
// to a stale department.
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*["']\*["']\s*,\s*false\s*\)/);
});
it("includes the active department in the summary query params", () => {
// The summary endpoint must also be re-issued with the new
// department; otherwise the summary cards show stale counts.
expect(source).toMatch(/HallId:\s*effectiveDepartmentId\.value/);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>AUT-1 primary-button hover preview</title>
<!--
Bulma 1.x stylesheet from the project's node_modules so the preview
matches the real app's default theme. The :root override is the same
--bulma-primary-h override that lives in src/assets/main.css so the
primary colour renders as the brand teal-blue (#0787BB ≈ HSL 197deg).
-->
<link rel="stylesheet" href="../../../node_modules/bulma/css/bulma.min.css" />
<style>
:root {
--bulma-primary-h: 197deg;
--bulma-primary-s: 92%;
--bulma-primary-l: 38%;
}
body {
background: #f5f5f5;
margin: 0;
padding: 48px 64px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.stage {
display: flex;
align-items: center;
gap: 48px;
background: white;
padding: 40px 56px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(10, 10, 10, 0.12);
width: fit-content;
}
.label {
font-size: 12px;
color: #7a7a7a;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 12px;
}
.button.is-primary {
min-width: 168px;
font-weight: 600;
}
</style>
</head>
<body>
<div id="stage-idle" class="stage" data-state="idle">
<div class="label">Primary button — default</div>
<button class="button is-primary" data-testid="primary-button-idle">
Confirm booking
</button>
</div>
<div style="height: 32px"></div>
<div id="stage-hover" class="stage" data-state="hover">
<div class="label">Primary button — hover</div>
<button class="button is-primary" data-testid="primary-button-hover">
Confirm booking
</button>
</div>
</body>
</html>