Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df7563c15d | ||
|
|
0b1df55728 | ||
|
|
398a0a146f | ||
|
|
dfaca1e8a5 | ||
|
|
a4a3b4adb1 | ||
|
|
b2f77b45e5 | ||
|
|
f0b3fc4675 | ||
|
|
1e7298245d | ||
|
|
0369664a96 | ||
|
|
e2cc76091f | ||
|
|
4db3be34f8 | ||
|
|
b1e0c61df0 | ||
|
|
eb8482585b | ||
|
|
c207fea61e | ||
|
|
01c5864382 | ||
|
|
c01596aeb5 | ||
|
|
5d4de1d932 |
@@ -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.
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,23 @@ if (props.hideSearch) {
|
||||
setHideSearchField(false);
|
||||
}
|
||||
|
||||
if (router.currentRoute.value.params.departmentId) {
|
||||
setOrder("StartTime", "desc");
|
||||
} else {
|
||||
setOrder("StartTime", "desc");
|
||||
const routeDepartmentId = Number.parseInt(
|
||||
String(router.currentRoute.value.params.departmentId ?? ""),
|
||||
10
|
||||
);
|
||||
const effectiveDepartmentId =
|
||||
props.departmentId > 0
|
||||
? props.departmentId
|
||||
: Number.isInteger(routeDepartmentId) && routeDepartmentId > 0
|
||||
? routeDepartmentId
|
||||
: 0;
|
||||
|
||||
if (effectiveDepartmentId > 0) {
|
||||
setFilter("HallId", effectiveDepartmentId, false);
|
||||
}
|
||||
|
||||
setOrder("StartTime", "desc");
|
||||
|
||||
const extractResponseData = (response) => response?.data?.data ?? response?.data ?? {};
|
||||
|
||||
const summary = ref({});
|
||||
@@ -253,6 +269,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 +396,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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4874,7 +4874,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4340,7 +4340,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": {
|
||||
@@ -4984,7 +4984,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4705,7 +4705,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3421,6 +3421,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": "XL Vask registration (no metadata available — see api flag-context serializer)...",
|
||||
"order_items": "@:{'templates.generated.compat.global_search.entity_types.order_items'}",
|
||||
"price": "@:common.price",
|
||||
"product": "@:common.product",
|
||||
@@ -4092,7 +4093,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'}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4343,7 +4343,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": {
|
||||
@@ -4987,7 +4987,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å"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4393,7 +4393,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": {
|
||||
@@ -5037,7 +5037,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
} } }
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
} } }
|
||||
}
|
||||
|
||||
@@ -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": "XL Vask registration (no metadata available — see api flag-context serializer)...",
|
||||
"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'}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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å"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,28 @@ import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
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]}`;
|
||||
};
|
||||
|
||||
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));
|
||||
|
||||
const initialDateFrom = computed(() => flaggedWashStartDate.value || dates.computed.formattedStartDate.value);
|
||||
const initialDateTo = computed(() => flaggedWashStartDate.value || dates.computed.formattedEndDate.value);
|
||||
|
||||
const selfWashTitle = computed(() => i18n.global.t("nav.self_wash"));
|
||||
</script>
|
||||
|
||||
@@ -18,8 +34,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"
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,55 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
@@ -684,6 +687,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,53 @@
|
||||
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");
|
||||
|
||||
expect(source).toMatch(/effectiveDepartmentId\s*>\s*0/);
|
||||
expect(source).toMatch(/setFilter\(\s*["']HallId["']\s*,\s*effectiveDepartmentId\s*,\s*false\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");
|
||||
|
||||
expect(source).toContain(
|
||||
"const effectiveDepartmentId =\n props.departmentId > 0\n ? props.departmentId\n : Number.isInteger(routeDepartmentId) && routeDepartmentId > 0\n ? routeDepartmentId\n : 0;"
|
||||
);
|
||||
expect(source).toMatch(/if\s*\(effectiveDepartmentId\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();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -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>
|
||||