Compare commits

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