Compare commits

..
Author SHA1 Message Date
Jeppe B 993beab80d Configure advisory Qodana analysis 2026-07-16 13:33:00 +02:00
Jeppe B 18302723e8 Prepare frontend master branch protection (#172)
Add stable Required CI, repair E2E ownership, make Qodana optional-state handling explicit, and document the desired protection policy.
2026-07-16 13:12:57 +02:00
Jeppe B 20c383d22d Format customer rule tests 2026-07-16 12:11:06 +02:00
Jeppe B 77a0c4e018 Add customer rule product restrictions 2026-07-16 11:51:42 +02:00
Jeppe B 9a5c8b193d style: format admin order filter test 2026-07-16 11:50:41 +02:00
Jeppe B 0562ba8ab0 feat: add admin order filters 2026-07-16 11:45:08 +02:00
Jeppe Bundgaard 906e860c86 test: distinguish order table requests 2026-07-16 01:29:02 +02:00
Jeppe B 516e1fb58d Expose invoicing flag badge translations 2026-07-15 22:47:11 +02:00
Jeppe B 7da1307cf6 Restore i18n locale compatibility keys 2026-07-15 21:37:21 +02:00
Jeppe B ea4893d815 Ignore warm prefetches in month selection E2E 2026-07-15 20:50:02 +02:00
Jeppe B f2e0079b59 Stabilize invoicing period E2E scenarios 2026-07-15 20:26:49 +02:00
Jeppe B 47d8baa496 Format frontend test files 2026-07-15 19:42:27 +02:00
Jeppe B 6d96d64811 Improve invoicing period tree and POS restrictions 2026-07-15 17:04:51 +02:00
Jeppe B 92fb998e5a Fix customer additional-service restrictions (#171) 2026-07-15 15:08:01 +02:00
99 changed files with 5339 additions and 857 deletions
+50
View File
@@ -0,0 +1,50 @@
# Default branch protection
The intended repository ruleset is stored in
[`rulesets/protect-default-branch.json`](rulesets/protect-default-branch.json).
It targets the configured default branch and requires pull requests, the strict
`Required CI` check from GitHub Actions, resolved review conversations,
squash-only merges, and linear history. Branch deletion and force pushes are
blocked. Qodana remains advisory and is not part of the required gate.
The ruleset's `RepositoryRole` actor ID `5` is GitHub's built-in Administrator
role. Its `pull_request` bypass mode permits an administrator to bypass rules
only while merging an existing pull request; it does not permit a direct push.
## Repository settings
Keep squash merge enabled and disable merge commits and rebase merge. Enable
auto-merge, the update-branch option, and automatic deletion of merged head
branches. Keep the Actions token read-only and do not allow Actions to approve
pull-request reviews.
## Activation and verification
1. Confirm a pull request and a `master` push each produce exactly one
successful `Required CI` check from GitHub Actions integration `15368`.
2. For the initial ruleset POST, override the committed JSON's `enforcement`
value to `disabled`, then compare GitHub's normalized API response with this
file.
3. PUT the exact committed JSON to the inspected ruleset to activate it.
4. Open a canary pull request and confirm that pending or failing CI, unresolved
conversations, and an out-of-date branch block merging; only squash merge is
available.
5. After merging, confirm the head branch is deleted and the post-merge full
E2E, frontend release, and mobile release guards still run.
If validation exposes a blocker, disable the ruleset rather than deleting it so
its configuration and history remain available.
## Normal publishing flow
Create a scoped feature branch, open a pull request to `master`, wait for
`Required CI`, update the branch if `master` advanced, resolve every review
conversation, and squash-merge. For waits expected to exceed 90 seconds, use
the workspace `scripts/ci-watch.sh` helper instead of repeatedly polling GitHub.
## Break glass
For an incident, an administrator must still open a pull request. Document the
incident and why the normal gate cannot complete, then use the PR-only bypass
when merging. Monitor all post-merge workflows and open a follow-up pull request
for any validation or remediation deferred during the incident.
@@ -0,0 +1,53 @@
{
"name": "Protect default branch",
"target": "branch",
"enforcement": "active",
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "pull_request"
}
],
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{
"type": "deletion"
},
{
"type": "non_fast_forward"
},
{
"type": "required_linear_history"
},
{
"type": "pull_request",
"parameters": {
"allowed_merge_methods": ["squash"],
"dismiss_stale_reviews_on_push": false,
"require_code_owner_review": false,
"require_last_push_approval": false,
"required_approving_review_count": 0,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"do_not_enforce_on_create": false,
"required_status_checks": [
{
"context": "Required CI",
"integration_id": 15368
}
],
"strict_required_status_checks_policy": true
}
}
]
}
+52 -18
View File
@@ -1,32 +1,66 @@
name: Qodana Configuration Upload
name: Qodana
on:
push:
branches: [main, dev]
pull_request:
branches: [main]
workflow_dispatch:
pull_request:
branches: [master, beta, canary, internal]
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [master, beta, canary, internal]
permissions:
contents: read
checks: write
pull-requests: write
concurrency:
group: qodana-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.ref }}
cancel-in-progress: true
jobs:
upload-qodana-config:
runs-on: [self-hosted, Linux, X64, default]
timeout-minutes: 10
qodana:
name: Qodana
if: >-
github.event_name != 'pull_request' ||
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.pull_request.user.login != 'dependabot[bot]'
)
runs-on: ubuntu-24.04
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v5
# v5.0.1
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Run Qodana Configuration Uploader
- name: Require Qodana project token
shell: bash
env:
QODANA_CONFIGURATIONS_TOKEN: ${{ secrets.QODANA_CONFIGURATIONS_TOKEN }}
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
run: |
docker run --rm \
-v "$(pwd):/workspace" \
-w /workspace \
-e QODANA_CONFIGURATIONS_TOKEN \
jetbrains/qodana-configuration-uploader@sha256:f4786ceea616048c3401cf0b0345d2220d22a2ec7b046fd48cbbfc522e6efe30 \
--global-configs-file qodana-global-configurations.yaml \
--qodana-host https://qodana.cloud
set -euo pipefail
if [[ -z "${QODANA_TOKEN:-}" ]]; then
echo "::error::QODANA_TOKEN is not configured for this repository."
exit 1
fi
- name: Qodana
# v2026.1.3
uses: JetBrains/qodana-action@4861e015da555e86a72b862892aba6c2b93e6891
with:
use-caches: true
cache-default-branch-only: true
upload-result: false
use-annotations: true
pr-mode: ${{ github.event_name == 'pull_request' }}
post-pr-comment: true
github-token: ${{ github.token }}
push-fixes: none
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
+27 -2
View File
@@ -48,8 +48,8 @@ permissions:
contents: read
concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.head_ref || github.ref_name }}
cancel-in-progress: true
group: frontend-tests-${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Repository variables used as CI runner and credit controls:
# - FRONTEND_CI_STANDARD_RUNNER: JSON runs-on value for format/build/unit jobs.
@@ -511,6 +511,31 @@ jobs:
if-no-files-found: ignore
retention-days: 1
required-ci:
if: ${{ always() && (github.event_name == 'pull_request' || github.event_name == 'push') }}
name: Required CI
needs: [format-tests, build-and-unit, e2e-pr]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify required jobs succeeded
shell: bash
env:
FORMAT_TESTS_RESULT: ${{ needs.format-tests.result }}
BUILD_AND_UNIT_RESULT: ${{ needs.build-and-unit.result }}
E2E_PR_RESULT: ${{ needs.e2e-pr.result }}
run: |
set -euo pipefail
failed=0
for required_job in FORMAT_TESTS_RESULT BUILD_AND_UNIT_RESULT E2E_PR_RESULT; do
result="${!required_job:-missing}"
if [[ "$result" != "success" ]]; then
echo "${required_job}=${result}" >&2
failed=1
fi
done
exit "$failed"
e2e-full:
if: >
always() &&
+10
View File
@@ -16,6 +16,16 @@ See [Vite Configuration Reference](https://vite.dev/config/).
npm install
```
## Contributing Changes
Create a scoped feature branch, push it, and open a pull request targeting
`master`. Do not push directly to `master`. Merge only after the `Required CI`
check succeeds, all review conversations are resolved, and the branch is up to
date. Use squash merge so `master` retains linear history.
See [`.github/BRANCH_PROTECTION.md`](.github/BRANCH_PROTECTION.md) for the
repository policy, rollout checks, and emergency bypass procedure.
### Compile and Hot-Reload for Development
```sh
+16 -40
View File
@@ -1,46 +1,22 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
linter: jetbrains/qodana-js:2026.1
#Specify inspection profile for code analysis
profile:
name: qodana.starter
name: qodana.recommended
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
bootstrap: npm ci --legacy-peer-deps
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
include:
- name: Eslint
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-js:2025.3
exclude:
- name: All
paths:
- src/i18n/generated
- node_modules.codex-backup
- output
- .gradle
- playwright/.cache
- android
- ios
- app
+27
View File
@@ -108,6 +108,33 @@ export const sourceMappings = [
specs: ["tests/e2e/userVehicles.spec.ts"],
projects: chromiumProjects,
},
{
name: "orders-filters",
patterns: [
/^src\/components\/displays\/pagination\/models\/(?:DepartmentPos\/OrdersPagination|SuperUserDashboard\/InvoiceOrdersPagination)\.vue$/u,
/^src\/components\/displays\/pagination\/(?:PaginationOtherFiltersDropdown|TableLabeledPagination)\.vue$/u,
/^src\/components\/displays\/buttons\/DatePeriodSelector\.vue$/u,
/^src\/services\/orderDateEvents\.js$/u,
/^src\/services\/relativeDateShortcuts\.js$/u,
/^src\/views\/dashboards\/departmentDashboard\/modules\/Pos\/DepartmentPos(?:Orders|Drafts)\.vue$/u,
],
specs: [
"tests/e2e/admin-pos-order-filters.spec.ts",
"tests/e2e/superuser-orders-date-filters.spec.ts",
],
projects: chromiumProjects,
},
{
name: "superuser-customer-rules",
patterns: [
/^src\/views\/dashboards\/superUserDashboard\/CustomerRuleProductRestrictions\.vue$/u,
/^src\/features\/customer\/customerRuleProductRestrictionService\.js$/u,
/^src\/features\/customer\/customerRuleConfigurationPermissions\.js$/u,
/^src\/views\/dashboards\/superUserDashboard\/user\/UserCustomerRuleManager\.vue$/u,
],
specs: ["tests/e2e/superuser-customer-rules.spec.ts", "tests/e2e/superuser-users.spec.ts"],
projects: chromiumProjects,
},
{
name: "pos",
patterns: [
+2
View File
@@ -62,6 +62,7 @@ export const ownedFilesByRole = {
"admin-department-visibility.spec.ts",
"admin-overview-mobile.spec.ts",
"admin-overview-night-washes.spec.ts",
"admin-pos-order-filters.spec.ts",
"admin-pos-drafts.spec.ts",
"admin-pos-orders.spec.ts",
"adminModuleGoals.spec.ts",
@@ -99,6 +100,7 @@ export const ownedFilesByRole = {
"session-bootstrap.spec.ts",
"superuser-bookings.spec.ts",
"superuser-cron.spec.ts",
"superuser-customer-rules.spec.ts",
"superuser-customer-complaints.spec.ts",
"superuser-customers-mass-import.spec.ts",
"superuser-department-branding.spec.js",
+39 -1
View File
@@ -21,6 +21,8 @@ const props = withDefaults(defineProps<{
expandOnClickNode?: boolean;
lazy?: boolean;
load?: (node: TreeNode) => Promise<TreeNode[]>;
progressiveBatchSize?: number;
loadMoreLabel?: string;
ariaLabel?: string;
}>(), {
data: () => [],
@@ -33,6 +35,8 @@ const props = withDefaults(defineProps<{
expandOnClickNode: true,
lazy: false,
load: undefined,
progressiveBatchSize: 0,
loadMoreLabel: "Show {count} more",
ariaLabel: undefined,
});
@@ -64,6 +68,7 @@ const state = reactive({
lazyChildrenCache: {} as Record<string, TreeNode[]>,
loadingKeys: [] as any[],
loadErrorKeys: [] as any[],
renderedLimits: {} as Record<string, number>,
});
const keyOf = (node: TreeNode) => node?.[resolvedFields.value.id];
@@ -77,6 +82,30 @@ const effectiveChildrenOf = (node: TreeNode) => {
const children = childrenOf(node);
return children.length > 0 ? children : cachedChildrenOf(node);
};
const progressiveBatchSize = computed(() => Math.max(0, Math.floor(Number(props.progressiveBatchSize) || 0)));
const visibleChildrenOf = (node: TreeNode) => {
const children = effectiveChildrenOf(node);
if (progressiveBatchSize.value < 1 || children.length <= progressiveBatchSize.value) {
return children;
}
const key = keyString(keyOf(node));
const limit = state.renderedLimits[key] || progressiveBatchSize.value;
return children.slice(0, limit);
};
const remainingChildrenCount = (node: TreeNode) => Math.max(0, effectiveChildrenOf(node).length - visibleChildrenOf(node).length);
const hasMoreChildren = (node: TreeNode) => remainingChildrenCount(node) > 0;
const showMoreChildren = (node: TreeNode) => {
if (!hasMoreChildren(node)) {
return;
}
const key = keyString(keyOf(node));
const currentLimit = state.renderedLimits[key] || progressiveBatchSize.value;
state.renderedLimits[key] = Math.min(effectiveChildrenOf(node).length, currentLimit + progressiveBatchSize.value);
};
const loadMoreLabelFor = (node: TreeNode) => props.loadMoreLabel.replace(
"{count}",
String(Math.min(progressiveBatchSize.value, remainingChildrenCount(node)))
);
const isDisabled = (node: TreeNode) => Boolean(node?.[resolvedFields.value.disabled]);
const isSelfSelectable = (node: TreeNode) => node?.selectable !== false;
const isBranchCheckable = (node: TreeNode) => node?.checkable === true;
@@ -133,6 +162,7 @@ watch(
watch(
() => props.data,
(nodes) => {
state.renderedLimits = {};
if (props.defaultExpandAll) {
state.expandedKeys = collectAllExpandableKeys(nodes);
emit("update:expandedKeys", [...state.expandedKeys]);
@@ -169,6 +199,9 @@ const loadNode = async (node: TreeNode) => {
try {
state.lazyChildrenCache[keyString(key)] = await props.load(node);
if (progressiveBatchSize.value > 0) {
state.renderedLimits[keyString(key)] = progressiveBatchSize.value;
}
} catch (error) {
state.loadErrorKeys = [...new Set([...state.loadErrorKeys, key])];
emit("load-error", error, node, key);
@@ -208,7 +241,7 @@ const toggleExpand = async (node: TreeNode) => {
setExpandedKeys([...state.expandedKeys, key]);
emit("expand", node, key);
if (props.lazy && !state.lazyChildrenCache[keyString(key)]) {
if (props.lazy && childrenOf(node).length === 0 && !state.lazyChildrenCache[keyString(key)]) {
await loadNode(node);
}
};
@@ -297,6 +330,11 @@ provide("BuefyTreeContext", {
keyOf,
childrenOf,
effectiveChildrenOf,
visibleChildrenOf,
hasMoreChildren,
showMoreChildren,
remainingChildrenCount,
loadMoreLabelFor,
isDisabled,
isCheckDisabled,
isLeaf,
+59 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { BCheckbox } from "buefy";
type TreeNode = Record<string, any>;
@@ -18,7 +18,9 @@ if (!tree) {
const keyValue = computed(() => tree.keyOf(props.node));
const keyString = computed(() => String(keyValue.value ?? ""));
const children = computed(() => tree.effectiveChildrenOf(props.node));
const logicalChildren = computed(() => tree.effectiveChildrenOf(props.node));
const children = computed(() => tree.visibleChildrenOf(props.node));
const hasMoreChildren = computed(() => tree.hasMoreChildren(props.node));
const checkState = computed(() => tree.checkState(props.node));
const isExpanded = computed(() => tree.state.expandedKeys.includes(keyValue.value));
const isLoading = computed(() => tree.state.loadingKeys.includes(keyValue.value));
@@ -37,6 +39,39 @@ const hasExpandToggle = computed(() => {
return tree.props.lazy || children.value.length > 0;
});
const label = computed(() => String(props.node?.[tree.fields.value.label] ?? ""));
const loadMoreButton = ref<HTMLElement | null>(null);
let loadMoreObserver: IntersectionObserver | null = null;
const disconnectLoadMoreObserver = () => {
loadMoreObserver?.disconnect();
loadMoreObserver = null;
};
const observeLoadMoreButton = async () => {
disconnectLoadMoreObserver();
if (!isExpanded.value || !hasMoreChildren.value || typeof IntersectionObserver === "undefined") {
return;
}
await nextTick();
if (!loadMoreButton.value) {
return;
}
loadMoreObserver = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) {
return;
}
disconnectLoadMoreObserver();
tree.showMoreChildren(props.node);
void observeLoadMoreButton();
}, { rootMargin: "160px 0px" });
loadMoreObserver.observe(loadMoreButton.value);
};
watch([isExpanded, hasMoreChildren], () => {
void observeLoadMoreButton();
}, { immediate: true });
onBeforeUnmount(disconnectLoadMoreObserver);
</script>
<template>
@@ -119,7 +154,7 @@ const label = computed(() => String(props.node?.[tree.fields.value.label] ?? "")
:key="tree.keyOf(child) ?? index"
:node="child"
:depth="depth + 1"
:setsize="children.length"
:setsize="logicalChildren.length"
:posinset="index + 1"
>
<template #default="slotProps">
@@ -129,6 +164,18 @@ const label = computed(() => String(props.node?.[tree.fields.value.label] ?? "")
<slot name="icon" v-bind="slotProps"></slot>
</template>
</BuefyTreeNode>
<li v-if="hasMoreChildren" class="b-tree-load-more" role="none">
<button
ref="loadMoreButton"
type="button"
class="button is-small is-light b-tree-load-more__button"
:aria-label="tree.loadMoreLabelFor(node)"
@click.stop="tree.showMoreChildren(node)"
>
<span class="icon is-small" aria-hidden="true"><i class="fas fa-chevron-down"></i></span>
<span>{{ tree.loadMoreLabelFor(node) }}</span>
</button>
</li>
</ul>
</li>
</template>
@@ -190,6 +237,15 @@ const label = computed(() => String(props.node?.[tree.fields.value.label] ?? "")
padding: 0 0 0 0.65rem;
}
.b-tree-load-more {
list-style: none;
padding: 0.25rem 0 0.35rem 0.35rem;
}
.b-tree-load-more__button {
gap: 0.25rem;
}
.is-invisible {
visibility: hidden;
}
+172 -19
View File
@@ -7,6 +7,7 @@ import CustomerProductDiscountDisplay
from "@/components/displays/department/pos/displays/CustomerProductDiscountDisplay.vue";
import { useI18n } from 'vue-i18n';
import { getCustomerProductRestriction } from "@/features/customer/customerProductRules.js";
import { BTooltip } from "buefy";
const { t } = useI18n();
const expandIcon = ref(null);
@@ -31,6 +32,10 @@ const props = defineProps({
setProductAddonNote: Function,
customerDiscounts: Array,
customerAttributes: Array,
customerAttributesStatus: {
type: String,
default: "ready",
},
compact: Boolean,
showPrices: {
type: Boolean,
@@ -44,6 +49,21 @@ const customerAttributesSafe = computed(() => (Array.isArray(props.customerAttri
const getAddonProduct = (addon) => addon?.product || {};
const getAddonName = (addon) => addon?.name || '';
const getAddonTargetId = (addon) => addon?.option_id ?? getAddonProduct(addon).id ?? addon?.product_id ?? addon?.id;
const getReadinessRestriction = () => {
if (props.customerAttributesStatus === "ready") {
return null;
}
const isLoadFailure = props.customerAttributesStatus === "error";
return {
restricted: true,
rule: null,
rules: [],
messageKey: isLoadFailure ? "pos.restrictions.load_failed" : "pos.restrictions.loading",
};
};
const isAddonSelected = (productId, addonId) => {
return selectedAddonsSafe.value.some((addon) => addon.product_id === productId && addon.addon_id === addonId && addon.status === true);
@@ -69,6 +89,9 @@ const hideAddonPopper = (addon) => {
};
const emitAddToCart = (id) => {
if (isProductRestricted()) {
return;
}
emit('addToCartProduct', props.product);
emit('add-to-cart', id);
};
@@ -148,9 +171,14 @@ const doesAddonHaveNoteRequirement = (addon) => {
};
const isAddonRestricted = (addon) => {
const readinessRestriction = getReadinessRestriction();
if (readinessRestriction) {
return true;
}
const addonProduct = getAddonProduct(addon);
return getCustomerProductRestriction({
...addonProduct,
id: getAddonTargetId(addon),
name: getAddonName(addon) || addonProduct.name,
category: addonProduct.category ?? addon?.category,
}, customerAttributesSafe.value, {
@@ -160,9 +188,14 @@ const isAddonRestricted = (addon) => {
};
const getAddonRestrictionMessage = (addon) => {
const readinessRestriction = getReadinessRestriction();
if (readinessRestriction) {
return t(readinessRestriction.messageKey);
}
const addonProduct = getAddonProduct(addon);
const restriction = getCustomerProductRestriction({
...addonProduct,
id: getAddonTargetId(addon),
name: getAddonName(addon) || addonProduct.name,
category: addonProduct.category ?? addon?.category,
}, customerAttributesSafe.value, {
@@ -173,12 +206,21 @@ const getAddonRestrictionMessage = (addon) => {
return restriction.messageKey ? t(restriction.messageKey) : "";
};
const isProductRestricted = () => {
const productRestriction = computed(() => {
const readinessRestriction = getReadinessRestriction();
if (readinessRestriction) {
return readinessRestriction;
}
return getCustomerProductRestriction({
...props.product,
name: props.name || props.product?.name,
}, customerAttributesSafe.value).restricted;
};
}, customerAttributesSafe.value);
});
const isProductRestricted = () => productRestriction.value.restricted;
const getProductRestrictionMessage = () => productRestriction.value.messageKey
? t(productRestriction.value.messageKey)
: "";
const orderByOrderPriority = (addons) => {
return [...addons].sort((a, b) => {
@@ -210,6 +252,13 @@ const orderByOrderPriority = (addons) => {
<template v-else>
<p class="subtitle is-6 mb-0 has-text-grey-light" style="font-size: smaller"><br/></p>
</template>
<span
v-if="isProductRestricted()"
class="tag is-danger is-light is-small mt-1"
:data-testid="`pos-product-restriction-${id}`"
>
{{ getProductRestrictionMessage() }}
</span>
</div>
</div>
<!-- Price -->
@@ -235,6 +284,14 @@ const orderByOrderPriority = (addons) => {
<div class="column is-8-desktop">
<!-- Actual product details -->
<p class="title is-6">{{ name }}</p>
<span
v-if="isProductRestricted()"
class="tag is-danger is-light mb-2"
:data-testid="`pos-product-restriction-${id}`"
>
<span class="icon is-small"><i class="fas fa-exclamation-triangle"></i></span>
<span>{{ getProductRestrictionMessage() }}</span>
</span>
<p class="subtitle is-6 mb-0" :style="{ 'color': Colors.global.primaryColor }">
No. {{ id }}</p>
<p class="subtitle is-6">{{ description }}</p>
@@ -255,6 +312,7 @@ const orderByOrderPriority = (addons) => {
<!-- Addon ( + ) -->
<button
class="button is-small"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-increase`"
@click="onBeforeAddProductAddon(addon, () => addProductAddon(
props.id,
addon.option_id
@@ -267,6 +325,7 @@ const orderByOrderPriority = (addons) => {
<!-- quantity (if any), Addon name, price -->
<button
class="button is-small is-fullwidth"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-name`"
:class="{ 'is-link': isAddonSelected(props.id, addon.option_id), 'is-light': !isAddonSelected(props.id, addon.option_id) }"
@click="onBeforeToggleProductAddon(addon, isAddonSelected(props.id, addon.option_id), () => toggleProductAddon(props.id, addon.option_id))"
@mouseenter="showAddonPopper(addon, $event.target)"
@@ -280,6 +339,7 @@ const orderByOrderPriority = (addons) => {
<!-- Addon ( - ) -->
<button
class="button is-small"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-decrease`"
:disabled="!isAddonSelected(props.id, addon.option_id)"
@click="subtractProductAddon(
props.id,
@@ -296,17 +356,75 @@ const orderByOrderPriority = (addons) => {
<template v-else>
<div
class="buttons has-addons is-small is-fullwidth is-flex-wrap-nowrap"
:data-testid="`pos-addon-restriction-${getAddonProduct(addon).id ?? addon.option_id ?? addon.id}`"
:data-testid="`pos-addon-restriction-${getAddonTargetId(addon)}`"
>
<button
class="button is-small is-fullwidth is-danger is-light"
disabled
<BTooltip
:label="getAddonRestrictionMessage(addon)"
:triggers="['hover', 'focus', 'click']"
multilined
position="is-top"
type="is-dark"
append-to-body
>
<span
class="pos-restriction-tooltip-trigger"
tabindex="0"
:data-testid="`pos-addon-restriction-tooltip-${getAddonTargetId(addon)}-increase`"
>
<span class="icon is-small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<span>{{ addon.name }} / {{ getAddonRestrictionMessage(addon) }}</span>
</button>
<button
class="button is-small is-danger is-light"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-increase`"
disabled
>
<span class="icon is-small"><i class="fas fa-plus"></i></span>
</button>
</span>
</BTooltip>
<BTooltip
:label="getAddonRestrictionMessage(addon)"
:triggers="['hover', 'focus', 'click']"
multilined
position="is-top"
type="is-dark"
append-to-body
>
<span
class="pos-restriction-tooltip-trigger pos-restriction-tooltip-trigger--grow"
tabindex="0"
:data-testid="`pos-addon-restriction-tooltip-${getAddonTargetId(addon)}`"
>
<button
class="button is-small is-fullwidth is-danger is-light"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-name`"
disabled
>
<span class="icon is-small"><i class="fas fa-exclamation-triangle"></i></span>
<span>{{ addon.name }} / {{ getAddonPrice(addon) }} Kr.</span>
</button>
</span>
</BTooltip>
<BTooltip
:label="getAddonRestrictionMessage(addon)"
:triggers="['hover', 'focus', 'click']"
multilined
position="is-top"
type="is-dark"
append-to-body
>
<span
class="pos-restriction-tooltip-trigger"
tabindex="0"
:data-testid="`pos-addon-restriction-tooltip-${getAddonTargetId(addon)}-decrease`"
>
<button
class="button is-small is-danger is-light"
:data-testid="`pos-addon-${getAddonTargetId(addon)}-decrease`"
disabled
>
<span class="icon is-small"><i class="fas fa-minus"></i></span>
</button>
</span>
</BTooltip>
</div>
</template>
</template>
@@ -315,15 +433,37 @@ const orderByOrderPriority = (addons) => {
<p>&nbsp;</p>
<!-- Add to cart -->
<div class="buttons is-centered mt-2 mb-3 pl-6">
<button
v-bind:disabled="isProductRestricted()"
:data-testid="`pos-add-to-cart-${id}`"
class="button is-link is-small is-fullwidth"
@click="emitAddToCart(id);"
<BTooltip
v-if="isProductRestricted()"
:label="getProductRestrictionMessage()"
:triggers="['hover', 'focus', 'click']"
multilined
position="is-top"
type="is-dark"
append-to-body
>
<span class="icon is-small">
<i class="fas fa-cart-plus"></i>
<span
class="pos-restriction-tooltip-trigger pos-restriction-tooltip-trigger--grow"
tabindex="0"
:data-testid="`pos-add-to-cart-restriction-tooltip-${id}`"
>
<button
disabled
:data-testid="`pos-add-to-cart-${id}`"
class="button is-small is-fullwidth is-danger is-light"
>
<span class="icon is-small"><i class="fas fa-cart-plus"></i></span>
<span>{{ t('pos.add_to_cart') }}</span>
</button>
</span>
</BTooltip>
<button
v-else
:data-testid="`pos-add-to-cart-${id}`"
class="button is-small is-fullwidth is-link"
@click="emitAddToCart(id);"
>
<span class="icon is-small"><i class="fas fa-cart-plus"></i></span>
<span>{{ t('pos.add_to_cart') }}</span>
</button>
</div>
@@ -359,4 +499,17 @@ const orderByOrderPriority = (addons) => {
white-space: nowrap;
}
.pos-restriction-tooltip-trigger {
display: inline-flex;
}
.pos-restriction-tooltip-trigger--grow {
flex: 1 1 auto;
min-width: 0;
}
.pos-restriction-tooltip-trigger--grow > .button {
width: 100%;
}
</style>
@@ -829,6 +829,9 @@ const customerRuleItems = computed(() =>
customerRuleAttribute: rule.attribute,
customerRuleCustomerNumber: props.customer_number,
customerRuleDepartmentId: customerRuleDepartmentId.value,
customerRuleRestriction: customerRuleAttributes.value.find(
(entry) => String(entry?.attribute || "").trim() === rule.attribute
) ?? null,
description: t(rule.descriptionKey),
disabled: !SessionUser.hasPermission(requiredPermission),
key: `customer-rule-${rule.attribute}`,
@@ -2983,6 +2986,7 @@ const syncDesktopFlyoutPosition = () => {
:active="item.customerRuleActive"
:customer-number="item.customerRuleCustomerNumber"
:department-id="item.customerRuleDepartmentId"
:restriction="item.customerRuleRestriction"
:test-id="`${item.testId}-tooltip`"
>
<span>{{ label }}</span>
@@ -3193,6 +3197,7 @@ const syncDesktopFlyoutPosition = () => {
:active="item.customerRuleActive"
:customer-number="item.customerRuleCustomerNumber"
:department-id="item.customerRuleDepartmentId"
:restriction="item.customerRuleRestriction"
:test-id="`${item.testId}-tooltip`"
>
<span>{{ label }}</span>
@@ -3443,6 +3448,7 @@ const syncDesktopFlyoutPosition = () => {
:active="item.customerRuleActive"
:customer-number="item.customerRuleCustomerNumber"
:department-id="item.customerRuleDepartmentId"
:restriction="item.customerRuleRestriction"
:test-id="`${item.testId}-tooltip`"
>
<span>{{ label }}</span>
@@ -167,6 +167,9 @@ const refreshCustomerAttributesForRules = async (selectedCustomerNumber = custom
const hasAttribute = (prop) => {
return customer_attributes.value.some((attribute) => attribute.attribute === prop);
};
const attributeEntry = (prop) => (
customer_attributes.value.find((attribute) => attribute.attribute === prop) ?? null
);
watch(
activeTabKey,
@@ -276,6 +279,7 @@ const customer_data_has_empty_details = () => {
:active="hasAttribute(attribute.prop)"
:customer-number="customer_id"
:department-id="department_id"
:restriction="attributeEntry(attribute.prop)"
:test-id="`pos-customer-rule-tooltip-${attribute.attribute}`"
>
<span class="pos-selected-customer__label">{{ attribute.name }}</span>
@@ -39,10 +39,13 @@ import {
reg_3,
department_id,
customer_id,
customer_attributes,
customer_attributes_status,
getCustomerEmail,
customer_name,
getAddonRestriction,
getProductRestriction,
retryCustomerAttributes,
registerPosStepSaveBarrier,
saveOrderMetadataField,
} from "@/components/shop/POSDepartmentProcess.vue";
@@ -488,6 +491,9 @@ const showRestrictedItemsRemovedWarning = () => {
restrictionWarningMessageKey.value = "pos.restrictions.restricted_items_removed";
};
const getRestrictionReadinessMessageKey = () =>
customer_attributes_status.value === "error" ? "pos.restrictions.load_failed" : "pos.restrictions.loading";
const getMobileAddonRestriction = (addon: any) => getAddonRestriction(addon, { isRelatedAddon: true });
const getStandaloneAdditionalItemRestriction = (item: any) =>
@@ -523,6 +529,10 @@ const decorateMobileAddonRestriction = (addon: any) => {
};
const sanitizeRestrictedTransactionItems = () => {
if (customer_attributes_status.value !== "ready") {
return false;
}
let removedRestrictedItem = false;
const primary = transactionItems.primaryItem.value;
@@ -590,6 +600,10 @@ const sanitizeRestrictedTransactionItems = () => {
};
const onCopyLastOrder = (vehicleIndex: number) => {
if (customer_attributes_status.value !== "ready") {
restrictionWarningMessageKey.value = getRestrictionReadinessMessageKey();
return;
}
lastOrders.select(vehicleIndex);
sanitizeRestrictedTransactionItems();
};
@@ -1055,6 +1069,12 @@ const syncCurrentTransactionToOrder = async () => {
throw new Error("No primary item selected");
}
const primaryRestriction = getProductRestriction(transactionItems.primaryItem.value);
if (primaryRestriction.restricted) {
restrictionWarningMessageKey.value = primaryRestriction.messageKey;
return false;
}
const existingItemsResponse = await getOrderItems(normalizedOrderId);
const existingItems = Array.isArray(existingItemsResponse?.data?.data) ? existingItemsResponse.data.data : [];
@@ -1248,6 +1268,12 @@ const onBeforeComplete = async () => {
throw new Error("No primary item selected");
}
const primaryRestriction = getProductRestriction(transactionItems.primaryItem.value);
if (primaryRestriction.restricted) {
restrictionWarningMessageKey.value = primaryRestriction.messageKey;
return false;
}
sanitizeRestrictedTransactionItems();
if (!(await ensureRequiredOrderItemNotes())) {
@@ -1408,6 +1434,8 @@ watch(
[
() => transactionItems.primaryItem.value?.addons,
() => transactionItems.additionalItems.value,
() => customer_attributes.value,
() => customer_attributes_status.value,
],
() => {
sanitizeRestrictedTransactionItems();
@@ -1434,6 +1462,21 @@ const filteredAddons = computed(() => {
<template>
<template v-if="vehicleSelection">
<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-3"
data-testid="pos-mobile-customer-restrictions-load-error"
>
<span>{{ t("pos.restrictions.load_failed") }}</span>
<button
type="button"
class="button is-small is-danger is-light"
data-testid="pos-mobile-customer-restrictions-retry"
@click="retryCustomerAttributes"
>
{{ t("common.retry") }}
</button>
</div>
<PosDepartmentStep2MobileVehicleSelection @close="vehicleSelection = false" />
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl variant="pos-step">
@@ -1500,6 +1543,21 @@ const filteredAddons = computed(() => {
>
{{ 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"
data-testid="pos-mobile-customer-restrictions-load-error"
>
<span>{{ t("pos.restrictions.load_failed") }}</span>
<button
type="button"
class="button is-small is-danger is-light"
data-testid="pos-mobile-customer-restrictions-retry"
@click="retryCustomerAttributes"
>
{{ t("common.retry") }}
</button>
</div>
<!-- Product -->
<PosDepartmentStepMobile2Product
v-on:pointerdown="onPrimaryProductPointerDown"
@@ -12,7 +12,12 @@ import PosDepartmentStepMobileFixedBottomControl from "@/components/displays/dep
import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue";
import { transactionItems } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import PosDepartmentStepMobile2FloatingCart from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue";
import { customer_id, getProductRestriction, hasAttribute } from "@/components/shop/POSDepartmentProcess.vue";
import {
customer_id,
customer_attributes_status,
getProductRestriction,
retryCustomerAttributes,
} from "@/components/shop/POSDepartmentProcess.vue";
import { useI18n } from "vue-i18n";
import { orderProducts } from "@/components/shop/Products.vue";
@@ -121,12 +126,6 @@ const getAvailableAdditionalItems = () => {
);
};
const availableAdditionalItems = ref<Addon[]>(getAvailableAdditionalItems());
const getStandaloneAdditionalServicesRestriction = () => ({
restricted: true,
rule: "restrictAdditionalServices",
messageKey: "pos.restrictions.addons_not_allowed",
});
const isAdditionalItemRestricted = (product: PosProduct) => {
return getAdditionalItemRestriction({ product } as Addon).restricted;
};
@@ -137,12 +136,7 @@ const getAdditionalSelectionRestrictionMessage = (product: PosProduct) => {
};
const getAdditionalItemRestriction = (addon: Addon) => {
if (hasAttribute("restrictAdditionalServices")) {
return getStandaloneAdditionalServicesRestriction();
}
return getProductRestriction(addon.product || addon, {
includeNumericAddonCategory: true,
isStandaloneAdditionalService: true,
});
};
@@ -324,6 +318,21 @@ const onClickAddProduct = async (product: PosProduct) => {
<template>
<div data-testid="pos-mobile-additional-items">
<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-3"
data-testid="pos-mobile-customer-restrictions-load-error"
>
<span>{{ t("pos.restrictions.load_failed") }}</span>
<button
type="button"
class="button is-small is-danger is-light"
data-testid="pos-mobile-customer-restrictions-retry"
@click="retryCustomerAttributes"
>
{{ t("common.retry") }}
</button>
</div>
<!-- Minimal view, when not set as fullscreen view -->
<WhiteBoxCard
:toggleable="
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { ref } from "vue";
import Plus from "@/components/viewport/elements/icons/Plus.vue";
import ControlSelectAmount from "@/components/viewport/elements/controls/ControlSelectAmount.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import { Addon } from "../objects/PosAddon.vue";
@@ -59,6 +58,9 @@ const getAddonKey = (addon: Addon) => String(addon.id ?? addon.product?.id ?? ""
const setAddonQuantity = (addon: Addon, quantity: number) => {
const nextQuantity = Number(quantity || 0);
if (nextQuantity > 0 && isAddonRestricted(addon)) {
return;
}
const nextAddons = props.addons.map((candidate) =>
getAddonKey(candidate) === getAddonKey(addon) ? { ...candidate, quantity: nextQuantity } : candidate
);
@@ -158,20 +160,18 @@ const getAddonRestrictionMessage = (addon: Addon) => {
:disabled="isAddonRestricted(addon)"
:restrictionMessage="getAddonRestrictionMessage(addon)"
@addProduct="onAddProduct(addon)"
:customButton="addon.quantity > 0 && !isAddonRestricted(addon)"
:customButton="true"
>
<span class="custom-select-quantity-container">
<ControlSelectAmount
v-if="!isAddonRestricted(addon)"
:testIdPrefix="`pos-mobile-addon-${addon.product.id}`"
@update:quantity="setAddonQuantity(addon, $event)"
:quantity="addon.quantity"
:max="addon.max"
:min="addon.min"
:disabled="isAddonRestricted(addon)"
:restrictionMessage="getAddonRestrictionMessage(addon)"
/>
<span v-else class="tag is-danger is-light is-rounded">
{{ getAddonRestrictionMessage(addon) }}
</span>
</span>
</PosDepartmentStepMobile2CategoryProduct>
</template>
@@ -3,6 +3,7 @@
import {getPicture} from "@/components/displays/department/pos/displays/Piktogrammer.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import Plus from "@/components/viewport/elements/icons/Plus.vue";
import { BTooltip } from "buefy";
const props = defineProps({
piktogram: {
@@ -82,42 +83,101 @@ const imageStyleCompact = {
<template>
<div
style="border-bottom: 1px solid #E5E5E5; padding: 10px 0;"
style="border-bottom: 1px solid #e5e5e5; padding: 10px 0"
class="mb-1"
:class="{ 'pos-mobile-category-product--disabled': props.disabled }"
>
<div class="columns is-vcentered is-mobile" :class="props.customButton ? 'has-quantity' : ''">
<div class="column is-narrow" @click="onClick" :style="{ cursor: props.disabled ? 'not-allowed' : 'pointer' }">
<img :src="getPicture(props.piktogram)" :alt="props.label" class="product-image"
:style="props.compact ? imageStyleCompact : imageSize"
style="border-radius: 8px; object-fit: contain;" />
<div class="column is-narrow">
<button
type="button"
class="pos-mobile-category-product__image-button"
:disabled="props.disabled"
:style="{ cursor: props.disabled ? 'not-allowed' : 'pointer' }"
@click="onClick"
>
<img
:src="getPicture(props.piktogram)"
:alt="props.label"
class="product-image"
:style="props.compact ? imageStyleCompact : imageSize"
style="border-radius: 8px; object-fit: contain"
/>
</button>
</div>
<div
class="column"
@click="onClick"
:style="{ cursor: props.disabled ? 'not-allowed' : 'pointer' }"
:data-testid="props.testId || undefined"
>
<template v-if="!props.compact">
<h1 class="title is-6">{{ props.label }}</h1>
<h2 class="subtitle is-6" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
<h2 class="subtitle is-6" v-else>&nbsp;</h2>
</template>
<template v-else>
<h1 class="title is-6" style="font-size: small">{{ props.label }}</h1>
<h2 class="subtitle is-6" style="font-size: smaller" v-if="props.showPrices">{{ SessionUser.functions.currency.toLocal(props.price) }}</h2>
<h2 class="subtitle is-6" style="font-size: smaller" v-else>&nbsp;</h2>
</template>
<p v-if="props.disabled && props.restrictionMessage" class="help is-danger mb-0">
{{ props.restrictionMessage }}
</p>
<div class="column pos-mobile-category-product__name-column">
<BTooltip
v-if="props.disabled && props.restrictionMessage"
:label="props.restrictionMessage"
:triggers="['hover', 'focus', 'click']"
multilined
position="is-top"
type="is-dark"
append-to-body
>
<span
class="pos-mobile-category-product__tooltip-trigger"
tabindex="0"
:data-testid="props.testId ? `${props.testId}-restriction-tooltip` : undefined"
>
<button
type="button"
class="pos-mobile-category-product__name-button"
:data-testid="props.testId || undefined"
disabled
>
<template v-if="!props.compact">
<span class="title is-6">{{ props.label }}</span>
<span class="subtitle is-6" v-if="props.showPrices">{{
SessionUser.functions.currency.toLocal(props.price)
}}</span>
<span class="subtitle is-6" v-else>&nbsp;</span>
</template>
<template v-else>
<span class="title is-6" style="font-size: small">{{ props.label }}</span>
<span class="subtitle is-6" style="font-size: smaller" v-if="props.showPrices">{{
SessionUser.functions.currency.toLocal(props.price)
}}</span>
<span class="subtitle is-6" style="font-size: smaller" v-else>&nbsp;</span>
</template>
</button>
</span>
</BTooltip>
<button
v-else
type="button"
class="pos-mobile-category-product__name-button"
:data-testid="props.testId || undefined"
@click="onClick"
>
<template v-if="!props.compact">
<span class="title is-6">{{ props.label }}</span>
<span class="subtitle is-6" v-if="props.showPrices">{{
SessionUser.functions.currency.toLocal(props.price)
}}</span>
<span class="subtitle is-6" v-else>&nbsp;</span>
</template>
<template v-else>
<span class="title is-6" style="font-size: small">{{ props.label }}</span>
<span class="subtitle is-6" style="font-size: smaller" v-if="props.showPrices">{{
SessionUser.functions.currency.toLocal(props.price)
}}</span>
<span class="subtitle is-6" style="font-size: smaller" v-else>&nbsp;</span>
</template>
</button>
</div>
<div class="column is-narrow">
<slot name="right"/>
<button class="custom-button-product" @click="onClick" v-if="!props.customButton" :style="props.compact ? imageStyleCompact : imageSize" v-show="false">
<span class="custom-icon">
<Plus width="100%" height="100%" />
</span>
<slot name="right" />
<button
class="custom-button-product"
@click="onClick"
v-if="!props.customButton"
:style="props.compact ? imageStyleCompact : imageSize"
v-show="false"
>
<span class="custom-icon">
<Plus width="100%" height="100%" />
</span>
</button>
<slot v-else></slot>
</div>
@@ -149,6 +209,33 @@ const imageStyleCompact = {
cursor: pointer;
transition: background-color 0.3s ease;
}
.pos-mobile-category-product__image-button,
.pos-mobile-category-product__name-button {
appearance: none;
background: transparent;
border: 0;
color: inherit;
padding: 0;
text-align: left;
}
.pos-mobile-category-product__image-button:disabled,
.pos-mobile-category-product__name-button:disabled {
cursor: not-allowed;
}
.pos-mobile-category-product__name-column,
.pos-mobile-category-product__name-button,
.pos-mobile-category-product__tooltip-trigger {
display: block;
width: 100%;
}
.pos-mobile-category-product__name-button .title,
.pos-mobile-category-product__name-button .subtitle {
display: block;
}
/* On larger screens, make the button appropriately sized */
@media (min-width: 768px) {
.custom-button-product {
@@ -6,9 +6,9 @@ import PosDepartmentStepMobile2CategoryProduct
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {PosProduct} from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue";
import {
customer_attributes,
customer_attributes_status,
getProductRestriction,
hasAttribute,
isProductRestricted,
} from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStepMobile2Addons
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2Addons.vue";
@@ -67,22 +67,22 @@ const mergeCountAddons = (products: PosProduct[], addons: Addon[] | undefined) =
return mergedAddons.value;
};
// Recalculate the merged addons when the props.addons change
watch(() => props.addons, (newAddons) => {
mergeCountAddons(pos.productList.get(), newAddons);
}, { immediate: true });
watch(
[
() => props.addons,
() => pos.productList.list.value,
() => customer_attributes.value,
() => customer_attributes_status.value,
],
([newAddons]) => {
mergeCountAddons(pos.productList.get(), newAddons);
},
{ immediate: true, deep: true }
);
// Computed property to filter out restricted products
const getProductRestrictionForRow = (product: PosProduct) => {
if (props.restrictionContext === "standaloneAdditionalService" && hasAttribute("restrictAdditionalServices")) {
return {
restricted: true,
rule: "restrictAdditionalServices",
messageKey: "pos.restrictions.addons_not_allowed",
};
}
const restriction = getProductRestriction(product, props.restrictionContext === "standaloneAdditionalService"
? { isStandaloneAdditionalService: true, includeNumericAddonCategory: true }
? { isStandaloneAdditionalService: true }
: {});
return restriction;
};
@@ -92,14 +92,15 @@ const getProductRestrictionMessage = (product: PosProduct) => {
return restriction.messageKey ? t(restriction.messageKey) : "";
};
const productListItems = computed(() => pos.productList.list.value || []);
const filteredProducts = computed(() => {
if (props.restrictionContext === "standaloneAdditionalService") {
return productListItems.value;
const onSelectProduct = (product: PosProduct) => {
if (getProductRestrictionForRow(product).restricted) {
return;
}
return productListItems.value.filter(product => !isProductRestricted(product));
});
emits("addProduct", product);
};
const productListItems = computed(() => pos.productList.list.value || []);
const shouldShowLoadingState = computed(() => {
return pos.categories.loading.value || (pos.productList.loading.value && productListItems.value.length === 0);
});
@@ -119,10 +120,10 @@ const shouldShowLoadingState = computed(() => {
</div>
<template v-else-if="pos.categories.isSelected()">
<template v-if="!props.asAddons">
<!-- Display of products, without a "basket" (filtered for customer restrictions) -->
<template v-for="product in filteredProducts" :key="product.id">
<!-- Display of products, without a "basket" -->
<template v-for="product in productListItems" :key="product.id">
<PosDepartmentStepMobile2CategoryProduct
@addProduct="emits('addProduct', product)"
@addProduct="onSelectProduct(product)"
:price="product.price"
:label="product.name"
:piktogram="product.piktogram"
@@ -11,6 +11,7 @@ import PosDepartmentStepMobile2Products
import PosDepartmentStepMobile2ProductRecommendations
from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2ProductRecommendations.vue";
import {Addon} from "@/components/displays/department/pos/steps/mobile/objects/PosAddon.vue";
import { getProductRestriction } from "@/components/shop/POSDepartmentProcess.vue";
// Define the close event to emit when the component is closed
const emit = defineEmits(["close"]);
// Define props
@@ -51,7 +52,7 @@ const props = defineProps({
// Function to handle adding a product
const onAddProduct = (product: any) => {
if (!canClose()) return;
if (!canClose() || getProductRestriction(product).restricted) return;
// If a custom onAddProduct function is provided, use it
if (props.onAddProduct) {
product.quantity = product.quantity || 1; // Ensure quantity is set
@@ -15,6 +15,10 @@ const props = defineProps({
type: String,
required: true,
},
showLabel: {
type: Boolean,
default: true,
},
showFilters: {
type: Boolean,
default: true,
@@ -38,7 +42,7 @@ const isSmall = ref(window.innerWidth < 1024);
<template>
<div data-disable-auto-excel-export="1">
<!-- Label -->
<h2 class="title is-4 mb-4">{{ label }}</h2>
<h2 v-if="props.showLabel" class="title is-4 mb-4">{{ label }}</h2>
<slot name="description"></slot>
<!-- Search & reload -->
<PaginationDisplayGeneralSearchReload
@@ -1,5 +1,6 @@
<script setup>
import SessionUser from "@/components/session/token/SessionUser.vue";
import InvoiceOrdersPagination
from "@/components/displays/pagination/models/SuperUserDashboard/InvoiceOrdersPagination.vue";
const props = defineProps({
onlyFromInvoiceCollection: {
@@ -22,124 +23,20 @@ const props = defineProps({
type: Boolean,
default: false
}
})
import { provide } from "vue";
import { useRouter } from "vue-router";
import {
usePaginatedList,
PaginatedListKey,
} from "@/components/pagination/paginatedList.vue";
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoaded,
isLoading,
list,
loadList,
metaCurrentPage,
metaItemsPerPage,
metaTotalItems,
setEndpoint,
setMetaItemsPerPage,
setPage,
metaSearch,
search,
setFilter,
setOrder,
hideSearchField,
setHideSearchField,
} = paginatedList;
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
import PaginationDisplayGeneralSearchReload
from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
import PaginationDisplayFilters from "@/components/displays/pagination/PaginationDisplayFilters.vue";
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter();
const resolveDepartmentId = () => {
const routeDepartmentId = Number.parseInt(String(router.currentRoute.value.params.departmentId ?? ""), 10);
if (Number.isInteger(routeDepartmentId) && routeDepartmentId > 0) {
return routeDepartmentId;
}
const pathDepartmentId = Number.parseInt(
String(
router.currentRoute.value.path?.match(/^\/admin\/(\d+)(?:\/|$)/)?.[1] ??
window.location.pathname?.match(/^\/admin\/(\d+)(?:\/|$)/)?.[1] ??
""
),
10
);
return Number.isInteger(pathDepartmentId) && pathDepartmentId > 0 ? pathDepartmentId : null;
};
setEndpoint("/orders", false);
// If the customer filter is set, filter the orders by the customer number
if (props.setCustomerFilter > 0) {
setFilter("customer_id", props.setCustomerFilter, false);
console.log("Setting customer filter to: " + props.setCustomerFilter);
}
// Hide the search field
if (props.hideSearch) {
setHideSearchField(true);
console.log("Hiding search field");
} else {
setHideSearchField(false);
}
// If the departmentId is set, in the route, filter the orders by the departmentId
const currentDepartmentId = resolveDepartmentId();
if (currentDepartmentId) {
setOrder("created_at", "desc");
setFilter("department_id", currentDepartmentId, false);
} else {
setOrder("created_at", "desc", false);
}
// If the onlyFromInvoiceCollection prop is set, filter the orders by the invoice collection id
if (props.onlyFromInvoiceCollection > 0) {
setFilter("invoice_collection_id", props.onlyFromInvoiceCollection, false);
}
// Load the list automatically if the autoLoad prop is set
if (props.autoLoad) {
loadList();
}
});
</script>
<template>
<PaginationDisplayGeneralSearchReload/>
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage">
<template #paginationColumns>
<!-- Sort by created_at -->
<div class="column is-narrow my-3">
<label class="label is-small">{{ t('pagination.order_direction') }}</label>
<div class="control">
<div class="select">
<select @change="setOrder('created_at', $event.target.value); loadList();">
<option value="asc">{{ t('pagination.ascending') }}</option>
<option value="desc" selected>{{ t('pagination.descending') }}</option>
</select>
</div>
</div>
</div>
<PaginationDisplayFilters />
</template>
</PaginationDisplay>
<OrdersTable :orders="list" :show-draft-assignment-actions="props.showDraftAssignmentActions" />
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
<InvoiceOrdersPagination
:only-from-invoice-collection="props.onlyFromInvoiceCollection"
:auto-load="props.autoLoad"
:set-customer-filter="props.setCustomerFilter"
:hide-search="props.hideSearch"
:show-draft-assignment-actions="props.showDraftAssignmentActions"
:apply-default-filters="false"
:show-department-filter="false"
:show-label="false"
:invoice-view="false"
:allow-select-multiple="false"
/>
</template>
<style scoped>
</style>
@@ -35,6 +35,18 @@ const props = defineProps({
type: Boolean,
default: false
},
showDepartmentFilter: {
type: Boolean,
default: true
},
showLabel: {
type: Boolean,
default: true
},
allowSelectMultiple: {
type: Boolean,
default: true
},
dates: {
type: Object,
default: () => ({
@@ -92,13 +104,10 @@ const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoading,
list,
loadList,
setEndpoint,
setMetaItemsPerPage,
setPage,
search,
endpoint,
filter,
metaSearch,
@@ -128,6 +137,23 @@ import {
const { t } = useI18n()
const router = useRouter();
const resolveDepartmentId = () => {
const routeDepartmentId = Number.parseInt(String(router.currentRoute.value.params.departmentId ?? ""), 10);
if (Number.isInteger(routeDepartmentId) && routeDepartmentId > 0) {
return routeDepartmentId;
}
const pathDepartmentId = Number.parseInt(
String(
router.currentRoute.value.path?.match(/^\/admin\/(\d+)(?:\/|$)/)?.[1] ??
window.location.pathname?.match(/^\/admin\/(\d+)(?:\/|$)/)?.[1] ??
""
),
10
);
return Number.isInteger(pathDepartmentId) && pathDepartmentId > 0 ? pathDepartmentId : null;
};
const areFiltersVisible = ref(!props.hideFilter);
const filterToggleLabel = computed(() => (
areFiltersVisible.value ? t("pagination.hide_filters") : t("pagination.show_filters")
@@ -143,10 +169,11 @@ if (props.setCustomerFilter > 0) {
if (Object.keys(props.queryParameters).length > 0) {
setAdditionalQueryParameters(props.queryParameters);
}
// If the departmentId is set, in the route, filter the orders by the departmentId
if (router.currentRoute.value.params.departmentId) {
// If the departmentId is set in the route, constrain both table and date-event requests.
const currentDepartmentId = resolveDepartmentId();
if (currentDepartmentId) {
setOrder("created_at", "desc");
setFilter("department_id", router.currentRoute.value.params.departmentId, false);
setFilter("department_id", currentDepartmentId, false);
} else {
setOrder("created_at", "desc");
}
@@ -414,6 +441,7 @@ watch(orderDateEventSignature, () => {
<template v-if="doesEndpointMatch('/orders')">
<TableLabeledPagination
:label="SessionUser.objects.orders.meta.labels.multiple"
:show-label="props.showLabel"
:show-filters="areFiltersVisible"
:hide-search="props.hideSearch"
:hide-pagination="props.hidePagination"
@@ -436,6 +464,7 @@ watch(orderDateEventSignature, () => {
</template>
<template #paginationDisplayFiltersElement>
<PaginationDisplayFilters
v-if="props.showDepartmentFilter"
:departmentColumn="'department_id'"
/>
</template>
@@ -539,7 +568,7 @@ watch(orderDateEventSignature, () => {
<template #default>
<OrdersTable :orders="list"
:invoiceView="props.invoiceView"
:allowSelectMultiple="true"
:allowSelectMultiple="props.allowSelectMultiple"
:show-draft-assignment-actions="props.showDraftAssignmentActions"
v-bind:groupInvoiceCollection="props.groupInvoiceCollection"
v-bind:showOnlyWithIds="props.showOnlyWithIds"
@@ -18,6 +18,8 @@ import {
getUserDiscounts,
customer_id,
customer_attributes,
customer_attributes_status,
retryCustomerAttributes,
loadCustomerAttributes
} from "@/components/shop/POSDepartmentProcess.vue";
import {getProductCategory, getProducts} from "@/components/shop/Products.vue";
@@ -205,6 +207,7 @@ const getValidOrderId = () => {
};
const CUSTOMER_PRODUCT_RULE_BLOCK_MESSAGE = "This product is not allowed for the selected customer";
const CUSTOMER_PRODUCT_RULE_BLOCK_CODE = "CUSTOMER_RULE_PRODUCT_RESTRICTED";
const getCreateOrderItemErrorMessage = (error) => {
return String(
@@ -216,7 +219,15 @@ const getCreateOrderItemErrorMessage = (error) => {
};
const isCustomerProductRuleBlockError = (error) => {
return getCreateOrderItemErrorMessage(error).includes(CUSTOMER_PRODUCT_RULE_BLOCK_MESSAGE);
const errorCode = String(
error?.response?.data?.data?.code ??
error?.response?.data?.code ??
""
);
return (
errorCode === CUSTOMER_PRODUCT_RULE_BLOCK_CODE ||
getCreateOrderItemErrorMessage(error).includes(CUSTOMER_PRODUCT_RULE_BLOCK_MESSAGE)
);
};
const showRestrictionWarning = async (messageKey = "pos.restrictions.product_not_allowed") => {
@@ -274,6 +285,13 @@ const getProductAddonDefinition = (productId, addonId) => {
return product.addons.find((addon) => Number(addon.option_id ?? addon.id) === Number(addonId)) || null;
};
const getProductAddonRestriction = (productId, addonId) => {
const addon = getProductAddonDefinition(productId, addonId);
return addon
? getAddonRestriction(addon, { isRelatedAddon: true })
: { restricted: true, messageKey: "pos.restrictions.product_not_allowed" };
};
const getPendingProductFromAddon = (productId, addonId) => {
const addon = getProductAddonDefinition(productId, addonId);
const addonProduct = addon?.product || {};
@@ -372,7 +390,11 @@ const showAddMultipleProducts = (productId) => {
return false;
}
return createOrderItem(orderId, productId, inputValue)
.then(async () => await addAddonsToOrderMiddleware(productId, inputValue, null, orderId).then(() => loadOrderItems()))
.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));
}
},
@@ -485,6 +507,9 @@ const isProductAddonTrue = (productId, addonId) => {
};
// Set the product addon
const setProductAddon = (productId, addonId, status, quantity = 1) => {
if (status && quantity > 0 && getProductAddonRestriction(productId, addonId).restricted) {
return;
}
// Set the product addon status
const productAddon = productAddons.value.find((productAddon) => productAddon.product_id === productId && productAddon.addon_id === addonId);
if (productAddon === undefined) {
@@ -515,6 +540,10 @@ const addAddonsToOrderMiddleware = async (product_id, quantity = 1, related_item
relatedItemId: related_item_id,
});
await warnIfRestrictedAddonsWereSkipped(restrictedSelections);
const normalizedRelatedItemId = Number(related_item_id);
if (product_addons.length > 0 && (!Number.isInteger(normalizedRelatedItemId) || normalizedRelatedItemId <= 0)) {
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)
@@ -524,7 +553,7 @@ const addAddonsToOrderMiddleware = async (product_id, quantity = 1, related_item
getPendingProductFromAddon(product_id, product_addons[i].addon_id),
addon_quantity,
getAddonPrice(product_id, product_addons[i].addon_id),
related_item_id,
normalizedRelatedItemId,
product_addons[i].notes === undefined ? null : product_addons[i].notes
);
}
@@ -535,7 +564,7 @@ const addAddonsToOrderMiddleware = async (product_id, quantity = 1, related_item
targetOrderId,
product_addons[i].addon_id,
addon_quantity,
related_item_id,
normalizedRelatedItemId,
product_addons[i].notes === undefined ? null : product_addons[i].notes
).catch((error) => handleCreateOrderItemError(error));
}
@@ -686,6 +715,16 @@ const getPreviousOrderProductPrice = (orderItem) => {
return Number(getPreviousOrderProduct(orderItem)?.price ?? 0);
};
const getPreviousOrderItemId = (orderItem) => {
const parsed = Number(orderItem?.id ?? 0);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getPreviousOrderRelatedItemId = (orderItem) => {
const parsed = Number(orderItem?.related_item_id ?? 0);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const addPreviousOrderToCurrent = async (previousOrder) => {
if (!hasOrderItems(previousOrder)) {
return;
@@ -695,14 +734,21 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
return;
}
for (const orderItem of previousOrder) {
const sourceToCreatedItemIds = new Map();
const standaloneItems = previousOrder.filter((orderItem) => getPreviousOrderRelatedItemId(orderItem) === null);
const relatedItems = previousOrder.filter((orderItem) => getPreviousOrderRelatedItemId(orderItem) !== null);
for (const orderItem of standaloneItems) {
const productId = getPreviousOrderProductId(orderItem);
const quantity = Number(orderItem?.quantity ?? 0);
if (productId <= 0 || quantity <= 0) {
continue;
}
const previousOrderProduct = getPreviousOrderProduct(orderItem) || { id: productId, price: getPreviousOrderProductPrice(orderItem) };
const previousOrderProduct = getPreviousOrderProduct(orderItem) || {
id: productId,
price: getPreviousOrderProductPrice(orderItem),
};
const restriction = getProductRestriction(previousOrderProduct);
if (restriction.restricted) {
await showRestrictionWarning(restriction.messageKey);
@@ -712,8 +758,51 @@ const addPreviousOrderToCurrent = async (previousOrder) => {
const discountedPrice = String(getUserProductPrice({ ...previousOrderProduct, price: basePrice }));
showPendingCreateOrderItem({ ...previousOrderProduct, price: basePrice }, quantity, discountedPrice);
await createOrderItem(orderId, productId, quantity).catch((error) => handleCreateOrderItemError(error));
await addAddonsToOrderMiddleware(productId, quantity, null, orderId);
const result = await createOrderItem(
orderId,
productId,
quantity,
null,
String(orderItem?.notes ?? "")
).catch((error) => handleCreateOrderItemError(error));
const sourceItemId = getPreviousOrderItemId(orderItem);
const createdItemId = Number(result?.data?.data?.id ?? 0);
if (sourceItemId && createdItemId > 0) {
sourceToCreatedItemIds.set(sourceItemId, createdItemId);
}
}
for (const orderItem of relatedItems) {
const productId = getPreviousOrderProductId(orderItem);
const quantity = Number(orderItem?.quantity ?? 0);
const relatedItemId = sourceToCreatedItemIds.get(getPreviousOrderRelatedItemId(orderItem));
if (productId <= 0 || quantity <= 0 || !relatedItemId) {
continue;
}
const previousOrderProduct = getPreviousOrderProduct(orderItem) || { id: productId };
const restriction = getAddonRestriction(previousOrderProduct, {
isRelatedAddon: true,
relatedItemId,
});
if (restriction.restricted) {
await showRestrictionWarning(restriction.messageKey);
continue;
}
showPendingCreateOrderItem(
{ ...previousOrderProduct, price: getPreviousOrderProductPrice(orderItem) },
quantity,
String(getUserProductPrice(previousOrderProduct)),
relatedItemId
);
await createOrderItem(
orderId,
productId,
quantity,
relatedItemId,
String(orderItem?.notes ?? "")
).catch((error) => handleCreateOrderItemError(error));
}
await loadOrderItems();
@@ -737,6 +826,15 @@ const getRecommendedProductAddons = (productId) => {
return Array.isArray(product?.addons) ? product.addons : [];
};
const getRecommendedAddonRestriction = (addon) => {
return getAddonRestriction(addon, { isRelatedAddon: true });
};
const getRecommendedAddonRestrictionMessage = (addon) => {
const restriction = getRecommendedAddonRestriction(addon);
return restriction.messageKey ? t(restriction.messageKey) : "";
};
const getRecommendedProductPrice = (productId) => {
const product = getProductById(productId);
if (!product) {
@@ -767,8 +865,11 @@ const addRecommendedProductToOrder = async (productId) => {
}
showPendingCreateOrderItem(product, 1, getRecommendedProductPrice(productId));
await createOrderItem(orderId, productId, 1).catch((error) => handleCreateOrderItemError(error));
await addAddonsToOrderMiddleware(productId, 1, null, orderId);
const result = await createOrderItem(orderId, productId, 1).catch((error) => handleCreateOrderItemError(error));
const createdItemId = Number(result?.data?.data?.id ?? 0);
if (createdItemId > 0) {
await addAddonsToOrderMiddleware(productId, 1, createdItemId, orderId);
}
await loadOrderItems();
};
@@ -953,7 +1054,7 @@ const addProductAddon = (productId, addonId) => {
}
const addon = product.addons.find((addon) => addon.option_id === addonId);
if (!addon) {
if (!addon || getAddonRestriction(addon, { isRelatedAddon: true }).restricted) {
return;
}
@@ -979,6 +1080,9 @@ const setProductAddonNote = (productId, addonId, note) => {
};
const subtractProductAddon = (productId, addonId) => {
if (getProductAddonRestriction(productId, addonId).restricted) {
return;
}
const productAddon = productAddons.value.find((productAddon) => productAddon.product_id === productId && productAddon.addon_id === addonId);
if (productAddon !== undefined) {
if (productAddon.quantity > 0) {
@@ -998,6 +1102,25 @@ const unselectProduct = () => {
}, 100);
};
const sanitizeRestrictedProductAddonSelections = () => {
productAddons.value.forEach((productAddon) => {
if (!productAddon?.status && Number(productAddon?.quantity ?? 0) <= 0) {
return;
}
if (getProductAddonRestriction(productAddon.product_id, productAddon.addon_id).restricted) {
productAddon.status = false;
productAddon.quantity = 0;
}
});
};
watch(
[customer_attributes, customer_attributes_status],
() => sanitizeRestrictedProductAddonSelections(),
{ deep: true }
);
// If the customer id is set, get the discounts
const onCustomerChange = () => {
if (parseInt(customer_id.value) > 0) {
@@ -1127,6 +1250,21 @@ const onSelectCategory = (rawId) => {
<template>
<div>
<div
v-if="customer_attributes_status === 'error'"
class="notification is-danger is-light is-flex is-align-items-center is-justify-content-space-between"
data-testid="pos-customer-restrictions-load-error"
>
<span>{{ t('pos.restrictions.load_failed') }}</span>
<button
type="button"
class="button is-small is-danger is-light"
data-testid="pos-customer-restrictions-retry"
@click="retryCustomerAttributes"
>
{{ t('common.retry') }}
</button>
</div>
<div class="columns is-multiline">
<div class="column is-12 is-flex is-justify-content-space-between">
<!-- Categories - Desktop -->
@@ -1260,13 +1398,35 @@ const onSelectCategory = (rawId) => {
<div class="content">
<div class="buttons is-narrow">
<template v-for="addons in getRecommendedProductAddons(product_id)" :key="addons.id">
<button class="button is-rounded is-small" @click="setProductAddon(product_id, addons.option_id, !isProductAddonTrue(product_id, addons.option_id))" :class="{ 'is-primary': isProductAddonTrue(product_id, addons.option_id), 'is-inverted': isProductAddonTrue(product_id, addons.option_id) }">
<button
class="button is-rounded is-small"
@click="setProductAddon(product_id, addons.option_id, !isProductAddonTrue(product_id, addons.option_id))"
:class="{
'is-primary': isProductAddonTrue(product_id, addons.option_id),
'is-inverted': isProductAddonTrue(product_id, addons.option_id),
'is-danger': getRecommendedAddonRestriction(addons).restricted,
'is-light': getRecommendedAddonRestriction(addons).restricted,
}"
:disabled="getRecommendedAddonRestriction(addons).restricted"
:data-testid="getRecommendedAddonRestriction(addons).restricted
? `pos-recommended-addon-restriction-${addons.option_id}`
: undefined"
>
<span class="icon">
<i class="fas fa-plus" v-if="!isProductAddonTrue(product_id, addons.option_id)"></i>
<i
class="fas"
:class="getRecommendedAddonRestriction(addons).restricted
? 'fa-exclamation-triangle'
: 'fa-plus'"
v-if="!isProductAddonTrue(product_id, addons.option_id)"
></i>
<i class="fas fa-check" v-else></i>
</span>
<span>
{{ addons.name }}
<template v-if="getRecommendedAddonRestriction(addons).restricted">
/ {{ getRecommendedAddonRestrictionMessage(addons) }}
</template>
</span>
</button>
</template>
@@ -1347,6 +1507,7 @@ const onSelectCategory = (rawId) => {
:setProductAddonNote="setProductAddonNote"
:customer-discounts="user_discounts"
v-bind:customer-attributes="customer_attributes"
:customer-attributes-status="customer_attributes_status"
/>
</WhiteBox>
</div>
@@ -11,6 +11,7 @@ import { fetchSuperUserDraftCount } from "@/components/models/navigation/items/s
import { fetchSuperUserBookingCounts } from "@/components/models/navigation/items/superUserBookingCount.js";
import { hasExplicitBookingCountPermission } from "@/components/models/navigation/items/bookingCountGuards.js";
import { NAVIGATION_COUNT_REFRESH_EVENT } from "@/components/models/navigation/items/navigationCountEvents.js";
import { canViewCustomerRuleConfiguration } from "@/features/customer/customerRuleConfigurationPermissions.js";
const firstToUpperCase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
@@ -331,6 +332,11 @@ const items = computed<NavigationItemProps[]>(() => [
children: [
{ label: t("common.products"), to: "/superuser/products" },
{ label: t("superuser.nav.categories"), to: "/superuser/categories" },
{
label: t("customer_rules.configuration.nav"),
to: "/superuser/customer-rules",
hidden: !canViewCustomerRuleConfiguration(),
},
],
},
// Users
@@ -285,6 +285,7 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th
url: requestUrl,
...(method === 'GET' ? { params: data } : { data }),
...(options?.signal ? { signal: options.signal } : {}),
...(options?.responseType ? { responseType: options.responseType } : {}),
__skipRequestQueue: true,
headers
}),
@@ -953,8 +953,30 @@ const assignDraftOrderCustomer = async ({
}
return response.data.data.download_link;
}).catch((error) => {
console.error(error);
});
console.error(error);
});
},
fetchAttachmentContent: async (id, attachment_id, disposition = "inline") => {
const normalizedDisposition = disposition === "attachment" ? "attachment" : "inline";
const response = await authenticatedRequest(
Orders.meta.endpoint + "/attachments/content",
"GET",
{
order_id: id,
attachment_id,
disposition: normalizedDisposition,
},
null,
null,
{
concurrencyLimit: 5,
responseType: "blob",
},
);
if (!(response?.data instanceof Blob)) {
throw new Error("Invalid attachment content response");
}
return response.data;
},
resendWashCertificate: async (id) => {
return SessionUser.request(
+67 -10
View File
@@ -13,8 +13,8 @@ import { doesOrderContainWashCertificateProduct } from "@/components/displays/de
import {
getCustomerProductRestriction,
getProductCategoryRestrictionForCustomer,
hasValidCustomerProductRestrictionContract,
isProductCategoryRestrictedForCustomer,
isProductRestrictedForCustomer,
} from "@/features/customer/customerProductRules.js";
import Swal from "sweetalert2";
@@ -278,6 +278,8 @@ export const reg_3 = ref(""); // Only uppercase letters and numbers without spac
export const order_id = ref(null);
export const order_items = ref([]);
export const customer_attributes = ref([]);
export const customer_attributes_status = ref("idle");
export const customer_attributes_error = ref(null);
export const user_discounts = ref([]);
const has_user_discounts_loaded = ref(false);
export const scan_data = ref([]);
@@ -285,6 +287,7 @@ export const invoiceCollectionId = ref(null);
export const completed_at = ref(null);
let latestCustomerNotesRequestId = 0;
let activeCustomerNotesCustomerNumber = null;
let latestCustomerAttributesRequestId = 0;
const toPositiveInteger = (value) => {
const parsed = Number.parseInt(String(value), 10);
@@ -546,6 +549,9 @@ const clearSelectedCustomerState = (options = {}) => {
customer_name.value = "";
customer_data.value = [];
customer_attributes.value = [];
customer_attributes_status.value = "idle";
customer_attributes_error.value = null;
latestCustomerAttributesRequestId += 1;
notes.value = [];
user_discounts.value = [];
has_user_discounts_loaded.value = false;
@@ -1577,23 +1583,65 @@ export const loadCustomerAttributes = async (customerNumber = customer_id.value)
const normalizedCustomerNumber = resolveCustomerNumber(customerNumber);
if (!normalizedCustomerNumber) {
customer_attributes.value = [];
customer_attributes_status.value = "idle";
customer_attributes_error.value = null;
return customer_attributes.value;
}
const response = await getAttributes(normalizedCustomerNumber);
const requestId = ++latestCustomerAttributesRequestId;
customer_attributes.value = [];
customer_attributes_status.value = "loading";
customer_attributes_error.value = null;
try {
if (!response.data.success) {
const response = await getAttributes(normalizedCustomerNumber);
if (
requestId !== latestCustomerAttributesRequestId ||
getSelectedCustomerNumber() !== normalizedCustomerNumber
) {
return customer_attributes.value;
}
} catch (e) {
if (
!response?.data?.success
|| !Array.isArray(response?.data?.data)
|| !hasValidCustomerProductRestrictionContract(response.data.data)
) {
throw new Error("Customer attributes response was invalid");
}
customer_attributes.value = response.data.data;
customer_attributes_status.value = "ready";
return customer_attributes.value;
} catch (error) {
if (
requestId === latestCustomerAttributesRequestId &&
getSelectedCustomerNumber() === normalizedCustomerNumber
) {
customer_attributes.value = [];
customer_attributes_status.value = "error";
customer_attributes_error.value = error;
}
return customer_attributes.value;
}
};
export const retryCustomerAttributes = () => loadCustomerAttributes(customer_id.value);
const getCustomerAttributeReadinessRestriction = () => {
if (!getSelectedCustomerNumber() || customer_attributes_status.value === "ready") {
return null;
}
if (getSelectedCustomerNumber() !== normalizedCustomerNumber) {
return customer_attributes.value;
}
customer_attributes.value = response.data.data;
return customer_attributes.value;
const isLoadFailure = customer_attributes_status.value === "error";
return {
restricted: true,
rule: null,
rules: [],
collections: [],
messageKey: isLoadFailure ? "pos.restrictions.load_failed" : "pos.restrictions.loading",
reason: isLoadFailure ? "customer-attributes-error" : "customer-attributes-loading",
};
};
/** Check if an attribute is set in the customer attributes */
@@ -1665,17 +1713,22 @@ export const hasOnlyTankCleaning = () => {
/** Check if a product is restricted based on customer attributes */
export const getProductRestriction = (product, options = {}) => {
const readinessRestriction = getCustomerAttributeReadinessRestriction();
if (readinessRestriction) {
return readinessRestriction;
}
return getCustomerProductRestriction(product, customer_attributes.value, options);
};
export const isProductRestricted = (product, options = {}) => {
return isProductRestrictedForCustomer(product, customer_attributes.value, options);
return getProductRestriction(product, options).restricted;
};
const getAddonProductForRestriction = (addon) => {
const addonProduct = addon?.product || addon || {};
return {
...addonProduct,
id: addon?.option_id ?? addonProduct.id ?? addon?.product_id ?? addon?.id,
name: addon?.name || addonProduct.name,
category: addonProduct.category ?? addon?.category,
category_name: addonProduct.category_name ?? addon?.category_name,
@@ -1684,6 +1737,10 @@ const getAddonProductForRestriction = (addon) => {
/** Check if an addon is restricted based on customer attributes */
export const getAddonRestriction = (addon, options = {}) => {
const readinessRestriction = getCustomerAttributeReadinessRestriction();
if (readinessRestriction) {
return readinessRestriction;
}
return getCustomerProductRestriction(getAddonProductForRestriction(addon), customer_attributes.value, {
includeNumericAddonCategory: true,
isRelatedAddon: true,
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { BTooltip } from "buefy";
const props = defineProps({
quantity: {
@@ -18,6 +19,14 @@ const props = defineProps({
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
restrictionMessage: {
type: String,
default: "",
},
});
const isBelow = (value: number, limit: number): boolean => {
@@ -28,21 +37,81 @@ const isBelow = (value: number, limit: number): boolean => {
<template>
<div class="control-select-amount">
<!-- Minus Button -->
<button class="modify-button"
<BTooltip
v-if="props.disabled"
:label="props.restrictionMessage"
:triggers="['hover', 'focus', 'click']"
multilined
position="is-top"
type="is-dark"
append-to-body
>
<span
class="control-select-amount__tooltip-trigger"
tabindex="0"
:data-testid="props.testIdPrefix ? `${props.testIdPrefix}-restriction-tooltip-decrease` : undefined"
>
<button
class="modify-button"
:data-testid="props.testIdPrefix ? `${props.testIdPrefix}-decrease` : undefined"
disabled
aria-label="Decrease amount"
>
-
</button>
</span>
</BTooltip>
<button
v-else
class="modify-button"
:data-testid="props.testIdPrefix ? `${props.testIdPrefix}-decrease` : undefined"
:disabled="props.quantity <= 0"
@click="$emit('update:quantity', isBelow(props.quantity - 1, props.min) ? 0 : props.quantity - 1)"
aria-label="Decrease amount">
aria-label="Decrease amount"
>
-
</button>
<!-- Amount Display -->
<button class="amount-display" :data-testid="props.testIdPrefix ? `${props.testIdPrefix}-value` : undefined">{{ props.quantity }}</button>
<button
class="amount-display"
:data-testid="props.testIdPrefix ? `${props.testIdPrefix}-value` : undefined"
:disabled="props.disabled"
>
{{ props.quantity }}
</button>
<!-- Plus Button -->
<button class="modify-button"
<BTooltip
v-if="props.disabled"
:label="props.restrictionMessage"
:triggers="['hover', 'focus', 'click']"
multilined
position="is-top"
type="is-dark"
append-to-body
>
<span
class="control-select-amount__tooltip-trigger"
tabindex="0"
:data-testid="props.testIdPrefix ? `${props.testIdPrefix}-restriction-tooltip-increase` : undefined"
>
<button
class="modify-button"
:data-testid="props.testIdPrefix ? `${props.testIdPrefix}-increase` : undefined"
disabled
aria-label="Increase amount"
>
+
</button>
</span>
</BTooltip>
<button
v-else
class="modify-button"
:data-testid="props.testIdPrefix ? `${props.testIdPrefix}-increase` : undefined"
:disabled="props.max !== -1 && props.quantity >= props.max"
@click="$emit('update:quantity', isBelow(props.quantity + 1, props.min) ? props.min : props.quantity + 1)"
aria-label="Increase amount">
aria-label="Increase amount"
>
+
</button>
</div>
@@ -73,6 +142,9 @@ const isBelow = (value: number, limit: number): boolean => {
flex-grow: 0;
}
.control-select-amount__tooltip-trigger {
display: inline-flex;
}
.modify-button {
/* - */
@@ -31,6 +31,10 @@ const props = defineProps({
type: Array,
default: null,
},
restriction: {
type: Object,
default: null,
},
testId: {
type: String,
default: "",
@@ -55,6 +59,7 @@ const effectiveProducts = computed(() => (hasProvidedProducts.value ? props.prod
const tooltipModel = computed(() => getCustomerRuleTooltipModel(props.attribute, {
active: props.active,
products: effectiveProducts.value,
restriction: props.restriction || {},
}));
const hasTooltip = computed(() => tooltipModel.value !== null);
const shouldLoadProducts = computed(() => (
@@ -181,7 +186,7 @@ const onTooltipFocusOut = () => {
};
watch(
() => [props.attribute, props.customerNumber, props.departmentId],
() => [props.attribute, props.customerNumber, props.departmentId, props.restriction],
() => {
closeTooltip();
if (!hasProvidedProducts.value) {
+140 -150
View File
@@ -1,9 +1,4 @@
const ADDON_CATEGORY_ID = 4;
const STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID = 8;
const TANK_CLEANING_TERMS = ["tank cleaning", "tankcleaning", "tankrens", "tank rens"];
const ADDITIONAL_SERVICE_TERMS = ["additional service", "additional services", "tillægsydelse", "tillægsydelser"];
const SPOT_FREE_PRODUCT_IDS = [23, 24];
const SPOT_FREE_TERMS = ["spot free", "spotfree", "skylning med ro"];
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
const CUSTOMER_PRODUCT_RULE_MESSAGE_KEYS = {
restrictAdditionalServices: "pos.restrictions.addons_not_allowed",
@@ -13,24 +8,66 @@ const CUSTOMER_PRODUCT_RULE_MESSAGE_KEYS = {
onlyTankCleaning: "pos.restrictions.product_not_allowed",
};
const allowedRestriction = () => ({
restricted: false,
rule: null,
messageKey: null,
});
const PRODUCT_RULE_ORDER = new Map(
getCustomerRuleDefinitions()
.filter((definition) => definition.productImpact)
.map((definition, index) => [definition.attribute, index])
);
const restrictedByRule = (rule) => ({
restricted: true,
rule,
messageKey: CUSTOMER_PRODUCT_RULE_MESSAGE_KEYS[rule] ?? "pos.restrictions.product_not_allowed",
});
export const normalizeCustomerRuleProductId = (value) => {
const parsed = Number(String(value ?? "").trim());
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const normalizeAttributeName = (attribute) => {
if (typeof attribute === "string") {
return attribute;
return attribute.trim();
}
return String(attribute?.attribute ?? "");
return String(attribute?.attribute ?? "").trim();
};
const normalizeProductIds = (values) => {
if (!Array.isArray(values)) {
return [];
}
return [...new Set(values.map(normalizeCustomerRuleProductId).filter((value) => value !== null))];
};
const getCollectionProductIds = (collection = {}) => {
if (Array.isArray(collection.product_ids)) {
return normalizeProductIds(collection.product_ids);
}
if (Array.isArray(collection.products)) {
return normalizeProductIds(
collection.products.map((product) => product?.id ?? product?.product_id ?? product)
);
}
return [];
};
export const normalizeCustomerAttributeProductRestriction = (attribute = {}) => {
const source = attribute?.product_restriction ?? attribute?.productRestriction ?? attribute ?? {};
const collections = Array.isArray(source.collections)
? source.collections.map((collection) => ({
id: collection?.id ?? null,
name: String(collection?.name ?? "").trim(),
product_ids: getCollectionProductIds(collection),
}))
: [];
const directProductIds = normalizeProductIds(
source.disabled_product_ids ?? source.disabledProductIds ?? attribute?.disabled_product_ids
);
const collectionProductIds = collections.flatMap((collection) => collection.product_ids);
return {
version: Number.parseInt(String(source.version ?? 0), 10) || 0,
collections,
disabled_product_ids: [...new Set([...directProductIds, ...collectionProductIds])],
};
};
export const hasCustomerAttribute = (attributes, attributeName) => {
@@ -41,156 +78,109 @@ export const hasCustomerAttribute = (attributes, attributeName) => {
return attributes.some((attribute) => normalizeAttributeName(attribute) === attributeName);
};
const normalizedText = (value) =>
String(value ?? "")
.trim()
.toLowerCase();
const textContainsAny = (value, terms) => {
const text = normalizedText(value);
return terms.some((term) => term !== "" && text.includes(term));
};
export const isTankCleaningCategory = (category) => {
if (Number(category) === 5) {
return true;
}
return textContainsAny(category, ["tank_cleaning", ...TANK_CLEANING_TERMS]);
};
export const isAddonCategory = (category, categoryName = null, options = {}) => {
if (
String(category ?? "")
.trim()
.toLowerCase() === "addons"
) {
return true;
}
if (options.includeNumericAddonCategory === true && Number(category) === ADDON_CATEGORY_ID) {
return true;
}
return textContainsAny([category, categoryName].join(" "), ADDITIONAL_SERVICE_TERMS);
};
export const isStandaloneAdditionalServiceCatalogProduct = (product) => {
if (!product) {
export const hasValidCustomerProductRestrictionContract = (attributes) => {
if (!Array.isArray(attributes)) {
return false;
}
const category = product.category ?? product.product_category;
if (Number(category) === STANDALONE_ADDITIONAL_SERVICE_CATEGORY_ID) {
return true;
}
return attributes.every((attribute) => {
const key = normalizeAttributeName(attribute);
if (!PRODUCT_RULE_ORDER.has(key)) {
return true;
}
return (
isAddonCategory(category, product.category_name ?? product.categoryName) ||
textContainsAny(
[product.name, product.product_name, product.category_name, product.categoryName].join(" "),
ADDITIONAL_SERVICE_TERMS
)
);
const restriction = attribute?.product_restriction;
if (!restriction || typeof restriction !== "object" || Array.isArray(restriction)) {
return false;
}
if (!Number.isInteger(Number(restriction.version)) || Number(restriction.version) < 1) {
return false;
}
if (!Array.isArray(restriction.disabled_product_ids) || !Array.isArray(restriction.collections)) {
return false;
}
if (restriction.disabled_product_ids.some((productId) => normalizeCustomerRuleProductId(productId) === null)) {
return false;
}
return restriction.collections.every((collection) => (
collection
&& typeof collection === "object"
&& !Array.isArray(collection)
&& Array.isArray(collection.product_ids)
&& collection.product_ids.every((productId) => normalizeCustomerRuleProductId(productId) !== null)
));
});
};
export const isTankCleaningProduct = (product) => {
if (!product) {
return false;
const sortedMatchingAttributes = (productId, attributes) => {
if (productId === null || !Array.isArray(attributes)) {
return [];
}
if (isTankCleaningCategory(product.category ?? product.product_category)) {
return true;
}
return textContainsAny(
[product.name, product.product_name, product.category_name, product.categoryName].join(" "),
TANK_CLEANING_TERMS
);
return attributes
.map((attribute) => ({
attribute,
key: normalizeAttributeName(attribute),
restriction: normalizeCustomerAttributeProductRestriction(attribute),
}))
.filter(({ key, restriction }) => (
PRODUCT_RULE_ORDER.has(key) && restriction.disabled_product_ids.includes(productId)
))
.sort((left, right) => (
(PRODUCT_RULE_ORDER.get(left.key) ?? Number.MAX_SAFE_INTEGER)
- (PRODUCT_RULE_ORDER.get(right.key) ?? Number.MAX_SAFE_INTEGER)
));
};
export const isSpotFreeProduct = (product) => {
if (!product) {
return false;
}
const allowedRestriction = () => ({
restricted: false,
rule: null,
rules: [],
collections: [],
messageKey: null,
});
const productId = Number(product.id ?? product.product_id ?? 0);
if (SPOT_FREE_PRODUCT_IDS.includes(productId)) {
return true;
}
export const getCustomerProductRestriction = (product, attributes = []) => {
const productId = normalizeCustomerRuleProductId(product?.id ?? product?.product_id);
const matchingAttributes = sortedMatchingAttributes(productId, attributes);
return textContainsAny(
[product.name, product.product_name, product.category_name, product.categoryName].join(" "),
SPOT_FREE_TERMS
);
};
export const getProductCategoryRestrictionForCustomer = (category, attributes = [], options = {}) => {
if (
hasCustomerAttribute(attributes, "restrictAdditionalServices") &&
isAddonCategory(category, options.categoryName, options)
) {
return restrictedByRule("restrictAdditionalServices");
}
if (category === "spot_free" && hasCustomerAttribute(attributes, "restrictSpotFree")) {
return restrictedByRule("restrictSpotFree");
}
if (category === "interior_cleaning" && hasCustomerAttribute(attributes, "restrictInteriorCleaning")) {
return restrictedByRule("restrictInteriorCleaning");
}
if (isTankCleaningCategory(category) && hasCustomerAttribute(attributes, "restrictTankCleaning")) {
return restrictedByRule("restrictTankCleaning");
}
if (hasCustomerAttribute(attributes, "onlyTankCleaning") && !isTankCleaningCategory(category)) {
return restrictedByRule("onlyTankCleaning");
}
return allowedRestriction();
};
export const isProductCategoryRestrictedForCustomer = (category, attributes = [], options = {}) => {
return getProductCategoryRestrictionForCustomer(category, attributes, options).restricted;
};
export const isAdditionalServiceProduct = (product, options = {}) => {
if (options.isStandaloneAdditionalService === true || options.hasExistingStandaloneOrderItem === true) {
return true;
}
const category = product?.category ?? product?.product_category;
const categoryName = product?.category_name ?? product?.categoryName ?? options.categoryName;
return textContainsAny([product?.name, product?.product_name, categoryName].join(" "), ADDITIONAL_SERVICE_TERMS);
};
export const getCustomerProductRestriction = (product, attributes = [], options = {}) => {
if (!product) {
if (matchingAttributes.length === 0) {
return allowedRestriction();
}
const productName = normalizedText(product.name ?? product.product_name);
const rules = matchingAttributes.map(({ key }) => key);
const matchingCollections = matchingAttributes.flatMap(({ key, restriction }) => (
restriction.collections
.filter((collection) => collection.product_ids.includes(productId))
.map((collection) => ({
...collection,
attribute: key,
}))
));
if (hasCustomerAttribute(attributes, "restrictAdditionalServices") && isAdditionalServiceProduct(product, options)) {
return restrictedByRule("restrictAdditionalServices");
}
if (hasCustomerAttribute(attributes, "restrictSpotFree") && isSpotFreeProduct(product)) {
return restrictedByRule("restrictSpotFree");
}
if (
hasCustomerAttribute(attributes, "restrictInteriorCleaning") &&
textContainsAny(productName, ["interior", "indvendig"])
) {
return restrictedByRule("restrictInteriorCleaning");
}
if (isTankCleaningProduct(product) && hasCustomerAttribute(attributes, "restrictTankCleaning")) {
return restrictedByRule("restrictTankCleaning");
}
if (hasCustomerAttribute(attributes, "onlyTankCleaning") && !isTankCleaningProduct(product)) {
return restrictedByRule("onlyTankCleaning");
}
return allowedRestriction();
return {
restricted: true,
rule: rules[0],
rules,
collections: matchingCollections,
messageKey: CUSTOMER_PRODUCT_RULE_MESSAGE_KEYS[rules[0]] ?? "pos.restrictions.product_not_allowed",
};
};
export const isProductRestrictedForCustomer = (product, attributes = [], options = {}) => {
return getCustomerProductRestriction(product, attributes, options).restricted;
};
// Kept as a compatibility seam while POS category call sites migrate. A category
// alone cannot be restricted now that rules are configured by exact product ID.
export const getProductCategoryRestrictionForCustomer = (_category, attributes = [], options = {}) => {
const product = options.product ?? {
id: options.productId ?? options.product_id,
};
return getCustomerProductRestriction(product, attributes);
};
export const isProductCategoryRestrictedForCustomer = (category, attributes = [], options = {}) => {
return getProductCategoryRestrictionForCustomer(category, attributes, options).restricted;
};
@@ -0,0 +1,24 @@
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const rawPermissions = () => (
Array.isArray(SessionUser.permissions.value) ? SessionUser.permissions.value : []
);
const isRootSuperuserGroup = () => Number(SessionUser.user.group_id.value) === 1;
export const canViewCustomerRuleConfiguration = () => (
SessionUser.canAccessSuperUser()
&& (
isRootSuperuserGroup()
|| rawPermissions().includes("superuser_customer_rules_view")
|| rawPermissions().includes("superuser_customer_rules_manage")
)
);
export const canManageCustomerRuleConfiguration = () => (
SessionUser.canAccessSuperUser()
&& (
isRootSuperuserGroup()
|| rawPermissions().includes("superuser_customer_rules_manage")
)
);
@@ -1,7 +1,6 @@
import {
getCustomerProductRestriction,
isStandaloneAdditionalServiceCatalogProduct,
isTankCleaningProduct,
normalizeCustomerAttributeProductRestriction,
normalizeCustomerRuleProductId,
} from "@/features/customer/customerProductRules.js";
import { getCustomerRuleDefinition } from "@/features/customer/customerRuleRegistry.js";
@@ -20,11 +19,6 @@ const PRODUCT_GROUPS = Object.freeze([
},
]);
const normalizeProductId = (value) => {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const normalizeProductName = (product) => String(product?.name ?? product?.product_name ?? "").trim();
const sortProductsByDisplayOrder = (products) =>
@@ -45,7 +39,7 @@ export const normalizeRuleProductList = (products = []) => {
const seen = new Set();
return sortProductsByDisplayOrder(products).filter((product) => {
const productId = normalizeProductId(product?.id ?? product?.product_id);
const productId = normalizeCustomerRuleProductId(product?.id ?? product?.product_id);
if (productId === null || seen.has(productId)) {
return false;
}
@@ -57,14 +51,12 @@ export const normalizeRuleProductList = (products = []) => {
const addonToProduct = (addon, parentProduct) => {
const addonProduct = addon?.product || {};
const addonId = normalizeProductId(addon?.option_id ?? addonProduct.id ?? addon?.id);
const addonId = normalizeCustomerRuleProductId(addon?.option_id ?? addonProduct.id ?? addon?.id);
return {
...addonProduct,
id: addonId ?? addonProduct.id ?? addon?.id,
name: addon?.name || addonProduct.name,
category: addonProduct.category ?? addon?.category,
category_name: addonProduct.category_name ?? addon?.category_name,
order_priority: addonProduct.order_priority ?? addon?.order_priority ?? parentProduct?.order_priority,
parentProductName: normalizeProductName(parentProduct),
};
@@ -75,53 +67,20 @@ const flattenRelatedAddons = (products) =>
Array.isArray(product?.addons) ? product.addons.map((addon) => addonToProduct(addon, product)) : []
);
const buildAttributeSet = (attribute) => [{ attribute }];
const productIsRestrictedByAttribute = (product, attribute, options = {}) =>
getCustomerProductRestriction(product, buildAttributeSet(attribute), options).rule === attribute;
const emptyGroups = () => ({
primaryProducts: [],
relatedAddons: [],
standaloneAdditionalServices: [],
});
const groupedRestrictedProducts = (attribute, products) => {
const normalizedProducts = normalizeRuleProductList(products);
const relatedAddons = normalizeRuleProductList(flattenRelatedAddons(normalizedProducts));
const standaloneCandidates = normalizeRuleProductList(
normalizedProducts.filter((product) => isStandaloneAdditionalServiceCatalogProduct(product))
);
return {
primaryProducts: normalizedProducts.filter((product) => productIsRestrictedByAttribute(product, attribute)),
relatedAddons:
attribute === "restrictAdditionalServices"
? []
: relatedAddons.filter((product) =>
productIsRestrictedByAttribute(product, attribute, {
includeNumericAddonCategory: true,
isRelatedAddon: true,
})
),
standaloneAdditionalServices: standaloneCandidates.filter((product) =>
productIsRestrictedByAttribute(product, attribute, {
includeNumericAddonCategory: true,
isStandaloneAdditionalService: true,
})
),
};
const restrictedProducts = (products, disabledProductIds) => {
const ids = new Set(disabledProductIds);
return normalizeRuleProductList(products).filter((product) => (
ids.has(normalizeCustomerRuleProductId(product?.id ?? product?.product_id))
));
};
const groupedAllowedOnlyTankCleaningProducts = (products) => ({
primaryProducts: normalizeRuleProductList(products).filter((product) => isTankCleaningProduct(product)),
relatedAddons: normalizeRuleProductList(flattenRelatedAddons(products)).filter((product) =>
isTankCleaningProduct(product)
),
standaloneAdditionalServices: [],
});
export const getCustomerRuleProductImpact = (attribute, products = []) => {
export const getCustomerRuleProductImpact = (attribute, products = [], restriction = {}) => {
const definition = getCustomerRuleDefinition(attribute);
if (!definition?.productImpact) {
return {
@@ -132,15 +91,24 @@ export const getCustomerRuleProductImpact = (attribute, products = []) => {
}
const normalizedProducts = normalizeRuleProductList(products);
const blocked = groupedRestrictedProducts(attribute, normalizedProducts);
const available =
attribute === "onlyTankCleaning" ? groupedAllowedOnlyTankCleaningProducts(normalizedProducts) : emptyGroups();
const normalizedRestriction = normalizeCustomerAttributeProductRestriction(restriction);
const relatedAddons = normalizeRuleProductList(flattenRelatedAddons(normalizedProducts));
const primaryProductIds = new Set(
normalizedProducts.map((product) => normalizeCustomerRuleProductId(product?.id ?? product?.product_id))
);
const blockedPrimary = restrictedProducts(normalizedProducts, normalizedRestriction.disabled_product_ids);
const blockedRelated = restrictedProducts(relatedAddons, normalizedRestriction.disabled_product_ids)
.filter((product) => !primaryProductIds.has(normalizeCustomerRuleProductId(product?.id ?? product?.product_id)));
return {
hasProductImpact: true,
groups: PRODUCT_GROUPS,
blocked,
available,
blocked: {
primaryProducts: blockedPrimary,
relatedAddons: blockedRelated,
standaloneAdditionalServices: [],
},
available: emptyGroups(),
};
};
@@ -153,7 +121,11 @@ export const getCustomerRuleTooltipModel = (attribute, options = {}) => {
return null;
}
const productImpact = getCustomerRuleProductImpact(attribute, options.products || []);
const productImpact = getCustomerRuleProductImpact(
attribute,
options.products || [],
options.restriction || {}
);
return {
attribute,
@@ -0,0 +1,90 @@
import axios from "axios";
import { API_URL } from "@/config.js";
import { normalizeCustomerRuleProductId } from "@/features/customer/customerProductRules.js";
const getAuthHeaders = () => {
const token = window.localStorage.getItem("token");
return token ? { Authorization: `Bearer ${token}` } : null;
};
export const extractCustomerRuleProductRestrictionPayload = (response) => (
response?.data?.data ?? response?.data ?? response ?? {}
);
const normalizeProductIds = (values) => (
[...new Set((Array.isArray(values) ? values : [])
.map((product) => normalizeCustomerRuleProductId(product?.id ?? product?.product_id ?? product))
.filter((productId) => productId !== null))]
);
export const normalizeCustomerRuleCollection = (collection = {}, index = 0) => ({
id: collection.id ?? null,
name: String(collection.name ?? "").trim(),
sort_order: Number.parseInt(String(collection.sort_order ?? index), 10) || 0,
product_ids: normalizeProductIds(collection.product_ids ?? collection.products),
});
export const normalizeCustomerRuleProductRestriction = (rule = {}) => {
const collections = (Array.isArray(rule.collections) ? rule.collections : [])
.map(normalizeCustomerRuleCollection)
.sort((left, right) => left.sort_order - right.sort_order || left.name.localeCompare(right.name));
return {
attribute: String(rule.attribute ?? "").trim(),
version: Number.parseInt(String(rule.version ?? 0), 10) || 0,
collections,
disabled_product_ids: normalizeProductIds([
...(Array.isArray(rule.disabled_product_ids) ? rule.disabled_product_ids : []),
...collections.flatMap((collection) => collection.product_ids),
]),
};
};
export const normalizeCustomerRuleProductRestrictionResponse = (response) => {
const payload = extractCustomerRuleProductRestrictionPayload(response);
const rawRules = Array.isArray(payload) ? payload : (payload.rules ?? payload.product_restrictions ?? []);
const rawProducts = Array.isArray(payload.products) ? payload.products : (payload.available_products ?? []);
return {
rules: (Array.isArray(rawRules) ? rawRules : []).map(normalizeCustomerRuleProductRestriction),
products: (Array.isArray(rawProducts) ? rawProducts : [])
.map((product) => ({
...product,
id: normalizeCustomerRuleProductId(product?.id ?? product?.product_id),
name: String(product?.name ?? product?.product_name ?? "").trim(),
}))
.filter((product) => product.id !== null && product.name !== ""),
};
};
export const serializeCustomerRuleProductRestriction = (rule = {}) => ({
version: Number.parseInt(String(rule.version ?? 0), 10) || 0,
collections: (Array.isArray(rule.collections) ? rule.collections : []).map((collection, index) => ({
...(Number.isInteger(Number(collection.id)) && Number(collection.id) > 0 ? { id: Number(collection.id) } : {}),
name: String(collection.name ?? "").trim(),
sort_order: index,
product_ids: normalizeProductIds(collection.product_ids),
})),
});
export const listCustomerRuleProductRestrictions = () => {
const headers = getAuthHeaders();
if (!headers) {
return null;
}
return axios.get(`${API_URL}/superuser/customer-rules/product-restrictions`, { headers });
};
export const updateCustomerRuleProductRestriction = (attribute, rule) => {
const headers = getAuthHeaders();
if (!headers || !attribute) {
return null;
}
return axios.put(
`${API_URL}/superuser/customer-rules/product-restrictions/${encodeURIComponent(attribute)}`,
serializeCustomerRuleProductRestriction(rule),
{ headers }
);
};
+37 -2
View File
@@ -3486,12 +3486,37 @@
"label": "@.capitalize:{'words.generated.brug'} @:{'words.generated.po'}-@:{'words.generated.numre'}"
}
},
"configuration": {
"add_collection": "Tilføj samling",
"archived": "Arkiveret",
"collection_name": "Samlingens navn",
"default_collection_name": "Samling {count}",
"errors": {
"collection_names": "Samlinger skal have unikke navne inden for reglen.",
"conflict": "Reglen er ændret af en anden. Genindlæs den aktuelle konfiguration, før du gemmer igen.",
"load": "Produktbegrænsningerne kunne ikke indlæses.",
"save": "Produktbegrænsningen kunne ikke gemmes."
},
"global_warning": "Samlingerne er globale. En ændring påvirker alle kunder, der har den tilsvarende regel.",
"move_down": "Flyt ned",
"move_up": "Flyt op",
"nav": "Kunderegler",
"no_products": "Ingen produkter matcher.",
"product_count": "{count} blokerede produkter",
"read_only": "Du har skrivebeskyttet adgang til denne konfiguration.",
"reload_configuration": "Genindlæs konfiguration",
"search_products": "Søg efter produkter",
"subtitle": "Administrer de præcise produkter, der blokeres af hver kunderegel",
"title": "Kundereglernes produktbegrænsninger",
"version": "Version {version}"
},
"manager": {
"active_count": "{count} aktive",
"errors": {
"load": "Kunderegler kunne ikke indlæses.",
"update": "Kundereglen kunne ikke opdateres."
},
"global_configuration": "Administrer globale produktbegrænsninger",
"no_target": "Der kræves et bruger-id eller kundenummer, før kunderegler kan administreres.",
"toggle_unavailable": "Du har ikke tilladelse til at ændre denne kunderegel.",
"unavailable": "Du har ikke tilladelse til at se kunderegler.",
@@ -4132,6 +4157,11 @@
},
"invoice_period": {
"flags": {
"badge": {
"automatic": "Automatiske advarsler",
"manual": "Manuelle flag",
"summary": "{red} røde flag og {yellow} gule advarsler"
},
"automatic": {
"customer_rule_exempt_from_administration_fees": "{product} @:{'words.generated.er'} @:{'words.generated.et_2'} @:{'words.generated.administrationsgebyr'} @:{'words.generated.for'} @:{'words.replication.host_definite_suffix'} fritaget @:{'words.generated.kunde'}.",
"customer_rule_invoice_all_orders_individually": "Fakturasamlingen @:{'words.generated.indeholder'} @:{'words.generated.flere'} @:{'words.generated.ordrer'} @:{'words.generated.for'} @:{'words.replication.host_definite_suffix'} @:{'words.generated.kunde'}, @:{'words.generated.der'} @:{'words.generated.skal'} faktureres @:{'words.generated.pr'}. @:{'words.generated.ordre'}.",
@@ -4391,6 +4421,7 @@
"resend_booking_completion": "Gensend afslutning",
"resend_wash_certificate": "Gensend vaskecertifikat",
"retry": "Prøv igen",
"show_more": "Vis {count} mere",
"unlink_booking": "Fjern booking",
"unlink_xlvask": "Fjern Selvvask"
},
@@ -4416,6 +4447,7 @@
"errors": {
"action_failed": "Handlingen mislykkedes",
"download_failed": "Download mislykkedes",
"invalid_attachment": "Vedhæftningen kunne ikke indlæses.",
"load_failed": "Kunne ikke indlæse indholdet."
},
"economic": {
@@ -4434,6 +4466,7 @@
"price": "Pris",
"product_id": "Produkt ID",
"quantity": "Antal",
"registration_number": "Registreringsnummer",
"reference": "Reference",
"show_empty": "Vis tomme felter"
},
@@ -4447,7 +4480,7 @@
"economic_draft_with_id": "E-conomic kladde #{id}",
"fixed_pricing": "Fastpris",
"invoice_for_order": "Faktura for ordre #{id}",
"order": "Ordre #{id}",
"order": "Vask #{id}",
"order_item_fallback": "Linje #{id}",
"orders_without_collection": "Orders uden fakturasamling",
"payment_for_order": "Kortbetaling for ordre #{id}",
@@ -4467,7 +4500,7 @@
},
"subtitles": {
"collection": "Samling #{id}",
"order": "Ordre #{id}",
"order": "Vask #{id}",
"quantity": "Antal {count}",
"wash_id": "WashId {id}"
},
@@ -5102,6 +5135,8 @@
"restrictions": {
"addons_not_allowed": "Tilvalg er ikke tilladt for denne kunde",
"backend_rejected": "Produktet blev afvist af kundereglerne og er ikke tilføjet",
"load_failed": "Kundereglerne kunne ikke indlæses. Prøv igen, før du ændrer produkter.",
"loading": "Kundereglerne indlæses. Produktændringer er midlertidigt deaktiveret.",
"product_not_allowed": "Produktet er ikke tilladt for denne kunde",
"restricted_items_removed": "Begrænsede tilvalg er fjernet fra kurven",
"title": "Kunderegel"
+35
View File
@@ -3596,12 +3596,37 @@
"label": "@.capitalize:{'words.generated.use'} @:{'words.generated.po'} @:{'words.generated.numbers'}"
}
},
"configuration": {
"add_collection": "Sammlung hinzufügen",
"archived": "Archiviert",
"collection_name": "Name der Sammlung",
"default_collection_name": "Sammlung {count}",
"errors": {
"collection_names": "Sammlungen müssen innerhalb der Regel eindeutige Namen haben.",
"conflict": "Diese Regel wurde von jemand anderem geändert. Laden Sie die aktuelle Konfiguration neu.",
"load": "Produktbeschränkungen konnten nicht geladen werden.",
"save": "Die Produktbeschränkung konnte nicht gespeichert werden."
},
"global_warning": "Diese Sammlungen sind global. Eine Änderung betrifft alle Kunden mit der entsprechenden Regel.",
"move_down": "Nach unten verschieben",
"move_up": "Nach oben verschieben",
"nav": "Kundenregeln",
"no_products": "Keine passenden Produkte.",
"product_count": "{count} gesperrte Produkte",
"read_only": "Sie haben schreibgeschützten Zugriff auf diese Konfiguration.",
"reload_configuration": "Konfiguration neu laden",
"search_products": "Produkte suchen",
"subtitle": "Verwalten Sie die genauen Produkte, die von jeder Kundenregel gesperrt werden",
"title": "Produktbeschränkungen für Kundenregeln",
"version": "Version {version}"
},
"manager": {
"active_count": "{count} aktiv",
"errors": {
"load": "Kundenregeln konnten nicht geladen werden.",
"update": "Die Kundenregel konnte nicht aktualisiert werden."
},
"global_configuration": "Globale Produktbeschränkungen verwalten",
"no_target": "Eine Benutzer-ID oder Kundennummer ist erforderlich, bevor Kundenregeln verwaltet werden können.",
"toggle_unavailable": "Sie haben keine Berechtigung, diese Kundenregel zu ändern.",
"unavailable": "Sie haben keine Berechtigung, Kundenregeln anzuzeigen.",
@@ -4242,6 +4267,11 @@
},
"invoice_period": {
"flags": {
"badge": {
"automatic": "Automatische Warnungen",
"manual": "Manuelle Markierungen",
"summary": "{red} rote Markierungen und {yellow} gelbe Warnungen"
},
"automatic": {
"customer_rule_exempt_from_administration_fees": "{product} @:{'words.generated.is'} @:{'words.generated.an'} @:{'words.generated.administration'} @:{'words.generated.fee'} @:{'words.generated.for'} @:{'words.generated.an'} @:{'words.generated.exempt'} @:{'words.generated.customer'}.",
"customer_rule_invoice_all_orders_individually": "@.capitalize:{'words.generated.invoice'} @:{'words.generated.collection'} @:{'words.generated.contains'} @:{'words.generated.multiple'} @:{'words.generated.orders'} @:{'words.generated.for'} @:{'words.generated.a'} @:{'words.generated.customer'} requiring individual @:{'words.generated.invoices'}.",
@@ -4501,6 +4531,7 @@
"resend_booking_completion": "Abschluss erneut senden",
"resend_wash_certificate": "Waschzertifikat erneut senden",
"retry": "Erneut versuchen",
"show_more": "{count} weitere anzeigen",
"unlink_booking": "Buchung entfernen",
"unlink_xlvask": "Selbstwasch entfernen"
},
@@ -4526,6 +4557,7 @@
"errors": {
"action_failed": "Aktion fehlgeschlagen",
"download_failed": "Download fehlgeschlagen",
"invalid_attachment": "Der Anhang konnte nicht geladen werden.",
"load_failed": "Inhalt konnte nicht geladen werden."
},
"economic": {
@@ -4544,6 +4576,7 @@
"price": "Preis",
"product_id": "Produkt-ID",
"quantity": "Anzahl",
"registration_number": "Kennzeichen",
"reference": "Referenz",
"show_empty": "Leere Felder anzeigen"
},
@@ -5212,6 +5245,8 @@
"restrictions": {
"addons_not_allowed": "Zusatzoptionen sind für diesen Kunden nicht erlaubt",
"backend_rejected": "Das Produkt wurde durch Kundenregeln abgelehnt und nicht hinzugefügt",
"load_failed": "Kundenregeln konnten nicht geladen werden. Versuchen Sie es erneut, bevor Sie Produkte ändern.",
"loading": "Kundenregeln werden geladen. Produktänderungen sind vorübergehend deaktiviert.",
"product_not_allowed": "Dieses Produkt ist für diesen Kunden nicht erlaubt",
"restricted_items_removed": "Eingeschränkte Zusatzoptionen wurden aus dem Warenkorb entfernt",
"title": "Kundenregel"
+37 -2
View File
@@ -3317,12 +3317,37 @@
"label": "@.capitalize:{'words.generated.use'} @:{'words.generated.po'} @:{'words.generated.numbers'}"
}
},
"configuration": {
"add_collection": "Add collection",
"archived": "Archived",
"collection_name": "Collection name",
"default_collection_name": "Collection {count}",
"errors": {
"collection_names": "Collection names must be present and unique within the rule.",
"conflict": "This rule was changed by someone else. Reload the current configuration before saving again.",
"load": "Could not load product restrictions.",
"save": "Could not save the product restriction."
},
"global_warning": "These collections are global. A change affects every customer that has the corresponding rule.",
"move_down": "Move down",
"move_up": "Move up",
"nav": "Customer rules",
"no_products": "No matching products.",
"product_count": "{count} disabled products",
"read_only": "You have read-only access to this configuration.",
"reload_configuration": "Reload configuration",
"search_products": "Search products",
"subtitle": "Manage the exact products disabled by each customer rule",
"title": "Customer rule product restrictions",
"version": "Version {version}"
},
"manager": {
"active_count": "{count} active",
"errors": {
"load": "Could not load customer rules.",
"update": "Could not update the customer rule."
},
"global_configuration": "Manage global product restrictions",
"no_target": "A user id or customer number is required before customer rules can be managed.",
"toggle_unavailable": "You do not have permission to change this customer rule.",
"unavailable": "You do not have permission to view customer rules.",
@@ -3963,6 +3988,11 @@
},
"invoice_period": {
"flags": {
"badge": {
"automatic": "Automatic warnings",
"manual": "Manual flags",
"summary": "{red} red flags and {yellow} yellow warnings"
},
"automatic": {
"customer_rule_exempt_from_administration_fees": "{product} @:{'words.generated.is'} @:{'words.generated.an'} @:{'words.generated.administration'} @:{'words.generated.fee'} @:{'words.generated.for'} @:{'words.generated.an'} @:{'words.generated.exempt'} @:{'words.generated.customer'}.",
"customer_rule_invoice_all_orders_individually": "@.capitalize:{'words.generated.invoice'} @:{'words.generated.collection'} @:{'words.generated.contains'} @:{'words.generated.multiple'} @:{'words.generated.orders'} @:{'words.generated.for'} @:{'words.generated.a'} @:{'words.generated.customer'} @:{'words.generated.requiring'} individual @:{'words.generated.invoices'}.",
@@ -4222,6 +4252,7 @@
"resend_booking_completion": "Resend completion",
"resend_wash_certificate": "Resend wash certificate",
"retry": "Retry",
"show_more": "Show {count} more",
"unlink_booking": "Remove booking",
"unlink_xlvask": "Remove self-wash"
},
@@ -4247,6 +4278,7 @@
"errors": {
"action_failed": "Action failed",
"download_failed": "Download failed",
"invalid_attachment": "The attachment could not be loaded.",
"load_failed": "Could not load the content."
},
"economic": {
@@ -4265,6 +4297,7 @@
"price": "Price",
"product_id": "Product ID",
"quantity": "Quantity",
"registration_number": "Registration number",
"reference": "Reference",
"show_empty": "Show empty fields"
},
@@ -4278,7 +4311,7 @@
"economic_draft_with_id": "E-conomic draft #{id}",
"fixed_pricing": "Fixed pricing",
"invoice_for_order": "Invoice for order #{id}",
"order": "Order #{id}",
"order": "Wash #{id}",
"order_item_fallback": "Line #{id}",
"orders_without_collection": "Orders without invoice collection",
"payment_for_order": "Card payment for order #{id}",
@@ -4298,7 +4331,7 @@
},
"subtitles": {
"collection": "Collection #{id}",
"order": "Order #{id}",
"order": "Wash #{id}",
"quantity": "Quantity {count}",
"wash_id": "WashId {id}"
},
@@ -4933,6 +4966,8 @@
"restrictions": {
"addons_not_allowed": "Add-ons are not allowed for this customer",
"backend_rejected": "The product was rejected by customer rules and was not added",
"load_failed": "Customer rules could not be loaded. Retry before changing products.",
"loading": "Customer rules are loading. Product changes are temporarily disabled.",
"product_not_allowed": "This product is not allowed for this customer",
"restricted_items_removed": "Restricted add-ons were removed from the cart",
"title": "Customer rule"
+32
View File
@@ -2301,12 +2301,37 @@
"label": "@:{'templates.generated.compat.customer_rules.attributes.usePONumbers.label'}"
}
},
"configuration": {
"add_collection": "@:{'templates.generated.compat.customer_rules.configuration.add_collection'}",
"archived": "@:{'templates.generated.compat.customer_rules.configuration.archived'}",
"collection_name": "@:{'templates.generated.compat.customer_rules.configuration.collection_name'}",
"default_collection_name": "@:{'templates.generated.compat.customer_rules.configuration.default_collection_name'}",
"errors": {
"collection_names": "@:{'templates.generated.compat.customer_rules.configuration.errors.collection_names'}",
"conflict": "@:{'templates.generated.compat.customer_rules.configuration.errors.conflict'}",
"load": "@:{'templates.generated.compat.customer_rules.configuration.errors.load'}",
"save": "@:{'templates.generated.compat.customer_rules.configuration.errors.save'}"
},
"global_warning": "@:{'templates.generated.compat.customer_rules.configuration.global_warning'}",
"move_down": "@:{'templates.generated.compat.customer_rules.configuration.move_down'}",
"move_up": "@:{'templates.generated.compat.customer_rules.configuration.move_up'}",
"nav": "@:{'templates.generated.compat.customer_rules.configuration.nav'}",
"no_products": "@:{'templates.generated.compat.customer_rules.configuration.no_products'}",
"product_count": "@:{'templates.generated.compat.customer_rules.configuration.product_count'}",
"read_only": "@:{'templates.generated.compat.customer_rules.configuration.read_only'}",
"reload_configuration": "@:{'templates.generated.compat.customer_rules.configuration.reload_configuration'}",
"search_products": "@:{'templates.generated.compat.customer_rules.configuration.search_products'}",
"subtitle": "@:{'templates.generated.compat.customer_rules.configuration.subtitle'}",
"title": "@:{'templates.generated.compat.customer_rules.configuration.title'}",
"version": "@:{'templates.generated.compat.customer_rules.configuration.version'}"
},
"manager": {
"active_count": "@:{'templates.generated.compat.customer_rules.manager.active_count'}",
"errors": {
"load": "@:{'templates.generated.compat.customer_rules.manager.errors.load'}",
"update": "@:{'templates.generated.compat.customer_rules.manager.errors.update'}"
},
"global_configuration": "@:{'templates.generated.compat.customer_rules.manager.global_configuration'}",
"no_target": "@:{'templates.generated.compat.customer_rules.manager.no_target'}",
"toggle_unavailable": "@:{'templates.generated.compat.customer_rules.manager.toggle_unavailable'}",
"unavailable": "@:{'templates.generated.compat.customer_rules.manager.unavailable'}",
@@ -3281,6 +3306,11 @@
"wash_certificate_item_without_certificate": "@:{'templates.generated.compat.invoice_period.flags.automatic.wash_certificate_item_without_certificate'}",
"xlvask_missing_order_link": "@:{'templates.generated.compat.invoice_period.flags.automatic.xlvask_missing_order_link'}"
},
"badge": {
"automatic": "@:{'templates.generated.compat.invoice_period.flags.badge.automatic'}",
"manual": "@:{'templates.generated.compat.invoice_period.flags.badge.manual'}",
"summary": "@:{'templates.generated.compat.invoice_period.flags.badge.summary'}"
},
"preview": {
"customer": "@:{'templates.generated.compat.common.customer'}",
"entities": {
@@ -4480,6 +4510,8 @@
"restrictions": {
"addons_not_allowed": "@:{'templates.generated.compat.pos.restrictions.addons_not_allowed'}",
"backend_rejected": "@:{'templates.generated.compat.pos.restrictions.backend_rejected'}",
"load_failed": "@:{'templates.generated.compat.pos.restrictions.load_failed'}",
"loading": "@:{'templates.generated.compat.pos.restrictions.loading'}",
"product_not_allowed": "@:{'templates.generated.compat.pos.restrictions.product_not_allowed'}",
"restricted_items_removed": "@:{'templates.generated.compat.pos.restrictions.restricted_items_removed'}",
"title": "@:{'templates.generated.compat.pos.restrictions.title'}"
+35
View File
@@ -3599,12 +3599,37 @@
"label": "@.capitalize:{'words.generated.use'} @:{'words.generated.po'} @:{'words.generated.numbers'}"
}
},
"configuration": {
"add_collection": "Legg til samling",
"archived": "Arkivert",
"collection_name": "Navn på samling",
"default_collection_name": "Samling {count}",
"errors": {
"collection_names": "Samlinger må ha unike navn innenfor regelen.",
"conflict": "Denne regelen ble endret av noen andre. Last inn gjeldende konfigurasjon på nytt.",
"load": "Produktbegrensningene kunne ikke lastes.",
"save": "Produktbegrensningen kunne ikke lagres."
},
"global_warning": "Disse samlingene er globale. En endring påvirker alle kunder med den tilsvarende regelen.",
"move_down": "Flytt ned",
"move_up": "Flytt opp",
"nav": "Kunderegler",
"no_products": "Ingen produkter samsvarer.",
"product_count": "{count} blokkerte produkter",
"read_only": "Du har skrivebeskyttet tilgang til denne konfigurasjonen.",
"reload_configuration": "Last inn konfigurasjonen på nytt",
"search_products": "Søk etter produkter",
"subtitle": "Administrer de nøyaktige produktene som blokkeres av hver kunderegel",
"title": "Produktbegrensninger for kunderegler",
"version": "Versjon {version}"
},
"manager": {
"active_count": "{count} aktive",
"errors": {
"load": "Kunderegler kunne ikke lastes.",
"update": "Kunderegelen kunne ikke oppdateres."
},
"global_configuration": "Administrer globale produktbegrensninger",
"no_target": "Bruker-ID eller kundenummer kreves før kunderegler kan administreres.",
"toggle_unavailable": "Du har ikke tillatelse til å endre denne kunderegelen.",
"unavailable": "Du har ikke tillatelse til å se kunderegler.",
@@ -4245,6 +4270,11 @@
},
"invoice_period": {
"flags": {
"badge": {
"automatic": "Automatiske advarsler",
"manual": "Manuelle flagg",
"summary": "{red} røde flagg og {yellow} gule advarsler"
},
"automatic": {
"customer_rule_exempt_from_administration_fees": "{product} @:{'words.generated.is'} @:{'words.generated.an'} @:{'words.generated.administration'} @:{'words.generated.fee'} @:{'words.generated.for'} @:{'words.generated.an'} @:{'words.generated.exempt'} @:{'words.generated.customer'}.",
"customer_rule_invoice_all_orders_individually": "@.capitalize:{'words.generated.invoice'} @:{'words.generated.collection'} @:{'words.generated.contains'} @:{'words.generated.multiple'} @:{'words.generated.orders'} @:{'words.generated.for'} @:{'words.generated.a_2'} @:{'words.generated.customer'} requiring individual @:{'words.generated.invoices'}.",
@@ -4504,6 +4534,7 @@
"resend_booking_completion": "Send fullføring på nytt",
"resend_wash_certificate": "Send vaskesertifikat på nytt",
"retry": "Prøv igjen",
"show_more": "Vis {count} til",
"unlink_booking": "Fjern booking",
"unlink_xlvask": "Fjern selvvask"
},
@@ -4529,6 +4560,7 @@
"errors": {
"action_failed": "Handlingen mislyktes",
"download_failed": "Nedlasting mislyktes",
"invalid_attachment": "Vedlegget kunne ikke lastes inn.",
"load_failed": "Kunne ikke laste inn innholdet."
},
"economic": {
@@ -4547,6 +4579,7 @@
"price": "Pris",
"product_id": "Produkt-ID",
"quantity": "Antall",
"registration_number": "Registreringsnummer",
"reference": "Referanse",
"show_empty": "Vis tomme felter"
},
@@ -5215,6 +5248,8 @@
"restrictions": {
"addons_not_allowed": "Tilvalg er ikke tillatt for denne kunden",
"backend_rejected": "Produktet ble avvist av kunderegler og ble ikke lagt til",
"load_failed": "Kundereglene kunne ikke lastes. Prøv igjen før du endrer produkter.",
"loading": "Kundereglene lastes. Produktendringer er midlertidig deaktivert.",
"product_not_allowed": "Dette produktet er ikke tillatt for denne kunden",
"restricted_items_removed": "Begrensede tilvalg ble fjernet fra kurven",
"title": "Kunderegel"
+35
View File
@@ -3649,12 +3649,37 @@
"label": "@.capitalize:{'words.generated.use'} @:{'words.generated.po'} @:{'words.generated.numbers'}"
}
},
"configuration": {
"add_collection": "Lägg till samling",
"archived": "Arkiverad",
"collection_name": "Samlingens namn",
"default_collection_name": "Samling {count}",
"errors": {
"collection_names": "Samlingar måste ha unika namn inom regeln.",
"conflict": "Regeln har ändrats av någon annan. Läs in den aktuella konfigurationen igen.",
"load": "Produktbegränsningarna kunde inte läsas in.",
"save": "Produktbegränsningen kunde inte sparas."
},
"global_warning": "Samlingarna är globala. En ändring påverkar alla kunder med motsvarande regel.",
"move_down": "Flytta ned",
"move_up": "Flytta upp",
"nav": "Kundregler",
"no_products": "Inga produkter matchar.",
"product_count": "{count} blockerade produkter",
"read_only": "Du har skrivskyddad åtkomst till den här konfigurationen.",
"reload_configuration": "Läs in konfigurationen igen",
"search_products": "Sök efter produkter",
"subtitle": "Hantera de exakta produkter som blockeras av varje kundregel",
"title": "Produktbegränsningar för kundregler",
"version": "Version {version}"
},
"manager": {
"active_count": "{count} aktiva",
"errors": {
"load": "Kundregler kunde inte läsas in.",
"update": "Kundregeln kunde inte uppdateras."
},
"global_configuration": "Hantera globala produktbegränsningar",
"no_target": "Användar-id eller kundnummer krävs innan kundregler kan hanteras.",
"toggle_unavailable": "Du har inte behörighet att ändra denna kundregel.",
"unavailable": "Du har inte behörighet att visa kundregler.",
@@ -4295,6 +4320,11 @@
},
"invoice_period": {
"flags": {
"badge": {
"automatic": "Automatiska varningar",
"manual": "Manuella flaggor",
"summary": "{red} röda flaggor och {yellow} gula varningar"
},
"automatic": {
"customer_rule_exempt_from_administration_fees": "{product} @:{'words.generated.is'} @:{'words.generated.an'} @:{'words.generated.administration'} @:{'words.generated.fee'} @:{'words.generated.for_2'} @:{'words.generated.an'} @:{'words.generated.exempt'} @:{'words.generated.customer'}.",
"customer_rule_invoice_all_orders_individually": "@.capitalize:{'words.generated.invoice'} @:{'words.generated.collection'} @:{'words.generated.contains'} @:{'words.generated.multiple'} @:{'words.generated.orders'} @:{'words.generated.for_2'} @:{'words.generated.a'} @:{'words.generated.customer'} requiring individual @:{'words.generated.invoices'}.",
@@ -4554,6 +4584,7 @@
"resend_booking_completion": "Skicka slutförande igen",
"resend_wash_certificate": "Skicka tvättcertifikat igen",
"retry": "Försök igen",
"show_more": "Visa {count} till",
"unlink_booking": "Ta bort bokning",
"unlink_xlvask": "Ta bort självtvätt"
},
@@ -4579,6 +4610,7 @@
"errors": {
"action_failed": "Åtgärden misslyckades",
"download_failed": "Nedladdningen misslyckades",
"invalid_attachment": "Bilagan kunde inte läsas in.",
"load_failed": "Kunde inte läsa in innehållet."
},
"economic": {
@@ -4597,6 +4629,7 @@
"price": "Pris",
"product_id": "Produkt-ID",
"quantity": "Antal",
"registration_number": "Registreringsnummer",
"reference": "Referens",
"show_empty": "Visa tomma fält"
},
@@ -5265,6 +5298,8 @@
"restrictions": {
"addons_not_allowed": "Tillval är inte tillåtna för den här kunden",
"backend_rejected": "Produkten avvisades av kundregler och lades inte till",
"load_failed": "Kundreglerna kunde inte läsas in. Försök igen innan du ändrar produkter.",
"loading": "Kundreglerna läses in. Produktändringar är tillfälligt inaktiverade.",
"product_not_allowed": "Den här produkten är inte tillåten för kunden",
"restricted_items_removed": "Begränsade tillval togs bort från varukorgen",
"title": "Kundregel"
@@ -50,12 +50,37 @@
"label": "@.capitalize:{'terms.glossary.brug'} @:{'terms.glossary.po'}-@:{'terms.glossary.numre'}"
}
},
"configuration": {
"add_collection": "Tilføj samling",
"archived": "Arkiveret",
"collection_name": "Samlingens navn",
"default_collection_name": "Samling {count}",
"errors": {
"collection_names": "Samlinger skal have unikke navne inden for reglen.",
"conflict": "Reglen er ændret af en anden. Genindlæs den aktuelle konfiguration, før du gemmer igen.",
"load": "Produktbegrænsningerne kunne ikke indlæses.",
"save": "Produktbegrænsningen kunne ikke gemmes."
},
"global_warning": "Samlingerne er globale. En ændring påvirker alle kunder, der har den tilsvarende regel.",
"move_down": "Flyt ned",
"move_up": "Flyt op",
"nav": "Kunderegler",
"no_products": "Ingen produkter matcher.",
"product_count": "{count} blokerede produkter",
"read_only": "Du har skrivebeskyttet adgang til denne konfiguration.",
"reload_configuration": "Genindlæs konfiguration",
"search_products": "Søg efter produkter",
"subtitle": "Administrer de præcise produkter, der blokeres af hver kunderegel",
"title": "Kundereglernes produktbegrænsninger",
"version": "Version {version}"
},
"manager": {
"active_count": "{count} aktive",
"errors": {
"load": "Kunderegler kunne ikke indlæses.",
"update": "Kundereglen kunne ikke opdateres."
},
"global_configuration": "Administrer globale produktbegrænsninger",
"no_target": "Der kræves et bruger-id eller kundenummer, før kunderegler kan administreres.",
"toggle_unavailable": "Du har ikke tilladelse til at ændre denne kunderegel.",
"unavailable": "Du har ikke tilladelse til at se kunderegler.",
@@ -2,6 +2,11 @@
"compat": {
"invoice_period": {
"flags": {
"badge": {
"automatic": "Automatiske advarsler",
"manual": "Manuelle flag",
"summary": "{red} røde flag og {yellow} gule advarsler"
},
"automatic": {
"customer_rule_exempt_from_administration_fees": "{product} @:{'terms.glossary.er'} @:{'terms.glossary.et_2'} @:{'terms.glossary.administrationsgebyr'} @:{'terms.glossary.for'} @:{'terms.replication.host_definite_suffix'} fritaget @:{'terms.glossary.kunde'}.",
"customer_rule_invoice_all_orders_individually": "Fakturasamlingen @:{'terms.glossary.indeholder'} @:{'terms.glossary.flere'} @:{'terms.glossary.ordrer'} @:{'terms.glossary.for'} @:{'terms.replication.host_definite_suffix'} @:{'terms.glossary.kunde'}, @:{'terms.glossary.der'} @:{'terms.glossary.skal'} faktureres @:{'terms.glossary.pr'}. @:{'terms.glossary.ordre'}.",
@@ -99,6 +99,7 @@
"resend_booking_completion": "Gensend afslutning",
"resend_wash_certificate": "Gensend vaskecertifikat",
"retry": "Prøv igen",
"show_more": "Vis {count} mere",
"unlink_booking": "Fjern booking",
"unlink_xlvask": "Fjern Selvvask"
},
@@ -124,6 +125,7 @@
"errors": {
"action_failed": "Handlingen mislykkedes",
"download_failed": "Download mislykkedes",
"invalid_attachment": "Vedhæftningen kunne ikke indlæses.",
"load_failed": "Kunne ikke indlæse indholdet."
},
"economic": {
@@ -142,6 +144,7 @@
"price": "Pris",
"product_id": "Produkt ID",
"quantity": "Antal",
"registration_number": "Registreringsnummer",
"reference": "Reference",
"show_empty": "Vis tomme felter"
},
@@ -155,7 +158,7 @@
"economic_draft_with_id": "E-conomic kladde #{id}",
"fixed_pricing": "Fastpris",
"invoice_for_order": "Faktura for ordre #{id}",
"order": "Ordre #{id}",
"order": "Vask #{id}",
"order_item_fallback": "Linje #{id}",
"orders_without_collection": "Orders uden fakturasamling",
"payment_for_order": "Kortbetaling for ordre #{id}",
@@ -175,7 +178,7 @@
},
"subtitles": {
"collection": "Samling #{id}",
"order": "Ordre #{id}",
"order": "Vask #{id}",
"quantity": "Antal {count}",
"wash_id": "WashId {id}"
},
@@ -108,6 +108,8 @@
"restrictions": {
"addons_not_allowed": "Tilvalg er ikke tilladt for denne kunde",
"backend_rejected": "Produktet blev afvist af kundereglerne og er ikke tilføjet",
"load_failed": "Kundereglerne kunne ikke indlæses. Prøv igen, før du ændrer produkter.",
"loading": "Kundereglerne indlæses. Produktændringer er midlertidigt deaktiveret.",
"product_not_allowed": "Produktet er ikke tilladt for denne kunde",
"restricted_items_removed": "Begrænsede tilvalg er fjernet fra kurven",
"title": "Kunderegel"
@@ -50,12 +50,37 @@
"label": "@.capitalize:{'terms.glossary.use'} @:{'terms.glossary.po'} @:{'terms.glossary.numbers'}"
}
},
"configuration": {
"add_collection": "Sammlung hinzufügen",
"archived": "Archiviert",
"collection_name": "Name der Sammlung",
"default_collection_name": "Sammlung {count}",
"errors": {
"collection_names": "Sammlungen müssen innerhalb der Regel eindeutige Namen haben.",
"conflict": "Diese Regel wurde von jemand anderem geändert. Laden Sie die aktuelle Konfiguration neu.",
"load": "Produktbeschränkungen konnten nicht geladen werden.",
"save": "Die Produktbeschränkung konnte nicht gespeichert werden."
},
"global_warning": "Diese Sammlungen sind global. Eine Änderung betrifft alle Kunden mit der entsprechenden Regel.",
"move_down": "Nach unten verschieben",
"move_up": "Nach oben verschieben",
"nav": "Kundenregeln",
"no_products": "Keine passenden Produkte.",
"product_count": "{count} gesperrte Produkte",
"read_only": "Sie haben schreibgeschützten Zugriff auf diese Konfiguration.",
"reload_configuration": "Konfiguration neu laden",
"search_products": "Produkte suchen",
"subtitle": "Verwalten Sie die genauen Produkte, die von jeder Kundenregel gesperrt werden",
"title": "Produktbeschränkungen für Kundenregeln",
"version": "Version {version}"
},
"manager": {
"active_count": "{count} aktiv",
"errors": {
"load": "Kundenregeln konnten nicht geladen werden.",
"update": "Die Kundenregel konnte nicht aktualisiert werden."
},
"global_configuration": "Globale Produktbeschränkungen verwalten",
"no_target": "Eine Benutzer-ID oder Kundennummer ist erforderlich, bevor Kundenregeln verwaltet werden können.",
"toggle_unavailable": "Sie haben keine Berechtigung, diese Kundenregel zu ändern.",
"unavailable": "Sie haben keine Berechtigung, Kundenregeln anzuzeigen.",
@@ -2,6 +2,11 @@
"compat": {
"invoice_period": {
"flags": {
"badge": {
"automatic": "Automatische Warnungen",
"manual": "Manuelle Markierungen",
"summary": "{red} rote Markierungen und {yellow} gelbe Warnungen"
},
"automatic": {
"customer_rule_exempt_from_administration_fees": "{product} @:{'terms.glossary.is'} @:{'terms.glossary.an'} @:{'terms.glossary.administration'} @:{'terms.glossary.fee'} @:{'terms.glossary.for'} @:{'terms.glossary.an'} @:{'terms.glossary.exempt'} @:{'terms.glossary.customer'}.",
"customer_rule_invoice_all_orders_individually": "@.capitalize:{'terms.glossary.invoice'} @:{'terms.glossary.collection'} @:{'terms.glossary.contains'} @:{'terms.glossary.multiple'} @:{'terms.glossary.orders'} @:{'terms.glossary.for'} @:{'terms.glossary.a'} @:{'terms.glossary.customer'} requiring individual @:{'terms.glossary.invoices'}.",
@@ -99,6 +99,7 @@
"resend_booking_completion": "Abschluss erneut senden",
"resend_wash_certificate": "Waschzertifikat erneut senden",
"retry": "Erneut versuchen",
"show_more": "{count} weitere anzeigen",
"unlink_booking": "Buchung entfernen",
"unlink_xlvask": "Selbstwasch entfernen"
},
@@ -124,6 +125,7 @@
"errors": {
"action_failed": "Aktion fehlgeschlagen",
"download_failed": "Download fehlgeschlagen",
"invalid_attachment": "Der Anhang konnte nicht geladen werden.",
"load_failed": "Inhalt konnte nicht geladen werden."
},
"economic": {
@@ -142,6 +144,7 @@
"price": "Preis",
"product_id": "Produkt-ID",
"quantity": "Anzahl",
"registration_number": "Kennzeichen",
"reference": "Referenz",
"show_empty": "Leere Felder anzeigen"
},
@@ -108,6 +108,8 @@
"restrictions": {
"addons_not_allowed": "Zusatzoptionen sind für diesen Kunden nicht erlaubt",
"backend_rejected": "Das Produkt wurde durch Kundenregeln abgelehnt und nicht hinzugefügt",
"load_failed": "Kundenregeln konnten nicht geladen werden. Versuchen Sie es erneut, bevor Sie Produkte ändern.",
"loading": "Kundenregeln werden geladen. Produktänderungen sind vorübergehend deaktiviert.",
"product_not_allowed": "Dieses Produkt ist für diesen Kunden nicht erlaubt",
"restricted_items_removed": "Eingeschränkte Zusatzoptionen wurden aus dem Warenkorb entfernt",
"title": "Kundenregel"
@@ -50,12 +50,37 @@
"label": "@.capitalize:{'terms.glossary.use'} @:{'terms.glossary.po'} @:{'terms.glossary.numbers'}"
}
},
"configuration": {
"add_collection": "Add collection",
"archived": "Archived",
"collection_name": "Collection name",
"default_collection_name": "Collection {count}",
"errors": {
"collection_names": "Collection names must be present and unique within the rule.",
"conflict": "This rule was changed by someone else. Reload the current configuration before saving again.",
"load": "Could not load product restrictions.",
"save": "Could not save the product restriction."
},
"global_warning": "These collections are global. A change affects every customer that has the corresponding rule.",
"move_down": "Move down",
"move_up": "Move up",
"nav": "Customer rules",
"no_products": "No matching products.",
"product_count": "{count} disabled products",
"read_only": "You have read-only access to this configuration.",
"reload_configuration": "Reload configuration",
"search_products": "Search products",
"subtitle": "Manage the exact products disabled by each customer rule",
"title": "Customer rule product restrictions",
"version": "Version {version}"
},
"manager": {
"active_count": "{count} active",
"errors": {
"load": "Could not load customer rules.",
"update": "Could not update the customer rule."
},
"global_configuration": "Manage global product restrictions",
"no_target": "A user id or customer number is required before customer rules can be managed.",
"toggle_unavailable": "You do not have permission to change this customer rule.",
"unavailable": "You do not have permission to view customer rules.",
@@ -2,6 +2,11 @@
"compat": {
"invoice_period": {
"flags": {
"badge": {
"automatic": "Automatic warnings",
"manual": "Manual flags",
"summary": "{red} red flags and {yellow} yellow warnings"
},
"automatic": {
"customer_rule_exempt_from_administration_fees": "{product} @:{'terms.glossary.is'} @:{'terms.glossary.an'} @:{'terms.glossary.administration'} @:{'terms.glossary.fee'} @:{'terms.glossary.for'} @:{'terms.glossary.an'} @:{'terms.glossary.exempt'} @:{'terms.glossary.customer'}.",
"customer_rule_invoice_all_orders_individually": "@.capitalize:{'terms.glossary.invoice'} @:{'terms.glossary.collection'} @:{'terms.glossary.contains'} @:{'terms.glossary.multiple'} @:{'terms.glossary.orders'} @:{'terms.glossary.for'} @:{'terms.glossary.a'} @:{'terms.glossary.customer'} @:{'terms.glossary.requiring'} individual @:{'terms.glossary.invoices'}.",
@@ -99,6 +99,7 @@
"resend_booking_completion": "Resend completion",
"resend_wash_certificate": "Resend wash certificate",
"retry": "Retry",
"show_more": "Show {count} more",
"unlink_booking": "Remove booking",
"unlink_xlvask": "Remove self-wash"
},
@@ -124,6 +125,7 @@
"errors": {
"action_failed": "Action failed",
"download_failed": "Download failed",
"invalid_attachment": "The attachment could not be loaded.",
"load_failed": "Could not load the content."
},
"economic": {
@@ -142,6 +144,7 @@
"price": "Price",
"product_id": "Product ID",
"quantity": "Quantity",
"registration_number": "Registration number",
"reference": "Reference",
"show_empty": "Show empty fields"
},
@@ -155,7 +158,7 @@
"economic_draft_with_id": "E-conomic draft #{id}",
"fixed_pricing": "Fixed pricing",
"invoice_for_order": "Invoice for order #{id}",
"order": "Order #{id}",
"order": "Wash #{id}",
"order_item_fallback": "Line #{id}",
"orders_without_collection": "Orders without invoice collection",
"payment_for_order": "Card payment for order #{id}",
@@ -175,7 +178,7 @@
},
"subtitles": {
"collection": "Collection #{id}",
"order": "Order #{id}",
"order": "Wash #{id}",
"quantity": "Quantity {count}",
"wash_id": "WashId {id}"
},
@@ -108,6 +108,8 @@
"restrictions": {
"addons_not_allowed": "Add-ons are not allowed for this customer",
"backend_rejected": "The product was rejected by customer rules and was not added",
"load_failed": "Customer rules could not be loaded. Retry before changing products.",
"loading": "Customer rules are loading. Product changes are temporarily disabled.",
"product_not_allowed": "This product is not allowed for this customer",
"restricted_items_removed": "Restricted add-ons were removed from the cart",
"title": "Customer rule"
@@ -50,12 +50,37 @@
"label": "@:{'phrases.compat.customer_rules.attributes.usePONumbers.label'}"
}
},
"configuration": {
"add_collection": "@:{'phrases.compat.customer_rules.configuration.add_collection'}",
"archived": "@:{'phrases.compat.customer_rules.configuration.archived'}",
"collection_name": "@:{'phrases.compat.customer_rules.configuration.collection_name'}",
"default_collection_name": "@:{'phrases.compat.customer_rules.configuration.default_collection_name'}",
"errors": {
"collection_names": "@:{'phrases.compat.customer_rules.configuration.errors.collection_names'}",
"conflict": "@:{'phrases.compat.customer_rules.configuration.errors.conflict'}",
"load": "@:{'phrases.compat.customer_rules.configuration.errors.load'}",
"save": "@:{'phrases.compat.customer_rules.configuration.errors.save'}"
},
"global_warning": "@:{'phrases.compat.customer_rules.configuration.global_warning'}",
"move_down": "@:{'phrases.compat.customer_rules.configuration.move_down'}",
"move_up": "@:{'phrases.compat.customer_rules.configuration.move_up'}",
"nav": "@:{'phrases.compat.customer_rules.configuration.nav'}",
"no_products": "@:{'phrases.compat.customer_rules.configuration.no_products'}",
"product_count": "@:{'phrases.compat.customer_rules.configuration.product_count'}",
"read_only": "@:{'phrases.compat.customer_rules.configuration.read_only'}",
"reload_configuration": "@:{'phrases.compat.customer_rules.configuration.reload_configuration'}",
"search_products": "@:{'phrases.compat.customer_rules.configuration.search_products'}",
"subtitle": "@:{'phrases.compat.customer_rules.configuration.subtitle'}",
"title": "@:{'phrases.compat.customer_rules.configuration.title'}",
"version": "@:{'phrases.compat.customer_rules.configuration.version'}"
},
"manager": {
"active_count": "@:{'phrases.compat.customer_rules.manager.active_count'}",
"errors": {
"load": "@:{'phrases.compat.customer_rules.manager.errors.load'}",
"update": "@:{'phrases.compat.customer_rules.manager.errors.update'}"
},
"global_configuration": "@:{'phrases.compat.customer_rules.manager.global_configuration'}",
"no_target": "@:{'phrases.compat.customer_rules.manager.no_target'}",
"toggle_unavailable": "@:{'phrases.compat.customer_rules.manager.toggle_unavailable'}",
"unavailable": "@:{'phrases.compat.customer_rules.manager.unavailable'}",
@@ -22,6 +22,11 @@
"wash_certificate_item_without_certificate": "@:{'phrases.compat.invoice_period.flags.automatic.wash_certificate_item_without_certificate'}",
"xlvask_missing_order_link": "@:{'phrases.compat.invoice_period.flags.automatic.xlvask_missing_order_link'}"
},
"badge": {
"automatic": "@:{'phrases.compat.invoice_period.flags.badge.automatic'}",
"manual": "@:{'phrases.compat.invoice_period.flags.badge.manual'}",
"summary": "@:{'phrases.compat.invoice_period.flags.badge.summary'}"
},
"preview": {
"customer": "@:{'phrases.compat.common.customer'}",
"entities": {
@@ -163,6 +163,8 @@
"restrictions": {
"addons_not_allowed": "@:{'phrases.compat.pos.restrictions.addons_not_allowed'}",
"backend_rejected": "@:{'phrases.compat.pos.restrictions.backend_rejected'}",
"load_failed": "@:{'phrases.compat.pos.restrictions.load_failed'}",
"loading": "@:{'phrases.compat.pos.restrictions.loading'}",
"product_not_allowed": "@:{'phrases.compat.pos.restrictions.product_not_allowed'}",
"restricted_items_removed": "@:{'phrases.compat.pos.restrictions.restricted_items_removed'}",
"title": "@:{'phrases.compat.pos.restrictions.title'}"
@@ -50,12 +50,37 @@
"label": "@.capitalize:{'terms.glossary.use'} @:{'terms.glossary.po'} @:{'terms.glossary.numbers'}"
}
},
"configuration": {
"add_collection": "Legg til samling",
"archived": "Arkivert",
"collection_name": "Navn på samling",
"default_collection_name": "Samling {count}",
"errors": {
"collection_names": "Samlinger må ha unike navn innenfor regelen.",
"conflict": "Denne regelen ble endret av noen andre. Last inn gjeldende konfigurasjon på nytt.",
"load": "Produktbegrensningene kunne ikke lastes.",
"save": "Produktbegrensningen kunne ikke lagres."
},
"global_warning": "Disse samlingene er globale. En endring påvirker alle kunder med den tilsvarende regelen.",
"move_down": "Flytt ned",
"move_up": "Flytt opp",
"nav": "Kunderegler",
"no_products": "Ingen produkter samsvarer.",
"product_count": "{count} blokkerte produkter",
"read_only": "Du har skrivebeskyttet tilgang til denne konfigurasjonen.",
"reload_configuration": "Last inn konfigurasjonen på nytt",
"search_products": "Søk etter produkter",
"subtitle": "Administrer de nøyaktige produktene som blokkeres av hver kunderegel",
"title": "Produktbegrensninger for kunderegler",
"version": "Versjon {version}"
},
"manager": {
"active_count": "{count} aktive",
"errors": {
"load": "Kunderegler kunne ikke lastes.",
"update": "Kunderegelen kunne ikke oppdateres."
},
"global_configuration": "Administrer globale produktbegrensninger",
"no_target": "Bruker-ID eller kundenummer kreves før kunderegler kan administreres.",
"toggle_unavailable": "Du har ikke tillatelse til å endre denne kunderegelen.",
"unavailable": "Du har ikke tillatelse til å se kunderegler.",
@@ -2,6 +2,11 @@
"compat": {
"invoice_period": {
"flags": {
"badge": {
"automatic": "Automatiske advarsler",
"manual": "Manuelle flagg",
"summary": "{red} røde flagg og {yellow} gule advarsler"
},
"automatic": {
"customer_rule_exempt_from_administration_fees": "{product} @:{'terms.glossary.is'} @:{'terms.glossary.an'} @:{'terms.glossary.administration'} @:{'terms.glossary.fee'} @:{'terms.glossary.for'} @:{'terms.glossary.an'} @:{'terms.glossary.exempt'} @:{'terms.glossary.customer'}.",
"customer_rule_invoice_all_orders_individually": "@.capitalize:{'terms.glossary.invoice'} @:{'terms.glossary.collection'} @:{'terms.glossary.contains'} @:{'terms.glossary.multiple'} @:{'terms.glossary.orders'} @:{'terms.glossary.for'} @:{'terms.glossary.a_2'} @:{'terms.glossary.customer'} requiring individual @:{'terms.glossary.invoices'}.",
@@ -99,6 +99,7 @@
"resend_booking_completion": "Send fullføring på nytt",
"resend_wash_certificate": "Send vaskesertifikat på nytt",
"retry": "Prøv igjen",
"show_more": "Vis {count} til",
"unlink_booking": "Fjern booking",
"unlink_xlvask": "Fjern selvvask"
},
@@ -124,6 +125,7 @@
"errors": {
"action_failed": "Handlingen mislyktes",
"download_failed": "Nedlasting mislyktes",
"invalid_attachment": "Vedlegget kunne ikke lastes inn.",
"load_failed": "Kunne ikke laste inn innholdet."
},
"economic": {
@@ -142,6 +144,7 @@
"price": "Pris",
"product_id": "Produkt-ID",
"quantity": "Antall",
"registration_number": "Registreringsnummer",
"reference": "Referanse",
"show_empty": "Vis tomme felter"
},
@@ -108,6 +108,8 @@
"restrictions": {
"addons_not_allowed": "Tilvalg er ikke tillatt for denne kunden",
"backend_rejected": "Produktet ble avvist av kunderegler og ble ikke lagt til",
"load_failed": "Kundereglene kunne ikke lastes. Prøv igjen før du endrer produkter.",
"loading": "Kundereglene lastes. Produktendringer er midlertidig deaktivert.",
"product_not_allowed": "Dette produktet er ikke tillatt for denne kunden",
"restricted_items_removed": "Begrensede tilvalg ble fjernet fra kurven",
"title": "Kunderegel"
@@ -50,12 +50,37 @@
"label": "@.capitalize:{'terms.glossary.use'} @:{'terms.glossary.po'} @:{'terms.glossary.numbers'}"
}
},
"configuration": {
"add_collection": "Lägg till samling",
"archived": "Arkiverad",
"collection_name": "Samlingens namn",
"default_collection_name": "Samling {count}",
"errors": {
"collection_names": "Samlingar måste ha unika namn inom regeln.",
"conflict": "Regeln har ändrats av någon annan. Läs in den aktuella konfigurationen igen.",
"load": "Produktbegränsningarna kunde inte läsas in.",
"save": "Produktbegränsningen kunde inte sparas."
},
"global_warning": "Samlingarna är globala. En ändring påverkar alla kunder med motsvarande regel.",
"move_down": "Flytta ned",
"move_up": "Flytta upp",
"nav": "Kundregler",
"no_products": "Inga produkter matchar.",
"product_count": "{count} blockerade produkter",
"read_only": "Du har skrivskyddad åtkomst till den här konfigurationen.",
"reload_configuration": "Läs in konfigurationen igen",
"search_products": "Sök efter produkter",
"subtitle": "Hantera de exakta produkter som blockeras av varje kundregel",
"title": "Produktbegränsningar för kundregler",
"version": "Version {version}"
},
"manager": {
"active_count": "{count} aktiva",
"errors": {
"load": "Kundregler kunde inte läsas in.",
"update": "Kundregeln kunde inte uppdateras."
},
"global_configuration": "Hantera globala produktbegränsningar",
"no_target": "Användar-id eller kundnummer krävs innan kundregler kan hanteras.",
"toggle_unavailable": "Du har inte behörighet att ändra denna kundregel.",
"unavailable": "Du har inte behörighet att visa kundregler.",
@@ -2,6 +2,11 @@
"compat": {
"invoice_period": {
"flags": {
"badge": {
"automatic": "Automatiska varningar",
"manual": "Manuella flaggor",
"summary": "{red} röda flaggor och {yellow} gula varningar"
},
"automatic": {
"customer_rule_exempt_from_administration_fees": "{product} @:{'terms.glossary.is'} @:{'terms.glossary.an'} @:{'terms.glossary.administration'} @:{'terms.glossary.fee'} @:{'terms.glossary.for_2'} @:{'terms.glossary.an'} @:{'terms.glossary.exempt'} @:{'terms.glossary.customer'}.",
"customer_rule_invoice_all_orders_individually": "@.capitalize:{'terms.glossary.invoice'} @:{'terms.glossary.collection'} @:{'terms.glossary.contains'} @:{'terms.glossary.multiple'} @:{'terms.glossary.orders'} @:{'terms.glossary.for_2'} @:{'terms.glossary.a'} @:{'terms.glossary.customer'} requiring individual @:{'terms.glossary.invoices'}.",
@@ -99,6 +99,7 @@
"resend_booking_completion": "Skicka slutförande igen",
"resend_wash_certificate": "Skicka tvättcertifikat igen",
"retry": "Försök igen",
"show_more": "Visa {count} till",
"unlink_booking": "Ta bort bokning",
"unlink_xlvask": "Ta bort självtvätt"
},
@@ -124,6 +125,7 @@
"errors": {
"action_failed": "Åtgärden misslyckades",
"download_failed": "Nedladdningen misslyckades",
"invalid_attachment": "Bilagan kunde inte läsas in.",
"load_failed": "Kunde inte läsa in innehållet."
},
"economic": {
@@ -142,6 +144,7 @@
"price": "Pris",
"product_id": "Produkt-ID",
"quantity": "Antal",
"registration_number": "Registreringsnummer",
"reference": "Referens",
"show_empty": "Visa tomma fält"
},
@@ -108,6 +108,8 @@
"restrictions": {
"addons_not_allowed": "Tillval är inte tillåtna för den här kunden",
"backend_rejected": "Produkten avvisades av kundregler och lades inte till",
"load_failed": "Kundreglerna kunde inte läsas in. Försök igen innan du ändrar produkter.",
"loading": "Kundreglerna läses in. Produktändringar är tillfälligt inaktiverade.",
"product_not_allowed": "Den här produkten är inte tillåten för kunden",
"restricted_items_removed": "Begränsade tillval togs bort från varukorgen",
"title": "Kundregel"
+7
View File
@@ -49,6 +49,7 @@ const DepartmentRelays = lazyView('@/views/dashboards/superUserDashboard/Departm
const DepartmentLane = lazyView('@/views/dashboards/superUserDashboard/department/lanes/DepartmentLane.vue');
const Users = lazyView('@/views/dashboards/superUserDashboard/Users.vue');
const Products = lazyView('@/views/dashboards/superUserDashboard/Products.vue');
const CustomerRuleProductRestrictions = lazyView('@/views/dashboards/superUserDashboard/CustomerRuleProductRestrictions.vue');
const Orders = lazyView('@/views/dashboards/superUserDashboard/Orders.vue');
const OrdersDrafts = lazyView('@/views/dashboards/superUserDashboard/OrdersDrafts.vue');
const Bookings = lazyView('@/views/dashboards/superUserDashboard/Bookings.vue');
@@ -1045,6 +1046,12 @@ export const router = createRouter({
component: Products,
meta: { middleware: superUserMiddleware }
},
{
name: 'customerRuleProductRestrictions',
path: '/superuser/customer-rules',
component: CustomerRuleProductRestrictions,
meta: { middleware: superUserMiddleware }
},
{
name: 'productsproduct',
path: '/superuser/products/:productId',
@@ -0,0 +1,503 @@
<script setup>
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { getCustomerRuleDefinitions } from "@/features/customer/customerRuleRegistry.js";
import {
canManageCustomerRuleConfiguration,
canViewCustomerRuleConfiguration,
} from "@/features/customer/customerRuleConfigurationPermissions.js";
import {
extractCustomerRuleProductRestrictionPayload,
listCustomerRuleProductRestrictions,
normalizeCustomerRuleProductRestriction,
normalizeCustomerRuleProductRestrictionResponse,
serializeCustomerRuleProductRestriction,
updateCustomerRuleProductRestriction,
} from "@/features/customer/customerRuleProductRestrictionService.js";
import SuperUserDashboardNavigation from "@/views/dashboards/superUserDashboard/SuperUserDashboardNavigation.vue";
const { t } = useI18n({ useScope: "global" });
const isLoading = ref(false);
const loadError = ref("");
const drafts = ref([]);
const savedSnapshots = ref(new Map());
const products = ref([]);
const collectionSearch = ref({});
const savingAttributes = ref(new Set());
const saveErrors = ref({});
const conflictAttributes = ref(new Set());
let nextTemporaryCollectionId = -1;
const canView = computed(canViewCustomerRuleConfiguration);
const canManage = computed(canManageCustomerRuleConfiguration);
const productRuleDefinitions = computed(() => (
getCustomerRuleDefinitions().filter((definition) => definition.productImpact)
));
const normalizedSnapshot = (rule) => JSON.stringify(serializeCustomerRuleProductRestriction(rule));
const isSaving = (attribute) => savingAttributes.value.has(attribute);
const hasConflict = (attribute) => conflictAttributes.value.has(attribute);
const isDirty = (rule) => savedSnapshots.value.get(rule.attribute) !== normalizedSnapshot(rule);
const effectiveDisabledProductIds = (rule) => (
[...new Set(rule.collections.flatMap((collection) => collection.product_ids))]
);
const productIsArchived = (product) => (
product?.deleted_at != null || product?.active === false || product?.is_active === false
);
const productLabel = (product) => {
const category = String(product?.category_name ?? product?.category?.name ?? "").trim();
return category ? `${product.name} · ${category}` : product.name;
};
const normalizeLoadedRules = (response) => {
const normalized = normalizeCustomerRuleProductRestrictionResponse(response);
const byAttribute = new Map(normalized.rules.map((rule) => [rule.attribute, rule]));
const nextDrafts = productRuleDefinitions.value.map((definition) => (
byAttribute.get(definition.attribute)
?? normalizeCustomerRuleProductRestriction({ attribute: definition.attribute, version: 0, collections: [] })
));
drafts.value = nextDrafts;
products.value = normalized.products.sort((left, right) => left.name.localeCompare(right.name));
savedSnapshots.value = new Map(nextDrafts.map((rule) => [rule.attribute, normalizedSnapshot(rule)]));
};
const parseError = (error, fallbackKey) => (
error?.response?.data?.data?.message
|| error?.response?.data?.message
|| t(fallbackKey)
);
const load = async () => {
if (!canView.value) {
return;
}
isLoading.value = true;
loadError.value = "";
saveErrors.value = {};
try {
const response = await listCustomerRuleProductRestrictions();
normalizeLoadedRules(response);
conflictAttributes.value = new Set();
} catch (error) {
console.error("Failed to load customer rule product restrictions", error);
loadError.value = parseError(error, "customer_rules.configuration.errors.load");
} finally {
isLoading.value = false;
}
};
const collectionKey = (rule, collection) => `${rule.attribute}:${collection.id}`;
const searchFor = (rule, collection) => collectionSearch.value[collectionKey(rule, collection)] ?? "";
const setSearchFor = (rule, collection, value) => {
collectionSearch.value = {
...collectionSearch.value,
[collectionKey(rule, collection)]: value,
};
};
const visibleProducts = (rule, collection) => {
const search = searchFor(rule, collection).trim().toLocaleLowerCase();
if (!search) {
return products.value;
}
return products.value.filter((product) => (
productLabel(product).toLocaleLowerCase().includes(search)
|| String(product.id).includes(search)
));
};
const isProductSelected = (collection, productId) => collection.product_ids.includes(productId);
const toggleProduct = (collection, productId, selected) => {
if (!canManage.value) {
return;
}
const next = new Set(collection.product_ids);
if (selected) {
next.add(productId);
} else {
next.delete(productId);
}
collection.product_ids = [...next];
};
const addCollection = (rule) => {
if (!canManage.value) {
return;
}
rule.collections.push({
id: nextTemporaryCollectionId--,
name: t("customer_rules.configuration.default_collection_name", { count: rule.collections.length + 1 }),
sort_order: rule.collections.length,
product_ids: [],
});
};
const removeCollection = (rule, collection) => {
if (!canManage.value) {
return;
}
rule.collections = rule.collections.filter((candidate) => candidate !== collection);
};
const moveCollection = (rule, collection, offset) => {
if (!canManage.value) {
return;
}
const currentIndex = rule.collections.indexOf(collection);
const nextIndex = currentIndex + offset;
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= rule.collections.length) {
return;
}
const nextCollections = [...rule.collections];
nextCollections.splice(currentIndex, 1);
nextCollections.splice(nextIndex, 0, collection);
rule.collections = nextCollections;
};
const collectionNamesAreValid = (rule) => {
const names = rule.collections.map((collection) => collection.name.trim().toLocaleLowerCase());
return names.every(Boolean) && new Set(names).size === names.length;
};
const replaceSavedRule = (attribute, savedRule) => {
const index = drafts.value.findIndex((rule) => rule.attribute === attribute);
if (index === -1) {
return;
}
drafts.value.splice(index, 1, savedRule);
savedSnapshots.value = new Map(savedSnapshots.value).set(attribute, normalizedSnapshot(savedRule));
};
const save = async (rule) => {
if (!canManage.value || isSaving(rule.attribute) || !isDirty(rule)) {
return;
}
saveErrors.value = { ...saveErrors.value, [rule.attribute]: "" };
if (!collectionNamesAreValid(rule)) {
saveErrors.value = {
...saveErrors.value,
[rule.attribute]: t("customer_rules.configuration.errors.collection_names"),
};
return;
}
const nextSaving = new Set(savingAttributes.value);
nextSaving.add(rule.attribute);
savingAttributes.value = nextSaving;
try {
const response = await updateCustomerRuleProductRestriction(rule.attribute, rule);
const payload = extractCustomerRuleProductRestrictionPayload(response);
const savedRule = normalizeCustomerRuleProductRestriction(payload.rule ?? payload);
replaceSavedRule(rule.attribute, {
...savedRule,
attribute: savedRule.attribute || rule.attribute,
});
const nextConflicts = new Set(conflictAttributes.value);
nextConflicts.delete(rule.attribute);
conflictAttributes.value = nextConflicts;
} catch (error) {
if (error?.response?.status === 409) {
const nextConflicts = new Set(conflictAttributes.value);
nextConflicts.add(rule.attribute);
conflictAttributes.value = nextConflicts;
saveErrors.value = {
...saveErrors.value,
[rule.attribute]: t("customer_rules.configuration.errors.conflict"),
};
} else {
saveErrors.value = {
...saveErrors.value,
[rule.attribute]: parseError(error, "customer_rules.configuration.errors.save"),
};
}
} finally {
const finishedSaving = new Set(savingAttributes.value);
finishedSaving.delete(rule.attribute);
savingAttributes.value = finishedSaving;
}
};
const cancel = (rule) => {
const snapshot = savedSnapshots.value.get(rule.attribute);
if (!snapshot) {
return;
}
const saved = JSON.parse(snapshot);
replaceSavedRule(rule.attribute, normalizeCustomerRuleProductRestriction({
attribute: rule.attribute,
...saved,
}));
saveErrors.value = { ...saveErrors.value, [rule.attribute]: "" };
};
watch(
canView,
(allowed) => {
if (allowed && drafts.value.length === 0 && !isLoading.value) {
void load();
}
},
{ immediate: true }
);
</script>
<template>
<RestrictedPageWrapper :has-permission="canView">
<SuperUserDashboardNavigation />
<PageTitle
:title="t('customer_rules.configuration.title')"
:subtitle="t('customer_rules.configuration.subtitle')"
/>
<section class="customer-rule-configuration" data-testid="superuser-customer-rules-page">
<div class="notification is-warning is-light" data-testid="customer-rules-global-warning">
{{ t("customer_rules.configuration.global_warning") }}
</div>
<div v-if="!canManage" class="notification is-info is-light" data-testid="customer-rules-read-only">
{{ t("customer_rules.configuration.read_only") }}
</div>
<div v-if="loadError" class="notification is-danger is-light" data-testid="customer-rules-load-error">
{{ loadError }}
<button class="button is-small is-danger is-light ml-2" type="button" @click="load">
{{ t("common.retry") }}
</button>
</div>
<progress v-if="isLoading" class="progress is-small is-dark" max="100"></progress>
<article
v-for="rule in drafts"
:key="rule.attribute"
class="box customer-rule-configuration__rule"
:data-testid="`customer-rule-config-${rule.attribute}`"
>
<div class="customer-rule-configuration__rule-header">
<div>
<h2 class="title is-5 mb-1">{{ t(`customer_rules.attributes.${rule.attribute}.label`) }}</h2>
<p class="is-size-7 has-text-grey">{{ t(`customer_rules.attributes.${rule.attribute}.description`) }}</p>
</div>
<div class="tags">
<span class="tag is-light">{{ t("customer_rules.configuration.version", { version: rule.version }) }}</span>
<span class="tag is-info is-light">
{{ t("customer_rules.configuration.product_count", { count: effectiveDisabledProductIds(rule).length }) }}
</span>
</div>
</div>
<div
v-if="hasConflict(rule.attribute)"
class="notification is-warning is-light"
:data-testid="`customer-rule-conflict-${rule.attribute}`"
>
{{ t("customer_rules.configuration.errors.conflict") }}
<button class="button is-small is-warning is-light ml-2" type="button" @click="load">
{{ t("customer_rules.configuration.reload_configuration") }}
</button>
</div>
<div class="customer-rule-configuration__collections">
<section
v-for="(collection, collectionIndex) in rule.collections"
:key="collection.id"
class="customer-rule-configuration__collection"
:data-testid="`customer-rule-collection-${rule.attribute}-${collection.id}`"
>
<div class="field is-grouped">
<div class="control is-expanded">
<input
v-model="collection.name"
class="input"
type="text"
:disabled="!canManage || isSaving(rule.attribute)"
:aria-label="t('customer_rules.configuration.collection_name')"
:data-testid="`customer-rule-collection-name-${rule.attribute}-${collection.id}`"
/>
</div>
<div class="control">
<button
class="button is-light"
type="button"
:disabled="!canManage || isSaving(rule.attribute) || collectionIndex === 0"
:aria-label="t('customer_rules.configuration.move_up')"
:title="t('customer_rules.configuration.move_up')"
:data-testid="`customer-rule-collection-move-up-${rule.attribute}-${collection.id}`"
@click="moveCollection(rule, collection, -1)"
>
<span class="icon"><i class="fas fa-arrow-up" /></span>
</button>
</div>
<div class="control">
<button
class="button is-light"
type="button"
:disabled="!canManage || isSaving(rule.attribute) || collectionIndex === rule.collections.length - 1"
:aria-label="t('customer_rules.configuration.move_down')"
:title="t('customer_rules.configuration.move_down')"
:data-testid="`customer-rule-collection-move-down-${rule.attribute}-${collection.id}`"
@click="moveCollection(rule, collection, 1)"
>
<span class="icon"><i class="fas fa-arrow-down" /></span>
</button>
</div>
<div class="control">
<button
class="button is-danger is-light"
type="button"
:disabled="!canManage || isSaving(rule.attribute)"
:data-testid="`customer-rule-collection-delete-${rule.attribute}-${collection.id}`"
@click="removeCollection(rule, collection)"
>
{{ t("common.delete") }}
</button>
</div>
</div>
<div class="field">
<div class="control has-icons-left">
<input
class="input is-small"
type="search"
:value="searchFor(rule, collection)"
:placeholder="t('customer_rules.configuration.search_products')"
:data-testid="`customer-rule-product-search-${rule.attribute}-${collection.id}`"
@input="setSearchFor(rule, collection, $event.target.value)"
/>
<span class="icon is-small is-left"><i class="fas fa-search" /></span>
</div>
</div>
<div class="customer-rule-configuration__products">
<label
v-for="product in visibleProducts(rule, collection)"
:key="product.id"
class="checkbox customer-rule-configuration__product"
:class="{ 'has-text-grey': productIsArchived(product) }"
>
<input
type="checkbox"
:checked="isProductSelected(collection, product.id)"
:disabled="!canManage || isSaving(rule.attribute)"
:data-testid="`customer-rule-product-${rule.attribute}-${collection.id}-${product.id}`"
@change="toggleProduct(collection, product.id, $event.target.checked)"
/>
<span>{{ productLabel(product) }}</span>
<span v-if="productIsArchived(product)" class="tag is-light is-small">
{{ t("customer_rules.configuration.archived") }}
</span>
</label>
<p v-if="visibleProducts(rule, collection).length === 0" class="has-text-grey is-size-7">
{{ t("customer_rules.configuration.no_products") }}
</p>
</div>
</section>
<button
v-if="canManage"
class="button is-light is-fullwidth"
type="button"
:disabled="isSaving(rule.attribute)"
:data-testid="`customer-rule-add-collection-${rule.attribute}`"
@click="addCollection(rule)"
>
<span class="icon"><i class="fas fa-plus" /></span>
<span>{{ t("customer_rules.configuration.add_collection") }}</span>
</button>
</div>
<div
v-if="saveErrors[rule.attribute]"
class="notification is-danger is-light mt-4"
:data-testid="`customer-rule-save-error-${rule.attribute}`"
>
{{ saveErrors[rule.attribute] }}
</div>
<div v-if="canManage" class="buttons is-right mt-4">
<button
class="button is-light"
type="button"
:disabled="!isDirty(rule) || isSaving(rule.attribute)"
:data-testid="`customer-rule-cancel-${rule.attribute}`"
@click="cancel(rule)"
>
{{ t("common.cancel") }}
</button>
<button
class="button is-primary"
type="button"
:class="{ 'is-loading': isSaving(rule.attribute) }"
:disabled="!isDirty(rule) || isSaving(rule.attribute) || hasConflict(rule.attribute)"
:data-testid="`customer-rule-save-${rule.attribute}`"
@click="save(rule)"
>
{{ t("common.save") }}
</button>
</div>
</article>
</section>
</RestrictedPageWrapper>
</template>
<style scoped>
.customer-rule-configuration {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0 1rem 2rem;
}
.customer-rule-configuration__rule-header {
align-items: flex-start;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.customer-rule-configuration__collections {
display: grid;
gap: 1rem;
margin-top: 1rem;
}
.customer-rule-configuration__collection {
border: 1px solid #dbe3ec;
border-radius: 8px;
padding: 1rem;
}
.customer-rule-configuration__products {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
max-height: 20rem;
overflow: auto;
padding: 0.25rem;
}
.customer-rule-configuration__product {
align-items: center;
display: flex;
gap: 0.5rem;
}
@media screen and (max-width: 640px) {
.customer-rule-configuration__rule-header {
flex-direction: column;
}
}
</style>
@@ -0,0 +1,186 @@
<script setup lang="ts">
import { BDropdown, BTag, BTooltip } from "buefy";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import InvoicingPeriodFlagList from "./InvoicingPeriodFlagList.vue";
const props = withDefaults(defineProps<{
flags?: any[];
}>(), {
flags: () => [],
});
const emit = defineEmits<{
(event: "statusChanged", flag: any): void;
}>();
const { t } = useI18n();
const dismissedKeys = ref(new Set<string>());
const dropdown = ref<any>(null);
const flagKey = (flag: any) => String(flag?.id || flag?.fingerprint || "");
const isManual = (flag: any) => String(flag?.source || "automatic") === "manual";
const createdTimestamp = (flag: any) => new Date(String(flag?.created_at || 0).replace(" ", "T")).getTime() || 0;
const activeFlags = computed(() => props.flags
.filter((flag) => String(flag?.status || "active") === "active" && !dismissedKeys.value.has(flagKey(flag)))
.sort((left, right) => Number(isManual(right)) - Number(isManual(left)) || createdTimestamp(right) - createdTimestamp(left)));
const manualFlags = computed(() => activeFlags.value.filter(isManual));
const automaticFlags = computed(() => activeFlags.value.filter((flag) => !isManual(flag)));
const tooltipLabel = computed(() => t("invoice_period.flags.badge.summary", {
red: manualFlags.value.length,
yellow: automaticFlags.value.length,
}));
const onStatusChanged = (flag: any) => {
dismissedKeys.value = new Set([...dismissedKeys.value, flagKey(flag)]);
emit("statusChanged", flag);
};
const toggleDropdown = () => {
dropdown.value?.toggle();
};
</script>
<template>
<span
v-if="activeFlags.length > 0"
class="invoice-period-flag-badge"
@click.stop
@mousedown.stop
>
<BDropdown
ref="dropdown"
class="invoice-period-flag-badge__dropdown"
position="is-bottom-left"
aria-role="menu"
append-to-body
:triggers="[]"
:close-on-click="false"
:trigger-tabindex="-1"
data-testid="invoice-period-flag-badge"
>
<template #trigger="{ active }">
<BTooltip
:label="tooltipLabel"
position="is-top"
type="is-dark"
multilined
>
<button
type="button"
class="invoice-period-flag-badge__trigger"
:aria-label="tooltipLabel"
:aria-expanded="active"
@click.stop="toggleDropdown"
>
<BTag
v-if="manualFlags.length > 0"
type="is-danger"
size="is-small"
class="invoice-period-flag-badge__tag invoice-period-flag-badge__tag--manual"
>
<span class="icon is-small" aria-hidden="true"><i class="fas fa-flag"></i></span>
<span>{{ manualFlags.length }}</span>
</BTag>
<BTag
v-if="automaticFlags.length > 0"
type="is-warning"
size="is-small"
class="invoice-period-flag-badge__tag invoice-period-flag-badge__tag--automatic"
>
<span class="icon is-small" aria-hidden="true"><i class="fas fa-flag"></i></span>
<span>{{ automaticFlags.length }}</span>
</BTag>
</button>
</BTooltip>
</template>
<div class="invoice-period-flag-badge__menu" role="menu">
<section v-if="manualFlags.length > 0" class="invoice-period-flag-badge__section">
<h4 class="invoice-period-flag-badge__heading has-text-danger">
{{ t("invoice_period.flags.badge.manual") }}
</h4>
<InvoicingPeriodFlagList compact :flags="manualFlags" @status-changed="onStatusChanged" />
</section>
<section v-if="automaticFlags.length > 0" class="invoice-period-flag-badge__section">
<h4 class="invoice-period-flag-badge__heading has-text-warning-dark">
{{ t("invoice_period.flags.badge.automatic") }}
</h4>
<InvoicingPeriodFlagList compact :flags="automaticFlags" @status-changed="onStatusChanged" />
</section>
</div>
</BDropdown>
</span>
</template>
<style scoped>
.invoice-period-flag-badge {
display: inline-flex;
flex: 0 0 auto;
line-height: 1;
}
.invoice-period-flag-badge__trigger {
align-items: center;
background: transparent;
border: 0;
cursor: pointer;
display: inline-flex;
min-height: 1.5rem;
padding: 0.1rem 0.15rem;
transition: filter 120ms ease;
}
.invoice-period-flag-badge__trigger:hover,
.invoice-period-flag-badge__trigger:focus-visible {
filter: drop-shadow(0 2px 3px rgba(31, 41, 55, 0.2));
outline: 2px solid transparent;
}
.invoice-period-flag-badge :deep(.tooltip-content) {
pointer-events: none;
}
.invoice-period-flag-badge__tag {
align-items: center;
display: inline-flex;
gap: 0.08rem;
min-width: 2rem;
padding-inline: 0.3rem;
position: relative;
}
.invoice-period-flag-badge__tag--manual {
z-index: 2;
}
.invoice-period-flag-badge__tag--automatic {
z-index: 1;
}
.invoice-period-flag-badge__tag--manual + .invoice-period-flag-badge__tag--automatic {
margin-left: -0.28rem;
transform: translateY(0.18rem);
}
.invoice-period-flag-badge__menu {
background: #fff;
max-height: min(32rem, 70vh);
min-width: min(34rem, calc(100vw - 2rem));
overflow: auto;
padding: 0.45rem;
}
.invoice-period-flag-badge__section + .invoice-period-flag-badge__section {
border-top: 1px solid #e8ebf0;
margin-top: 0.45rem;
padding-top: 0.45rem;
}
.invoice-period-flag-badge__heading {
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0;
margin: 0 0 0.2rem 0.35rem;
text-transform: uppercase;
}
</style>
@@ -5,9 +5,9 @@ import { useI18n } from "vue-i18n";
import BuefyTree from "@/components/buefy/tree/BuefyTree.vue";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
import InvoicingPeriodFlagBadge from "./InvoicingPeriodFlagBadge.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
createEmbeddablePreviewUrl,
getAttachmentPreviewKind,
releaseObjectUrl,
} from "@/services/attachmentPreview.js";
@@ -17,6 +17,7 @@ import {
TREE_CATEGORY_TYPES,
TREE_NODE_TYPES,
buildCollectionRootNodes,
buildOrderItemTree,
classifyAttachment,
hasWashCertificateOrderItem,
makeAttachmentNode,
@@ -25,7 +26,6 @@ import {
makeCategoryNode,
makeEconomicInvoiceNode,
makeNodeId,
makeOrderItemNode,
makeOrderNode,
makeXlvaskInferredItemNode,
makeXlvaskNode,
@@ -103,7 +103,7 @@ const resetPreviewState = () => {
const nodeLabel = {
collection: (id: any) => treeText("nodes.collection", `Fakturasamling #${id}`, { id }),
ordersWithoutCollection: () => treeText("nodes.orders_without_collection", "Orders uden fakturasamling"),
order: (id: any) => treeText("nodes.order", `Ordre #${id}`, { id }),
order: (id: any) => treeText("nodes.order", `Vask #${id}`, { id }),
orderItemFallback: (id: any) => treeText("nodes.order_item_fallback", `Linje #${id}`, { id }),
attachmentFallback: (id: any) => treeText("nodes.attachment_fallback", `Vedhæftning #${id}`, { id }),
booking: (id: any) => treeText("nodes.booking", `Booking #${id}`, { id }),
@@ -320,7 +320,7 @@ const buildCollectionCategoryNodes = (collectionNode: TreeNode) => {
count: orders.length,
icon: "fa-receipt",
checkable: true,
meta: { orders },
meta: { orders, invoiceState: getNodeInvoiceState(collectionNode) },
}));
}
@@ -365,6 +365,7 @@ const buildCollectionCategoryNodes = (collectionNode: TreeNode) => {
collectionId: collectionNode.meta.collectionId,
draft: props.customer?.draft,
orders,
invoiceState: getNodeInvoiceState(collectionNode),
},
}));
}
@@ -518,7 +519,10 @@ const buildEconomicInvoiceNodes = async (node: TreeNode) => {
const details = await getEconomicDetails(collectionId);
const draftId = toPositiveInteger(details?.economic?.draft_id);
const bookedId = toPositiveInteger(details?.economic?.booked_id);
if (draftId && details?.draft?.exists === true) {
const economicState = String(details?.economic?.state || details?.economic_state || "").toLowerCase();
const availablePdfType = String(details?.economic?.available_pdf_type || details?.available_pdf_type || "").toLowerCase();
const isBookedState = economicState === "booked" || availablePdfType === "booked";
if (!isBookedState && draftId && details?.draft?.exists === true) {
nodes.push(makeEconomicInvoiceNode({
collectionId,
invoiceType: "draft",
@@ -527,7 +531,7 @@ const buildEconomicInvoiceNodes = async (node: TreeNode) => {
label: treeText("nodes.economic_draft_with_id", `E-conomic kladde #${draftId}`, { id: draftId }),
}));
}
if (bookedId && details?.booked?.exists === true) {
if (bookedId) {
nodes.push(makeEconomicInvoiceNode({
collectionId,
invoiceType: "booked",
@@ -646,7 +650,7 @@ const loadOrderCategoryNodes = async (orderNode: TreeNode) => {
parentId: orderNode.id,
icon: "fa-list-check",
checkable: true,
meta: { orderId, items: orderItems },
meta: { orderId, items: orderItems, invoiceState: getNodeInvoiceState(orderNode) },
}),
];
@@ -703,9 +707,11 @@ const nodeOrderId = (node: TreeNode) => toPositiveInteger(node?.meta?.orderId ??
const loadOrderItems = async (orderId: number) => {
const rows = await getCachedOrderItemRows(orderId);
return rows.map((item: any) => makeOrderItemNode(item, {
fallbackLabel: nodeLabel.orderItemFallback(item?.id ?? item?.order_item_id),
}));
const orderNode = nodeById.value[makeNodeId(TREE_NODE_TYPES.ORDER, orderId)];
return buildOrderItemTree(rows, {
fallbackLabel: (item: any) => nodeLabel.orderItemFallback(item?.id ?? item?.order_item_id),
invoiceState: getNodeInvoiceState(orderNode),
});
};
const makeAttachmentCategory = (orderId: number, category: string, label: string, attachments: any[]) => makeCategoryNode({
@@ -803,7 +809,99 @@ const buildXlvaskItemNodes = (node: TreeNode) => {
];
};
const getNodeIcon = (node: TreeNode) => node.icon || "fa-file";
type InvoiceState = "open" | "closed" | "economic_draft" | "economic_booked";
const normalizeInvoiceState = (value: any): InvoiceState | null => {
const normalized = String(value || "").trim().toLowerCase().replace(/-/g, "_");
if (["economic_booked", "booked"].includes(normalized)) {
return "economic_booked";
}
if (["economic_draft", "draft"].includes(normalized)) {
return "economic_draft";
}
if (["closed", "completed"].includes(normalized)) {
return "closed";
}
if (normalized === "open") {
return "open";
}
return null;
};
const stateWeight: Record<InvoiceState, number> = {
open: 0,
closed: 1,
economic_draft: 2,
economic_booked: 3,
};
const highestInvoiceState = (states: Array<InvoiceState | null | undefined>): InvoiceState => (
states.filter(Boolean).sort((left, right) => stateWeight[right as InvoiceState] - stateWeight[left as InvoiceState])[0] as InvoiceState
|| "open"
);
const orderInvoiceState = (order: any): InvoiceState => {
const explicitState = normalizeInvoiceState(order?.invoice_state);
if (explicitState) {
return explicitState;
}
const collection = order?.invoice_collection || {};
if (order?.booked || order?.booked_invoice_id || collection?.booked_invoice_id || collection?.booked_id) {
return "economic_booked";
}
if (order?.draft_invoice_id || collection?.draft_invoice_id || collection?.draft_id) {
return "economic_draft";
}
if (order?.closed_at || order?.completed_at || collection?.closed_at) {
return "closed";
}
return "open";
};
const getNodeInvoiceState = (node: TreeNode | null | undefined): InvoiceState => {
if (!node) {
return "open";
}
if (node.type === TREE_NODE_TYPES.ECONOMIC_INVOICE) {
return node.meta?.economicType === "draft" ? "economic_draft" : "economic_booked";
}
const explicitState = normalizeInvoiceState(node.meta?.invoiceState || node.meta?.collectionSummary?.state);
if (explicitState) {
return explicitState;
}
if (node.type === TREE_NODE_TYPES.ORDER) {
return orderInvoiceState(node.meta?.order);
}
const orders = node.type === TREE_NODE_TYPES.COLLECTION || node.type === TREE_NODE_TYPES.CATEGORY
? (node.meta?.orders || [])
: [];
if (orders.length > 0) {
return highestInvoiceState(orders.map(orderInvoiceState));
}
return "open";
};
const getNodeIcon = (node: TreeNode) => {
if (node.type === TREE_NODE_TYPES.COLLECTION) {
return "fa-file-invoice-dollar";
}
if (node.type === TREE_NODE_TYPES.ORDER) {
return "fa-soap";
}
if (node.type === TREE_NODE_TYPES.ORDER_ITEM) {
return "fa-list-check";
}
if (node.type === TREE_NODE_TYPES.ECONOMIC_INVOICE) {
return "fa-file-invoice";
}
if (node.type === TREE_NODE_TYPES.CATEGORY && node.category === TREE_CATEGORY_TYPES.COLLECTION_ORDERS) {
return "fa-receipt";
}
if (node.type === TREE_NODE_TYPES.CATEGORY && node.category === TREE_CATEGORY_TYPES.COLLECTION_ECONOMIC) {
return "fa-file-invoice";
}
return node.icon || "fa-file";
};
const getNodeIconColorClass = (node: TreeNode) => ({
open: "has-text-grey",
closed: "has-text-info",
economic_draft: "has-text-warning-dark",
economic_booked: "has-text-success",
}[getNodeInvoiceState(node)]);
const getNodeSubtitle = (node: TreeNode) => {
if (node.type === TREE_NODE_TYPES.COLLECTION) {
return `${node.meta.orderCount} orders · ${formatCurrency(node.meta.totalNetAmount)}`;
@@ -860,15 +958,66 @@ const nodeFlagTarget = (node: TreeNode) => {
return null;
};
const getNodeFlagCount = (node: TreeNode) => {
const flagMatchesTarget = (flag: any, type: string, id: number | null) => Boolean(id) && (
normalizeTargetType(flag) === type
&& normalizeTargetId(flag) === id
);
const normalizeFlagField = (flag: any) => String(flag?.field || flag?.target_field || "").trim().toLowerCase();
const getEntityNodeFlags = (node: TreeNode) => {
const target = nodeFlagTarget(node);
if (!target?.id) {
return 0;
return [];
}
return activeFlags.value.filter((flag: any) => flagMatchesTarget(flag, target.type, target.id));
};
const fieldFlagTarget = (node: TreeNode) => {
if (node.type === TREE_NODE_TYPES.ORDER) {
return { type: "order_field", id: toPositiveInteger(node.meta?.orderId) };
}
if (node.type === TREE_NODE_TYPES.ORDER_ITEM) {
return { type: "order_item_field", id: toPositiveInteger(node.meta?.itemId) };
}
return null;
};
const getFieldFlags = (node: TreeNode, fieldKey: string) => {
const target = fieldFlagTarget(node);
if (!target?.id) {
return [];
}
return activeFlags.value.filter((flag: any) => (
normalizeTargetType(flag) === target.type
&& normalizeTargetId(flag) === target.id
)).length;
flagMatchesTarget(flag, target.type, target.id)
&& normalizeFlagField(flag) === fieldKey
));
};
const getNodeFlags = (node: TreeNode) => {
const entityFlags = getEntityNodeFlags(node);
if (node.type === TREE_NODE_TYPES.COLLECTION) {
const collectionId = toPositiveInteger(node.meta?.collectionId);
const visibleOrderIds = new Set((node.meta?.orders || []).map((order: any) => toPositiveInteger(order?.id)).filter(Boolean));
const unmatchedCollectionFlags = activeFlags.value.filter((flag: any) => {
const flagCollectionId = toPositiveInteger(
flag?.invoice_collection_id
?? flagContext(flag)?.invoice_collection_id
?? flagContext(flag)?.invoiceCollectionId
);
const orderId = flagOrderId(flag);
return flagCollectionId === collectionId && Boolean(orderId) && !visibleOrderIds.has(orderId);
});
return [...new Map([...entityFlags, ...unmatchedCollectionFlags].map((flag: any) => [flag?.id || flag?.fingerprint, flag])).values()];
}
const target = fieldFlagTarget(node);
if (!target?.id) {
return entityFlags;
}
const renderedFieldKeys = new Set(visibleNodeFields(node).map((field) => field.key));
const fallbackFieldFlags = activeFlags.value.filter((flag: any) => (
flagMatchesTarget(flag, target.type, target.id)
&& !renderedFieldKeys.has(normalizeFlagField(flag))
));
return [...entityFlags, ...fallbackFieldFlags];
};
const handleFlagStatusChanged = () => {
emit("refresh");
};
const setNodePreviewLoading = (nodeId: string, isLoading: boolean) => {
@@ -907,7 +1056,6 @@ const ensureAttachmentPreviewSource = async (node: TreeNode) => {
const previewKind = getAttachmentPreviewKind(attachment);
const source = {
kind: previewKind,
downloadUrl: "",
url: "",
isObjectUrl: false,
};
@@ -915,20 +1063,29 @@ const ensureAttachmentPreviewSource = async (node: TreeNode) => {
setNodePreviewLoading(node.id, true);
setNodePreviewError(node.id, null);
try {
source.downloadUrl = await SessionUser.objects.orders.functions.downloadAttachment(
const result = await SessionUser.objects.orders.functions.fetchAttachmentContent(
node.meta.orderId,
node.meta.attachmentId,
false
"inline"
);
if (["image", "pdf", "office"].includes(previewKind)) {
const previewSource = await createEmbeddablePreviewUrl(source.downloadUrl, previewKind);
source.url = previewSource.url;
source.isObjectUrl = previewSource.isObjectUrl;
const blob = result instanceof Blob ? result : result?.data;
if (!(blob instanceof Blob)) {
throw new Error(treeText("errors.invalid_attachment", "Vedhæftningen kunne ikke indlæses."));
}
const blobType = String(blob.type || "").toLowerCase();
if (blobType.startsWith("image/")) {
source.kind = "image";
} else if (blobType === "application/pdf") {
source.kind = "pdf";
} else if (previewKind === "office") {
source.kind = "download";
}
if (["image", "pdf"].includes(source.kind)) {
source.url = URL.createObjectURL(blob);
source.isObjectUrl = true;
if (source.isObjectUrl && source.url) {
generatedPreviewObjectUrls.add(source.url);
}
} else {
source.url = source.downloadUrl;
}
attachmentPreviewByNodeId.value = {
...attachmentPreviewByNodeId.value,
@@ -1019,11 +1176,29 @@ const economicPreviewRows = (node: TreeNode) => {
};
const economicPreviewWarnings = (node: TreeNode) => {
const warnings = economicDetailsForNode(node)?.warnings;
return Array.isArray(warnings) ? warnings.slice(0, 4) : [];
const filteredWarnings = Array.isArray(warnings) ? warnings : [];
return (node.meta?.economicType === "booked"
? filteredWarnings.filter((warning: any) => !/draft|kladde/i.test(String(warning)))
: filteredWarnings).slice(0, 4);
};
const downloadAttachmentNode = async (node: TreeNode) => {
await SessionUser.objects.orders.functions.downloadAttachment(node.meta.orderId, node.meta.attachmentId, true);
const result = await SessionUser.objects.orders.functions.fetchAttachmentContent(
node.meta.orderId,
node.meta.attachmentId,
"attachment"
);
const blob = result instanceof Blob ? result : result?.data;
if (!(blob instanceof Blob)) {
throw new Error(treeText("errors.invalid_attachment", "Vedhæftningen kunne ikke indlæses."));
}
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = String(node.label || `attachment-${node.meta.attachmentId}`);
anchor.rel = "noopener";
anchor.click();
window.setTimeout(() => releaseObjectUrl(url), 0);
};
const downloadEconomicInvoiceNode = async (node: TreeNode) => {
@@ -1072,7 +1247,9 @@ const refreshAfterInlineEdit = async () => {
emit("refresh");
};
const collectionObjectForNode = (node: TreeNode) => {
const invoiceCollection = (node.meta?.orders || []).find((order: any) => order?.invoice_collection)?.invoice_collection || {};
const invoiceCollection = node.meta?.collectionSummary
|| (node.meta?.orders || []).find((order: any) => order?.invoice_collection)?.invoice_collection
|| {};
return {
id: node.meta.collectionId,
customer_number: node.meta.customerNumber,
@@ -1148,6 +1325,10 @@ type NodeField = {
editable?: boolean;
editFunction?: any;
formatter?: ((value: any) => string) | null;
slot?: number;
span?: number;
numeric?: boolean;
lines?: NodeField[];
};
const makeNodeField = ({
key,
@@ -1158,6 +1339,10 @@ const makeNodeField = ({
editable = false,
editFunction = null,
formatter = null,
slot = 1,
span = 1,
numeric = false,
lines = undefined,
}: Partial<NodeField> & { key: string; label: string; value: any }): NodeField => ({
key,
label,
@@ -1168,49 +1353,74 @@ const makeNodeField = ({
editable,
editFunction,
formatter,
slot,
span,
numeric,
lines,
});
const registrationField = (object: any, columns: Record<string, any>): NodeField => {
const registrationColumns = ["reg_1", "reg_2"];
if (!valueIsEmpty(object.reg_3)) {
registrationColumns.push("reg_3");
}
const lines = registrationColumns.map((column) => makeNodeField({
key: column,
label: fieldLabel(columns, column, column.replace("reg_", "Reg. ")),
value: object[column],
object,
column,
editable: canEditOrderField(object, column),
editFunction: SessionUser.objects.orders.showEditObjectFieldForm,
}));
return makeNodeField({
key: "registrations",
label: treeText("fields.registration_number", "Registreringsnummer"),
value: lines.map((line) => line.value).join("\n"),
slot: 4,
span: 2,
lines,
});
};
const getNodeFields = (node: TreeNode): NodeField[] => {
if (node.type === TREE_NODE_TYPES.COLLECTION) {
const object = collectionObjectForNode(node);
const columns = SessionUser.objects.collectedOrderInvoices.columns;
return [
makeNodeField({ key: "notes", label: fieldLabel(columns, "notes", "Noter"), value: object.notes, object, column: "notes", editable: canEditCollectionField(), editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm }),
makeNodeField({ key: "po_number", label: fieldLabel(columns, "po_number", "PO"), value: object.po_number, object, column: "po_number", editable: canEditCollectionField(), editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm }),
makeNodeField({ key: "external_id", label: fieldLabel(columns, "external_id", "Eksternt ID"), value: object.external_id, object, column: "external_id", editable: false }),
makeNodeField({ key: "closed_at", label: fieldLabel(columns, "closed_at", "Lukket"), value: object.closed_at, object, column: "closed_at", editable: canEditCollectionField(), editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm }),
makeNodeField({ key: "total_net_amount", label: fieldLabel(columns, "total_net_amount", "Total"), value: object.total_net_amount, formatter: formatCurrency }),
makeNodeField({ key: "po_number", label: fieldLabel(columns, "po_number", "PO"), value: object.po_number, object, column: "po_number", editable: canEditCollectionField(), editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm, slot: 2 }),
makeNodeField({ key: "notes", label: fieldLabel(columns, "notes", "Noter"), value: object.notes, object, column: "notes", editable: canEditCollectionField(), editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm, slot: 3 }),
makeNodeField({ key: "external_id", label: fieldLabel(columns, "external_id", "Eksternt ID"), value: object.external_id, object, column: "external_id", editable: false, slot: 5 }),
makeNodeField({ key: "closed_at", label: fieldLabel(columns, "closed_at", "Lukket"), value: object.closed_at, object, column: "closed_at", editable: canEditCollectionField(), editFunction: SessionUser.objects.collectedOrderInvoices.showEditObjectFieldForm, slot: 6 }),
makeNodeField({ key: "total_net_amount", label: fieldLabel(columns, "total_net_amount", "Total"), value: object.total_net_amount, formatter: formatCurrency, slot: 8, numeric: true }),
];
}
if (node.type === TREE_NODE_TYPES.ORDER) {
const object = node.meta.order || {};
const columns = SessionUser.objects.orders.columns;
return ["reference", "notes", "po", "reg_1", "reg_2", "reg_3", "include_in_invoice"].map((column) => makeNodeField({
key: column,
label: fieldLabel(columns, column, column),
value: object[column],
object,
column,
editable: canEditOrderField(object, column),
editFunction: SessionUser.objects.orders.showEditObjectFieldForm,
})).concat([
return [
makeNodeField({ key: "reference", label: fieldLabel(columns, "reference", "Reference"), value: object.reference, object, column: "reference", editable: canEditOrderField(object, "reference"), editFunction: SessionUser.objects.orders.showEditObjectFieldForm, slot: 1 }),
makeNodeField({ key: "po", label: fieldLabel(columns, "po", "PO"), value: object.po, object, column: "po", editable: canEditOrderField(object, "po"), editFunction: SessionUser.objects.orders.showEditObjectFieldForm, slot: 2 }),
makeNodeField({ key: "notes", label: fieldLabel(columns, "notes", "Noter"), value: object.notes, object, column: "notes", editable: canEditOrderField(object, "notes"), editFunction: SessionUser.objects.orders.showEditObjectFieldForm, slot: 3 }),
registrationField(object, columns),
makeNodeField({ key: "include_in_invoice", label: fieldLabel(columns, "include_in_invoice", "Faktura"), value: object.include_in_invoice, object, column: "include_in_invoice", editable: canEditOrderField(object, "include_in_invoice"), editFunction: SessionUser.objects.orders.showEditObjectFieldForm, slot: 6 }),
makeNodeField({
key: "total_net_amount",
label: fieldLabel(columns, "total_net_amount", "Total"),
value: object.total_net_amount ?? node.meta.totalNetAmount,
formatter: formatCurrency,
slot: 8,
numeric: true,
}),
]);
];
}
if (node.type === TREE_NODE_TYPES.ORDER_ITEM) {
const object = orderItemObjectForNode(node);
return [
makeNodeField({ key: "quantity", label: treeText("fields.quantity", "Antal"), value: object.quantity, object, column: "quantity", editable: canEditOrderItemField(), editFunction: showEditOrderItemFieldForm }),
makeNodeField({ key: "price", label: treeText("fields.price", "Pris"), value: object.price, object, column: "price", editable: canEditOrderItemField(), editFunction: showEditOrderItemFieldForm, formatter: formatCurrency }),
makeNodeField({ key: "reference", label: treeText("fields.reference", "Reference"), value: object.reference, object, column: "reference", editable: canEditOrderItemField(), editFunction: showEditOrderItemFieldForm }),
makeNodeField({ key: "notes", label: treeText("fields.notes", "Noter"), value: object.notes, object, column: "notes", editable: canEditOrderItemField(), editFunction: showEditOrderItemFieldForm }),
makeNodeField({ key: "product_id", label: treeText("fields.product_id", "Produkt ID"), value: object.product_id }),
makeNodeField({ key: "reference", label: treeText("fields.reference", "Reference"), value: object.reference, object, column: "reference", editable: canEditOrderItemField(), editFunction: showEditOrderItemFieldForm, slot: 1 }),
makeNodeField({ key: "notes", label: treeText("fields.notes", "Noter"), value: object.notes, object, column: "notes", editable: canEditOrderItemField(), editFunction: showEditOrderItemFieldForm, slot: 3 }),
makeNodeField({ key: "quantity", label: treeText("fields.quantity", "Antal"), value: object.quantity, object, column: "quantity", editable: canEditOrderItemField(), editFunction: showEditOrderItemFieldForm, slot: 7, numeric: true }),
makeNodeField({ key: "price", label: treeText("fields.price", "Pris"), value: object.price, object, column: "price", editable: canEditOrderItemField(), editFunction: showEditOrderItemFieldForm, formatter: formatCurrency, slot: 8, numeric: true }),
];
}
@@ -1663,7 +1873,7 @@ const deleteSelectedOrderItems = () => deleteOrderItems(orderItemIds());
const downloadAttachments = async (nodes: TreeNode[] = attachmentNodes()) => {
for (const node of attachmentNodesFromNodes(nodes)) {
await SessionUser.objects.orders.functions.downloadAttachment(node.meta.orderId, node.meta.attachmentId, true);
await downloadAttachmentNode(node);
}
};
@@ -2412,13 +2622,15 @@ const nodeActionWheelProps = (node: TreeNode) => {
selection-mode="checkbox"
:lazy="true"
:load="loadTreeNodeChildren"
:progressive-batch-size="50"
:load-more-label="treeText('buttons.show_more', 'Vis {count} mere', { count: '{count}' })"
:default-expand-all="autoExpandAll"
:aria-label="treeText('aria_label', 'Fakturaperiode objekttræ')"
@load-error="(error, node) => { nodeErrors[node.id] = SessionUser.functions.parseErrorMessage?.(error) || loadFailedMessage() }"
@node-click="onTreeNodeClick"
>
<template #icon="{ node, loading, error }">
<span class="icon is-small" :class="{ 'has-text-danger': error }">
<span class="icon is-small" :class="error ? 'has-text-danger' : getNodeIconColorClass(node)">
<i v-if="loading" class="fas fa-spinner fa-spin"></i>
<i v-else class="fas" :class="getNodeIcon(node)"></i>
</span>
@@ -2455,17 +2667,15 @@ const nodeActionWheelProps = (node: TreeNode) => {
{{ node.label }}
</template>
</span>
<InvoicingPeriodFlagBadge
:key="`${node.id}:title-flags`"
:flags="getNodeFlags(node)"
@status-changed="handleFlagStatusChanged"
/>
</span>
<span v-if="node.meta?.count !== null && node.meta?.count !== undefined" class="tag is-light is-small">
{{ node.meta.count }}
</span>
<span v-if="getNodeFlagCount(node) > 0" class="tag is-warning is-light is-small">
<span class="icon is-small"><i class="fas fa-flag"></i></span>
<span>{{ getNodeFlagCount(node) }}</span>
</span>
<span v-if="node.type !== TREE_NODE_TYPES.CATEGORY" class="tag is-white is-small">
{{ typeLabel(node.type) }}
</span>
</div>
<div v-if="getNodeSubtitle(node)" class="invoice-period-tree-node__subtitle" :title="getNodeSubtitle(node)">
{{ getNodeSubtitle(node) }}
@@ -2481,11 +2691,25 @@ const nodeActionWheelProps = (node: TreeNode) => {
v-for="field in visibleNodeFields(node)"
:key="field.key"
class="invoice-period-tree-node-field"
:class="{ 'is-empty': valueIsEmpty(field.value), 'is-editable': field.editable }"
:class="{
'is-empty': valueIsEmpty(field.value),
'is-editable': field.editable,
'is-numeric': field.numeric,
'is-composite': field.lines?.length,
}"
:style="{ gridColumn: `${field.slot || 1} / span ${field.span || 1}` }"
:data-testid="`invoice-period-tree-field-${node.id}-${field.key}`"
>
<span class="invoice-period-tree-node-field__label">{{ field.label }}</span>
<span class="invoice-period-tree-node-field__label">
<span>{{ field.label }}</span>
<InvoicingPeriodFlagBadge
:key="`${node.id}:${field.key}:flags`"
:flags="getFieldFlags(node, field.key)"
@status-changed="handleFlagStatusChanged"
/>
</span>
<span
v-if="!field.lines?.length"
class="invoice-period-tree-node-field__value"
:title="field.display"
:data-testid="`invoice-period-tree-field-value-${node.id}-${field.key}`"
@@ -2508,6 +2732,29 @@ const nodeActionWheelProps = (node: TreeNode) => {
{{ field.display }}
</span>
</span>
<span v-else class="invoice-period-tree-node-field__lines">
<span
v-for="line in field.lines"
:key="line.key"
class="invoice-period-tree-node-field__line"
:data-testid="`invoice-period-tree-field-${node.id}-${line.key}`"
>
<span class="invoice-period-tree-node-field__line-label">{{ line.label }}</span>
<EditableTableColumn
v-if="line.editable"
component-wrapper="span"
theme="simple"
:object="line.object"
:column="line.column"
:load-list="refreshAfterInlineEdit"
:parse-function="fieldParseFunction(line)"
:edit-function="fieldEditFunction(line)"
:permission-check-function="() => line.editable === true"
:cell-test-id="`invoice-period-tree-field-editor-${node.id}-${line.key}`"
/>
<span v-else>{{ line.display }}</span>
</span>
</span>
</div>
</div>
<div v-if="error || nodeErrors[node.id]" class="invoice-period-tree-node__error">
@@ -2687,6 +2934,7 @@ const nodeActionWheelProps = (node: TreeNode) => {
}
.invoice-period-tree-node {
container-type: inline-size;
min-width: 0;
width: 100%;
}
@@ -2708,11 +2956,11 @@ const nodeActionWheelProps = (node: TreeNode) => {
align-items: start;
column-gap: 0.55rem;
display: grid;
grid-template-columns: minmax(7.5rem, 10rem) minmax(0, 1fr);
grid-template-columns: minmax(9rem, 13rem) minmax(0, 1fr);
}
.invoice-period-tree-node__content--order {
grid-template-columns: minmax(5.5rem, 7.25rem) minmax(0, 1fr);
grid-template-columns: minmax(9rem, 13rem) minmax(0, 1fr);
}
.invoice-period-tree-node__identity {
@@ -2754,15 +3002,18 @@ const nodeActionWheelProps = (node: TreeNode) => {
white-space: nowrap;
}
.invoice-period-tree-node__content--order_item .invoice-period-tree-node__label-text {
overflow: visible;
overflow-wrap: anywhere;
text-overflow: clip;
white-space: normal;
}
.invoice-period-tree-node__main .tag {
flex: 0 0 auto;
max-width: 8rem;
}
.invoice-period-tree-node__content--order .invoice-period-tree-node__main .tag.is-white {
display: none;
}
.invoice-period-tree-node__range-icon {
color: #7a8699;
height: 1rem;
@@ -2788,24 +3039,12 @@ const nodeActionWheelProps = (node: TreeNode) => {
align-items: stretch;
display: grid;
gap: 0.28rem;
grid-template-columns: repeat(4, minmax(7.5rem, 1fr));
grid-template-columns: repeat(8, minmax(0, 1fr));
margin-top: 0;
max-width: 100%;
min-width: 0;
}
.invoice-period-tree-node__fields--order {
grid-template-columns: repeat(8, minmax(4.75rem, 1fr));
}
.invoice-period-tree-node__fields--order_item {
grid-template-columns: repeat(5, minmax(5.8rem, 1fr));
}
.invoice-period-tree-node__fields--collected_order_invoice {
grid-template-columns: repeat(7, minmax(5.75rem, 1fr));
}
.invoice-period-tree-node-field {
background: #f8fafc;
border: 1px solid #e2e7ef;
@@ -2824,14 +3063,51 @@ const nodeActionWheelProps = (node: TreeNode) => {
}
.invoice-period-tree-node-field__label {
align-items: center;
color: #687385;
display: flex;
flex-wrap: wrap;
font-size: 0.68rem;
font-weight: 700;
gap: 0.18rem;
justify-content: space-between;
line-height: 1.1;
min-width: 0;
text-transform: uppercase;
}
@container (max-width: 38rem) {
.invoice-period-tree-node__content--with-fields,
.invoice-period-tree-node__content--order {
grid-template-columns: minmax(0, 1fr);
row-gap: 0.28rem;
}
.invoice-period-tree-node__fields,
.invoice-period-tree-node__fields--order,
.invoice-period-tree-node__fields--order_item,
.invoice-period-tree-node__fields--collected_order_invoice {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.invoice-period-tree-node-field {
grid-column: auto !important;
}
}
@container (max-width: 24rem) {
.invoice-period-tree-node__fields,
.invoice-period-tree-node__fields--order,
.invoice-period-tree-node__fields--order_item,
.invoice-period-tree-node__fields--collected_order_invoice {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.invoice-period-tree-node-field__label > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
@@ -2851,6 +3127,45 @@ const nodeActionWheelProps = (node: TreeNode) => {
font-style: italic;
}
.invoice-period-tree-node-field.is-numeric .invoice-period-tree-node-field__label,
.invoice-period-tree-node-field.is-numeric .invoice-period-tree-node-field__value,
.invoice-period-tree-node-field.is-numeric :deep(.hover-illustration) {
text-align: right;
}
.invoice-period-tree-node-field.is-numeric .invoice-period-tree-node-field__value,
.invoice-period-tree-node-field.is-numeric :deep(.hover-illustration) {
font-variant-numeric: tabular-nums;
}
.invoice-period-tree-node-field__lines {
display: grid;
gap: 0.12rem;
min-width: 0;
}
.invoice-period-tree-node-field__line {
align-items: baseline;
display: grid;
font-size: 0.74rem;
gap: 0.3rem;
grid-template-columns: 2.8rem minmax(0, 1fr);
line-height: 1.15;
min-width: 0;
}
.invoice-period-tree-node-field__line-label {
color: #7a8492;
font-size: 0.64rem;
font-weight: 700;
white-space: nowrap;
}
.invoice-period-tree-node-field__line :deep(.hover-illustration) {
overflow-wrap: anywhere;
white-space: normal;
}
.invoice-period-tree-node-field :deep(.hover-illustration) {
color: #27313d;
display: block;
@@ -3006,6 +3321,10 @@ const nodeActionWheelProps = (node: TreeNode) => {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invoice-period-tree-node-field {
grid-column: auto !important;
}
.invoice-period-tree-node__actions {
width: 2rem;
}
@@ -40,10 +40,12 @@ let lastSyncedRouteSignature = "";
let periodCacheWarmTimers = new Set<ReturnType<typeof setTimeout>>();
let isPeriodComponentUnmounted = false;
let isCommittingPendingPeriodView = false;
let isCommittingInitialMonthSelection = false;
let periodDateLoadSequence = 0;
const periodCacheRequestInFlight = new Set<string>();
const PERIOD_CACHE_WARM_DELAY_MS = 100;
const pendingPeriodView = ref<string | null>(null);
const hasPeriodDateSelection = () => dates.variables.hasSelection?.value !== false;
const normalizePeriodResult = (data: any = {}) => ({
...data,
@@ -170,6 +172,9 @@ const isViewActive = computed(() => (type: typeof types[number]) => {
});
const onClickType = (type: typeof types[number]) => {
if (!hasPeriodDateSelection()) {
return;
}
if (view.variables.currentView.value === type.view && pendingPeriodView.value === null) {
return;
}
@@ -235,6 +240,7 @@ const applyRoutePeriodDates = () => {
|| requestedStartDate > requestedEndDate
|| typeof dates.functions?.setSelection !== "function"
) {
dates.functions.clearSelection?.();
return false;
}
@@ -242,7 +248,10 @@ const applyRoutePeriodDates = () => {
dates.computed.formattedStartDate.value === routeStartDate
&& dates.computed.formattedEndDate.value === routeEndDate
) {
return false;
if (dates.variables.hasSelection) {
dates.variables.hasSelection.value = true;
}
return true;
}
isApplyingRoutePeriodDates = true;
@@ -291,7 +300,7 @@ const getBackendPeriodView = (periodView: string | null = null) => {
};
const syncPeriodRouteQuery = () => {
if (!isPeriodTabActive.value || isApplyingRoutePeriodState) {
if (!isPeriodTabActive.value || isApplyingRoutePeriodState || !hasPeriodDateSelection()) {
return;
}
@@ -623,6 +632,9 @@ const applyPeriodResult = (
};
const getPeriod = async ({ forceRefresh = false, showLoading = false } = {}) => {
if (!hasPeriodDateSelection()) {
return;
}
const requestParameters = getPeriodRequestParameters();
const cacheKey = buildPeriodCacheKey(requestParameters);
const cachedPage = forceRefresh ? null : getCachedPeriodPage(cacheKey);
@@ -912,6 +924,9 @@ const isViewAvailable = computed(() => (type: typeof types[number]) => {
// Check if the view is available based on the current date range
return type.isAvailable ? type.isAvailable.value : true;
});
const canActivateView = computed(() => (type: typeof types[number]) => (
hasPeriodDateSelection() && isViewAvailable.value(type)
));
const shouldShowSelectorCounts = (_type: typeof types[number]) => true;
const shouldShowSelectorProgress = (_type: typeof types[number]) => true;
@@ -938,7 +953,7 @@ const getVisiblePeriodCustomerNumbers = () => {
};
const refreshVisiblePeriodCustomers = async () => {
if (!isPeriodTabActive.value || status.loading || draftStatusRefreshInFlight) {
if (!isPeriodTabActive.value || !hasPeriodDateSelection() || status.loading || draftStatusRefreshInFlight) {
return;
}
@@ -956,7 +971,7 @@ const refreshVisiblePeriodCustomers = async () => {
};
const startDraftStatusRefreshTimer = () => {
if (draftStatusRefreshTimer !== null || !isPeriodTabActive.value) {
if (draftStatusRefreshTimer !== null || !isPeriodTabActive.value || !hasPeriodDateSelection()) {
return;
}
@@ -975,7 +990,7 @@ const stopDraftStatusRefreshTimer = () => {
};
const syncDraftStatusRefreshTimer = () => {
if (isPeriodTabActive.value) {
if (isPeriodTabActive.value && hasPeriodDateSelection()) {
startDraftStatusRefreshTimer();
return;
}
@@ -991,6 +1006,9 @@ const handleSelfWashUsageOrderUpdated = () => {
};
const loadPeriodForDateChange = () => {
if (!hasPeriodDateSelection()) {
return;
}
const loadSequence = ++periodDateLoadSequence;
clearPeriodCache();
periodPaging.page = 1;
@@ -1009,14 +1027,23 @@ onMounted(() => {
acc[type.name] = type.displayName;
return acc;
}, {});
applyRoutePeriodDates();
applyRoutePeriodView();
const hasRoutePeriodDates = applyRoutePeriodDates();
if (hasRoutePeriodDates) {
applyRoutePeriodView();
if (view.variables.currentView.value === "home") {
view.variables.currentView.value = "all";
}
} else {
view.variables.currentView.value = "home";
}
applyRoutePeriodPaging();
hasMountedPeriodState = true;
queueMicrotask(() => {
syncPeriodRouteQuery();
});
getPeriod();
if (hasRoutePeriodDates) {
queueMicrotask(() => {
syncPeriodRouteQuery();
});
getPeriod();
}
syncDraftStatusRefreshTimer();
window.addEventListener('xlvask-usage-order-updated', handleSelfWashUsageOrderUpdated);
});
@@ -1035,6 +1062,19 @@ watch(
if (isApplyingRoutePeriodDates) {
return;
}
if (!hasPeriodDateSelection()) {
return;
}
if (view.variables.currentView.value === "home") {
isCommittingInitialMonthSelection = true;
view.variables.currentView.value = "all";
pendingPeriodView.value = null;
queueMicrotask(() => {
isCommittingInitialMonthSelection = false;
});
}
syncPeriodRouteQuery();
syncDraftStatusRefreshTimer();
loadPeriodForDateChange();
}
);
@@ -1043,7 +1083,9 @@ watch(
() => route.query.activeTab,
(newTab, oldTab) => {
if (newTab === 'period' && oldTab !== 'period') {
getPeriod();
if (hasPeriodDateSelection()) {
getPeriod();
}
}
syncDraftStatusRefreshTimer();
@@ -1062,8 +1104,20 @@ watch(
],
() => {
const routeSignature = getPeriodRouteSignature(route.query as Record<string, any>);
applyRoutePeriodDates();
applyRoutePeriodView();
const hasRoutePeriodDates = applyRoutePeriodDates();
if (hasRoutePeriodDates) {
applyRoutePeriodView();
if (!parseRouteString(route.query.periodView).trim()) {
view.variables.currentView.value = "all";
}
} else {
view.variables.currentView.value = "home";
view.variables.sharedVariables.value = null;
period_result.value = null;
status.loaded = false;
status.loading = false;
stopDraftStatusRefreshTimer();
}
applyRoutePeriodPaging();
if (isSyncingRouteFromState || routeSignature === lastSyncedRouteSignature) {
if (routeSignature === lastSyncedRouteSignature) {
@@ -1071,7 +1125,7 @@ watch(
}
return;
}
if (hasMountedPeriodState && isPeriodTabActive.value) {
if (hasMountedPeriodState && isPeriodTabActive.value && hasRoutePeriodDates) {
getPeriod();
}
}
@@ -1088,7 +1142,13 @@ watch(
periodPaging.includeBooked,
],
() => {
if (!hasMountedPeriodState || isApplyingRoutePeriodState || isCommittingPendingPeriodView) {
if (
!hasMountedPeriodState
|| isApplyingRoutePeriodState
|| isCommittingPendingPeriodView
|| isCommittingInitialMonthSelection
|| !hasPeriodDateSelection()
) {
return;
}
syncPeriodRouteQuery();
@@ -1140,9 +1200,11 @@ watch(
:data-testid="`invoicing-period-view-selector-${type.name}`"
v-bind:class="{
'is-selected-view': isViewActive(type),
'is-clickable': isViewAvailable(type),
'is-clickable': canActivateView(type),
'is-disabled-view': !canActivateView(type),
}"
@click="(isViewAvailable(type) ? onClickType(type) : () => {})">
:aria-disabled="!canActivateView(type)"
@click="(canActivateView(type) ? onClickType(type) : () => {})">
<div class="level is-mobile is-align-items-center">
<div class="level-left">
<div class="level-item">
@@ -1208,6 +1270,11 @@ watch(
background-color: #f0f8ff; /* Light blue background for selected view */
}
.is-disabled-view {
cursor: default;
opacity: 0.55;
}
.period-selector-progress {
position: relative;
width: 100%;
@@ -1,13 +1,14 @@
<script setup lang="ts">
import InvoicingBillingPeriodDatePeriodSelector
from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/InvoicingBillingPeriodDatePeriodSelector.vue";
import { dates } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportDates.vue";
</script>
<template>
<!-- Time / Period selection -->
<InvoicingBillingPeriodDatePeriodSelector/>
<InvoicingBillingPeriodDatePeriodSelector v-if="dates.variables.hasSelection.value"/>
</template>
<style scoped>
</style>
</style>
@@ -9,11 +9,13 @@ import { ref, computed } from "vue";
// Dates
const startDate = ref(new Date(new Date().setHours(0, 0, 0, 0))); // Start of the day (00:00:00 Today)
const endDate = ref(new Date(new Date().setHours(23, 59, 59, 999))); // End of the day (23:59:59 Today)
const hasSelection = ref(false);
// Combine start and end dates into a single object for easy access
const variablesDates = {
start: startDate,
end: endDate,
hasSelection,
};
/**
@@ -39,12 +41,32 @@ const setSelection = (start: Date, end: Date) => {
nextEnd.setHours(23, 59, 59, 999);
startDate.value = nextStart;
endDate.value = nextEnd;
hasSelection.value = true;
};
const setMonthSelection = (month: Date) => {
const selectedMonth = new Date(month);
if (Number.isNaN(selectedMonth.getTime())) {
return false;
}
setSelection(
new Date(selectedMonth.getFullYear(), selectedMonth.getMonth(), 1),
new Date(selectedMonth.getFullYear(), selectedMonth.getMonth() + 1, 0),
);
return true;
};
const clearSelection = () => {
hasSelection.value = false;
};
const functionsDates = {
formatDate,
formatTime,
setSelection,
setMonthSelection,
clearSelection,
};
/**
@@ -211,7 +211,7 @@ export const makeOrderNode = (order, options = {}) => {
const orderId = toPositiveInteger(order?.id);
return {
id: makeNodeId(TREE_NODE_TYPES.ORDER, orderId),
label: resolveLabel(`Ordre #${orderId}`, options.label),
label: resolveLabel(`Vask #${orderId}`, options.label),
type: TREE_NODE_TYPES.ORDER,
isLeaf: false,
selectable: true,
@@ -227,6 +227,7 @@ export const makeOrderNode = (order, options = {}) => {
totalNetAmount: getTreeAmount(order),
customerNumber: toPositiveInteger(order?.customer_id ?? order?.customer_number),
departmentId: toPositiveInteger(order?.department_id),
invoiceState: normalizeString(order?.invoice_state),
},
};
};
@@ -240,6 +241,7 @@ export const makeOrderItemNode = (item, options = {}) => {
label: productName,
type: TREE_NODE_TYPES.ORDER_ITEM,
isLeaf: true,
children: [],
selectable: true,
actionable: true,
icon: "fa-list-check",
@@ -248,6 +250,8 @@ export const makeOrderItemNode = (item, options = {}) => {
itemId,
orderId: toPositiveInteger(item?.order_id),
productId: toPositiveInteger(item?.product_id),
relatedItemId: toPositiveInteger(item?.related_item_id),
invoiceState: normalizeString(options.invoiceState),
quantity: toFiniteNumber(item?.quantity ?? item?.amount),
price: toFiniteNumber(item?.price),
totalNetAmount: toFiniteNumber(item?.price) * toFiniteNumber(item?.quantity ?? item?.amount ?? 1),
@@ -255,6 +259,75 @@ export const makeOrderItemNode = (item, options = {}) => {
};
};
const orderItemParentId = (item) => toPositiveInteger(item?.related_item_id);
const orderItemId = (item) => toPositiveInteger(item?.id ?? item?.order_item_id);
const orderItemOrderId = (item) => toPositiveInteger(item?.order_id);
const hasParentCycle = (itemId, parentId, parentByItemId) => {
const visited = new Set([itemId]);
let currentId = parentId;
while (currentId) {
if (visited.has(currentId)) {
return true;
}
visited.add(currentId);
currentId = parentByItemId.get(currentId) || null;
}
return false;
};
export const buildOrderItemTree = (items = [], options = {}) => {
if (!Array.isArray(items)) {
return [];
}
const entries = items.map((item, index) => {
const id = orderItemId(item);
const node = makeOrderItemNode(item, {
fallbackLabel: typeof options.fallbackLabel === "function"
? options.fallbackLabel(item, index)
: options.fallbackLabel,
invoiceState: options.invoiceState,
});
if (!id) {
node.id = makeNodeId(TREE_NODE_TYPES.ORDER_ITEM, `unknown-${index + 1}`);
}
return {
item,
index,
id,
parentId: orderItemParentId(item),
orderId: orderItemOrderId(item),
node,
};
});
const entriesById = new Map(entries.filter((entry) => entry.id).map((entry) => [entry.id, entry]));
const parentByItemId = new Map(entries.filter((entry) => entry.id && entry.parentId).map((entry) => [entry.id, entry.parentId]));
const roots = [];
entries.forEach((entry) => {
const parent = entry.parentId ? entriesById.get(entry.parentId) : null;
const isSameOrder = parent && (!entry.orderId || !parent.orderId || entry.orderId === parent.orderId);
const canAttach = Boolean(
entry.id
&& parent
&& entry.id !== entry.parentId
&& isSameOrder
&& !hasParentCycle(entry.id, entry.parentId, parentByItemId)
);
if (!canAttach) {
roots.push(entry.node);
return;
}
parent.node.children.push(entry.node);
parent.node.isLeaf = false;
});
return roots;
};
export const classifyAttachment = (attachment) => {
const rawName = normalizeString(
attachment?.file_name
@@ -444,9 +517,13 @@ export const buildCollectionRootNodes = (customer, transactions = [], excludedOr
locale: labels.locale,
fallbackLabel,
});
const collectionSummary = (Array.isArray(customer?.invoice_collections) ? customer.invoice_collections : [])
.find((collection) => toPositiveInteger(collection?.id ?? collection?.invoice_collection_id) === collectionId) || null;
return makeCollectionNode(collectionId, orders, customer, {
label: relativeLabel?.label || fallbackLabel,
relativeLabel,
collectionSummary,
invoiceState: normalizeString(collectionSummary?.state),
});
});
@@ -711,8 +711,6 @@ const isOrderLineFlag = (flag: any) => ["order", "order_field"].includes(String(
const isOrderItemLineFlag = (flag: any) => ["order_item", "order_item_field"].includes(String(flag?.target_type || ""));
const getCustomerCardFlags = (customer: any) =>
getCustomerScopedFlags(customer).filter((flag: any) => !isOrderLineFlag(flag) && !isOrderItemLineFlag(flag));
const getCustomerExpandedFlags = (customer: any) =>
getCustomerScopedFlags(customer).filter((flag: any) => isOrderLineFlag(flag) || isOrderItemLineFlag(flag));
const recomputeCustomerFlagState = (customer: any) => {
customer.flags = sortCustomerFlags(getActiveFlags(customer.flags || []));
@@ -1281,11 +1279,6 @@ const getTransactionQueryParameters = () => {
/>
</template>
<template v-else>
<InvoicingPeriodFlagList
:flags="getCustomerExpandedFlags(customer)"
compact
@statusChanged="(flag) => onFlagStatusChanged(customer, flag)"
/>
<InvoicingPeriodObjectTree
:customer="customer"
:transactions="getTransactionsInView(customer)"
@@ -1,13 +1,36 @@
<script setup lang="ts">
import { ref } from "vue";
import BuefyMonthField from "@/components/forms/BuefyMonthField.vue";
import { dates } from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/imports/InvoicingBillingPeriodImportDates.vue";
import InvoicingBillingPeriodInvoiceProgressBar
from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/displays/InvoicingBillingPeriodInvoiceProgressBar.vue";
const selectedMonth = ref<Date | null>(null);
const selectMonth = (month: Date | null) => {
if (month instanceof Date) {
dates.functions.setMonthSelection(month);
}
};
</script>
<template>
<InvoicingBillingPeriodInvoiceProgressBar/>
<div class="invoicing-period-month-entry" data-testid="invoicing-period-month-entry">
<BuefyMonthField
v-model="selectedMonth"
value-type="date"
inline
:append-to-body="false"
data-testid="invoicing-period-inline-month-selector"
@change="selectMonth"
/>
</div>
</template>
<style scoped>
</style>
.invoicing-period-month-entry {
align-items: flex-start;
display: flex;
justify-content: center;
min-height: 22rem;
padding: 1rem 0;
}
</style>
@@ -16,6 +16,7 @@ import {
getCustomerRuleDefinitions,
} from "@/features/customer/customerRuleRegistry.js";
import CustomerRuleTooltip from "@/features/customer/CustomerRuleTooltip.vue";
import { canViewCustomerRuleConfiguration } from "@/features/customer/customerRuleConfigurationPermissions.js";
const props = defineProps({
userId: {
@@ -48,6 +49,7 @@ const canView = computed(() => (
));
const canAdd = computed(() => SessionUser.hasPermission("add_customer_attribute"));
const canDelete = computed(() => SessionUser.hasPermission("delete_customer_attribute"));
const canViewGlobalConfiguration = computed(canViewCustomerRuleConfiguration);
const targetPayload = computed(() => buildCustomerAttributeTargetPayload({
userId: props.userId,
customerNumber: props.customerNumber,
@@ -69,6 +71,7 @@ const activeAttributeKeys = computed(() => {
});
const ruleItems = computed(() => definitions.value.map((rule) => ({
...rule,
attributeEntry: attributes.value.find((entry) => getCustomerRuleAttributeKey(entry) === rule.attribute) ?? null,
description: t(rule.descriptionKey),
isActive: activeAttributeKeys.value.has(rule.attribute),
isBusy: busyAttributes.value.has(rule.attribute),
@@ -214,6 +217,15 @@ watch(
</span>
<span>{{ $t("global.reload") }}</span>
</button>
<router-link
v-if="canViewGlobalConfiguration"
class="button is-info is-light is-small"
to="/superuser/customer-rules"
data-testid="superuser-user-security-global-rule-configuration"
>
<span class="icon"><i class="fas fa-layer-group" /></span>
<span>{{ $t("customer_rules.manager.global_configuration") }}</span>
</router-link>
</div>
<div v-if="loadError" class="notification is-danger is-light" data-testid="superuser-user-security-rules-error">
@@ -237,6 +249,7 @@ watch(
<CustomerRuleTooltip
:attribute="rule.attribute"
:active="rule.isActive"
:restriction="rule.attributeEntry"
:test-id="`superuser-user-security-rule-tooltip-${rule.attribute}`"
>
<span
+417
View File
@@ -0,0 +1,417 @@
import { expect, test, type Page } from "@playwright/test";
import { apiPathPattern, createPosFixture, mockApi, seedAuthenticatedState } from "./support/network.js";
type OrderRequest = {
filters: string;
limit: number;
order: string;
search: string;
};
const buildOrder = (id: number, createdAt: string, customerId = 12345679) => ({
id,
customer_id: customerId,
customer_name: customerId === 6001 ? "Draft customer" : "Filter customer",
user_id: 77,
cashier_id: 5,
cashier_name: "Operator",
department_id: 12,
reference: `FILTER-${id}`,
po: "",
safety_seal: "",
notes: "",
reg_1: `FILTER${id}`,
reg_2: "",
reg_3: "",
invoice_collection_id: null,
invoice_collection: null,
economic_invoice_module: null,
stripe_invoice_module: null,
booking_id: null,
completed_at: null,
closed_at: null,
created_at: createdAt,
include_in_invoice: null,
total_net_amount: 10000,
});
const setupAdminOrderFilters = async (page: Page) => {
await seedAuthenticatedState(page, "admin-order-filter-token");
const fixture = createPosFixture({
ordersById: {
71001: buildOrder(71001, "2026-07-01 08:15:00"),
71002: buildOrder(71002, "2026-07-05 10:30:00"),
71003: buildOrder(71003, "2026-07-07 13:45:00"),
71004: buildOrder(71004, "2026-07-07 14:15:00", 6001),
},
orderItemsByOrderId: {
71001: [],
71002: [],
71003: [],
71004: [],
},
});
const fixtureOrders = Object.values(fixture.ordersById || {});
await mockApi(page, {
authenticated: true,
permissions: ["admin", "list_orders", "department_access_12"],
sessionData: {
runtime_config: {
economic: {
transaction_draft_customer_number: 6001,
},
},
},
pos: fixture,
});
const requests: OrderRequest[] = [];
await page.route(apiPathPattern("/orders"), async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/orders") && route.request().method() === "GET") {
const filters = url.searchParams.get("filters") || "";
const filterMap = filters
.split(",")
.filter(Boolean)
.reduce<Record<string, string>>((result, expression) => {
const separatorIndex = expression.indexOf(":");
if (separatorIndex > 0) {
result[expression.slice(0, separatorIndex)] = expression.slice(separatorIndex + 1);
}
return result;
}, {});
const pageNumber = Number(url.searchParams.get("page") || "1");
const limit = Number(url.searchParams.get("limit") || "100");
const order = url.searchParams.get("order") || "created_at:desc";
const [orderBy, orderDirection] = order.split(":");
const matchingOrders = fixtureOrders
.filter((orderRow) => {
const createdAtDate = String(orderRow.created_at || "").slice(0, 10);
return (
(!filterMap.department_id || Number(orderRow.department_id) === Number(filterMap.department_id)) &&
(!filterMap.customer_id || Number(orderRow.customer_id) === Number(filterMap.customer_id)) &&
(!filterMap["created_at-date_from"] || createdAtDate >= filterMap["created_at-date_from"]) &&
(!filterMap["created_at-date_to"] || createdAtDate <= filterMap["created_at-date_to"])
);
})
.sort((left, right) => {
const leftValue = String(left[orderBy] ?? "");
const rightValue = String(right[orderBy] ?? "");
return orderDirection.toLowerCase() === "asc"
? leftValue.localeCompare(rightValue)
: rightValue.localeCompare(leftValue);
});
const startIndex = Math.max(0, (pageNumber - 1) * limit);
requests.push({
filters,
limit,
order,
search: url.searchParams.get("search") || "",
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: matchingOrders.slice(startIndex, startIndex + limit),
meta: {
pagination: {
page: pageNumber,
per_page: limit,
total: matchingOrders.length,
},
},
}),
});
return;
}
await route.fallback();
});
return requests;
};
const latestTableRequest = (requests: OrderRequest[]) =>
[...requests]
.reverse()
.find((request) => request.limit > 1 && request.limit < 1000 && request.order.startsWith("created_at:"));
const expectLatestTableRequest = async (requests: OrderRequest[], predicate: (request: OrderRequest) => boolean) => {
await expect
.poll(() => {
const request = latestTableRequest(requests);
return request ? predicate(request) : false;
})
.toBe(true);
};
test.describe("admin POS order filters", () => {
test("reuses the complete desktop filter surface without superuser defaults", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "Desktop filter layout coverage");
await page.setViewportSize({ width: 1920, height: 900 });
await page.clock.setFixedTime(new Date("2026-07-07T10:00:00.000Z"));
const requests = await setupAdminOrderFilters(page);
await page.goto("/admin/12/modules/pos/orders", { waitUntil: "domcontentloaded" });
const filters = page.getByTestId("table-labeled-pagination-filters");
const otherFiltersButton = page.getByTestId("invoice-orders-other-filters-button");
const otherFiltersMenu = page.getByTestId("invoice-orders-other-filters-menu");
const fromField = page.getByTestId("invoice-orders-date-from");
const toField = page.getByTestId("invoice-orders-date-to");
const fromInput = fromField.locator("input").first();
const toInput = toField.locator("input").first();
const searchInput = page.getByRole("textbox", { name: "Søg", exact: true });
await expect(filters).toBeVisible();
await expect(filters.locator(".autocomplete")).toHaveCount(0);
await expect(page.locator('[data-disable-auto-excel-export="1"] > h2.title.is-4')).toHaveCount(0);
await expect(otherFiltersButton).toBeVisible();
await expect(page.getByTestId("invoice-orders-other-filters-count")).toHaveCount(0);
await expect(page.getByTestId("date-period-shortcut-anytime")).toBeVisible();
await expect(page.getByTestId("date-period-shortcut-today")).toBeVisible();
await expect(page.locator('table input[type="checkbox"]')).toHaveCount(0);
await expectLatestTableRequest(
requests,
(request) =>
request.filters.includes("department_id:12") &&
!request.filters.includes("booked_invoice_id") &&
!request.filters.includes("OtherSpecialArrangement") &&
!request.filters.includes("OtherVaskeabonnement") &&
request.order === "created_at:desc"
);
await fromInput.click();
await fromInput.press("Enter");
await expect(page.locator(".datepicker-content:visible")).toBeVisible();
await expect(page.locator(".datepicker-content:visible .datepicker-cell.has-event")).toHaveCount(3);
await page
.locator(".datepicker-content:visible a.datepicker-cell.is-selectable")
.filter({ hasText: /^5$/ })
.click();
await expect(fromInput).toHaveValue("05.07.2026");
await expectLatestTableRequest(
requests,
(request) =>
request.filters.includes("department_id:12") && request.filters.includes("created_at-date_from:2026-07-05")
);
await fromField.locator(".icon.is-right").click();
await expect(fromInput).toHaveValue("");
await expectLatestTableRequest(requests, (request) => !request.filters.includes("created_at-date_from:"));
await page.getByTestId("date-period-shortcut-today").click();
await expect(fromInput).toHaveValue("07.07.2026");
await expect(toInput).toHaveValue("07.07.2026");
await expectLatestTableRequest(
requests,
(request) =>
request.filters.includes("department_id:12") &&
request.filters.includes("created_at-date_from:2026-07-07") &&
request.filters.includes("created_at-date_to:2026-07-07")
);
await toField.locator(".icon.is-right").click();
await expect(fromInput).toHaveValue("07.07.2026");
await expect(toInput).toHaveValue("");
await expectLatestTableRequest(
requests,
(request) =>
request.filters.includes("created_at-date_from:2026-07-07") && !request.filters.includes("created_at-date_to:")
);
await page.getByTestId("date-period-shortcut-today").click();
await searchInput.fill("FILTER");
await expectLatestTableRequest(requests, (request) => request.search === "FILTER");
await otherFiltersButton.click();
await expect(otherFiltersMenu).toBeVisible();
for (const filterTestId of [
"invoice-orders-other-filter-order-direction",
"invoice-orders-other-filter-booked-invoice-id",
"invoice-orders-other-filter-processor",
"invoice-orders-other-filter-completed-at",
"invoice-orders-other-filter-invoice-selection",
"invoice-orders-other-filter-special-agreement",
"invoice-orders-other-filter-wash-subscription",
]) {
await expect(page.getByTestId(filterTestId)).toBeVisible();
}
const hiddenFilterSelections = [
["invoice-orders-other-filter-booked-invoice-id", "not null"],
["invoice-orders-other-filter-processor", "2"],
["invoice-orders-other-filter-completed-at", "not null"],
["invoice-orders-other-filter-invoice-selection", "invoiceAllOrdersIndividually"],
["invoice-orders-other-filter-special-agreement", "OtherSpecialArrangement"],
["invoice-orders-other-filter-wash-subscription", "OtherVaskeabonnement"],
["invoice-orders-other-filter-order-direction", "asc"],
] as const;
for (const [testId, value] of hiddenFilterSelections) {
await page.getByTestId(testId).selectOption(value);
}
const activeCount = page.getByTestId("invoice-orders-other-filters-count");
await expect(activeCount).toHaveText("7");
await expectLatestTableRequest(
requests,
(request) =>
request.filters.includes("department_id:12") &&
request.filters.includes("booked_invoice_id:not null") &&
request.filters.includes("processor:2") &&
request.filters.includes("completed_at:not null") &&
request.filters.includes("customer_id-has_attribute:invoiceAllOrdersIndividually") &&
request.filters.includes("customer_id-has_key-OtherSpecialArrangement") &&
request.filters.includes("customer_id-has_key-OtherVaskeabonnement") &&
request.order === "created_at:asc" &&
request.search === "FILTER"
);
await expect
.poll(() =>
requests.some(
(request) =>
request.limit === 1000 &&
request.filters.includes("department_id:12") &&
request.filters.includes("processor:2") &&
!request.filters.includes("created_at-date_") &&
request.search === "FILTER"
)
)
.toBe(true);
const [buttonBox, countBox] = await Promise.all([otherFiltersButton.boundingBox(), activeCount.boundingBox()]);
expect(buttonBox).not.toBeNull();
expect(countBox).not.toBeNull();
expect((countBox?.x ?? 0) + (countBox?.width ?? 0)).toBeGreaterThan((buttonBox?.x ?? 0) + (buttonBox?.width ?? 0));
await page.getByTestId("invoice-orders-other-filters-clear").click();
await expect(page.getByTestId("invoice-orders-other-filters-count")).toHaveCount(0);
await expect(fromInput).toHaveValue("07.07.2026");
await expect(toInput).toHaveValue("07.07.2026");
await expectLatestTableRequest(
requests,
(request) =>
request.filters.includes("department_id:12") &&
request.filters.includes("created_at-date_from:2026-07-07") &&
request.filters.includes("created_at-date_to:2026-07-07") &&
!request.filters.includes("processor:2") &&
!request.filters.includes("completed_at:not null") &&
request.order === "created_at:desc" &&
request.search === "FILTER"
);
await page.keyboard.press("Escape");
await page.getByTestId("date-period-other-dropdown-trigger").click();
await page.getByTestId("date-period-other-last_seven_days").click();
await expect(fromInput).toHaveValue("01.07.2026");
await expect(toInput).toHaveValue("07.07.2026");
await page.getByTestId("date-period-other-dropdown-trigger").click();
await page.getByTestId("date-period-other-other_month").click();
await expect(page.getByTestId("date-period-other-month-modal")).toBeVisible();
await expect(page.getByTestId("date-period-other-month-picker").locator(".datepicker-table")).toBeVisible();
await page.getByTestId("date-period-other-month-close").click();
await page.getByTestId("date-period-shortcut-anytime").click();
await expect(fromInput).toHaveValue("");
await expect(toInput).toHaveValue("");
await expectLatestTableRequest(
requests,
(request) => request.filters.includes("department_id:12") && !request.filters.includes("created_at-date_")
);
});
test("keeps the shortcut controls usable in the mobile layout", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("mobile"), "Mobile filter layout coverage");
await page.setViewportSize({ width: 390, height: 844 });
await page.clock.setFixedTime(new Date("2026-07-07T10:00:00.000Z"));
const requests = await setupAdminOrderFilters(page);
await page.goto("/admin/12/modules/pos/orders", { waitUntil: "domcontentloaded" });
const shortcutSelect = page.getByTestId("date-period-shortcuts");
await expect(page.getByTestId("date-period-mobile-layout")).toBeVisible();
await expect(shortcutSelect).toBeVisible();
await shortcutSelect.selectOption("today");
await expectLatestTableRequest(
requests,
(request) =>
request.filters.includes("department_id:12") &&
request.filters.includes("created_at-date_from:2026-07-07") &&
request.filters.includes("created_at-date_to:2026-07-07")
);
await page.getByTestId("date-period-other-dropdown-trigger").click();
await expect(page.getByTestId("date-period-other-menu")).toBeVisible();
await page.getByTestId("date-period-other-last_seven_days").click();
await expectLatestTableRequest(
requests,
(request) =>
request.filters.includes("created_at-date_from:2026-07-01") &&
request.filters.includes("created_at-date_to:2026-07-07")
);
await expect(page.getByTestId("invoice-orders-other-filters-button")).toBeVisible();
});
test("preserves the department and draft customer across filter changes", async ({ page }, testInfo) => {
test.skip(testInfo.project.name.includes("mobile"), "Desktop draft filter coverage");
await page.clock.setFixedTime(new Date("2026-07-07T10:00:00.000Z"));
const requests = await setupAdminOrderFilters(page);
await page.goto("/admin/12/modules/pos/drafts", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("department-pos-drafts-page")).toBeVisible();
await expect(page.getByTestId("invoice-orders-other-filters-button")).toBeVisible();
await expect(page.getByTestId("draft-order-assign-customer-button-71004")).toBeVisible();
await expectLatestTableRequest(
requests,
(request) =>
request.filters.includes("department_id:12") &&
request.filters.includes("customer_id:6001") &&
!request.filters.includes("booked_invoice_id") &&
request.order === "created_at:desc"
);
await page.getByTestId("date-period-shortcut-today").click();
await page.getByTestId("invoice-orders-other-filters-button").click();
await page.getByTestId("invoice-orders-other-filter-processor").selectOption("1");
await expectLatestTableRequest(
requests,
(request) =>
request.filters.includes("department_id:12") &&
request.filters.includes("customer_id:6001") &&
request.filters.includes("created_at-date_from:2026-07-07") &&
request.filters.includes("processor:1")
);
await expect
.poll(() =>
requests.some(
(request) =>
request.limit === 1000 &&
request.filters.includes("department_id:12") &&
request.filters.includes("customer_id:6001") &&
request.filters.includes("processor:1") &&
!request.filters.includes("created_at-date_")
)
)
.toBe(true);
await page.getByTestId("invoice-orders-other-filters-clear").click();
await expectLatestTableRequest(
requests,
(request) =>
request.filters.includes("department_id:12") &&
request.filters.includes("customer_id:6001") &&
request.filters.includes("created_at-date_from:2026-07-07") &&
!request.filters.includes("processor:1")
);
});
});
+117 -15
View File
@@ -980,7 +980,9 @@ async function openPeriodView(page, options = {}) {
invoiceDistribution: true,
});
await setupPeriodEndpoints(page, periodRequests, options);
await page.goto("/superuser/invoices?activeTab=period", { waitUntil: "domcontentloaded" });
await page.goto("/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all", {
waitUntil: "domcontentloaded",
});
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({
timeout: periodRouteReadyTimeout,
@@ -1189,6 +1191,7 @@ async function routeObjectTreeOrderEndpoints(page) {
{
id: 7702,
order_id: 9001,
related_item_id: 7701,
product_id: 102,
product_name: "Bagvogn",
reference: "TRAILER",
@@ -1240,6 +1243,54 @@ function expectBoxInside(innerBox, outerBox, label, tolerance = 1) {
}
test.describe("Invoicing period tab", () => {
test("@smoke @pr period view waits for an inline month selection when dates are absent", async ({ page }) => {
const periodRequests = [];
const selectedPeriodRequests = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (
request.method() === "GET" &&
matchesApiPath(request.url(), "/superuser/invoicing/period") &&
url.searchParams.has("periodView") &&
url.searchParams.get("periodWarm") !== "1"
) {
selectedPeriodRequests.push({
dateFrom: url.searchParams.get("dateFrom"),
dateTo: url.searchParams.get("dateTo"),
});
}
});
await page.clock.setFixedTime(new Date("2026-07-15T10:00:00.000Z"));
await seedInvoicesPage(page);
await setupPeriodEndpoints(page, periodRequests);
await page.goto("/superuser/invoices?activeTab=period", { waitUntil: "domcontentloaded" });
const monthSelector = page.getByTestId("invoicing-period-inline-month-selector");
await expect(monthSelector).toBeVisible({ timeout: periodRouteReadyTimeout });
await expect(monthSelector.locator(".datepicker-months .datepicker-cell")).toHaveCount(12);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toHaveAttribute("aria-disabled", "true");
await expect(page.getByTestId("date-period-start")).toHaveCount(0);
expect(selectedPeriodRequests).toHaveLength(0);
const julyOption = monthSelector.locator(".datepicker-months .datepicker-cell").filter({ hasText: /jul/i });
await expect(julyOption).toHaveCount(1);
await julyOption.click();
await expect
.poll(() => selectedPeriodRequests.at(-1))
.toEqual({
dateFrom: "2026-07-01",
dateTo: "2026-07-31",
});
await expect(page).toHaveURL(/startDate=2026-07-01/);
await expect(page).toHaveURL(/endDate=2026-07-31/);
await expect(page).toHaveURL(/periodView=all/);
await expect(page.getByTestId("invoicing-period-inline-month-selector")).toHaveCount(0);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toHaveAttribute("aria-disabled", "false");
});
test("@smoke @pr period view does not throw queue refresh errors on load", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => {
@@ -1547,6 +1598,9 @@ test.describe("Invoicing period tab", () => {
await customerRow.getByText("Acme Fleet").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
await expandTreeNode(page, "category:orders_without_collection:4001");
const referenceField = page.getByTestId("invoice-period-tree-field-order:9001-reference");
await referenceField.getByTestId("invoice-period-flag-badge").locator("button").first().click();
const orderFlag = page.getByTestId("invoice-period-flag-auto-order-reference-1");
await expect(orderFlag).toBeVisible();
await expect(orderFlag).toContainText("Ordren mangler en påkrævet reference.");
@@ -1571,7 +1625,19 @@ test.describe("Invoicing period tab", () => {
}
await expect(page.getByTestId("invoice-period-object-tree")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-node-category:orders_without_collection:4001")).toBeVisible();
await expandTreeNode(page, "category:orders_without_collection:4001");
await expandTreeNode(page, "order:9001");
const priceField = page.getByTestId("invoice-period-tree-field-order_item:7701-price");
const priceFlagButton = priceField.getByTestId("invoice-period-flag-badge").locator("button").first();
const priceActionWheel = page.getByTestId("invoice-period-tree-action-wheel-order_item:7701");
const [priceFlagBox, priceActionBox] = await Promise.all([
getBoundingBox(priceFlagButton, "price flag button"),
getBoundingBox(priceActionWheel, "price action wheel"),
]);
expect(boxesOverlap(priceFlagBox, priceActionBox), "price flag overlaps its row action wheel").toBe(false);
await priceFlagButton.click();
await expect(priceFlagButton).toHaveAttribute("aria-expanded", "true");
const automaticFlag = page.getByTestId("invoice-period-flag-auto-price-1");
await expect(automaticFlag).toBeVisible();
await automaticFlag.hover();
@@ -1641,6 +1707,15 @@ test.describe("Invoicing period tab", () => {
await customerRow.getByText("Acme Fleet").click();
await expect(page.getByTestId("invoicing-period-customer-expanded-4001")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-node-collected_order_invoice:16891")).toBeVisible();
await expandTreeNode(page, "collected_order_invoice:16891");
await expandTreeNode(page, "category:16891:collection_orders");
const collectionNode = page.getByTestId("invoice-period-tree-node-collected_order_invoice:16891");
await collectionNode
.locator(".invoice-period-tree-node__identity")
.getByTestId("invoice-period-flag-badge")
.locator("button")
.first()
.click();
const hiddenFlag = page.getByTestId("invoice-period-flag-auto-hidden-order-reference-1");
await expect(hiddenFlag).toBeVisible();
await expect(hiddenFlag).toContainText(
@@ -1733,19 +1808,44 @@ test.describe("Invoicing period tab", () => {
expect(Math.max(...wheelRightEdges) - Math.min(...wheelRightEdges)).toBeLessThanOrEqual(6);
await expect(page.getByTestId("invoice-period-tree-field-order:9001-notes")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-registrations")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-reg_2")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-reg_3")).toBeVisible();
await expect(page.getByTestId("invoice-period-tree-field-order:9001-reg_3")).toHaveCount(0);
await expect(page.getByTestId("invoice-period-tree-field-value-order:9001-notes")).toContainText(/Tom|Empty/i);
await expect(page.locator("[data-testid^='invoice-period-tree-field-empty-toggle-']")).toHaveCount(0);
await expect(page.getByTestId("invoice-period-tree-node-order:9001")).toContainText("Vask #9001");
await expect(page.locator("[data-testid$='-product_id']")).toHaveCount(0);
const fieldBoxes = await Promise.all(
["reference", "notes", "po", "reg_1", "reg_2", "reg_3", "include_in_invoice", "total_net_amount"].map((field) =>
["reference", "notes", "po", "registrations", "include_in_invoice", "total_net_amount"].map((field) =>
getBoundingBox(page.getByTestId(`invoice-period-tree-field-order:9001-${field}`), field)
)
);
const fieldTops = fieldBoxes.map((box) => Math.round(box.y));
expect(Math.max(...fieldTops) - Math.min(...fieldTops)).toBeLessThanOrEqual(2);
const quantityBox = await getBoundingBox(
page.getByTestId("invoice-period-tree-field-order_item:7701-quantity"),
"quantity"
);
const priceBox = await getBoundingBox(page.getByTestId("invoice-period-tree-field-order_item:7701-price"), "price");
expect(quantityBox.x + quantityBox.width).toBeLessThanOrEqual(priceBox.x + 2);
const alignedAmountBoxes = await Promise.all([
getBoundingBox(
page.getByTestId("invoice-period-tree-field-collected_order_invoice:3001-total_net_amount"),
"collection total"
),
getBoundingBox(page.getByTestId("invoice-period-tree-field-order:9001-total_net_amount"), "order total"),
getBoundingBox(page.getByTestId("invoice-period-tree-field-order_item:7701-price"), "item price"),
]);
const amountRightEdges = alignedAmountBoxes.map((box) => Math.round(box.x + box.width));
expect(Math.max(...amountRightEdges) - Math.min(...amountRightEdges)).toBeLessThanOrEqual(8);
await expandTreeNode(page, "order_item:7701");
await expect(page.locator('[data-node-key="order_item:7701"] [data-node-key="order_item:7702"]')).toBeVisible();
await expect(
page.getByTestId("invoice-period-tree-node-order_item:7701").locator(".invoice-period-tree-node__label-text")
).toHaveCSS("white-space", "normal");
const orderWheelRoot = page.getByTestId("invoice-period-tree-action-wheel-order:9001");
await orderWheelRoot.locator(".action-settings-wheel-trigger").click();
await expect(orderWheelRoot.getByTestId("action-settings-wheel-section-order")).toBeVisible();
@@ -1822,20 +1922,24 @@ test.describe("Invoicing period tab", () => {
await expect(duplicateSelector).toContainText(/Mulige|Possible/i);
await expect(duplicateSelector).toContainText("(0/1)");
const duplicateGroup = page.getByTestId("invoicing-period-duplicate-group-EC21233-2026-05-11");
const duplicateGroup = page.getByTestId(/^invoicing-period-duplicate-group-EC21233-\d{4}-\d{2}-\d{2}$/);
await expect(duplicateGroup).toBeVisible();
const duplicateGroupTestId = await duplicateGroup.getAttribute("data-testid");
const duplicateGroupKey = duplicateGroupTestId?.replace("invoicing-period-duplicate-group-", "");
expect(duplicateGroupKey).toMatch(/^EC21233-\d{4}-\d{2}-\d{2}$/);
const duplicateDate = duplicateGroupKey?.replace("EC21233-", "");
await expect(page.getByTestId("invoicing-period-customer-7201")).toHaveCount(0);
await expect(page.getByTestId("invoicing-period-customer-7202")).toHaveCount(0);
await expect(duplicateGroup).toContainText("EC21233");
await expect(duplicateGroup).toContainText("2 kunder");
await expect(duplicateGroup).toContainText("2 vaskelog");
await expect(duplicateGroup).not.toContainText("EC21233 - 11/05/2026");
await expect(duplicateGroup.getByText(/^EC21233\s+-\s+/)).toHaveCount(0);
await expect(duplicateGroup).toContainText("Pleno Vognmandsforretning #7201");
await expect(duplicateGroup).toContainText("Estland Alle ApS #7202");
await expect(page.getByTestId("invoicing-period-duplicate-group-SINGLE1-2026-05-11")).toHaveCount(0);
await expect(page.getByTestId(`invoicing-period-duplicate-group-SINGLE1-${duplicateDate}`)).toHaveCount(0);
const plateTag = page.getByTestId("invoicing-period-duplicate-plate-tag-EC21233-2026-05-11");
const dateTag = page.getByTestId("invoicing-period-duplicate-date-tag-EC21233-2026-05-11");
const plateTag = page.getByTestId(`invoicing-period-duplicate-plate-tag-${duplicateGroupKey}`);
const dateTag = page.getByTestId(`invoicing-period-duplicate-date-tag-${duplicateGroupKey}`);
const plateBox = await plateTag.boundingBox();
const dateBox = await dateTag.boundingBox();
expect(plateBox).not.toBeNull();
@@ -1844,12 +1948,12 @@ test.describe("Invoicing period tab", () => {
expect(dateBox?.y ?? 0).toBeGreaterThan(plateBox?.y ?? 0);
await duplicateGroup.click();
const expandedGroup = page.getByTestId("invoicing-period-duplicate-group-expanded-EC21233-2026-05-11");
const expandedGroup = page.getByTestId(`invoicing-period-duplicate-group-expanded-${duplicateGroupKey}`);
await expect(expandedGroup.locator("[data-testid='pagination-search-input']")).toHaveCount(0);
await expect(expandedGroup.locator("[data-testid='pagination-reload-actions']")).toHaveCount(0);
await expect(expandedGroup.locator("nav.pagination")).toHaveCount(0);
await expect(expandedGroup.getByText(/Side\s+\d+\s+af/i)).toHaveCount(0);
const comparison = page.getByTestId("invoicing-period-duplicate-comparison-EC21233-2026-05-11");
const comparison = page.getByTestId(`invoicing-period-duplicate-comparison-${duplicateGroupKey}`);
await expect(comparison).toBeVisible();
await expect(comparison.getByTestId("invoicing-period-duplicate-comparison-row-9701")).toContainText(
"Pleno Vognmandsforretning #7201"
@@ -2392,7 +2496,7 @@ test.describe("Invoicing period tab", () => {
);
});
await page.goto("/superuser/invoices?activeTab=period");
await page.goto("/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all");
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 15_000 });
await page.getByTestId("invoicing-period-view-selector-all").click();
const visibleCustomerOrder = () =>
@@ -2541,8 +2645,6 @@ test.describe("Invoicing period tab", () => {
const { periodRequests } = await openPeriodView(page);
await expect(page.getByTestId("invoicing-period-reload-button")).toBeVisible();
await page.getByTestId("invoicing-period-view-selector-all").click();
await expect(page.getByTestId("invoicing-period-page-reload-button")).toBeVisible();
await expect(page.getByTestId("invoicing-period-reload-button")).toHaveCount(0);
const initialRequestCount = periodRequests.length;
@@ -2823,7 +2925,7 @@ test.describe("Invoicing period tab", () => {
);
});
await page.goto("/superuser/invoices?activeTab=period");
await page.goto("/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all");
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 15_000 });
@@ -2976,7 +3078,7 @@ test.describe("Invoicing period tab", () => {
await expect(page).toHaveURL(/activeTab=overview/);
returnQueuedPayload = true;
await page.goto("/superuser/invoices?activeTab=period");
await page.goto("/superuser/invoices?activeTab=period&startDate=2026-07-01&endDate=2026-07-31&periodView=all");
await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({
timeout: periodRouteReadyTimeout,
+108 -31
View File
@@ -279,7 +279,18 @@ test("only tankcleaning customers can only add tankcleaning products", async ({
};
const fixture = createPosFixture({
customerAttributesByNumber: {
12345679: [{ id: 1, customer_number: 12345679, attribute: "onlyTankCleaning" }],
12345679: [
{
id: 1,
customer_number: 12345679,
attribute: "onlyTankCleaning",
product_restriction: {
version: 1,
disabled_product_ids: [53],
collections: [{ id: 501, name: "Legacy migration", product_ids: [53] }],
},
},
],
},
products: [...baseFixture.products, tankCleaningProduct],
departmentCategories: [
@@ -311,6 +322,10 @@ test("only tankcleaning customers can only add tankcleaning products", async ({
await page.getByTestId("pos-product-card-53").first().click();
await expect(page.getByTestId("pos-add-to-cart-53").first()).toBeDisabled();
await page.getByTestId("pos-add-to-cart-restriction-tooltip-53").hover();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Produktet er ikke tilladt for denne kunde"
);
await page.locator(".tabs li").filter({ hasText: "Tankcleaning" }).click();
await expect(page.getByTestId("pos-product-card-66").first()).toBeVisible();
@@ -325,7 +340,7 @@ test("only tankcleaning customers can only add tankcleaning products", async ({
expect(request.postDataJSON().product_id).toBe(66);
});
test("restricted customer sees disabled desktop add-ons without placeholder basket rows", async ({
test("exact customer-rule configuration disables desktop add-on controls by option_id without placeholder rows", async ({
page,
}, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
@@ -338,9 +353,14 @@ test("restricted customer sees disabled desktop add-ons without placeholder bask
...baseFixture.products.find((product) => Number(product.id) === 63),
addons: [],
};
const allowedLookalikeProduct = {
...addonProduct,
id: 64,
name: "Allowed additional-service lookalike",
};
primaryProduct.addons = [
{
id: addonProduct.id,
id: 9001,
option_id: addonProduct.id,
name: addonProduct.name,
price: addonProduct.price,
@@ -348,15 +368,36 @@ test("restricted customer sees disabled desktop add-ons without placeholder bask
min: 0,
max: 1,
},
{
id: 9002,
option_id: allowedLookalikeProduct.id,
name: allowedLookalikeProduct.name,
price: allowedLookalikeProduct.price,
product: allowedLookalikeProduct,
min: 0,
max: 1,
},
];
const fixture = createPosFixture({
customerAttributesByNumber: {
12345679: [{ id: 1, customer_number: 12345679, attribute: "restrictAdditionalServices" }],
12345679: [
{
id: 1,
customer_number: 12345679,
attribute: "restrictAdditionalServices",
product_restriction: {
version: 1,
disabled_product_ids: [addonProduct.id],
collections: [{ id: 502, name: "Blocked add-ons", product_ids: [addonProduct.id] }],
},
},
],
},
products: [
primaryProduct,
addonProduct,
...baseFixture.products.filter((product) => ![53, 63].includes(Number(product.id))),
allowedLookalikeProduct,
...baseFixture.products.filter((product) => ![53, 63, 64].includes(Number(product.id))),
],
});
const customer = fixture.customersByNumber[12345679];
@@ -383,43 +424,78 @@ test("restricted customer sees disabled desktop add-ons without placeholder bask
await openPosAndSelectCustomer(page, customer);
await customerAttributesResponse;
await page.locator('[data-testid="pos-customer-tab-rules"]:visible').first().click();
const ruleTooltip = page
.locator('[data-testid="pos-customer-rule-tooltip-restrictAdditionalServices"]:visible')
.first();
await expect(ruleTooltip).toBeVisible();
const productsResponse = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
response.url().includes("/products") &&
response.url().includes("final_price=false")
);
await ruleTooltip.hover();
await productsResponse;
const tooltipContent = page
.locator('[data-testid="pos-customer-rule-tooltip-restrictAdditionalServices-content"]')
.first();
await expect(tooltipContent).toContainText(addonProduct.name, { timeout: 10_000 });
await expect(tooltipContent).toContainText("Vaskecertifikat - Safety Seal");
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();
const restrictedAddon = page.getByTestId("pos-addon-restriction-63");
await expect(restrictedAddon).toBeVisible({ timeout: 10_000 });
await expect(restrictedAddon).toContainText("Tilvalg er ikke tilladt for denne kunde");
await expect(restrictedAddon.locator("button")).toBeDisabled();
await expect(page.getByTestId("pos-addon-restriction-63")).toBeVisible();
await expect(page.getByTestId("pos-addon-63-increase")).toBeDisabled();
await expect(page.getByTestId("pos-addon-63-name")).toBeDisabled();
await expect(page.getByTestId("pos-addon-63-decrease")).toBeDisabled();
await page.getByTestId("pos-addon-restriction-tooltip-63").hover();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Tilvalg er ikke tilladt for denne kunde"
);
for (const testId of ["pos-addon-63-increase", "pos-addon-63-name", "pos-addon-63-decrease"]) {
await page.getByTestId(testId).evaluate((button) => button.click());
}
expect(orderItemPosts).toEqual([]);
const allowedAddonButton = page.getByTestId("pos-addon-64-name");
await expect(allowedAddonButton).toBeEnabled();
await allowedAddonButton.click();
await page.getByTestId("pos-add-to-cart-53").first().click();
await expect.poll(() => orderItemPosts.length, { timeout: 10_000 }).toBe(1);
expect(orderItemPosts.map((body) => Number(body.product_id))).toEqual([53]);
await expect.poll(() => orderItemPosts.length, { timeout: 10_000 }).toBe(2);
expect(orderItemPosts.map((body) => Number(body.product_id))).toEqual([53, 64]);
expect(Number(orderItemPosts[1].related_item_id)).toBeGreaterThan(0);
const cartPanel = page.getByTestId("pos-step-2").getByTestId("pos-order-panel-cart");
await expect(cartPanel).not.toContainText("Ingen data");
await expect(cartPanel).not.toContainText("0 DKK");
});
test("desktop remains fail-closed when an active product rule has a malformed restriction payload", async ({
page,
}, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
const fixture = createPosFixture({
customerAttributesByNumber: {
12345679: [{ id: 1, customer_number: 12345679, attribute: "restrictSpotFree" }],
},
});
const customer = fixture.customersByNumber[12345679];
const orderItemPosts = [];
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: fixture,
});
page.on("request", (request) => {
if (request.method() === "POST" && request.url().includes("/order/items")) {
orderItemPosts.push(request.postDataJSON());
}
});
await primeOperatorSession(page, "pos-malformed-customer-restrictions-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 expect(page.getByTestId("pos-customer-restrictions-load-error")).toBeVisible();
await page.getByTestId("pos-product-card-53").first().click();
await expect(page.getByTestId("pos-add-to-cart-53").first()).toBeDisabled();
await page
.getByTestId("pos-add-to-cart-53")
.first()
.evaluate((button) => button.click());
expect(orderItemPosts).toEqual([]);
});
test("desktop clears pending basket row when customer-rule create is rejected", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop only");
@@ -446,6 +522,7 @@ test("desktop clears pending basket row when customer-rule create is rejected",
{
success: false,
data: {
code: "CUSTOMER_RULE_PRODUCT_RESTRICTED",
message: "This product is not allowed for the selected customer",
},
},
+158 -12
View File
@@ -3080,7 +3080,7 @@ test.describe("POS mobile order flow", () => {
]);
});
test("copy previous wash skips restricted addon and additional items", async ({ page }) => {
test("copy previous wash keeps related add-ons and skips standalone additional services", async ({ page }) => {
const orderId = 9416;
const fixture = createMobilePosFixture({
customerAttributesByNumber: {
@@ -3089,6 +3089,11 @@ test.describe("POS mobile order flow", () => {
id: 941601,
customer_number: REGULAR_CUSTOMER_ID,
attribute: "restrictAdditionalServices",
product_restriction: {
version: 1,
disabled_product_ids: [91],
collections: [{ id: 94161, name: "Standalone services", product_ids: [91] }],
},
},
],
},
@@ -3192,19 +3197,22 @@ test.describe("POS mobile order flow", () => {
.toEqual({
primaryId: 53,
addons: [
{
id: 41,
quantity: 0,
restricted: true,
},
{
id: 71,
quantity: 0,
restricted: true,
quantity: 2,
restricted: false,
},
],
additionalItems: [],
});
await page.getByTestId("pos-mobile-next-step").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(2);
const primaryCreate = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 53);
const addonCreate = fixture.requestLog.orderItemCreates.find((entry) => Number(entry.product_id) === 71);
expect(primaryCreate?.related_item_id ?? null).toBeNull();
expect(Number(addonCreate?.related_item_id ?? 0)).toBeGreaterThan(0);
expect(fixture.requestLog.orderItemCreates.some((entry) => Number(entry.product_id) === 91)).toBe(false);
});
test("step 2 registration popup flushes edits on close and survives reload", async ({ page }) => {
@@ -3423,7 +3431,7 @@ test.describe("POS mobile order flow", () => {
expect(createdProductIds).toEqual([53, 71, 91]);
});
test("manual step 2 shows restricted addons and additional items as disabled", async ({ page }) => {
test("manual step 2 disables exact add-on and standalone product controls with tap tooltips", async ({ page }) => {
const orderId = 9414;
const fixture = createMobilePosFixture({
customerAttributesByNumber: {
@@ -3432,6 +3440,11 @@ test.describe("POS mobile order flow", () => {
id: 941401,
customer_number: REGULAR_CUSTOMER_ID,
attribute: "restrictAdditionalServices",
product_restriction: {
version: 1,
disabled_product_ids: [71, 91],
collections: [{ id: 94141, name: "Blocked mobile products", product_ids: [71, 91] }],
},
},
],
},
@@ -3469,12 +3482,28 @@ test.describe("POS mobile order flow", () => {
const addonRow = page.getByTestId("pos-mobile-addon-71");
await expect(addonRow).toBeVisible({ timeout: 10_000 });
await expect(addonRow).toContainText("Tilvalg er ikke tilladt for denne kunde", { timeout: 10_000 });
await addonRow.click();
await expect(page.getByTestId("pos-mobile-addon-71-value")).toHaveCount(0);
await expect(addonRow).toBeDisabled();
await expect(page.getByTestId("pos-mobile-addon-71-decrease")).toBeDisabled();
await expect(page.getByTestId("pos-mobile-addon-71-increase")).toBeDisabled();
await page.getByTestId("pos-mobile-addon-71-restriction-tooltip").click();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Tilvalg er ikke tilladt for denne kunde"
);
for (const testId of ["pos-mobile-addon-71-decrease", "pos-mobile-addon-71", "pos-mobile-addon-71-increase"]) {
await page.getByTestId(testId).evaluate((button) => button.click());
}
expect(fixture.requestCounters.orderItemsPost).toBe(0);
await longPressAdditionalItems(page);
await expect(page.getByTestId("pos-mobile-additional-items-selection")).toBeVisible({ timeout: 10_000 });
const restrictedAdditionalItem = page.getByTestId("pos-mobile-product-91");
await expect(restrictedAdditionalItem).toBeVisible({ timeout: 10_000 });
await expect(restrictedAdditionalItem).toBeDisabled();
await page.getByTestId("pos-mobile-product-91-restriction-tooltip").click();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Tilvalg er ikke tilladt for denne kunde"
);
await expect
.poll(
async () => {
@@ -3498,6 +3527,123 @@ test.describe("POS mobile order flow", () => {
});
});
test("exact primary-product restriction blocks mobile selection while an unconfigured lookalike remains enabled", async ({
page,
}) => {
const orderId = 9417;
const fixture = createMobilePosFixture({
customerAttributesByNumber: {
[REGULAR_CUSTOMER_ID]: [
{
id: 941701,
customer_number: REGULAR_CUSTOMER_ID,
attribute: "onlyTankCleaning",
product_restriction: {
version: 1,
disabled_product_ids: [53],
collections: [{ id: 94171, name: "Blocked primary products", product_ids: [53] }],
},
},
],
},
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "STEP2-RESTRICT-PRIMARY",
reg_1: "ZZ00000",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-step2-restrict-primary-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "ZZ00000",
reference: "STEP2-RESTRICT-PRIMARY",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect.poll(() => fixture.requestCounters.customerAttributesGet, { timeout: 10_000 }).toBeGreaterThan(0);
await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-category-4").click();
await page.waitForTimeout(800);
const blockedPrimary = page.getByTestId("pos-mobile-product-53");
await expect(blockedPrimary).toBeDisabled();
await page.getByTestId("pos-mobile-product-53-restriction-tooltip").click();
await expect(page.locator(".tooltip-content:visible").last()).toContainText(
"Produktet er ikke tilladt for denne kunde"
);
await blockedPrimary.evaluate((button) => button.click());
await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible();
expect(fixture.requestCounters.orderItemsPost).toBe(0);
const allowedPrimary = page.getByTestId("pos-mobile-product-63");
await expect(allowedPrimary).toBeEnabled();
await allowedPrimary.click();
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-next-step").click();
await expect.poll(() => fixture.requestCounters.orderItemsPost, { timeout: 10_000 }).toBe(1);
expect(Number(fixture.requestLog.orderItemCreates[0]?.product_id)).toBe(63);
});
test("customer-rule load failures keep mobile product mutations disabled until retry succeeds", async ({ page }) => {
const orderId = 9418;
const fixture = createMobilePosFixture({
failureBudget: {
customerAttributesGet: 2,
},
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reference: "STEP2-RULE-RETRY",
reg_1: "ZZ00000",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-step2-rule-retry-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "ZZ00000",
reference: "STEP2-RULE-RETRY",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-customer-restrictions-load-error")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-category-4").click();
await page.waitForTimeout(800);
await expect(page.getByTestId("pos-mobile-product-53")).toBeDisabled();
expect(fixture.requestCounters.orderItemsPost).toBe(0);
await page.getByTestId("pos-mobile-customer-restrictions-retry").click();
await expect.poll(() => fixture.requestCounters.customerAttributesGet, { timeout: 10_000 }).toBeGreaterThan(1);
await expect(page.getByTestId("pos-mobile-customer-restrictions-load-error")).toHaveCount(0);
await expect(page.getByTestId("pos-mobile-product-53")).toBeEnabled();
});
test("clear all deletes the order and returns to the scanner", async ({ page }) => {
const orderId = 9403;
const fixture = createMobilePosFixture({
+236
View File
@@ -0,0 +1,236 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
test.use({ serviceWorkers: "block" });
const json = (body: unknown, status = 200) => ({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
const products = [
{ id: 23, name: "RO rinse", category_name: "Exterior" },
{ id: 24, name: "Premium rinse", category_name: "Exterior" },
{ id: 63, name: "Interior add-on", category_name: "Add-ons" },
{ id: 91, name: "Extra detergent", category_name: "Additional services", is_active: false },
];
const productRuleAttributes = [
"restrictAdditionalServices",
"restrictTankCleaning",
"restrictSpotFree",
"restrictInteriorCleaning",
"onlyTankCleaning",
];
type Collection = {
id: number;
name: string;
sort_order: number;
product_ids: number[];
};
type Rule = {
attribute: string;
version: number;
collections: Collection[];
disabled_product_ids: number[];
};
const createInitialRules = (): Rule[] =>
productRuleAttributes.map((attribute, index) => ({
attribute,
version: 1,
collections: [
{
id: index + 5,
name: attribute === "restrictSpotFree" ? "Legacy rinses" : "Legacy migration",
sort_order: 0,
product_ids: attribute === "restrictSpotFree" ? [23] : [],
},
],
disabled_product_ids: attribute === "restrictSpotFree" ? [23] : [],
}));
const restrictionApiPattern = /\/superuser\/customer-rules\/product-restrictions(?:\/([^/?#]+))?(?:[?#].*)?$/i;
const setup = async (
page: Page,
permissions: string[],
initialPutMode: "success" | "conflict" | "invalid" = "success",
groupId = 2
) => {
await page.addInitScript(() => window.localStorage.setItem("locale", "en"));
await mockApi(page, {
authenticated: true,
permissions,
sessionData: { group_id: groupId },
});
await primeMockSession(page, { token: "customer-rule-configuration-token", bootPath: null });
let rules = createInitialRules();
let nextCollectionId = 100;
let putMode = initialPutMode;
let getRequests = 0;
const putPayloads: Array<Record<string, unknown>> = [];
await page.route(restrictionApiPattern, async (route: Route) => {
const method = route.request().method();
const match = new URL(route.request().url()).pathname.match(restrictionApiPattern);
const attribute = match?.[1] ? decodeURIComponent(match[1]) : null;
if (method === "GET" && !attribute) {
getRequests += 1;
await route.fulfill(json({ success: true, data: { rules, products } }));
return;
}
if (method !== "PUT" || !attribute) {
await route.fallback();
return;
}
const payload = route.request().postDataJSON() as {
version: number;
collections: Array<Partial<Collection>>;
};
putPayloads.push(payload as unknown as Record<string, unknown>);
if (putMode === "conflict") {
putMode = "success";
await route.fulfill(json({ success: false, message: "Stale version" }, 409));
return;
}
if (putMode === "invalid") {
putMode = "success";
await route.fulfill(json({ success: false, message: "Invalid product membership" }, 422));
return;
}
const current = rules.find((rule) => rule.attribute === attribute);
const collections = payload.collections.map((collection, index) => ({
id: Number(collection.id) > 0 ? Number(collection.id) : nextCollectionId++,
name: String(collection.name),
sort_order: index,
product_ids: (collection.product_ids || []).map(Number),
}));
const saved: Rule = {
attribute,
version: (current?.version ?? payload.version) + 1,
collections,
disabled_product_ids: [...new Set(collections.flatMap((collection) => collection.product_ids))],
};
rules = rules.map((rule) => (rule.attribute === attribute ? saved : rule));
await route.fulfill(json({ success: true, data: saved }));
});
return {
getRequests: () => getRequests,
putPayloads,
setPutMode: (mode: "success" | "conflict" | "invalid") => {
putMode = mode;
},
};
};
test.describe("Superuser customer rule product restrictions", () => {
test("creates, renames, deletes, and persists exact product collections", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop-focused global configuration coverage.");
const api = await setup(page, ["superuser", "superuser_customer_rules_view", "superuser_customer_rules_manage"]);
await page.goto("/superuser/customer-rules", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("superuser-customer-rules-page")).toBeVisible();
await expect(page.getByTestId("customer-rules-global-warning")).toBeVisible();
await expect.poll(() => api.getRequests()).toBeGreaterThan(0);
const rule = page.getByTestId("customer-rule-config-restrictSpotFree");
await expect(rule).toBeVisible();
await expect(rule.getByTestId("customer-rule-collection-restrictSpotFree-7")).toBeVisible();
await rule.getByTestId("customer-rule-collection-name-restrictSpotFree-7").fill("Premium rinses");
await rule.getByTestId("customer-rule-product-restrictSpotFree-7-24").check();
await rule.getByTestId("customer-rule-add-collection-restrictSpotFree").click();
await rule.getByTestId("customer-rule-collection-name-restrictSpotFree--1").fill("Archived services");
await rule.getByTestId("customer-rule-product-restrictSpotFree--1-91").check();
await rule.getByTestId("customer-rule-collection-move-up-restrictSpotFree--1").click();
await expect(rule).toContainText("3 disabled products");
await rule.getByTestId("customer-rule-save-restrictSpotFree").click();
await expect.poll(() => api.putPayloads.length).toBe(1);
expect(api.putPayloads[0]).toEqual({
version: 1,
collections: [
{ name: "Archived services", sort_order: 0, product_ids: [91] },
{ id: 7, name: "Premium rinses", sort_order: 1, product_ids: [23, 24] },
],
});
await expect(rule).toContainText("Version 2");
await rule.getByTestId("customer-rule-collection-delete-restrictSpotFree-100").click();
await rule.getByTestId("customer-rule-save-restrictSpotFree").click();
await expect.poll(() => api.putPayloads.length).toBe(2);
await page.reload({ waitUntil: "domcontentloaded" });
const reloadedRule = page.getByTestId("customer-rule-config-restrictSpotFree");
await expect(reloadedRule.getByTestId("customer-rule-collection-name-restrictSpotFree-7")).toHaveValue(
"Premium rinses"
);
await expect(reloadedRule.getByTestId("customer-rule-product-restrictSpotFree-7-24")).toBeChecked();
await expect(reloadedRule.getByTestId("customer-rule-collection-restrictSpotFree-100")).toHaveCount(0);
await expect(reloadedRule).toContainText("2 disabled products");
});
test("separates view-only, root-superuser, and denied access", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop-focused global configuration coverage.");
await setup(page, ["superuser", "superuser_customer_rules_view"]);
await page.goto("/superuser/customer-rules", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("customer-rules-read-only")).toBeVisible();
await expect(page.getByTestId("customer-rule-collection-name-restrictSpotFree-7")).toBeDisabled();
await expect(page.getByTestId("customer-rule-add-collection-restrictSpotFree")).toHaveCount(0);
const rootPage = await page.context().newPage();
await setup(rootPage, ["superuser"], "success", 1);
await rootPage.goto("/superuser/customer-rules", { waitUntil: "domcontentloaded" });
await expect(rootPage.getByTestId("customer-rule-collection-name-restrictSpotFree-7")).toBeEnabled();
await expect(rootPage.getByTestId("customer-rule-add-collection-restrictSpotFree")).toBeVisible();
await rootPage.close();
const deniedPage = await page.context().newPage();
const deniedApi = await setup(deniedPage, ["superuser"]);
await deniedPage.goto("/superuser/customer-rules", { waitUntil: "domcontentloaded" });
await expect(deniedPage.getByText("403 Forbidden", { exact: true }).first()).toBeVisible();
expect(deniedApi.getRequests()).toBe(0);
await deniedPage.close();
});
test("preserves drafts on conflicts and validation failures until an explicit reload", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop-focused global configuration coverage.");
const api = await setup(
page,
["superuser", "superuser_customer_rules_view", "superuser_customer_rules_manage"],
"conflict"
);
await page.goto("/superuser/customer-rules", { waitUntil: "domcontentloaded" });
const name = page.getByTestId("customer-rule-collection-name-restrictSpotFree-7");
await name.fill("Conflicting draft");
await page.getByTestId("customer-rule-save-restrictSpotFree").click();
await expect(page.getByTestId("customer-rule-conflict-restrictSpotFree")).toBeVisible();
await expect(name).toHaveValue("Conflicting draft");
await expect(page.getByTestId("customer-rule-save-restrictSpotFree")).toBeDisabled();
await page.getByTestId("customer-rule-conflict-restrictSpotFree").getByRole("button").click();
await expect(name).toHaveValue("Legacy rinses");
api.setPutMode("invalid");
await name.fill("Invalid draft");
await page.getByTestId("customer-rule-save-restrictSpotFree").click();
await expect(page.getByTestId("customer-rule-save-error-restrictSpotFree")).toContainText(
"Invalid product membership"
);
await expect(name).toHaveValue("Invalid draft");
});
});
@@ -58,6 +58,8 @@ test.describe("superuser order date filters", () => {
});
const orderRequests: Array<{ filters: string; limit: number }> = [];
const latestTableRequest = () =>
[...orderRequests].reverse().find((request) => request.limit > 1 && request.limit < 1000);
await page.route(apiPathPattern("/orders"), async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/orders") && route.request().method() === "GET") {
@@ -207,11 +209,10 @@ test.describe("superuser order date filters", () => {
await expect(toInput).toHaveValue("");
await expect
.poll(() => {
const latestTableRequest = [...orderRequests].reverse().find((request) => request.limit !== 1000);
const request = latestTableRequest();
return Boolean(
latestTableRequest?.filters.includes("created_at-date_from:2026-07-02") &&
!latestTableRequest.filters.includes("processor:2")
request?.filters.includes("created_at-date_from:2026-07-02") && !request.filters.includes("processor:2")
);
})
.toBeTruthy();
@@ -230,8 +231,8 @@ test.describe("superuser order date filters", () => {
await expect(fromInput).toHaveValue("");
await expect
.poll(() => {
const lastTableRequest = [...orderRequests].reverse().find((request) => request.limit !== 1000);
return lastTableRequest?.filters.includes("created_at-date_from:") ?? true;
const request = latestTableRequest();
return request?.filters.includes("created_at-date_from:") ?? true;
})
.toBe(false);
});
+4
View File
@@ -1086,6 +1086,10 @@ test.describe("Superuser user overview", () => {
/Invoice per order|Fakturer alle ordrer enkeltvis/
);
await expect(page.getByTestId("superuser-user-security-rule-toggle-invoiceAllOrdersIndividually")).toBeChecked();
await expect(page.getByTestId("superuser-user-security-global-rule-configuration")).toHaveAttribute(
"href",
"/superuser/customer-rules"
);
await page.locator('label[for="superuser-user-security-rule-toggle-restrictSpotFree"]').click();
await expect
+20
View File
@@ -391,6 +391,7 @@ function createFailureBudget(overrides = {}) {
bookingComplete: 0,
attachmentUpload: 0,
orderDelete: 0,
customerAttributesGet: 0,
...overrides,
};
}
@@ -1717,6 +1718,25 @@ export async function mockMobilePosApi(page, fixture) {
if (pathname.endsWith("/customer/attributes") && method === "GET") {
recordCounter(fixture, "customerAttributesGet");
const delayMs = Number(fixture.customerAttributesDelayMs ?? 0);
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
if (fixture.failureBudget.customerAttributesGet > 0) {
fixture.failureBudget.customerAttributesGet -= 1;
await route.fulfill(
json(
{
success: false,
data: { message: "Unable to load customer attributes" },
},
500
)
);
return;
}
const customerNumber =
toPositiveInteger(parsedUrl.searchParams.get("customer_number")) ??
toPositiveInteger(parsedUrl.searchParams.get("id")) ??
@@ -0,0 +1,53 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const departmentOrdersPaginationSource = readFileSync(
join(root, "src/components/displays/pagination/models/DepartmentPos/OrdersPagination.vue"),
"utf8"
);
const invoiceOrdersPaginationSource = readFileSync(
join(root, "src/components/displays/pagination/models/SuperUserDashboard/InvoiceOrdersPagination.vue"),
"utf8"
);
const tableLabeledPaginationSource = readFileSync(
join(root, "src/components/displays/pagination/TableLabeledPagination.vue"),
"utf8"
);
describe("admin orders pagination filter reuse", () => {
it("delegates department order lists to the shared filtered pagination", () => {
expect(departmentOrdersPaginationSource).toContain("<InvoiceOrdersPagination");
expect(departmentOrdersPaginationSource).toContain(':apply-default-filters="false"');
expect(departmentOrdersPaginationSource).toContain(':show-department-filter="false"');
expect(departmentOrdersPaginationSource).toContain(':show-label="false"');
expect(departmentOrdersPaginationSource).toContain(':invoice-view="false"');
expect(departmentOrdersPaginationSource).toContain(':allow-select-multiple="false"');
});
it("forwards the history and draft constraints through the adapter", () => {
expect(departmentOrdersPaginationSource).toContain(
':only-from-invoice-collection="props.onlyFromInvoiceCollection"'
);
expect(departmentOrdersPaginationSource).toContain(':set-customer-filter="props.setCustomerFilter"');
expect(departmentOrdersPaginationSource).toContain(
':show-draft-assignment-actions="props.showDraftAssignmentActions"'
);
expect(departmentOrdersPaginationSource).toContain(':hide-search="props.hideSearch"');
expect(departmentOrdersPaginationSource).toContain(':auto-load="props.autoLoad"');
});
it("keeps the shared pagination configurable without changing superuser defaults", () => {
expect(invoiceOrdersPaginationSource).toContain("showDepartmentFilter:");
expect(invoiceOrdersPaginationSource).toContain("showLabel:");
expect(invoiceOrdersPaginationSource).toContain("allowSelectMultiple:");
expect(invoiceOrdersPaginationSource).toContain('v-if="props.showDepartmentFilter"');
expect(invoiceOrdersPaginationSource).toContain(':show-label="props.showLabel"');
expect(invoiceOrdersPaginationSource).toContain(':allowSelectMultiple="props.allowSelectMultiple"');
expect(invoiceOrdersPaginationSource).toContain("const currentDepartmentId = resolveDepartmentId();");
expect(invoiceOrdersPaginationSource).toContain('setFilter("department_id", currentDepartmentId, false);');
expect(tableLabeledPaginationSource).toContain("showLabel:");
expect(tableLabeledPaginationSource).toContain('v-if="props.showLabel"');
});
});
+17
View File
@@ -74,6 +74,23 @@ describe("authenticatedRequest", () => {
localStorage.setItem("token", "token");
});
it("passes an explicit blob response type to axios", async () => {
const blob = new Blob(["attachment"], { type: "application/pdf" });
axiosMock.mockResolvedValueOnce({ status: 200, data: blob });
const response = await authenticatedRequest(
"/orders/attachments/content",
"GET",
{ order_id: 42, attachment_id: 301, disposition: "inline" },
null,
null,
{ responseType: "blob" }
);
expect(response.data).toBe(blob);
expect(axiosMock).toHaveBeenCalledWith(expect.objectContaining({ responseType: "blob" }));
});
it("resolves scanner lpr no-plate responses without recording a failed request", async () => {
const response = {
status: 200,
+32
View File
@@ -61,4 +61,36 @@ describe("BuefyTree checkbox selection", () => {
expect(load).toHaveBeenCalledTimes(1);
expect(wrapper.emitted("update:checkedKeys")?.at(-1)?.[0]).toEqual([]);
});
it("renders progressive batches while selecting every logical child", async () => {
const children = Array.from({ length: 1000 }, (_, index) => ({
id: `order:${index + 1}`,
label: `Order ${index + 1}`,
selectable: true,
isLeaf: true,
}));
const wrapper = mount(BuefyTree, {
props: {
data: [{ id: "category:orders", label: "Orders", selectable: false, checkable: true, isLeaf: false }],
selectionMode: "checkbox",
lazy: true,
progressiveBatchSize: 50,
loadMoreLabel: "Show {count} more",
load: vi.fn(async () => children),
},
});
await wrapper.find(".b-tree-node-toggle").trigger("click");
await flushPromises();
expect(wrapper.findAll('[data-node-key^="order:"]')).toHaveLength(50);
expect(wrapper.get(".b-tree-load-more__button").text()).toContain("Show 50 more");
await wrapper.get(".b-tree-load-more__button").trigger("click");
expect(wrapper.findAll('[data-node-key^="order:"]')).toHaveLength(100);
await wrapper.find(".b-checkbox-stub").trigger("click");
await flushPromises();
expect(wrapper.emitted("update:checkedKeys")?.at(-1)?.[0]).toHaveLength(1000);
expect(wrapper.findAll('[data-node-key^="order:"]')).toHaveLength(100);
});
});
+121 -122
View File
@@ -1,156 +1,155 @@
import { describe, expect, it } from "vitest";
import {
getCustomerProductRestriction,
isProductCategoryRestrictedForCustomer,
getProductCategoryRestrictionForCustomer,
hasValidCustomerProductRestrictionContract,
isProductRestrictedForCustomer,
isSpotFreeProduct,
isTankCleaningProduct,
normalizeCustomerAttributeProductRestriction,
} from "@/features/customer/customerProductRules.js";
import { getCustomerRuleProductImpact } from "@/features/customer/customerRuleProductImpact.js";
const onlyTankCleaning = [{ attribute: "onlyTankCleaning" }];
const restrictTankCleaning = [{ attribute: "restrictTankCleaning" }];
const restrictAdditionalServices = [{ attribute: "restrictAdditionalServices" }];
const attribute = (name, productIds, collections = []) => ({
attribute: name,
product_restriction: {
version: 3,
disabled_product_ids: productIds,
collections,
},
});
describe("customer product rules", () => {
it("recognizes tankcleaning products by category and legacy names", () => {
expect(isTankCleaningProduct({ category: 5, name: "Saebe/kemi" })).toBe(true);
expect(isTankCleaningProduct({ category: 4, name: "Tank cleaning 4 spulehoveder" })).toBe(true);
expect(isTankCleaningProduct({ category: 4, name: "Saebe/kemi", category_name: "Tankrens" })).toBe(true);
expect(isTankCleaningProduct({ category: 4, name: "Forvogn" })).toBe(false);
it("matches only configured product ids and never infers from category or name", () => {
const attributes = [attribute("restrictTankCleaning", [52])];
expect(isProductRestrictedForCustomer({ id: "52", category: 4, name: "Ordinary wash" }, attributes)).toBe(true);
expect(isProductRestrictedForCustomer({ id: 53, category: 5, name: "Tank cleaning" }, attributes)).toBe(false);
expect(isProductRestrictedForCustomer({ id: 54, name: "Tankrens premium" }, attributes)).toBe(false);
});
it("limits only-tankcleaning customers to tankcleaning products", () => {
expect(isProductRestrictedForCustomer({ category: 4, name: "Forvogn" }, onlyTankCleaning)).toBe(true);
expect(isProductRestrictedForCustomer({ category: 5, name: "Tank cleaning" }, onlyTankCleaning)).toBe(false);
expect(isProductRestrictedForCustomer({ category: 4, name: "Forvogn" }, [])).toBe(false);
it("keeps a deterministic primary rule and returns every matching rule", () => {
const attributes = [attribute("restrictInteriorCleaning", [63]), attribute("restrictAdditionalServices", [63])];
expect(getCustomerProductRestriction({ id: 63 }, attributes)).toMatchObject({
restricted: true,
rule: "restrictAdditionalServices",
rules: ["restrictAdditionalServices", "restrictInteriorCleaning"],
messageKey: "pos.restrictions.addons_not_allowed",
});
});
it("does not invert tankcleaning access for regular customers", () => {
expect(isProductRestrictedForCustomer({ category: 5, name: "Tank cleaning" }, [])).toBe(false);
expect(isProductRestrictedForCustomer({ category: 5, name: "Tank cleaning" }, restrictTankCleaning)).toBe(true);
it("returns the collections that contain a blocked product", () => {
const attributes = [
attribute(
"restrictSpotFree",
[],
[
{ id: 7, name: "Rinses", product_ids: [23, "24"] },
{ id: 8, name: "Legacy", products: [{ id: 23 }] },
]
),
];
const restriction = getCustomerProductRestriction({ product_id: "23" }, attributes);
expect(restriction.collections).toEqual([
{ id: 7, name: "Rinses", product_ids: [23, 24], attribute: "restrictSpotFree" },
{ id: 8, name: "Legacy", product_ids: [23], attribute: "restrictSpotFree" },
]);
});
it("recognizes spot-free products by canonical ids and legacy names", () => {
expect(isSpotFreeProduct({ id: 23, name: "RO rinse" })).toBe(true);
expect(isSpotFreeProduct({ product_id: 24, product_name: "Rinse product" })).toBe(true);
expect(isSpotFreeProduct({ id: 88, name: "Skylning med RO" })).toBe(true);
expect(isSpotFreeProduct({ id: 89, name: "Standard foam wash" })).toBe(false);
});
it("applies category restrictions consistently", () => {
expect(isProductCategoryRestrictedForCustomer(4, onlyTankCleaning)).toBe(true);
expect(isProductCategoryRestrictedForCustomer(5, onlyTankCleaning)).toBe(false);
expect(isProductCategoryRestrictedForCustomer("tank_cleaning", restrictTankCleaning)).toBe(true);
});
it("does not restrict related interior wash add-ons when additional services are restricted", () => {
const restriction = getCustomerProductRestriction(
{ id: 63, category: 4, name: "Indvendig vask Forvogn" },
restrictAdditionalServices,
{ isRelatedAddon: true }
);
expect(restriction).toEqual({
it("treats workflow rules, legacy string rows, and malformed ids as unrestricted", () => {
expect(getCustomerProductRestriction({ id: 23 }, ["restrictSpotFree"])).toEqual({
restricted: false,
rule: null,
rules: [],
collections: [],
messageKey: null,
});
expect(
isProductRestrictedForCustomer(
{ id: 63, category: 4, name: "Indvendig vask Forvogn" },
restrictAdditionalServices,
{ isRelatedAddon: true }
)
).toBe(false);
expect(isProductRestrictedForCustomer({ id: 23 }, [attribute("requiresReferenceNumber", [23])])).toBe(false);
expect(isProductRestrictedForCustomer({ id: "not-an-id" }, [attribute("restrictSpotFree", [23])])).toBe(false);
expect(isProductRestrictedForCustomer({ id: "23-trailing" }, [attribute("restrictSpotFree", [23])])).toBe(false);
});
it("does not treat booking add-on categories as standalone additional services", () => {
it("validates the structured exact-product contract for every active product-impact rule", () => {
expect(
isProductCategoryRestrictedForCustomer(4, restrictAdditionalServices, { includeNumericAddonCategory: true })
hasValidCustomerProductRestrictionContract([
attribute("restrictSpotFree", [23], [{ id: 7, name: "Rinses", product_ids: [23] }]),
{ attribute: "requiresReferenceNumber", product_restriction: null },
])
).toBe(true);
expect(isProductCategoryRestrictedForCustomer(4, restrictAdditionalServices)).toBe(false);
expect(isProductCategoryRestrictedForCustomer("addons", restrictAdditionalServices)).toBe(true);
});
it("blocks standalone additional-service items without hiding regular primary products by default", () => {
const product = { id: 91, category: 8, name: "Extra detergent" };
expect(isProductRestrictedForCustomer(product, restrictAdditionalServices)).toBe(false);
expect(hasValidCustomerProductRestrictionContract([{ attribute: "restrictSpotFree" }])).toBe(false);
expect(
isProductRestrictedForCustomer(product, restrictAdditionalServices, { isStandaloneAdditionalService: true })
).toBe(true);
});
it("keeps trailer, dolly, and interior wash add-ons available under additional-service restrictions", () => {
const relatedAddonOptions = { isRelatedAddon: true, relatedItemId: 10 };
expect(
isProductRestrictedForCustomer(
{ id: 62, category: 4, name: "Indvendig vask Forvogn" },
restrictAdditionalServices,
relatedAddonOptions
)
).toBe(false);
expect(
isProductRestrictedForCustomer(
{ id: 63, category: 4, name: "Dolly" },
restrictAdditionalServices,
relatedAddonOptions
)
).toBe(false);
expect(
isProductRestrictedForCustomer(
{ id: 92, category: 4, name: "Trailer" },
restrictAdditionalServices,
relatedAddonOptions
)
hasValidCustomerProductRestrictionContract([
{
attribute: "restrictSpotFree",
product_restriction: { version: 1, collections: [], disabled_product_ids: null },
},
])
).toBe(false);
});
it("describes exact products blocked by spot-free restrictions", () => {
const impact = getCustomerRuleProductImpact("restrictSpotFree", [
{ id: 23, category: 4, name: "Spot free rinse" },
{ id: 24, category: 4, name: "Skylning med RO" },
{ id: 88, category: 4, name: "Standard wash" },
]);
it("normalizes top-level and nested restriction response variants", () => {
expect(
normalizeCustomerAttributeProductRestriction({
disabled_product_ids: ["23", 23, 0],
collections: [{ id: 2, products: [{ product_id: 24 }] }],
})
).toEqual({
version: 0,
collections: [{ id: 2, name: "", product_ids: [24] }],
disabled_product_ids: [23, 24],
});
});
expect(impact.hasProductImpact).toBe(true);
const blockedNames = impact.blocked.primaryProducts.map((product) => product.name);
expect(blockedNames).toHaveLength(2);
expect(blockedNames).toEqual(expect.arrayContaining(["Spot free rinse", "Skylning med RO"]));
it("keeps category helpers as an exact-id compatibility seam", () => {
const attributes = [attribute("onlyTankCleaning", [10])];
expect(getProductCategoryRestrictionForCustomer(4, attributes).restricted).toBe(false);
expect(getProductCategoryRestrictionForCustomer(4, attributes, { productId: 10 }).restricted).toBe(true);
});
it("describes exact primary and addon impact from the configured set", () => {
const products = [
{
id: 10,
name: "Truck wash",
addons: [
{ id: 900, option_id: 63, name: "Interior", product: { id: 63, name: "Interior" } },
{ id: 901, option_id: 64, name: "Dolly", product: { id: 64, name: "Dolly" } },
],
},
{ id: 53, name: "Primary product" },
];
const impact = getCustomerRuleProductImpact(
"restrictInteriorCleaning",
products,
attribute("restrictInteriorCleaning", [53, 63])
);
expect(impact.blocked.primaryProducts.map((product) => product.id)).toEqual([53]);
expect(impact.blocked.relatedAddons.map((product) => product.id)).toEqual([63]);
expect(impact.available.primaryProducts).toEqual([]);
});
it("groups additional-service impact by related add-ons and standalone services", () => {
const impact = getCustomerRuleProductImpact("restrictAdditionalServices", [
{
id: 10,
category: 4,
name: "Truck wash",
addons: [
{
option_id: 62,
name: "Interior add-on",
product: { id: 62, category: 4, name: "Interior add-on" },
},
],
it("does not give workflow-only rules product impact", () => {
expect(
getCustomerRuleProductImpact(
"requiresReferenceNumber",
[{ id: 23, name: "Rinse" }],
attribute("requiresReferenceNumber", [23])
)
).toEqual({
hasProductImpact: false,
blocked: {
primaryProducts: [],
relatedAddons: [],
standaloneAdditionalServices: [],
},
{ id: 91, category: 8, name: "Extra detergent" },
]);
expect(impact.blocked.primaryProducts).toEqual([]);
expect(impact.blocked.relatedAddons).toEqual([]);
expect(impact.blocked.standaloneAdditionalServices.map((product) => product.name)).toEqual(["Extra detergent"]);
});
it("describes only-tank-cleaning restrictions and the products that stay available", () => {
const impact = getCustomerRuleProductImpact("onlyTankCleaning", [
{ id: 10, category: 4, name: "Truck wash" },
{ id: 20, category: 5, name: "Tank cleaning 4 spulehoveder" },
]);
expect(impact.blocked.primaryProducts.map((product) => product.name)).toEqual(["Truck wash"]);
expect(impact.available.primaryProducts.map((product) => product.name)).toEqual(["Tank cleaning 4 spulehoveder"]);
available: {
primaryProducts: [],
relatedAddons: [],
standaloneAdditionalServices: [],
},
});
});
});
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sessionUserMock = vi.hoisted(() => ({
permissions: { value: [] },
user: { group_id: { value: null } },
canAccessSuperUser: () => sessionUserMock.permissions.value.includes("superuser"),
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: sessionUserMock,
}));
import {
canManageCustomerRuleConfiguration,
canViewCustomerRuleConfiguration,
} from "@/features/customer/customerRuleConfigurationPermissions.js";
describe("customer rule configuration permissions", () => {
beforeEach(() => {
sessionUserMock.permissions.value = [];
sessionUserMock.user.group_id.value = null;
});
it("gives root superusers implicit view and manage access", () => {
sessionUserMock.permissions.value = ["superuser"];
sessionUserMock.user.group_id.value = 1;
expect(canViewCustomerRuleConfiguration()).toBe(true);
expect(canManageCustomerRuleConfiguration()).toBe(true);
});
it("keeps custom superuser view and manage permissions separate", () => {
sessionUserMock.permissions.value = ["superuser", "superuser_customer_rules_view"];
sessionUserMock.user.group_id.value = 2;
expect(canViewCustomerRuleConfiguration()).toBe(true);
expect(canManageCustomerRuleConfiguration()).toBe(false);
sessionUserMock.permissions.value.push("superuser_customer_rules_manage");
expect(canManageCustomerRuleConfiguration()).toBe(true);
});
it("denies custom superusers and non-superusers without an applicable node", () => {
sessionUserMock.permissions.value = ["superuser"];
sessionUserMock.user.group_id.value = 2;
expect(canViewCustomerRuleConfiguration()).toBe(false);
sessionUserMock.permissions.value = ["superuser_customer_rules_manage"];
expect(canViewCustomerRuleConfiguration()).toBe(false);
expect(canManageCustomerRuleConfiguration()).toBe(false);
});
});
@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("axios", () => ({
default: {
get: vi.fn(),
put: vi.fn(),
},
}));
import axios from "axios";
import {
extractCustomerRuleProductRestrictionPayload,
listCustomerRuleProductRestrictions,
normalizeCustomerRuleProductRestrictionResponse,
serializeCustomerRuleProductRestriction,
updateCustomerRuleProductRestriction,
} from "@/features/customer/customerRuleProductRestrictionService.js";
describe("customer rule product restriction service", () => {
beforeEach(() => {
window.localStorage.clear();
vi.clearAllMocks();
});
it("unwraps both API success wrapper variants", () => {
expect(extractCustomerRuleProductRestrictionPayload({ data: { data: { rules: [1] } } })).toEqual({ rules: [1] });
expect(extractCustomerRuleProductRestrictionPayload({ data: { rules: [2] } })).toEqual({ rules: [2] });
});
it("normalizes collections, exact product ids, and available products", () => {
const normalized = normalizeCustomerRuleProductRestrictionResponse({
data: {
data: {
rules: [
{
attribute: "restrictSpotFree",
version: "2",
collections: [{ id: 5, name: "Rinses", sort_order: 1, products: [{ id: "23" }] }],
},
],
products: [
{ product_id: "23", product_name: "RO rinse" },
{ id: 0, name: "Invalid" },
],
},
},
});
expect(normalized.rules[0]).toEqual({
attribute: "restrictSpotFree",
version: 2,
collections: [{ id: 5, name: "Rinses", sort_order: 1, product_ids: [23] }],
disabled_product_ids: [23],
});
expect(normalized.products).toEqual([{ product_id: "23", product_name: "RO rinse", id: 23, name: "RO rinse" }]);
});
it("serializes atomic replacement payloads without temporary ids", () => {
expect(
serializeCustomerRuleProductRestriction({
version: 4,
collections: [
{ id: -1, name: " New ", product_ids: ["23", 23] },
{ id: 7, name: "Existing", product_ids: [24] },
],
})
).toEqual({
version: 4,
collections: [
{ name: "New", sort_order: 0, product_ids: [23] },
{ id: 7, name: "Existing", sort_order: 1, product_ids: [24] },
],
});
});
it("uses authenticated GET and versioned atomic PUT endpoints", async () => {
window.localStorage.setItem("token", "test-token");
axios.get.mockResolvedValue({ data: {} });
axios.put.mockResolvedValue({ data: {} });
await listCustomerRuleProductRestrictions();
await updateCustomerRuleProductRestriction("restrictSpotFree", { version: 3, collections: [] });
expect(axios.get).toHaveBeenCalledWith(
expect.stringMatching(/\/superuser\/customer-rules\/product-restrictions$/),
{ headers: { Authorization: "Bearer test-token" } }
);
expect(axios.put).toHaveBeenCalledWith(
expect.stringMatching(/\/superuser\/customer-rules\/product-restrictions\/restrictSpotFree$/),
{ version: 3, collections: [] },
{ headers: { Authorization: "Bearer test-token" } }
);
});
});
+20 -14
View File
@@ -88,7 +88,7 @@ afterEach(() => {
});
describe("CustomerRuleTooltip", () => {
it("renders the rule change and exact standalone additional-service product groups", () => {
it("renders only the primary and addon products in the configured exact-id set", () => {
const wrapper = mountTooltip({
active: true,
attribute: "restrictAdditionalServices",
@@ -107,6 +107,11 @@ describe("CustomerRuleTooltip", () => {
},
{ id: 91, category: 8, name: "Extra detergent" },
],
restriction: {
product_restriction: {
disabled_product_ids: [62, 91],
},
},
});
const content = wrapper.get('[data-testid="rule-tooltip-content"]');
@@ -114,13 +119,11 @@ describe("CustomerRuleTooltip", () => {
expect(content.text()).toContain("Blocks additional services.");
expect(content.text()).toContain("Blocked while active");
expect(content.text()).not.toContain("Truck wash");
expect(wrapper.find('[data-testid="rule-tooltip-blocked-relatedAddons"]').exists()).toBe(false);
expect(wrapper.get('[data-testid="rule-tooltip-blocked-standaloneAdditionalServices"]').text()).toContain(
"Extra detergent"
);
expect(wrapper.get('[data-testid="rule-tooltip-blocked-relatedAddons"]').text()).toContain("Interior add-on");
expect(wrapper.get('[data-testid="rule-tooltip-blocked-primaryProducts"]').text()).toContain("Extra detergent");
});
it("renders both blocked and available products for only-tank-cleaning rules", () => {
it("renders the exact only-tank-cleaning blocked set without reconstructing allowed products", () => {
const wrapper = mountTooltip({
active: false,
attribute: "onlyTankCleaning",
@@ -128,20 +131,17 @@ describe("CustomerRuleTooltip", () => {
{ id: 10, category: 4, name: "Truck wash" },
{ id: 20, category: 5, name: "Tank cleaning 4 spulehoveder" },
],
restriction: {
disabled_product_ids: [10],
},
});
expect(wrapper.get('[data-testid="rule-tooltip-blocked-primaryProducts"]').text()).toContain("Truck wash");
expect(
wrapper.get('[data-testid="rule-tooltip-blocked-primaryProducts"] .customer-rule-tooltip__blocked-prefix').text()
).toBe("-");
expect(wrapper.get('[data-testid="rule-tooltip-available-primaryProducts"]').text()).toContain(
"Tank cleaning 4 spulehoveder"
);
expect(
wrapper
.find('[data-testid="rule-tooltip-available-primaryProducts"] .customer-rule-tooltip__blocked-prefix')
.exists()
).toBe(false);
expect(wrapper.find('[data-testid="rule-tooltip-available-primaryProducts"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain("Tank cleaning 4 spulehoveder");
});
it("lazy-loads catalog products on hover when no products are provided", async () => {
@@ -156,6 +156,9 @@ describe("CustomerRuleTooltip", () => {
customerNumber: 12345679,
departmentId: 12,
testId: "spot-free-tooltip",
restriction: {
disabled_product_ids: [23],
},
});
await wrapper.get('[data-testid="spot-free-tooltip"]').trigger("mouseenter");
@@ -188,6 +191,9 @@ describe("CustomerRuleTooltip", () => {
],
},
],
restriction: {
disabled_product_ids: [62],
},
});
const tooltip = wrapper.get(".b-tooltip-stub");
+30
View File
@@ -419,6 +419,36 @@ describe("DatePeriodSelector month warning", () => {
expect(wrapper.get("[data-testid='date-period-other-month-picker']").attributes("data-inline")).toBe("true");
});
it("applies an arbitrary month from the Other month picker", async () => {
sharedState.width.value = 480;
const onSelectionChange = vi.fn();
const wrapper = mountWithApp(DatePeriodSelector, {
messages,
props: {
selection: {
startDate: null,
endDate: null,
},
onSelectionChange,
allowEmptySelection: true,
visibility: {
showUpdateButton: false,
},
},
global: {
stubs: componentStubs,
},
});
await wrapper.get("[data-testid='date-period-other-dropdown-trigger']").trigger("click");
await wrapper.get("[data-testid='date-period-other-other_month']").trigger("click");
await wrapper.get("[data-testid='date-period-other-month-picker']").setValue("2026-02");
expect(onSelectionChange).toHaveBeenCalledTimes(1);
expect(formatLocalDate(onSelectionChange.mock.calls[0][0])).toBe("2026-02-01");
expect(formatLocalDate(onSelectionChange.mock.calls[0][1])).toBe("2026-02-28");
});
it("selects Anytime for empty ranges without highlighting Today", () => {
const wrapper = mountWithApp(DatePeriodSelector, {
messages,
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key, params = {}) =>
key === "invoice_period.flags.badge.summary" ? `${params.red} red and ${params.yellow} yellow` : key,
locale: { value: "en" },
}),
}));
vi.mock("sweetalert2", () => ({
default: { fire: vi.fn(async () => ({ isConfirmed: false })) },
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: vi.fn(),
functions: { redirectTo: { department: vi.fn(), superUser: vi.fn() } },
},
}));
vi.mock("@/components/displays/PopperDefault.vue", () => ({
popperBox: vi.fn(),
removePopperIfOpen: vi.fn(),
showPopper: vi.fn(),
}));
import InvoicingPeriodFlagBadge from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagBadge.vue";
describe("InvoicingPeriodFlagBadge with Buefy", () => {
it("opens the dropdown when the badge trigger button is clicked", async () => {
globalThis.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
const wrapper = mount(InvoicingPeriodFlagBadge, {
attachTo: document.body,
props: {
flags: [{ id: 1, source: "manual", status: "active", reason: "Review" }],
},
});
expect(wrapper.get(".dropdown").classes()).not.toContain("is-active");
await wrapper.get(".invoice-period-flag-badge__trigger").trigger("click");
await new Promise((resolve) => window.setTimeout(resolve, 0));
await wrapper.vm.$nextTick();
expect(wrapper.get(".dropdown").classes()).toContain("is-active");
expect(document.body.textContent).toContain("Review");
wrapper.unmount();
});
});
@@ -0,0 +1,101 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
vi.mock("buefy", () => ({
BDropdown: {
name: "BDropdown",
props: {
triggers: { type: Array, default: () => ["click"] },
},
data: () => ({ active: false }),
methods: {
toggle() {
this.active = !this.active;
},
},
template: `
<div class="b-dropdown-stub" :class="{ 'is-active': active }">
<div class="b-dropdown-trigger-stub" @click="triggers.includes('click') && toggle()"><slot name="trigger" /></div>
<div v-if="active" class="b-dropdown-menu-stub"><slot /></div>
</div>
`,
},
BTag: {
name: "BTag",
template: `<span class="b-tag-stub"><slot /></span>`,
},
BTooltip: {
name: "BTooltip",
props: ["label"],
template: `<span class="b-tooltip-stub" :data-label="label"><slot name="content" /><slot /></span>`,
},
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key, params = {}) =>
({
"invoice_period.flags.badge.manual": "Manual flags",
"invoice_period.flags.badge.automatic": "Automatic warnings",
"invoice_period.flags.badge.summary": `${params.red} red and ${params.yellow} yellow`,
}[key] || key),
locale: { value: "en" },
}),
}));
vi.mock("sweetalert2", () => ({
default: { fire: vi.fn(async () => ({ isConfirmed: false })) },
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: vi.fn(),
functions: {
redirectTo: { department: vi.fn(), superUser: vi.fn() },
},
},
}));
vi.mock("@/components/displays/PopperDefault.vue", () => ({
popperBox: vi.fn(),
removePopperIfOpen: vi.fn(),
showPopper: vi.fn(),
}));
import InvoicingPeriodFlagBadge from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagBadge.vue";
import InvoicingPeriodFlagList from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodFlagList.vue";
describe("InvoicingPeriodFlagBadge", () => {
it("overlaps red and yellow counters and groups active flags red-first", async () => {
const manual = { id: 1, source: "manual", status: "active", reason: "Review" };
const automatic = { id: 2, source: "automatic", status: "active", message: "Warning" };
const wrapper = mount(InvoicingPeriodFlagBadge, {
props: {
flags: [automatic, { id: 3, source: "manual", status: "resolved" }, manual],
},
});
expect(wrapper.get(".invoice-period-flag-badge__tag--manual").text()).toContain("1");
expect(wrapper.get(".invoice-period-flag-badge__tag--automatic").text()).toContain("1");
expect(wrapper.get(".b-tooltip-stub").attributes("data-label")).toBe("1 red and 1 yellow");
await wrapper.get(".invoice-period-flag-badge__trigger").trigger("click");
expect(wrapper.get(".b-dropdown-stub").classes()).toContain("is-active");
expect(wrapper.findAll(".invoice-period-flag-badge__heading").map((heading) => heading.text())).toEqual([
"Manual flags",
"Automatic warnings",
]);
const lists = wrapper.findAllComponents(InvoicingPeriodFlagList);
expect(lists).toHaveLength(2);
expect(lists[0].props("flags")).toEqual([manual]);
expect(lists[1].props("flags")).toEqual([automatic]);
lists[0].vm.$emit("statusChanged", { ...manual, status: "resolved" });
await wrapper.vm.$nextTick();
expect(wrapper.find(".invoice-period-flag-badge__tag--manual").exists()).toBe(false);
expect(wrapper.emitted("statusChanged")?.[0]?.[0]).toMatchObject({ id: 1, status: "resolved" });
});
});
+234 -12
View File
@@ -15,8 +15,20 @@ const mocks = vi.hoisted(() => {
po: "",
reg_1: "AB12345",
};
const orderItems = [
{
id: 501,
order_id: 9001,
product_name: "Premium wash",
quantity: 2,
price: 120,
reference: "",
notes: "",
},
];
return {
order,
orderItems,
economicDetails: vi.fn(async () => ({
data: {
data: {
@@ -55,17 +67,7 @@ const mocks = vi.hoisted(() => {
if (url === "/order/items" && method === "GET") {
return {
data: {
data: [
{
id: 501,
order_id: 9001,
product_name: "Premium wash",
quantity: 2,
price: 120,
reference: "",
notes: "",
},
],
data: orderItems,
},
};
}
@@ -166,6 +168,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
functions: {
fetchAttachments: vi.fn(async () => []),
fetchAttachmentContent: vi.fn(async () => new Blob(["pdf"], { type: "application/pdf" })),
downloadAttachment: vi.fn(async () => "https://example.test/attachment.pdf"),
removeAttachment: vi.fn(),
resendWashCertificate: vi.fn(),
@@ -242,8 +245,9 @@ vi.mock("@/components/displays/department/pos/orders/invoiceCollectionBulkAction
}));
import InvoicingPeriodObjectTree from "@/views/dashboards/superUserDashboard/InvoicingBillingPeriod/components/InvoicingPeriodObjectTree.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
const mountTree = () =>
const mountTree = (overrides = {}) =>
mount(InvoicingPeriodObjectTree, {
props: {
customer: {
@@ -266,6 +270,16 @@ const mountTree = () =>
dateTo: "2026-06-30",
},
invoicePeriodFlags: [],
...overrides,
},
global: {
stubs: {
InvoicingPeriodFlagBadge: {
name: "InvoicingPeriodFlagBadge",
props: ["flags"],
template: `<span class="invoice-period-flag-badge-stub" :data-flag-count="flags.length"></span>`,
},
},
},
});
@@ -279,10 +293,30 @@ const expandNode = async (wrapper, key) => {
describe("InvoicingPeriodObjectTree", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.orderItems.splice(0, mocks.orderItems.length, {
id: 501,
order_id: 9001,
product_name: "Premium wash",
quantity: 2,
price: 120,
reference: "",
notes: "",
});
delete mocks.order.reg_2;
delete mocks.order.reg_3;
delete mocks.order.attachments;
Object.defineProperty(window, "open", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => "blob:attachment-preview"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(),
});
});
it("auto-expands order items when an order node is expanded", async () => {
@@ -332,10 +366,89 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.find('[data-testid="invoice-period-tree-field-order:9001-notes"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-field-value-order:9001-notes"]').text()).toContain("Tom");
expect(wrapper.find('[data-testid="invoice-period-tree-field-order:9001-registrations"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-field-order:9001-reg_2"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="invoice-period-tree-field-order:9001-reg_3"]').exists()).toBe(false);
expect(wrapper.find('[data-testid^="invoice-period-tree-field-empty-toggle-"]').exists()).toBe(false);
});
it("uses wash labels, nests related order items, and omits product ids and type tags", async () => {
mocks.orderItems.push({
id: 502,
order_id: 9001,
related_item_id: 501,
product_id: 702,
product_name: "Interior wash with a complete descriptive product label",
quantity: 1,
price: 45,
});
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
await expandNode(wrapper, "order_item:501");
expect(wrapper.get('[data-node-key="order:9001"]').text()).toContain("Vask #9001");
expect(wrapper.find('[data-node-key="order_item:501"] [data-node-key="order_item:502"]').exists()).toBe(true);
expect(wrapper.text()).toContain("Interior wash with a complete descriptive product label");
expect(wrapper.find('[data-testid$="-product_id"]').exists()).toBe(false);
expect(wrapper.find(".tag.is-white").exists()).toBe(false);
});
it("places entity and field flags on their exact labels", async () => {
const wrapper = mountTree({
invoicePeriodFlags: [
{ id: 1, source: "manual", target_type: "order", target_id: 9001, status: "active" },
{
id: 2,
source: "automatic",
target_type: "order_item_field",
target_id: 501,
field: "price",
status: "active",
},
{
id: 3,
source: "automatic",
target_type: "order_field",
target_id: 9006,
field: "reference",
invoice_collection_id: 3001,
order_id: 9006,
status: "active",
},
],
});
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
expect(
wrapper
.get('[data-node-key="order:9001"] .invoice-period-tree-node__identity .invoice-period-flag-badge-stub')
.attributes("data-flag-count")
).toBe("1");
expect(
wrapper
.get(
'[data-node-key="collected_order_invoice:3001"] .invoice-period-tree-node__identity .invoice-period-flag-badge-stub'
)
.attributes("data-flag-count")
).toBe("1");
expect(
wrapper
.get('[data-testid="invoice-period-tree-field-order_item:501-price"] .invoice-period-flag-badge-stub')
.attributes("data-flag-count")
).toBe("1");
expect(
wrapper
.get('[data-testid="invoice-period-tree-field-order_item:501-quantity"] .invoice-period-flag-badge-stub')
.attributes("data-flag-count")
).toBe("0");
});
it("omits redundant collection name and customer number fields", async () => {
const wrapper = mountTree();
@@ -350,6 +463,39 @@ describe("InvoicingPeriodObjectTree", () => {
).toBe(false);
});
it("renders collection fields from the period collection summary", () => {
const wrapper = mountTree({
customer: {
customer_number: 2001,
customer_name: "ACME",
invoice_collections: [
{
id: 3001,
state: "closed",
notes: "Summary note",
po_number: "PO-SUMMARY",
external_id: "EXT-3001",
closed_at: "2026-06-30 12:00:00",
},
],
draft: { has_valid_draft: false, invoice_collection_ids: [] },
},
});
expect(
wrapper.get('[data-testid="invoice-period-tree-field-value-collected_order_invoice:3001-notes"]').text()
).toContain("Summary note");
expect(
wrapper.get('[data-testid="invoice-period-tree-field-value-collected_order_invoice:3001-po_number"]').text()
).toContain("PO-SUMMARY");
expect(
wrapper.get('[data-testid="invoice-period-tree-field-value-collected_order_invoice:3001-external_id"]').text()
).toContain("EXT-3001");
expect(
wrapper.get('[data-testid="invoice-period-tree-field-value-collected_order_invoice:3001-closed_at"]').text()
).toContain("2026-06-30");
});
it("renders only resolved draft/booked e-conomic invoice children", async () => {
const wrapper = mountTree();
@@ -361,6 +507,59 @@ describe("InvoicingPeriodObjectTree", () => {
expect(wrapper.text()).toContain("E-conomic faktura #99");
});
it("keeps a persisted booked invoice downloadable when normalized booked details are unavailable", async () => {
mocks.economicDetails.mockResolvedValueOnce({
data: {
data: {
economic: { state: "booked", available_pdf_type: "booked", draft_id: 88, booked_id: 99 },
draft: { exists: false, normalized: null },
booked: { exists: false, normalized: null },
warnings: ["Invoice draft was not found"],
},
},
});
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_economic");
expect(wrapper.find('[data-node-key="economic_invoice:3001:booked:99"]').exists()).toBe(true);
expect(wrapper.text()).not.toContain("E-conomic kladde #88");
});
it("colors collection, invoice, order, and item icons from the booked state", async () => {
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
expect(
wrapper
.get('[data-node-key="collected_order_invoice:3001"] > .b-tree-node-content .b-tree-node-icon span')
.classes()
).toContain("has-text-success");
expect(
wrapper
.get('[data-node-key="category:3001:collection_orders"] > .b-tree-node-content .b-tree-node-icon span')
.classes()
).toContain("has-text-success");
expect(
wrapper
.get('[data-node-key="category:3001:collection_economic"] > .b-tree-node-content .b-tree-node-icon span')
.classes()
).toContain("has-text-success");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
expect(
wrapper.get('[data-node-key="order:9001"] > .b-tree-node-content .b-tree-node-icon span').classes()
).toContain("has-text-success");
expect(
wrapper.get('[data-node-key="category:9001:order_items"] > .b-tree-node-content .b-tree-node-icon span').classes()
).toContain("has-text-success");
expect(
wrapper.get('[data-node-key="order_item:501"] > .b-tree-node-content .b-tree-node-icon span').classes()
).toContain("has-text-success");
});
it("downloads booked e-conomic invoice pdfs for booked invoice nodes", async () => {
const wrapper = mountTree();
@@ -375,4 +574,27 @@ describe("InvoicingPeriodObjectTree", () => {
expect(mocks.economicPdf).toHaveBeenCalledWith(3001, "booked");
expect(window.open).toHaveBeenCalledWith("https://example.test/invoice.pdf", "_blank", "noopener,noreferrer");
});
it("fetches attachment hover previews as authenticated inline blobs", async () => {
mocks.order.attachments = [{ id: 601, order_id: 9001, content: { other: "wash_certificate" } }];
const blob = new Blob(["pdf"], { type: "application/pdf" });
SessionUser.objects.orders.functions.fetchAttachmentContent.mockResolvedValueOnce(blob);
const wrapper = mountTree();
await expandNode(wrapper, "collected_order_invoice:3001");
await expandNode(wrapper, "category:3001:collection_orders");
await expandNode(wrapper, "order:9001");
await expandNode(wrapper, "category:9001:order_attachments_certificates");
await wrapper.get('[data-testid="invoice-period-tree-node-attachment:601:9001"]').trigger("mouseenter");
await flushPromises();
expect(SessionUser.objects.orders.functions.fetchAttachmentContent).toHaveBeenCalledWith(9001, 601, "inline");
expect(URL.createObjectURL).toHaveBeenCalledWith(blob);
expect(
wrapper.get('[data-testid="invoice-period-tree-preview-attachment:601:9001"] iframe').attributes("src")
).toBe("blob:attachment-preview");
wrapper.unmount();
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:attachment-preview");
});
});
@@ -14,6 +14,7 @@ const {
availableViewNamesRef,
currentViewRef,
sharedVariablesRef,
hasDateSelectionRef,
} = vi.hoisted(() => {
const { reactive, ref } = require("vue");
@@ -24,6 +25,8 @@ const {
routeState: reactive({
query: {
activeTab: "period",
startDate: "2026-04-01",
endDate: "2026-04-30",
},
}),
periodRefreshSignalRef: ref({
@@ -36,6 +39,7 @@ const {
availableViewNamesRef: ref({}),
currentViewRef: ref("all"),
sharedVariablesRef: ref({}),
hasDateSelectionRef: ref(true),
};
});
@@ -78,6 +82,17 @@ vi.mock(
variables: {
start: startDateRef,
end: endDateRef,
hasSelection: hasDateSelectionRef,
},
functions: {
setSelection: (start, end) => {
startDateRef.value = start;
endDateRef.value = end;
hasDateSelectionRef.value = true;
},
clearSelection: () => {
hasDateSelectionRef.value = false;
},
},
computed: {
isEntireMonth: computed(() => true),
@@ -187,6 +202,8 @@ describe("Invoicing period queue-driven refresh", () => {
requestMock.mockReset();
mockPeriodResponses(emptyPeriodResponse());
routeState.query.activeTab = "period";
routeState.query.startDate = "2026-04-01";
routeState.query.endDate = "2026-04-30";
delete routeState.query.periodView;
delete routeState.query.periodSearch;
delete routeState.query.periodPage;
@@ -202,6 +219,7 @@ describe("Invoicing period queue-driven refresh", () => {
sharedVariablesRef.value = {};
availableViewNamesRef.value = {};
currentViewRef.value = "all";
hasDateSelectionRef.value = true;
});
afterEach(() => {
@@ -211,6 +229,20 @@ describe("Invoicing period queue-driven refresh", () => {
window.localStorage.clear();
});
it("does not load period data until a date range has been selected", async () => {
delete routeState.query.startDate;
delete routeState.query.endDate;
hasDateSelectionRef.value = false;
const wrapper = mountRight();
await flushAll();
expect(periodCalls()).toHaveLength(0);
expect(selfWashCalls()).toHaveLength(0);
expect(currentViewRef.value).toBe("home");
expect(wrapper.get('[data-testid="invoicing-period-view-selector-all"]').attributes("aria-disabled")).toBe("true");
});
it("refreshes the current paginated page when queue activity reaches a terminal state", async () => {
mockPeriodResponses(
{
@@ -4,6 +4,7 @@ import {
TREE_NODE_TYPES,
buildRelativeCollectionLabel,
buildCollectionRootNodes,
buildOrderItemTree,
classifyAttachment,
getTreeAmount,
hasWashCertificateOrderItem,
@@ -204,6 +205,34 @@ describe("invoicing period tree node builders", () => {
expect(inferredNode).toMatchObject({ type: TREE_NODE_TYPES.XLVASK_INFERRED_ITEM, actionable: false });
});
it("builds related order items as a stable tree and leaves malformed relationships visible", () => {
const nodes = buildOrderItemTree(
[
{ id: 501, order_id: 91, product_name: "Wash" },
{ id: 502, order_id: 91, related_item_id: 501, product_name: "Interior" },
{ id: 503, order_id: 91, related_item_id: 502, product_name: "Mat cleaning" },
{ id: 504, order_id: 91, related_item_id: 999, product_name: "Orphan" },
{ id: 505, order_id: 91, related_item_id: 506, product_name: "Cycle A" },
{ id: 506, order_id: 91, related_item_id: 505, product_name: "Cycle B" },
{ id: 507, order_id: 92, related_item_id: 501, product_name: "Cross-order" },
],
{ invoiceState: "closed" }
);
expect(nodes.map((node) => node.meta.itemId)).toEqual([501, 504, 505, 506, 507]);
expect(nodes[0]).toMatchObject({
isLeaf: false,
meta: { invoiceState: "closed" },
children: [
{
meta: { itemId: 502, relatedItemId: 501 },
children: [{ meta: { itemId: 503, relatedItemId: 502 } }],
},
],
});
expect(nodes.slice(1).every((node) => node.isLeaf)).toBe(true);
});
it("creates economic invoice nodes with draft/booked metadata", () => {
const node = makeEconomicInvoiceNode({
collectionId: 3001,
+19 -1
View File
@@ -7,6 +7,7 @@ const sessionUserRequestMock = vi.hoisted(() =>
},
}))
);
const authenticatedRequestMock = vi.hoisted(() => vi.fn());
vi.mock("sweetalert2", () => ({
default: {
@@ -49,7 +50,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
}));
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: vi.fn(),
authenticatedRequest: authenticatedRequestMock,
}));
import { Orders } from "@/components/session/token/SessionUser/Objects/Orders.vue";
@@ -57,6 +58,7 @@ import { Orders } from "@/components/session/token/SessionUser/Objects/Orders.vu
describe("Orders attachments requests", () => {
beforeEach(() => {
sessionUserRequestMock.mockClear();
authenticatedRequestMock.mockReset();
});
it("limits order attachment list GET requests without assigning a queue group", async () => {
@@ -67,4 +69,20 @@ describe("Orders attachments requests", () => {
concurrencyLimit: 5,
});
});
it("fetches authenticated attachment content as a blob", async () => {
const blob = new Blob(["pdf"], { type: "application/pdf" });
authenticatedRequestMock.mockResolvedValue({ data: blob });
await expect(Orders.functions.fetchAttachmentContent(42, 301, "inline")).resolves.toBe(blob);
expect(authenticatedRequestMock).toHaveBeenCalledWith(
"/orders/attachments/content",
"GET",
{ order_id: 42, attachment_id: 301, disposition: "inline" },
null,
null,
{ concurrencyLimit: 5, responseType: "blob" }
);
});
});
+26
View File
@@ -15,6 +15,23 @@ describe("Playwright PR mapping", () => {
);
});
it("maps shared order filter changes to admin and superuser coverage", () => {
for (const sourceFile of [
"src/components/displays/pagination/models/DepartmentPos/OrdersPagination.vue",
"src/components/displays/pagination/models/SuperUserDashboard/InvoiceOrdersPagination.vue",
"src/components/displays/buttons/DatePeriodSelector.vue",
"src/services/orderDateEvents.js",
"src/services/relativeDateShortcuts.js",
"src/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosOrders.vue",
"src/views/dashboards/departmentDashboard/modules/Pos/DepartmentPosDrafts.vue",
]) {
const specs = specsFor(sourceFile);
expect(specs).toContain("tests/e2e/admin-pos-order-filters.spec.ts");
expect(specs).toContain("tests/e2e/superuser-orders-date-filters.spec.ts");
}
});
it("maps superuser department pricing changes to custom-only pricing coverage", () => {
expect(specsFor("src/views/dashboards/superUserDashboard/department/DepartmentPricing.vue")).toContain(
"tests/e2e/superuser-department-pricing-custom-only.spec.ts"
@@ -163,6 +180,15 @@ describe("Playwright PR mapping", () => {
expect(specsFor("src/components/shop/POSDepartmentProcess.vue")).toContain("tests/e2e/admin-pos-orders.spec.ts");
});
it("maps global customer rule configuration changes to its editor coverage", () => {
expect(specsFor("src/views/dashboards/superUserDashboard/CustomerRuleProductRestrictions.vue")).toContain(
"tests/e2e/superuser-customer-rules.spec.ts"
);
expect(specsFor("src/features/customer/customerRuleProductRestrictionService.js")).toContain(
"tests/e2e/superuser-customer-rules.spec.ts"
);
});
it("maps limited backoffice changes to the limited backoffice E2E coverage", () => {
expect(specsFor("src/views/backoffice/LimitedBackofficeEmployees.vue")).toContain(
"tests/e2e/limited-backoffice.spec.ts"
+97
View File
@@ -148,11 +148,14 @@ import {
createOrder,
completed_at,
customer_data,
customer_attributes,
customer_attributes_status,
customer_id,
customer_name,
department_id,
ensureVehiclePlateBookingsLoaded,
getVehiclePlateBookings,
getProductRestriction,
invoiceCollectionId,
loadOrderItems,
nextStep,
@@ -518,6 +521,100 @@ describe("POSDepartmentProcess.searchAndSelectCustomer", () => {
expect(refreshedCustomer.name).toBe("Pleno Logistics Updated");
expect(customer_name.value).toBe("Pleno Logistics Updated");
});
it("fails closed while customer restrictions load and resolves exact product IDs when ready", async () => {
let resolveAttributes;
getAttributes.mockReturnValueOnce(
new Promise((resolve) => {
resolveAttributes = resolve;
})
);
authenticatedRequest.mockResolvedValueOnce(mockCustomerResponse());
await searchAndSelectCustomer(12345679);
expect(customer_attributes_status.value).toBe("loading");
expect(getProductRestriction({ id: 53 })).toMatchObject({
restricted: true,
messageKey: "pos.restrictions.loading",
});
resolveAttributes({
data: {
success: true,
data: [
{
id: 1,
attribute: "onlyTankCleaning",
product_restriction: {
version: 1,
disabled_product_ids: [53],
collections: [],
},
},
],
},
});
await vi.waitFor(() => expect(customer_attributes_status.value).toBe("ready"));
expect(getProductRestriction({ id: "53" }).restricted).toBe(true);
expect(getProductRestriction({ id: 63 }).restricted).toBe(false);
});
it("fails closed when an active product-impact rule lacks its structured restriction payload", async () => {
getAttributes.mockResolvedValueOnce({
data: {
success: true,
data: [{ id: 1, attribute: "restrictSpotFree" }],
},
});
authenticatedRequest.mockResolvedValueOnce(mockCustomerResponse());
await searchAndSelectCustomer(12345679);
await vi.waitFor(() => expect(customer_attributes_status.value).toBe("error"));
expect(customer_attributes.value).toEqual([]);
expect(getProductRestriction({ id: 23 })).toMatchObject({
restricted: true,
messageKey: "pos.restrictions.load_failed",
});
});
it("ignores a late customer-attribute response after the selected customer changes", async () => {
let resolveFirstAttributes;
getAttributes
.mockReturnValueOnce(
new Promise((resolve) => {
resolveFirstAttributes = resolve;
})
)
.mockResolvedValueOnce({ data: { success: true, data: [] } });
authenticatedRequest
.mockResolvedValueOnce(mockCustomerResponse(12345679, "First customer"))
.mockResolvedValueOnce(mockCustomerResponse(12345680, "Second customer"));
await searchAndSelectCustomer(12345679);
await searchAndSelectCustomer(12345680);
await vi.waitFor(() => expect(customer_attributes_status.value).toBe("ready"));
resolveFirstAttributes({
data: {
success: true,
data: [
{
id: 2,
attribute: "onlyTankCleaning",
product_restriction: { version: 1, disabled_product_ids: [53], collections: [] },
},
],
},
});
await Promise.resolve();
expect(customer_id.value).toBe(12345680);
expect(customer_attributes.value).toEqual([]);
expect(getProductRestriction({ id: 53 }).restricted).toBe(false);
});
});
describe("POSDepartmentProcess.hydrateSelectedOrderBookingForDesktop", () => {
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {
global: {
language: {
text: {
anytime: "Anytime",
today: "Today",
yesterday: "Yesterday",
last_7_days: "Last 7 days",
this_week: "This week",
last_week: "Last week",
this_month: "This month",
last_month: "Last month",
same_week_last_year: "Same week last year",
same_month_last_year: "Same month last year",
},
},
},
},
},
}));
import { formatLocalDateOnly } from "@/services/dateOnly.js";
import { buildRelativeDateShortcuts } from "@/services/relativeDateShortcuts.js";
const formatRange = (range) => ({
startDate: range.startDate ? formatLocalDateOnly(range.startDate) : null,
endDate: range.endDate ? formatLocalDateOnly(range.endDate) : null,
});
describe("relative date shortcut ranges", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 6, 7, 10, 0, 0, 0));
});
afterEach(() => {
vi.useRealTimers();
});
it("produces the exact local date range for every orders shortcut", () => {
const ranges = Object.fromEntries(
buildRelativeDateShortcuts().map((shortcut) => [shortcut.key, formatRange(shortcut.getRange())])
);
expect(ranges).toEqual({
anytime: { startDate: null, endDate: null },
today: { startDate: "2026-07-07", endDate: "2026-07-07" },
yesterday: { startDate: "2026-07-06", endDate: "2026-07-06" },
last_seven_days: { startDate: "2026-07-01", endDate: "2026-07-07" },
this_week: { startDate: "2026-07-06", endDate: "2026-07-12" },
last_week: { startDate: "2026-06-29", endDate: "2026-07-05" },
this_month: { startDate: "2026-07-01", endDate: "2026-07-31" },
last_month: { startDate: "2026-06-01", endDate: "2026-06-30" },
same_week_last_year: { startDate: "2025-07-06", endDate: "2025-07-12" },
same_month_last_year: { startDate: "2025-07-01", endDate: "2025-07-31" },
});
});
});
+12 -6
View File
@@ -410,7 +410,8 @@ describe("Periode tab contract", () => {
});
it("keeps top selector and left dynamic view renderer", () => {
expect(periodTopSource).toContain("<InvoicingBillingPeriodDatePeriodSelector/>");
expect(periodTopSource).toContain("<InvoicingBillingPeriodDatePeriodSelector");
expect(periodTopSource).toContain("dates.variables.hasSelection.value");
expect(periodLeftSource).toContain('<Component v-bind:is="view.computed.currentViewComponent.value" />');
});
@@ -607,10 +608,8 @@ describe("Periode tab contract", () => {
expect(periodViewAllSource).toContain("const isOrderItemLineFlag =");
expect(periodViewAllSource).toContain("const getCustomerCardFlags = (customer: any) =>");
expect(periodViewAllSource).toContain("!isOrderLineFlag(flag) && !isOrderItemLineFlag(flag)");
expect(periodViewAllSource).toContain("const getCustomerExpandedFlags = (customer: any) =>");
expect(periodViewAllSource).toContain("isOrderLineFlag(flag) || isOrderItemLineFlag(flag)");
expect(periodViewAllSource).toContain(':flags="getCustomerCardFlags(customer)"');
expect(periodViewAllSource).toContain(':flags="getCustomerExpandedFlags(customer)"');
expect(periodViewAllSource).not.toContain("getCustomerExpandedFlags");
expect(periodViewAllSource).toContain(':invoice-period-flags="getCustomerScopedFlags(customer)"');
expect(periodViewAllSource).toContain('targetType === "customer"');
expect(periodViewAllSource).toContain('targetType === "collected_order_invoice"');
@@ -647,19 +646,26 @@ describe("Periode tab contract", () => {
expect(buefyTreeSource).toContain("node?.selectable !== false");
expect(buefyTreeSource).toContain("node?.checkable === true");
expect(buefyTreeSource).toContain("ensureChildrenLoadedForCheck");
expect(buefyTreeSource).toContain("progressiveBatchSize");
expect(buefyTreeSource).toContain("visibleChildrenOf");
expect(buefyTreeSource).toContain("remainingChildrenCount");
expect(buefyTreeNodeSource).toContain('<slot name="icon"');
expect(buefyTreeNodeSource).toContain(':retry="() => tree.retryLoad(node)"');
expect(buefyTreeNodeSource).toContain("b-tree-load-more__button");
expect(periodObjectTreeSource).toContain('selection-mode="checkbox"');
expect(periodObjectTreeSource).toContain(':lazy="true"');
expect(periodObjectTreeSource).toContain(':progressive-batch-size="50"');
expect(periodObjectTreeSource).toContain("@load-error=");
expect(periodObjectTreeSource).toContain("getNodeFlagCount");
expect(periodObjectTreeSource).toContain("InvoicingPeriodFlagBadge");
expect(periodObjectTreeSource).toContain("getNodeFlags");
expect(periodObjectTreeSource).toContain("getFieldFlags");
expect(periodObjectTreeSource).toContain("getCachedOrderItemRows");
expect(periodObjectTreeSource).toContain("getCachedAttachmentRows");
expect(periodObjectTreeSource).toContain("shouldShowWashCertificateCategory");
expect(periodObjectTreeSource).not.toContain("order?.attachments === undefined");
expect(periodTreeNodeServiceSource).toContain("getTreeAmount");
expect(periodTreeNodeServiceSource).toContain("hasWashCertificateOrderItem");
expect(periodObjectTreeSource).toContain("fa-flag");
expect(periodObjectTreeSource).toContain("fetchAttachmentContent");
expect(periodObjectTreeSource).toContain('data-testid="invoice-period-tree-toolbar"');
expect(periodObjectTreeSource).toContain("`invoice-period-tree-actions-dropdown-${group.type}`");
});