Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c9eb8b644 | ||
|
|
e2cc76091f | ||
|
|
4db3be34f8 | ||
|
|
b1e0c61df0 | ||
|
|
eb8482585b | ||
|
|
c207fea61e | ||
|
|
01c5864382 | ||
|
|
c01596aeb5 | ||
|
|
5d4de1d932 | ||
|
|
768b6dcdab | ||
|
|
253d72f7fb | ||
|
|
a1fa132c99 | ||
|
|
113f49f018 | ||
|
|
666d467b46 | ||
|
|
9c74c4d477 | ||
|
|
0b7efc3be5 | ||
|
|
4cfd003864 | ||
|
|
58adb1bef5 | ||
|
|
e4bd3420c6 | ||
|
|
e08f1ecba8 | ||
|
|
187da74794 | ||
|
|
c9935d1e0a | ||
|
|
cc7d5cf4ff | ||
|
|
8d9f1e6fde | ||
|
|
d61d91b6ae | ||
|
|
ba92bc4cb6 | ||
|
|
c353bfac3a | ||
|
|
9024a5a1fa | ||
|
|
35e4bba859 | ||
|
|
50535dbed0 | ||
|
|
f5ccb2a2a9 |
@@ -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.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
+49
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"kind": "VisualEvidenceManifestV1",
|
||||
"taskId": "workboard-94209138-31f6-422e-ac8c-181ad391b8a7",
|
||||
"view": "POS extra sale audit",
|
||||
"files": [
|
||||
{
|
||||
"device": "mobile",
|
||||
"state": "before",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-before.png",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"device": "mobile",
|
||||
"state": "after",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-mobile-after.png",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
{
|
||||
"device": "tablet",
|
||||
"state": "before",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-before.png",
|
||||
"width": 768,
|
||||
"height": 1024
|
||||
},
|
||||
{
|
||||
"device": "tablet",
|
||||
"state": "after",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-tablet-after.png",
|
||||
"width": 768,
|
||||
"height": 1024
|
||||
},
|
||||
{
|
||||
"device": "desktop",
|
||||
"state": "before",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-before.png",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"device": "desktop",
|
||||
"state": "after",
|
||||
"path": "docs/pr-previews/workboard-94209138-31f6-422e-ac8c-181ad391b8a7/pos-extra-sale-audit-desktop-after.png",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"marketingVersion": "1.0.0",
|
||||
"marketingVersion": "1.0.1",
|
||||
"bundleId": "io.truckwash.app",
|
||||
"minimumIosVersion": "15.0"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -38,17 +38,23 @@ import {
|
||||
reg_2,
|
||||
reg_3,
|
||||
department_id,
|
||||
customer_id,
|
||||
customer_id,
|
||||
customer_attributes,
|
||||
customer_attributes_status,
|
||||
customer_name,
|
||||
getAddonRestriction,
|
||||
getProductRestriction,
|
||||
customer_name,
|
||||
getAddonRestriction,
|
||||
getProductRestriction,
|
||||
retryCustomerAttributes,
|
||||
registerPosStepSaveBarrier,
|
||||
saveOrderMetadataField,
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
registerPosStepSaveBarrier,
|
||||
saveOrderMetadataField,
|
||||
} from "@/components/shop/POSDepartmentProcess.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";
|
||||
import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileFixedBottomControl.vue";
|
||||
@@ -58,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();
|
||||
|
||||
@@ -345,14 +352,14 @@ const applyPendingBookingFromSelection = async () => {
|
||||
effectivePrimaryProduct = firstWash;
|
||||
}
|
||||
}
|
||||
effectivePrimaryProduct.addons = preparedAddons as any;
|
||||
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
|
||||
if (transactionItems.primaryItem.value) {
|
||||
transactionItems.primaryItem.value.addons = preparedAddons as any;
|
||||
}
|
||||
sanitizeRestrictedTransactionItems();
|
||||
effectivePrimaryProduct.addons = preparedAddons as any;
|
||||
transactionItems.setPrimaryItem(effectivePrimaryProduct as any);
|
||||
if (transactionItems.primaryItem.value) {
|
||||
transactionItems.primaryItem.value.addons = preparedAddons as any;
|
||||
}
|
||||
sanitizeRestrictedTransactionItems();
|
||||
|
||||
lastAppliedBookingId.value = booking.id;
|
||||
lastAppliedBookingId.value = booking.id;
|
||||
//console.warn('Applied pending booking to cart (primary + addons):', booking.id, primaryProduct, preparedAddons);
|
||||
//console.warn('Current transaction items after applying booking:', transactionItems.primaryItem.value);
|
||||
lastFetchedPrimaryItemProduct.value = effectivePrimaryProduct; // Update last fetched primary item
|
||||
@@ -885,83 +892,12 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const normalizeOrderItemShape = (item: any) => ({
|
||||
product_id: Number(item?.product_id ?? item?.product?.id ?? 0),
|
||||
quantity: Number(item?.quantity ?? 0),
|
||||
related_item_id:
|
||||
item?.related_item_id === null || item?.related_item_id === undefined ? null : Number(item.related_item_id),
|
||||
price: Number(item?.price ?? 0),
|
||||
notes: String(item?.notes ?? ""),
|
||||
});
|
||||
|
||||
const buildDesiredOrderItemShapes = () => {
|
||||
if (!transactionItems.primaryItem.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const primaryShape = {
|
||||
kind: "primary",
|
||||
relatedKey: "primary",
|
||||
product_id: Number(transactionItems.primaryItem.value.id),
|
||||
quantity: 1,
|
||||
related_item_id: null,
|
||||
price: Number(transactionItems.primaryItem.value.price ?? 0),
|
||||
notes: String(transactionItems.primaryItem.value?.notes ?? ""),
|
||||
skip_price_override: transactionItems.primaryItem.value?.skip_price_override === true,
|
||||
};
|
||||
|
||||
const addonShapes = (transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||
.map((addon: any) => {
|
||||
const addonProduct = addon?.product ?? addon;
|
||||
return {
|
||||
kind: "addon",
|
||||
relatedKey: "primary",
|
||||
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
|
||||
quantity: Number(addon?.quantity ?? 0),
|
||||
related_item_id: "__PRIMARY__",
|
||||
price: Number(addonProduct?.price ?? addon?.price ?? 0),
|
||||
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
|
||||
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
|
||||
};
|
||||
});
|
||||
|
||||
const additionalShapes = (transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||
.map((item: any) => ({
|
||||
kind: "additional",
|
||||
relatedKey: null,
|
||||
product_id: Number(item?.id ?? 0),
|
||||
quantity: Number(item?.quantity ?? 0),
|
||||
related_item_id: null,
|
||||
price: Number(item?.price ?? 0),
|
||||
notes: String(item?.notes ?? ""),
|
||||
skip_price_override: item?.skip_price_override === true,
|
||||
}));
|
||||
|
||||
return [primaryShape, ...addonShapes, ...additionalShapes];
|
||||
};
|
||||
|
||||
const normalizeExistingOrderItemShapes = (items: any[]) => {
|
||||
const primaryItems = items.filter(
|
||||
(item: any) => item?.related_item_id === null || item?.related_item_id === undefined
|
||||
);
|
||||
if (primaryItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const additionalItems = primaryItems.filter(
|
||||
(item: any) =>
|
||||
Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(transactionItems.primaryItem.value?.id ?? 0)
|
||||
);
|
||||
const primaryItemShape = normalizeOrderItemShape(primaryItems[0]);
|
||||
const addonShapes = items
|
||||
.filter((item: any) => item?.related_item_id === primaryItems[0]?.id)
|
||||
.map(normalizeOrderItemShape);
|
||||
const additionalShapes = additionalItems.map(normalizeOrderItemShape);
|
||||
|
||||
return [primaryItemShape, ...addonShapes, ...additionalShapes];
|
||||
};
|
||||
// The pre-existing helpers buildDesiredOrderItemShapes and
|
||||
// normalizeExistingOrderItemShapes (and their shared normalizeOrderItemShape
|
||||
// normalizer) used to live here. They have been extracted to
|
||||
// src/components/displays/department/pos/steps/mobile/utils/syncMobileOrderItems.js
|
||||
// so they can be unit-tested in isolation and so the same comparison logic
|
||||
// is used by syncCurrentTransactionToOrder and any future caller.
|
||||
|
||||
const sortComparableLastWashShapes = (shapes: Array<{ kind: string; product_id: number; quantity: number }>) =>
|
||||
shapes.slice().sort((left, right) => {
|
||||
@@ -988,8 +924,8 @@ const buildCurrentSelectionComparableShapes = () => {
|
||||
};
|
||||
|
||||
const addonShapes = sortComparableLastWashShapes(
|
||||
(transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||
(transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||
.map((addon: any) => ({
|
||||
kind: "addon",
|
||||
product_id: Number(addon?.product?.id ?? addon?.id ?? 0),
|
||||
@@ -998,8 +934,8 @@ const buildCurrentSelectionComparableShapes = () => {
|
||||
);
|
||||
|
||||
const additionalShapes = sortComparableLastWashShapes(
|
||||
(transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||
(transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||
.map((item: any) => ({
|
||||
kind: "additional",
|
||||
product_id: Number(item?.id ?? 0),
|
||||
@@ -1074,65 +1010,19 @@ const syncCurrentTransactionToOrder = async () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingItemsResponse = await getOrderItems(normalizedOrderId);
|
||||
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
|
||||
|
||||
const desiredShapes = buildDesiredOrderItemShapes();
|
||||
const currentShapes = normalizeExistingOrderItemShapes(existingItems);
|
||||
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
|
||||
const comparableDesiredShapes = desiredShapes.map(({
|
||||
kind: _kind,
|
||||
relatedKey: _relatedKey,
|
||||
skip_price_override: _skipPriceOverride,
|
||||
...shape
|
||||
}) => shape);
|
||||
|
||||
if (!shouldForceRecreateForRepricing && JSON.stringify(currentShapes) === JSON.stringify(comparableDesiredShapes)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await Promise.all(existingItems.map((item: any) => removeOrderItem(item.id)));
|
||||
|
||||
const createdPrimaryItemResponse = await createOrderItem(
|
||||
normalizedOrderId,
|
||||
transactionItems.primaryItem.value.id,
|
||||
1,
|
||||
null,
|
||||
transactionItems.primaryItem.value?.notes || "",
|
||||
transactionItems.primaryItem.value.skip_price_override === true ? null : transactionItems.primaryItem.value.price
|
||||
);
|
||||
const createdPrimaryItemId = createdPrimaryItemResponse?.data?.data?.id;
|
||||
|
||||
const addonPromises = (transactionItems.primaryItem.value.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||
.map((addon: any) => {
|
||||
const addonProduct = addon?.product ?? addon;
|
||||
return createOrderItem(
|
||||
normalizedOrderId,
|
||||
addonProduct.id,
|
||||
Number(addon.quantity),
|
||||
createdPrimaryItemId,
|
||||
addonProduct?.notes || addon?.notes || "",
|
||||
addonProduct?.skip_price_override === true || addon?.skip_price_override === true
|
||||
? null
|
||||
: addonProduct.price ?? addon.price
|
||||
);
|
||||
});
|
||||
|
||||
const additionalPromises = (transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||
.map((item: any) =>
|
||||
createOrderItem(
|
||||
normalizedOrderId,
|
||||
item.id,
|
||||
Number(item.quantity),
|
||||
null,
|
||||
item?.notes || "",
|
||||
item?.skip_price_override === true ? null : item.price
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all([...addonPromises, ...additionalPromises]);
|
||||
// The sync helper owns the create / rollback / allSettled logic and lives
|
||||
// in tests/unit/pos-mobile-step-2-addon-sync.spec.js. Returning its result
|
||||
// unchanged preserves the existing contract: true = synced (or already in
|
||||
// sync), false = primary product blocked, throw = partial failure.
|
||||
await syncMobileOrderItems({
|
||||
orderId: normalizedOrderId,
|
||||
primaryItem: transactionItems.primaryItem.value,
|
||||
additionalItems: transactionItems.additionalItems.value || [],
|
||||
isAddonRestricted: isMobileAddonRestricted,
|
||||
isStandaloneRestricted: isStandaloneAdditionalItemRestricted,
|
||||
isPrimaryRestricted: (item) => getProductRestriction(item).restricted,
|
||||
api: { createOrderItem, getOrderItems, removeOrderItem },
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1140,8 +1030,7 @@ const normalizeText = (value: unknown) => String(value ?? "").trim();
|
||||
|
||||
const isEnabledFlag = (value: unknown) => value === true || value === 1 || value === "1" || value === "true";
|
||||
|
||||
const getProductId = (product: any) =>
|
||||
Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
|
||||
const getProductId = (product: any) => Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0);
|
||||
|
||||
const getProductName = (product: any) => normalizeText(product?.product?.name ?? product?.name);
|
||||
|
||||
@@ -1152,13 +1041,13 @@ 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
|
||||
);
|
||||
};
|
||||
|
||||
const productHasOrderItemNote = (product: any) =>
|
||||
normalizeText(product?.notes ?? product?.product?.notes).length > 0;
|
||||
const productHasOrderItemNote = (product: any) => normalizeText(product?.notes ?? product?.product?.notes).length > 0;
|
||||
|
||||
const getSelectedProductsMissingRequiredNotes = () => {
|
||||
const missingProducts: any[] = [];
|
||||
@@ -1168,8 +1057,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
|
||||
missingProducts.push(primaryProduct);
|
||||
}
|
||||
|
||||
(primaryProduct?.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||
(primaryProduct?.addons || [])
|
||||
.filter((addon: any) => Number(addon?.quantity ?? 0) > 0 && !isMobileAddonRestricted(addon))
|
||||
.forEach((addon: any) => {
|
||||
const addonProduct = addon?.product ?? addon;
|
||||
if (
|
||||
@@ -1181,8 +1070,8 @@ const getSelectedProductsMissingRequiredNotes = () => {
|
||||
}
|
||||
});
|
||||
|
||||
(transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||
(transactionItems.additionalItems.value || [])
|
||||
.filter((item: any) => Number(item?.quantity ?? 0) > 0 && !isStandaloneAdditionalItemRestricted(item))
|
||||
.forEach((item: any) => {
|
||||
if (productRequiresOrderItemNote(item) && !productHasOrderItemNote(item)) {
|
||||
missingProducts.push(item);
|
||||
@@ -1538,15 +1427,15 @@ const filteredAddons = computed(() => {
|
||||
<i class="fa fa-search"></i>
|
||||
</span>
|
||||
</button>
|
||||
<!-- Registration numbers -->
|
||||
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
||||
<p
|
||||
v-if="restrictionWarningMessageKey"
|
||||
class="notification is-warning is-light py-2 px-3 mb-0"
|
||||
data-testid="pos-mobile-restriction-warning"
|
||||
>
|
||||
{{ t(restrictionWarningMessageKey) }}
|
||||
</p>
|
||||
<!-- Registration numbers -->
|
||||
<PosDepartmentStepMobile2RegistrationNumbers :classes="layout.classes" />
|
||||
<p
|
||||
v-if="restrictionWarningMessageKey"
|
||||
class="notification is-warning is-light py-2 px-3 mb-0"
|
||||
data-testid="pos-mobile-restriction-warning"
|
||||
>
|
||||
{{ t(restrictionWarningMessageKey) }}
|
||||
</p>
|
||||
<div
|
||||
v-if="customer_attributes_status === 'error'"
|
||||
class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between py-2 px-3 mb-0"
|
||||
@@ -1562,8 +1451,8 @@ const filteredAddons = computed(() => {
|
||||
{{ t("common.retry") }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Product -->
|
||||
<PosDepartmentStepMobile2Product
|
||||
<!-- Product -->
|
||||
<PosDepartmentStepMobile2Product
|
||||
v-on:pointerdown="onPrimaryProductPointerDown"
|
||||
v-on:pointermove="onPrimaryProductPointerMove"
|
||||
v-on:pointerup="onPrimaryProductPointerUp"
|
||||
|
||||
+7
@@ -469,6 +469,13 @@ const onClick = async () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Next-step action was interrupted:", error);
|
||||
// Surface the failure to the operator so silent API rejections don't look
|
||||
// like the button did nothing. Backend validation errors typically carry a
|
||||
// human-readable message on error.response.data.data.message.
|
||||
const parsedError =
|
||||
SessionUser.functions.parseErrorMessage(error) || error?.message || "Unknown error";
|
||||
errors.value.push(error);
|
||||
popups.select("error", { message: parsedError });
|
||||
} finally {
|
||||
isProcessingClick.value = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Pure, side-effect-free (apart from the injected API calls) helper that
|
||||
* reconciles the in-memory mobile POS transaction with the server-side
|
||||
* order_items table.
|
||||
*
|
||||
* Behavior contract:
|
||||
* - Every primary product add-on with quantity > 0 that is not
|
||||
* customer-rule-restricted becomes its own order_items row, linked to
|
||||
* the freshly-created primary row by related_item_id.
|
||||
* - Every additional (standalone) item with quantity > 0 that is not
|
||||
* customer-rule-restricted becomes its own order_items row.
|
||||
* - The old "Promise.all over parallel POSTs" fan-out silently dropped
|
||||
* rows on a single rejection: the rows that already landed stayed on
|
||||
* the server while the operator saw only a generic failure popup.
|
||||
* This helper delegates the add-on / additional-item fan-out to the
|
||||
* shared `addOrderItemAddons` helper, which uses Promise.allSettled,
|
||||
* collects every per-product failure, and rolls back every
|
||||
* order_items row created during this attempt before throwing, so a
|
||||
* retry starts from a clean state.
|
||||
*
|
||||
* The shared error class + helpers live in
|
||||
* `src/components/displays/department/pos/utils/orderItemsPartialSync.js`
|
||||
* so the desktop `addAddonsToOrderMiddleware` can throw the same shape.
|
||||
*/
|
||||
|
||||
import {
|
||||
OrderItemsPartialSyncError,
|
||||
extractErrorMessage,
|
||||
formatFailureFragment,
|
||||
} from "@/components/displays/department/pos/utils/orderItemsPartialSync.js";
|
||||
import { addOrderItemAddons } from "@/components/displays/department/pos/utils/addOrderItemAddons.js";
|
||||
|
||||
// Re-export for back-compat with existing tests / call sites.
|
||||
export { OrderItemsPartialSyncError, extractErrorMessage, formatFailureFragment };
|
||||
|
||||
const PLACEHOLDER_PRIMARY_RELATED_ITEM_ID = "__PRIMARY__";
|
||||
|
||||
const toInteger = (value) => {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const toPositiveInteger = (value) => {
|
||||
const parsed = toInteger(value);
|
||||
return parsed !== null && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const toNonNegativeNumber = (value) => {
|
||||
const parsed = Number(value ?? 0);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
||||
};
|
||||
|
||||
const safeGet = (object, ...keys) => {
|
||||
for (const key of keys) {
|
||||
const candidate = object?.[key];
|
||||
if (candidate !== undefined && candidate !== null && candidate !== "") {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a single order item from the server (returned by GET
|
||||
* /order/items) into the comparison shape used by the idempotency check.
|
||||
*
|
||||
* related_item_id is intentionally excluded from the comparison shape so
|
||||
* that "desired add-on with placeholder related_item_id" can be matched
|
||||
* against "existing add-on with real numeric related_item_id". The other
|
||||
* fields (product_id, quantity, price, notes) fully characterize the row.
|
||||
*/
|
||||
export const normalizeExistingOrderItemShape = (item) => ({
|
||||
product_id: Number(safeGet(item, "product_id", "product_id") ?? 0),
|
||||
quantity: toNonNegativeNumber(safeGet(item, "quantity", "quantity")),
|
||||
price: Number(safeGet(item, "price", "price") ?? 0),
|
||||
notes: String(safeGet(item, "notes", "notes") ?? ""),
|
||||
});
|
||||
|
||||
/**
|
||||
* Group raw server items into [primaryShape, ...addonShapes, ...additionalShapes]
|
||||
* so we can compare the server state with what we want the server to look like.
|
||||
*
|
||||
* - `primaryItems` is every item with related_item_id === null. The first one
|
||||
* is treated as the primary; subsequent ones are standalone "additional"
|
||||
* items unless their product id matches the in-memory primary product.
|
||||
* - `addonShapes` are items whose related_item_id equals the primary's id.
|
||||
*/
|
||||
export const normalizeExistingOrderItemShapes = (existingItems, primaryProductId) => {
|
||||
const items = Array.isArray(existingItems) ? existingItems : [];
|
||||
const primaryItems = items.filter((item) => item?.related_item_id === null || item?.related_item_id === undefined);
|
||||
if (primaryItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const primaryItem = primaryItems[0];
|
||||
const primaryItemId = Number(primaryItem?.id ?? 0);
|
||||
const primaryShape = normalizeExistingOrderItemShape(primaryItem);
|
||||
|
||||
const additionalItems = primaryItems
|
||||
.filter((item) => Number(item?.product?.id ?? item?.product_id ?? 0) !== Number(primaryProductId ?? 0))
|
||||
.map(normalizeExistingOrderItemShape);
|
||||
|
||||
const addonShapes =
|
||||
primaryItemId > 0
|
||||
? items
|
||||
.filter((item) => Number(item?.related_item_id ?? 0) === primaryItemId)
|
||||
.map(normalizeExistingOrderItemShape)
|
||||
: [];
|
||||
|
||||
return [primaryShape, ...addonShapes, ...additionalItems];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the desired shapes from the in-memory primaryItem + additionalItems.
|
||||
* Uses a stable placeholder ("__PRIMARY__") for the add-on related_item_id
|
||||
* because the real primary id is not known until the primary POST resolves.
|
||||
*/
|
||||
export const buildDesiredOrderItemShapes = ({
|
||||
primaryItem,
|
||||
additionalItems,
|
||||
isAddonRestricted = () => false,
|
||||
isStandaloneRestricted = () => false,
|
||||
}) => {
|
||||
if (!primaryItem) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const primaryShape = {
|
||||
kind: "primary",
|
||||
relatedKey: "primary",
|
||||
product_id: Number(primaryItem.id ?? 0),
|
||||
quantity: 1,
|
||||
related_item_id: null,
|
||||
price: Number(primaryItem.price ?? 0),
|
||||
notes: String(primaryItem.notes ?? ""),
|
||||
skip_price_override: primaryItem.skip_price_override === true,
|
||||
};
|
||||
|
||||
const addonShapes = (Array.isArray(primaryItem.addons) ? primaryItem.addons : [])
|
||||
.filter((addon) => Number(addon?.quantity ?? 0) > 0 && !isAddonRestricted(addon))
|
||||
.map((addon) => {
|
||||
const addonProduct = addon?.product ?? addon;
|
||||
return {
|
||||
kind: "addon",
|
||||
relatedKey: "primary",
|
||||
product_id: Number(addonProduct?.id ?? addon?.id ?? 0),
|
||||
quantity: Number(addon?.quantity ?? 0),
|
||||
related_item_id: PLACEHOLDER_PRIMARY_RELATED_ITEM_ID,
|
||||
price: Number(addonProduct?.price ?? addon?.price ?? 0),
|
||||
notes: String(addonProduct?.notes ?? addon?.notes ?? ""),
|
||||
skip_price_override: addonProduct?.skip_price_override === true || addon?.skip_price_override === true,
|
||||
};
|
||||
});
|
||||
|
||||
const additionalShapes = (Array.isArray(additionalItems) ? additionalItems : [])
|
||||
.filter((item) => Number(item?.quantity ?? 0) > 0 && !isStandaloneRestricted(item))
|
||||
.map((item) => ({
|
||||
kind: "additional",
|
||||
relatedKey: null,
|
||||
product_id: Number(item?.id ?? 0),
|
||||
quantity: Number(item?.quantity ?? 0),
|
||||
related_item_id: null,
|
||||
price: Number(item?.price ?? 0),
|
||||
notes: String(item?.notes ?? ""),
|
||||
skip_price_override: item?.skip_price_override === true,
|
||||
}));
|
||||
|
||||
return [primaryShape, ...addonShapes, ...additionalShapes];
|
||||
};
|
||||
|
||||
const stripKindMarkers = (shape) => {
|
||||
const {
|
||||
kind: _kind,
|
||||
relatedKey: _relatedKey,
|
||||
skip_price_override: _skip,
|
||||
// related_item_id is intentionally stripped: in the desired shapes it is
|
||||
// the placeholder "__PRIMARY__" for add-ons (the real primary id is not
|
||||
// known until the primary POST resolves), while in the existing shapes
|
||||
// it is the real numeric primary id. Without stripping, the comparison
|
||||
// would always fail and the helper would rebuild the order on every
|
||||
// Fuldfør click.
|
||||
related_item_id: _relatedItemId,
|
||||
...rest
|
||||
} = shape;
|
||||
return rest;
|
||||
};
|
||||
|
||||
const shallowEqual = (left, right) => {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return false;
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
for (const key of leftKeys) {
|
||||
if (left[key] !== right[key]) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const arraysEqual = (left, right) => {
|
||||
if (left === right) return true;
|
||||
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i += 1) {
|
||||
if (!shallowEqual(left[i], right[i])) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} OrderItemApi
|
||||
* @property {(orderId, productId, quantity, relatedItemId, notes, price) => Promise<{data:{data:{id:number}}}>} createOrderItem
|
||||
* @property {(orderId) => Promise<{data:{data:Array<object>}}>} getOrderItems
|
||||
* @property {(orderItemId) => Promise<unknown>} removeOrderItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* Reconcile the in-memory transaction against the server's order_items.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {number} args.orderId
|
||||
* @param {object|null} args.primaryItem
|
||||
* @param {Array<object>} [args.additionalItems]
|
||||
* @param {(addon:object) => boolean} [args.isAddonRestricted]
|
||||
* @param {(item:object) => boolean} [args.isStandaloneRestricted]
|
||||
* @param {(primary:object) => boolean} [args.isPrimaryRestricted]
|
||||
* @param {OrderItemApi} args.api
|
||||
* @returns {Promise<{createdIds: number[], createdPrimaryItemId: number|null}>}
|
||||
* @throws {OrderItemsPartialSyncError} when any add-on or additional POST fails
|
||||
* after the partial rollback has already run.
|
||||
*/
|
||||
export const syncMobileOrderItems = async ({
|
||||
orderId,
|
||||
primaryItem,
|
||||
additionalItems = [],
|
||||
isAddonRestricted = () => false,
|
||||
isStandaloneRestricted = () => false,
|
||||
isPrimaryRestricted = () => false,
|
||||
api,
|
||||
}) => {
|
||||
const normalizedOrderId = toPositiveInteger(orderId);
|
||||
if (!normalizedOrderId) {
|
||||
throw new Error("Order ID is required");
|
||||
}
|
||||
if (!primaryItem) {
|
||||
throw new Error("No primary item selected");
|
||||
}
|
||||
if (isPrimaryRestricted(primaryItem)) {
|
||||
return { createdIds: [], createdPrimaryItemId: null };
|
||||
}
|
||||
|
||||
const existingItemsResponse = await api.getOrderItems(normalizedOrderId);
|
||||
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
|
||||
|
||||
const desiredShapes = buildDesiredOrderItemShapes({
|
||||
primaryItem,
|
||||
additionalItems,
|
||||
isAddonRestricted,
|
||||
isStandaloneRestricted,
|
||||
});
|
||||
const currentShapes = normalizeExistingOrderItemShapes(existingItems, primaryItem?.id);
|
||||
const comparableDesiredShapes = desiredShapes.map(stripKindMarkers);
|
||||
const shouldForceRecreateForRepricing = desiredShapes.some((shape) => shape.skip_price_override === true);
|
||||
|
||||
if (!shouldForceRecreateForRepricing && arraysEqual(currentShapes, comparableDesiredShapes)) {
|
||||
return { createdIds: [], createdPrimaryItemId: null };
|
||||
}
|
||||
|
||||
// Tear down whatever the server currently has for this order, including
|
||||
// any half-synced rows left over from a previous failed attempt.
|
||||
await Promise.allSettled(existingItems.map((item) => api.removeOrderItem(Number(item.id))));
|
||||
|
||||
let primaryCreate;
|
||||
try {
|
||||
primaryCreate = await api.createOrderItem(
|
||||
normalizedOrderId,
|
||||
Number(primaryItem.id ?? 0),
|
||||
1,
|
||||
null,
|
||||
String(primaryItem.notes ?? ""),
|
||||
primaryItem.skip_price_override === true ? null : Number(primaryItem.price ?? 0)
|
||||
);
|
||||
} catch (reason) {
|
||||
// The primary row never landed, so there is nothing to roll back. Wrap
|
||||
// the rejection so the UI layer gets the same error shape for both
|
||||
// primary and add-on failures.
|
||||
throw new OrderItemsPartialSyncError(
|
||||
extractErrorMessage(reason),
|
||||
[{ productId: Number(primaryItem.id ?? 0), message: extractErrorMessage(reason) }],
|
||||
[]
|
||||
);
|
||||
}
|
||||
const createdPrimaryItemId = toPositiveInteger(primaryCreate?.data?.data?.id);
|
||||
|
||||
const addonCandidates = (Array.isArray(primaryItem.addons) ? primaryItem.addons : [])
|
||||
.filter((addon) => Number(addon?.quantity ?? 0) > 0 && !isAddonRestricted(addon))
|
||||
.map((addon) => ({ ...addon, priceOverride: true }));
|
||||
const additionalCandidates = (Array.isArray(additionalItems) ? additionalItems : [])
|
||||
.filter((item) => Number(item?.quantity ?? 0) > 0 && !isStandaloneRestricted(item))
|
||||
.map((item) => ({ ...item, priceOverride: true }));
|
||||
|
||||
try {
|
||||
const { createdIds: childIds } = await addOrderItemAddons({
|
||||
orderId: normalizedOrderId,
|
||||
primaryItemId: createdPrimaryItemId,
|
||||
addons: addonCandidates,
|
||||
additionalItems: additionalCandidates,
|
||||
api,
|
||||
});
|
||||
|
||||
return {
|
||||
createdIds: [createdPrimaryItemId, ...childIds].filter(Boolean),
|
||||
createdPrimaryItemId,
|
||||
};
|
||||
} catch (error) {
|
||||
// The shared helper has already rolled back every add-on / additional
|
||||
// row that landed during this attempt. Roll back the primary too so a
|
||||
// retry starts from a clean state, then re-throw with the same error
|
||||
// shape (`OrderItemsPartialSyncError`) the rest of the UI expects.
|
||||
// Preserve the helper's aggregated failures and grow the
|
||||
// rolledBackIds list to include the primary for callers / tests that
|
||||
// inspect the error.
|
||||
if (createdPrimaryItemId) {
|
||||
await Promise.allSettled([api.removeOrderItem(createdPrimaryItemId)]);
|
||||
}
|
||||
if (error && typeof error === "object" && error.name === "OrderItemsPartialSyncError") {
|
||||
const rolledBackIds = Array.isArray(error.rolledBackIds) ? error.rolledBackIds.slice() : [];
|
||||
if (createdPrimaryItemId && !rolledBackIds.includes(createdPrimaryItemId)) {
|
||||
rolledBackIds.push(createdPrimaryItemId);
|
||||
}
|
||||
throw new OrderItemsPartialSyncError(error.message, error.failures, rolledBackIds);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,18 @@
|
||||
export const XLVASK_IMPORT_STATES = ["new", "updated", "unchanged", "invalid"];
|
||||
export const XLVASK_RESOLUTION_STATES = [
|
||||
/**
|
||||
* Compatibility shim for the now-removed XL Vask autopilot UI helpers.
|
||||
*
|
||||
* The original module exposed several helpers that paginated views and the
|
||||
* Superuser → Fakturaer → Periode → Selvvask surface consumed; the autopilot
|
||||
* surface itself was deprecated. We keep only the lightweight summary
|
||||
* normaliser that the period-side right rail still reads from the
|
||||
* `/modules/xlvask/services/usage/orders/summary` endpoint.
|
||||
*
|
||||
* Any caller that previously imported removed helpers (e.g.
|
||||
* `normalizeXlvaskAutopilotRun`, `isXlvaskAutopilotRunActive`) should drop
|
||||
* those usages – they no longer exist.
|
||||
*/
|
||||
|
||||
const XLVASK_RESOLUTION_STATES = [
|
||||
"already_linked",
|
||||
"auto_linked",
|
||||
"auto_created",
|
||||
@@ -8,240 +21,29 @@ export const XLVASK_RESOLUTION_STATES = [
|
||||
"ignored",
|
||||
"failed",
|
||||
];
|
||||
export const XLVASK_CERTAINTY_STATES = ["certain", "uncertain", "none"];
|
||||
export const XLVASK_PLANNED_ACTIONS = [
|
||||
"attach_order",
|
||||
"create_order",
|
||||
"resolve_mapping",
|
||||
"recheck",
|
||||
"ignore",
|
||||
"none",
|
||||
];
|
||||
|
||||
export const emptyXlvaskAutopilotSummary = () => ({
|
||||
total: 0,
|
||||
new: 0,
|
||||
updated: 0,
|
||||
unchanged: 0,
|
||||
invalid: 0,
|
||||
already_linked: 0,
|
||||
auto_linked: 0,
|
||||
auto_created: 0,
|
||||
needs_review: 0,
|
||||
blocked: 0,
|
||||
ignored: 0,
|
||||
failed: 0,
|
||||
certain: 0,
|
||||
uncertain: 0,
|
||||
none: 0,
|
||||
});
|
||||
const XLVASK_IMPORT_STATES = ["new", "updated", "unchanged", "invalid"];
|
||||
const XLVASK_CERTAINTY_STATES = ["certain", "uncertain", "none"];
|
||||
|
||||
const nonNegativeInteger = (value) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
|
||||
};
|
||||
|
||||
export const normalizeXlvaskAutopilotSummary = (summary) => {
|
||||
export const emptyXlvaskAutopilotSummary = () => ({
|
||||
total: 0,
|
||||
...Object.fromEntries(
|
||||
[...XLVASK_IMPORT_STATES, ...XLVASK_RESOLUTION_STATES, ...XLVASK_CERTAINTY_STATES].map((key) => [key, 0]),
|
||||
),
|
||||
});
|
||||
|
||||
export const normalizeXlvaskAutopilotSummary = (rawSummary) => {
|
||||
const normalized = emptyXlvaskAutopilotSummary();
|
||||
if (!rawSummary || typeof rawSummary !== "object") {
|
||||
return normalized;
|
||||
}
|
||||
Object.keys(normalized).forEach((key) => {
|
||||
normalized[key] = nonNegativeInteger(summary?.[key]);
|
||||
normalized[key] = nonNegativeInteger(rawSummary[key]);
|
||||
});
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const normalizeXlvaskAutopilotRun = (run) => {
|
||||
if (!run || typeof run !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
id: run.id ?? null,
|
||||
status: String(run.status || "queued"),
|
||||
phase: String(run.phase || run.status || "queued"),
|
||||
processed: nonNegativeInteger(run.processed),
|
||||
total: nonNegativeInteger(run.total),
|
||||
summary: normalizeXlvaskAutopilotSummary(run.summary),
|
||||
};
|
||||
};
|
||||
|
||||
const stringList = (value) => Array.isArray(value)
|
||||
? value.map((entry) => String(entry || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const booleanCapability = (source, key) => source?.[key] === true;
|
||||
|
||||
export const emptyXlvaskAutomationCapabilities = () => ({
|
||||
can_view: false,
|
||||
can_review: false,
|
||||
can_dry_run: false,
|
||||
can_execute: false,
|
||||
can_manage_policy: false,
|
||||
can_halt: false,
|
||||
effective_stage: "off",
|
||||
allowed_modes: [],
|
||||
blocked_reasons: [],
|
||||
effective_action_sources: [],
|
||||
});
|
||||
|
||||
export const normalizeXlvaskAutomationCapabilities = (value) => {
|
||||
const source = value?.capabilities && typeof value.capabilities === "object"
|
||||
? value.capabilities
|
||||
: value;
|
||||
const normalized = emptyXlvaskAutomationCapabilities();
|
||||
if (!source || typeof source !== "object") return normalized;
|
||||
|
||||
Object.keys(normalized).forEach((key) => {
|
||||
if (key.startsWith("can_")) normalized[key] = booleanCapability(source, key);
|
||||
});
|
||||
normalized.effective_stage = String(source.effective_stage || source.policy_stage || "off").toLowerCase();
|
||||
normalized.allowed_modes = stringList(source.allowed_modes).map((mode) => mode.toLowerCase());
|
||||
normalized.blocked_reasons = stringList(source.blocked_reasons);
|
||||
normalized.effective_action_sources = stringList(source.effective_action_sources);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const emptyXlvaskAutomationReadiness = () => ({
|
||||
ready: false,
|
||||
effective_stage: "off",
|
||||
policy_version: "",
|
||||
model: "",
|
||||
worker_healthy: false,
|
||||
blocked_reasons: [],
|
||||
budgets: {
|
||||
attach_order: { remaining_global: 0, remaining_hall: 0 },
|
||||
create_order: { remaining_global: 0, remaining_hall: 0 },
|
||||
},
|
||||
review_progress: {
|
||||
attach_order: { reviewed: 0, target: 200 },
|
||||
create_order: { reviewed: 0, target: 50 },
|
||||
},
|
||||
eligible_counts: { attach_order: 0, create_order: 0, total: 0 },
|
||||
calibrations: {},
|
||||
});
|
||||
|
||||
const normalizeProgress = (value, defaultTarget) => ({
|
||||
reviewed: nonNegativeInteger(value?.reviewed ?? value?.completed),
|
||||
target: nonNegativeInteger(value?.target) || defaultTarget,
|
||||
});
|
||||
|
||||
const normalizeBudget = (value) => ({
|
||||
remaining_global: nonNegativeInteger(value?.remaining_global ?? value?.global_remaining),
|
||||
remaining_hall: nonNegativeInteger(value?.remaining_hall ?? value?.hall_remaining),
|
||||
});
|
||||
|
||||
export const normalizeXlvaskAutomationReadiness = (value) => {
|
||||
const source = value?.readiness && typeof value.readiness === "object" ? value.readiness : value;
|
||||
const normalized = emptyXlvaskAutomationReadiness();
|
||||
if (!source || typeof source !== "object") return normalized;
|
||||
const policy = source.policy && typeof source.policy === "object" ? source.policy : {};
|
||||
const worker = source.worker && typeof source.worker === "object"
|
||||
? source.worker
|
||||
: source.workers && typeof source.workers === "object"
|
||||
? source.workers
|
||||
: {};
|
||||
|
||||
normalized.ready = source.ready === true;
|
||||
normalized.effective_stage = String(
|
||||
source.effective_stage || policy.effective_stage || policy.stage || "off"
|
||||
).toLowerCase();
|
||||
normalized.policy_version = String(source.policy_version || policy.version || "");
|
||||
normalized.model = String(source.model || source.model_identity || policy.model || "");
|
||||
normalized.worker_healthy = source.worker_healthy === true || worker.healthy === true;
|
||||
normalized.blocked_reasons = stringList(source.blocked_reasons);
|
||||
normalized.budgets.attach_order = normalizeBudget(source.budgets?.attach_order);
|
||||
normalized.budgets.create_order = normalizeBudget(source.budgets?.create_order);
|
||||
normalized.review_progress.attach_order = normalizeProgress(source.review_progress?.attach_order, 200);
|
||||
normalized.review_progress.create_order = normalizeProgress(source.review_progress?.create_order, 50);
|
||||
normalized.eligible_counts.attach_order = nonNegativeInteger(source.eligible_counts?.attach_order);
|
||||
normalized.eligible_counts.create_order = nonNegativeInteger(source.eligible_counts?.create_order);
|
||||
normalized.eligible_counts.total = nonNegativeInteger(source.eligible_counts?.total)
|
||||
|| normalized.eligible_counts.attach_order + normalized.eligible_counts.create_order;
|
||||
normalized.calibrations = source.calibrations && typeof source.calibrations === "object"
|
||||
? source.calibrations
|
||||
: {};
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export const isXlvaskReviewEligible = (row) => {
|
||||
return row?.automation?.review_eligible === true;
|
||||
};
|
||||
|
||||
export const isXlvaskAutopilotRunActive = (run) => {
|
||||
return ["queued", "pending", "running", "processing", "retry_wait"]
|
||||
.includes(String(run?.status || "").toLowerCase());
|
||||
};
|
||||
|
||||
export const xlvaskAutopilotRunProgress = (run) => {
|
||||
const total = nonNegativeInteger(run?.total);
|
||||
const processed = Math.min(total, nonNegativeInteger(run?.processed));
|
||||
return total > 0 ? Math.round((processed / total) * 100) : 0;
|
||||
};
|
||||
|
||||
export const getXlvaskAutomation = (row) => row?.automation && typeof row.automation === "object"
|
||||
? row.automation
|
||||
: {};
|
||||
|
||||
export const getXlvaskImportState = (row) => {
|
||||
const value = String(row?.import_state || "").toLowerCase();
|
||||
return XLVASK_IMPORT_STATES.includes(value) ? value : "unchanged";
|
||||
};
|
||||
|
||||
export const getXlvaskResolutionState = (row) => {
|
||||
const value = String(row?.resolution_state || "").toLowerCase();
|
||||
if (XLVASK_RESOLUTION_STATES.includes(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const status = String(getXlvaskAutomation(row).status || "").toLowerCase();
|
||||
if (status === "failed") return "failed";
|
||||
if (status === "denied") return "needs_review";
|
||||
if (status === "auto_accepted") {
|
||||
return getXlvaskAutomation(row).action === "create_order" ? "auto_created" : "auto_linked";
|
||||
}
|
||||
if (status === "accepted" || row?.linked_order_id || row?.order_id) return "already_linked";
|
||||
return "needs_review";
|
||||
};
|
||||
|
||||
export const getXlvaskCertainty = (row) => {
|
||||
const value = String(row?.certainty || "").toLowerCase();
|
||||
return XLVASK_CERTAINTY_STATES.includes(value) ? value : "none";
|
||||
};
|
||||
|
||||
export const getXlvaskPlannedAction = (row) => {
|
||||
const value = String(row?.planned_action || getXlvaskAutomation(row).action || "none").toLowerCase();
|
||||
return XLVASK_PLANNED_ACTIONS.includes(value) ? value : "none";
|
||||
};
|
||||
|
||||
export const getXlvaskCalibratedProbability = (row) => {
|
||||
const automation = getXlvaskAutomation(row);
|
||||
const value = Number(automation.calibrated_probability ?? 0);
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return Math.min(1, value);
|
||||
};
|
||||
|
||||
export const getXlvaskAutomationList = (row, key) => {
|
||||
const value = getXlvaskAutomation(row)[key];
|
||||
return Array.isArray(value) ? value.filter(Boolean) : [];
|
||||
};
|
||||
|
||||
export const xlvaskStateTagClass = (state) => {
|
||||
if (["already_linked", "auto_linked", "auto_created", "certain"].includes(state)) return "is-success";
|
||||
if (["invalid", "blocked", "failed"].includes(state)) return "is-danger";
|
||||
if (["new", "updated", "needs_review", "uncertain"].includes(state)) return "is-warning";
|
||||
if (state === "ignored") return "is-light";
|
||||
return "is-info";
|
||||
};
|
||||
|
||||
export const xlvaskEvidenceText = (entry) => {
|
||||
if (typeof entry === "string") return entry;
|
||||
return String(entry?.label ?? entry?.message ?? entry?.reason ?? entry?.code ?? "");
|
||||
};
|
||||
|
||||
export const xlvaskCandidateOrderId = (candidate) => candidate?.order_id ?? candidate?.id ?? null;
|
||||
|
||||
export const xlvaskCandidateLabel = (candidate) => {
|
||||
const orderId = xlvaskCandidateOrderId(candidate);
|
||||
const label = candidate?.label || candidate?.reason || candidate?.customer_name || "";
|
||||
return [orderId ? `#${orderId}` : "", label].filter(Boolean).join(" · ");
|
||||
};
|
||||
|
||||
@@ -5,15 +5,6 @@ export const isUsageOrderAttachedToOrder = (object) => {
|
||||
return true;
|
||||
}
|
||||
|
||||
const automation = object?.automation;
|
||||
const automationStatus = automation?.status;
|
||||
if (
|
||||
["auto_accepted", "accepted"].includes(automationStatus) &&
|
||||
["attach_order", "create_order"].includes(automation?.action)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const duplicates = Array.isArray(object?.duplicates) ? object.duplicates : [];
|
||||
return duplicates.some((duplicate) => duplicate?.wash_id === object?.wash_id);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Pure, side-effect-free (apart from the injected API calls) helper that
|
||||
* fans out a batch of `createOrderItem` POSTs for primary-product add-ons
|
||||
* (and, optionally, additional standalone items) and rolls back every
|
||||
* successful row when any one of the POSTs rejects.
|
||||
*
|
||||
* The same fan-out + rollback pattern is shared between the desktop
|
||||
* (`SelectProductsFormPOS.vue → addAddonsToOrderMiddleware`) and the mobile
|
||||
* (`PosDepartmentStepMobile2.vue → syncMobileOrderItems`) reconciliation
|
||||
* paths. The desktop path passes a `primaryItemId` of the row it has just
|
||||
* created; the mobile helper uses the same helper for its add-on /
|
||||
* additional-item fan-out after the primary POST resolves.
|
||||
*
|
||||
* Why Promise.allSettled: a single rejection inside Promise.all would
|
||||
* short-circuit the rest of the batch while the rows that already landed
|
||||
* stayed on the server. On retry the operator saw only "some" of the
|
||||
* selected add-ons persisted and a generic failure popup. allSettled
|
||||
* collects every outcome, and the rollback restores a clean server state
|
||||
* for the next attempt.
|
||||
*/
|
||||
|
||||
import {
|
||||
OrderItemsPartialSyncError,
|
||||
extractErrorMessage,
|
||||
formatFailureFragment,
|
||||
} from "./orderItemsPartialSync.js";
|
||||
|
||||
const toPositiveInteger = (value) => {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
};
|
||||
|
||||
const toNonNegativeNumber = (value) => {
|
||||
const parsed = Number(value ?? 0);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the createOrderItem arguments for one add-on row. The caller passes
|
||||
* either an addon-shaped object (with `option_id` / `addon_id` / nested
|
||||
* `product`) or a plain object with `product_id` + `quantity`; this helper
|
||||
* normalizes both shapes.
|
||||
*
|
||||
* The `price` field is only included when the caller has explicitly set
|
||||
* `priceOverride: true` on the addon. The mobile path always sets the
|
||||
* override (it computes the customer-discounted price locally and wants
|
||||
* the server to record it); the desktop path does not (it lets the server
|
||||
* fall back to the product's default price). This matches the original
|
||||
* desktop behaviour pre-PR-289 fix, where the existing `createOrderItem`
|
||||
* call was made with five arguments and the server-side price was left
|
||||
* untouched.
|
||||
*
|
||||
* @param {object} addon
|
||||
* @param {number} primaryItemId The order_items.id of the freshly-created
|
||||
* primary row that the add-on should be linked to via related_item_id.
|
||||
*/
|
||||
export const buildAddonCreateOrderItemArgs = (addon, primaryItemId) => {
|
||||
const addonProduct = addon?.product ?? addon ?? {};
|
||||
const productId = Number(addonProduct?.id ?? addon?.option_id ?? addon?.addon_id ?? addon?.id ?? 0);
|
||||
const quantity = toNonNegativeNumber(addon?.quantity ?? addonProduct?.quantity);
|
||||
const relatedItemId = toPositiveInteger(primaryItemId);
|
||||
const notes = String(addonProduct?.notes ?? addon?.notes ?? "");
|
||||
const skipPriceOverride =
|
||||
addonProduct?.skip_price_override === true || addon?.skip_price_override === true;
|
||||
const shouldOverridePrice = addon?.priceOverride === true;
|
||||
const price = shouldOverridePrice
|
||||
? skipPriceOverride
|
||||
? null
|
||||
: Number(addonProduct?.price ?? addon?.price ?? 0)
|
||||
: null;
|
||||
|
||||
return {
|
||||
productId,
|
||||
quantity,
|
||||
relatedItemId,
|
||||
notes,
|
||||
skipPriceOverride,
|
||||
overridePrice: shouldOverridePrice,
|
||||
price,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @typedef {Object} AddOrderItemAddonsApi
|
||||
* @property {(orderId, productId, quantity, relatedItemId, notes, price) => Promise<{data:{data:{id:number}}}>} createOrderItem
|
||||
* @property {(orderItemId) => Promise<unknown>} removeOrderItem
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fan out `createOrderItem` for every add-on (and additional item) and
|
||||
* rollback any rows that landed if any POST rejects.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {number} args.orderId
|
||||
* @param {number} args.primaryItemId The order_items.id of the freshly-
|
||||
* created primary row. Every add-on POST links to it via
|
||||
* related_item_id.
|
||||
* @param {Array<object>} [args.addons] Primary-product add-ons to POST.
|
||||
* @param {Array<object>} [args.additionalItems] Standalone additional
|
||||
* items to POST (related_item_id is null).
|
||||
* @param {AddOrderItemAddonsApi} args.api
|
||||
* @returns {Promise<{createdIds: number[], createdAddonIds: number[], createdAdditionalIds: number[]}>}
|
||||
* @throws {OrderItemsPartialSyncError} when any add-on or additional POST
|
||||
* fails, after the partial rollback has already run.
|
||||
*/
|
||||
export const addOrderItemAddons = async ({ orderId, primaryItemId, addons = [], additionalItems = [], api }) => {
|
||||
const normalizedOrderId = toPositiveInteger(orderId);
|
||||
if (!normalizedOrderId) {
|
||||
throw new Error("Order ID is required");
|
||||
}
|
||||
const normalizedPrimaryItemId = toPositiveInteger(primaryItemId);
|
||||
if (!normalizedPrimaryItemId) {
|
||||
throw new Error("Primary order item ID is required");
|
||||
}
|
||||
if (!api || typeof api.createOrderItem !== "function" || typeof api.removeOrderItem !== "function") {
|
||||
throw new Error("createOrderItem and removeOrderItem are required");
|
||||
}
|
||||
|
||||
const addonCandidates = Array.isArray(addons) ? addons.filter((addon) => toNonNegativeNumber(addon?.quantity) > 0) : [];
|
||||
const additionalCandidates = Array.isArray(additionalItems)
|
||||
? additionalItems.filter((item) => toNonNegativeNumber(item?.quantity) > 0)
|
||||
: [];
|
||||
|
||||
const addonPromises = addonCandidates.map((addon) => {
|
||||
const args = buildAddonCreateOrderItemArgs(addon, normalizedPrimaryItemId);
|
||||
return Promise.resolve(
|
||||
api.createOrderItem(
|
||||
normalizedOrderId,
|
||||
args.productId,
|
||||
args.quantity,
|
||||
args.relatedItemId,
|
||||
args.notes,
|
||||
args.price
|
||||
)
|
||||
).then((response) => ({
|
||||
productId: args.productId,
|
||||
relatedItemId: args.relatedItemId,
|
||||
response,
|
||||
}));
|
||||
});
|
||||
|
||||
const additionalPromises = additionalCandidates.map((item) => {
|
||||
const productId = Number(item?.id ?? item?.product_id ?? 0);
|
||||
const quantity = toNonNegativeNumber(item?.quantity);
|
||||
const notes = String(item?.notes ?? "");
|
||||
const skipPriceOverride = item?.skip_price_override === true;
|
||||
const shouldOverridePrice = item?.priceOverride === true;
|
||||
const price = shouldOverridePrice ? (skipPriceOverride ? null : Number(item?.price ?? 0)) : null;
|
||||
return Promise.resolve(
|
||||
api.createOrderItem(normalizedOrderId, productId, quantity, null, notes, price)
|
||||
).then((response) => ({
|
||||
productId,
|
||||
relatedItemId: null,
|
||||
response,
|
||||
}));
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled([...addonPromises, ...additionalPromises]);
|
||||
|
||||
const createdAddonIds = [];
|
||||
const createdAdditionalIds = [];
|
||||
const failures = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const productId =
|
||||
index < addonPromises.length
|
||||
? Number(addonCandidates[index]?.product?.id ?? addonCandidates[index]?.id ?? 0)
|
||||
: Number(additionalCandidates[index - addonPromises.length]?.id ?? 0);
|
||||
if (result.status === "fulfilled") {
|
||||
const id = toPositiveInteger(result.value?.response?.data?.data?.id);
|
||||
if (id) {
|
||||
if (index < addonPromises.length) {
|
||||
createdAddonIds.push(id);
|
||||
} else {
|
||||
createdAdditionalIds.push(id);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
failures.push({
|
||||
productId,
|
||||
message: extractErrorMessage(result.reason),
|
||||
});
|
||||
});
|
||||
|
||||
if (failures.length === 0) {
|
||||
return {
|
||||
createdIds: [...createdAddonIds, ...createdAdditionalIds],
|
||||
createdAddonIds,
|
||||
createdAdditionalIds,
|
||||
};
|
||||
}
|
||||
|
||||
// Roll back every row we created in this attempt so the next attempt
|
||||
// starts from a clean server state. Use allSettled so a rollback
|
||||
// rejection doesn't mask the original failure.
|
||||
const createdIdsToRollBack = [...createdAddonIds, ...createdAdditionalIds];
|
||||
await Promise.allSettled(createdIdsToRollBack.map((id) => Promise.resolve(api.removeOrderItem(id))));
|
||||
|
||||
const aggregated = failures.map(formatFailureFragment).join("; ");
|
||||
throw new OrderItemsPartialSyncError(aggregated, failures, createdIdsToRollBack);
|
||||
};
|
||||
|
||||
export { OrderItemsPartialSyncError };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Shared error class + helpers for the POS order_items partial-sync pattern.
|
||||
*
|
||||
* Both the desktop (SelectProductsFormPOS.vue → addAddonsToOrderMiddleware)
|
||||
* and the mobile (PosDepartmentStepMobile2.vue → syncMobileOrderItems)
|
||||
* reconciliation paths use the same fan-out strategy:
|
||||
*
|
||||
* 1. Fan out every pending createOrderItem POST via Promise.allSettled so
|
||||
* no single rejection short-circuits the batch.
|
||||
* 2. If any POST rejects, roll back the rows that did land via
|
||||
* Promise.allSettled(removeOrderItem) so a retry starts from a clean
|
||||
* server state instead of the half-synced snapshot that caused the
|
||||
* "only some of the selected primary product add-ons were persisted"
|
||||
* bug originally filed for the mobile path.
|
||||
* 3. Throw OrderItemsPartialSyncError so the UI can surface an aggregated
|
||||
* error message that names every failed product.
|
||||
*
|
||||
* Keeping this contract in one place lets both call sites produce the same
|
||||
* error shape and rollback guarantees.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Map an axios-like rejection to the human-readable string the operator
|
||||
* should see. Falls back to the error message itself.
|
||||
*/
|
||||
export const extractErrorMessage = (reason) => {
|
||||
const message =
|
||||
reason?.response?.data?.data?.message ?? reason?.response?.data?.message ?? reason?.message ?? "Unknown error";
|
||||
return String(message);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a per-product failure into a human-readable fragment. Used to
|
||||
* aggregate multiple add-on / additional-item rejections into one operator
|
||||
* message.
|
||||
*/
|
||||
export const formatFailureFragment = ({ productId, message }) => {
|
||||
if (!Number.isFinite(Number(productId)) || Number(productId) <= 0) {
|
||||
return message;
|
||||
}
|
||||
return `Product ${productId}: ${message}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thrown when one or more order_items POSTs fail during a fan-out. Carries
|
||||
* the aggregated message and the per-product failure list so the UI layer
|
||||
* can either display it directly or surface structured details.
|
||||
*/
|
||||
export class OrderItemsPartialSyncError extends Error {
|
||||
constructor(message, failures, rolledBackIds) {
|
||||
super(message);
|
||||
this.name = "OrderItemsPartialSyncError";
|
||||
this.failures = failures;
|
||||
this.rolledBackIds = rolledBackIds;
|
||||
}
|
||||
}
|
||||
+131
-746
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { parseError, getError } from "@/components/request/HandleGlobalError.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
onSave: Function, // Function to call when the secret key is changed
|
||||
@@ -11,10 +14,25 @@ const props = defineProps({
|
||||
icon: String, // The icon of the select
|
||||
color: String, // The color of the select
|
||||
isSet: Boolean, // If the secret key is set or not, this is used to show a warning if the secret key is not set.
|
||||
warningTitle: { // Optional override for the warning title (defaults to i18n key)
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
warningMessage: { // Optional override for the warning body (defaults to i18n key)
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
warningKey: { // Override the i18n key used for the default warning body
|
||||
type: String,
|
||||
default: 'configuration.secret_key_not_set',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['saved']);
|
||||
|
||||
const v_model = ref(props.value);
|
||||
const isEditting = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const errorId = ref(null);
|
||||
|
||||
@@ -24,6 +42,32 @@ const parseErrorOnSave = (error) => {
|
||||
parseError(error, unique_id);
|
||||
}
|
||||
|
||||
const onClickSave = async () => {
|
||||
if (!props.onSave) {
|
||||
isEditting.value = false;
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
errorId.value = null;
|
||||
try {
|
||||
const result = await props.onSave(v_model.value);
|
||||
// Treat any resolved value (including undefined) as success — the only signal
|
||||
// we have for failure is the catch path. The previous implementation did the
|
||||
// same thing but also clobbered `isEditting = false` synchronously, which
|
||||
// could mask in-flight failures when the parent awaited silently.
|
||||
emit('saved', { value: v_model.value, result });
|
||||
isEditting.value = false;
|
||||
} catch (error) {
|
||||
parseErrorOnSave(error);
|
||||
// Stay in edit mode so the user can correct the input.
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedWarningTitle = () => props.warningTitle ?? t('common.warning', 'Warning');
|
||||
const resolvedWarningMessage = () => props.warningMessage ?? t(props.warningKey, 'The secret key is not set, please set it before enabling the integration.');
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -33,8 +77,8 @@ const parseErrorOnSave = (error) => {
|
||||
<!-- If the secret key is not set, show a warning -->
|
||||
<div v-if="!props.isSet" class="message is-warning">
|
||||
<div class="message-body">
|
||||
<strong>Warning:</strong>
|
||||
The secret key is not set, please set it before enabling the integration.
|
||||
<strong>{{ resolvedWarningTitle() }}:</strong>
|
||||
{{ resolvedWarningMessage() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
@@ -52,11 +96,16 @@ const parseErrorOnSave = (error) => {
|
||||
class="input"
|
||||
type="text"
|
||||
v-model="v_model"
|
||||
:disabled="disabled || !isEditting"
|
||||
:disabled="disabled || !isEditting || saving"
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<a class="button is-info" @click="onSave(v_model).catch(parseErrorOnSave); isEditting = false" v-if="isEditting">
|
||||
<a
|
||||
class="button is-info"
|
||||
@click="onClickSave"
|
||||
v-if="isEditting"
|
||||
:class="{ 'is-loading': saving }"
|
||||
>
|
||||
<span class="icon">
|
||||
<i class="fas fa-save"></i>
|
||||
</span>
|
||||
|
||||
@@ -50,7 +50,16 @@ const getOrders = (object) => {
|
||||
let invoice_orders = invoice.objects;
|
||||
orders = orders.concat(invoice_orders);
|
||||
}
|
||||
return orders;
|
||||
return orders
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftId = Number.parseInt(String(left?.id ?? ""), 10);
|
||||
const rightId = Number.parseInt(String(right?.id ?? ""), 10);
|
||||
if (Number.isInteger(leftId) && Number.isInteger(rightId)) {
|
||||
return leftId - rightId;
|
||||
}
|
||||
return String(left?.id ?? "").localeCompare(String(right?.id ?? ""));
|
||||
});
|
||||
}
|
||||
|
||||
getDepartments();
|
||||
|
||||
@@ -47,9 +47,35 @@ const emit = defineEmits(["flagStatusChanged"]);
|
||||
|
||||
const orderItems = ref([]);
|
||||
|
||||
// Stable ordering for the order items table: primary items (related_item_id === 0
|
||||
// / null) first, then addons grouped by their parent, in insertion order. The
|
||||
// backend now also orders the SELECT (api PR), but we sort defensively here so a
|
||||
// stale cache or older API proxy cannot regress the render order (which previously
|
||||
// made it look like only Trailer/Dolly were attached to a Trækker order because
|
||||
// Spot-free and Undervognsskyld were listed above the primary and visually buried).
|
||||
const sortOrderItemsForDisplay = (items) => {
|
||||
if (!Array.isArray(items)) {
|
||||
return [];
|
||||
}
|
||||
return items.slice().sort((left, right) => {
|
||||
const leftRelatedId = Number(left?.related_item_id ?? 0);
|
||||
const rightRelatedId = Number(right?.related_item_id ?? 0);
|
||||
// Primary items (related_item_id 0 / null) come first.
|
||||
if ((leftRelatedId === 0) !== (rightRelatedId === 0)) {
|
||||
return leftRelatedId === 0 ? -1 : 1;
|
||||
}
|
||||
// Within addons, group by parent.
|
||||
if (leftRelatedId !== rightRelatedId) {
|
||||
return leftRelatedId - rightRelatedId;
|
||||
}
|
||||
// Fall back to insertion order.
|
||||
return Number(left?.id ?? 0) - Number(right?.id ?? 0);
|
||||
});
|
||||
};
|
||||
|
||||
const loadOrderItems = async () => {
|
||||
if (props.useLocalOrderItems) {
|
||||
orderItems.value = props.localOrderItems;
|
||||
orderItems.value = sortOrderItemsForDisplay(props.localOrderItems);
|
||||
return;
|
||||
}
|
||||
await SessionUser.request(
|
||||
@@ -59,7 +85,7 @@ const loadOrderItems = async () => {
|
||||
order_id: props.orderId,
|
||||
}
|
||||
).then((response) => {
|
||||
orderItems.value = response.data.data;
|
||||
orderItems.value = sortOrderItemsForDisplay(response.data.data);
|
||||
}).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
loadCustomerAttributes
|
||||
} from "@/components/shop/POSDepartmentProcess.vue";
|
||||
import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
|
||||
import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
import { AUDITED_ORDER_ITEM_PRODUCT_IDS, createOrderItem, removeOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from "vue-i18n";
|
||||
@@ -28,7 +28,12 @@ import Swal from "sweetalert2";
|
||||
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
||||
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();
|
||||
@@ -385,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) {
|
||||
@@ -396,7 +406,7 @@ const showAddMultipleProducts = (productId) => {
|
||||
return;
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
const quantityResult = await Swal.fire({
|
||||
title: 'Tilføj flere produkter',
|
||||
input: 'number',
|
||||
inputAttributes: {
|
||||
@@ -404,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) {
|
||||
@@ -422,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";
|
||||
@@ -576,28 +601,62 @@ const addAddonsToOrderMiddleware = async (product_id, quantity = 1, related_item
|
||||
throw new Error("Created parent order item ID is missing");
|
||||
}
|
||||
|
||||
for (let i = 0; i < product_addons.length; i++) {
|
||||
// Get the addons quantity (If the addon quantity is 0, use the product quantity, otherwise multiply the addon quantity with the product quantity)
|
||||
let addon_quantity = product_addons[i].quantity === 0 ? quantity : product_addons[i].quantity * quantity;
|
||||
// Get the products addon, and show the fake create order item
|
||||
showPendingCreateOrderItem(
|
||||
getPendingProductFromAddon(product_id, product_addons[i].addon_id),
|
||||
addon_quantity,
|
||||
getAddonPrice(product_id, product_addons[i].addon_id),
|
||||
normalizedRelatedItemId,
|
||||
product_addons[i].notes === undefined ? null : product_addons[i].notes
|
||||
);
|
||||
// Show the pending rows for every add-on so the cart panel reflects the
|
||||
// optimistic state immediately. The real POST fan-out happens below and
|
||||
// the pending rows are cleared by `loadOrderItems` on success or by
|
||||
// `clearPendingOrderItems` on failure.
|
||||
const addonDefinitions = product_addons.map((selection) => {
|
||||
const addon_quantity = selection.quantity === 0 ? quantity : selection.quantity * quantity;
|
||||
const addonAddon = getProductAddonDefinition(product_id, selection.addon_id);
|
||||
return {
|
||||
addon_id: selection.addon_id,
|
||||
quantity: addon_quantity,
|
||||
notes: selection.notes === undefined ? null : selection.notes,
|
||||
product: getPendingProductFromAddon(product_id, selection.addon_id),
|
||||
price: getAddonPrice(product_id, selection.addon_id),
|
||||
_addon_definition: addonAddon,
|
||||
};
|
||||
});
|
||||
|
||||
for (const addon of addonDefinitions) {
|
||||
showPendingCreateOrderItem(addon.product, addon.quantity, addon.price, normalizedRelatedItemId, addon.notes);
|
||||
}
|
||||
// Add the addons to the order
|
||||
for (let i = 0; i < product_addons.length; i++) {
|
||||
let addon_quantity = product_addons[i].quantity === 0 ? quantity : product_addons[i].quantity * quantity;
|
||||
await createOrderItem(
|
||||
targetOrderId,
|
||||
product_addons[i].addon_id,
|
||||
addon_quantity,
|
||||
normalizedRelatedItemId,
|
||||
product_addons[i].notes === undefined ? null : product_addons[i].notes
|
||||
).catch((error) => handleCreateOrderItemError(error));
|
||||
|
||||
// Build the add-on payloads the shared fan-out helper expects. The
|
||||
// helper accepts either an addon-shaped object (`option_id` / nested
|
||||
// `product`) or a plain object with `product_id` + `quantity`; we pass
|
||||
// the nested `product` form so the helper can read both `product.id`
|
||||
// and `product.price` for the POST without us flattening it twice.
|
||||
const helperAddons = addonDefinitions.map((addon) => ({
|
||||
option_id: addon.addon_id,
|
||||
quantity: addon.quantity,
|
||||
notes: addon.notes,
|
||||
skip_price_override: addon._addon_definition?.product?.skip_price_override === true,
|
||||
product: {
|
||||
id: addon.product?.id,
|
||||
price: addon.price,
|
||||
notes: addon.notes ?? "",
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
await addOrderItemAddons({
|
||||
orderId: targetOrderId,
|
||||
primaryItemId: normalizedRelatedItemId,
|
||||
addons: helperAddons,
|
||||
api: {
|
||||
createOrderItem: (orderId, productId, addonQuantity, relatedItemId, notes, price) =>
|
||||
createOrderItem(orderId, productId, addonQuantity, relatedItemId, notes, price),
|
||||
removeOrderItem: (orderItemId) => removeOrderItem(orderItemId),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// The shared helper has already rolled back every row that landed
|
||||
// during this attempt. Surface the error through the same handler
|
||||
// the rest of the desktop flow uses so the operator sees a friendly
|
||||
// restriction warning for backend customer-rule blocks and the
|
||||
// original error otherwise.
|
||||
await handleCreateOrderItemError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -650,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"',
|
||||
@@ -659,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
|
||||
@@ -704,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
|
||||
@@ -781,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);
|
||||
@@ -819,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));
|
||||
}
|
||||
|
||||
@@ -888,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;
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<script>
|
||||
import {authenticatedRequest} from "@/components/session/authenticatedRequest.vue";
|
||||
|
||||
/**
|
||||
* The MiniMax -> Config object
|
||||
*/
|
||||
export const Config = {
|
||||
get: async ( variable ) => {
|
||||
return authenticatedRequest(
|
||||
"/minimax/config?variable=" + variable,
|
||||
"GET")
|
||||
},
|
||||
get_all: async () => {
|
||||
return authenticatedRequest(
|
||||
"/minimax/config",
|
||||
"GET")
|
||||
},
|
||||
set: async ( variable, value ) => {
|
||||
return authenticatedRequest(
|
||||
"/minimax/config",
|
||||
"POST",
|
||||
{
|
||||
variable: variable,
|
||||
value: value
|
||||
})
|
||||
},
|
||||
keys: {
|
||||
api_key: {
|
||||
get: async () => {
|
||||
return Config.get("api_key");
|
||||
},
|
||||
set: async (value) => {
|
||||
return Config.set("api_key", value);
|
||||
},
|
||||
},
|
||||
},
|
||||
enabled: {
|
||||
get: async () => {
|
||||
return Config.get("enabled");
|
||||
},
|
||||
set: async (enabled) => {
|
||||
return Config.set("enabled", enabled);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
</script>
|
||||
@@ -1,22 +0,0 @@
|
||||
<script>
|
||||
import { Config } from "@/components/session/token/superUser/modules/miniMax/Config.vue";
|
||||
|
||||
/**
|
||||
* The MiniMax object
|
||||
*/
|
||||
export const MiniMax = {
|
||||
meta: {
|
||||
title: "MiniMax M3",
|
||||
icon: "fas fa-robot",
|
||||
description: "MiniMax M3 (Anthropic-messages) integration used by XL Vask autopilot and other AI features.",
|
||||
endpoint: "/modules/MiniMax",
|
||||
config_endpoint: "/configuration/MiniMax",
|
||||
labels: {
|
||||
single: "MiniMax",
|
||||
multiple: "MiniMax"
|
||||
}
|
||||
},
|
||||
config: Config,
|
||||
functions: {}
|
||||
};
|
||||
</script>
|
||||
@@ -65,14 +65,6 @@ export const Config = {
|
||||
return Config.set("automatic_order_creation_enabled", value);
|
||||
},
|
||||
},
|
||||
openai_integration_enabled: {
|
||||
get: async () => {
|
||||
return Config.get("openai_integration_enabled");
|
||||
},
|
||||
set: async (value) => {
|
||||
return Config.set("openai_integration_enabled", value);
|
||||
},
|
||||
},
|
||||
},
|
||||
enabled: {
|
||||
get: async () => {
|
||||
|
||||
@@ -23,7 +23,6 @@ import { Entra } from "@/components/session/token/superUser/modules/entra/Entra.
|
||||
import { Limble } from "@/components/session/token/superUser/modules/limble/Limble.vue";
|
||||
import { OcrSpace } from "@/components/session/token/superUser/modules/ocrSpace/OcrSpace.vue";
|
||||
import { OpenAI } from "@/components/session/token/superUser/modules/openAI/OpenAI.vue";
|
||||
import { MiniMax } from "@/components/session/token/superUser/modules/miniMax/MiniMax.vue";
|
||||
import { LicensePlateRecognizer } from "@/components/session/token/superUser/modules/licensePlateRecognizer/LicensePlateRecognizer.vue";
|
||||
import { VirkData } from "@/components/session/token/superUser/modules/virkdata/VirkData.vue";
|
||||
import { Shelly } from "@/components/session/token/superUser/modules/shelly/Shelly.vue";
|
||||
@@ -106,9 +105,6 @@ export const SuperUserObject = {
|
||||
get openai() {
|
||||
return OpenAI;
|
||||
},
|
||||
get minimax() {
|
||||
return MiniMax;
|
||||
},
|
||||
get licenseplaterecognizer() {
|
||||
return LicensePlateRecognizer;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { onMounted, ref } from "vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
const TRUTHY_BOOL_LITERALS = new Set(["true", "1", "yes", "on"]);
|
||||
|
||||
const coerceConfigBoolean = (raw) => {
|
||||
if (raw === true || raw === 1) return true;
|
||||
if (typeof raw === "string") {
|
||||
return TRUTHY_BOOL_LITERALS.has(raw.trim().toLowerCase());
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const normalizeEntries = (payload) => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload.map((entry) => ({
|
||||
variable: entry?.variable,
|
||||
value: entry?.value,
|
||||
isSecret: entry?.isSecret === true,
|
||||
isSet: entry?.isSet === true,
|
||||
}));
|
||||
}
|
||||
return Object.values(payload ?? {}).map((entry) => ({
|
||||
variable: entry?.variable,
|
||||
value: entry?.value,
|
||||
isSecret: entry?.isSecret === true,
|
||||
isSet: entry?.isSet === true,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the copy-pasted `getModuleConfig` / `getModuleConfigValue` /
|
||||
* `module_config = ref([])` triple that 20+ `Configuration*.vue` pages
|
||||
* each defined themselves.
|
||||
*
|
||||
* @param {string} moduleName - e.g. "stripe", "xlvask", "bird"
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.coerceBooleans=true] - return `true`/`false` for `'true'`/`'false'` strings
|
||||
* @param {boolean} [options.autoLoad=true] - load on mount
|
||||
* @returns module_config, loading, error, refresh, getModuleConfigValue, getEntry, isVariableSet
|
||||
*/
|
||||
export const useModuleConfig = (moduleName, options = {}) => {
|
||||
const { coerceBooleans = true, autoLoad = true } = options;
|
||||
|
||||
const module_config = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const getEntry = (variable) => module_config.value.find((entry) => entry.variable === variable);
|
||||
|
||||
const getModuleConfigValue = (variable) => {
|
||||
const entry = getEntry(variable);
|
||||
if (!entry) return "";
|
||||
const raw = entry.value;
|
||||
if (coerceBooleans) {
|
||||
if (raw === "true" || raw === true) return true;
|
||||
if (raw === "false" || raw === false) return false;
|
||||
}
|
||||
return raw ?? "";
|
||||
};
|
||||
|
||||
const isVariableSet = (variable) => {
|
||||
const entry = getEntry(variable);
|
||||
if (!entry) return false;
|
||||
if (entry.isSecret) return entry.isSet;
|
||||
return entry.value !== null && entry.value !== undefined && entry.value !== "";
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await SessionUser.superUser.modules[moduleName].config.get_all();
|
||||
module_config.value = normalizeEntries(response?.data?.data);
|
||||
} catch (err) {
|
||||
console.error(`useModuleConfig(${moduleName}): load failed`, err);
|
||||
module_config.value = [];
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
if (autoLoad) {
|
||||
onMounted(load);
|
||||
}
|
||||
|
||||
return {
|
||||
module_config,
|
||||
loading,
|
||||
error,
|
||||
load,
|
||||
getEntry,
|
||||
getModuleConfigValue,
|
||||
isVariableSet,
|
||||
coerceConfigBoolean,
|
||||
};
|
||||
};
|
||||
@@ -2541,6 +2541,7 @@
|
||||
"zip": "Postnummer"
|
||||
},
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
@@ -4587,6 +4588,10 @@
|
||||
"previous": "Forrige kunde til gennemgang",
|
||||
"selected_customer": "Valgt kunde"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "Fakturer nu mislykkedes",
|
||||
"fakturer_nu_failed_body": "Kunne ikke oprette faktura for denne kunde. Prøv igen, eller tjek kundens transaktioner."
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "Periodens hovedtal",
|
||||
"booked": "Bogført",
|
||||
@@ -4673,7 +4678,7 @@
|
||||
"workflow": "Arbejdsgang"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Accepter forslag",
|
||||
"attach_order": "Tilknyt ordre",
|
||||
@@ -4869,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2651,6 +2651,7 @@
|
||||
"zip": "Postleitzahl"
|
||||
},
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
@@ -4339,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": {
|
||||
@@ -4697,6 +4698,10 @@
|
||||
"previous": "Vorheriger zu prüfender Kunde",
|
||||
"selected_customer": "Ausgewählter Kunde"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "Jetzt fakturieren fehlgeschlagen",
|
||||
"fakturer_nu_failed_body": "Rechnung für diesen Kunden konnte nicht erstellt werden. Erneut versuchen oder Transaktionen prüfen."
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "Kennzahlen des Zeitraums",
|
||||
"booked": "Gebucht",
|
||||
@@ -4783,7 +4788,7 @@
|
||||
"workflow": "Arbeitsablauf"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Vorschlag annehmen",
|
||||
"attach_order": "Auftrag verknüpfen",
|
||||
@@ -4979,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2372,6 +2372,7 @@
|
||||
"zip": "@:{'words.generated.zip'} @:{'words.generated.code'}"
|
||||
},
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
@@ -4418,6 +4419,10 @@
|
||||
"previous": "Previous customer needing review",
|
||||
"selected_customer": "Selected customer"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "Invoice now failed",
|
||||
"fakturer_nu_failed_body": "Could not create invoice for this customer. Try again, or check the customer's transactions."
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "Period headline figures",
|
||||
"booked": "Booked",
|
||||
@@ -4504,7 +4509,7 @@
|
||||
"workflow": "Workflow"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Accept suggestion",
|
||||
"attach_order": "Attach order",
|
||||
@@ -4700,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+182
-174
@@ -1255,6 +1255,7 @@
|
||||
"workspace_id": "Workspace ID",
|
||||
"workspace_id_desc": "The canonical Bird workspace identifier. Existing workplaceId values remain readable during migration."
|
||||
},
|
||||
"secret_key_not_set": "@:{'templates.generated.compat.configuration.secret_key_not_set'}",
|
||||
"backups": {
|
||||
"active_jobs": "@:{'templates.generated.compat.configuration.backups.active_jobs'}",
|
||||
"actions": "@:{'templates.generated.compat.configuration.backups.actions'}",
|
||||
@@ -1657,6 +1658,7 @@
|
||||
"api_settings_desc": "@:{'templates.generated.compat.configuration.xlvask.api_settings_desc'}",
|
||||
"automation_settings": "@:{'templates.generated.compat.configuration.xlvask.automation_settings'}",
|
||||
"automation_settings_desc": "@:{'templates.generated.compat.configuration.xlvask.automation_settings_desc'}",
|
||||
"automation_settings_removed_desc": "Automatic attachment, automatic creation, OpenAI and MiniMax integrations have been removed. Operator review is now performed from the Superuser -> Fakturaer -> Periode -> Selvvask view.",
|
||||
"connection_failed": "@:{'templates.generated.compat.configuration.xlvask.connection_failed'}",
|
||||
"connection_failed_desc": "@:{'templates.generated.compat.configuration.xlvask.connection_failed_desc'}",
|
||||
"connection_success": "@:configuration.limble.connection_success",
|
||||
@@ -3419,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",
|
||||
@@ -3804,6 +3807,10 @@
|
||||
"previous": "@:{'templates.generated.compat.invoicing_period.review_workspace.detail.previous'}",
|
||||
"selected_customer": "@:{'templates.generated.compat.invoicing_period.review_workspace.detail.selected_customer'}"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "@:{'templates.generated.compat.invoicing_period.review_workspace.errors.fakturer_nu_failed_title'}",
|
||||
"fakturer_nu_failed_body": "@:{'templates.generated.compat.invoicing_period.review_workspace.errors.fakturer_nu_failed_body'}"
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "@:{'templates.generated.compat.invoicing_period.review_workspace.headline.aria_label'}",
|
||||
"booked": "@:{'templates.generated.compat.invoicing_period.review_workspace.headline.booked'}",
|
||||
@@ -3890,203 +3897,204 @@
|
||||
"workflow": "@:{'templates.generated.compat.invoicing_period.review_workspace.toolbar.workflow'}"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.accept'}",
|
||||
"attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.attach_order'}",
|
||||
"compare": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare'}",
|
||||
"compare_modal_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_modal_title'}",
|
||||
"compare_no_candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_no_candidates'}",
|
||||
"compare_price_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_price_match'}",
|
||||
"compare_price_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_price_mismatch'}",
|
||||
"compare_usage_price": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_usage_price'}",
|
||||
"compare_candidate_price": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.compare_candidate_price'}",
|
||||
"create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.create_order'}",
|
||||
"deny": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.deny'}",
|
||||
"ignore": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.ignore'}",
|
||||
"link": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link'}",
|
||||
"link_prompt_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_label'}",
|
||||
"link_prompt_invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_invalid'}",
|
||||
"link_prompt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_title'}",
|
||||
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.none'}",
|
||||
"recheck": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.recheck'}",
|
||||
"resolve_mapping": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.actions.resolve_mapping'}"
|
||||
"accept": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.accept'}",
|
||||
"attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.attach_order'}",
|
||||
"compare": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare'}",
|
||||
"compare_modal_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_modal_title'}",
|
||||
"compare_no_candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_no_candidates'}",
|
||||
"compare_price_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_price_match'}",
|
||||
"compare_price_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_price_mismatch'}",
|
||||
"compare_usage_price": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_usage_price'}",
|
||||
"compare_candidate_price": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.compare_candidate_price'}",
|
||||
"create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.create_order'}",
|
||||
"deny": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.deny'}",
|
||||
"ignore": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.ignore'}",
|
||||
"link": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link'}",
|
||||
"link_prompt_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_label'}",
|
||||
"link_prompt_invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_invalid'}",
|
||||
"link_prompt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.link_prompt_title'}",
|
||||
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.none'}",
|
||||
"recheck": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.recheck'}",
|
||||
"resolve_mapping": "@:{'templates.generated.compat.invoicing_period.xlvask_review.actions.resolve_mapping'}"
|
||||
},
|
||||
"adjudication": {
|
||||
"confirm_correct": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_correct'}",
|
||||
"confirm_cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_cross_hall'}",
|
||||
"confirm_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_duplicate'}",
|
||||
"confirm_incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_incorrect'}",
|
||||
"confirm_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_title'}",
|
||||
"confirm_unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_unaudited'}",
|
||||
"description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.description'}",
|
||||
"halted": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.halted'}",
|
||||
"confirm_correct": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_correct'}",
|
||||
"confirm_cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_cross_hall'}",
|
||||
"confirm_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_duplicate'}",
|
||||
"confirm_incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_incorrect'}",
|
||||
"confirm_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_title'}",
|
||||
"confirm_unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.confirm_unaudited'}",
|
||||
"description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.description'}",
|
||||
"halted": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.halted'}",
|
||||
"outcomes": {
|
||||
"correct": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.correct'}",
|
||||
"cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.cross_hall'}",
|
||||
"duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.duplicate'}",
|
||||
"incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.incorrect'}",
|
||||
"unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.unaudited'}"
|
||||
"correct": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.correct'}",
|
||||
"cross_hall": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.cross_hall'}",
|
||||
"duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.duplicate'}",
|
||||
"incorrect": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.incorrect'}",
|
||||
"unaudited": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.outcomes.unaudited'}"
|
||||
},
|
||||
"saved": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.saved'}",
|
||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.adjudication.title'}"
|
||||
"saved": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.saved'}",
|
||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.adjudication.title'}"
|
||||
},
|
||||
"audit": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.audit'}",
|
||||
"bulk_selected": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.bulk_selected'}",
|
||||
"bulk_eligibility": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.bulk_eligibility'}",
|
||||
"calibrated_probability": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.calibrated_probability'}",
|
||||
"candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.candidates'}",
|
||||
"contradictions": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.contradictions'}",
|
||||
"audit": "@:{'templates.generated.compat.invoicing_period.xlvask_review.audit'}",
|
||||
"bulk_selected": "@:{'templates.generated.compat.invoicing_period.xlvask_review.bulk_selected'}",
|
||||
"bulk_eligibility": "@:{'templates.generated.compat.invoicing_period.xlvask_review.bulk_eligibility'}",
|
||||
"calibrated_probability": "@:{'templates.generated.compat.invoicing_period.xlvask_review.calibrated_probability'}",
|
||||
"candidates": "@:{'templates.generated.compat.invoicing_period.xlvask_review.candidates'}",
|
||||
"contradictions": "@:{'templates.generated.compat.invoicing_period.xlvask_review.contradictions'}",
|
||||
"controls": {
|
||||
"active_run_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.active_run_error'}",
|
||||
"analyze": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.analyze'}",
|
||||
"budget": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.budget'}",
|
||||
"execute": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute'}",
|
||||
"execute_description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_description'}",
|
||||
"execute_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_phrase'}",
|
||||
"execute_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.execute_title'}",
|
||||
"halt": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.halt'}",
|
||||
"halt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.halt_title'}",
|
||||
"load_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.load_error'}",
|
||||
"no_access": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.no_access'}",
|
||||
"not_ready": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.not_ready'}",
|
||||
"policy_advance": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_advance'}",
|
||||
"policy_apply": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_apply'}",
|
||||
"policy_description": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_description'}",
|
||||
"policy_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_phrase'}",
|
||||
"policy_reason": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_reason'}",
|
||||
"policy_reason_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_reason_title'}",
|
||||
"policy_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.policy_title'}",
|
||||
"readiness_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.readiness_error'}",
|
||||
"ready": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.ready'}",
|
||||
"stage": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.stage'}",
|
||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.title'}",
|
||||
"worker_healthy": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.worker_healthy'}",
|
||||
"worker_unhealthy": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.controls.worker_unhealthy'}"
|
||||
"active_run_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.active_run_error'}",
|
||||
"analyze": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.analyze'}",
|
||||
"budget": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.budget'}",
|
||||
"execute": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute'}",
|
||||
"execute_description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_description'}",
|
||||
"execute_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_phrase'}",
|
||||
"execute_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.execute_title'}",
|
||||
"halt": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.halt'}",
|
||||
"halt_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.halt_title'}",
|
||||
"load_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.load_error'}",
|
||||
"no_access": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.no_access'}",
|
||||
"not_ready": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.not_ready'}",
|
||||
"policy_advance": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_advance'}",
|
||||
"policy_apply": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_apply'}",
|
||||
"policy_description": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_description'}",
|
||||
"policy_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_phrase'}",
|
||||
"policy_reason": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_reason'}",
|
||||
"policy_reason_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_reason_title'}",
|
||||
"policy_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.policy_title'}",
|
||||
"readiness_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.readiness_error'}",
|
||||
"ready": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.ready'}",
|
||||
"stage": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.stage'}",
|
||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.title'}",
|
||||
"worker_healthy": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.worker_healthy'}",
|
||||
"worker_unhealthy": "@:{'templates.generated.compat.invoicing_period.xlvask_review.controls.worker_unhealthy'}"
|
||||
},
|
||||
"clear_selection": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.clear_selection'}",
|
||||
"evidence": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.evidence'}",
|
||||
"clear_selection": "@:{'templates.generated.compat.invoicing_period.xlvask_review.clear_selection'}",
|
||||
"evidence": "@:{'templates.generated.compat.invoicing_period.xlvask_review.evidence'}",
|
||||
"filters": {
|
||||
"all": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.all'}",
|
||||
"certainty": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.certainty'}",
|
||||
"clear": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.clear'}",
|
||||
"import_state": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.import_state'}",
|
||||
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.planned_action'}",
|
||||
"resolution_state": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.resolution_state'}",
|
||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.title'}",
|
||||
"unattached_only": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.filters.unattached_only'}"
|
||||
"all": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.all'}",
|
||||
"certainty": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.certainty'}",
|
||||
"clear": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.clear'}",
|
||||
"import_state": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.import_state'}",
|
||||
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.planned_action'}",
|
||||
"resolution_state": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.resolution_state'}",
|
||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.title'}",
|
||||
"unattached_only": "@:{'templates.generated.compat.invoicing_period.xlvask_review.filters.unattached_only'}"
|
||||
},
|
||||
"hide_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.hide_match'}",
|
||||
"model": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.model'}",
|
||||
"no_safe_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.no_safe_match'}",
|
||||
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.planned_action'}",
|
||||
"policy_version": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.policy_version'}",
|
||||
"hide_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.hide_match'}",
|
||||
"model": "@:{'templates.generated.compat.invoicing_period.xlvask_review.model'}",
|
||||
"no_safe_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.no_safe_match'}",
|
||||
"planned_action": "@:{'templates.generated.compat.invoicing_period.xlvask_review.planned_action'}",
|
||||
"policy_version": "@:{'templates.generated.compat.invoicing_period.xlvask_review.policy_version'}",
|
||||
"preview": {
|
||||
"after": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.after'}",
|
||||
"applied": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.applied'}",
|
||||
"apply": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.apply'}",
|
||||
"before": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.before'}",
|
||||
"confirmation_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_label'}",
|
||||
"confirmation_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_mismatch'}",
|
||||
"confirmation_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.confirmation_phrase'}",
|
||||
"error_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.error_title'}",
|
||||
"reason_label": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.reason_label'}",
|
||||
"reason_required": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.reason_required'}",
|
||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.preview.title'}"
|
||||
"after": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.after'}",
|
||||
"applied": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.applied'}",
|
||||
"apply": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.apply'}",
|
||||
"before": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.before'}",
|
||||
"confirmation_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_label'}",
|
||||
"confirmation_mismatch": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_mismatch'}",
|
||||
"confirmation_phrase": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.confirmation_phrase'}",
|
||||
"error_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.error_title'}",
|
||||
"reason_label": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.reason_label'}",
|
||||
"reason_required": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.reason_required'}",
|
||||
"title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.preview.title'}"
|
||||
},
|
||||
"resume_status": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.resume_status'}",
|
||||
"run": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run'}",
|
||||
"run_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_id'}",
|
||||
"resume_status": "@:{'templates.generated.compat.invoicing_period.xlvask_review.resume_status'}",
|
||||
"run": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run'}",
|
||||
"run_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_id'}",
|
||||
"run_phases": {
|
||||
"circuit_breaker": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.circuit_breaker'}",
|
||||
"completed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.completed'}",
|
||||
"completed_with_warnings": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.completed_with_warnings'}",
|
||||
"evaluating": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.evaluating'}",
|
||||
"executing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.executing'}",
|
||||
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.failed'}",
|
||||
"importing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.importing'}",
|
||||
"pending": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.pending'}",
|
||||
"processing": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.processing'}",
|
||||
"queued": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.queued'}",
|
||||
"reconciling": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.reconciling'}",
|
||||
"retry_wait": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.retry_wait'}",
|
||||
"running": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_phases.running'}"
|
||||
"circuit_breaker": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.circuit_breaker'}",
|
||||
"completed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.completed'}",
|
||||
"completed_with_warnings": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.completed_with_warnings'}",
|
||||
"evaluating": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.evaluating'}",
|
||||
"executing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.executing'}",
|
||||
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.failed'}",
|
||||
"importing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.importing'}",
|
||||
"pending": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.pending'}",
|
||||
"processing": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.processing'}",
|
||||
"queued": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.queued'}",
|
||||
"reconciling": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.reconciling'}",
|
||||
"retry_wait": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.retry_wait'}",
|
||||
"running": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_phases.running'}"
|
||||
},
|
||||
"run_progress": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_progress'}",
|
||||
"run_start_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_start_error'}",
|
||||
"run_status_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.run_status_error'}",
|
||||
"select_record": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.select_record'}",
|
||||
"show_match": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.show_match'}",
|
||||
"source_revision": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.source_revision'}",
|
||||
"run_progress": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_progress'}",
|
||||
"run_start_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_start_error'}",
|
||||
"run_status_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.run_status_error'}",
|
||||
"select_record": "@:{'templates.generated.compat.invoicing_period.xlvask_review.select_record'}",
|
||||
"show_match": "@:{'templates.generated.compat.invoicing_period.xlvask_review.show_match'}",
|
||||
"source_revision": "@:{'templates.generated.compat.invoicing_period.xlvask_review.source_revision'}",
|
||||
"states": {
|
||||
"accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.accepted_attach'}",
|
||||
"accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.accepted_create'}",
|
||||
"already_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.already_linked'}",
|
||||
"auto_accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_attach'}",
|
||||
"auto_accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_create'}",
|
||||
"auto_created": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_created'}",
|
||||
"auto_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.auto_linked'}",
|
||||
"blocked": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.blocked'}",
|
||||
"certain": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.certain'}",
|
||||
"denied": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.denied'}",
|
||||
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.failed'}",
|
||||
"ignored": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.ignored'}",
|
||||
"invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.invalid'}",
|
||||
"needs_review": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.needs_review'}",
|
||||
"new": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.new'}",
|
||||
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.none'}",
|
||||
"suggested_attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.suggested_attach_order'}",
|
||||
"suggested_create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.suggested_create_order'}",
|
||||
"uncertain": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.uncertain'}",
|
||||
"unchanged": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.unchanged'}",
|
||||
"updated": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.states.updated'}"
|
||||
"accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.accepted_attach'}",
|
||||
"accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.accepted_create'}",
|
||||
"already_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.already_linked'}",
|
||||
"auto_accepted_attach": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_accepted_attach'}",
|
||||
"auto_accepted_create": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_accepted_create'}",
|
||||
"auto_created": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_created'}",
|
||||
"auto_linked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.auto_linked'}",
|
||||
"blocked": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.blocked'}",
|
||||
"certain": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.certain'}",
|
||||
"denied": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.denied'}",
|
||||
"failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.failed'}",
|
||||
"ignored": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.ignored'}",
|
||||
"invalid": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.invalid'}",
|
||||
"needs_review": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.needs_review'}",
|
||||
"new": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.new'}",
|
||||
"none": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.none'}",
|
||||
"suggested_attach_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.suggested_attach_order'}",
|
||||
"suggested_create_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.suggested_create_order'}",
|
||||
"uncertain": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.uncertain'}",
|
||||
"unchanged": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.unchanged'}",
|
||||
"updated": "@:{'templates.generated.compat.invoicing_period.xlvask_review.states.updated'}"
|
||||
},
|
||||
"summary_error": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.summary_error'}",
|
||||
"summary_title": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.summary_title'}",
|
||||
"summary_error": "@:{'templates.generated.compat.invoicing_period.xlvask_review.summary_error'}",
|
||||
"summary_title": "@:{'templates.generated.compat.invoicing_period.xlvask_review.summary_title'}",
|
||||
"labels": {
|
||||
"attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.attached_to_order'}",
|
||||
"not_attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.not_attached_to_order'}",
|
||||
"compare_select_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.compare_select_duplicate'}",
|
||||
"compare_price_mismatch_inline": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.compare_price_mismatch_inline'}",
|
||||
"wash_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.wash_id'}",
|
||||
"wash_already_withdrawn": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.wash_already_withdrawn'}",
|
||||
"go_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.go_to_order'}",
|
||||
"unknown_product": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.unknown_product'}",
|
||||
"unknown_product_with_id": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.unknown_product_with_id'}",
|
||||
"ok": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.labels.ok'}"
|
||||
"attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.attached_to_order'}",
|
||||
"not_attached_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.not_attached_to_order'}",
|
||||
"compare_select_duplicate": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.compare_select_duplicate'}",
|
||||
"compare_price_mismatch_inline": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.compare_price_mismatch_inline'}",
|
||||
"wash_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.wash_id'}",
|
||||
"wash_already_withdrawn": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.wash_already_withdrawn'}",
|
||||
"go_to_order": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.go_to_order'}",
|
||||
"unknown_product": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.unknown_product'}",
|
||||
"unknown_product_with_id": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.unknown_product_with_id'}",
|
||||
"ok": "@:{'templates.generated.compat.invoicing_period.xlvask_review.labels.ok'}"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.just_now'}",
|
||||
"ago_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.ago_one'}",
|
||||
"ago_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.ago_other'}",
|
||||
"just_now": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.just_now'}",
|
||||
"ago_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.ago_one'}",
|
||||
"ago_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.ago_other'}",
|
||||
"units": {
|
||||
"year_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.year_one'}",
|
||||
"year_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.year_other'}",
|
||||
"month_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.month_one'}",
|
||||
"month_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.month_other'}",
|
||||
"week_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.week_one'}",
|
||||
"week_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.week_other'}",
|
||||
"day_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.day_one'}",
|
||||
"day_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.day_other'}",
|
||||
"hour_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.hour_one'}",
|
||||
"hour_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.hour_other'}",
|
||||
"minute_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.minute_one'}",
|
||||
"minute_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.minute_other'}",
|
||||
"second_one": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.second_one'}",
|
||||
"second_other": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.time.units.second_other'}"
|
||||
"year_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.year_one'}",
|
||||
"year_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.year_other'}",
|
||||
"month_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.month_one'}",
|
||||
"month_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.month_other'}",
|
||||
"week_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.week_one'}",
|
||||
"week_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.week_other'}",
|
||||
"day_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.day_one'}",
|
||||
"day_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.day_other'}",
|
||||
"hour_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.hour_one'}",
|
||||
"hour_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.hour_other'}",
|
||||
"minute_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.minute_one'}",
|
||||
"minute_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.minute_other'}",
|
||||
"second_one": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.second_one'}",
|
||||
"second_other": "@:{'templates.generated.compat.invoicing_period.xlvask_review.time.units.second_other'}"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fetch_usage_log": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_usage_log'}",
|
||||
"fetch_vehicle_types": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'}",
|
||||
"fetch_related_orders": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_related_orders'}",
|
||||
"fetch_fast_link": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.fetch_fast_link'}",
|
||||
"create_order_unrecognized_items": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.create_order_unrecognized_items'}",
|
||||
"create_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.create_order_failed'}",
|
||||
"create_order_item_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.create_order_item_failed'}",
|
||||
"redirect_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.redirect_order_failed'}",
|
||||
"load_customers_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.load_customers_failed'}",
|
||||
"load_usage_log_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'}"
|
||||
"fetch_usage_log": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_usage_log'}",
|
||||
"fetch_vehicle_types": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_vehicle_types'}",
|
||||
"fetch_related_orders": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_related_orders'}",
|
||||
"fetch_fast_link": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.fetch_fast_link'}",
|
||||
"create_order_unrecognized_items": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_unrecognized_items'}",
|
||||
"create_order_failed": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.create_order_failed'}",
|
||||
"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'}",
|
||||
"api_endpoint_not_implemented": "@:{'templates.generated.compat.invoicing_period.xlvask_review.errors.api_endpoint_not_implemented'}"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2654,6 +2654,7 @@
|
||||
"zip": "@:{'words.generated.postnummer'}"
|
||||
},
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
@@ -4342,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": {
|
||||
@@ -4700,6 +4701,10 @@
|
||||
"previous": "Forrige kunde til gjennomgang",
|
||||
"selected_customer": "Valgt kunde"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "Fakturer nå mislyktes",
|
||||
"fakturer_nu_failed_body": "Kunne ikke opprette faktura for denne kunden. Prøv igjen, eller sjekk kundens transaksjoner."
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "Periodens hovedtall",
|
||||
"booked": "Bokført",
|
||||
@@ -4786,7 +4791,7 @@
|
||||
"workflow": "Arbeidsflyt"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Godta forslag",
|
||||
"attach_order": "Knytt til ordre",
|
||||
@@ -4982,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å"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2704,6 +2704,7 @@
|
||||
"zip": "Postnummer"
|
||||
},
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
@@ -4392,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": {
|
||||
@@ -4750,6 +4751,10 @@
|
||||
"previous": "Föregående kund att granska",
|
||||
"selected_customer": "Vald kund"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "Fakturera nu misslyckades",
|
||||
"fakturer_nu_failed_body": "Kunde inte skapa faktura för denna kund. Försök igen, eller kontrollera kundens transaktioner."
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "Periodens huvudtal",
|
||||
"booked": "Bokfört",
|
||||
@@ -4836,7 +4841,7 @@
|
||||
"workflow": "Arbetsflöde"
|
||||
}
|
||||
},
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Godkänn förslag",
|
||||
"attach_order": "Koppla order",
|
||||
@@ -5032,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -2,17 +2,104 @@
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"review_workspace": {
|
||||
"analytics": { "title": "Overblik og nøgletal" },
|
||||
"detail": { "next": "Næste kunde til gennemgang", "open_prompt": "Vælg kunden i køen for at åbne fakturadetaljerne.", "previous": "Forrige kunde til gennemgang", "selected_customer": "Valgt kunde" },
|
||||
"headline": { "aria_label": "Periodens hovedtal", "booked": "Bogført", "not_booked": "Ikke bogført", "total": "I alt" },
|
||||
"navigation": { "agreements": "Aftaler", "aria_label": "Forretningsvisninger", "billing": "Fakturering", "operations": "Drift", "review": "Gennemgang" },
|
||||
"next_actions": { "create_invoice": "Opret faktura", "none": "Ingen handling nødvendig", "resolve_collection_errors": "Ret fejl i fakturasamling", "resolve_draft": "Afklar kladde", "resolve_manual_flags": "Afklar manuelle flag", "review_warnings": "Gennemgå advarsler", "wait_for_export": "Afvent eksport" },
|
||||
"queue": { "count": "{count} kunder", "title": "Kunder til gennemgang" },
|
||||
"reasons": { "automatic_flags": "Automatiske advarsler", "automatic_warnings": "Automatiske advarsler", "collection_errors": "Fejl i fakturasamling", "completed": "Alt er bogført", "draft": "Kladde blokerer", "draft_blocks_action": "Kladde blokerer", "export_in_progress": "Eksport er i gang", "manual_flags": "Manuelle flag kræver handling", "queued": "Fakturaen er i kø", "ready_to_invoice": "Klar til fakturering", "requires_action": "Kræver gennemgang", "unbooked_transactions": "Ikke-bogførte transaktioner" },
|
||||
"severities": { "blue": "I kø", "green": "Klar", "red": "Blokeret", "yellow": "Advarsel" },
|
||||
"sort": { "customer_name": "Kundenavn", "customer_number": "Kundenummer", "priority": "Vigtigst først", "total_amount": "Beløb" },
|
||||
"states": { "attention": "Kræver opmærksomhed", "blocked": "Blokeret", "completed": "Færdig", "empty_body": "Vælg en anden periode eller forretningsvisning.", "empty_title": "Ingen fakturadata i perioden", "error_body": "Prøv at genindlæse perioden.", "error_title": "Fakturaperioden kunne ikke indlæses", "filtered_empty_body": "Nulstil arbejdsgangsfiltrene eller prøv en anden søgning.", "filtered_empty_title": "Ingen kunder matcher filtrene", "loading_body": "Samler kunder, fakturasamlinger og kontrolpunkter…", "loading_title": "Indlæser fakturaperioden", "queued": "I kø", "ready": "Klar til fakturering", "refreshing": "Opdaterer eksisterende resultat…", "retained_error": "Opdateringen mislykkedes. Det senest indlæste resultat vises stadig." },
|
||||
"toolbar": { "all_departments": "Alle afdelinger", "all_invoice_states": "Alle fakturastatusser", "all_severities": "Alle niveauer", "all_states": "Alle tilstande", "change_direction": "Skift sorteringsretning", "clear_filters": "Ryd filtre", "department": "Afdeling", "invoice_state": "Fakturastatus", "reload": "Genindlæs", "retry": "Prøv igen", "search": "Søg i kunder og fakturadata", "severity": "Alvor", "sort": "Sortér", "workflow": "Arbejdsgang" }
|
||||
"analytics": {
|
||||
"title": "Overblik og nøgletal"
|
||||
},
|
||||
"detail": {
|
||||
"next": "Næste kunde til gennemgang",
|
||||
"open_prompt": "Vælg kunden i køen for at åbne fakturadetaljerne.",
|
||||
"previous": "Forrige kunde til gennemgang",
|
||||
"selected_customer": "Valgt kunde"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "Fakturer nu mislykkedes",
|
||||
"fakturer_nu_failed_body": "Kunne ikke oprette faktura for denne kunde. Prøv igen, eller tjek kundens transaktioner."
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "Periodens hovedtal",
|
||||
"booked": "Bogført",
|
||||
"not_booked": "Ikke bogført",
|
||||
"total": "I alt"
|
||||
},
|
||||
"navigation": {
|
||||
"agreements": "Aftaler",
|
||||
"aria_label": "Forretningsvisninger",
|
||||
"billing": "Fakturering",
|
||||
"operations": "Drift",
|
||||
"review": "Gennemgang"
|
||||
},
|
||||
"next_actions": {
|
||||
"create_invoice": "Opret faktura",
|
||||
"none": "Ingen handling nødvendig",
|
||||
"resolve_collection_errors": "Ret fejl i fakturasamling",
|
||||
"resolve_draft": "Afklar kladde",
|
||||
"resolve_manual_flags": "Afklar manuelle flag",
|
||||
"review_warnings": "Gennemgå advarsler",
|
||||
"wait_for_export": "Afvent eksport"
|
||||
},
|
||||
"queue": {
|
||||
"count": "{count} kunder",
|
||||
"title": "Kunder til gennemgang"
|
||||
},
|
||||
"reasons": {
|
||||
"automatic_flags": "Automatiske advarsler",
|
||||
"automatic_warnings": "Automatiske advarsler",
|
||||
"collection_errors": "Fejl i fakturasamling",
|
||||
"completed": "Alt er bogført",
|
||||
"draft": "Kladde blokerer",
|
||||
"draft_blocks_action": "Kladde blokerer",
|
||||
"export_in_progress": "Eksport er i gang",
|
||||
"manual_flags": "Manuelle flag kræver handling",
|
||||
"queued": "Fakturaen er i kø",
|
||||
"ready_to_invoice": "Klar til fakturering",
|
||||
"requires_action": "Kræver gennemgang",
|
||||
"unbooked_transactions": "Ikke-bogførte transaktioner"
|
||||
},
|
||||
"severities": {
|
||||
"blue": "I kø",
|
||||
"green": "Klar",
|
||||
"red": "Blokeret",
|
||||
"yellow": "Advarsel"
|
||||
},
|
||||
"sort": {
|
||||
"customer_name": "Kundenavn",
|
||||
"customer_number": "Kundenummer",
|
||||
"priority": "Vigtigst først",
|
||||
"total_amount": "Beløb"
|
||||
},
|
||||
"states": {
|
||||
"attention": "Kræver opmærksomhed",
|
||||
"blocked": "Blokeret",
|
||||
"completed": "Færdig",
|
||||
"empty_body": "Vælg en anden periode eller forretningsvisning.",
|
||||
"empty_title": "Ingen fakturadata i perioden",
|
||||
"error_body": "Prøv at genindlæse perioden.",
|
||||
"error_title": "Fakturaperioden kunne ikke indlæses",
|
||||
"filtered_empty_body": "Nulstil arbejdsgangsfiltrene eller prøv en anden søgning.",
|
||||
"filtered_empty_title": "Ingen kunder matcher filtrene",
|
||||
"loading_body": "Samler kunder, fakturasamlinger og kontrolpunkter…",
|
||||
"loading_title": "Indlæser fakturaperioden",
|
||||
"queued": "I kø",
|
||||
"ready": "Klar til fakturering",
|
||||
"refreshing": "Opdaterer eksisterende resultat…",
|
||||
"retained_error": "Opdateringen mislykkedes. Det senest indlæste resultat vises stadig."
|
||||
},
|
||||
"toolbar": {
|
||||
"all_departments": "Alle afdelinger",
|
||||
"all_invoice_states": "Alle fakturastatusser",
|
||||
"all_severities": "Alle niveauer",
|
||||
"all_states": "Alle tilstande",
|
||||
"change_direction": "Skift sorteringsretning",
|
||||
"clear_filters": "Ryd filtre",
|
||||
"department": "Afdeling",
|
||||
"invoice_state": "Fakturastatus",
|
||||
"reload": "Genindlæs",
|
||||
"retry": "Prøv igen",
|
||||
"search": "Søg i kunder og fakturadata",
|
||||
"severity": "Alvor",
|
||||
"sort": "Sortér",
|
||||
"workflow": "Arbejdsgang"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"compat": { "invoicing_period": { "xlvask_autopilot": {
|
||||
"compat": { "invoicing_period": { "xlvask_review": {
|
||||
"actions": { "accept": "Accepter forslag", "attach_order": "Tilknyt ordre", "compare": "Sammenlign kandidater", "compare_modal_title": "Sammenlign kandidater med vask", "compare_no_candidates": "Ingen kandidater at sammenligne", "compare_price_match": "Pris matcher", "compare_price_mismatch": "Pris matcher ikke", "compare_usage_price": "Vaskens beregnede pris", "compare_candidate_price": "Kandidatpris", "create_order": "Opret ordre", "deny": "Afvis", "ignore": "Ignorer", "link": "Tilknyt ordre-ID", "link_prompt_label": "Indtast ordre-ID", "link_prompt_invalid": "Indtast et gyldigt numerisk ordre-ID", "link_prompt_title": "Tilknyt vask til en eksisterende ordre", "none": "Ingen handling", "recheck": "Kontrollér igen", "resolve_mapping": "Ret tilknytning" },
|
||||
"adjudication": {
|
||||
"confirm_correct": "Bekræft, at den automatiske handling var korrekt.",
|
||||
@@ -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"
|
||||
}
|
||||
} } }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,15 +1,106 @@
|
||||
{
|
||||
"compat": { "invoicing_period": { "review_workspace": {
|
||||
"analytics": { "title": "Übersicht und Kennzahlen" },
|
||||
"detail": { "next": "Nächster zu prüfender Kunde", "open_prompt": "Wählen Sie den Kunden in der Warteschlange, um die Rechnungsdetails zu öffnen.", "previous": "Vorheriger zu prüfender Kunde", "selected_customer": "Ausgewählter Kunde" },
|
||||
"headline": { "aria_label": "Kennzahlen des Zeitraums", "booked": "Gebucht", "not_booked": "Nicht gebucht", "total": "Gesamt" },
|
||||
"navigation": { "agreements": "Vereinbarungen", "aria_label": "Geschäftsansichten", "billing": "Rechnungsstellung", "operations": "Betrieb", "review": "Prüfung" },
|
||||
"next_actions": { "create_invoice": "Rechnung erstellen", "none": "Keine Aktion erforderlich", "resolve_collection_errors": "Sammlungsfehler beheben", "resolve_draft": "Entwurf klären", "resolve_manual_flags": "Manuelle Markierungen klären", "review_warnings": "Warnungen prüfen", "wait_for_export": "Auf Export warten" },
|
||||
"queue": { "count": "{count} Kunden", "title": "Zu prüfende Kunden" },
|
||||
"reasons": { "automatic_flags": "Automatische Warnungen", "automatic_warnings": "Automatische Warnungen", "collection_errors": "Fehler in Rechnungssammlung", "completed": "Alles verbucht", "draft": "Entwurf blockiert", "draft_blocks_action": "Entwurf blockiert", "export_in_progress": "Export läuft", "manual_flags": "Manuelle Markierungen erfordern Aktion", "queued": "Rechnung ist eingereiht", "ready_to_invoice": "Bereit zur Abrechnung", "requires_action": "Prüfung erforderlich", "unbooked_transactions": "Nicht gebuchte Transaktionen" },
|
||||
"severities": { "blue": "Eingereiht", "green": "Bereit", "red": "Blockiert", "yellow": "Warnung" },
|
||||
"sort": { "customer_name": "Kundenname", "customer_number": "Kundennummer", "priority": "Priorität zuerst", "total_amount": "Betrag" },
|
||||
"states": { "attention": "Aufmerksamkeit erforderlich", "blocked": "Blockiert", "completed": "Abgeschlossen", "empty_body": "Wählen Sie einen anderen Zeitraum oder eine andere Ansicht.", "empty_title": "Keine Rechnungsdaten in diesem Zeitraum", "error_body": "Laden Sie den Zeitraum erneut.", "error_title": "Der Rechnungszeitraum konnte nicht geladen werden", "filtered_empty_body": "Setzen Sie die Filter zurück oder suchen Sie erneut.", "filtered_empty_title": "Keine Kunden entsprechen den Filtern", "loading_body": "Kunden, Rechnungssammlungen und Prüfpunkte werden gesammelt…", "loading_title": "Rechnungszeitraum wird geladen", "queued": "Eingereiht", "ready": "Bereit zur Abrechnung", "refreshing": "Aktuelles Ergebnis wird aktualisiert…", "retained_error": "Die Aktualisierung ist fehlgeschlagen. Das zuletzt geladene Ergebnis bleibt sichtbar." },
|
||||
"toolbar": { "all_departments": "Alle Abteilungen", "all_invoice_states": "Alle Rechnungsstatus", "all_severities": "Alle Stufen", "all_states": "Alle Zustände", "change_direction": "Sortierrichtung ändern", "clear_filters": "Filter löschen", "department": "Abteilung", "invoice_state": "Rechnungsstatus", "reload": "Neu laden", "retry": "Erneut versuchen", "search": "Kunden und Rechnungsdaten durchsuchen", "severity": "Schweregrad", "sort": "Sortieren", "workflow": "Arbeitsablauf" }
|
||||
} } }
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"review_workspace": {
|
||||
"analytics": {
|
||||
"title": "Übersicht und Kennzahlen"
|
||||
},
|
||||
"detail": {
|
||||
"next": "Nächster zu prüfender Kunde",
|
||||
"open_prompt": "Wählen Sie den Kunden in der Warteschlange, um die Rechnungsdetails zu öffnen.",
|
||||
"previous": "Vorheriger zu prüfender Kunde",
|
||||
"selected_customer": "Ausgewählter Kunde"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "Jetzt fakturieren fehlgeschlagen",
|
||||
"fakturer_nu_failed_body": "Rechnung für diesen Kunden konnte nicht erstellt werden. Erneut versuchen oder Transaktionen prüfen."
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "Kennzahlen des Zeitraums",
|
||||
"booked": "Gebucht",
|
||||
"not_booked": "Nicht gebucht",
|
||||
"total": "Gesamt"
|
||||
},
|
||||
"navigation": {
|
||||
"agreements": "Vereinbarungen",
|
||||
"aria_label": "Geschäftsansichten",
|
||||
"billing": "Rechnungsstellung",
|
||||
"operations": "Betrieb",
|
||||
"review": "Prüfung"
|
||||
},
|
||||
"next_actions": {
|
||||
"create_invoice": "Rechnung erstellen",
|
||||
"none": "Keine Aktion erforderlich",
|
||||
"resolve_collection_errors": "Sammlungsfehler beheben",
|
||||
"resolve_draft": "Entwurf klären",
|
||||
"resolve_manual_flags": "Manuelle Markierungen klären",
|
||||
"review_warnings": "Warnungen prüfen",
|
||||
"wait_for_export": "Auf Export warten"
|
||||
},
|
||||
"queue": {
|
||||
"count": "{count} Kunden",
|
||||
"title": "Zu prüfende Kunden"
|
||||
},
|
||||
"reasons": {
|
||||
"automatic_flags": "Automatische Warnungen",
|
||||
"automatic_warnings": "Automatische Warnungen",
|
||||
"collection_errors": "Fehler in Rechnungssammlung",
|
||||
"completed": "Alles verbucht",
|
||||
"draft": "Entwurf blockiert",
|
||||
"draft_blocks_action": "Entwurf blockiert",
|
||||
"export_in_progress": "Export läuft",
|
||||
"manual_flags": "Manuelle Markierungen erfordern Aktion",
|
||||
"queued": "Rechnung ist eingereiht",
|
||||
"ready_to_invoice": "Bereit zur Abrechnung",
|
||||
"requires_action": "Prüfung erforderlich",
|
||||
"unbooked_transactions": "Nicht gebuchte Transaktionen"
|
||||
},
|
||||
"severities": {
|
||||
"blue": "Eingereiht",
|
||||
"green": "Bereit",
|
||||
"red": "Blockiert",
|
||||
"yellow": "Warnung"
|
||||
},
|
||||
"sort": {
|
||||
"customer_name": "Kundenname",
|
||||
"customer_number": "Kundennummer",
|
||||
"priority": "Priorität zuerst",
|
||||
"total_amount": "Betrag"
|
||||
},
|
||||
"states": {
|
||||
"attention": "Aufmerksamkeit erforderlich",
|
||||
"blocked": "Blockiert",
|
||||
"completed": "Abgeschlossen",
|
||||
"empty_body": "Wählen Sie einen anderen Zeitraum oder eine andere Ansicht.",
|
||||
"empty_title": "Keine Rechnungsdaten in diesem Zeitraum",
|
||||
"error_body": "Laden Sie den Zeitraum erneut.",
|
||||
"error_title": "Der Rechnungszeitraum konnte nicht geladen werden",
|
||||
"filtered_empty_body": "Setzen Sie die Filter zurück oder suchen Sie erneut.",
|
||||
"filtered_empty_title": "Keine Kunden entsprechen den Filtern",
|
||||
"loading_body": "Kunden, Rechnungssammlungen und Prüfpunkte werden gesammelt…",
|
||||
"loading_title": "Rechnungszeitraum wird geladen",
|
||||
"queued": "Eingereiht",
|
||||
"ready": "Bereit zur Abrechnung",
|
||||
"refreshing": "Aktuelles Ergebnis wird aktualisiert…",
|
||||
"retained_error": "Die Aktualisierung ist fehlgeschlagen. Das zuletzt geladene Ergebnis bleibt sichtbar."
|
||||
},
|
||||
"toolbar": {
|
||||
"all_departments": "Alle Abteilungen",
|
||||
"all_invoice_states": "Alle Rechnungsstatus",
|
||||
"all_severities": "Alle Stufen",
|
||||
"all_states": "Alle Zustände",
|
||||
"change_direction": "Sortierrichtung ändern",
|
||||
"clear_filters": "Filter löschen",
|
||||
"department": "Abteilung",
|
||||
"invoice_state": "Rechnungsstatus",
|
||||
"reload": "Neu laden",
|
||||
"retry": "Erneut versuchen",
|
||||
"search": "Kunden und Rechnungsdaten durchsuchen",
|
||||
"severity": "Schweregrad",
|
||||
"sort": "Sortieren",
|
||||
"workflow": "Arbeitsablauf"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Vorschlag annehmen",
|
||||
"attach_order": "Auftrag verknüpfen",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -2,17 +2,104 @@
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"review_workspace": {
|
||||
"analytics": { "title": "Overview and key figures" },
|
||||
"detail": { "next": "Next customer needing review", "open_prompt": "Select the customer in the queue to open invoice details.", "previous": "Previous customer needing review", "selected_customer": "Selected customer" },
|
||||
"headline": { "aria_label": "Period headline figures", "booked": "Booked", "not_booked": "Not booked", "total": "Total" },
|
||||
"navigation": { "agreements": "Agreements", "aria_label": "Business views", "billing": "Invoicing", "operations": "Operations", "review": "Review" },
|
||||
"next_actions": { "create_invoice": "Create invoice", "none": "No action needed", "resolve_collection_errors": "Fix collection errors", "resolve_draft": "Resolve draft", "resolve_manual_flags": "Resolve manual flags", "review_warnings": "Review warnings", "wait_for_export": "Wait for export" },
|
||||
"queue": { "count": "{count} customers", "title": "Customers to review" },
|
||||
"reasons": { "automatic_flags": "Automatic warnings", "automatic_warnings": "Automatic warnings", "collection_errors": "Invoice collection errors", "completed": "Everything is booked", "draft": "Draft is blocking", "draft_blocks_action": "Draft is blocking", "export_in_progress": "Export is in progress", "manual_flags": "Manual flags need action", "queued": "Invoice is queued", "ready_to_invoice": "Ready to invoice", "requires_action": "Needs review", "unbooked_transactions": "Unbooked transactions" },
|
||||
"severities": { "blue": "Queued", "green": "Ready", "red": "Blocked", "yellow": "Warning" },
|
||||
"sort": { "customer_name": "Customer name", "customer_number": "Customer number", "priority": "Priority first", "total_amount": "Amount" },
|
||||
"states": { "attention": "Needs attention", "blocked": "Blocked", "completed": "Completed", "empty_body": "Choose another period or business view.", "empty_title": "No invoice data in this period", "error_body": "Try reloading the period.", "error_title": "The invoice period could not be loaded", "filtered_empty_body": "Reset the workflow filters or try another search.", "filtered_empty_title": "No customers match the filters", "loading_body": "Collecting customers, invoice collections, and review points…", "loading_title": "Loading invoice period", "queued": "Queued", "ready": "Ready to invoice", "refreshing": "Refreshing the current result…", "retained_error": "The refresh failed. The most recently loaded result is still shown." },
|
||||
"toolbar": { "all_departments": "All departments", "all_invoice_states": "All invoice states", "all_severities": "All levels", "all_states": "All states", "change_direction": "Change sort direction", "clear_filters": "Clear filters", "department": "Department", "invoice_state": "Invoice state", "reload": "Reload", "retry": "Try again", "search": "Search customers and invoice data", "severity": "Severity", "sort": "Sort", "workflow": "Workflow" }
|
||||
"analytics": {
|
||||
"title": "Overview and key figures"
|
||||
},
|
||||
"detail": {
|
||||
"next": "Next customer needing review",
|
||||
"open_prompt": "Select the customer in the queue to open invoice details.",
|
||||
"previous": "Previous customer needing review",
|
||||
"selected_customer": "Selected customer"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "Invoice now failed",
|
||||
"fakturer_nu_failed_body": "Could not create invoice for this customer. Try again, or check the customer's transactions."
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "Period headline figures",
|
||||
"booked": "Booked",
|
||||
"not_booked": "Not booked",
|
||||
"total": "Total"
|
||||
},
|
||||
"navigation": {
|
||||
"agreements": "Agreements",
|
||||
"aria_label": "Business views",
|
||||
"billing": "Invoicing",
|
||||
"operations": "Operations",
|
||||
"review": "Review"
|
||||
},
|
||||
"next_actions": {
|
||||
"create_invoice": "Create invoice",
|
||||
"none": "No action needed",
|
||||
"resolve_collection_errors": "Fix collection errors",
|
||||
"resolve_draft": "Resolve draft",
|
||||
"resolve_manual_flags": "Resolve manual flags",
|
||||
"review_warnings": "Review warnings",
|
||||
"wait_for_export": "Wait for export"
|
||||
},
|
||||
"queue": {
|
||||
"count": "{count} customers",
|
||||
"title": "Customers to review"
|
||||
},
|
||||
"reasons": {
|
||||
"automatic_flags": "Automatic warnings",
|
||||
"automatic_warnings": "Automatic warnings",
|
||||
"collection_errors": "Invoice collection errors",
|
||||
"completed": "Everything is booked",
|
||||
"draft": "Draft is blocking",
|
||||
"draft_blocks_action": "Draft is blocking",
|
||||
"export_in_progress": "Export is in progress",
|
||||
"manual_flags": "Manual flags need action",
|
||||
"queued": "Invoice is queued",
|
||||
"ready_to_invoice": "Ready to invoice",
|
||||
"requires_action": "Needs review",
|
||||
"unbooked_transactions": "Unbooked transactions"
|
||||
},
|
||||
"severities": {
|
||||
"blue": "Queued",
|
||||
"green": "Ready",
|
||||
"red": "Blocked",
|
||||
"yellow": "Warning"
|
||||
},
|
||||
"sort": {
|
||||
"customer_name": "Customer name",
|
||||
"customer_number": "Customer number",
|
||||
"priority": "Priority first",
|
||||
"total_amount": "Amount"
|
||||
},
|
||||
"states": {
|
||||
"attention": "Needs attention",
|
||||
"blocked": "Blocked",
|
||||
"completed": "Completed",
|
||||
"empty_body": "Choose another period or business view.",
|
||||
"empty_title": "No invoice data in this period",
|
||||
"error_body": "Try reloading the period.",
|
||||
"error_title": "The invoice period could not be loaded",
|
||||
"filtered_empty_body": "Reset the workflow filters or try another search.",
|
||||
"filtered_empty_title": "No customers match the filters",
|
||||
"loading_body": "Collecting customers, invoice collections, and review points…",
|
||||
"loading_title": "Loading invoice period",
|
||||
"queued": "Queued",
|
||||
"ready": "Ready to invoice",
|
||||
"refreshing": "Refreshing the current result…",
|
||||
"retained_error": "The refresh failed. The most recently loaded result is still shown."
|
||||
},
|
||||
"toolbar": {
|
||||
"all_departments": "All departments",
|
||||
"all_invoice_states": "All invoice states",
|
||||
"all_severities": "All levels",
|
||||
"all_states": "All states",
|
||||
"change_direction": "Change sort direction",
|
||||
"clear_filters": "Clear filters",
|
||||
"department": "Department",
|
||||
"invoice_state": "Invoice state",
|
||||
"reload": "Reload",
|
||||
"retry": "Try again",
|
||||
"search": "Search customers and invoice data",
|
||||
"severity": "Severity",
|
||||
"sort": "Sort",
|
||||
"workflow": "Workflow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"compat": { "invoicing_period": { "xlvask_autopilot": {
|
||||
"compat": { "invoicing_period": { "xlvask_review": {
|
||||
"actions": { "accept": "Accept suggestion", "attach_order": "Attach order", "compare": "Compare candidates", "compare_modal_title": "Compare candidates with wash", "compare_no_candidates": "No candidates to compare", "compare_price_match": "Price matches", "compare_price_mismatch": "Price does not match", "compare_usage_price": "Computed wash price", "compare_candidate_price": "Candidate price", "create_order": "Create order", "deny": "Deny", "ignore": "Ignore", "link": "Link order ID", "link_prompt_label": "Enter order ID", "link_prompt_invalid": "Enter a valid numeric order ID", "link_prompt_title": "Link the wash to an existing order", "none": "No action", "recheck": "Check again", "resolve_mapping": "Fix mapping" },
|
||||
"adjudication": {
|
||||
"confirm_correct": "Confirm that the automatic action was correct.",
|
||||
@@ -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"
|
||||
}
|
||||
} } }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"configuration": {
|
||||
"secret_key_not_set": "@:{'phrases.compat.configuration.secret_key_not_set'}",
|
||||
"backups": {
|
||||
"active_jobs": "@:{'phrases.compat.configuration.backups.active_jobs'}",
|
||||
"actions": "@:{'phrases.compat.configuration.backups.actions'}",
|
||||
@@ -402,6 +403,7 @@
|
||||
"api_settings_desc": "@:{'phrases.compat.configuration.xlvask.api_settings_desc'}",
|
||||
"automation_settings": "@:{'phrases.compat.configuration.xlvask.automation_settings'}",
|
||||
"automation_settings_desc": "@:{'phrases.compat.configuration.xlvask.automation_settings_desc'}",
|
||||
"automation_settings_removed_desc": "Automatic attachment, automatic creation, OpenAI and MiniMax integrations have been removed. Operator review is now performed from the Superuser -> Fakturaer -> Periode -> Selvvask view.",
|
||||
"connection_failed": "@:{'phrases.compat.configuration.xlvask.connection_failed'}",
|
||||
"connection_failed_desc": "@:{'phrases.compat.configuration.xlvask.connection_failed_desc'}",
|
||||
"connection_success": "@:configuration.limble.connection_success",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
"previous": "@:{'phrases.compat.invoicing_period.review_workspace.detail.previous'}",
|
||||
"selected_customer": "@:{'phrases.compat.invoicing_period.review_workspace.detail.selected_customer'}"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "@:{'phrases.compat.invoicing_period.review_workspace.errors.fakturer_nu_failed_title'}",
|
||||
"fakturer_nu_failed_body": "@:{'phrases.compat.invoicing_period.review_workspace.errors.fakturer_nu_failed_body'}"
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "@:{'phrases.compat.invoicing_period.review_workspace.headline.aria_label'}",
|
||||
"booked": "@:{'phrases.compat.invoicing_period.review_workspace.headline.booked'}",
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
{
|
||||
"invoicing_period": {
|
||||
"xlvask_autopilot": {
|
||||
"actions": {
|
||||
"accept": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.accept'}",
|
||||
"attach_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.attach_order'}",
|
||||
"compare": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare'}",
|
||||
"compare_modal_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_modal_title'}",
|
||||
"compare_no_candidates": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_no_candidates'}",
|
||||
"compare_price_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_price_match'}",
|
||||
"compare_price_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_price_mismatch'}",
|
||||
"compare_usage_price": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_usage_price'}",
|
||||
"compare_candidate_price": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.compare_candidate_price'}",
|
||||
"create_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.create_order'}",
|
||||
"deny": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.deny'}",
|
||||
"ignore": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.ignore'}",
|
||||
"link": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link'}",
|
||||
"link_prompt_label": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_label'}",
|
||||
"link_prompt_invalid": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_invalid'}",
|
||||
"link_prompt_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.link_prompt_title'}",
|
||||
"none": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.none'}",
|
||||
"recheck": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.recheck'}",
|
||||
"resolve_mapping": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.actions.resolve_mapping'}"
|
||||
},
|
||||
"adjudication": {
|
||||
"confirm_correct": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_correct'}",
|
||||
"confirm_cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_cross_hall'}",
|
||||
"confirm_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_duplicate'}",
|
||||
"confirm_incorrect": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_incorrect'}",
|
||||
"confirm_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_title'}",
|
||||
"confirm_unaudited": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.confirm_unaudited'}",
|
||||
"description": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.description'}",
|
||||
"halted": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.halted'}",
|
||||
"outcomes": {
|
||||
"correct": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.correct'}",
|
||||
"cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.cross_hall'}",
|
||||
"duplicate": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.duplicate'}",
|
||||
"incorrect": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.incorrect'}",
|
||||
"unaudited": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.outcomes.unaudited'}"
|
||||
},
|
||||
"saved": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.saved'}",
|
||||
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.adjudication.title'}"
|
||||
},
|
||||
"audit": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.audit'}",
|
||||
"bulk_selected": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.bulk_selected'}",
|
||||
"bulk_eligibility": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.bulk_eligibility'}",
|
||||
"calibrated_probability": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.calibrated_probability'}",
|
||||
"candidates": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.candidates'}",
|
||||
"contradictions": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.contradictions'}",
|
||||
"controls": {
|
||||
"active_run_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.active_run_error'}",
|
||||
"analyze": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.analyze'}",
|
||||
"budget": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.budget'}",
|
||||
"execute": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute'}",
|
||||
"execute_description": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute_description'}",
|
||||
"execute_phrase": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute_phrase'}",
|
||||
"execute_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.execute_title'}",
|
||||
"halt": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.halt'}",
|
||||
"halt_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.halt_title'}",
|
||||
"load_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.load_error'}",
|
||||
"no_access": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.no_access'}",
|
||||
"not_ready": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.not_ready'}",
|
||||
"policy_advance": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_advance'}",
|
||||
"policy_apply": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_apply'}",
|
||||
"policy_description": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_description'}",
|
||||
"policy_phrase": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_phrase'}",
|
||||
"policy_reason": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_reason'}",
|
||||
"policy_reason_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_reason_title'}",
|
||||
"policy_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.policy_title'}",
|
||||
"readiness_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.readiness_error'}",
|
||||
"ready": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.ready'}",
|
||||
"stage": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.stage'}",
|
||||
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.title'}",
|
||||
"worker_healthy": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.worker_healthy'}",
|
||||
"worker_unhealthy": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.controls.worker_unhealthy'}"
|
||||
},
|
||||
"clear_selection": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.clear_selection'}",
|
||||
"evidence": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.evidence'}",
|
||||
"filters": {
|
||||
"all": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.all'}",
|
||||
"certainty": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.certainty'}",
|
||||
"clear": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.clear'}",
|
||||
"import_state": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.import_state'}",
|
||||
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.planned_action'}",
|
||||
"resolution_state": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.resolution_state'}",
|
||||
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.title'}",
|
||||
"unattached_only": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.filters.unattached_only'}"
|
||||
},
|
||||
"hide_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.hide_match'}",
|
||||
"model": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.model'}",
|
||||
"no_safe_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.no_safe_match'}",
|
||||
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.planned_action'}",
|
||||
"policy_version": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.policy_version'}",
|
||||
"preview": {
|
||||
"after": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.after'}",
|
||||
"applied": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.applied'}",
|
||||
"apply": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.apply'}",
|
||||
"before": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.before'}",
|
||||
"confirmation_label": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.confirmation_label'}",
|
||||
"confirmation_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.confirmation_mismatch'}",
|
||||
"confirmation_phrase": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.confirmation_phrase'}",
|
||||
"error_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.error_title'}",
|
||||
"reason_label": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.reason_label'}",
|
||||
"reason_required": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.reason_required'}",
|
||||
"title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.preview.title'}"
|
||||
},
|
||||
"resume_status": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.resume_status'}",
|
||||
"run": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run'}",
|
||||
"run_id": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_id'}",
|
||||
"run_phases": {
|
||||
"circuit_breaker": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.circuit_breaker'}",
|
||||
"completed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.completed'}",
|
||||
"completed_with_warnings": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.completed_with_warnings'}",
|
||||
"evaluating": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.evaluating'}",
|
||||
"executing": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.executing'}",
|
||||
"failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.failed'}",
|
||||
"importing": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.importing'}",
|
||||
"pending": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.pending'}",
|
||||
"processing": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.processing'}",
|
||||
"queued": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.queued'}",
|
||||
"reconciling": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.reconciling'}",
|
||||
"retry_wait": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.retry_wait'}",
|
||||
"running": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_phases.running'}"
|
||||
},
|
||||
"run_progress": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_progress'}",
|
||||
"run_start_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_start_error'}",
|
||||
"run_status_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.run_status_error'}",
|
||||
"select_record": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.select_record'}",
|
||||
"show_match": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.show_match'}",
|
||||
"source_revision": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.source_revision'}",
|
||||
"states": {
|
||||
"accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.accepted_attach'}",
|
||||
"accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.accepted_create'}",
|
||||
"already_linked": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.already_linked'}",
|
||||
"auto_accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_attach'}",
|
||||
"auto_accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_accepted_create'}",
|
||||
"auto_created": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_created'}",
|
||||
"auto_linked": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.auto_linked'}",
|
||||
"blocked": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.blocked'}",
|
||||
"certain": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.certain'}",
|
||||
"denied": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.denied'}",
|
||||
"failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.failed'}",
|
||||
"ignored": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.ignored'}",
|
||||
"invalid": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.invalid'}",
|
||||
"needs_review": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.needs_review'}",
|
||||
"new": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.new'}",
|
||||
"none": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.none'}",
|
||||
"suggested_attach_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.suggested_attach_order'}",
|
||||
"suggested_create_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.suggested_create_order'}",
|
||||
"uncertain": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.uncertain'}",
|
||||
"unchanged": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.unchanged'}",
|
||||
"updated": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.states.updated'}"
|
||||
},
|
||||
"summary_error": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.summary_error'}",
|
||||
"summary_title": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.summary_title'}",
|
||||
"labels": {
|
||||
"attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.attached_to_order'}",
|
||||
"not_attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.not_attached_to_order'}",
|
||||
"compare_select_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.compare_select_duplicate'}",
|
||||
"compare_price_mismatch_inline": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.compare_price_mismatch_inline'}",
|
||||
"wash_id": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.wash_id'}",
|
||||
"wash_already_withdrawn": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.wash_already_withdrawn'}",
|
||||
"go_to_order": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.go_to_order'}",
|
||||
"unknown_product": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.unknown_product'}",
|
||||
"unknown_product_with_id": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.unknown_product_with_id'}",
|
||||
"ok": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.labels.ok'}"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.just_now'}",
|
||||
"ago_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.ago_one'}",
|
||||
"ago_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.ago_other'}",
|
||||
"units": {
|
||||
"year_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.year_one'}",
|
||||
"year_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.year_other'}",
|
||||
"month_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.month_one'}",
|
||||
"month_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.month_other'}",
|
||||
"week_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.week_one'}",
|
||||
"week_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.week_other'}",
|
||||
"day_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.day_one'}",
|
||||
"day_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.day_other'}",
|
||||
"hour_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.hour_one'}",
|
||||
"hour_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.hour_other'}",
|
||||
"minute_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.minute_one'}",
|
||||
"minute_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.minute_other'}",
|
||||
"second_one": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.second_one'}",
|
||||
"second_other": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.time.units.second_other'}"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fetch_usage_log": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_usage_log'}",
|
||||
"fetch_vehicle_types": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'}",
|
||||
"fetch_related_orders": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_related_orders'}",
|
||||
"fetch_fast_link": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.fetch_fast_link'}",
|
||||
"create_order_unrecognized_items": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.create_order_unrecognized_items'}",
|
||||
"create_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.create_order_failed'}",
|
||||
"create_order_item_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.create_order_item_failed'}",
|
||||
"redirect_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.redirect_order_failed'}",
|
||||
"load_customers_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.load_customers_failed'}",
|
||||
"load_usage_log_failed": "@:{'phrases.compat.invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"invoicing_period": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.accept'}",
|
||||
"attach_order": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.attach_order'}",
|
||||
"compare": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare'}",
|
||||
"compare_modal_title": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_modal_title'}",
|
||||
"compare_no_candidates": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_no_candidates'}",
|
||||
"compare_price_match": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_price_match'}",
|
||||
"compare_price_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_price_mismatch'}",
|
||||
"compare_usage_price": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_usage_price'}",
|
||||
"compare_candidate_price": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.compare_candidate_price'}",
|
||||
"create_order": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.create_order'}",
|
||||
"deny": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.deny'}",
|
||||
"ignore": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.ignore'}",
|
||||
"link": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link'}",
|
||||
"link_prompt_label": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link_prompt_label'}",
|
||||
"link_prompt_invalid": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link_prompt_invalid'}",
|
||||
"link_prompt_title": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.link_prompt_title'}",
|
||||
"none": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.none'}",
|
||||
"recheck": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.recheck'}",
|
||||
"resolve_mapping": "@:{'phrases.compat.invoicing_period.xlvask_review.actions.resolve_mapping'}"
|
||||
},
|
||||
"adjudication": {
|
||||
"confirm_correct": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_correct'}",
|
||||
"confirm_cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_cross_hall'}",
|
||||
"confirm_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_duplicate'}",
|
||||
"confirm_incorrect": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_incorrect'}",
|
||||
"confirm_title": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_title'}",
|
||||
"confirm_unaudited": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.confirm_unaudited'}",
|
||||
"description": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.description'}",
|
||||
"halted": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.halted'}",
|
||||
"outcomes": {
|
||||
"correct": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.correct'}",
|
||||
"cross_hall": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.cross_hall'}",
|
||||
"duplicate": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.duplicate'}",
|
||||
"incorrect": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.incorrect'}",
|
||||
"unaudited": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.outcomes.unaudited'}"
|
||||
},
|
||||
"saved": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.saved'}",
|
||||
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.adjudication.title'}"
|
||||
},
|
||||
"audit": "@:{'phrases.compat.invoicing_period.xlvask_review.audit'}",
|
||||
"bulk_selected": "@:{'phrases.compat.invoicing_period.xlvask_review.bulk_selected'}",
|
||||
"bulk_eligibility": "@:{'phrases.compat.invoicing_period.xlvask_review.bulk_eligibility'}",
|
||||
"calibrated_probability": "@:{'phrases.compat.invoicing_period.xlvask_review.calibrated_probability'}",
|
||||
"candidates": "@:{'phrases.compat.invoicing_period.xlvask_review.candidates'}",
|
||||
"contradictions": "@:{'phrases.compat.invoicing_period.xlvask_review.contradictions'}",
|
||||
"controls": {
|
||||
"active_run_error": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.active_run_error'}",
|
||||
"analyze": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.analyze'}",
|
||||
"budget": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.budget'}",
|
||||
"execute": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute'}",
|
||||
"execute_description": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute_description'}",
|
||||
"execute_phrase": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute_phrase'}",
|
||||
"execute_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.execute_title'}",
|
||||
"halt": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.halt'}",
|
||||
"halt_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.halt_title'}",
|
||||
"load_error": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.load_error'}",
|
||||
"no_access": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.no_access'}",
|
||||
"not_ready": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.not_ready'}",
|
||||
"policy_advance": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_advance'}",
|
||||
"policy_apply": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_apply'}",
|
||||
"policy_description": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_description'}",
|
||||
"policy_phrase": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_phrase'}",
|
||||
"policy_reason": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_reason'}",
|
||||
"policy_reason_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_reason_title'}",
|
||||
"policy_title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.policy_title'}",
|
||||
"readiness_error": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.readiness_error'}",
|
||||
"ready": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.ready'}",
|
||||
"stage": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.stage'}",
|
||||
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.title'}",
|
||||
"worker_healthy": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.worker_healthy'}",
|
||||
"worker_unhealthy": "@:{'phrases.compat.invoicing_period.xlvask_review.controls.worker_unhealthy'}"
|
||||
},
|
||||
"clear_selection": "@:{'phrases.compat.invoicing_period.xlvask_review.clear_selection'}",
|
||||
"evidence": "@:{'phrases.compat.invoicing_period.xlvask_review.evidence'}",
|
||||
"filters": {
|
||||
"all": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.all'}",
|
||||
"certainty": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.certainty'}",
|
||||
"clear": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.clear'}",
|
||||
"import_state": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.import_state'}",
|
||||
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.planned_action'}",
|
||||
"resolution_state": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.resolution_state'}",
|
||||
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.title'}",
|
||||
"unattached_only": "@:{'phrases.compat.invoicing_period.xlvask_review.filters.unattached_only'}"
|
||||
},
|
||||
"hide_match": "@:{'phrases.compat.invoicing_period.xlvask_review.hide_match'}",
|
||||
"model": "@:{'phrases.compat.invoicing_period.xlvask_review.model'}",
|
||||
"no_safe_match": "@:{'phrases.compat.invoicing_period.xlvask_review.no_safe_match'}",
|
||||
"planned_action": "@:{'phrases.compat.invoicing_period.xlvask_review.planned_action'}",
|
||||
"policy_version": "@:{'phrases.compat.invoicing_period.xlvask_review.policy_version'}",
|
||||
"preview": {
|
||||
"after": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.after'}",
|
||||
"applied": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.applied'}",
|
||||
"apply": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.apply'}",
|
||||
"before": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.before'}",
|
||||
"confirmation_label": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.confirmation_label'}",
|
||||
"confirmation_mismatch": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.confirmation_mismatch'}",
|
||||
"confirmation_phrase": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.confirmation_phrase'}",
|
||||
"error_title": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.error_title'}",
|
||||
"reason_label": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.reason_label'}",
|
||||
"reason_required": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.reason_required'}",
|
||||
"title": "@:{'phrases.compat.invoicing_period.xlvask_review.preview.title'}"
|
||||
},
|
||||
"resume_status": "@:{'phrases.compat.invoicing_period.xlvask_review.resume_status'}",
|
||||
"run": "@:{'phrases.compat.invoicing_period.xlvask_review.run'}",
|
||||
"run_id": "@:{'phrases.compat.invoicing_period.xlvask_review.run_id'}",
|
||||
"run_phases": {
|
||||
"circuit_breaker": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.circuit_breaker'}",
|
||||
"completed": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.completed'}",
|
||||
"completed_with_warnings": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.completed_with_warnings'}",
|
||||
"evaluating": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.evaluating'}",
|
||||
"executing": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.executing'}",
|
||||
"failed": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.failed'}",
|
||||
"importing": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.importing'}",
|
||||
"pending": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.pending'}",
|
||||
"processing": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.processing'}",
|
||||
"queued": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.queued'}",
|
||||
"reconciling": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.reconciling'}",
|
||||
"retry_wait": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.retry_wait'}",
|
||||
"running": "@:{'phrases.compat.invoicing_period.xlvask_review.run_phases.running'}"
|
||||
},
|
||||
"run_progress": "@:{'phrases.compat.invoicing_period.xlvask_review.run_progress'}",
|
||||
"run_start_error": "@:{'phrases.compat.invoicing_period.xlvask_review.run_start_error'}",
|
||||
"run_status_error": "@:{'phrases.compat.invoicing_period.xlvask_review.run_status_error'}",
|
||||
"select_record": "@:{'phrases.compat.invoicing_period.xlvask_review.select_record'}",
|
||||
"show_match": "@:{'phrases.compat.invoicing_period.xlvask_review.show_match'}",
|
||||
"source_revision": "@:{'phrases.compat.invoicing_period.xlvask_review.source_revision'}",
|
||||
"states": {
|
||||
"accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_review.states.accepted_attach'}",
|
||||
"accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_review.states.accepted_create'}",
|
||||
"already_linked": "@:{'phrases.compat.invoicing_period.xlvask_review.states.already_linked'}",
|
||||
"auto_accepted_attach": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_accepted_attach'}",
|
||||
"auto_accepted_create": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_accepted_create'}",
|
||||
"auto_created": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_created'}",
|
||||
"auto_linked": "@:{'phrases.compat.invoicing_period.xlvask_review.states.auto_linked'}",
|
||||
"blocked": "@:{'phrases.compat.invoicing_period.xlvask_review.states.blocked'}",
|
||||
"certain": "@:{'phrases.compat.invoicing_period.xlvask_review.states.certain'}",
|
||||
"denied": "@:{'phrases.compat.invoicing_period.xlvask_review.states.denied'}",
|
||||
"failed": "@:{'phrases.compat.invoicing_period.xlvask_review.states.failed'}",
|
||||
"ignored": "@:{'phrases.compat.invoicing_period.xlvask_review.states.ignored'}",
|
||||
"invalid": "@:{'phrases.compat.invoicing_period.xlvask_review.states.invalid'}",
|
||||
"needs_review": "@:{'phrases.compat.invoicing_period.xlvask_review.states.needs_review'}",
|
||||
"new": "@:{'phrases.compat.invoicing_period.xlvask_review.states.new'}",
|
||||
"none": "@:{'phrases.compat.invoicing_period.xlvask_review.states.none'}",
|
||||
"suggested_attach_order": "@:{'phrases.compat.invoicing_period.xlvask_review.states.suggested_attach_order'}",
|
||||
"suggested_create_order": "@:{'phrases.compat.invoicing_period.xlvask_review.states.suggested_create_order'}",
|
||||
"uncertain": "@:{'phrases.compat.invoicing_period.xlvask_review.states.uncertain'}",
|
||||
"unchanged": "@:{'phrases.compat.invoicing_period.xlvask_review.states.unchanged'}",
|
||||
"updated": "@:{'phrases.compat.invoicing_period.xlvask_review.states.updated'}"
|
||||
},
|
||||
"summary_error": "@:{'phrases.compat.invoicing_period.xlvask_review.summary_error'}",
|
||||
"summary_title": "@:{'phrases.compat.invoicing_period.xlvask_review.summary_title'}",
|
||||
"labels": {
|
||||
"attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.attached_to_order'}",
|
||||
"not_attached_to_order": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.not_attached_to_order'}",
|
||||
"compare_select_duplicate": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.compare_select_duplicate'}",
|
||||
"compare_price_mismatch_inline": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.compare_price_mismatch_inline'}",
|
||||
"wash_id": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.wash_id'}",
|
||||
"wash_already_withdrawn": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.wash_already_withdrawn'}",
|
||||
"go_to_order": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.go_to_order'}",
|
||||
"unknown_product": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.unknown_product'}",
|
||||
"unknown_product_with_id": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.unknown_product_with_id'}",
|
||||
"ok": "@:{'phrases.compat.invoicing_period.xlvask_review.labels.ok'}"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "@:{'phrases.compat.invoicing_period.xlvask_review.time.just_now'}",
|
||||
"ago_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.ago_one'}",
|
||||
"ago_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.ago_other'}",
|
||||
"units": {
|
||||
"year_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.year_one'}",
|
||||
"year_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.year_other'}",
|
||||
"month_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.month_one'}",
|
||||
"month_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.month_other'}",
|
||||
"week_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.week_one'}",
|
||||
"week_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.week_other'}",
|
||||
"day_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.day_one'}",
|
||||
"day_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.day_other'}",
|
||||
"hour_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.hour_one'}",
|
||||
"hour_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.hour_other'}",
|
||||
"minute_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.minute_one'}",
|
||||
"minute_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.minute_other'}",
|
||||
"second_one": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.second_one'}",
|
||||
"second_other": "@:{'phrases.compat.invoicing_period.xlvask_review.time.units.second_other'}"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"fetch_usage_log": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_usage_log'}",
|
||||
"fetch_vehicle_types": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_vehicle_types'}",
|
||||
"fetch_related_orders": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_related_orders'}",
|
||||
"fetch_fast_link": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.fetch_fast_link'}",
|
||||
"create_order_unrecognized_items": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.create_order_unrecognized_items'}",
|
||||
"create_order_failed": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.create_order_failed'}",
|
||||
"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'}",
|
||||
"api_endpoint_not_implemented": "@:{'phrases.compat.invoicing_period.xlvask_review.errors.api_endpoint_not_implemented'}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,15 +1,106 @@
|
||||
{
|
||||
"compat": { "invoicing_period": { "review_workspace": {
|
||||
"analytics": { "title": "Oversikt og nøkkeltall" },
|
||||
"detail": { "next": "Neste kunde til gjennomgang", "open_prompt": "Velg kunden i køen for å åpne fakturadetaljene.", "previous": "Forrige kunde til gjennomgang", "selected_customer": "Valgt kunde" },
|
||||
"headline": { "aria_label": "Periodens hovedtall", "booked": "Bokført", "not_booked": "Ikke bokført", "total": "Totalt" },
|
||||
"navigation": { "agreements": "Avtaler", "aria_label": "Forretningsvisninger", "billing": "Fakturering", "operations": "Drift", "review": "Gjennomgang" },
|
||||
"next_actions": { "create_invoice": "Opprett faktura", "none": "Ingen handling nødvendig", "resolve_collection_errors": "Rett feil i fakturasamling", "resolve_draft": "Avklar utkast", "resolve_manual_flags": "Avklar manuelle flagg", "review_warnings": "Gjennomgå advarsler", "wait_for_export": "Vent på eksport" },
|
||||
"queue": { "count": "{count} kunder", "title": "Kunder til gjennomgang" },
|
||||
"reasons": { "automatic_flags": "Automatiske advarsler", "automatic_warnings": "Automatiske advarsler", "collection_errors": "Feil i fakturasamling", "completed": "Alt er bokført", "draft": "Utkast blokkerer", "draft_blocks_action": "Utkast blokkerer", "export_in_progress": "Eksport pågår", "manual_flags": "Manuelle flagg krever handling", "queued": "Fakturaen er i kø", "ready_to_invoice": "Klar til fakturering", "requires_action": "Krever gjennomgang", "unbooked_transactions": "Ikke-bokførte transaksjoner" },
|
||||
"severities": { "blue": "I kø", "green": "Klar", "red": "Blokkert", "yellow": "Advarsel" },
|
||||
"sort": { "customer_name": "Kundenavn", "customer_number": "Kundenummer", "priority": "Viktigst først", "total_amount": "Beløp" },
|
||||
"states": { "attention": "Krever oppmerksomhet", "blocked": "Blokkert", "completed": "Ferdig", "empty_body": "Velg en annen periode eller forretningsvisning.", "empty_title": "Ingen fakturadata i perioden", "error_body": "Prøv å laste perioden på nytt.", "error_title": "Fakturaperioden kunne ikke lastes", "filtered_empty_body": "Nullstill arbeidsflytfiltrene eller prøv et annet søk.", "filtered_empty_title": "Ingen kunder samsvarer med filtrene", "loading_body": "Samler kunder, fakturasamlinger og kontrollpunkter…", "loading_title": "Laster fakturaperioden", "queued": "I kø", "ready": "Klar til fakturering", "refreshing": "Oppdaterer eksisterende resultat…", "retained_error": "Oppdateringen mislyktes. Det sist innlastede resultatet vises fortsatt." },
|
||||
"toolbar": { "all_departments": "Alle avdelinger", "all_invoice_states": "Alle fakturastatuser", "all_severities": "Alle nivåer", "all_states": "Alle tilstander", "change_direction": "Endre sorteringsretning", "clear_filters": "Tøm filtre", "department": "Avdeling", "invoice_state": "Fakturastatus", "reload": "Last på nytt", "retry": "Prøv igjen", "search": "Søk i kunder og fakturadata", "severity": "Alvor", "sort": "Sorter", "workflow": "Arbeidsflyt" }
|
||||
} } }
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"review_workspace": {
|
||||
"analytics": {
|
||||
"title": "Oversikt og nøkkeltall"
|
||||
},
|
||||
"detail": {
|
||||
"next": "Neste kunde til gjennomgang",
|
||||
"open_prompt": "Velg kunden i køen for å åpne fakturadetaljene.",
|
||||
"previous": "Forrige kunde til gjennomgang",
|
||||
"selected_customer": "Valgt kunde"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "Fakturer nå mislyktes",
|
||||
"fakturer_nu_failed_body": "Kunne ikke opprette faktura for denne kunden. Prøv igjen, eller sjekk kundens transaksjoner."
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "Periodens hovedtall",
|
||||
"booked": "Bokført",
|
||||
"not_booked": "Ikke bokført",
|
||||
"total": "Totalt"
|
||||
},
|
||||
"navigation": {
|
||||
"agreements": "Avtaler",
|
||||
"aria_label": "Forretningsvisninger",
|
||||
"billing": "Fakturering",
|
||||
"operations": "Drift",
|
||||
"review": "Gjennomgang"
|
||||
},
|
||||
"next_actions": {
|
||||
"create_invoice": "Opprett faktura",
|
||||
"none": "Ingen handling nødvendig",
|
||||
"resolve_collection_errors": "Rett feil i fakturasamling",
|
||||
"resolve_draft": "Avklar utkast",
|
||||
"resolve_manual_flags": "Avklar manuelle flagg",
|
||||
"review_warnings": "Gjennomgå advarsler",
|
||||
"wait_for_export": "Vent på eksport"
|
||||
},
|
||||
"queue": {
|
||||
"count": "{count} kunder",
|
||||
"title": "Kunder til gjennomgang"
|
||||
},
|
||||
"reasons": {
|
||||
"automatic_flags": "Automatiske advarsler",
|
||||
"automatic_warnings": "Automatiske advarsler",
|
||||
"collection_errors": "Feil i fakturasamling",
|
||||
"completed": "Alt er bokført",
|
||||
"draft": "Utkast blokkerer",
|
||||
"draft_blocks_action": "Utkast blokkerer",
|
||||
"export_in_progress": "Eksport pågår",
|
||||
"manual_flags": "Manuelle flagg krever handling",
|
||||
"queued": "Fakturaen er i kø",
|
||||
"ready_to_invoice": "Klar til fakturering",
|
||||
"requires_action": "Krever gjennomgang",
|
||||
"unbooked_transactions": "Ikke-bokførte transaksjoner"
|
||||
},
|
||||
"severities": {
|
||||
"blue": "I kø",
|
||||
"green": "Klar",
|
||||
"red": "Blokkert",
|
||||
"yellow": "Advarsel"
|
||||
},
|
||||
"sort": {
|
||||
"customer_name": "Kundenavn",
|
||||
"customer_number": "Kundenummer",
|
||||
"priority": "Viktigst først",
|
||||
"total_amount": "Beløp"
|
||||
},
|
||||
"states": {
|
||||
"attention": "Krever oppmerksomhet",
|
||||
"blocked": "Blokkert",
|
||||
"completed": "Ferdig",
|
||||
"empty_body": "Velg en annen periode eller forretningsvisning.",
|
||||
"empty_title": "Ingen fakturadata i perioden",
|
||||
"error_body": "Prøv å laste perioden på nytt.",
|
||||
"error_title": "Fakturaperioden kunne ikke lastes",
|
||||
"filtered_empty_body": "Nullstill arbeidsflytfiltrene eller prøv et annet søk.",
|
||||
"filtered_empty_title": "Ingen kunder samsvarer med filtrene",
|
||||
"loading_body": "Samler kunder, fakturasamlinger og kontrollpunkter…",
|
||||
"loading_title": "Laster fakturaperioden",
|
||||
"queued": "I kø",
|
||||
"ready": "Klar til fakturering",
|
||||
"refreshing": "Oppdaterer eksisterende resultat…",
|
||||
"retained_error": "Oppdateringen mislyktes. Det sist innlastede resultatet vises fortsatt."
|
||||
},
|
||||
"toolbar": {
|
||||
"all_departments": "Alle avdelinger",
|
||||
"all_invoice_states": "Alle fakturastatuser",
|
||||
"all_severities": "Alle nivåer",
|
||||
"all_states": "Alle tilstander",
|
||||
"change_direction": "Endre sorteringsretning",
|
||||
"clear_filters": "Tøm filtre",
|
||||
"department": "Avdeling",
|
||||
"invoice_state": "Fakturastatus",
|
||||
"reload": "Last på nytt",
|
||||
"retry": "Prøv igjen",
|
||||
"search": "Søk i kunder og fakturadata",
|
||||
"severity": "Alvor",
|
||||
"sort": "Sorter",
|
||||
"workflow": "Arbeidsflyt"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Godta forslag",
|
||||
"attach_order": "Knytt til ordre",
|
||||
@@ -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å"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"configuration": {
|
||||
"secret_key_not_set": "The secret key is not set, please set it before enabling the integration.",
|
||||
"backups": {
|
||||
"active_jobs": "Active jobs",
|
||||
"actions": "Actions",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,15 +1,106 @@
|
||||
{
|
||||
"compat": { "invoicing_period": { "review_workspace": {
|
||||
"analytics": { "title": "Översikt och nyckeltal" },
|
||||
"detail": { "next": "Nästa kund att granska", "open_prompt": "Välj kunden i kön för att öppna fakturadetaljerna.", "previous": "Föregående kund att granska", "selected_customer": "Vald kund" },
|
||||
"headline": { "aria_label": "Periodens huvudtal", "booked": "Bokfört", "not_booked": "Inte bokfört", "total": "Totalt" },
|
||||
"navigation": { "agreements": "Avtal", "aria_label": "Affärsvyer", "billing": "Fakturering", "operations": "Drift", "review": "Granskning" },
|
||||
"next_actions": { "create_invoice": "Skapa faktura", "none": "Ingen åtgärd behövs", "resolve_collection_errors": "Rätta samlingsfel", "resolve_draft": "Hantera utkast", "resolve_manual_flags": "Hantera manuella flaggor", "review_warnings": "Granska varningar", "wait_for_export": "Vänta på export" },
|
||||
"queue": { "count": "{count} kunder", "title": "Kunder att granska" },
|
||||
"reasons": { "automatic_flags": "Automatiska varningar", "automatic_warnings": "Automatiska varningar", "collection_errors": "Fel i fakturasamling", "completed": "Allt är bokfört", "draft": "Utkast blockerar", "draft_blocks_action": "Utkast blockerar", "export_in_progress": "Export pågår", "manual_flags": "Manuella flaggor kräver åtgärd", "queued": "Fakturan ligger i kö", "ready_to_invoice": "Klar för fakturering", "requires_action": "Behöver granskas", "unbooked_transactions": "Ej bokförda transaktioner" },
|
||||
"severities": { "blue": "I kö", "green": "Klar", "red": "Blockerad", "yellow": "Varning" },
|
||||
"sort": { "customer_name": "Kundnamn", "customer_number": "Kundnummer", "priority": "Viktigast först", "total_amount": "Belopp" },
|
||||
"states": { "attention": "Behöver uppmärksamhet", "blocked": "Blockerad", "completed": "Klar", "empty_body": "Välj en annan period eller affärsvy.", "empty_title": "Inga fakturadata i perioden", "error_body": "Försök ladda perioden igen.", "error_title": "Fakturaperioden kunde inte laddas", "filtered_empty_body": "Nollställ arbetsflödesfiltren eller prova en annan sökning.", "filtered_empty_title": "Inga kunder matchar filtren", "loading_body": "Samlar kunder, fakturasamlingar och kontrollpunkter…", "loading_title": "Laddar fakturaperiod", "queued": "I kö", "ready": "Klar för fakturering", "refreshing": "Uppdaterar befintligt resultat…", "retained_error": "Uppdateringen misslyckades. Det senast laddade resultatet visas fortfarande." },
|
||||
"toolbar": { "all_departments": "Alla avdelningar", "all_invoice_states": "Alla fakturastatusar", "all_severities": "Alla nivåer", "all_states": "Alla tillstånd", "change_direction": "Ändra sorteringsriktning", "clear_filters": "Rensa filter", "department": "Avdelning", "invoice_state": "Fakturastatus", "reload": "Ladda om", "retry": "Försök igen", "search": "Sök kunder och fakturadata", "severity": "Allvar", "sort": "Sortera", "workflow": "Arbetsflöde" }
|
||||
} } }
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"review_workspace": {
|
||||
"analytics": {
|
||||
"title": "Översikt och nyckeltal"
|
||||
},
|
||||
"detail": {
|
||||
"next": "Nästa kund att granska",
|
||||
"open_prompt": "Välj kunden i kön för att öppna fakturadetaljerna.",
|
||||
"previous": "Föregående kund att granska",
|
||||
"selected_customer": "Vald kund"
|
||||
},
|
||||
"errors": {
|
||||
"fakturer_nu_failed_title": "Fakturera nu misslyckades",
|
||||
"fakturer_nu_failed_body": "Kunde inte skapa faktura för denna kund. Försök igen, eller kontrollera kundens transaktioner."
|
||||
},
|
||||
"headline": {
|
||||
"aria_label": "Periodens huvudtal",
|
||||
"booked": "Bokfört",
|
||||
"not_booked": "Inte bokfört",
|
||||
"total": "Totalt"
|
||||
},
|
||||
"navigation": {
|
||||
"agreements": "Avtal",
|
||||
"aria_label": "Affärsvyer",
|
||||
"billing": "Fakturering",
|
||||
"operations": "Drift",
|
||||
"review": "Granskning"
|
||||
},
|
||||
"next_actions": {
|
||||
"create_invoice": "Skapa faktura",
|
||||
"none": "Ingen åtgärd behövs",
|
||||
"resolve_collection_errors": "Rätta samlingsfel",
|
||||
"resolve_draft": "Hantera utkast",
|
||||
"resolve_manual_flags": "Hantera manuella flaggor",
|
||||
"review_warnings": "Granska varningar",
|
||||
"wait_for_export": "Vänta på export"
|
||||
},
|
||||
"queue": {
|
||||
"count": "{count} kunder",
|
||||
"title": "Kunder att granska"
|
||||
},
|
||||
"reasons": {
|
||||
"automatic_flags": "Automatiska varningar",
|
||||
"automatic_warnings": "Automatiska varningar",
|
||||
"collection_errors": "Fel i fakturasamling",
|
||||
"completed": "Allt är bokfört",
|
||||
"draft": "Utkast blockerar",
|
||||
"draft_blocks_action": "Utkast blockerar",
|
||||
"export_in_progress": "Export pågår",
|
||||
"manual_flags": "Manuella flaggor kräver åtgärd",
|
||||
"queued": "Fakturan ligger i kö",
|
||||
"ready_to_invoice": "Klar för fakturering",
|
||||
"requires_action": "Behöver granskas",
|
||||
"unbooked_transactions": "Ej bokförda transaktioner"
|
||||
},
|
||||
"severities": {
|
||||
"blue": "I kö",
|
||||
"green": "Klar",
|
||||
"red": "Blockerad",
|
||||
"yellow": "Varning"
|
||||
},
|
||||
"sort": {
|
||||
"customer_name": "Kundnamn",
|
||||
"customer_number": "Kundnummer",
|
||||
"priority": "Viktigast först",
|
||||
"total_amount": "Belopp"
|
||||
},
|
||||
"states": {
|
||||
"attention": "Behöver uppmärksamhet",
|
||||
"blocked": "Blockerad",
|
||||
"completed": "Klar",
|
||||
"empty_body": "Välj en annan period eller affärsvy.",
|
||||
"empty_title": "Inga fakturadata i perioden",
|
||||
"error_body": "Försök ladda perioden igen.",
|
||||
"error_title": "Fakturaperioden kunde inte laddas",
|
||||
"filtered_empty_body": "Nollställ arbetsflödesfiltren eller prova en annan sökning.",
|
||||
"filtered_empty_title": "Inga kunder matchar filtren",
|
||||
"loading_body": "Samlar kunder, fakturasamlingar och kontrollpunkter…",
|
||||
"loading_title": "Laddar fakturaperiod",
|
||||
"queued": "I kö",
|
||||
"ready": "Klar för fakturering",
|
||||
"refreshing": "Uppdaterar befintligt resultat…",
|
||||
"retained_error": "Uppdateringen misslyckades. Det senast laddade resultatet visas fortfarande."
|
||||
},
|
||||
"toolbar": {
|
||||
"all_departments": "Alla avdelningar",
|
||||
"all_invoice_states": "Alla fakturastatusar",
|
||||
"all_severities": "Alla nivåer",
|
||||
"all_states": "Alla tillstånd",
|
||||
"change_direction": "Ändra sorteringsriktning",
|
||||
"clear_filters": "Rensa filter",
|
||||
"department": "Avdelning",
|
||||
"invoice_state": "Fakturastatus",
|
||||
"reload": "Ladda om",
|
||||
"retry": "Försök igen",
|
||||
"search": "Sök kunder och fakturadata",
|
||||
"severity": "Allvar",
|
||||
"sort": "Sortera",
|
||||
"workflow": "Arbetsflöde"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compat": {
|
||||
"invoicing_period": {
|
||||
"xlvask_autopilot": {
|
||||
"xlvask_review": {
|
||||
"actions": {
|
||||
"accept": "Godkänn förslag",
|
||||
"attach_order": "Koppla order",
|
||||
@@ -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>
|
||||
|
||||
+18
-3
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
+2
-5
@@ -2710,14 +2710,11 @@ const decideXlvaskAutomation = (
|
||||
: treeText("actions.xlvask.deny.preview", "{count} XL Vask forslag afvises.", { count: rows.length }),
|
||||
],
|
||||
async () => {
|
||||
const endpointSuffix = decision === "deny" ? "reject" : decision;
|
||||
for (const node of rows) {
|
||||
await SessionUser.request(
|
||||
`/modules/xlvask/services/usage/orders/${node.meta.usageId}/automation/${decision}`,
|
||||
`/modules/xlvask/services/usage/orders/${node.meta.usageId}/${endpointSuffix}`,
|
||||
"POST",
|
||||
{
|
||||
suggestion_id: node.meta.automation?.id ?? null,
|
||||
reason: `${decision} from invoice period tree`,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
-1
@@ -19,7 +19,6 @@ const concreteDateShortcutIds = [
|
||||
"same_month_last_year",
|
||||
];
|
||||
const onSelectionChange = (startDate: Date, endDate: Date) => {
|
||||
console.log("Selection changed:", startDate, endDate);
|
||||
showReloadAnimation();
|
||||
dates.functions.setSelection(startDate, endDate);
|
||||
};
|
||||
|
||||
+33
-5
@@ -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;
|
||||
|
||||
+16
@@ -35,7 +35,11 @@ const getResponsePayload = (response: any = {}) => {
|
||||
* Fixed pricing distribution data structure:
|
||||
*/
|
||||
const fixed_pricing_department_distribution = ref<any[]>([]);
|
||||
const fixedPricingDistributionLoaded = ref(false);
|
||||
const fixedPricingDistributionFailed = ref(false);
|
||||
const fetchFixedPricingDistribution = async () => {
|
||||
fixedPricingDistributionLoaded.value = false;
|
||||
fixedPricingDistributionFailed.value = false;
|
||||
SessionUser.request(
|
||||
'/superuser/invoicing/period/distribution/fixed-pricing',
|
||||
'GET',
|
||||
@@ -47,15 +51,20 @@ const fetchFixedPricingDistribution = async () => {
|
||||
|
||||
if (response.data && response.data.includes.collective_fixed_pricing_results) {
|
||||
fixed_pricing_department_distribution.value = response.data.includes.collective_fixed_pricing_results;
|
||||
fixedPricingDistributionLoaded.value = true;
|
||||
fixedPricingDistributionFailed.value = false;
|
||||
/**
|
||||
* Fixed pricing distribution data structure:
|
||||
* { "total_fixed_price": 137962, "total_original_price": 72799, "total_department_totals": { "1": 11826, "2": 22687, "3": 17009, "4": 12457, "5": 1161, "6": 6611, "7": 1048 }, "total_department_totals_relative": { "1": 30962.876996916584, "2": 28790.813863868243, "3": 32997.60337384933, "4": 15442.430865260254, "5": 1407.808993176734, "6": 9521.78041528052, "7": 1010.6854916483431 }, "total_department_totals_parsed": { "Glostrup": 17009, "Taastrup": 22687, "Hvidovre": 11826, "Roskilde": 6611, "Køge": 12457, "Aarhus C": 1161, "Taulov": 1048 }, "total_department_totals_relative_parsed": { "Glostrup": 32997.60337384933, "Taastrup": 28790.813863868243, "Hvidovre": 30962.876996916584, "Roskilde": 9521.78041528052, "Køge": 15442.430865260254, "Aarhus C": 1407.808993176734, "Taulov": 1010.6854916483431 } }
|
||||
*/
|
||||
} else {
|
||||
console.error('Unexpected response structure:', response);
|
||||
fixedPricingDistributionFailed.value = true;
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Error fetching fixed pricing distribution:', error);
|
||||
fixedPricingDistributionLoaded.value = true;
|
||||
fixedPricingDistributionFailed.value = true;
|
||||
});
|
||||
}
|
||||
const clearFixedPricingDistribution = () => {
|
||||
@@ -134,7 +143,11 @@ watch(
|
||||
* Subscription display logic:
|
||||
*/
|
||||
const vehicleSubscriptionDistribution = ref<any>(null);
|
||||
const vehicleSubscriptionDistributionLoaded = ref(false);
|
||||
const vehicleSubscriptionDistributionFailed = ref(false);
|
||||
const fetchVehicleSubscriptionDistribution = async () => {
|
||||
vehicleSubscriptionDistributionLoaded.value = false;
|
||||
vehicleSubscriptionDistributionFailed.value = false;
|
||||
SessionUser.request(
|
||||
'/superuser/invoicing/period/distribution/wash-subscriptions',
|
||||
'GET',
|
||||
@@ -150,9 +163,12 @@ const fetchVehicleSubscriptionDistribution = async () => {
|
||||
vehicleSubscriptionDistribution.value = response.data.includes.collective_subscription_results;
|
||||
} else {
|
||||
console.error('Unexpected response structure:', response);
|
||||
vehicleSubscriptionDistributionFailed.value = true;
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Error fetching vehicle subscription distribution:', error);
|
||||
vehicleSubscriptionDistributionLoaded.value = true;
|
||||
vehicleSubscriptionDistributionFailed.value = true;
|
||||
});
|
||||
}
|
||||
const clearVehicleSubscriptionDistribution = () => {
|
||||
|
||||
-1
@@ -32,7 +32,6 @@ import { Line } from 'vue-chartjs';
|
||||
ChartJS.register(Title, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Colors);
|
||||
|
||||
const parseTransactions = () => {
|
||||
console.log('Parsing transactions for chart data...', props.transactions.length, 'transactions found.');
|
||||
const labels = [];
|
||||
const data = [];
|
||||
props.transactions.forEach(transaction => {
|
||||
|
||||
+4
@@ -8,6 +8,10 @@ const REVIEW_WORKSPACE_TRANSLATORS = {
|
||||
composer.t("invoicing_period.review_workspace.detail.previous", params),
|
||||
"detail.selected_customer": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.detail.selected_customer", params),
|
||||
"errors.fakturer_nu_failed_title": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.errors.fakturer_nu_failed_title", params),
|
||||
"errors.fakturer_nu_failed_body": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.errors.fakturer_nu_failed_body", params),
|
||||
"headline.aria_label": (composer, params) =>
|
||||
composer.t("invoicing_period.review_workspace.headline.aria_label", params),
|
||||
"headline.booked": (composer, params) =>
|
||||
|
||||
-1
@@ -467,7 +467,6 @@ export const makeXlvaskNode = (usage, order = null, options = {}) => {
|
||||
usageId,
|
||||
washId,
|
||||
orderId: toPositiveInteger(order?.id ?? usage?.linked_order_id ?? usage?.order_id),
|
||||
automation: usage?.automation,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
+14
-4
@@ -38,6 +38,7 @@ import {
|
||||
} from "../utils/possibleDuplicateGroups.js";
|
||||
import { deriveLegacyPeriodCustomerReview } from "../services/invoicingPeriodReview.js";
|
||||
import { translateInvoicingPeriodReview } from "../services/invoicingPeriodTranslation.js";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
const expandedDuplicateGroupKey = ref<string | null>(null);
|
||||
const tr = (key: string, fallback: string, params: Record<string, any> = {}) =>
|
||||
@@ -299,6 +300,11 @@ const getCustomerFlagTabType = (customer: any): CustomerFlagTab => {
|
||||
return "none";
|
||||
};
|
||||
|
||||
const hasRedFlags = (customer: any): boolean => {
|
||||
const flagCounts = getCustomerActiveFlagCounts(customer);
|
||||
return Number(flagCounts?.manual ?? 0) > 0;
|
||||
};
|
||||
|
||||
const hasMultipleRedFlags = (customer: any): boolean => {
|
||||
const flagCounts = getCustomerActiveFlagCounts(customer);
|
||||
return Number(flagCounts?.manual ?? 0) >= 2;
|
||||
@@ -552,7 +558,11 @@ const onClickInvoiceNow = async (customer: any, transactionIds: number[]) => {
|
||||
queueInvoiceCollections(invoiceCollectionIds, customer);
|
||||
} catch (error: any) {
|
||||
invoiceQueue.finishPeriodRefresh?.([customerNumber], []);
|
||||
console.error("Error queueing invoice collection:", error);
|
||||
await Swal.fire({
|
||||
title: tr("errors.fakturer_nu_failed_title", "Fakturer nu mislykkedes"),
|
||||
text: tr("errors.fakturer_nu_failed_body", "Kunne ikke oprette faktura for denne kunde. Prøv igen, eller tjek kundens transaktioner."),
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1521,7 +1531,7 @@ const getTransactionQueryParameters = () => {
|
||||
</div>
|
||||
<div class="column is-narrow period-customer-card__state" v-else>
|
||||
<button
|
||||
v-if="tmpFilters.displayRequiresAction && customer.requires_action && !hasMultipleRedFlags(customer)"
|
||||
v-if="tmpFilters.displayRequiresAction && (customer.requires_action || hasRedFlags(customer))"
|
||||
type="button"
|
||||
class="button is-small is-dark is-inverted"
|
||||
:class="{ 'is-loading': isCustomerPeriodActionLoading(customer) }"
|
||||
@@ -1535,8 +1545,8 @@ const getTransactionQueryParameters = () => {
|
||||
<span>{{ SessionUser.objects.global.language.invoice_now }}</span>
|
||||
</button>
|
||||
<span
|
||||
v-else-if="tmpFilters.displayRequiresAction && customer.requires_action && hasMultipleRedFlags(customer)"
|
||||
class="tag is-danger is-light is-small"
|
||||
v-if="tmpFilters.displayRequiresAction && customer.requires_action && hasMultipleRedFlags(customer)"
|
||||
class="tag is-danger is-light is-small ml-1"
|
||||
:data-testid="`invoicing-period-customer-multiple-red-flag-${customer.customer_number}`"
|
||||
:title="tr('card.multiple_red_flags_title', 'Flere røde flag — gennemgå kunden før fakturering.')"
|
||||
>
|
||||
|
||||
+18
-3
@@ -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,12 +34,11 @@ 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"
|
||||
:automation-workspace="true"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { view } from '../imports/InvoicingBillingPeriodImportView.vue';
|
||||
import { SessionUser } from '@/components/session/token/SessionUser.vue';
|
||||
import {ref} from "vue";
|
||||
import "@/components/displays/superuser/tables/customersTable.vue";
|
||||
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
|
||||
import WhiteBox from "@/components/displays/boxes/WhiteBox.vue";
|
||||
|
||||
const customersWithSubscriptions = ref<Array<{
|
||||
id: number;
|
||||
customer_name: string;
|
||||
customer_number?: number;
|
||||
display_name?: string;
|
||||
group_id?: number;
|
||||
phone?: {
|
||||
country_code: string | null;
|
||||
number: string | null;
|
||||
};
|
||||
email?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
|
||||
}>[]>([]);
|
||||
|
||||
const onLoad = () => {
|
||||
// Send a test request
|
||||
SessionUser.request(
|
||||
'/superuser/users-with-vehicle-subscriptions',
|
||||
'GET',
|
||||
{}
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
customersWithSubscriptions.value = response.data.data;
|
||||
} else {
|
||||
console.error('Failed to fetch vehicles with subscriptions:', response);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching vehicles with subscriptions:', error);
|
||||
});
|
||||
};
|
||||
|
||||
onLoad();
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--{{ view.variables.sharedVariables.value }}-->
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<template v-for="customer in view.variables.sharedVariables.value.types.vehicle_subscriptions" :key="customer.customer_number">
|
||||
<div class="column is-12">
|
||||
<WhiteBox class="is-clickable">
|
||||
<!--{{ customer }}-->
|
||||
<div class="columns is-vcentered">
|
||||
<div class="column">
|
||||
<ColorIndicator
|
||||
v-bind:color_class="(customer.transactions.length > 0) ? 'has-text-success' : 'has-text-grey'"
|
||||
v-bind:label="{
|
||||
text: customer.customer_name,
|
||||
classes: [],
|
||||
}"
|
||||
v-bind:visibility="{
|
||||
icon: true,
|
||||
dropdown: false,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<p>{{customer.transactions.length}} transactions</p>
|
||||
</div>
|
||||
</div>
|
||||
</WhiteBox>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p>
|
||||
VA customers with no transactions
|
||||
|
||||
</p>
|
||||
<button class="button is-primary" @click="view.functions.setCurrentView('home')">
|
||||
Go to Home
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -3,107 +3,64 @@ import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import ConfigurationSubPageWrapper
|
||||
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
||||
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
||||
import ConfigurationSelect from "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
|
||||
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
|
||||
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useModuleConfig } from "@/composables/useModuleConfig.js";
|
||||
const { t } = useI18n();
|
||||
|
||||
// Get the department from the route
|
||||
|
||||
|
||||
|
||||
const module_config = ref([]);
|
||||
|
||||
|
||||
|
||||
const getModuleConfig = async () => {
|
||||
await SessionUser.superUser.modules.email.config.get_all().then((response) => {
|
||||
let tmp_module_config = response.data.data;
|
||||
let tmp_module_config_array = [];
|
||||
for (const value of Object.values(tmp_module_config)) {
|
||||
console.log(`${value.variable}: ${value.value}`);
|
||||
tmp_module_config_array.push({
|
||||
variable: value.variable,
|
||||
value: value.value,
|
||||
});
|
||||
}
|
||||
module_config.value = tmp_module_config_array;
|
||||
console.log(module_config.value);
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const getModuleConfigValue = (variable) => {
|
||||
const config = module_config.value.find((config) => config.variable === variable);
|
||||
// Log to the console, what the value was found
|
||||
if (config) {
|
||||
console.log(`Found value for ${variable}: ${config.value}`);
|
||||
} else {
|
||||
console.log(`No value found for ${variable}`);
|
||||
}
|
||||
return config ? config.value : '';
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await getModuleConfig();
|
||||
};
|
||||
const { module_config, getModuleConfigValue } = useModuleConfig("email");
|
||||
|
||||
const encryptionOptions = [
|
||||
{ value: 'ssl', label: 'SSL' },
|
||||
{ value: 'tls', label: 'TLS' },
|
||||
{ value: 'starttls', label: 'STARTTLS' },
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: "ssl", label: "SSL" },
|
||||
{ value: "tls", label: "TLS" },
|
||||
{ value: "starttls", label: "STARTTLS" },
|
||||
{ value: "none", label: "None" },
|
||||
];
|
||||
|
||||
const showSendTestEmail = async () => {
|
||||
await Swal.fire({
|
||||
title: t('configuration.email.send_test_email'),
|
||||
text: t('configuration.email.send_test_email_prompt'),
|
||||
input: 'email',
|
||||
title: t("configuration.email.send_test_email"),
|
||||
text: t("configuration.email.send_test_email_prompt"),
|
||||
input: "email",
|
||||
inputAttributes: {
|
||||
autocapitalize: 'off'
|
||||
autocapitalize: "off",
|
||||
},
|
||||
showCancelButton: true,
|
||||
confirmButtonText: t('configuration.email.send'),
|
||||
confirmButtonText: t("configuration.email.send"),
|
||||
showLoaderOnConfirm: true,
|
||||
preConfirm: (email) => {
|
||||
return SessionUser.superUser.modules.email.sendTestEmail(email)
|
||||
.then(() => {
|
||||
Swal.fire({
|
||||
title: t('configuration.email.test_email_sent'),
|
||||
text: t('configuration.email.test_email_sent_success'),
|
||||
icon: 'success',
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
Swal.fire({
|
||||
title: t('common.error'),
|
||||
text: t('configuration.email.test_email_error'),
|
||||
icon: 'error',
|
||||
});
|
||||
return SessionUser.superUser.modules.email
|
||||
.sendTestEmail(email)
|
||||
.then(() => {
|
||||
Swal.fire({
|
||||
title: t("configuration.email.test_email_sent"),
|
||||
text: t("configuration.email.test_email_sent_success"),
|
||||
icon: "success",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
Swal.fire({
|
||||
title: t("common.error"),
|
||||
text: t("configuration.email.test_email_error"),
|
||||
icon: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
allowOutsideClick: () => !Swal.isLoading()
|
||||
allowOutsideClick: () => !Swal.isLoading(),
|
||||
});
|
||||
};
|
||||
|
||||
load();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
|
||||
<ConfigurationSubPageWrapper>
|
||||
<template #title>
|
||||
<PageTitle :title="$t('configuration.email.title')" :subtitle="$t('configuration.email.subtitle')"/>
|
||||
<PageTitle :title="$t('configuration.email.title')" :subtitle="$t('configuration.email.subtitle')" />
|
||||
</template>
|
||||
<template v-if="module_config.length > 0" #content>
|
||||
<ConfigurationCategory
|
||||
@@ -113,16 +70,18 @@ load();
|
||||
:description="$t('configuration.email.general_settings_desc')"
|
||||
icon="fas fa-cogs"
|
||||
>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.enable_system')"
|
||||
:description="$t('configuration.email.enable_system_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.email.config.enabled.set"
|
||||
/>
|
||||
<button class="button is-dark mt-2" @click="showSendTestEmail()">{{ $t('configuration.email.send_test_email') }}</button>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.enable_system')"
|
||||
:description="$t('configuration.email.enable_system_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.email.config.enabled.set"
|
||||
/>
|
||||
<button class="button is-dark mt-2" @click="showSendTestEmail()">
|
||||
{{ $t("configuration.email.send_test_email") }}
|
||||
</button>
|
||||
</ConfigurationCategory>
|
||||
<!-- Sender identity -->
|
||||
<ConfigurationCategory
|
||||
@@ -133,40 +92,40 @@ load();
|
||||
icon="fas fa-cogs"
|
||||
>
|
||||
<ConfigurationInput
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.from_email')"
|
||||
:description="$t('configuration.email.from_email_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_from')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from.set"
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.from_email')"
|
||||
:description="$t('configuration.email.from_email_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_from')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from.set"
|
||||
/>
|
||||
<ConfigurationInput
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.from_name')"
|
||||
:description="$t('configuration.email.from_name_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_from_name')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from_name.set"
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.from_name')"
|
||||
:description="$t('configuration.email.from_name_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_from_name')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_from_name.set"
|
||||
/>
|
||||
<ConfigurationInput
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.reply_to_email')"
|
||||
:description="$t('configuration.email.reply_to_email_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_reply_to')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to.set"
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.reply_to_email')"
|
||||
:description="$t('configuration.email.reply_to_email_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_reply_to')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to.set"
|
||||
/>
|
||||
<ConfigurationInput
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.reply_to_name')"
|
||||
:description="$t('configuration.email.reply_to_name_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_reply_to_name')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to_name.set"
|
||||
class="mt-2"
|
||||
module="Email"
|
||||
:title="$t('configuration.email.reply_to_name')"
|
||||
:description="$t('configuration.email.reply_to_name_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('smtp_reply_to_name')"
|
||||
:on-save="SessionUser.superUser.modules.email.config.keys.smtp_reply_to_name.set"
|
||||
/>
|
||||
</ConfigurationCategory>
|
||||
<ConfigurationCategory
|
||||
@@ -224,7 +183,6 @@ load();
|
||||
:value="getModuleConfigValue('smtp_encryption')"
|
||||
:on-select="SessionUser.superUser.modules.email.config.keys.smtp_encryption.set"
|
||||
/>
|
||||
|
||||
</ConfigurationCategory>
|
||||
<ConfigurationCategory
|
||||
class="mt-2"
|
||||
@@ -257,6 +215,4 @@ load();
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -3,57 +3,14 @@ import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
import { ref } from 'vue';
|
||||
import ConfigurationSubPageWrapper
|
||||
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
||||
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
||||
|
||||
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
|
||||
import { useModuleConfig } from "@/composables/useModuleConfig.js";
|
||||
|
||||
// Get the department from the route
|
||||
|
||||
|
||||
|
||||
const module_config = ref([]);
|
||||
|
||||
|
||||
|
||||
const getModuleConfig = async () => {
|
||||
await SessionUser.superUser.modules.openai.config.get_all().then((response) => {
|
||||
let tmp_module_config = response.data.data;
|
||||
let tmp_module_config_array = [];
|
||||
for (const value of Object.values(tmp_module_config)) {
|
||||
console.log(`${value.variable}: ${value.value}`);
|
||||
tmp_module_config_array.push({
|
||||
variable: value.variable,
|
||||
value: value.value,
|
||||
});
|
||||
}
|
||||
module_config.value = tmp_module_config_array;
|
||||
console.log(module_config.value);
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const getModuleConfigValue = (variable) => {
|
||||
const config = module_config.value.find((config) => config.variable === variable);
|
||||
// Log to the console, what the value was found
|
||||
if (config) {
|
||||
console.log(`Found value for ${variable}: ${config.value}`);
|
||||
} else {
|
||||
console.log(`No value found for ${variable}`);
|
||||
}
|
||||
return config ? config.value : '';
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await getModuleConfig();
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
const { module_config, getModuleConfigValue } = useModuleConfig("openai");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -102,6 +59,4 @@ load();
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -3,10 +3,7 @@ import PageTitle from "@/components/global/PageTitle.vue";
|
||||
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
|
||||
import { ref } from 'vue';
|
||||
|
||||
import ConfigurationSubPageWrapper
|
||||
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
|
||||
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
|
||||
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
|
||||
import "@/components/displays/superuser/configuration/ConfigurationSelect.vue";
|
||||
@@ -14,95 +11,64 @@ import ConfigurationSecretKey from "@/components/displays/superuser/configuratio
|
||||
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
|
||||
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useModuleConfig } from "@/composables/useModuleConfig.js";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Get the department from the route
|
||||
|
||||
|
||||
|
||||
const module_config = ref([]);
|
||||
|
||||
|
||||
|
||||
const getModuleConfig = async () => {
|
||||
await SessionUser.superUser.modules.stripe.config.get_all().then((response) => {
|
||||
let tmp_module_config = response.data.data;
|
||||
let tmp_module_config_array = [];
|
||||
for (const value of Object.values(tmp_module_config)) {
|
||||
console.log(`${value.variable}: ${value.value}`);
|
||||
tmp_module_config_array.push({
|
||||
variable: value.variable,
|
||||
value: value.value,
|
||||
});
|
||||
}
|
||||
module_config.value = tmp_module_config_array;
|
||||
console.log(module_config.value);
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const getModuleConfigValue = (variable) => {
|
||||
const config = module_config.value.find((config) => config.variable === variable);
|
||||
// Log to the console, what the value was found
|
||||
if (config) {
|
||||
console.log(`Found value for ${variable}: ${config.value}`);
|
||||
} else {
|
||||
console.log(`No value found for ${variable}`);
|
||||
}
|
||||
return config ? config.value : '';
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await getModuleConfig();
|
||||
};
|
||||
|
||||
load();
|
||||
const { module_config, getModuleConfigValue } = useModuleConfig("stripe");
|
||||
|
||||
const showGetCustomers = async () => {
|
||||
await SessionUser.superUser.modules.stripe.functions.customers.list().then((response) => {
|
||||
console.log('Stripe customers: ', response.data.data);
|
||||
Swal.fire({
|
||||
title: t('configuration.stripe.customers'),
|
||||
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
await SessionUser.superUser.modules.stripe.functions.customers
|
||||
.list()
|
||||
.then((response) => {
|
||||
console.log("Stripe customers: ", response.data.data);
|
||||
Swal.fire({
|
||||
title: t("configuration.stripe.customers"),
|
||||
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const showGetProducts = async () => {
|
||||
await SessionUser.superUser.modules.stripe.functions.products.list().then((response) => {
|
||||
console.log('Stripe products: ', response.data.data);
|
||||
Swal.fire({
|
||||
title: t('configuration.stripe.products'),
|
||||
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
await SessionUser.superUser.modules.stripe.functions.products
|
||||
.list()
|
||||
.then((response) => {
|
||||
console.log("Stripe products: ", response.data.data);
|
||||
Swal.fire({
|
||||
title: t("configuration.stripe.products"),
|
||||
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
|
||||
const showGetPrices = async () => {
|
||||
await SessionUser.superUser.modules.stripe.functions.prices.list().then((response) => {
|
||||
console.log('Stripe prices: ', response.data.data);
|
||||
Swal.fire({
|
||||
title: t('configuration.stripe.prices'),
|
||||
html: '<pre>' + JSON.stringify(response.data.data, null, 2) + '</pre>',
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
await SessionUser.superUser.modules.stripe.functions.prices
|
||||
.list()
|
||||
.then((response) => {
|
||||
console.log("Stripe prices: ", response.data.data);
|
||||
Swal.fire({
|
||||
title: t("configuration.stripe.prices"),
|
||||
html: "<pre>" + JSON.stringify(response.data.data, null, 2) + "</pre>",
|
||||
showCloseButton: true,
|
||||
showCancelButton: false,
|
||||
focusConfirm: false,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.log(error);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -120,15 +86,15 @@ const showGetPrices = async () => {
|
||||
:description="$t('configuration.stripe.general_settings_desc')"
|
||||
icon="fas fa-cogs"
|
||||
>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="Stripe"
|
||||
:title="$t('configuration.stripe.enable')"
|
||||
:description="$t('configuration.stripe.enable_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.stripe.config.enabled.set"
|
||||
/>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="Stripe"
|
||||
:title="$t('configuration.stripe.enable')"
|
||||
:description="$t('configuration.stripe.enable_desc')"
|
||||
icon="fas fa-cogs"
|
||||
:value="getModuleConfigValue('enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.stripe.config.enabled.set"
|
||||
/>
|
||||
</ConfigurationCategory>
|
||||
<ConfigurationCategory
|
||||
class="mt-2"
|
||||
@@ -177,25 +143,23 @@ const showGetPrices = async () => {
|
||||
<span class="icon">
|
||||
<i class="fas fa-users"></i>
|
||||
</span>
|
||||
<span>{{ $t('configuration.stripe.get_customers') }}</span>
|
||||
<span>{{ $t("configuration.stripe.get_customers") }}</span>
|
||||
</button>
|
||||
<button class="button is-dark" @click="showGetProducts">
|
||||
<span class="icon">
|
||||
<i class="fas fa-box"></i>
|
||||
</span>
|
||||
<span>{{ $t('configuration.stripe.get_products') }}</span>
|
||||
<span>{{ $t("configuration.stripe.get_products") }}</span>
|
||||
</button>
|
||||
<button class="button is-dark" @click="showGetPrices">
|
||||
<span class="icon">
|
||||
<i class="fas fa-money-bill-wave"></i>
|
||||
</span>
|
||||
<span>{{ $t('configuration.stripe.get_prices') }}</span>
|
||||
<span>{{ $t("configuration.stripe.get_prices") }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</ConfigurationSubPageWrapper>
|
||||
</RestrictedPageWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -83,95 +83,8 @@ const onClickTestConnection = async () => {
|
||||
})
|
||||
}
|
||||
|
||||
// --- MiniMax re-authenticate / remove ---------------------------------------
|
||||
const minimaxApiKeyIsSet = ref(false);
|
||||
const reauthenticatingMiniMax = ref(false);
|
||||
const removingMiniMax = ref(false);
|
||||
|
||||
const refreshMiniMaxStatus = async () => {
|
||||
try {
|
||||
const response = await SessionUser.superUser.modules.minimax.config.keys.api_key.get();
|
||||
const value = response?.data?.data?.value ?? response?.data?.data ?? '';
|
||||
minimaxApiKeyIsSet.value = typeof value === 'string' && value !== '';
|
||||
} catch (error) {
|
||||
// If the endpoint is unreachable or the key isn't set yet, treat as not-set.
|
||||
minimaxApiKeyIsSet.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onClickReauthenticateMiniMax = async () => {
|
||||
const { value: apiKey } = await Swal.fire({
|
||||
title: t('configuration.xlvask.minimax_reauth_title'),
|
||||
text: t('configuration.xlvask.minimax_reauth_desc'),
|
||||
input: 'password',
|
||||
inputAttributes: { autocomplete: 'off', autocapitalize: 'off', spellcheck: 'false' },
|
||||
inputPlaceholder: t('configuration.xlvask.minimax_api_key_placeholder'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: t('configuration.xlvask.minimax_reauth_confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
preConfirm: (val) => {
|
||||
if (!val || String(val).trim() === '') {
|
||||
Swal.showValidationMessage(t('configuration.xlvask.minimax_api_key_required'));
|
||||
return false;
|
||||
}
|
||||
return String(val).trim();
|
||||
},
|
||||
});
|
||||
if (!apiKey) return;
|
||||
reauthenticatingMiniMax.value = true;
|
||||
try {
|
||||
await SessionUser.superUser.modules.minimax.config.keys.api_key.set(apiKey);
|
||||
minimaxApiKeyIsSet.value = true;
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: t('configuration.xlvask.minimax_reauth_success'),
|
||||
text: t('configuration.xlvask.minimax_reauth_success_desc'),
|
||||
});
|
||||
} catch (error) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('configuration.xlvask.minimax_reauth_failed'),
|
||||
text: error?.message ?? String(error),
|
||||
});
|
||||
} finally {
|
||||
reauthenticatingMiniMax.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onClickRemoveMiniMax = async () => {
|
||||
const confirmation = await Swal.fire({
|
||||
title: t('configuration.xlvask.minimax_remove_title'),
|
||||
text: t('configuration.xlvask.minimax_remove_desc'),
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#d33',
|
||||
confirmButtonText: t('configuration.xlvask.minimax_remove_confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
});
|
||||
if (!confirmation.isConfirmed) return;
|
||||
removingMiniMax.value = true;
|
||||
try {
|
||||
await SessionUser.superUser.modules.minimax.config.keys.api_key.set('');
|
||||
minimaxApiKeyIsSet.value = false;
|
||||
Swal.fire({
|
||||
icon: 'success',
|
||||
title: t('configuration.xlvask.minimax_remove_success'),
|
||||
text: t('configuration.xlvask.minimax_remove_success_desc'),
|
||||
});
|
||||
} catch (error) {
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('configuration.xlvask.minimax_remove_failed'),
|
||||
text: error?.message ?? String(error),
|
||||
});
|
||||
} finally {
|
||||
removingMiniMax.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
await getModuleConfig();
|
||||
await refreshMiniMaxStatus();
|
||||
};
|
||||
|
||||
load();
|
||||
@@ -244,82 +157,9 @@ load();
|
||||
:description="$t('configuration.xlvask.automation_settings_desc')"
|
||||
icon="fas fa-magic"
|
||||
>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.enable_automatic_attachment')"
|
||||
:description="$t('configuration.xlvask.enable_automatic_attachment_desc')"
|
||||
icon="fas fa-link"
|
||||
:value="getModuleConfigValue('automatic_order_attachment_enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.automatic_order_attachment_enabled.set"
|
||||
/>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.enable_automatic_creation')"
|
||||
:description="$t('configuration.xlvask.enable_automatic_creation_desc')"
|
||||
icon="fas fa-plus-circle"
|
||||
:value="getModuleConfigValue('automatic_order_creation_enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.automatic_order_creation_enabled.set"
|
||||
/>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.enable_openai_integration')"
|
||||
:description="$t('configuration.xlvask.enable_openai_integration_desc')"
|
||||
icon="fas fa-brain"
|
||||
:value="getModuleConfigValue('openai_integration_enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.openai_integration_enabled.set"
|
||||
/>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.enable_minimax_integration')"
|
||||
:description="$t('configuration.xlvask.enable_minimax_integration_desc')"
|
||||
icon="fas fa-robot"
|
||||
:value="getModuleConfigValue('minimax_integration_enabled') === true"
|
||||
:on-switch="SessionUser.superUser.modules.xlvask.config.keys.minimax_integration_enabled.set"
|
||||
/>
|
||||
</ConfigurationCategory>
|
||||
<ConfigurationCategory
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.minimax_settings')"
|
||||
:description="$t('configuration.xlvask.minimax_settings_desc')"
|
||||
icon="fas fa-robot"
|
||||
>
|
||||
<ConfigurationSwitch
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.minimax_enable')"
|
||||
:description="$t('configuration.xlvask.minimax_enable_desc')"
|
||||
icon="fas fa-robot"
|
||||
:value="SessionUser.superUser.modules.minimax.config.enabled.get ? true : false"
|
||||
:on-switch="SessionUser.superUser.modules.minimax.config.enabled.set"
|
||||
/>
|
||||
<ConfigurationSecretKey
|
||||
class="mt-2"
|
||||
module="XLVask"
|
||||
:title="$t('configuration.xlvask.minimax_api_key')"
|
||||
:description="$t('configuration.xlvask.minimax_api_key_desc')"
|
||||
icon="fas fa-key"
|
||||
:isSet="minimaxApiKeyIsSet"
|
||||
:on-save="SessionUser.superUser.modules.minimax.config.keys.api_key.set"
|
||||
/>
|
||||
<div class="buttons mt-2">
|
||||
<button class="button is-warning" @click="onClickReauthenticateMiniMax" :disabled="reauthenticatingMiniMax">
|
||||
<span class="icon">
|
||||
<i class="fas fa-redo"></i>
|
||||
</span>
|
||||
<span>{{ reauthenticatingMiniMax ? $t('configuration.xlvask.minimax_reauth_in_progress') : $t('configuration.xlvask.minimax_reauthenticate') }}</span>
|
||||
</button>
|
||||
<button class="button is-danger ml-2" @click="onClickRemoveMiniMax" :disabled="removingMiniMax">
|
||||
<span class="icon">
|
||||
<i class="fas fa-trash"></i>
|
||||
</span>
|
||||
<span>{{ removingMiniMax ? $t('configuration.xlvask.minimax_remove_in_progress') : $t('configuration.xlvask.minimax_remove') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="is-size-7 has-text-grey">
|
||||
{{ $t('configuration.xlvask.automation_settings_removed_desc') }}
|
||||
</p>
|
||||
</ConfigurationCategory>
|
||||
<div class="buttons">
|
||||
<button class="button is-dark" @click="onClickTestConnection">
|
||||
|
||||
@@ -1,849 +0,0 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import {SessionUser} from "@/components/session/token/SessionUser.vue";
|
||||
import ColorIndicator from "@/components/displays/buttons/ColorIndicator.vue";
|
||||
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
|
||||
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
|
||||
import { createOrderItem } from "@/components/shop/OrdersItems.vue";
|
||||
import Swal from "sweetalert2";
|
||||
import ActionSettingsWheelItemLabel from "@/components/displays/buttons/ActionSettingsWheelItemLabel.vue";
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
reg: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
dateFrom: {
|
||||
type: Date,
|
||||
default: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
/** Dynamic variables */
|
||||
const usageLog = ref(null);
|
||||
const usageLogLoading = ref(false);
|
||||
const xlvask_vehicle_types = ref(null);
|
||||
const product_options = ref(null);
|
||||
const departments = ref(null);
|
||||
const related_orders = ref(null);
|
||||
|
||||
const getUsageLog = async () => {
|
||||
usageLogLoading.value = true;
|
||||
usageLog.value = null;
|
||||
SessionUser.superUser.modules.xlvask.functions.getUsageLog(
|
||||
(props.dateFrom === null ? null : SessionUser.superUser.modules.xlvask.functions.convertDateTimeToISO(props.dateFrom)),
|
||||
props.reg,
|
||||
).then(
|
||||
(response) => {
|
||||
if (response.status === 200) {
|
||||
usageLog.value = response.data.data;
|
||||
} else {
|
||||
console.error("XL-Vask: usage log returned non-OK status.", { status: response?.status, reg: props.reg });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_usage_log'),
|
||||
});
|
||||
}
|
||||
}
|
||||
).catch(
|
||||
(error) => {
|
||||
console.error("XL-Vask: failed to load usage log.", { reg: props.reg, error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_usage_log'),
|
||||
});
|
||||
}
|
||||
).finally(() => {
|
||||
usageLogLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const getVehicleTypes = async () => {
|
||||
xlvask_vehicle_types.value = null;
|
||||
SessionUser.superUser.modules.xlvask.functions.getVehicleTypes().then(
|
||||
(response) => {
|
||||
if (response.status === 200) {
|
||||
xlvask_vehicle_types.value = response.data.data;
|
||||
} else {
|
||||
console.error("XL-Vask: vehicle types returned non-OK status.", { status: response?.status });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'),
|
||||
});
|
||||
}
|
||||
}
|
||||
).catch(
|
||||
(error) => {
|
||||
console.error("XL-Vask: failed to load vehicle types.", { error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_vehicle_types'),
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getProductOptions = async () => {
|
||||
if (product_options.value === null) {
|
||||
SessionUser.objects.product_options.get.all().then((result) => {
|
||||
product_options.value = result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const getDepartments = async () => {
|
||||
SessionUser.objects.departments.get.all().then((result) => {
|
||||
departments.value = result;
|
||||
});
|
||||
}
|
||||
|
||||
const getRelatedOrders = async () => {
|
||||
related_orders.value = null;
|
||||
SessionUser.superUser.modules.xlvask.functions.getRelatedOrders(listWashIds()).then(
|
||||
(response) => {
|
||||
if (response.status === 200) {
|
||||
related_orders.value = response.data.data;
|
||||
} else {
|
||||
console.error("XL-Vask: related orders returned non-OK status.", { status: response?.status });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_related_orders'),
|
||||
});
|
||||
}
|
||||
}
|
||||
).catch(
|
||||
(error) => {
|
||||
console.error("XL-Vask: failed to load related orders.", { error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.fetch_related_orders'),
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Watch for changes in the usage log and update the related orders
|
||||
watch(usageLog, (newValue) => {
|
||||
if (newValue) {
|
||||
getRelatedOrders();
|
||||
}
|
||||
});
|
||||
|
||||
const listWashIds = () => {
|
||||
let washIds = [];
|
||||
if (usageLog.value) {
|
||||
for (const usage of usageLog.value) {
|
||||
washIds.push(usage.WashId);
|
||||
}
|
||||
}
|
||||
return washIds;
|
||||
}
|
||||
|
||||
const _example = {
|
||||
"WashId": "892ae789-aeea-4bda-9374-cf931290aefd",
|
||||
"CustomerId": "59440200",
|
||||
"Customer": "DITOBUS EXCURSIONS A/S",
|
||||
"VatNumber": "31171520",
|
||||
"Location": "Hvidovre",
|
||||
"Hall": "Hvidovre_1",
|
||||
"HallId": "845d29a1-a7d2-4e3b-bbc3-2b13242d744a",
|
||||
"StartTime": "2024-01-26T14:46:53.067",
|
||||
"FinishTime": "2024-01-26T14:54:08.653",
|
||||
"RegistrationNumber": "BJ22227",
|
||||
"VehicleType": "Bus/autocamper, M",
|
||||
"IdentificationType": "LPR",
|
||||
"IdentificationId": "BJ22227",
|
||||
"Info": "BJ22227",
|
||||
"Updated": "",
|
||||
"Prepaid": false,
|
||||
"FinishStatus": 1,
|
||||
"CustomerGuid": "21ba156a-b2d2-44be-8398-4b67d66003d6",
|
||||
"VehicleId": "0584ef66-3deb-491e-9b82-0a28cfc20e9e",
|
||||
"WashItems": [
|
||||
{
|
||||
"WashItemId": "399b25c1-5c03-4f25-b5e9-00731e9b94c4",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "Ikke HT dysebom bag",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
},
|
||||
{
|
||||
"WashItemId": "ef3ca812-bbe5-4cf3-916f-06fd17c04225",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": "Spot Free",
|
||||
"OriginalProductName": "Skylning med RO",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 35,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 35,
|
||||
"Vat": 3.06,
|
||||
"PriceIncVat": 15.31
|
||||
},
|
||||
{
|
||||
"WashItemId": "5c4ec0d9-cffc-45eb-9213-406dbcb8c975",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "2-børstevask",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
},
|
||||
{
|
||||
"WashItemId": "f7bd7404-f5a3-4a59-8e85-53b284df3260",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "Halleje",
|
||||
"Unit": "min",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
},
|
||||
{
|
||||
"WashItemId": "6dba6c24-8191-4c2b-913c-7366518fb41d",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "Stor bil",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 559,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 559,
|
||||
"Vat": 48.91,
|
||||
"PriceIncVat": 244.56
|
||||
},
|
||||
{
|
||||
"WashItemId": "61d11d87-2ee4-4a4d-890b-a0870ae1a924",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "HT sider",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
},
|
||||
{
|
||||
"WashItemId": "ec6eb1c4-b58c-457f-8224-b674cf41dc29",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "EU spejl",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
},
|
||||
{
|
||||
"WashItemId": "54b4deec-be3e-4cb2-b68d-b9af9cff6fcf",
|
||||
"ExternalProductId": null,
|
||||
"ExternalProductName": null,
|
||||
"OriginalProductName": "HT chassis",
|
||||
"Unit": "stk",
|
||||
"UnitPrice": 0,
|
||||
"Count": 1,
|
||||
"Discount": 65,
|
||||
"PriceExVat": 0,
|
||||
"Vat": 0,
|
||||
"PriceIncVat": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
const _exampleVehicleTypes = [
|
||||
{
|
||||
"id": 1,
|
||||
"vehicleTypeId": "0f915576-587c-4494-bcce-388b3b3fe55a",
|
||||
"product": 17,
|
||||
"name": "Bus/autocamper, M",
|
||||
"created_at": "2025-05-19 09:41:20",
|
||||
"updated_at": "2025-05-19 09:41:20"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"vehicleTypeId": "e2638c21-366d-4b7f-b0af-eb3634ae2c8c",
|
||||
"product": 15,
|
||||
"name": "Kassevogn/Varevogn, L",
|
||||
"created_at": "2025-05-19 09:44:39",
|
||||
"updated_at": "2025-05-19 09:44:39"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"vehicleTypeId": "5e6cfa13-df14-4a11-8d3d-603a41d37f68",
|
||||
"product": 17,
|
||||
"name": "Bus/autocamper, L",
|
||||
"created_at": "2025-05-19 10:12:54",
|
||||
"updated_at": "2025-05-19 10:12:54"
|
||||
}
|
||||
];
|
||||
onMounted(() => {
|
||||
getVehicleTypes();
|
||||
getUsageLog();
|
||||
getProductOptions();
|
||||
getDepartments();
|
||||
});
|
||||
|
||||
const getProductName = (item) => {
|
||||
if (item.ExternalProductName) {
|
||||
return item.ExternalProductName;
|
||||
} else if (item.OriginalProductName) {
|
||||
return item.OriginalProductName;
|
||||
} else {
|
||||
return t('invoicing_period.xlvask_autopilot.labels.unknown_product_with_id', { id: item.WashItemId });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
||||
};
|
||||
|
||||
|
||||
const getOrderFromUsageLogEntry = (usageLogEntry) => {
|
||||
return {
|
||||
customer_id: usageLogEntry.CustomerId,
|
||||
reg_1: usageLogEntry.RegistrationNumber,
|
||||
reg_2: "",
|
||||
reg_3: "",
|
||||
wash_id: usageLogEntry.WashId,
|
||||
}
|
||||
}
|
||||
|
||||
const getPrimaryServiceProductId = (usageLogEntry) => {
|
||||
// Check if the vehicle type is in the list
|
||||
const vehicleType = xlvask_vehicle_types.value.find(type => type.name === usageLogEntry.VehicleType);
|
||||
if (vehicleType) {
|
||||
// If the vehicle type is found, return the product ID
|
||||
return vehicleType.product;
|
||||
} else {
|
||||
// If the vehicle type is not found, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const recognizedItems = {
|
||||
"Undervognskylning": {
|
||||
getProductId: (usageLogEntry) => {
|
||||
let primaryProductId = getPrimaryServiceProductId(usageLogEntry);
|
||||
// Get all the matching options where the product id is the same as the primary product id
|
||||
let options = product_options.value.filter(option => option.product_id === primaryProductId);
|
||||
// Check if any options have the option_id 21
|
||||
let option = options.find(option => option.option_id === 21);
|
||||
if (option) {
|
||||
// If the option is found, return the option_id (21)
|
||||
return option.option_id;
|
||||
} else {
|
||||
// If the option is not found, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
// Spot Free
|
||||
"Skylning med RO": {
|
||||
getProductId: (usageLogEntry) => {
|
||||
let primaryProductId = getPrimaryServiceProductId(usageLogEntry);
|
||||
// Get all the matching options where the product id is the same as the primary product id
|
||||
let options = product_options.value.filter(option => option.product_id === primaryProductId);
|
||||
// Check if any options have the option_id 23, or 24
|
||||
let option = options.find(option => option.option_id === 23 || option.option_id === 24);
|
||||
if (option) {
|
||||
// If the option is found, return the option_id (23 or 24)
|
||||
return option.option_id;
|
||||
} else {
|
||||
// If the option is not found, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
},
|
||||
// Primary services
|
||||
"Stor bil": {
|
||||
getProductId: (usageLogEntry) => {
|
||||
// Check if the vehicle type is in the list
|
||||
return getPrimaryServiceProductId(usageLogEntry);
|
||||
}
|
||||
},
|
||||
"Lille bil": {
|
||||
getProductId: (usageLogEntry) => {
|
||||
// Check if the vehicle type is in the list
|
||||
return getPrimaryServiceProductId(usageLogEntry);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const round_price_down = (price) => {
|
||||
// Round the price down to the nearest whole number
|
||||
return Math.floor(price);
|
||||
}
|
||||
|
||||
const getOrderItemsFromUsageLogEntry = (usageLogEntry) => {
|
||||
let items = [];
|
||||
let unrecognizedItems = [];
|
||||
// Filter out all the free items
|
||||
const filteredItems = usageLogEntry.WashItems.filter(item => item.PriceIncVat > 0);
|
||||
// If the "Stor bil" item is present, set it to be the first item
|
||||
const storBilIndex = filteredItems.findIndex(item => item.OriginalProductName === "Stor bil" || item.OriginalProductName === "Lille bil");
|
||||
if (storBilIndex > -1) {
|
||||
const storBilItem = filteredItems.splice(storBilIndex, 1)[0];
|
||||
filteredItems.unshift(storBilItem);
|
||||
}
|
||||
// Loop through the filtered items
|
||||
for (const item of filteredItems) {
|
||||
// Check if the item is recognized
|
||||
if (recognizedItems[item.OriginalProductName]) {
|
||||
items.push({
|
||||
product_id: recognizedItems[item.OriginalProductName].getProductId(usageLogEntry),
|
||||
quantity: item.Count,
|
||||
discount_percentage: item.Discount,
|
||||
price: {
|
||||
unit: round_price_down(item.UnitPrice), // Before discount
|
||||
each: round_price_down( item.UnitPrice - (item.UnitPrice * item.Discount / 100) ), // One item after discount
|
||||
total: round_price_down( (item.UnitPrice * item.Count) - (item.UnitPrice * item.Count * item.Discount / 100) ), // Total price after discount x quantity
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If the item is not recognized, add it to the unrecognized items
|
||||
unrecognizedItems.push(item);
|
||||
}
|
||||
}
|
||||
// Calculate the total price
|
||||
let price = {
|
||||
total: 0,
|
||||
}
|
||||
for (const item of items) {
|
||||
price.total += item.price.total;
|
||||
}
|
||||
return { items, unrecognizedItems, price };
|
||||
}
|
||||
|
||||
const onClickCreateOrder = async (usageLogEntry) => {
|
||||
// Check if the order can be created
|
||||
if (!canCreateOrder(usageLogEntry)) {
|
||||
console.warn("XL-Vask: cannot create order from usage log entry.", {
|
||||
washId: usageLogEntry?.WashId,
|
||||
hasUnrecognizedItems: hasUnrecognizedItems(usageLogEntry),
|
||||
hasUnrecognizedDepartment: hasUnrecognizedDepartment(usageLogEntry),
|
||||
hasRelatedOrder: hasRelatedOrder(usageLogEntry),
|
||||
});
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.create_order_unrecognized_items'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
let order = getOrderFromUsageLogEntry(usageLogEntry);
|
||||
let orderItems = getOrderItemsFromUsageLogEntry(usageLogEntry);
|
||||
// Create the order
|
||||
let order_props = {
|
||||
customer_id: parseInt(order.customer_id),
|
||||
cashier_id: null,
|
||||
department_id: getDepartmentId(usageLogEntry),
|
||||
reference: null,
|
||||
reg_1: order.reg_1,
|
||||
reg_2: order.reg_2,
|
||||
reg_3: order.reg_3,
|
||||
notes: null,
|
||||
invoice_collection_id: null,
|
||||
}
|
||||
await SessionUser.objects.orders
|
||||
.add(
|
||||
order_props.customer_id,
|
||||
order_props.cashier_id,
|
||||
order_props.department_id,
|
||||
order_props.reference,
|
||||
order_props.reg_1,
|
||||
order_props.reg_2,
|
||||
order_props.reg_3,
|
||||
order_props.notes,
|
||||
order_props.invoice_collection_id,
|
||||
).then(
|
||||
async (response) => {
|
||||
if (response.status === 200) {
|
||||
let orderId = parseInt(response.data.data.id);
|
||||
// Add the wash id to the order
|
||||
await SessionUser.objects.orders.set.wash_id(orderId, usageLogEntry.WashId);
|
||||
// Set the time created to the start time
|
||||
await SessionUser.objects.orders.set.created_at(orderId, SessionUser.functions.date.format(SessionUser.superUser.modules.xlvask.functions.convertISODateTimeToDate(usageLogEntry.StartTime)));
|
||||
let relational_id = null;
|
||||
let itemFailure = false;
|
||||
// Add the order items to the order
|
||||
for (const item of orderItems.items) {
|
||||
await createOrderItem(
|
||||
orderId,
|
||||
item.product_id,
|
||||
item.quantity,
|
||||
relational_id,
|
||||
null,
|
||||
item.price.each
|
||||
).then(
|
||||
(response) => {
|
||||
if (response.status === 200) {
|
||||
// If the relational_id is null, set it to the order item id
|
||||
if (relational_id === null) {
|
||||
relational_id = parseInt(response.data.data.id);
|
||||
}
|
||||
} else {
|
||||
itemFailure = true;
|
||||
console.error("XL-Vask: order item returned non-OK status.", {
|
||||
orderId,
|
||||
productId: item?.product_id,
|
||||
status: response?.status,
|
||||
});
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.create_order_item_failed'),
|
||||
});
|
||||
}
|
||||
}
|
||||
).catch(
|
||||
(error) => {
|
||||
itemFailure = true;
|
||||
console.error("XL-Vask: failed to create order item.", { orderId, error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.create_order_item_failed'),
|
||||
});
|
||||
}
|
||||
)
|
||||
}
|
||||
if (!itemFailure) {
|
||||
onOrderCreated(orderId);
|
||||
}
|
||||
} else {
|
||||
console.error("XL-Vask: order creation returned non-OK status.", { status: response?.status });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.create_order_failed'),
|
||||
});
|
||||
}
|
||||
}
|
||||
).catch(
|
||||
(error) => {
|
||||
console.error("XL-Vask: failed to create order.", { washId: usageLogEntry?.WashId, error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.create_order_failed'),
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const onOrderCreated = (orderId) => {
|
||||
Swal.fire({
|
||||
title: t('tables.xlvask.order_created'),
|
||||
text: t('tables.xlvask.order_id', { id: orderId }),
|
||||
icon: "success",
|
||||
confirmButtonText: t('invoicing_period.xlvask_autopilot.labels.ok')
|
||||
});
|
||||
// Load the related orders
|
||||
getRelatedOrders();
|
||||
}
|
||||
|
||||
const hasUnrecognizedItems = (usageLogEntry) => {
|
||||
let orderItems = getOrderItemsFromUsageLogEntry(usageLogEntry);
|
||||
return orderItems.unrecognizedItems.length > 0;
|
||||
}
|
||||
|
||||
const hasUnrecognizedDepartment = (usageLogEntry) => {
|
||||
// Check if the department is in the list
|
||||
const department = getDepartmentId(usageLogEntry);
|
||||
return ( department === null || department === undefined );
|
||||
}
|
||||
|
||||
const listRelatedOrders = (usageLogEntry) => {
|
||||
// Check if the order is already created (If the wash id key is present in the related orders)
|
||||
if (related_orders.value) {
|
||||
let keys = Object.keys(related_orders.value);
|
||||
if (keys.includes(usageLogEntry.WashId)) {
|
||||
return related_orders.value[usageLogEntry.WashId];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasRelatedOrder = (usageLogEntry) => {
|
||||
// Check if the order is already created (If the wash id key is present in the related orders)
|
||||
/**
|
||||
* {
|
||||
* "892ae789-aeea-4bda-9374-cf931290aefd": [
|
||||
* 9997, // Order ID
|
||||
* 9998, // Order ID #2 (If multiple orders are related)
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
return listRelatedOrders(usageLogEntry) !== undefined && listRelatedOrders(usageLogEntry).length > 0;
|
||||
}
|
||||
|
||||
const canCreateOrder = (usageLogEntry) => {
|
||||
// Check if there are any unrecognized items, and if the department is recognized
|
||||
return (
|
||||
!hasUnrecognizedItems(usageLogEntry) &&
|
||||
!hasUnrecognizedDepartment(usageLogEntry) &&
|
||||
!hasRelatedOrder(usageLogEntry)
|
||||
);
|
||||
}
|
||||
|
||||
const getDepartmentId = (usageLogEntry) => {
|
||||
// Check if the department is in the list
|
||||
const department = departments.value.find(department => department.name === usageLogEntry.Location);
|
||||
if (department) {
|
||||
// If the department is found, return the department id
|
||||
return parseInt(department.id);
|
||||
} else {
|
||||
// If the department is not found, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const redirectDepartmentOrderPage = async (orderId) => {
|
||||
// Send the user to the order page (In a new tab)
|
||||
// Get the department id from the order
|
||||
await SessionUser.objects.orders.functions.get_department_id(orderId).then((response) => {
|
||||
// Get the department id from the response
|
||||
// Send the user to the order page (In a new tab)
|
||||
window.open(`/admin/${response}/modules/pos/orders/${orderId}`, '_blank');
|
||||
}).catch((error) => {
|
||||
console.error("XL-Vask: failed to resolve department id for related order.", { orderId, error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.redirect_order_failed'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getUsageStatus = (usageLogEntry) => {
|
||||
let result = {
|
||||
color_class: "has-text-grey",
|
||||
price: {
|
||||
total: 0,
|
||||
}
|
||||
}
|
||||
if (hasRelatedOrder(usageLogEntry)) {
|
||||
result.color_class = "has-text-success";
|
||||
}
|
||||
if (hasUnrecognizedItems(usageLogEntry)) {
|
||||
result.color_class = "has-text-danger";
|
||||
}
|
||||
if (hasUnrecognizedDepartment(usageLogEntry)) {
|
||||
result.color_class = "has-text-warning";
|
||||
}
|
||||
// Add the price to the result
|
||||
result.price = getOrderItemsFromUsageLogEntry(usageLogEntry).price;
|
||||
return result;
|
||||
}
|
||||
|
||||
const onClickCreateOrderAllApplicable = async () => {
|
||||
// Loop through all the usage log entries
|
||||
for (const usageLogEntry of usageLog.value) {
|
||||
// Check if the order can be created
|
||||
if (canCreateOrder(usageLogEntry)) {
|
||||
await onClickCreateOrder(usageLogEntry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ t('tables.xlvask.usage_log_title') }}</h1>
|
||||
<div class="buttons">
|
||||
<button class="button is-primary" @click="onClickCreateOrderAllApplicable">{{ t('tables.xlvask.create_order_all') }}</button>
|
||||
<button class="button is-info" :class="{ 'is-loading': usageLogLoading }" :disabled="usageLogLoading" @click="getUsageLog">{{ t('tables.xlvask.refresh') }}</button>
|
||||
</div>
|
||||
<table class="table mb-6">
|
||||
<!-- Table header -->
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="is-narrow"><!-- Status --></th>
|
||||
<th>{{ t('tables.common.customer') }}</th>
|
||||
<th>{{ t('tables.common.registration_number') }}</th>
|
||||
<th>{{ t('tables.common.price') }}</th>
|
||||
<th>{{ t('tables.common.start_time') }}</th>
|
||||
<th>{{ t('tables.common.end_time') }}</th>
|
||||
<th>{{ t('tables.common.location') }}</th>
|
||||
<th><!-- Actions --></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- Table body -->
|
||||
<tbody>
|
||||
<tr v-if="!usageLog || usageLog.length === 0">
|
||||
<td colspan="8" class="has-text-centered has-text-grey">
|
||||
{{ t('tables.xlvask.usage_log_empty') }}
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="(usage, index) in usageLog" :key="index">
|
||||
<tr>
|
||||
<td>
|
||||
<ColorIndicator
|
||||
v-bind:color_class="getUsageStatus(usage).color_class"
|
||||
v-bind:visibility="{
|
||||
icon: true,
|
||||
dropdown: false
|
||||
}"
|
||||
@onClick="() => { /* status indicator clicked */ }"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ usage.CustomerId }}</td>
|
||||
<td>{{ usage.RegistrationNumber }}</td>
|
||||
<td>
|
||||
<ColorIndicator
|
||||
v-bind:color_class="getUsageStatus(usage).color_class"
|
||||
v-bind:visibility="{
|
||||
icon: false,
|
||||
dropdown: true
|
||||
}"
|
||||
v-bind:label="{
|
||||
text: SessionUser.functions.currency.toLocal(getUsageStatus(usage).price.total),
|
||||
classes: []
|
||||
}"
|
||||
v-bind:dropdown_content="{
|
||||
content: [
|
||||
{
|
||||
text: t('common.services'),
|
||||
button: false,
|
||||
action: () => {},
|
||||
},
|
||||
...(getOrderItemsFromUsageLogEntry(usage).items.map(item => {
|
||||
return {
|
||||
text: `${SessionUser.objects.products.functions.getProductName(item.product_id)} - ${item.quantity} x ${SessionUser.functions.currency.toLocal(item.price.each)}`,
|
||||
button: true,
|
||||
action: () => {},
|
||||
button_text: SessionUser.functions.currency.toLocal(item.price.total),
|
||||
v_centered: true,
|
||||
}
|
||||
})),
|
||||
...(getOrderItemsFromUsageLogEntry(usage).unrecognizedItems.map(item => {
|
||||
return {
|
||||
text: `${item.OriginalProductName} - ${item.Count} x ${SessionUser.functions.currency.toLocal(item.UnitPrice)}`,
|
||||
button: true,
|
||||
action: () => {},
|
||||
icon: 'fas fa-exclamation-triangle',
|
||||
button_text: SessionUser.functions.currency.toLocal(item.PriceIncVat),
|
||||
v_centered: true,
|
||||
}
|
||||
}))
|
||||
]
|
||||
}"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ formatDate(usage.StartTime) }}</td>
|
||||
<td>{{ formatDate(usage.FinishTime) }}</td>
|
||||
<td>
|
||||
<ColorIndicator
|
||||
v-bind:color_class="getUsageStatus(usage).color_class"
|
||||
v-bind:visibility="{
|
||||
icon: false,
|
||||
dropdown: false
|
||||
}"
|
||||
v-bind:label="{
|
||||
text: usage.Location,
|
||||
classes: []
|
||||
}"
|
||||
v-bind:dropdown_content="{
|
||||
content: [
|
||||
{
|
||||
text: t('self_wash.lane'),
|
||||
button: true,
|
||||
button_text: usage.Hall,
|
||||
action: () => {},
|
||||
v_centered: true,
|
||||
}
|
||||
]
|
||||
}"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<ActionSettingsWheelButton>
|
||||
<template #actions>
|
||||
<!-- Create order based on usage log entry -->
|
||||
<ActionSettingsWheelItem
|
||||
:label="t('tables.xlvask.create_order')"
|
||||
icon="fas fa-plus"
|
||||
v-bind:disabled="!canCreateOrder(usage)"
|
||||
v-bind:click-action="() => onClickCreateOrder(usage)"
|
||||
/>
|
||||
<!-- If there's related orders, add them as buttons -->
|
||||
<template v-if="hasRelatedOrder(usage)">
|
||||
<ActionSettingsWheelItemLabel
|
||||
:label="SessionUser.objects.orders.meta.labels.multiple"
|
||||
/>
|
||||
<ActionSettingsWheelItem
|
||||
v-for="(order, orderIndex) in listRelatedOrders(usage)"
|
||||
:key="orderIndex"
|
||||
:label="`${SessionUser.functions.ucFirst(SessionUser.objects.orders.meta.labels.single)} #${order}`"
|
||||
icon="fas fa-file-invoice"
|
||||
v-bind:click-action="() => redirectDepartmentOrderPage(order)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</ActionSettingsWheelButton>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-if="usage.WashItems && usage.WashItems.length > 0">
|
||||
<!-- Nested table for wash items -->
|
||||
<tr class="xlvask-usage-log-wash-items-row">
|
||||
<td colspan="100%">
|
||||
<details class="xlvask-usage-log-wash-items" data-testid="xlvask-usage-log-wash-items">
|
||||
<summary class="is-size-7">
|
||||
{{ t('tables.common.wash_item') }} ({{ usage.WashItems.length }})
|
||||
</summary>
|
||||
<table class="table is-fullwidth is-striped is-hoverable is-bordered mt-2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('tables.common.wash_item') }}</th>
|
||||
<th>{{ t('tables.common.count') }}</th>
|
||||
<th>{{ t('tables.common.price') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, itemIndex) in usage.WashItems" :key="itemIndex">
|
||||
<td>{{ getProductName(item) }}</td>
|
||||
<td>{{ item.Count }}</td>
|
||||
<td>{{ SessionUser.functions.currency.toLocal(Number(item.PriceIncVat ?? 0).toFixed(2)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.xlvask-usage-log-wash-items-row > td {
|
||||
background: rgba(10, 10, 10, 0.03);
|
||||
padding: 0.5rem 0.85rem;
|
||||
}
|
||||
|
||||
.xlvask-usage-log-wash-items > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xlvask-usage-log-wash-items[open] > summary {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -35,14 +35,14 @@ const fetchObjects = () => {
|
||||
console.error("XL-Vask: customer list returned non-OK status.", { status: response?.status });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.load_customers_failed'),
|
||||
title: t('invoicing_period.xlvask_review.errors.load_customers_failed'),
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error("XL-Vask: failed to load customers.", { error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.load_customers_failed'),
|
||||
title: t('invoicing_period.xlvask_review.errors.load_customers_failed'),
|
||||
});
|
||||
}).finally(() => {
|
||||
isLoading.value = false;
|
||||
|
||||
@@ -29,7 +29,7 @@ const fetchObjects = () => {
|
||||
if (!isoDateFrom) {
|
||||
Swal.fire({
|
||||
icon: 'warning',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'),
|
||||
title: t('invoicing_period.xlvask_review.errors.load_usage_log_failed'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -43,14 +43,14 @@ const fetchObjects = () => {
|
||||
console.error("XL-Vask: usage log list returned non-OK status.", { status: response?.status });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'),
|
||||
title: t('invoicing_period.xlvask_review.errors.load_usage_log_failed'),
|
||||
});
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error("XL-Vask: failed to load usage log list.", { error });
|
||||
Swal.fire({
|
||||
icon: 'error',
|
||||
title: t('invoicing_period.xlvask_autopilot.errors.load_usage_log_failed'),
|
||||
title: t('invoicing_period.xlvask_review.errors.load_usage_log_failed'),
|
||||
});
|
||||
}).finally(() => {
|
||||
isLoading.value = false;
|
||||
|
||||
@@ -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 ?? {
|
||||
@@ -1780,493 +1824,6 @@ test.describe("Invoicing period tab", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("@smoke period view opens Selvvask import and attaching view", async ({ page }, testInfo) => {
|
||||
const usageOrderRequests = [];
|
||||
const fastLinkRequests = [];
|
||||
const automationPreviewRequests = [];
|
||||
const automationApplyRequests = [];
|
||||
const adjudicationRequests = [];
|
||||
const importUsageRequests = [];
|
||||
let accepted = false;
|
||||
let actionHalted = false;
|
||||
let automation = {
|
||||
id: 7101,
|
||||
status: "suggested",
|
||||
action: "attach_order",
|
||||
confidence: 0.93,
|
||||
calibrated_probability: 0.995,
|
||||
source: "fuzzy",
|
||||
reason: "Samme primære produkt og relaterede tillæg matcher en ordre fra samme dag. Ordre #7001.",
|
||||
matched_order_id: 7001,
|
||||
created_order_id: null,
|
||||
candidate_order: {
|
||||
id: 7001,
|
||||
department_id: 1,
|
||||
created_at: "2026-03-10 12:03:00",
|
||||
},
|
||||
proposed_order: null,
|
||||
can_accept: true,
|
||||
can_deny: true,
|
||||
can_ignore: true,
|
||||
can_attach_order: true,
|
||||
can_create_order: true,
|
||||
review_eligible: true,
|
||||
evidence: ["Samme registrering og total"],
|
||||
contradictions: [],
|
||||
risk_flags: [],
|
||||
plan_steps: ["Kontrollér ordre #7001", "Tilknyt vasken atomisk"],
|
||||
candidate_orders: [{ order_id: 7001, reason: "Samme registrering og total" }],
|
||||
expected_version: "usage-v1",
|
||||
run_id: "run-history-1",
|
||||
policy_version: "policy-v1",
|
||||
model: "gpt-5.6-sol",
|
||||
};
|
||||
let autoAdjudication = {
|
||||
id: 7201,
|
||||
suggestion_id: 7201,
|
||||
status: "auto_accepted",
|
||||
action: "create_order",
|
||||
source: "openai",
|
||||
review_eligible: false,
|
||||
adjudication_eligible: true,
|
||||
allowed_adjudication_outcomes: ["correct", "incorrect", "duplicate", "cross_hall", "unaudited"],
|
||||
run_id: "run-auto-1",
|
||||
policy_version: "policy-v1",
|
||||
model: "gpt-5.6-sol",
|
||||
};
|
||||
|
||||
await openPeriodView(page);
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/automation/capabilities**", async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
can_view: true,
|
||||
can_review: true,
|
||||
can_dry_run: true,
|
||||
can_execute: !actionHalted,
|
||||
can_manage_policy: true,
|
||||
can_halt: true,
|
||||
effective_stage: actionHalted ? "halted" : "ai_attach_canary",
|
||||
allowed_modes: actionHalted ? ["dry_run"] : ["dry_run", "execute"],
|
||||
blocked_reasons: actionHalted ? ["automation_halted"] : [],
|
||||
effective_action_sources: ["deterministic", "openai"],
|
||||
readiness: {
|
||||
ready: !actionHalted,
|
||||
effective_stage: actionHalted ? "halted" : "ai_attach_canary",
|
||||
policy_version: "policy-v1",
|
||||
model: "gpt-5.6-sol",
|
||||
worker_healthy: true,
|
||||
budgets: {
|
||||
attach_order: { remaining_global: 100, remaining_hall: 10 },
|
||||
create_order: { remaining_global: 20, remaining_hall: 3 },
|
||||
},
|
||||
review_progress: {
|
||||
attach_order: { reviewed: actionHalted ? 41 : 40, target: 200 },
|
||||
create_order: { reviewed: 0, target: 50 },
|
||||
},
|
||||
eligible_counts: { attach_order: 1, create_order: 0, total: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/automation/admin/readiness**", async (route) => {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
ready: !actionHalted,
|
||||
effective_stage: actionHalted ? "halted" : "ai_attach_canary",
|
||||
policy_version: "policy-v1",
|
||||
model: "gpt-5.6-sol",
|
||||
worker_healthy: true,
|
||||
blocked_reasons: actionHalted ? ["automation_halted"] : [],
|
||||
budgets: {
|
||||
attach_order: { remaining_global: 100, remaining_hall: 10 },
|
||||
create_order: { remaining_global: 20, remaining_hall: 3 },
|
||||
},
|
||||
review_progress: {
|
||||
attach_order: { reviewed: actionHalted ? 41 : 40, target: 200 },
|
||||
create_order: { reviewed: 0, target: 50 },
|
||||
},
|
||||
eligible_counts: { attach_order: 1, create_order: 0, total: 1 },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/autopilot-runs**", async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
if (route.request().method() === "GET" && path.endsWith("/autopilot-runs/active")) {
|
||||
await route.fulfill(json({ data: { run: null } }));
|
||||
return;
|
||||
}
|
||||
if (route.request().method() !== "POST" || !path.endsWith("/autopilot-runs")) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
importUsageRequests.push(JSON.parse(route.request().postData() || "{}"));
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
run: {
|
||||
id: "run-1",
|
||||
status: "completed",
|
||||
phase: "completed",
|
||||
processed: 2,
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/automation/decisions/**", async (route) => {
|
||||
const path = new URL(route.request().url()).pathname;
|
||||
const body = JSON.parse(route.request().postData() || "{}");
|
||||
if (path.endsWith("/preview")) {
|
||||
automationPreviewRequests.push(body);
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
preview: {
|
||||
id: "preview-1",
|
||||
selection_hash: "selection-1",
|
||||
action: body.action,
|
||||
items: [{ usage_log_id: 8101, before: {}, after: { linked_order_id: 7001 }, warnings: [] }],
|
||||
requires_confirmation: true,
|
||||
confirmation_phrase: "CONFIRM XLVASK",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (path.endsWith("/apply")) {
|
||||
automationApplyRequests.push(body);
|
||||
accepted = true;
|
||||
automation = { ...automation, status: "accepted", can_accept: false, can_deny: false };
|
||||
await route.fulfill(json({ data: { applied: 1, results: [{ usage_log_id: 8101 }], failed: [] } }));
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/automation/admin/calibrations/labels**", async (route) => {
|
||||
const body = JSON.parse(route.request().postData() || "{}");
|
||||
adjudicationRequests.push(body);
|
||||
if (body.outcome === "correct") {
|
||||
autoAdjudication = {
|
||||
...autoAdjudication,
|
||||
id: 7202,
|
||||
suggestion_id: 7202,
|
||||
allowed_adjudication_outcomes: ["incorrect"],
|
||||
};
|
||||
await route.fulfill(json({ data: { automatic_action_review: { outcome: "correct" } } }));
|
||||
return;
|
||||
}
|
||||
actionHalted = true;
|
||||
autoAdjudication = {
|
||||
...autoAdjudication,
|
||||
adjudication_eligible: false,
|
||||
allowed_adjudication_outcomes: [],
|
||||
};
|
||||
await route.fulfill(json({ data: { action_halted: true, automatic_action_review: { outcome: body.outcome } } }));
|
||||
});
|
||||
|
||||
await page.route("**/modules/xlvask/services/usage/orders**", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesApiPath(route.request().url(), "/modules/xlvask/services/usage/orders/fast-link")) {
|
||||
fastLinkRequests.push(route.request().url());
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
order_items: [
|
||||
{
|
||||
id: 9101,
|
||||
product_id: 301,
|
||||
product: { name: "Kassevogn/varevogn" },
|
||||
quantity: 1,
|
||||
price: 125,
|
||||
},
|
||||
],
|
||||
potential_duplicates: [],
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesApiPath(route.request().url(), "/modules/xlvask/services/usage/orders/summary")) {
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
summary: {
|
||||
total: 3,
|
||||
new: 1,
|
||||
updated: 0,
|
||||
unchanged: 2,
|
||||
invalid: 0,
|
||||
already_linked: accepted ? 1 : 0,
|
||||
auto_linked: 0,
|
||||
auto_created: 1,
|
||||
needs_review: accepted ? 0 : 1,
|
||||
blocked: 0,
|
||||
ignored: 0,
|
||||
failed: 1,
|
||||
certain: accepted ? 2 : 1,
|
||||
uncertain: accepted ? 0 : 1,
|
||||
none: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!matchesApiPath(route.request().url(), "/modules/xlvask/services/usage/orders")) {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(route.request().url());
|
||||
usageOrderRequests.push({
|
||||
filters: url.searchParams.get("filters") || "",
|
||||
limit: url.searchParams.get("limit") || "",
|
||||
});
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: [
|
||||
{
|
||||
id: 8101,
|
||||
reg_1: "AB12345",
|
||||
created_at: "2026-03-10 12:00:00",
|
||||
customer_id: 4001,
|
||||
customer_number: 4001,
|
||||
customer_name: "DEKRA AMU Center Hovedstaden A/S",
|
||||
department_id: 1,
|
||||
lane: 1,
|
||||
wash_id: "wash-selvvask-1",
|
||||
duplicates: [],
|
||||
fast_link_key: "temporary_cache_selfwash8101",
|
||||
total_net_amount: 125,
|
||||
xlvask_primary_product_name: "Kassevogn/varevogn",
|
||||
import_state: "new",
|
||||
resolution_state: accepted ? "already_linked" : "needs_review",
|
||||
certainty: accepted ? "certain" : "uncertain",
|
||||
planned_action: accepted ? "none" : "attach_order",
|
||||
automation,
|
||||
},
|
||||
{
|
||||
id: 8102,
|
||||
reg_1: "CD67890",
|
||||
created_at: "2026-03-10 12:15:00",
|
||||
customer_id: 4002,
|
||||
customer_number: 4002,
|
||||
customer_name: "Self Wash Transport",
|
||||
department_id: 2,
|
||||
lane: 2,
|
||||
wash_id: "wash-selvvask-2",
|
||||
duplicates: [],
|
||||
fast_link_key: null,
|
||||
total_net_amount: 88,
|
||||
xlvask_primary_product_name: "Varebil",
|
||||
import_state: "unchanged",
|
||||
resolution_state: "failed",
|
||||
certainty: "none",
|
||||
planned_action: "none",
|
||||
automation: {
|
||||
id: 7102,
|
||||
status: "failed",
|
||||
action: "attach_order",
|
||||
error: "No safe match",
|
||||
can_accept: false,
|
||||
can_deny: false,
|
||||
can_ignore: false,
|
||||
can_attach_order: false,
|
||||
can_create_order: false,
|
||||
review_eligible: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 8103,
|
||||
reg_1: "EF24680",
|
||||
created_at: "2026-03-10 12:30:00",
|
||||
customer_id: 4003,
|
||||
customer_number: 4003,
|
||||
customer_name: "Automatic Action Transport",
|
||||
department_id: 1,
|
||||
lane: 1,
|
||||
wash_id: "wash-selvvask-3",
|
||||
duplicates: [],
|
||||
fast_link_key: null,
|
||||
total_net_amount: 150,
|
||||
xlvask_primary_product_name: "Lastbil",
|
||||
import_state: "unchanged",
|
||||
resolution_state: "auto_created",
|
||||
certainty: "certain",
|
||||
planned_action: "none",
|
||||
automation: autoAdjudication,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 100,
|
||||
total: 3,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await setEntireMarchPeriod(page);
|
||||
|
||||
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toBeVisible();
|
||||
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (1/3)");
|
||||
const selfWashProgress = page.getByTestId("invoicing-period-view-selector-progress-self_wash");
|
||||
const selfWashProgressMetrics = await selfWashProgress.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const slot = element.parentElement?.getBoundingClientRect();
|
||||
const controls = element.parentElement?.parentElement?.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
slotWidth: slot?.width || 0,
|
||||
controlsWidth: controls?.width || 0,
|
||||
};
|
||||
});
|
||||
expect(
|
||||
selfWashProgressMetrics.width,
|
||||
`self-wash selector progress geometry: ${JSON.stringify(selfWashProgressMetrics)}`
|
||||
).toBeGreaterThanOrEqual(56);
|
||||
expect(selfWashProgressMetrics.height).toBeGreaterThan(0);
|
||||
await expect(selfWashProgress).toBeVisible();
|
||||
|
||||
await page.getByTestId("invoicing-period-view-selector-self_wash").click();
|
||||
await expect(page.getByTestId("invoicing-period-self-wash-view")).toBeVisible();
|
||||
const selfWashView = page.getByTestId("invoicing-period-self-wash-view");
|
||||
await expect(selfWashView.getByTestId("date-period-start")).toHaveCount(0);
|
||||
await expect(selfWashView.locator("input[placeholder='Søg i transaktioner']")).toHaveCount(0);
|
||||
await expect(selfWashView.getByText("Per side")).toHaveCount(0);
|
||||
await expect(selfWashView.getByText("Rækkefølge")).toHaveCount(0);
|
||||
await expect(selfWashView.getByText("Dato fra")).toHaveCount(0);
|
||||
await expect(selfWashView.getByText("Dato til")).toHaveCount(0);
|
||||
await expect(selfWashView.getByText("Vis kun ikke tilknyttede vaske")).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: /Selvvask/ })).toBeVisible();
|
||||
await expect(page.getByText("AB12345")).toBeVisible();
|
||||
await expect(page.getByText("CD67890")).toBeVisible();
|
||||
if (await captureXlvaskVisualEvidence(page, testInfo)) {
|
||||
return;
|
||||
}
|
||||
await expect(page.getByTestId("xlvask-summary-new")).toContainText("1");
|
||||
await expect(page.getByTestId("xlvask-summary-unchanged")).toContainText("2");
|
||||
await expect(page.getByTestId("xlvask-summary-uncertain")).toContainText("1");
|
||||
await expect(page.getByTestId("xlvask-summary-failed")).toContainText("1");
|
||||
await expect(page.getByTestId("xlvask-automation-controls")).toBeVisible();
|
||||
await expect(page.getByTestId("xlvask-automation-readiness")).toContainText("policy-v1");
|
||||
await expect(selfWashView.locator(".xlvask-usage-price").first()).toContainText("125");
|
||||
await expect(page.getByTestId("xlvask-resolution-state-8102")).toContainText("Mislykket");
|
||||
await expect(page.getByTestId("xlvask-certainty-8102")).toContainText("Ikke vurderet");
|
||||
expect(fastLinkRequests).toHaveLength(0);
|
||||
await expect(page.getByTestId("xlvask-automation-suggestion-8101")).toContainText("Foreslået: Tilknyt ordre #7001");
|
||||
await expect(selfWashView.locator(".xlvask-usage-card-header").first()).toBeVisible();
|
||||
|
||||
await page.getByText("EF24680").click();
|
||||
await page.getByTestId("xlvask-adjudication-correct-8103").click();
|
||||
await expect(page.getByRole("heading", { name: "Kontrollér det automatiske resultat" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Korrekt" }).click();
|
||||
await expect.poll(() => adjudicationRequests.length).toBe(1);
|
||||
expect(adjudicationRequests[0]).toEqual({ suggestion_id: 7201, outcome: "correct" });
|
||||
await expect(page.locator(".swal2-popup")).toHaveCount(0, { timeout: 4000 });
|
||||
|
||||
await selfWashView.getByRole("checkbox", { name: "Vælg vask #8101" }).check();
|
||||
await expect(page.getByTestId("xlvask-autopilot-bulk-bar")).toContainText("1 vaske valgt");
|
||||
await page.getByTestId("xlvask-autopilot-bulk-bar").getByRole("button", { name: "Ryd valg" }).click();
|
||||
await expect(page.getByTestId("xlvask-autopilot-bulk-bar")).toHaveCount(0);
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth))
|
||||
.toBeLessThanOrEqual(1);
|
||||
|
||||
await page.getByText("AB12345").click();
|
||||
await expect(page.getByTestId("xlvask-automation-evidence-8101")).toContainText("Samme registrering og total");
|
||||
await expect(page.getByTestId("xlvask-automation-candidates-8101")).toContainText("#7001");
|
||||
await expect(page.getByTestId("xlvask-automation-accept-8101")).toBeVisible();
|
||||
await page.getByTestId("xlvask-automation-accept-8101").click();
|
||||
await expect(page.getByRole("heading", { name: "Kontrollér ændringen" })).toBeVisible();
|
||||
await page.locator(".swal2-input").fill("CONFIRM XLVASK");
|
||||
await page.getByRole("button", { name: "Udfør" }).click();
|
||||
await expect(page.getByTestId("xlvask-automation-suggestion-8101")).toContainText("Accepteret #7001");
|
||||
expect(automationPreviewRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
usage_log_ids: [8101],
|
||||
action: "accept",
|
||||
suggestion_id: 7101,
|
||||
}),
|
||||
]);
|
||||
expect(automationApplyRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
preview_id: "preview-1",
|
||||
selection_hash: "selection-1",
|
||||
confirmation_text: "CONFIRM XLVASK",
|
||||
}),
|
||||
]);
|
||||
await expect(page.getByTestId("invoicing-period-view-selector-self_wash")).toContainText("Selvvask (2/3)");
|
||||
|
||||
await expect.poll(() => usageOrderRequests.length).toBeGreaterThan(0);
|
||||
expect(usageOrderRequests.some((request) => request.limit === "10000")).toBe(false);
|
||||
expect(
|
||||
usageOrderRequests.some(
|
||||
(request) =>
|
||||
request.filters.includes("StartTime-date_from:2026-03-01") &&
|
||||
request.filters.includes("StartTime-date_to:2026-03-31")
|
||||
)
|
||||
).toBe(true);
|
||||
await selfWashView.getByTestId("xlvask-autopilot-dry-run").click();
|
||||
await expect.poll(() => importUsageRequests.length).toBe(1);
|
||||
expect(importUsageRequests[0]).toMatchObject({
|
||||
dateFrom: "2026-03-01",
|
||||
dateTo: "2026-03-31",
|
||||
mode: "dry_run",
|
||||
forceRefetch: true,
|
||||
});
|
||||
expect(importUsageRequests[0].idempotency_key).toEqual(expect.any(String));
|
||||
|
||||
await selfWashView.getByTestId("xlvask-autopilot-execute").click();
|
||||
const executeDialog = page.locator(".swal2-popup");
|
||||
await expect(executeDialog).toContainText("2026-03-01");
|
||||
await expect(executeDialog).toContainText("deterministic, openai");
|
||||
await expect(executeDialog).toContainText("100 globalt; laveste restgrænse blandt haller 10");
|
||||
await expect(executeDialog).toContainText("20 globalt; laveste restgrænse blandt haller 3");
|
||||
await executeDialog.locator(".swal2-input").fill("KØR AUTOMATIK");
|
||||
await executeDialog.getByRole("button", { name: "Kør automatiske handlinger" }).click();
|
||||
await expect.poll(() => importUsageRequests.length).toBe(2);
|
||||
expect(importUsageRequests[1]).toMatchObject({ mode: "execute", forceRefetch: true });
|
||||
expect(importUsageRequests[1].idempotency_key).toEqual(expect.any(String));
|
||||
expect(importUsageRequests[1].idempotency_key).not.toBe(importUsageRequests[0].idempotency_key);
|
||||
|
||||
const incorrectAdjudication = page.getByTestId("xlvask-adjudication-incorrect-8103");
|
||||
if (!(await incorrectAdjudication.isVisible())) {
|
||||
await page.getByText("EF24680").click();
|
||||
}
|
||||
await incorrectAdjudication.click();
|
||||
await expect(page.getByRole("heading", { name: "Kontrollér det automatiske resultat" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Forkert" }).click();
|
||||
await expect.poll(() => adjudicationRequests.length).toBe(2);
|
||||
expect(adjudicationRequests[1]).toEqual({ suggestion_id: 7202, outcome: "incorrect" });
|
||||
await expect(page.getByRole("heading", { name: "Resultatet er gemt, og automatikken er stoppet" })).toBeVisible();
|
||||
await expect(page.locator(".swal2-popup")).toHaveCount(0, { timeout: 4000 });
|
||||
await expect(selfWashView.getByTestId("xlvask-autopilot-execute")).toHaveCount(0);
|
||||
await expect(page.getByTestId("xlvask-automation-readiness")).toContainText("Ikke klar");
|
||||
expect(importUsageRequests.filter((request) => request.mode === "execute")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("@smoke period view shows flags and saves automatic flag decisions", async ({ page }) => {
|
||||
const automaticStatusRequests = [];
|
||||
const manualStatusRequests = [];
|
||||
@@ -2947,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);
|
||||
|
||||
|
||||
@@ -605,3 +605,199 @@ test("desktop clears pending basket row when customer-rule create is rejected",
|
||||
await expect(cartPanel.getByTestId("pos-order-empty-state")).toBeVisible();
|
||||
await expect(cartPanel.locator('[data-testid^="pos-order-item-name-"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("desktop rolls back partial step-2 addon sync when one addon POST rejects", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
|
||||
|
||||
// Regression test for the bug where, on the desktop POS step 2, only some
|
||||
// addons ended up on the order when one of the parallel add-on POSTs was
|
||||
// rejected. The shared `addOrderItemAddons` helper now fans out the add-on
|
||||
// POSTs via Promise.allSettled and rolls back every order_items row that
|
||||
// landed during the failed attempt, so the operator sees a clean retry
|
||||
// state with no half-synced addons left on the order.
|
||||
const baseFixture = createPosFixture();
|
||||
const orderId = 9710;
|
||||
const primaryProduct = {
|
||||
...baseFixture.products.find((product) => Number(product.id) === 53),
|
||||
};
|
||||
const addonA = {
|
||||
...baseFixture.products.find((product) => Number(product.id) === 63),
|
||||
addons: [],
|
||||
};
|
||||
const addonB = {
|
||||
...baseFixture.products.find((product) => Number(product.id) === 64),
|
||||
addons: [],
|
||||
};
|
||||
addonA.id = 7001;
|
||||
addonA.name = "Addon A (will be rejected)";
|
||||
addonB.id = 7002;
|
||||
addonB.name = "Addon B (will be rolled back)";
|
||||
primaryProduct.addons = [
|
||||
{
|
||||
id: 8001,
|
||||
option_id: 7001,
|
||||
name: addonA.name,
|
||||
price: addonA.price,
|
||||
product: addonA,
|
||||
min: 0,
|
||||
max: 1,
|
||||
},
|
||||
{
|
||||
id: 8002,
|
||||
option_id: 7002,
|
||||
name: addonB.name,
|
||||
price: addonB.price,
|
||||
product: addonB,
|
||||
min: 0,
|
||||
max: 1,
|
||||
},
|
||||
];
|
||||
const fixture = createPosFixture({
|
||||
ordersById: {
|
||||
9710: {
|
||||
id: 9710,
|
||||
customer_id: 12345679,
|
||||
department_id: 12,
|
||||
reference: "STEP2-DESKTOP-ROLLBACK",
|
||||
notes: "",
|
||||
po: "",
|
||||
reg_1: "AB12345",
|
||||
reg_2: "",
|
||||
reg_3: "",
|
||||
invoice_collection_id: null,
|
||||
booking_id: null,
|
||||
completed_at: null,
|
||||
created_at: "2026-01-01T10:00:00.000Z",
|
||||
},
|
||||
...baseFixture.ordersById,
|
||||
},
|
||||
orderItemsByOrderId: {
|
||||
[orderId]: [],
|
||||
},
|
||||
products: [
|
||||
primaryProduct,
|
||||
addonA,
|
||||
addonB,
|
||||
...baseFixture.products.filter((product) => ![53, 63, 64].includes(Number(product.id))),
|
||||
],
|
||||
});
|
||||
const customer = fixture.customersByNumber[12345679];
|
||||
const orderItemPosts = [];
|
||||
const orderItemResponseIds = [];
|
||||
const orderItemDeletes = [];
|
||||
let rejectedAddonsRemaining = 0;
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: fixture,
|
||||
});
|
||||
await page.route(/\/order\/items(?:\?.*)?$/, async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() === "POST") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
orderItemPosts.push(body);
|
||||
// Reject the first add-on POST that targets the configured add-on
|
||||
// product. The shared helper fans out the add-ons in parallel via
|
||||
// Promise.allSettled, so the other add-on may have already landed by
|
||||
// the time the rejection is returned; the helper must roll it back.
|
||||
if (Number(body.product_id) === 7001 && rejectedAddonsRemaining > 0) {
|
||||
rejectedAddonsRemaining -= 1;
|
||||
await route.fulfill(
|
||||
json(
|
||||
{
|
||||
success: false,
|
||||
data: { message: "Addon 7001 rejected by backend" },
|
||||
},
|
||||
400
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
if (request.method() === "DELETE") {
|
||||
const url = new URL(request.url());
|
||||
const id = Number(url.searchParams.get("id") || 0);
|
||||
orderItemDeletes.push(id);
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
// Capture successful POST responses so the test can map the order_items
|
||||
// id back to the request that produced it. The route handler above only
|
||||
// short-circuits the 7001 rejection; every other POST falls through to
|
||||
// mockApi and surfaces here with its server-generated id.
|
||||
page.on("response", async (response) => {
|
||||
if (response.request().method() !== "POST") {
|
||||
return;
|
||||
}
|
||||
if (!/\/order\/items(?:\?.*)?$/.test(response.url())) {
|
||||
return;
|
||||
}
|
||||
if (response.status() >= 400) {
|
||||
return;
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
payload = null;
|
||||
}
|
||||
const id = Number(payload?.data?.id ?? payload?.id ?? 0);
|
||||
if (id > 0) {
|
||||
orderItemResponseIds.push(id);
|
||||
}
|
||||
});
|
||||
await primeOperatorSession(page, "pos-desktop-step2-rollback-token");
|
||||
|
||||
await openPosAndSelectCustomer(page, customer);
|
||||
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
||||
await expect(page.getByTestId("pos-step-2")).toBeVisible({ timeout: POS_STEP_TIMEOUT });
|
||||
|
||||
await page.getByTestId("pos-product-card-53").first().click();
|
||||
await expect(page.getByTestId("pos-addon-7001-name")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("pos-addon-7002-name")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Select both add-ons so the desktop middleware fans out two POSTs.
|
||||
await page.getByTestId("pos-addon-7001-name").click();
|
||||
await page.getByTestId("pos-addon-7002-name").click();
|
||||
|
||||
rejectedAddonsRemaining = 1;
|
||||
|
||||
// Click add-to-cart. The primary POST must succeed; the first addon POST
|
||||
// will reject; the second addon POST will succeed and must be rolled back.
|
||||
await page.getByTestId("pos-add-to-cart-53").first().click();
|
||||
|
||||
// 1 primary + 2 addons = 3 POST attempts.
|
||||
await expect.poll(() => orderItemPosts.length, { timeout: 10_000 }).toBe(3);
|
||||
expect(orderItemPosts.map((body) => Number(body.product_id))).toEqual([53, 7001, 7002]);
|
||||
|
||||
// The primary must be linked to no parent (related_item_id is null or 0).
|
||||
expect(Number(orderItemPosts[0].related_item_id || 0)).toBe(0);
|
||||
// Both addons must be linked to the primary by related_item_id.
|
||||
expect(Number(orderItemPosts[1].related_item_id)).toBeGreaterThan(0);
|
||||
expect(Number(orderItemPosts[2].related_item_id)).toBeGreaterThan(0);
|
||||
|
||||
// The successful addon (7002) must be rolled back via DELETE /order/items.
|
||||
// The shared helper uses Promise.allSettled under the hood, so by the
|
||||
// time the 7001 rejection surfaces the 7002 POST may have already landed;
|
||||
// the rollback must clean it up so the operator can retry without the
|
||||
// previous attempt's half-saved addon lingering on the order.
|
||||
await expect.poll(() => orderItemDeletes.length, { timeout: 10_000 }).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The 7001 rejection never reached the server, so no DELETE may target
|
||||
// an order_items row that points at product 7001. The middleware only
|
||||
// deletes the rows it created during the failed attempt — the addon 7002
|
||||
// row that came back with a successful POST. The primary 53 row is owned
|
||||
// by the surrounding createOrderItem call and must remain untouched, so
|
||||
// the rollback list must never contain the primary's order_item id.
|
||||
await expect.poll(() => orderItemResponseIds[0], { timeout: 10_000 }).toBeGreaterThan(0);
|
||||
const primaryOrderItemId = orderItemResponseIds[0];
|
||||
expect(orderItemDeletes).not.toContain(primaryOrderItemId);
|
||||
// The 7002 row (which landed) must be among the rolled-back ids.
|
||||
expect(orderItemResponseIds).toContain(orderItemDeletes[0]);
|
||||
});
|
||||
|
||||
@@ -3447,6 +3447,173 @@ test.describe("POS mobile order flow", () => {
|
||||
expect(createdProductIds).toEqual([53, 71, 91]);
|
||||
});
|
||||
|
||||
test("manual step 2 persists every selected primary product add-on (multi-addon happy path)", async ({ page }) => {
|
||||
// Regression test for the bug where only some of the selected primary
|
||||
// product add-ons were persisted to the order during mobile POS step 2.
|
||||
// We select two add-ons on the primary and one standalone additional
|
||||
// item, click Fuldfør, and assert that all four POST /order/items calls
|
||||
// landed and that the server-side rows are linked correctly.
|
||||
const orderId = 9426;
|
||||
const fixture = createMobilePosFixture({
|
||||
ordersById: {
|
||||
[orderId]: buildRegularOrder(orderId, {
|
||||
reference: "STEP2-MULTI-ADDON-REF",
|
||||
reg_1: "ZZ00000",
|
||||
}),
|
||||
},
|
||||
orderItemsByOrderId: {
|
||||
[orderId]: [],
|
||||
},
|
||||
});
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-step2-multi-addon-happy-token",
|
||||
seedState: {
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "ZZ00000",
|
||||
reference: "STEP2-MULTI-ADDON-REF",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: null,
|
||||
lastOrderId: null,
|
||||
},
|
||||
route: {
|
||||
step: 2,
|
||||
orderId,
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
},
|
||||
});
|
||||
|
||||
await selectPrimaryProduct(page, 53);
|
||||
|
||||
// Addons 71 and 41 are both attached to fixture product 53.
|
||||
await expect(page.getByTestId("pos-mobile-addon-71")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-addon-71").click();
|
||||
await expect(page.getByTestId("pos-mobile-addon-71-value")).toHaveText("1", { timeout: 10_000 });
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-addon-41")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-addon-41").click();
|
||||
await expect(page.getByTestId("pos-mobile-addon-41-value")).toHaveText("1", { timeout: 10_000 });
|
||||
|
||||
// One standalone additional item (id 91) so we also exercise the
|
||||
// additionalItems path alongside the add-ons.
|
||||
await longPressAdditionalItems(page);
|
||||
await expect(page.getByTestId("pos-mobile-additional-items-selection")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-category-8").click();
|
||||
await page.waitForTimeout(2_000);
|
||||
await page.getByTestId("pos-mobile-product-91").click();
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const snapshot = await getStoredPosSnapshot(page);
|
||||
return (snapshot?.transactionItems?.additionalItems || [])
|
||||
.map((item) => Number(item?.id))
|
||||
.sort((left, right) => left - right);
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual([91]);
|
||||
|
||||
// First click closes the additional-items fullscreen view (the
|
||||
// fullscreen "Next" button's customAction toggles additionalItemSelection
|
||||
// from true -> false via onClickAddOtherProduct). The second click lands
|
||||
// on the main step 2 layout's Complete button and triggers
|
||||
// syncCurrentTransactionToOrder via onBeforeComplete.
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
|
||||
// 1 primary + 2 addons + 1 additional = 4 POST /order/items calls.
|
||||
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(4);
|
||||
await expect.poll(() => (fixture.orderItemsByOrderId[orderId] || []).length, { timeout: 10_000 }).toBe(4);
|
||||
|
||||
const createdProductIds = fixture.requestLog.orderItemCreates.map((entry) => Number(entry.product_id));
|
||||
// The order of POSTs is: primary, then add-ons (in fixture order), then
|
||||
// additional items. Add-ons 71 and 41 are listed before the additional
|
||||
// item 91.
|
||||
expect(createdProductIds).toEqual([53, 71, 41, 91]);
|
||||
|
||||
// The add-on rows must be linked to the primary by related_item_id.
|
||||
const persistedItems = fixture.orderItemsByOrderId[orderId] || [];
|
||||
const primary = persistedItems.find((item) => item.product_id === 53 && item.related_item_id === null);
|
||||
expect(primary).toBeTruthy();
|
||||
const addon71 = persistedItems.find((item) => item.product_id === 71);
|
||||
const addon41 = persistedItems.find((item) => item.product_id === 41);
|
||||
expect(addon71?.related_item_id).toBe(primary.id);
|
||||
expect(addon41?.related_item_id).toBe(primary.id);
|
||||
// The standalone additional item must NOT be linked to the primary.
|
||||
const additional91 = persistedItems.find((item) => item.product_id === 91);
|
||||
expect(additional91?.related_item_id).toBeNull();
|
||||
});
|
||||
|
||||
test("mobile POS rolls back partial sync when one primary add-on POST fails", async ({ page }) => {
|
||||
// Regression test for the bug where a single add-on POST rejection left
|
||||
// the order with the primary and a subset of add-ons on the server while
|
||||
// the operator only saw a generic failure popup. The sync helper now
|
||||
// collects per-product failures via Promise.allSettled and rolls back
|
||||
// every order_items row it created during this attempt.
|
||||
const orderId = 9427;
|
||||
const fixture = createMobilePosFixture({
|
||||
failureBudget: {
|
||||
orderItemCreateForProductId: { 41: 1 },
|
||||
},
|
||||
ordersById: {
|
||||
[orderId]: buildRegularOrder(orderId, {
|
||||
reference: "STEP2-PARTIAL-ROLLBACK-REF",
|
||||
reg_1: "ZZ00000",
|
||||
}),
|
||||
},
|
||||
orderItemsByOrderId: {
|
||||
[orderId]: [],
|
||||
},
|
||||
});
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-step2-partial-rollback-token",
|
||||
seedState: {
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "ZZ00000",
|
||||
reference: "STEP2-PARTIAL-ROLLBACK-REF",
|
||||
includePrimaryItem: false,
|
||||
vehicleType: null,
|
||||
lastOrderId: null,
|
||||
},
|
||||
route: {
|
||||
step: 2,
|
||||
orderId,
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
},
|
||||
});
|
||||
|
||||
await selectPrimaryProduct(page, 53);
|
||||
await expect(page.getByTestId("pos-mobile-addon-71")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-addon-71").click();
|
||||
await expect(page.getByTestId("pos-mobile-addon-71-value")).toHaveText("1", { timeout: 10_000 });
|
||||
|
||||
await expect(page.getByTestId("pos-mobile-addon-41")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("pos-mobile-addon-41").click();
|
||||
await expect(page.getByTestId("pos-mobile-addon-41-value")).toHaveText("1", { timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("pos-mobile-next-step").click();
|
||||
|
||||
// The error popup should appear with the per-product failure message.
|
||||
const errorPopup = page.locator('[data-testid="pos-mobile-popup"][data-popup-id="error"]');
|
||||
await expect(errorPopup).toBeVisible({ timeout: 10_000 });
|
||||
await expect(errorPopup).toContainText("Product 41 rejected");
|
||||
|
||||
// The failure budget forced exactly one rejection on add-on 41.
|
||||
expect(fixture.failureBudget.orderItemCreateForProductId[41]).toBe(0);
|
||||
|
||||
// Rollback runs after the partial failure: every order_items row that
|
||||
// landed in this attempt must have been deleted, so the order ends
|
||||
// up empty (no half-synced state) and ready for a clean retry.
|
||||
await expect.poll(() => (fixture.orderItemsByOrderId[orderId] || []).length, { timeout: 10_000 }).toBe(0);
|
||||
await expect.poll(() => fixture.requestCounters.orderItemsDelete, { timeout: 10_000 }).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// The order is still open (not marked completed) because the failure
|
||||
// happened before completion could run.
|
||||
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 1_000 }).toBe(0);
|
||||
});
|
||||
|
||||
test("manual step 2 disables exact add-on and standalone product controls with tap tooltips", async ({ page }) => {
|
||||
const orderId = 9414;
|
||||
const fixture = createMobilePosFixture({
|
||||
@@ -4046,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({
|
||||
@@ -4572,6 +4827,55 @@ test.describe("POS mobile order flow", () => {
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("surfaces a backend rejection as an error popup instead of failing silently", async ({ page }) => {
|
||||
// Regression test: when syncCurrentTransactionToOrder hits an API rejection
|
||||
// (e.g. a 400 from POST /order/items that the frontend can't recover from
|
||||
// automatically), the operator used to see the Fuldfør button do nothing.
|
||||
// The catch block in PosDepartmentStepMobileButtonNextStep now opens the
|
||||
// standard error popup so the rejection is visible.
|
||||
const orderId = 9421;
|
||||
const fixture = createMobilePosFixture({
|
||||
failureBudget: {
|
||||
orderItemCreate: 1,
|
||||
},
|
||||
ordersById: {
|
||||
[orderId]: buildRegularOrder(orderId),
|
||||
},
|
||||
orderItemsByOrderId: {
|
||||
[orderId]: [],
|
||||
},
|
||||
});
|
||||
|
||||
await setupMobilePosPage(page, fixture, {
|
||||
token: "mobile-order-item-error-popup-token",
|
||||
seedState: {
|
||||
customerId: REGULAR_CUSTOMER_ID,
|
||||
reg: "AB12345",
|
||||
reference: "ERR-POPUP-REF",
|
||||
primaryItem: fixtureProduct(53),
|
||||
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 page.getByTestId("pos-mobile-next-step").click();
|
||||
|
||||
const errorPopup = page.locator('[data-testid="pos-mobile-popup"][data-popup-id="error"]');
|
||||
await expect(errorPopup).toBeVisible({ timeout: 10_000 });
|
||||
await expect(errorPopup).toContainText("Notes is required for this product");
|
||||
|
||||
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 1_000 }).toBe(0);
|
||||
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 5_000 }).toBeGreaterThanOrEqual(1);
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
function fixtureProduct(productId) {
|
||||
|
||||
@@ -1029,9 +1029,17 @@ test.describe("Superuser user overview", () => {
|
||||
|
||||
for (const tile of tileRoutes) {
|
||||
await page.goto("/superuser/users/11", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId(`superuser-user-overview-metric-${tile.key}`).hover();
|
||||
// The metric card has 140ms CSS transitions on background/border/transform/opacity
|
||||
// (see SuperuserOverviewMetricCard.vue). After `page.goto` returns on
|
||||
// `domcontentloaded` the previous iteration's hover/click transitions can still
|
||||
// be in flight, so Playwright's "visible, enabled, and stable" actionability
|
||||
// check times out on WebKit (flake observed on
|
||||
// E2E-full-WebKit-desktop-superuser-shard-2-of-2). `force: true` skips the
|
||||
// stability check while still moving the mouse / firing the real hover/click
|
||||
// events that drive the `:hover`-revealed action label.
|
||||
await page.getByTestId(`superuser-user-overview-metric-${tile.key}`).hover({ force: true });
|
||||
await expect(page.getByTestId(`superuser-user-overview-metric-${tile.key}-action`)).toContainText(tile.action);
|
||||
await page.getByTestId(`superuser-user-overview-metric-${tile.key}`).click();
|
||||
await page.getByTestId(`superuser-user-overview-metric-${tile.key}`).click({ force: true });
|
||||
await expect(page).toHaveURL(tile.path);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -395,6 +397,14 @@ function createFailureBudget(overrides = {}) {
|
||||
attachmentUpload: 0,
|
||||
orderDelete: 0,
|
||||
customerAttributesGet: 0,
|
||||
orderItemCreate: 0,
|
||||
// Per-product-id order-item creation failure budget. Keys are product
|
||||
// ids, values are how many POST /order/items calls for that product
|
||||
// should be rejected with 400. Used by regression tests for the mobile
|
||||
// POS step 2 partial-sync rollback (see
|
||||
// tests/unit/pos-mobile-step-2-addon-sync.spec.js for the matching
|
||||
// unit-level contract).
|
||||
orderItemCreateForProductId: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -1984,8 +1994,39 @@ export async function mockMobilePosApi(page, fixture) {
|
||||
if (pathname.endsWith("/order/items") && method === "POST") {
|
||||
recordCounter(fixture, "orderItemsPost");
|
||||
recordLog(fixture, "orderItemCreates", body);
|
||||
if (fixture.failureBudget.orderItemCreate > 0) {
|
||||
fixture.failureBudget.orderItemCreate -= 1;
|
||||
await route.fulfill(
|
||||
json(
|
||||
{
|
||||
success: false,
|
||||
data: {
|
||||
message: "Notes is required for this product",
|
||||
},
|
||||
},
|
||||
400
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const orderId = toPositiveInteger(body.order_id);
|
||||
const productId = toPositiveInteger(body.product_id);
|
||||
const perProductBudget = fixture.failureBudget.orderItemCreateForProductId || {};
|
||||
if (perProductBudget[productId] > 0) {
|
||||
perProductBudget[productId] -= 1;
|
||||
await route.fulfill(
|
||||
json(
|
||||
{
|
||||
success: false,
|
||||
data: {
|
||||
message: `Product ${productId} rejected`,
|
||||
},
|
||||
},
|
||||
400
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const order = fixture.ordersById[orderId];
|
||||
const product = getProductById(fixture, productId);
|
||||
if (!order || !product) {
|
||||
|
||||
@@ -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,91 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("vue-i18n", () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, fallback) => (typeof fallback === "string" ? fallback : key),
|
||||
}),
|
||||
}));
|
||||
|
||||
const parseError = vi.fn();
|
||||
const getError = vi.fn();
|
||||
vi.mock("@/components/request/HandleGlobalError.vue", () => ({
|
||||
parseError: (...args) => parseError(...args),
|
||||
getError: (...args) => getError(...args),
|
||||
}));
|
||||
|
||||
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
|
||||
|
||||
const findSaveButton = (wrapper) => wrapper.findAll("a.button").find((b) => b.find("i.fa-save").exists());
|
||||
const findEditButton = (wrapper) => wrapper.findAll("a.button").find((b) => b.find("i.fa-edit").exists());
|
||||
|
||||
const mountKey = (props = {}) =>
|
||||
mount(ConfigurationSecretKey, {
|
||||
props: {
|
||||
title: "API key",
|
||||
description: "desc",
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
describe("ConfigurationSecretKey", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the warning when not set, hidden view when set", () => {
|
||||
const unset = mountKey({ isSet: false });
|
||||
expect(unset.find(".message.is-warning").exists()).toBe(true);
|
||||
const setWrapper = mountKey({ isSet: true });
|
||||
expect(setWrapper.find(".message.is-warning").exists()).toBe(false);
|
||||
expect(setWrapper.find('input[value="Hidden"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("emits 'saved' after a successful onSave and exits edit mode", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue({ ok: true });
|
||||
const wrapper = mountKey({ onSave, isSet: false });
|
||||
// The input is disabled until the user enters edit mode — click Edit first,
|
||||
// then type, then Save. This matches the production UX for ConfigurationXLVask.
|
||||
await findEditButton(wrapper).trigger("click");
|
||||
await wrapper.find('input[type="text"]').setValue("super-secret");
|
||||
await findSaveButton(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
expect(onSave).toHaveBeenCalledWith("super-secret");
|
||||
expect(wrapper.emitted("saved")).toBeTruthy();
|
||||
expect(wrapper.emitted("saved").length).toBe(1);
|
||||
expect(wrapper.emitted("saved")[0][0]).toEqual(expect.objectContaining({ value: "super-secret" }));
|
||||
// Edit mode exited — the Save button is gone, the Edit button is back.
|
||||
expect(findSaveButton(wrapper)).toBeUndefined();
|
||||
expect(findEditButton(wrapper).exists()).toBe(true);
|
||||
// Whether the "Hidden" view shows after a save depends on the parent updating
|
||||
// isSet; that's the caller's responsibility and is exercised in the
|
||||
// ConfigurationXLVask end-to-end coverage.
|
||||
});
|
||||
|
||||
it("stays in edit mode and surfaces the error when onSave rejects", async () => {
|
||||
const failure = new Error("boom");
|
||||
const onSave = vi.fn().mockRejectedValue(failure);
|
||||
getError.mockReturnValue("boom");
|
||||
const wrapper = mountKey({ onSave, isSet: false });
|
||||
await findEditButton(wrapper).trigger("click");
|
||||
await wrapper.find('input[type="text"]').setValue("super-secret");
|
||||
await findSaveButton(wrapper).trigger("click");
|
||||
await flushPromises();
|
||||
expect(onSave).toHaveBeenCalledWith("super-secret");
|
||||
expect(wrapper.emitted("saved")).toBeFalsy();
|
||||
expect(parseError).toHaveBeenCalledWith(failure, expect.any(String));
|
||||
// Edit mode preserved so the user can retry.
|
||||
expect(findSaveButton(wrapper).exists()).toBe(true);
|
||||
expect(wrapper.find(".help.is-danger").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("renders localized warning message instead of hardcoded English", () => {
|
||||
// useI18n mock returns the fallback for any unknown key, so the warning body
|
||||
// should reflect whatever the caller passed via the i18n key fallback path.
|
||||
const wrapper = mountKey({ isSet: false });
|
||||
const body = wrapper.find(".message.is-warning .message-body").text();
|
||||
expect(body).toContain("The secret key is not set");
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
[
|
||||
@@ -466,4 +547,64 @@ describe("InvoicingPeriodFlagList", () => {
|
||||
});
|
||||
expect(wrapper.emitted("statusChanged")).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Regression test for TBC-105 / error-report #10: a `historical_primary_product_mismatch`
|
||||
// flag must surface the historical product name (e.g. "Forvogn med hænger") through the
|
||||
// i18n message_key path. The backend now only emits this flag when the current row's reg_2
|
||||
// presence matches the historical orders' reg_2 presence, so the FE must render whatever
|
||||
// the API does send. As of this commit there is no specific handler in flagMessageParts()
|
||||
// for `historical_primary_product_mismatch`; the component falls back to flag.message.
|
||||
// This test pins that contract so any future handler addition doesn't regress the
|
||||
// visible text on existing deployments.
|
||||
it("renders historical_primary_product_mismatch automatic flags with the historical product name", () => {
|
||||
const wrapper = mountList([
|
||||
{
|
||||
id: "auto-historical-1",
|
||||
source: "automatic",
|
||||
fingerprint: "historical-fingerprint-1",
|
||||
target_type: "order_item",
|
||||
target_id: 905,
|
||||
definition_key: "historical_primary_product_mismatch",
|
||||
message_key: "invoice_period.flags.automatic.historical_primary_product_mismatch",
|
||||
message_params: {
|
||||
product: "Forvogn",
|
||||
expected_product: "Forvogn med hænger",
|
||||
},
|
||||
message: "Forvogn differs from the registration number's usual product Forvogn med hænger.",
|
||||
order_id: 502,
|
||||
order_item_id: 905,
|
||||
},
|
||||
]);
|
||||
|
||||
const row = wrapper.get('[data-testid="invoice-period-flag-auto-historical-1"]');
|
||||
expect(row.text()).toContain("Forvogn");
|
||||
expect(row.text()).toContain("differs from");
|
||||
expect(row.text()).toContain("Forvogn med hænger");
|
||||
// The component does not yet emit an interactive order-item token for this flag type;
|
||||
// pinning the current behaviour so the eventual handler addition is intentional.
|
||||
expect(row.findAll(".invoice-period-flag-token")).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Backstop: the API may still send `historical_primary_product_mismatch` with no
|
||||
// message_params (older API versions, or a row that was cached before the schema bump).
|
||||
// The FE must not blow up; it should fall back to flag.message verbatim.
|
||||
it("falls back to flag.message when historical_primary_product_mismatch has no message_params", () => {
|
||||
const wrapper = mountList([
|
||||
{
|
||||
id: "auto-historical-2",
|
||||
source: "automatic",
|
||||
fingerprint: "historical-fingerprint-2",
|
||||
target_type: "order_item",
|
||||
target_id: 906,
|
||||
definition_key: "historical_primary_product_mismatch",
|
||||
message_key: "invoice_period.flags.automatic.historical_primary_product_mismatch",
|
||||
message: "Spot Free differs from the registration number's usual product Forvogn med hænger.",
|
||||
},
|
||||
]);
|
||||
|
||||
const row = wrapper.get('[data-testid="invoice-period-flag-auto-historical-2"]');
|
||||
expect(row.text()).toContain("Spot Free differs from the registration number's usual product Forvogn med hænger.");
|
||||
// No order-item token because order_id is missing — should fall back to plain text only.
|
||||
expect(row.findAll(".invoice-period-flag-token")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -729,6 +729,55 @@ describe("Invoicing period queue state", () => {
|
||||
expect(customer.get("[data-testid='invoice-period-flags-stub']").text()).toBe("2");
|
||||
});
|
||||
|
||||
it("keeps the Fakturer nu button visible for customers with red flags even when requires_action is false", async () => {
|
||||
sharedVariablesRef.value = {
|
||||
types: {
|
||||
all: [
|
||||
{
|
||||
id: 11,
|
||||
customer_number: 1011,
|
||||
customer_name: "Red Flag Only Customer",
|
||||
requires_action: false,
|
||||
status_indicator: "flag_red",
|
||||
flag_counts: {
|
||||
manual: 1,
|
||||
automatic: 0,
|
||||
total: 1,
|
||||
},
|
||||
flags: [
|
||||
{
|
||||
id: 1011,
|
||||
source: "manual",
|
||||
status: "active",
|
||||
target_type: "customer",
|
||||
target_id: 1011,
|
||||
customer_number: 1011,
|
||||
reason: "Manual flag — review required",
|
||||
},
|
||||
],
|
||||
transactions: [
|
||||
{
|
||||
id: 8101,
|
||||
amount: 60,
|
||||
booked: false,
|
||||
excluded: false,
|
||||
invoice_collection_id: 6101,
|
||||
date: "2026-04-15T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = mountView();
|
||||
await nextTick();
|
||||
|
||||
const customer = wrapper.get("[data-testid='invoicing-period-customer-1011']");
|
||||
expect(customer.get("[data-testid='invoicing-period-customer-invoice-1011']").text()).toContain("Invoice now");
|
||||
expect(customer.find("[data-testid='invoicing-period-customer-multiple-red-flag-1011']").exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps collected invoice flags visible even when linked transactions are excluded", async () => {
|
||||
sharedVariablesRef.value = {
|
||||
types: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import OrderContentTable from "@/components/displays/superuser/tables/OrderContentTable.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { createTestI18n } from "./helpers/mountWithApp.js";
|
||||
|
||||
const requestMock = vi.hoisted(() => vi.fn());
|
||||
const showPopperMock = vi.hoisted(() => vi.fn());
|
||||
const removePopperIfOpenMock = vi.hoisted(() => vi.fn());
|
||||
const popperBoxMock = vi.hoisted(() => vi.fn((title, body) => ({ title, body })));
|
||||
const swalFireMock = vi.hoisted(() => vi.fn(() => Promise.resolve({ isConfirmed: false })));
|
||||
|
||||
vi.mock("sweetalert2", () => ({
|
||||
default: {
|
||||
fire: swalFireMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/PopperDefault.vue", () => ({
|
||||
showPopper: showPopperMock,
|
||||
removePopperIfOpen: removePopperIfOpenMock,
|
||||
popperBox: popperBoxMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: {
|
||||
request: requestMock,
|
||||
functions: {
|
||||
currency: {
|
||||
toLocal: (value) => String(value ?? ""),
|
||||
},
|
||||
redirectTo: {
|
||||
department: vi.fn(),
|
||||
},
|
||||
},
|
||||
objects: {
|
||||
vehicles: {
|
||||
columns: {
|
||||
wash_subscription: {
|
||||
label: "Wash subscription",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const i18n = createTestI18n({
|
||||
en: {
|
||||
global: {
|
||||
cancel: "Cancel",
|
||||
no_data: "No data",
|
||||
quantity: "Quantity",
|
||||
reference: "Reference",
|
||||
},
|
||||
tables: {
|
||||
products: {
|
||||
name: "Product",
|
||||
price: "Price",
|
||||
},
|
||||
},
|
||||
objects: {
|
||||
columns: {
|
||||
notes: "Notes",
|
||||
},
|
||||
},
|
||||
invoice_period: {
|
||||
flags: {
|
||||
status: {
|
||||
resolved: "Resolved",
|
||||
ignored: "Ignored",
|
||||
false_positive: "False positive",
|
||||
reason_placeholder: "Optional reason",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const buildItem = (overrides) => ({
|
||||
id: overrides.id,
|
||||
related_item_id: overrides.related_item_id ?? 0,
|
||||
product_id: overrides.product_id ?? overrides.id,
|
||||
product: {
|
||||
id: overrides.product_id ?? overrides.id,
|
||||
name: overrides.name,
|
||||
},
|
||||
quantity: 1,
|
||||
price: 0,
|
||||
notes: "",
|
||||
reference: "",
|
||||
include_in_invoice: true,
|
||||
});
|
||||
|
||||
// Read the rendered product rows in display order. The component renders a
|
||||
// primary item as plain text in the first cell, and an addon as "+ <name>".
|
||||
// We ignore icon flag nodes and collapse whitespace. The returned shape is
|
||||
// { name, isAddon } so callers can assert on primary vs addon rows.
|
||||
const collectRenderedRows = (wrapper) => {
|
||||
const cells = wrapper.element.querySelectorAll("tbody td:first-child");
|
||||
const rows = [];
|
||||
cells.forEach((td) => {
|
||||
const children = Array.from(td.children).filter(
|
||||
(node) => !node.classList?.contains("invoice-period-item-flag-indicator")
|
||||
);
|
||||
const text = (children.length ? children : [td])
|
||||
.map((node) => node.textContent || "")
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const isAddon = text.startsWith("+");
|
||||
const name = isAddon ? text.slice(1).trim() : text;
|
||||
rows.push({ name, isAddon, raw: text });
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
|
||||
// Helper that returns the index of the first row matching the predicate.
|
||||
const indexOfRow = (rows, predicate) => rows.findIndex(predicate);
|
||||
|
||||
describe("OrderContentTable addon ordering", () => {
|
||||
beforeEach(() => {
|
||||
requestMock.mockReset();
|
||||
swalFireMock.mockClear();
|
||||
showPopperMock.mockClear();
|
||||
removePopperIfOpenMock.mockClear();
|
||||
popperBoxMock.mockClear();
|
||||
});
|
||||
|
||||
it("places the primary before its addons when the API returns them out of order", async () => {
|
||||
const primary = buildItem({ id: 101, product_id: 11, name: "Traekker" });
|
||||
const trailerAddon = buildItem({ id: 102, product_id: 12, name: "TrailerAddon", related_item_id: 101 });
|
||||
const dollyAddon = buildItem({ id: 103, product_id: 13, name: "DollyAddon", related_item_id: 101 });
|
||||
const spotFreeAddon = buildItem({ id: 104, product_id: 14, name: "SpotFreeAddon", related_item_id: 101 });
|
||||
const undervognAddon = buildItem({ id: 105, product_id: 15, name: "UndervognAddon", related_item_id: 101 });
|
||||
|
||||
// API returns rows in undefined order (addons listed before their primary).
|
||||
const apiOrderItems = [trailerAddon, spotFreeAddon, dollyAddon, primary, undervognAddon];
|
||||
|
||||
requestMock.mockResolvedValue({ data: { data: apiOrderItems } });
|
||||
|
||||
const wrapper = mount(OrderContentTable, {
|
||||
props: { orderId: 9001 },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
InvoicingPeriodFlagList: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
const rows = collectRenderedRows(wrapper);
|
||||
|
||||
// The primary ("Traekker") must always appear before its addons, regardless of API order.
|
||||
const primaryIndex = indexOfRow(rows, (row) => row.name === "Traekker");
|
||||
expect(primaryIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
["TrailerAddon", "DollyAddon", "SpotFreeAddon", "UndervognAddon"].forEach((name) => {
|
||||
const idx = indexOfRow(rows, (row) => row.name === name && row.isAddon);
|
||||
expect(idx).toBeGreaterThanOrEqual(0);
|
||||
expect(idx).toBeGreaterThan(primaryIndex);
|
||||
});
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("groups sibling addons under their parent when several primaries are present", async () => {
|
||||
const primaryA = buildItem({ id: 201, product_id: 21, name: "PrimaryA" });
|
||||
const primaryB = buildItem({ id: 202, product_id: 22, name: "PrimaryB" });
|
||||
const addonA1 = buildItem({ id: 203, product_id: 31, name: "AddonA1", related_item_id: 201 });
|
||||
const addonA2 = buildItem({ id: 204, product_id: 32, name: "AddonA2", related_item_id: 201 });
|
||||
const addonB1 = buildItem({ id: 205, product_id: 33, name: "AddonB1", related_item_id: 202 });
|
||||
|
||||
const apiOrderItems = [addonA1, addonB1, primaryB, addonA2, primaryA];
|
||||
|
||||
requestMock.mockResolvedValue({ data: { data: apiOrderItems } });
|
||||
|
||||
const wrapper = mount(OrderContentTable, {
|
||||
props: { orderId: 9002 },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
InvoicingPeriodFlagList: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
const rows = collectRenderedRows(wrapper);
|
||||
|
||||
const primaryAIndex = indexOfRow(rows, (row) => row.name === "PrimaryA");
|
||||
const primaryBIndex = indexOfRow(rows, (row) => row.name === "PrimaryB");
|
||||
const addonA1Index = indexOfRow(rows, (row) => row.name === "AddonA1");
|
||||
const addonA2Index = indexOfRow(rows, (row) => row.name === "AddonA2");
|
||||
const addonB1Index = indexOfRow(rows, (row) => row.name === "AddonB1");
|
||||
|
||||
expect(primaryAIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(primaryBIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(primaryAIndex).toBeLessThan(primaryBIndex);
|
||||
expect(addonA1Index).toBeGreaterThan(primaryAIndex);
|
||||
expect(addonA2Index).toBeGreaterThan(primaryAIndex);
|
||||
expect(addonA1Index).toBeLessThan(primaryBIndex);
|
||||
expect(addonA2Index).toBeLessThan(primaryBIndex);
|
||||
expect(addonB1Index).toBeGreaterThan(primaryBIndex);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("preserves a stable order when the consumer passes local items directly", async () => {
|
||||
const primary = buildItem({ id: 301, product_id: 31, name: "TraekkerLocal" });
|
||||
const addon = buildItem({ id: 302, product_id: 32, name: "TrailerAddonLocal", related_item_id: 301 });
|
||||
|
||||
requestMock.mockResolvedValue({ data: { data: [] } });
|
||||
|
||||
const wrapper = mount(OrderContentTable, {
|
||||
props: {
|
||||
orderId: 9003,
|
||||
useLocalOrderItems: true,
|
||||
localOrderItems: [addon, primary],
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
InvoicingPeriodFlagList: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
const rows = collectRenderedRows(wrapper);
|
||||
const primaryIndex = indexOfRow(rows, (row) => row.name === "TraekkerLocal");
|
||||
const addonIndex = indexOfRow(rows, (row) => row.name === "TrailerAddonLocal");
|
||||
expect(primaryIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(addonIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(primaryIndex).toBeLessThan(addonIndex);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("does not call the API when local order items are provided", async () => {
|
||||
const primary = buildItem({ id: 401, product_id: 41, name: "TraekkerNoApi" });
|
||||
|
||||
requestMock.mockResolvedValue({ data: { data: [primary] } });
|
||||
|
||||
const wrapper = mount(OrderContentTable, {
|
||||
props: {
|
||||
orderId: 9004,
|
||||
useLocalOrderItems: true,
|
||||
localOrderItems: [primary],
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
InvoicingPeriodFlagList: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
expect(requestMock).not.toHaveBeenCalled();
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("renders a Trækker order with all addons in the correct order regardless of API order", async () => {
|
||||
// The scenario from the bug report: Trækker + Trailer + Dolly + Spot-free + Undervognsskyld.
|
||||
// The API previously returned rows in undefined order, which made the FE render only
|
||||
// Trailer/Dolly on top of the table and bury the primary Trækker below them. With the
|
||||
// defensive sort, Trækker renders first and every addon renders after it.
|
||||
const primary = buildItem({ id: 501, product_id: 51, name: "TraekkerBugRepro" });
|
||||
const trailerAddon = buildItem({ id: 502, product_id: 52, name: "TrailerAddonBugRepro", related_item_id: 501 });
|
||||
const dollyAddon = buildItem({ id: 503, product_id: 53, name: "DollyAddonBugRepro", related_item_id: 501 });
|
||||
const spotFreeAddon = buildItem({ id: 504, product_id: 54, name: "SpotFreeAddonBugRepro", related_item_id: 501 });
|
||||
const undervognAddon = buildItem({ id: 505, product_id: 55, name: "UndervognAddonBugRepro", related_item_id: 501 });
|
||||
|
||||
// Worst case: all addons before their primary.
|
||||
const apiOrderItems = [trailerAddon, dollyAddon, spotFreeAddon, undervognAddon, primary];
|
||||
|
||||
requestMock.mockResolvedValue({ data: { data: apiOrderItems } });
|
||||
|
||||
const wrapper = mount(OrderContentTable, {
|
||||
props: { orderId: 9005 },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
InvoicingPeriodFlagList: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await flushPromises();
|
||||
|
||||
const rows = collectRenderedRows(wrapper);
|
||||
const primaryIndex = indexOfRow(rows, (row) => row.name === "TraekkerBugRepro");
|
||||
expect(primaryIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
["TrailerAddonBugRepro", "DollyAddonBugRepro", "SpotFreeAddonBugRepro", "UndervognAddonBugRepro"].forEach(
|
||||
(name) => {
|
||||
const idx = indexOfRow(rows, (row) => row.name === name && row.isAddon);
|
||||
expect(idx).toBeGreaterThan(primaryIndex);
|
||||
}
|
||||
);
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
addOrderItemAddons,
|
||||
buildAddonCreateOrderItemArgs,
|
||||
} from "@/components/displays/department/pos/utils/addOrderItemAddons.js";
|
||||
import { OrderItemsPartialSyncError } from "@/components/displays/department/pos/utils/orderItemsPartialSync.js";
|
||||
|
||||
const makeApi = (overrides = {}) => ({
|
||||
createOrderItem: overrides.createOrderItem ?? vi.fn(),
|
||||
removeOrderItem: overrides.removeOrderItem ?? vi.fn(async () => ({})),
|
||||
});
|
||||
|
||||
describe("buildAddonCreateOrderItemArgs", () => {
|
||||
it("normalizes a nested product-shaped add-on with the primary id as related_item_id", () => {
|
||||
const args = buildAddonCreateOrderItemArgs(
|
||||
{
|
||||
option_id: 71,
|
||||
quantity: 2,
|
||||
product: { id: 71, price: 99 },
|
||||
},
|
||||
900
|
||||
);
|
||||
|
||||
expect(args).toEqual({
|
||||
productId: 71,
|
||||
quantity: 2,
|
||||
relatedItemId: 900,
|
||||
notes: "",
|
||||
skipPriceOverride: false,
|
||||
overridePrice: false,
|
||||
price: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits the price when the caller does not request an override (preserves desktop default-price behaviour)", () => {
|
||||
const args = buildAddonCreateOrderItemArgs({ option_id: 71, quantity: 1, product: { id: 71, price: 99 } }, 900);
|
||||
|
||||
expect(args.overridePrice).toBe(false);
|
||||
expect(args.price).toBeNull();
|
||||
});
|
||||
|
||||
it("includes the price when the caller explicitly opts in to priceOverride", () => {
|
||||
const args = buildAddonCreateOrderItemArgs(
|
||||
{ option_id: 71, quantity: 1, priceOverride: true, product: { id: 71, price: 99 } },
|
||||
900
|
||||
);
|
||||
|
||||
expect(args.overridePrice).toBe(true);
|
||||
expect(args.price).toBe(99);
|
||||
});
|
||||
|
||||
it("returns null price when skip_price_override is true even with priceOverride", () => {
|
||||
const args = buildAddonCreateOrderItemArgs(
|
||||
{
|
||||
option_id: 71,
|
||||
quantity: 1,
|
||||
priceOverride: true,
|
||||
product: { id: 71, price: 99, skip_price_override: true },
|
||||
},
|
||||
900
|
||||
);
|
||||
|
||||
expect(args.skipPriceOverride).toBe(true);
|
||||
expect(args.price).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null price when the addon has no product metadata and no override is requested", () => {
|
||||
const args = buildAddonCreateOrderItemArgs({ option_id: 71, quantity: 1 }, 900);
|
||||
expect(args.overridePrice).toBe(false);
|
||||
expect(args.price).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addOrderItemAddons", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates every add-on linked to the primary on the happy path", async () => {
|
||||
const createdIds = { value: 200 };
|
||||
const api = makeApi({
|
||||
createOrderItem: vi.fn(async () => {
|
||||
const id = ++createdIds.value;
|
||||
return { data: { data: { id } } };
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await addOrderItemAddons({
|
||||
orderId: 9400,
|
||||
primaryItemId: 199,
|
||||
addons: [
|
||||
{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } },
|
||||
{ option_id: 41, quantity: 2, product: { id: 41, price: 25 } },
|
||||
],
|
||||
api,
|
||||
});
|
||||
|
||||
expect(api.createOrderItem).toHaveBeenCalledTimes(2);
|
||||
expect(api.createOrderItem.mock.calls.map(([, productId]) => productId)).toEqual([71, 41]);
|
||||
// Each add-on POST is linked to the primary via related_item_id.
|
||||
expect(api.createOrderItem.mock.calls[0][3]).toBe(199);
|
||||
expect(api.createOrderItem.mock.calls[1][3]).toBe(199);
|
||||
expect(api.removeOrderItem).not.toHaveBeenCalled();
|
||||
expect(result.createdAddonIds).toHaveLength(2);
|
||||
expect(result.createdAdditionalIds).toHaveLength(0);
|
||||
expect(result.createdIds).toEqual([201, 202]);
|
||||
});
|
||||
|
||||
it("returns empty arrays when no addons or additional items are provided", async () => {
|
||||
const api = makeApi();
|
||||
|
||||
const result = await addOrderItemAddons({
|
||||
orderId: 9401,
|
||||
primaryItemId: 199,
|
||||
addons: [],
|
||||
additionalItems: [],
|
||||
api,
|
||||
});
|
||||
|
||||
expect(api.createOrderItem).not.toHaveBeenCalled();
|
||||
expect(result.createdIds).toEqual([]);
|
||||
expect(result.createdAddonIds).toEqual([]);
|
||||
expect(result.createdAdditionalIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("fans out additional items with related_item_id = null", async () => {
|
||||
const createdIds = { value: 300 };
|
||||
const api = makeApi({
|
||||
createOrderItem: vi.fn(async () => {
|
||||
const id = ++createdIds.value;
|
||||
return { data: { data: { id } } };
|
||||
}),
|
||||
});
|
||||
|
||||
await addOrderItemAddons({
|
||||
orderId: 9402,
|
||||
primaryItemId: 199,
|
||||
addons: [{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } }],
|
||||
additionalItems: [{ id: 91, quantity: 1, price: 120 }],
|
||||
api,
|
||||
});
|
||||
|
||||
expect(api.createOrderItem).toHaveBeenCalledTimes(2);
|
||||
// Addon linked to the primary.
|
||||
expect(api.createOrderItem.mock.calls[0][3]).toBe(199);
|
||||
// Additional item is standalone.
|
||||
expect(api.createOrderItem.mock.calls[1][3]).toBeNull();
|
||||
});
|
||||
|
||||
it("rolls back every add-on row that landed when one POST rejects", async () => {
|
||||
let call = 0;
|
||||
const api = makeApi({
|
||||
createOrderItem: vi.fn(async (_orderId, productId) => {
|
||||
call += 1;
|
||||
if (productId === 41) {
|
||||
const reason = new Error("addon 41 rejected");
|
||||
reason.response = { data: { data: { message: "Product 41 rejected" } } };
|
||||
throw reason;
|
||||
}
|
||||
return { data: { data: { id: 1000 + call } } };
|
||||
}),
|
||||
});
|
||||
|
||||
let captured;
|
||||
try {
|
||||
await addOrderItemAddons({
|
||||
orderId: 9403,
|
||||
primaryItemId: 199,
|
||||
addons: [
|
||||
{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } },
|
||||
{ option_id: 41, quantity: 1, product: { id: 41, price: 25 } },
|
||||
{ option_id: 99, quantity: 1, product: { id: 99, price: 10 } },
|
||||
],
|
||||
api,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(OrderItemsPartialSyncError);
|
||||
expect(api.createOrderItem).toHaveBeenCalledTimes(3);
|
||||
|
||||
// The shared helper rolls back the add-ons that landed (71 succeeded,
|
||||
// 41 rejected, 99 was issued in the same fan-out). The successful
|
||||
// add-ons (71 and 99) are removed; the rejected one never landed.
|
||||
const rolledBack = api.removeOrderItem.mock.calls.map(([id]) => id).sort((a, b) => a - b);
|
||||
expect(rolledBack).toEqual([1001, 1003]);
|
||||
|
||||
expect(captured.message).toBe("Product 41: Product 41 rejected");
|
||||
expect(captured.failures).toEqual([{ productId: 41, message: "Product 41 rejected" }]);
|
||||
expect(captured.rolledBackIds).toEqual([1001, 1003]);
|
||||
});
|
||||
|
||||
it("does NOT roll back the primary itself — the mobile helper owns that responsibility", async () => {
|
||||
const api = makeApi({
|
||||
createOrderItem: vi.fn(async (_orderId, productId) => {
|
||||
if (productId === 41) {
|
||||
throw Object.assign(new Error("backend rejection"), {
|
||||
response: { data: { data: { message: "Customer rule block" } } },
|
||||
});
|
||||
}
|
||||
return { data: { data: { id: 1000 + Number(productId) } } };
|
||||
}),
|
||||
});
|
||||
|
||||
let captured;
|
||||
try {
|
||||
await addOrderItemAddons({
|
||||
orderId: 9404,
|
||||
primaryItemId: 199,
|
||||
addons: [
|
||||
{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } },
|
||||
{ option_id: 41, quantity: 1, product: { id: 41, price: 25 } },
|
||||
],
|
||||
api,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(OrderItemsPartialSyncError);
|
||||
// Only the add-on row was rolled back; the primary (199) is the
|
||||
// caller's responsibility and is not touched here.
|
||||
const rolledBack = api.removeOrderItem.mock.calls.map(([id]) => id);
|
||||
expect(rolledBack).not.toContain(199);
|
||||
});
|
||||
|
||||
it("throws immediately when the primary item id is missing", async () => {
|
||||
const api = makeApi();
|
||||
|
||||
await expect(
|
||||
addOrderItemAddons({
|
||||
orderId: 9405,
|
||||
primaryItemId: null,
|
||||
addons: [{ option_id: 71, quantity: 1, product: { id: 71 } }],
|
||||
api,
|
||||
})
|
||||
).rejects.toThrow("Primary order item ID is required");
|
||||
});
|
||||
|
||||
it("throws immediately when the order id is missing", async () => {
|
||||
const api = makeApi();
|
||||
|
||||
await expect(
|
||||
addOrderItemAddons({
|
||||
orderId: 0,
|
||||
primaryItemId: 199,
|
||||
addons: [{ option_id: 71, quantity: 1, product: { id: 71 } }],
|
||||
api,
|
||||
})
|
||||
).rejects.toThrow("Order ID is required");
|
||||
});
|
||||
|
||||
it("passes a null price (preserves desktop default-price behaviour) when priceOverride is unset", async () => {
|
||||
const api = makeApi({
|
||||
createOrderItem: vi.fn(async () => ({ data: { data: { id: 500 } } })),
|
||||
});
|
||||
|
||||
await addOrderItemAddons({
|
||||
orderId: 9406,
|
||||
primaryItemId: 199,
|
||||
addons: [{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } }],
|
||||
api,
|
||||
});
|
||||
|
||||
// The 6th arg is the price (forcePrice on createOrderItem). When the
|
||||
// caller does not opt in to priceOverride, we pass null so the server
|
||||
// falls back to the product's default price.
|
||||
expect(api.createOrderItem.mock.calls[0][5]).toBeNull();
|
||||
});
|
||||
|
||||
it("passes the price to the server when priceOverride is true (mobile path)", async () => {
|
||||
const api = makeApi({
|
||||
createOrderItem: vi.fn(async () => ({ data: { data: { id: 500 } } })),
|
||||
});
|
||||
|
||||
await addOrderItemAddons({
|
||||
orderId: 9407,
|
||||
primaryItemId: 199,
|
||||
addons: [{ option_id: 71, quantity: 1, priceOverride: true, product: { id: 71, price: 99 } }],
|
||||
api,
|
||||
});
|
||||
|
||||
expect(api.createOrderItem.mock.calls[0][5]).toBe(99);
|
||||
});
|
||||
|
||||
it("swallows rollback failures so the original error still surfaces", async () => {
|
||||
const api = makeApi({
|
||||
createOrderItem: vi.fn(async (_orderId, productId) => {
|
||||
if (productId === 41) {
|
||||
throw Object.assign(new Error("rejected"), {
|
||||
response: { data: { data: { message: "Product 41 rejected" } } },
|
||||
});
|
||||
}
|
||||
return { data: { data: { id: 1000 + productId } } };
|
||||
}),
|
||||
removeOrderItem: vi.fn(async () => {
|
||||
throw new Error("rollback removeOrderItem failed");
|
||||
}),
|
||||
});
|
||||
|
||||
let captured;
|
||||
try {
|
||||
await addOrderItemAddons({
|
||||
orderId: 9408,
|
||||
primaryItemId: 199,
|
||||
addons: [
|
||||
{ option_id: 71, quantity: 1, product: { id: 71, price: 99 } },
|
||||
{ option_id: 41, quantity: 1, product: { id: 41, price: 25 } },
|
||||
],
|
||||
api,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(OrderItemsPartialSyncError);
|
||||
expect(captured.failures).toEqual([{ productId: 41, message: "Product 41 rejected" }]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user