Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
993beab80d | ||
|
|
18302723e8 | ||
|
|
20c383d22d | ||
|
|
77a0c4e018 | ||
|
|
9a5c8b193d | ||
|
|
0562ba8ab0 | ||
|
|
906e860c86 | ||
|
|
516e1fb58d | ||
|
|
7da1307cf6 | ||
|
|
ea4893d815 | ||
|
|
f2e0079b59 | ||
|
|
47d8baa496 | ||
|
|
6d96d64811 | ||
|
|
92fb998e5a |
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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() &&
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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> </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"
|
||||
|
||||
+21
-12
@@ -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="
|
||||
|
||||
+6
-6
@@ -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>
|
||||
|
||||
+116
-29
@@ -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> </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> </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> </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> </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> </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> </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 {
|
||||
|
||||
+25
-24
@@ -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"
|
||||
|
||||
+2
-1
@@ -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>
|
||||
|
||||
+36
-7
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'}"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
+186
@@ -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>
|
||||
+398
-79
@@ -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;
|
||||
}
|
||||
|
||||
+85
-18
@@ -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%;
|
||||
|
||||
+3
-2
@@ -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>
|
||||
|
||||
+22
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+78
-1
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
-7
@@ -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)"
|
||||
|
||||
+28
-5
@@ -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
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" } }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user